/* ===================================================================== CowAgent Console - Main Application Script ===================================================================== */ // ===================================================================== // Version — fetched from backend (single source: /VERSION file) // ===================================================================== let APP_VERSION = ''; // ===================================================================== // i18n // ===================================================================== const I18N = { zh: { console: '控制台', nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控', menu_chat: '对话', menu_config: '配置', menu_skills: '技能', menu_memory: '记忆', menu_knowledge: '知识', menu_channels: '通道', menu_tasks: '定时', menu_logs: '日志', knowledge_title: '知识库', knowledge_desc: '浏览和探索你的知识库', knowledge_tab_docs: '文档', knowledge_tab_graph: '图谱', knowledge_loading: '加载知识库中...', knowledge_loading_desc: '知识页面将显示在这里', knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面', knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。', knowledge_go_chat: '开始对话', welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过
长期记忆和知识库不断成长', example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件', example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器', example_code_title: '编程助手', example_code_text: '搜索AI资讯并生成可视化网页报告', example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况', example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能', example_web_title: '指令中心', example_web_text: '查看全部命令', input_placeholder: '输入消息,或输入 / 使用指令', config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', config_channel: '通道配置', config_agent_enabled: 'Agent 模式', config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理', config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理', config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', config_custom_model_hint: '输入自定义模型名称', config_save: '保存', config_saved: '已保存', config_save_error: '保存失败', config_custom_option: '自定义...', config_security: '安全设置', config_password: '访问密码', config_password_hint: '留空则不启用密码保护', config_password_changed: '密码已更新,请重新登录', config_password_cleared: '密码已清除', skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', skills_hub_btn: '探索技能广场', skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处', tools_section_title: '内置工具', tools_loading: '加载工具中...', skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', 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_add: '接入通道', channels_disconnect: '断开', channels_save: '保存配置', channels_saved: '已保存', channels_save_error: '保存失败', channels_restarted: '已保存并重启', channels_connect_btn: '接入', channels_cancel: '取消', channels_select_placeholder: '选择要接入的通道...', channels_empty: '暂未接入任何通道', channels_empty_desc: '点击右上角「接入通道」按钮开始配置', channels_disconnect_confirm: '确认断开该通道?配置将保留但通道会停止运行。', channels_connected: '已接入', channels_connecting: '接入中...', weixin_scan_title: '微信扫码登录', weixin_scan_desc: '请使用微信扫描下方二维码', weixin_scan_loading: '正在获取二维码...', weixin_scan_waiting: '等待扫码...', weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...', weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败', weixin_qr_tip: '二维码约2分钟后过期', wecom_scan_btn: '扫码创建企微机器人', wecom_scan_desc: '使用企业微信扫码,一键创建智能机器人', wecom_scan_success: '创建成功,正在启动通道...', wecom_scan_fail: '创建失败', wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写', 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_knowledge: 'Knowledge', menu_channels: 'Channels', menu_tasks: 'Tasks', menu_logs: 'Logs', knowledge_title: 'Knowledge', knowledge_desc: 'Browse and explore your knowledge base', knowledge_tab_docs: 'Documents', knowledge_tab_graph: 'Graph', knowledge_loading: 'Loading knowledge base...', knowledge_loading_desc: 'Knowledge pages will be displayed here', knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet', knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.', knowledge_go_chat: 'Start a conversation', welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through
long-term memory and a personal knowledge base.', example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace', example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes', example_code_title: 'Coding', example_code_text: 'Search today\'s AI news and generate a visual report webpage', example_knowledge_title: 'Knowledge', example_knowledge_text: 'Show me the current knowledge base', example_skill_title: 'Skills', example_skill_text: 'Show current tools and skills', example_web_title: 'Commands', example_web_text: 'Show all commands', input_placeholder: 'Type a message, or press / for commands', 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 Context Tokens', config_max_tokens_hint: 'Max tokens the Agent can input per conversation, auto-compressed when exceeded', config_max_turns: 'Max Memory Turns', config_max_turns_hint: 'One Q&A pair = one turn, auto-compressed when exceeded', config_max_steps: 'Max Steps', config_max_steps_hint: 'Max tool calls the Agent can make in a single conversation', config_channel_type: 'Channel Type', config_provider: 'Provider', config_model_name: 'Model', config_custom_model_hint: 'Enter custom model name', config_save: 'Save', config_saved: 'Saved', config_save_error: 'Save failed', config_custom_option: 'Custom...', config_security: 'Security', config_password: 'Password', config_password_hint: 'Leave empty to disable password protection', config_password_changed: 'Password updated, please re-login', config_password_cleared: 'Password cleared', skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills', skills_hub_btn: 'Skill Hub', skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading', tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...', skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable', skill_toggle_error: 'Operation failed, please try again', 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: 'Manage connected messaging channels', channels_add: 'Connect', channels_disconnect: 'Disconnect', channels_save: 'Save', channels_saved: 'Saved', channels_save_error: 'Save failed', channels_restarted: 'Saved & Restarted', channels_connect_btn: 'Connect', channels_cancel: 'Cancel', channels_select_placeholder: 'Select a channel to connect...', channels_empty: 'No channels connected', channels_empty_desc: 'Click the "Connect" button above to get started', channels_disconnect_confirm: 'Disconnect this channel? Config will be preserved but the channel will stop.', channels_connected: 'Connected', channels_connecting: 'Connecting...', weixin_scan_title: 'WeChat QR Login', weixin_scan_desc: 'Scan the QR code below with WeChat', weixin_scan_loading: 'Loading QR code...', weixin_scan_waiting: 'Waiting for scan...', weixin_scan_scanned: 'Scanned, please confirm on your phone', weixin_scan_expired: 'QR code expired, refreshing...', weixin_scan_success: 'Login successful, starting channel...', weixin_scan_fail: 'Failed to load QR code', weixin_qr_tip: 'QR code expires in ~2 minutes', wecom_scan_btn: 'Scan to Create WeCom Bot', wecom_scan_desc: 'Scan with WeCom to create a bot instantly', wecom_scan_success: 'Bot created, starting channel...', wecom_scan_fail: 'Bot creation failed', wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual', 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.querySelectorAll('[data-tip-key]').forEach(el => { el.setAttribute('data-tooltip', t(el.dataset.tipKey)); }); 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' }, knowledge:{ group: 'nav_manage', page: 'menu_knowledge' }, 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(); const VIDEO_EXT_RE = /\.(?:mp4|webm|mov|avi|mkv)$/i; // tested against URL without query string function _buildVideoHtml(url) { const fileName = url.split('/').pop().split('?')[0]; return `
` + `` + `` + ` ${escapeHtml(fileName)}
`; } function injectVideoPlayers(html) { // Step 1: replace markdown-it anchor tags whose href points to a video file. const step1 = html.replace( /]*>[^<]*<\/a>/gi, (match, url) => VIDEO_EXT_RE.test(url.split('?')[0]) ? _buildVideoHtml(url) : match ); // Step 2: replace any remaining bare video URLs in text nodes (not inside HTML tags). // Split on HTML tags to avoid touching src/href attributes already in markup. return step1.split(/(<[^>]+>)/).map((chunk, idx) => { // Even indices are text nodes; odd indices are HTML tags — leave them untouched. if (idx % 2 !== 0) return chunk; return chunk.replace(/https?:\/\/\S+/gi, (url) => { const bare = url.replace(/[),.\s]+$/, ''); // strip trailing punctuation return VIDEO_EXT_RE.test(bare.split('?')[0]) ? _buildVideoHtml(bare) : url; }); }).join(''); } function renderMarkdown(text) { try { const html = md.render(text); return injectVideoPlayers(html); } catch (e) { return text.replace(/\n/g, '
'); } } // ===================================================================== // Chat Module // ===================================================================== let isPolling = false; let pollGeneration = 0; // incremented on each restart to cancel stale poll loops let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource let isComposing = false; let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; const SESSION_ID_KEY = 'cow_session_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) ); } // Restore session_id from localStorage so conversation history survives page refresh. // A new id is only generated when the user explicitly starts a new chat. function loadOrCreateSessionId() { const stored = localStorage.getItem(SESSION_ID_KEY); if (stored) return stored; const fresh = generateSessionId(); localStorage.setItem(SESSION_ID_KEY, fresh); return fresh; } let sessionId = loadOrCreateSessionId(); // ---- Conversation history state ---- let historyPage = 0; // last page fetched (0 = nothing fetched yet) let historyHasMore = false; let historyLoading = false; 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; initConfigView(data); } loadHistory(1); }).catch(() => { loadHistory(1); }); // Start polling immediately so scheduler/push messages are received at any time startPolling(); const chatInput = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const messagesDiv = document.getElementById('chat-messages'); const fileInput = document.getElementById('file-input'); const attachmentPreview = document.getElementById('attachment-preview'); // Pending attachments: [{file_path, file_name, file_type, preview_url}] // Items with _uploading=true are still in flight. let pendingAttachments = []; let uploadingCount = 0; // Input history (like terminal arrow-key recall) const inputHistory = []; let historyIdx = -1; let historySavedDraft = ''; function updateSendBtnState() { sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0); } function renderAttachmentPreview() { if (pendingAttachments.length === 0) { attachmentPreview.classList.add('hidden'); attachmentPreview.innerHTML = ''; updateSendBtnState(); return; } attachmentPreview.classList.remove('hidden'); attachmentPreview.innerHTML = pendingAttachments.map((att, idx) => { if (att._uploading) { return `
${escapeHtml(att.file_name)}
`; } if (att.file_type === 'image') { return `
${escapeHtml(att.file_name)}
`; } const icon = att.file_type === 'video' ? 'fa-film' : 'fa-file-alt'; return `
${escapeHtml(att.file_name)}
`; }).join(''); updateSendBtnState(); } function removeAttachment(idx) { if (pendingAttachments[idx]?._uploading) return; pendingAttachments.splice(idx, 1); renderAttachmentPreview(); } async function handleFileSelect(files) { if (!files || files.length === 0) return; const tasks = []; for (const file of files) { const placeholder = { file_name: file.name, file_type: 'file', _uploading: true }; pendingAttachments.push(placeholder); uploadingCount++; renderAttachmentPreview(); tasks.push((async () => { const formData = new FormData(); formData.append('file', file); formData.append('session_id', sessionId); try { const resp = await fetch('/upload', { method: 'POST', body: formData }); const data = await resp.json(); if (data.status === 'success') { placeholder.file_path = data.file_path; placeholder.file_name = data.file_name; placeholder.file_type = data.file_type; placeholder.preview_url = data.preview_url; delete placeholder._uploading; } else { const i = pendingAttachments.indexOf(placeholder); if (i !== -1) pendingAttachments.splice(i, 1); } } catch (e) { console.error('Upload failed:', e); const i = pendingAttachments.indexOf(placeholder); if (i !== -1) pendingAttachments.splice(i, 1); } uploadingCount--; renderAttachmentPreview(); })()); } await Promise.all(tasks); } fileInput.addEventListener('change', function() { handleFileSelect(this.files); this.value = ''; }); // Drag-and-drop support on chat input area const chatInputArea = chatInput.closest('.flex-shrink-0'); chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); }); chatInputArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); }); chatInputArea.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files); }); // Paste image support chatInput.addEventListener('paste', (e) => { const items = e.clipboardData?.items; if (!items) return; const files = []; for (const item of items) { if (item.kind === 'file') { files.push(item.getAsFile()); } } if (files.length) { e.preventDefault(); handleFileSelect(files); } }); chatInput.addEventListener('compositionstart', () => { isComposing = true; }); chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); }); // ── Slash Command Menu ─────────────────────────────────────── const SLASH_COMMANDS = [ { cmd: '/help', desc: '显示命令帮助' }, { cmd: '/status', desc: '查看运行状态' }, { cmd: '/context', desc: '查看对话上下文' }, { cmd: '/context clear', desc: '清除对话上下文' }, { cmd: '/skill list', desc: '查看已安装技能' }, { cmd: '/skill list --remote', desc: '浏览技能广场' }, { cmd: '/skill search ', desc: '搜索技能' }, { cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' }, { cmd: '/skill uninstall ', desc: '卸载技能' }, { cmd: '/skill info ', desc: '查看技能详情' }, { cmd: '/skill enable ', desc: '启用技能' }, { cmd: '/skill disable ', desc: '禁用技能' }, { cmd: '/knowledge', desc: '查看知识库统计' }, { cmd: '/knowledge list', desc: '查看知识库文件树' }, { cmd: '/knowledge on', desc: '开启知识库' }, { cmd: '/knowledge off', desc: '关闭知识库' }, { cmd: '/config', desc: '查看当前配置' }, { cmd: '/logs', desc: '查看最近日志' }, { cmd: '/version', desc: '查看版本' }, ]; const slashMenu = document.getElementById('slash-menu'); let slashActiveIdx = 0; let slashFiltered = []; let slashJustSelected = false; let slashLastFilter = ''; let slashLastMouseX = -1; let slashLastMouseY = -1; function showSlashMenu(filter) { const q = filter.toLowerCase(); if (q === slashLastFilter && !slashMenu.classList.contains('hidden')) return; slashLastFilter = q; const newFiltered = SLASH_COMMANDS.filter(c => c.cmd.toLowerCase().startsWith(q)); if (newFiltered.length === 0) { hideSlashMenu(); return; } const changed = newFiltered.length !== slashFiltered.length || newFiltered.some((c, i) => c.cmd !== slashFiltered[i]?.cmd); slashFiltered = newFiltered; if (changed) slashActiveIdx = 0; slashActiveIdx = Math.min(slashActiveIdx, slashFiltered.length - 1); slashNavByKeyboard = true; renderSlashItems(); slashMenu.classList.remove('hidden'); } function hideSlashMenu() { slashMenu.classList.add('hidden'); slashMenu.innerHTML = ''; slashFiltered = []; slashActiveIdx = -1; slashLastFilter = ''; slashNavByKeyboard = false; slashLastMouseX = -1; slashLastMouseY = -1; } function isSlashMenuVisible() { return !slashMenu.classList.contains('hidden') && slashFiltered.length > 0; } function renderSlashItems() { slashMenu.innerHTML = '
Commands
' + slashFiltered.map((c, i) => `
` + `${escapeHtml(c.cmd)}` + `${escapeHtml(c.desc)}
` ).join(''); const activeEl = slashMenu.querySelector('.slash-menu-item.active'); if (activeEl) activeEl.scrollIntoView({ block: 'nearest' }); } // Delegated events on the persistent slashMenu container (not destroyed by innerHTML) // Use coordinate comparison to distinguish real mouse movement from DOM-rebuild phantom events. slashMenu.addEventListener('mousemove', (e) => { if (e.clientX === slashLastMouseX && e.clientY === slashLastMouseY) return; slashLastMouseX = e.clientX; slashLastMouseY = e.clientY; if (!slashNavByKeyboard) return; slashNavByKeyboard = false; const item = e.target.closest('.slash-menu-item'); if (!item) return; const idx = parseInt(item.dataset.idx); if (idx === slashActiveIdx) return; slashActiveIdx = idx; slashMenu.querySelectorAll('.slash-menu-item').forEach(el => { el.classList.toggle('active', parseInt(el.dataset.idx) === idx); }); }); slashMenu.addEventListener('mouseover', (e) => { if (slashNavByKeyboard) return; const item = e.target.closest('.slash-menu-item'); if (!item) return; const idx = parseInt(item.dataset.idx); if (idx === slashActiveIdx) return; slashActiveIdx = idx; slashMenu.querySelectorAll('.slash-menu-item').forEach(el => { el.classList.toggle('active', parseInt(el.dataset.idx) === idx); }); }); slashMenu.addEventListener('mousedown', (e) => { const item = e.target.closest('.slash-menu-item'); if (!item) return; e.preventDefault(); selectSlashCommand(parseInt(item.dataset.idx)); }); function selectSlashCommand(idx) { if (idx < 0 || idx >= slashFiltered.length) return; const chosen = slashFiltered[idx].cmd; slashJustSelected = true; chatInput.value = chosen; chatInput.dispatchEvent(new Event('input')); hideSlashMenu(); chatInput.focus(); chatInput.selectionStart = chatInput.selectionEnd = chosen.length; } 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'; updateSendBtnState(); const val = this.value; if (slashJustSelected) { slashJustSelected = false; } else if (val.startsWith('/')) { showSlashMenu(val); } else { hideSlashMenu(); } }); chatInput.addEventListener('keydown', function(e) { if (e.keyCode === 229 || e.isComposing || isComposing) return; if (isSlashMenuVisible()) { if (e.key === 'ArrowDown') { e.preventDefault(); slashNavByKeyboard = true; slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1); renderSlashItems(); return; } if (e.key === 'ArrowUp') { e.preventDefault(); slashNavByKeyboard = true; slashActiveIdx = Math.max(slashActiveIdx - 1, 0); renderSlashItems(); return; } if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) { e.preventDefault(); selectSlashCommand(slashActiveIdx); return; } if (e.key === 'Escape') { e.preventDefault(); hideSlashMenu(); return; } if (e.key === 'Tab') { e.preventDefault(); selectSlashCommand(slashActiveIdx); return; } } // Arrow-key history recall (only when input is empty or already browsing history) if (e.key === 'ArrowUp' && inputHistory.length > 0 && !isSlashMenuVisible()) { const curVal = this.value.trim(); const isSingleLine = !this.value.includes('\n'); if (isSingleLine && (curVal === '' || historyIdx >= 0)) { e.preventDefault(); if (historyIdx < 0) { historySavedDraft = this.value; historyIdx = inputHistory.length - 1; } else if (historyIdx > 0) { historyIdx--; } this.value = inputHistory[historyIdx]; slashJustSelected = true; this.dispatchEvent(new Event('input')); hideSlashMenu(); this.selectionStart = this.selectionEnd = this.value.length; return; } } if (e.key === 'ArrowDown' && historyIdx >= 0 && !isSlashMenuVisible()) { const isSingleLine = !this.value.includes('\n'); if (isSingleLine) { e.preventDefault(); if (historyIdx < inputHistory.length - 1) { historyIdx++; this.value = inputHistory[historyIdx]; } else { historyIdx = -1; this.value = historySavedDraft; historySavedDraft = ''; } slashJustSelected = true; this.dispatchEvent(new Event('input')); hideSlashMenu(); this.selectionStart = this.selectionEnd = this.value.length; return; } } 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) { sendMessage(); e.preventDefault(); } }); chatInput.addEventListener('blur', () => { setTimeout(hideSlashMenu, 150); }); document.querySelectorAll('.example-card').forEach(card => { card.addEventListener('click', () => { // data-send overrides the visible text (e.g. show "查看全部命令" but send "/help") const sendText = card.dataset.send; if (sendText) { chatInput.value = sendText; chatInput.dispatchEvent(new Event('input')); chatInput.focus(); return; } 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 && pendingAttachments.length === 0) return; if (text) { inputHistory.push(text); historyIdx = -1; historySavedDraft = ''; } const ws = document.getElementById('welcome-screen'); if (ws) ws.remove(); const timestamp = new Date(); const attachments = [...pendingAttachments]; addUserMessage(text, timestamp, attachments); const loadingEl = addLoadingIndicator(); chatInput.value = ''; chatInput.style.height = '42px'; chatInput.style.overflowY = 'hidden'; pendingAttachments = []; renderAttachmentPreview(); sendBtn.disabled = true; const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() }; if (attachments.length > 0) { body.attachments = attachments.map(a => ({ file_path: a.file_path, file_name: a.file_name, file_type: a.file_type, })); } const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1000; function postWithRetry(attempt) { fetch('/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .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; } } else { loadingEl.remove(); addBotMessage(t('error_send'), new Date()); } }) .catch(err => { if (err.name === 'AbortError') { loadingEl.remove(); addBotMessage(t('error_timeout'), new Date()); return; } if (attempt < MAX_RETRIES) { console.warn(`[sendMessage] attempt ${attempt + 1} failed, retrying...`, err); setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1)); return; } loadingEl.remove(); addBotMessage(t('error_send'), new Date()); }); } postWithRetry(0); } function startSSE(requestId, loadingEl, timestamp) { let botEl = null; let stepsEl = null; // .agent-steps (thinking summaries + tool indicators) let contentEl = null; // .answer-content (final streaming answer) let mediaEl = null; // .media-content (images & file attachments) let accumulatedText = ''; let currentToolEl = null; let currentReasoningEl = null; // live reasoning bubble let reasoningText = ''; let done = false; const MAX_RECONNECTS = 10; const RECONNECT_BASE_MS = 1000; let reconnectCount = 0; 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'); mediaEl = botEl.querySelector('.media-content'); } function connect() { const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`); activeStreams[requestId] = es; es.onmessage = function(e) { let item; try { item = JSON.parse(e.data); } catch (_) { return; } // Successful data received, reset reconnect counter reconnectCount = 0; if (item.type === 'reasoning') { ensureBotEl(); reasoningText += item.content; if (!currentReasoningEl) { currentReasoningEl = document.createElement('div'); currentReasoningEl.className = 'agent-step agent-thinking-step'; currentReasoningEl.innerHTML = `
`; stepsEl.appendChild(currentReasoningEl); } const oneLine = reasoningText.trim().replace(/\n+/g, ' '); currentReasoningEl.querySelector('.thinking-summary').textContent = oneLine.length > 80 ? oneLine.substring(0, 80) + '…' : oneLine; currentReasoningEl.querySelector('.thinking-full').innerHTML = renderMarkdown(reasoningText); scrollChatToBottom(); } else if (item.type === 'delta') { ensureBotEl(); if (currentReasoningEl) { if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80) currentReasoningEl.classList.add('no-expand'); currentReasoningEl = null; reasoningText = ''; } accumulatedText += item.content; contentEl.innerHTML = renderMarkdown(accumulatedText); scrollChatToBottom(); } else if (item.type === 'message_end') { if (item.has_tool_calls && accumulatedText.trim()) { ensureBotEl(); const frozenEl = document.createElement('div'); frozenEl.className = 'agent-step agent-content-step'; frozenEl.innerHTML = `
${renderMarkdown(accumulatedText.trim())}
`; stepsEl.appendChild(frozenEl); accumulatedText = ''; contentEl.innerHTML = ''; scrollChatToBottom(); } } else if (item.type === 'tool_start') { ensureBotEl(); if (currentReasoningEl) { if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80) currentReasoningEl.classList.add('no-expand'); currentReasoningEl = null; reasoningText = ''; } 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 === 'image') { ensureBotEl(); const imgEl = document.createElement('img'); imgEl.src = item.content; imgEl.alt = 'screenshot'; imgEl.style.cssText = 'max-width:600px;border-radius:8px;margin:8px 0;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.1);'; imgEl.onclick = () => window.open(item.content, '_blank'); mediaEl.appendChild(imgEl); scrollChatToBottom(); } else if (item.type === 'text') { // Intermediate text sent before media items; display it but keep SSE open. ensureBotEl(); contentEl.classList.remove('sse-streaming'); const textContent = item.content || accumulatedText; if (textContent) contentEl.innerHTML = renderMarkdown(textContent); applyHighlighting(botEl); scrollChatToBottom(); } else if (item.type === 'video') { ensureBotEl(); const wrapper = document.createElement('div'); wrapper.innerHTML = _buildVideoHtml(item.content); mediaEl.appendChild(wrapper.firstElementChild || wrapper); scrollChatToBottom(); } else if (item.type === 'file') { ensureBotEl(); const fileName = item.file_name || item.content.split('/').pop(); const fileEl = document.createElement('a'); fileEl.href = item.content; fileEl.download = fileName; fileEl.target = '_blank'; fileEl.className = 'file-attachment'; fileEl.style.cssText = 'display:inline-flex;align-items:center;gap:6px;padding:8px 14px;margin:8px 0;border-radius:8px;background:var(--bg-secondary,#f3f4f6);color:var(--text-primary,#374151);text-decoration:none;font-size:14px;border:1px solid var(--border-color,#e5e7eb);'; fileEl.innerHTML = ` ${fileName}`; mediaEl.appendChild(fileEl); scrollChatToBottom(); } else if (item.type === 'phase') { // Coarse progress (e.g. cow install-browser); must not close SSE (unlike "done") ensureBotEl(); const wrap = document.createElement('div'); wrap.className = 'text-xs sm:text-sm text-slate-600 dark:text-slate-400 border-l-2 border-primary-400 pl-2 py-1 my-0.5'; wrap.textContent = String(item.content || ''); stepsEl.appendChild(wrap); scrollChatToBottom(); } else if (item.type === 'done') { done = true; es.close(); delete activeStreams[requestId]; // item.content may be empty when "done" is only a stream-close signal after media. 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'); // Only update text content when there is something new to show. if (finalText) contentEl.innerHTML = renderMarkdown(finalText); applyHighlighting(botEl); } scrollChatToBottom(); } else if (item.type === 'error') { done = true; 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 (done) return; if (currentReasoningEl) { if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80) currentReasoningEl.classList.add('no-expand'); currentReasoningEl = null; reasoningText = ''; } if (reconnectCount < MAX_RECONNECTS) { reconnectCount++; const delay = Math.min(RECONNECT_BASE_MS * reconnectCount, 5000); console.warn(`[SSE] connection lost for ${requestId}, reconnecting in ${delay}ms (attempt ${reconnectCount}/${MAX_RECONNECTS})`); setTimeout(connect, delay); return; } // Exhausted retries, show whatever we have 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); bindChatKnowledgeLinks(botEl); } }; } connect(); } function startPolling() { const gen = ++pollGeneration; isPolling = true; let pollInFlight = false; function poll() { if (gen !== pollGeneration) return; if (pollInFlight) return; if (document.hidden) { setTimeout(poll, 10000); return; } pollInFlight = true; fetch('/poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }) .then(r => r.json()) .then(data => { pollInFlight = false; if (gen !== pollGeneration) return; if (data.status === 'success' && data.has_content) { const rid = data.request_id; if (loadingContainers[rid]) { loadingContainers[rid].remove(); delete loadingContainers[rid]; } const welcomeScreen = document.getElementById('welcome-screen'); if (welcomeScreen) welcomeScreen.remove(); addBotMessage(data.content, new Date(data.timestamp * 1000), rid); scrollChatToBottom(); } const delay = (data.status === 'success' && data.has_content) ? 5000 : 10000; setTimeout(poll, delay); }) .catch(() => { pollInFlight = false; setTimeout(poll, 10000); }); } poll(); } function createUserMessageEl(content, timestamp, attachments) { const el = document.createElement('div'); el.className = 'flex justify-end px-4 sm:px-6 py-3'; let attachHtml = ''; if (attachments && attachments.length > 0) { const items = attachments.map(a => { if (a.file_type === 'image') { return `${escapeHtml(a.file_name)}`; } const icon = a.file_type === 'video' ? 'fa-film' : 'fa-file-alt'; return `
${escapeHtml(a.file_name)}
`; }).join(''); attachHtml = `
${items}
`; } const textHtml = content ? renderMarkdown(content) : ''; el.innerHTML = `
${attachHtml}${textHtml}
${formatTime(timestamp)}
`; return el; } function renderToolCallsHtml(toolCalls) { if (!toolCalls || toolCalls.length === 0) return ''; return toolCalls.map(tc => { const argsStr = formatToolArgs(tc.arguments || {}); const resultStr = tc.result ? escapeHtml(String(tc.result)) : ''; const hasResult = !!resultStr; return `
${escapeHtml(tc.name || '')}
Input
${argsStr}
${hasResult ? `
Output
${resultStr}
` : ''}
`; }).join(''); } function renderThinkingHtml(text) { if (!text || !text.trim()) return ''; const full = text.trim(); const oneLine = full.replace(/\n+/g, ' '); if (oneLine.length > 80) { const truncated = oneLine.substring(0, 80) + '…'; return `
${escapeHtml(truncated)}
${renderMarkdown(full)}
`; } return `
${escapeHtml(oneLine)}
`; } function renderStepsHtml(steps) { if (!steps || steps.length === 0) return { stepsHtml: '', finalContent: '' }; // Find the index of the last content step — it becomes the main answer, not a step let lastContentIdx = -1; for (let i = steps.length - 1; i >= 0; i--) { if (steps[i].type === 'content') { lastContentIdx = i; break; } } let html = ''; let lastContentText = ''; for (let i = 0; i < steps.length; i++) { const step = steps[i]; if (step.type === 'thinking') { html += renderThinkingHtml(step.content); } else if (step.type === 'content') { if (i === lastContentIdx) { lastContentText = step.content; } else { html += `
${renderMarkdown(step.content)}
`; } } else if (step.type === 'tool') { const argsStr = formatToolArgs(step.arguments || {}); const resultStr = step.result ? escapeHtml(String(step.result)) : ''; html += `
${escapeHtml(step.name || '')}
Input
${argsStr}
${resultStr ? `
Output
${resultStr}
` : ''}
`; } } return { stepsHtml: html, lastContentText }; } function createBotMessageEl(content, timestamp, requestId, msg) { const el = document.createElement('div'); el.className = 'flex gap-3 px-4 sm:px-6 py-3'; if (requestId) el.dataset.requestId = requestId; let stepsHtml = ''; let displayContent = content; if (msg && msg.steps && msg.steps.length > 0) { // New format: ordered steps with interleaved content const result = renderStepsHtml(msg.steps); stepsHtml = result.stepsHtml; // The final content (last text after all steps) is the main answer displayContent = content || result.lastContentText; } else { // Legacy format: separate tool_calls + optional reasoning const toolCalls = msg && msg.tool_calls; const reasoning = msg && msg.reasoning; stepsHtml = renderThinkingHtml(reasoning) + renderToolCallsHtml(toolCalls); } el.innerHTML = ` CowAgent
${stepsHtml ? `
${stepsHtml}
` : ''}
${renderMarkdown(displayContent)}
${formatTime(timestamp)}
`; applyHighlighting(el); bindChatKnowledgeLinks(el); return el; } function addUserMessage(content, timestamp, attachments) { const el = createUserMessageEl(content, timestamp, attachments); messagesDiv.appendChild(el); scrollChatToBottom(); } function addBotMessage(content, timestamp, requestId) { const el = createBotMessageEl(content, timestamp, requestId); messagesDiv.appendChild(el); scrollChatToBottom(); } // Load conversation history from the server (page 1 = most recent messages). // Subsequent pages prepend older messages when the user scrolls to the top. function loadHistory(page) { if (historyLoading) return; historyLoading = true; fetch(`/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=20`) .then(r => r.json()) .then(data => { if (data.status !== 'success' || data.messages.length === 0) return; const prevScrollHeight = messagesDiv.scrollHeight; const isFirstLoad = page === 1; // On first load, remove the welcome screen if history exists if (isFirstLoad) { const ws = document.getElementById('welcome-screen'); if (ws) ws.remove(); } // Build a fragment of history message elements in chronological order const fragment = document.createDocumentFragment(); if (data.has_more && page > 1) { // Keep the "load more" sentinel in place (inserted below) } data.messages.forEach(msg => { const hasContent = msg.content && msg.content.trim(); const hasToolCalls = msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0; if (!hasContent && !hasToolCalls) return; const ts = new Date(msg.created_at * 1000); const el = msg.role === 'user' ? createUserMessageEl(msg.content, ts) : createBotMessageEl(msg.content || '', ts, null, msg); fragment.appendChild(el); }); // Prepend history above any existing messages const sentinel = document.getElementById('history-load-more'); const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild; messagesDiv.insertBefore(fragment, insertBefore); // Manage the "load more" sentinel at the very top if (data.has_more) { if (!document.getElementById('history-load-more')) { const btn = document.createElement('div'); btn.id = 'history-load-more'; btn.className = 'flex justify-center py-3'; btn.innerHTML = ``; messagesDiv.insertBefore(btn, messagesDiv.firstChild); } } else { const sentinel = document.getElementById('history-load-more'); if (sentinel) sentinel.remove(); } historyHasMore = data.has_more; historyPage = page; if (isFirstLoad) { // Use requestAnimationFrame to ensure the DOM has fully rendered // before scrolling, otherwise scrollHeight may not reflect new content. requestAnimationFrame(() => scrollChatToBottom()); } else { // Restore scroll position so loading older messages doesn't jump the view messagesDiv.scrollTop = messagesDiv.scrollHeight - prevScrollHeight; } }) .catch(() => {}) .finally(() => { historyLoading = false; }); } 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 = {}; // Generate a fresh session and persist it so the next page load also starts clean sessionId = generateSessionId(); localStorage.setItem(SESSION_ID_KEY, sessionId); loadingContainers = {}; startPolling(); // bump generation so old loop self-cancels, new loop uses fresh sessionId messagesDiv.innerHTML = ''; const ws = document.createElement('div'); ws.id = 'welcome-screen'; ws.className = 'flex flex-col items-center justify-center h-full px-6 pb-16'; ws.style.paddingTop = '6vh'; 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')}

${t('example_knowledge_title')}

${t('example_knowledge_text')}

${t('example_skill_title')}

${t('example_skill_text')}

${t('example_web_title')}

${t('example_web_text')}

`; messagesDiv.appendChild(ws); ws.querySelectorAll('.example-card').forEach(card => { card.addEventListener('click', () => { const sendText = card.dataset.send; if (sendText) { chatInput.value = sendText; chatInput.dispatchEvent(new Event('input')); chatInput.focus(); return; } 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 ChannelsHandler_maskSecret(val) { if (!val || val.length <= 8) return val; return val.slice(0, 4) + '*'.repeat(val.length - 8) + val.slice(-4); } 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 // ===================================================================== let configProviders = {}; let configApiBases = {}; let configApiKeys = {}; let configCurrentModel = ''; let cfgProviderValue = ''; let cfgModelValue = ''; // --- Custom dropdown helper --- function initDropdown(el, options, selectedValue, onChange) { const textEl = el.querySelector('.cfg-dropdown-text'); const menuEl = el.querySelector('.cfg-dropdown-menu'); const selEl = el.querySelector('.cfg-dropdown-selected'); el._ddValue = selectedValue || ''; el._ddOnChange = onChange; function render() { menuEl.innerHTML = ''; options.forEach(opt => { const item = document.createElement('div'); item.className = 'cfg-dropdown-item' + (opt.value === el._ddValue ? ' active' : ''); item.textContent = opt.label; item.dataset.value = opt.value; item.addEventListener('click', (e) => { e.stopPropagation(); el._ddValue = opt.value; textEl.textContent = opt.label; menuEl.querySelectorAll('.cfg-dropdown-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); el.classList.remove('open'); if (el._ddOnChange) el._ddOnChange(opt.value); }); menuEl.appendChild(item); }); const sel = options.find(o => o.value === el._ddValue); textEl.textContent = sel ? sel.label : (options[0] ? options[0].label : '--'); if (!sel && options[0]) el._ddValue = options[0].value; } render(); if (!el._ddBound) { selEl.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.cfg-dropdown.open').forEach(d => { if (d !== el) d.classList.remove('open'); }); el.classList.toggle('open'); }); el._ddBound = true; } } document.addEventListener('click', () => { document.querySelectorAll('.cfg-dropdown.open').forEach(d => d.classList.remove('open')); }); function getDropdownValue(el) { return el._ddValue || ''; } // --- Config init --- function initConfigView(data) { configProviders = data.providers || {}; configApiBases = data.api_bases || {}; configApiKeys = data.api_keys || {}; configCurrentModel = data.model || ''; const providerEl = document.getElementById('cfg-provider'); const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: p.label })); // if use_linkai is enabled, always select linkai as the provider // Otherwise prefer bot_type from config, fall back to model-based detection const detected = data.use_linkai ? 'linkai' : (data.bot_type && configProviders[data.bot_type] ? data.bot_type : detectProvider(configCurrentModel)); cfgProviderValue = detected || (providerOpts[0] ? providerOpts[0].value : ''); initDropdown(providerEl, providerOpts, cfgProviderValue, onProviderChange); onProviderChange(cfgProviderValue); syncModelSelection(configCurrentModel); document.getElementById('cfg-max-tokens').value = data.agent_max_context_tokens || 50000; document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 20; document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20; const pwdInput = document.getElementById('cfg-password'); const maskedPwd = data.web_password_masked || ''; pwdInput.value = maskedPwd; pwdInput.dataset.masked = maskedPwd ? '1' : ''; pwdInput.dataset.maskedVal = maskedPwd; pwdInput.classList.toggle('cfg-key-masked', !!maskedPwd); if (maskedPwd) { pwdInput.placeholder = '••••••••'; } else { pwdInput.placeholder = ''; } if (!pwdInput._cfgBound) { pwdInput.addEventListener('focus', function() { if (this.dataset.masked === '1') { this.value = ''; this.dataset.masked = ''; this.classList.remove('cfg-key-masked'); } }); pwdInput.addEventListener('input', function() { this.dataset.masked = ''; }); pwdInput._cfgBound = true; } } function detectProvider(model) { if (!model) return Object.keys(configProviders)[0] || ''; for (const [pid, p] of Object.entries(configProviders)) { if (pid === 'linkai') continue; if (p.models && p.models.includes(model)) return pid; } return Object.keys(configProviders)[0] || ''; } function onProviderChange(pid) { cfgProviderValue = pid || getDropdownValue(document.getElementById('cfg-provider')); const p = configProviders[cfgProviderValue]; if (!p) return; const modelEl = document.getElementById('cfg-model-select'); const modelOpts = (p.models || []).map(m => ({ value: m, label: m })); modelOpts.push({ value: '__custom__', label: t('config_custom_option') }); initDropdown(modelEl, modelOpts, modelOpts[0] ? modelOpts[0].value : '', onModelSelectChange); // API Key const keyField = p.api_key_field; const keyWrap = document.getElementById('cfg-api-key-wrap'); const keyInput = document.getElementById('cfg-api-key'); if (keyField) { keyWrap.classList.remove('hidden'); keyInput.classList.add('cfg-key-masked'); const maskedVal = configApiKeys[keyField] || ''; keyInput.value = maskedVal; keyInput.dataset.field = keyField; keyInput.dataset.masked = maskedVal ? '1' : ''; keyInput.dataset.maskedVal = maskedVal; const toggleIcon = document.querySelector('#cfg-api-key-toggle i'); if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs'; if (!keyInput._cfgBound) { keyInput.addEventListener('focus', function() { if (this.dataset.masked === '1') { this.value = ''; this.dataset.masked = ''; this.classList.remove('cfg-key-masked'); } }); keyInput.addEventListener('blur', function() { if (!this.value.trim() && this.dataset.maskedVal) { this.value = this.dataset.maskedVal; this.dataset.masked = '1'; this.classList.add('cfg-key-masked'); } }); keyInput.addEventListener('input', function() { this.dataset.masked = ''; }); keyInput._cfgBound = true; } } else { keyWrap.classList.add('hidden'); keyInput.value = ''; keyInput.dataset.field = ''; } // API Base if (p.api_base_key) { document.getElementById('cfg-api-base-wrap').classList.remove('hidden'); document.getElementById('cfg-api-base').value = configApiBases[p.api_base_key] || p.api_base_default || ''; } else { document.getElementById('cfg-api-base-wrap').classList.add('hidden'); document.getElementById('cfg-api-base').value = ''; } onModelSelectChange(modelOpts[0] ? modelOpts[0].value : ''); } function onModelSelectChange(val) { cfgModelValue = val || getDropdownValue(document.getElementById('cfg-model-select')); const customWrap = document.getElementById('cfg-model-custom-wrap'); if (cfgModelValue === '__custom__') { customWrap.classList.remove('hidden'); document.getElementById('cfg-model-custom').focus(); } else { customWrap.classList.add('hidden'); document.getElementById('cfg-model-custom').value = ''; } } function syncModelSelection(model) { const p = configProviders[cfgProviderValue]; if (!p) return; const modelEl = document.getElementById('cfg-model-select'); if (p.models && p.models.includes(model)) { const modelOpts = (p.models || []).map(m => ({ value: m, label: m })); modelOpts.push({ value: '__custom__', label: t('config_custom_option') }); initDropdown(modelEl, modelOpts, model, onModelSelectChange); cfgModelValue = model; document.getElementById('cfg-model-custom-wrap').classList.add('hidden'); } else { cfgModelValue = '__custom__'; const modelOpts = (p.models || []).map(m => ({ value: m, label: m })); modelOpts.push({ value: '__custom__', label: t('config_custom_option') }); initDropdown(modelEl, modelOpts, '__custom__', onModelSelectChange); document.getElementById('cfg-model-custom-wrap').classList.remove('hidden'); document.getElementById('cfg-model-custom').value = model; } } function getSelectedModel() { if (cfgModelValue === '__custom__') { return document.getElementById('cfg-model-custom').value.trim(); } return cfgModelValue; } function toggleApiKeyVisibility() { const input = document.getElementById('cfg-api-key'); const icon = document.querySelector('#cfg-api-key-toggle i'); if (input.classList.contains('cfg-key-masked')) { input.classList.remove('cfg-key-masked'); icon.className = 'fas fa-eye-slash text-xs'; } else { input.classList.add('cfg-key-masked'); icon.className = 'fas fa-eye text-xs'; } } function showStatus(elId, msgKey, isError) { const el = document.getElementById(elId); el.textContent = t(msgKey); el.classList.toggle('text-red-500', !!isError); el.classList.toggle('text-primary-500', !isError); el.classList.remove('opacity-0'); setTimeout(() => el.classList.add('opacity-0'), 2500); } function saveModelConfig() { const model = getSelectedModel(); if (!model) return; const updates = { model: model }; const p = configProviders[cfgProviderValue]; updates.use_linkai = (cfgProviderValue === 'linkai'); if (cfgProviderValue === 'linkai') { updates.bot_type = ''; } else { updates.bot_type = cfgProviderValue; } if (p && p.api_base_key) { const base = document.getElementById('cfg-api-base').value.trim(); if (base) updates[p.api_base_key] = base; } if (p && p.api_key_field) { const keyInput = document.getElementById('cfg-api-key'); const rawVal = keyInput.value.trim(); if (rawVal && keyInput.dataset.masked !== '1') { updates[p.api_key_field] = rawVal; } } const btn = document.getElementById('cfg-model-save'); btn.disabled = true; fetch('/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { configCurrentModel = model; if (data.applied) { const keyInput = document.getElementById('cfg-api-key'); Object.entries(data.applied).forEach(([k, v]) => { if (k === 'model') return; if (k.includes('api_key')) { const masked = v.length > 8 ? v.substring(0, 4) + '*'.repeat(v.length - 8) + v.substring(v.length - 4) : v; configApiKeys[k] = masked; if (keyInput.dataset.field === k) { keyInput.value = masked; keyInput.dataset.masked = '1'; keyInput.dataset.maskedVal = masked; keyInput.classList.add('cfg-key-masked'); const toggleIcon = document.querySelector('#cfg-api-key-toggle i'); if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs'; } } else { configApiBases[k] = v; } }); } showStatus('cfg-model-status', 'config_saved', false); } else { showStatus('cfg-model-status', 'config_save_error', true); } }) .catch(() => showStatus('cfg-model-status', 'config_save_error', true)) .finally(() => { btn.disabled = false; }); } function saveAgentConfig() { const updates = { agent_max_context_tokens: parseInt(document.getElementById('cfg-max-tokens').value) || 50000, agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 20, agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 20, }; const btn = document.getElementById('cfg-agent-save'); btn.disabled = true; fetch('/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { showStatus('cfg-agent-status', 'config_saved', false); } else { showStatus('cfg-agent-status', 'config_save_error', true); } }) .catch(() => showStatus('cfg-agent-status', 'config_save_error', true)) .finally(() => { btn.disabled = false; }); } function savePasswordConfig() { const input = document.getElementById('cfg-password'); if (input.dataset.masked === '1') { showStatus('cfg-password-status', 'config_saved', false); return; } const newPwd = input.value.trim(); const btn = document.getElementById('cfg-password-save'); btn.disabled = true; fetch('/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: { web_password: newPwd } }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { if (newPwd) { showStatus('cfg-password-status', 'config_password_changed', false); setTimeout(() => { window.location.reload(); }, 1500); } else { input.dataset.masked = ''; input.dataset.maskedVal = ''; input.classList.remove('cfg-key-masked'); showStatus('cfg-password-status', 'config_password_cleared', false); } } else { showStatus('cfg-password-status', 'config_save_error', true); } }) .catch(() => showStatus('cfg-password-status', 'config_save_error', true)) .finally(() => { btn.disabled = false; }); } function loadConfigView() { fetch('/config').then(r => r.json()).then(data => { if (data.status !== 'success') return; appConfig = data; initConfigView(data); }).catch(() => {}); } // ===================================================================== // Skills View // ===================================================================== let toolsLoaded = false; const TOOL_ICONS = { bash: 'fa-terminal', edit: 'fa-pen-to-square', read: 'fa-file-lines', write: 'fa-file-pen', ls: 'fa-folder-open', send: 'fa-paper-plane', web_search: 'fa-magnifying-glass', browser: 'fa-globe', env_config: 'fa-key', scheduler: 'fa-clock', memory_get: 'fa-brain', memory_search: 'fa-brain', }; function getToolIcon(name) { return TOOL_ICONS[name] || 'fa-wrench'; } function loadSkillsView() { loadToolsSection(); loadSkillsSection(); } function loadToolsSection() { if (toolsLoaded) return; const emptyEl = document.getElementById('tools-empty'); const listEl = document.getElementById('tools-list'); const badge = document.getElementById('tools-count-badge'); fetch('/api/tools').then(r => r.json()).then(data => { if (data.status !== 'success') return; const tools = data.tools || []; emptyEl.classList.add('hidden'); if (tools.length === 0) { emptyEl.classList.remove('hidden'); emptyEl.innerHTML = `${currentLang === 'zh' ? '暂无内置工具' : 'No built-in tools'}`; return; } badge.textContent = tools.length; badge.classList.remove('hidden'); listEl.innerHTML = ''; tools.forEach(tool => { 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'; card.innerHTML = `
${escapeHtml(tool.name)}

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

`; listEl.appendChild(card); }); listEl.classList.remove('hidden'); toolsLoaded = true; }).catch(() => { emptyEl.classList.remove('hidden'); emptyEl.innerHTML = `${currentLang === 'zh' ? '加载失败' : 'Failed to load'}`; }); } function loadSkillsSection() { const emptyEl = document.getElementById('skills-empty'); const listEl = document.getElementById('skills-list'); const badge = document.getElementById('skills-count-badge'); fetch('/api/skills').then(r => r.json()).then(data => { if (data.status !== 'success') return; const skills = data.skills || []; if (skills.length === 0) { const p = emptyEl.querySelector('p'); if (p) p.textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found'; return; } badge.textContent = skills.length; badge.classList.remove('hidden'); emptyEl.classList.add('hidden'); listEl.innerHTML = ''; skills.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 transition-opacity'; card.dataset.skillName = sk.name; card.dataset.skillDesc = sk.description || ''; card.dataset.enabled = sk.enabled ? '1' : '0'; renderSkillCard(card, sk); listEl.appendChild(card); }); }).catch(() => {}); } function renderSkillCard(card, sk) { const enabled = sk.enabled; const iconColor = enabled ? 'text-primary-400' : 'text-slate-300 dark:text-slate-600'; const trackClass = enabled ? 'bg-primary-400' : 'bg-slate-200 dark:bg-slate-700'; const thumbTranslate = enabled ? 'translate-x-3' : 'translate-x-0.5'; card.innerHTML = `
${escapeHtml(sk.display_name || sk.name)}

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

`; } function toggleSkill(name, currentlyEnabled) { const action = currentlyEnabled ? 'close' : 'open'; const card = document.querySelector(`[data-skill-name="${CSS.escape(name)}"]`); if (card) card.style.opacity = '0.5'; fetch('/api/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, name }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { if (card) { const desc = card.dataset.skillDesc || ''; card.dataset.enabled = currentlyEnabled ? '0' : '1'; card.style.opacity = '1'; renderSkillCard(card, { name, description: desc, enabled: !currentlyEnabled }); } } else { if (card) card.style.opacity = '1'; alert(currentLang === 'zh' ? '操作失败,请稍后再试' : 'Operation failed, please try again'); } }) .catch(() => { if (card) card.style.opacity = '1'; alert(currentLang === 'zh' ? '操作失败,请稍后再试' : 'Operation failed, please try again'); }); } // ===================================================================== // 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'); } // ===================================================================== // Custom Confirm Dialog // ===================================================================== function showConfirmDialog({ title, message, okText, cancelText, onConfirm }) { const overlay = document.getElementById('confirm-dialog-overlay'); document.getElementById('confirm-dialog-title').textContent = title || ''; document.getElementById('confirm-dialog-message').textContent = message || ''; document.getElementById('confirm-dialog-ok').textContent = okText || 'OK'; document.getElementById('confirm-dialog-cancel').textContent = cancelText || t('channels_cancel'); function cleanup() { overlay.classList.add('hidden'); okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); overlay.removeEventListener('click', onOverlayClick); } function onOk() { cleanup(); if (onConfirm) onConfirm(); } function onCancel() { cleanup(); } function onOverlayClick(e) { if (e.target === overlay) cleanup(); } const okBtn = document.getElementById('confirm-dialog-ok'); const cancelBtn = document.getElementById('confirm-dialog-cancel'); okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onOverlayClick); overlay.classList.remove('hidden'); } // ===================================================================== // Channels View // ===================================================================== let channelsData = []; function loadChannelsView() { const container = document.getElementById('channels-content'); container.innerHTML = `
Loading...
`; fetch('/api/channels').then(r => r.json()).then(data => { if (data.status !== 'success') return; channelsData = data.channels || []; renderActiveChannels(); }).catch(() => { container.innerHTML = '

Failed to load channels

'; }); } function renderActiveChannels() { stopWeixinQrPoll(); stopWeixinStatusPoll(); const container = document.getElementById('channels-content'); container.innerHTML = ''; closeAddChannelPanel(); const activeChannels = channelsData.filter(ch => ch.active); if (activeChannels.length === 0) { container.innerHTML = `

${t('channels_empty')}

${t('channels_empty_desc')}

`; return; } activeChannels.forEach(ch => { const label = (typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label; const card = document.createElement('div'); card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; card.id = `channel-card-${ch.name}`; const fieldsHtml = buildChannelFieldsHtml(ch.name, ch.fields || []); const hasFields = (ch.fields || []).length > 0; const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in'; const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch); let statusDot, statusText; if (weixinWaiting) { statusDot = 'bg-amber-400 animate-pulse'; statusText = ch.login_status === 'scanned' ? `${t('weixin_scan_scanned')}` : `${t('weixin_scan_waiting')}`; } else if (wecomNeedsCreds) { statusDot = 'bg-amber-400 animate-pulse'; statusText = `${t('channels_connecting')}`; } else { statusDot = 'bg-primary-400'; statusText = `${t('channels_connected')}`; } card.innerHTML = `
${escapeHtml(label)} ${statusText}

${escapeHtml(ch.name)}

${weixinWaiting ? `
` : ''} ${wecomNeedsCreds ? `

${t('wecom_scan_desc')}

` : ''} ${hasFields ? `
${fieldsHtml}
` : ''}`; container.appendChild(card); bindSecretFieldEvents(card); if (weixinWaiting) { startWeixinActiveStatusPoll(); } }); } function buildChannelFieldsHtml(chName, fields) { let html = ''; fields.forEach(f => { const inputId = `ch-${chName}-${f.key}`; let inputHtml = ''; if (f.type === 'bool') { const checked = f.value ? 'checked' : ''; inputHtml = ``; } else if (f.type === 'secret') { inputHtml = ``; } else { const inputType = f.type === 'number' ? 'number' : 'text'; inputHtml = ``; } html += `
${inputHtml}
`; }); return html; } function bindSecretFieldEvents(container) { container.querySelectorAll('input[data-masked="1"]').forEach(inp => { inp.addEventListener('focus', function() { if (this.dataset.masked === '1') { this.value = ''; this.dataset.masked = ''; this.classList.remove('cfg-key-masked'); } }); }); } function showChannelStatus(chName, msgKey, isError) { const el = document.getElementById(`ch-status-${chName}`); if (!el) return; el.textContent = t(msgKey); el.classList.toggle('text-red-500', !!isError); el.classList.toggle('text-primary-500', !isError); el.classList.remove('opacity-0'); setTimeout(() => el.classList.add('opacity-0'), 2500); } function saveChannelConfig(chName) { const card = document.getElementById(`channel-card-${chName}`); if (!card) return; const updates = {}; card.querySelectorAll('input[data-ch="' + chName + '"]').forEach(inp => { const key = inp.dataset.field; if (inp.type === 'checkbox') { updates[key] = inp.checked; } else { if (inp.dataset.masked === '1') return; updates[key] = inp.value; } }); const btn = document.getElementById(`ch-save-${chName}`); if (btn) btn.disabled = true; fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save', channel: chName, config: updates }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { showChannelStatus(chName, data.restarted ? 'channels_restarted' : 'channels_saved', false); } else { showChannelStatus(chName, 'channels_save_error', true); } }) .catch(() => showChannelStatus(chName, 'channels_save_error', true)) .finally(() => { if (btn) btn.disabled = false; }); } function disconnectChannel(chName) { const ch = channelsData.find(c => c.name === chName); const label = ch ? ((typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label) : chName; showConfirmDialog({ title: t('channels_disconnect'), message: t('channels_disconnect_confirm'), okText: t('channels_disconnect'), cancelText: t('channels_cancel'), onConfirm: () => { fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'disconnect', channel: chName }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { if (ch) ch.active = false; renderActiveChannels(); } }) .catch(() => {}); } }); } // --- Add channel panel --- function openAddChannelPanel() { const panel = document.getElementById('channels-add-panel'); const activeNames = new Set(channelsData.filter(c => c.active).map(c => c.name)); const available = channelsData.filter(c => !activeNames.has(c.name)); const content = document.getElementById('channels-content'); if (activeNames.size === 0 && content) content.classList.add('hidden'); if (available.length === 0) { panel.innerHTML = `

${currentLang === 'zh' ? '所有通道均已接入' : 'All channels are already connected'}

`; panel.classList.remove('hidden'); return; } const ddOptions = [ { value: '', label: t('channels_select_placeholder') }, ...available.map(ch => { const label = (typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label; return { value: ch.name, label: `${label} (${ch.name})` }; }) ]; panel.innerHTML = `

${t('channels_add')}

--
`; panel.classList.remove('hidden'); panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); const ddEl = document.getElementById('add-channel-select'); initDropdown(ddEl, ddOptions, '', onAddChannelSelect); } function closeAddChannelPanel() { stopWeixinQrPoll(); const panel = document.getElementById('channels-add-panel'); if (panel) { panel.classList.add('hidden'); panel.innerHTML = ''; } const content = document.getElementById('channels-content'); if (content) content.classList.remove('hidden'); } function onAddChannelSelect(chName) { stopWeixinQrPoll(); const fieldsContainer = document.getElementById('add-channel-fields'); const actions = document.getElementById('add-channel-actions'); if (!chName) { fieldsContainer.innerHTML = ''; actions.classList.add('hidden'); return; } if (chName === 'weixin') { actions.classList.add('hidden'); fieldsContainer.innerHTML = `

${t('weixin_scan_loading')}

`; startWeixinQrLogin(); return; } if (chName === 'wecom_bot') { actions.classList.add('hidden'); const ch = channelsData.find(c => c.name === chName); fieldsContainer.innerHTML = buildWecomBotPanel(ch); return; } const ch = channelsData.find(c => c.name === chName); if (!ch) return; fieldsContainer.innerHTML = buildChannelFieldsHtml(chName, ch.fields || []); bindSecretFieldEvents(fieldsContainer); actions.classList.remove('hidden'); } function submitAddChannel() { const ddEl = document.getElementById('add-channel-select'); const chName = getDropdownValue(ddEl); if (!chName) return; const fieldsContainer = document.getElementById('add-channel-fields'); const updates = {}; fieldsContainer.querySelectorAll('input[data-ch="' + chName + '"]').forEach(inp => { const key = inp.dataset.field; if (inp.type === 'checkbox') { updates[key] = inp.checked; } else { if (inp.dataset.masked === '1') return; updates[key] = inp.value; } }); const btn = document.getElementById('add-channel-submit'); if (btn) { btn.disabled = true; btn.textContent = t('channels_connecting'); } fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'connect', channel: chName, config: updates }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { const ch = channelsData.find(c => c.name === chName); if (ch) { ch.active = true; (ch.fields || []).forEach(f => { if (updates[f.key] !== undefined) { f.value = f.type === 'secret' ? ChannelsHandler_maskSecret(updates[f.key]) : updates[f.key]; } }); } renderActiveChannels(); } else { if (btn) { btn.disabled = false; btn.textContent = t('channels_connect_btn'); } } }) .catch(() => { if (btn) { btn.disabled = false; btn.textContent = t('channels_connect_btn'); } }); } // ===================================================================== // WeChat QR Login // ===================================================================== let _weixinQrPollTimer = null; let _weixinStatusPollTimer = null; function stopWeixinStatusPoll() { if (_weixinStatusPollTimer) { clearTimeout(_weixinStatusPollTimer); _weixinStatusPollTimer = null; } } function startWeixinActiveStatusPoll() { stopWeixinStatusPoll(); _weixinStatusPollTimer = setTimeout(() => { fetch('/api/channels').then(r => r.json()).then(data => { if (data.status !== 'success') return; const wx = (data.channels || []).find(c => c.name === 'weixin'); if (!wx || !wx.active) return; if (wx.login_status === 'logged_in') { channelsData = data.channels; renderActiveChannels(); } else { const ch = channelsData.find(c => c.name === 'weixin'); if (ch) ch.login_status = wx.login_status; startWeixinActiveStatusPoll(); } }).catch(() => { startWeixinActiveStatusPoll(); }); }, 3000); } function showWeixinActiveQr() { const container = document.getElementById('weixin-active-qr'); if (!container) return; container.innerHTML = `

${t('weixin_scan_loading')}

`; stopWeixinStatusPoll(); startWeixinQrLogin(); } function stopWeixinQrPoll() { if (_weixinQrPollTimer) { clearTimeout(_weixinQrPollTimer); _weixinQrPollTimer = null; } } function startWeixinQrLogin() { stopWeixinQrPoll(); fetch('/api/weixin/qrlogin') .then(r => r.json()) .then(data => { const panel = document.getElementById('weixin-qr-panel'); if (!panel) return; if (data.status !== 'success') { panel.innerHTML = `

${t('weixin_scan_fail')}: ${data.message || ''}

`; return; } renderWeixinQr(data.qr_image || data.qrcode_url, 'waiting'); if (data.source === 'channel') { startWeixinActiveStatusPoll(); } else { pollWeixinQrStatus(); } }) .catch(() => { const panel = document.getElementById('weixin-qr-panel'); if (panel) panel.innerHTML = `

${t('weixin_scan_fail')}

`; }); } function renderWeixinQr(qrcodeUrl, status) { const panel = document.getElementById('weixin-qr-panel'); if (!panel) return; let statusText = t('weixin_scan_waiting'); let statusColor = 'text-slate-500 dark:text-slate-400'; if (status === 'scanned') { statusText = t('weixin_scan_scanned'); statusColor = 'text-primary-500'; } else if (status === 'expired') { statusText = t('weixin_scan_expired'); statusColor = 'text-amber-500'; } else if (status === 'confirmed') { statusText = t('weixin_scan_success'); statusColor = 'text-primary-500'; } panel.innerHTML = `

${t('weixin_scan_title')}

${t('weixin_scan_desc')}

QR Code

${statusText}

${t('weixin_qr_tip')}

`; } function pollWeixinQrStatus() { _weixinQrPollTimer = setTimeout(() => { fetch('/api/weixin/qrlogin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'poll' }) }) .then(r => r.json()) .then(data => { const panel = document.getElementById('weixin-qr-panel'); if (!panel) { stopWeixinQrPoll(); return; } if (data.status !== 'success') { pollWeixinQrStatus(); return; } const qrStatus = data.qr_status; if (qrStatus === 'confirmed') { renderWeixinQr('', 'confirmed'); panel.innerHTML = `

${t('weixin_scan_success')}

`; connectWeixinAfterQr(); } else if (qrStatus === 'expired' && (data.qr_image || data.qrcode_url)) { renderWeixinQr(data.qr_image || data.qrcode_url, 'waiting'); pollWeixinQrStatus(); } else if (qrStatus === 'scaned') { const img = panel.querySelector('img'); const currentSrc = img ? img.src : ''; renderWeixinQr(currentSrc, 'scanned'); pollWeixinQrStatus(); } else { pollWeixinQrStatus(); } }) .catch(() => { pollWeixinQrStatus(); }); }, 2000); } function connectWeixinAfterQr() { fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'connect', channel: 'weixin', config: {} }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { const ch = channelsData.find(c => c.name === 'weixin'); if (ch) ch.active = true; setTimeout(() => renderActiveChannels(), 1500); } }) .catch(() => {}); } // ===================================================================== // WeCom Bot QR Auth // ===================================================================== const WECOM_BOT_SDK_URL = 'https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js'; const WECOM_BOT_SOURCE = 'cowagent'; let _wecomSdkLoaded = false; function ensureWecomSdkLoaded() { return new Promise((resolve, reject) => { if (_wecomSdkLoaded && window.WecomAIBotSDK) { resolve(); return; } if (document.querySelector(`script[src="${WECOM_BOT_SDK_URL}"]`)) { _wecomSdkLoaded = true; resolve(); return; } const s = document.createElement('script'); s.src = WECOM_BOT_SDK_URL; s.onload = () => { _wecomSdkLoaded = true; resolve(); }; s.onerror = () => reject(new Error('Failed to load WecomAIBotSDK')); document.head.appendChild(s); }); } function _wecomBotHasCreds(ch) { if (!ch || !ch.fields) return false; const idField = ch.fields.find(f => f.key === 'wecom_bot_id'); const secretField = ch.fields.find(f => f.key === 'wecom_bot_secret'); return !!(idField && idField.value && secretField && secretField.value); } function buildWecomBotPanel(ch) { const scanLabel = t('wecom_mode_scan'); const manualLabel = t('wecom_mode_manual'); const hasCreds = _wecomBotHasCreds(ch); const defaultMode = hasCreds ? 'manual' : 'scan'; return `
`; } function switchWecomBotMode(mode) { const scanTab = document.getElementById('wecom-tab-scan'); const manualTab = document.getElementById('wecom-tab-manual'); const content = document.getElementById('wecom-mode-content'); const actions = document.getElementById('add-channel-actions'); if (!scanTab || !manualTab || !content) return; const activeClasses = 'bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm'; const inactiveClasses = 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'; if (mode === 'scan') { scanTab.className = scanTab.className.replace(/text-slate-500[^\s]*/g, '').replace(/hover:\S+/g, ''); scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`; manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`; actions.classList.add('hidden'); content.innerHTML = `

${t('wecom_scan_desc')}

`; } else { manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`; scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`; const ch = channelsData.find(c => c.name === 'wecom_bot'); content.innerHTML = `
${buildChannelFieldsHtml('wecom_bot', ch ? ch.fields || [] : [])}
`; bindSecretFieldEvents(content); actions.classList.remove('hidden'); } } function startWecomBotAuth() { const statusEl = document.getElementById('wecom-scan-status'); ensureWecomSdkLoaded().then(() => { WecomAIBotSDK.openBotInfoAuthWindow({ source: WECOM_BOT_SOURCE, onCreated: function(bot) { if (statusEl) { statusEl.innerHTML = `

${t('wecom_scan_success')}

`; } connectWecomBotAfterAuth(bot.botid, bot.secret); }, onError: function(err) { if (statusEl) { statusEl.innerHTML = `

${t('wecom_scan_fail')}: ${err.message || err.code || ''}

`; } } }); }).catch(err => { if (statusEl) { statusEl.innerHTML = `

SDK load failed: ${err.message}

`; } }); } function connectWecomBotAfterAuth(botId, secret) { fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'connect', channel: 'wecom_bot', config: { wecom_bot_id: botId, wecom_bot_secret: secret } }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { const ch = channelsData.find(c => c.name === 'wecom_bot'); if (ch) { ch.active = true; (ch.fields || []).forEach(f => { if (f.key === 'wecom_bot_id') f.value = botId; if (f.key === 'wecom_bot_secret') f.value = ChannelsHandler_maskSecret(secret); }); } setTimeout(() => renderActiveChannels(), 1500); } }) .catch(() => {}); } function startWecomBotAuthInCard() { const statusEl = document.getElementById('wecom-card-scan-status'); ensureWecomSdkLoaded().then(() => { WecomAIBotSDK.openBotInfoAuthWindow({ source: WECOM_BOT_SOURCE, onCreated: function(bot) { if (statusEl) { statusEl.innerHTML = `

${t('wecom_scan_success')}

`; } connectWecomBotAfterAuth(bot.botid, bot.secret); }, onError: function(err) { if (statusEl) { statusEl.innerHTML = `

${t('wecom_scan_fail')}: ${err.message || err.code || ''}

`; } } }); }).catch(err => { if (statusEl) { statusEl.innerHTML = `

SDK load failed: ${err.message}

`; } }); } // Initialize wecom bot panel with correct default mode when inserted into DOM document.addEventListener('DOMContentLoaded', function() { const observer = new MutationObserver(function() { const panel = document.getElementById('wecom-bot-panel'); if (panel && !panel.dataset.initialized) { panel.dataset.initialized = '1'; switchWecomBotMode(panel.dataset.defaultMode || 'scan'); } }); observer.observe(document.body, { childList: true, subtree: true }); }); // ===================================================================== // 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 === 'config') loadConfigView(); else 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 === 'knowledge') loadKnowledgeView(); else if (viewId === 'channels') loadChannelsView(); else if (viewId === 'tasks') loadTasksView(); else if (viewId === 'logs') startLogStream(); }; // ===================================================================== // Knowledge View // ===================================================================== let _knowledgeTreeData = []; let _knowledgeCurrentFile = null; let _knowledgeGraphLoaded = false; function loadKnowledgeView() { // Reset to docs tab switchKnowledgeTab('docs'); _knowledgeGraphLoaded = false; _knowledgeCurrentFile = null; fetch('/api/knowledge/list').then(r => r.json()).then(data => { if (data.status !== 'success') return; const emptyEl = document.getElementById('knowledge-empty'); const docsPanel = document.getElementById('knowledge-panel-docs'); const statsEl = document.getElementById('knowledge-stats'); const tree = data.tree || []; _knowledgeTreeData = tree; const stats = data.stats || {}; const totalPages = stats.pages || 0; const sizeStr = stats.size < 1024 ? stats.size + ' B' : (stats.size / 1024).toFixed(1) + ' KB'; statsEl.textContent = totalPages + ' pages · ' + sizeStr; if (totalPages === 0) { emptyEl.querySelector('p').textContent = t('knowledge_empty_hint'); const guideEl = document.getElementById('knowledge-empty-guide'); if (guideEl) guideEl.classList.remove('hidden'); emptyEl.classList.remove('hidden'); docsPanel.classList.add('hidden'); return; } emptyEl.classList.add('hidden'); docsPanel.classList.remove('hidden'); renderKnowledgeTree(tree); // Auto-select the first file (desktop only) if (window.innerWidth >= 768) { const firstGroup = tree.find(g => g.files && g.files.length > 0); if (firstGroup) { const firstFile = firstGroup.files[0]; openKnowledgeFile(firstGroup.dir + '/' + firstFile.name, firstFile.title); } } else { document.getElementById('knowledge-content-placeholder').classList.add('hidden'); document.getElementById('knowledge-content-viewer').classList.add('hidden'); } }).catch(() => {}); } function renderKnowledgeTree(tree, filter) { const container = document.getElementById('knowledge-tree'); container.innerHTML = ''; const lowerFilter = (filter || '').toLowerCase(); tree.forEach(group => { const files = group.files.filter(f => !lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter) ); if (files.length === 0 && lowerFilter) return; const div = document.createElement('div'); div.className = 'knowledge-tree-group open'; const btn = document.createElement('button'); btn.className = 'knowledge-tree-group-btn'; btn.innerHTML = `${escapeHtml(group.dir)}${files.length}`; btn.onclick = () => div.classList.toggle('open'); div.appendChild(btn); const items = document.createElement('div'); items.className = 'knowledge-tree-group-items'; files.forEach(f => { const fbtn = document.createElement('button'); const fpath = group.dir + '/' + f.name; fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : ''); fbtn.dataset.path = fpath; fbtn.innerHTML = `${escapeHtml(f.title)}`; fbtn.onclick = () => openKnowledgeFile(fpath, f.title); items.appendChild(fbtn); }); div.appendChild(items); container.appendChild(div); }); } function filterKnowledgeTree(query) { renderKnowledgeTree(_knowledgeTreeData, query); } function resolveKnowledgePath(currentFilePath, relativeHref) { // currentFilePath: e.g. "concepts/mcp-protocol.md" // relativeHref: e.g. "../entities/openai.md" const parts = currentFilePath.split('/'); parts.pop(); // remove filename, keep directory const segments = [...parts, ...relativeHref.split('/')]; const resolved = []; for (const seg of segments) { if (seg === '..') resolved.pop(); else if (seg !== '.' && seg !== '') resolved.push(seg); } return resolved.join('/'); } function bindKnowledgeLinks(container, currentFilePath) { container.querySelectorAll('a').forEach(a => { const href = a.getAttribute('href'); if (!href || !href.endsWith('.md')) return; // Skip absolute URLs if (/^https?:\/\//.test(href)) return; a.addEventListener('click', (e) => { e.preventDefault(); const resolved = resolveKnowledgePath(currentFilePath, href); const linkTitle = a.textContent.trim() || resolved.replace(/\.md$/, '').split('/').pop(); openKnowledgeFile(resolved, linkTitle); }); a.style.cursor = 'pointer'; a.classList.add('text-primary-500', 'hover:underline'); }); } function bindChatKnowledgeLinks(container) { if (!container) return; container.querySelectorAll('a').forEach(a => { const href = a.getAttribute('href'); if (!href || !href.endsWith('.md')) return; if (/^https?:\/\//.test(href)) return; // Determine knowledge path let knowledgePath = null; if (href.startsWith('knowledge/')) { // Full path from workspace root: knowledge/concepts/moe.md knowledgePath = href.replace(/^knowledge\//, ''); } else if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(href)) { // Looks like category/file.md pattern without knowledge/ prefix knowledgePath = href; } else if (href.includes('/') && !href.startsWith('/')) { // Relative path like ../entities/deepseek.md — extract filename and search const filename = href.split('/').pop(); knowledgePath = '__search__:' + filename; } if (!knowledgePath) return; a.addEventListener('click', (e) => { e.preventDefault(); if (knowledgePath.startsWith('__search__:')) { const filename = knowledgePath.replace('__search__:', ''); // Find the file in cached tree data const found = _findKnowledgeFileByName(filename); if (found) { navigateTo('knowledge'); setTimeout(() => openKnowledgeFile(found.path, found.title), 100); } } else { navigateTo('knowledge'); const linkTitle = a.textContent.trim() || knowledgePath.replace(/\.md$/, '').split('/').pop(); setTimeout(() => openKnowledgeFile(knowledgePath, linkTitle), 100); } }); a.style.cursor = 'pointer'; a.classList.add('text-primary-500', 'hover:underline'); }); } function _findKnowledgeFileByName(filename) { for (const group of _knowledgeTreeData) { for (const f of group.files) { if (f.name === filename) { return { path: group.dir + '/' + f.name, title: f.title }; } } } return null; } function openKnowledgeFile(path, title) { _knowledgeCurrentFile = path; // Update active state in tree via data-path document.querySelectorAll('.knowledge-tree-file').forEach(el => { el.classList.toggle('active', el.dataset.path === path); }); // Immediately hide placeholder document.getElementById('knowledge-content-placeholder').classList.add('hidden'); fetch(`/api/knowledge/read?path=${encodeURIComponent(path)}`).then(r => r.json()).then(data => { if (data.status !== 'success') return; const viewer = document.getElementById('knowledge-content-viewer'); document.getElementById('knowledge-viewer-title').textContent = title; document.getElementById('knowledge-viewer-path').textContent = path; const bodyEl = document.getElementById('knowledge-viewer-body'); bodyEl.innerHTML = renderMarkdown(data.content || ''); viewer.classList.remove('hidden'); applyHighlighting(viewer); bindKnowledgeLinks(bodyEl, path); // Mobile: hide sidebar, show content if (window.innerWidth < 768) { document.getElementById('knowledge-sidebar').classList.add('hidden'); } }).catch(() => {}); } function knowledgeMobileBack() { document.getElementById('knowledge-sidebar').classList.remove('hidden'); document.getElementById('knowledge-content-viewer').classList.add('hidden'); } function switchKnowledgeTab(tab) { document.querySelectorAll('.knowledge-tab').forEach(el => el.classList.remove('active')); document.getElementById('knowledge-tab-' + tab).classList.add('active'); const docsPanel = document.getElementById('knowledge-panel-docs'); const graphPanel = document.getElementById('knowledge-panel-graph'); if (tab === 'docs') { docsPanel.classList.remove('hidden'); graphPanel.classList.add('hidden'); } else { docsPanel.classList.add('hidden'); graphPanel.classList.remove('hidden'); if (!_knowledgeGraphLoaded) { loadKnowledgeGraph(); } } } function loadKnowledgeGraph() { _knowledgeGraphLoaded = true; const container = document.getElementById('knowledge-graph-container'); container.innerHTML = ''; fetch('/api/knowledge/graph').then(r => r.json()).then(data => { const nodes = data.nodes || []; const links = data.links || []; if (nodes.length === 0) { container.innerHTML = `

${t('knowledge_empty_hint')}

`; return; } renderKnowledgeGraph(container, nodes, links); }).catch(() => { container.innerHTML = '
Failed to load graph
'; }); } function renderKnowledgeGraph(container, nodes, links) { const width = container.clientWidth; const height = container.clientHeight || 600; const categories = [...new Set(nodes.map(n => n.category))]; const colorScale = d3.scaleOrdinal(d3.schemeTableau10).domain(categories); // Connection count for sizing const connCount = {}; nodes.forEach(n => connCount[n.id] = 0); links.forEach(l => { connCount[l.source] = (connCount[l.source] || 0) + 1; connCount[l.target] = (connCount[l.target] || 0) + 1; }); const svg = d3.select(container) .append('svg') .attr('width', width) .attr('height', height); const g = svg.append('g'); // Zoom with adaptive label visibility let currentZoomScale = 1; const zoom = d3.zoom() .scaleExtent([0.2, 5]) .on('zoom', (event) => { g.attr('transform', event.transform); currentZoomScale = event.transform.k; updateLabelVisibility(); }); svg.call(zoom); function updateLabelVisibility() { if (!label) return; if (currentZoomScale < 0.8) { label.attr('opacity', 0); } else { const baseFontSize = Math.min(12, 10 / Math.max(currentZoomScale * 0.7, 0.5)); label.attr('opacity', 1).attr('font-size', baseFontSize); } } const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(links).id(d => d.id).distance(90)) .force('charge', d3.forceManyBody().strength(-180)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('x', d3.forceX(width / 2).strength(0.06)) .force('y', d3.forceY(height / 2).strength(0.06)) .force('collision', d3.forceCollide().radius(d => getNodeRadius(d) + 30)); function getNodeRadius(d) { return Math.max(5, Math.min(16, 5 + (connCount[d.id] || 0) * 2)); } const link = g.append('g') .selectAll('line') .data(links) .join('line') .attr('stroke', '#94a3b8') .attr('stroke-opacity', 0.3) .attr('stroke-width', 1); const node = g.append('g') .selectAll('circle') .data(nodes) .join('circle') .attr('r', d => getNodeRadius(d)) .attr('fill', d => colorScale(d.category)) .attr('stroke', '#fff') .attr('stroke-width', 1.5) .style('cursor', 'pointer') .call(d3.drag() .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }) ); const label = g.append('g') .selectAll('text') .data(nodes) .join('text') .text(d => d.label.length > 15 ? d.label.slice(0, 14) + '…' : d.label) .attr('font-size', 9) .attr('dx', d => getNodeRadius(d) + 4) .attr('dy', 3) .attr('fill', '#64748b') .style('pointer-events', 'none'); // Tooltip const tooltip = document.createElement('div'); tooltip.className = 'knowledge-graph-tooltip'; container.style.position = 'relative'; container.appendChild(tooltip); node.on('mouseover', (event, d) => { tooltip.textContent = d.label + ' (' + d.category + ')'; tooltip.style.opacity = '1'; tooltip.style.left = (event.offsetX + 12) + 'px'; tooltip.style.top = (event.offsetY - 8) + 'px'; // Highlight connections link.attr('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 0.8 : 0.1); node.attr('opacity', n => n.id === d.id || links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.2); label.attr('opacity', n => n.id === d.id || links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.1); }).on('mousemove', (event) => { tooltip.style.left = (event.offsetX + 12) + 'px'; tooltip.style.top = (event.offsetY - 8) + 'px'; }).on('mouseout', () => { tooltip.style.opacity = '0'; link.attr('stroke-opacity', 0.3); node.attr('opacity', 1); label.attr('opacity', 1); }).on('click', (event, d) => { // Switch to docs tab and open the file switchKnowledgeTab('docs'); openKnowledgeFile(d.id, d.label); }); simulation.on('tick', () => { link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) .attr('x2', d => d.target.x).attr('y2', d => d.target.y); node.attr('cx', d => d.x).attr('cy', d => d.y); label.attr('x', d => d.x).attr('y', d => d.y); }); // Auto fit-to-view when simulation settles simulation.on('end', () => { const pad = 16; let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; nodes.forEach(n => { if (n.x < x0) x0 = n.x; if (n.y < y0) y0 = n.y; if (n.x > x1) x1 = n.x; if (n.y > y1) y1 = n.y; }); const bw = x1 - x0 + pad * 2; const bh = y1 - y0 + pad * 2; if (bw > 0 && bh > 0) { const scale = Math.min(width / bw, height / bh, 4); const tx = width / 2 - (x0 + x1) / 2 * scale; const ty = height / 2 - (y0 + y1) / 2 * scale; svg.transition().duration(500).call( zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale) ); } }); // Legend const legendDiv = document.createElement('div'); legendDiv.className = 'knowledge-graph-legend'; categories.forEach(cat => { const item = document.createElement('span'); item.className = 'knowledge-graph-legend-item'; item.innerHTML = `${escapeHtml(cat)}`; legendDiv.appendChild(item); }); container.appendChild(legendDiv); } // ===================================================================== // Authentication // ===================================================================== function toggleLoginPassword() { const input = document.getElementById('login-password'); const icon = document.querySelector('#login-toggle-pwd i'); if (input.type === 'password') { input.type = 'text'; icon.classList.replace('fa-eye', 'fa-eye-slash'); } else { input.type = 'password'; icon.classList.replace('fa-eye-slash', 'fa-eye'); } } window.toggleLoginPassword = toggleLoginPassword; function showLoginScreen() { const overlay = document.getElementById('login-overlay'); if (!overlay) return; overlay.classList.remove('hidden'); document.getElementById('app').classList.add('hidden'); const subtitle = document.getElementById('login-subtitle'); const loginBtn = document.getElementById('login-btn'); if (currentLang === 'en') { subtitle.textContent = 'Enter password to access the console'; loginBtn.textContent = 'Login'; } else { subtitle.textContent = '请输入密码以访问控制台'; loginBtn.textContent = '登录'; } const form = document.getElementById('login-form'); const pwdInput = document.getElementById('login-password'); pwdInput.focus(); form.onsubmit = function(e) { e.preventDefault(); const pwd = pwdInput.value; if (!pwd) return; const btn = document.getElementById('login-btn'); const errEl = document.getElementById('login-error'); btn.disabled = true; errEl.classList.add('hidden'); fetch('/auth/login', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password: pwd}) }).then(r => r.json()).then(data => { if (data.status === 'success') { overlay.classList.add('hidden'); document.getElementById('app').classList.remove('hidden'); initApp(); } else { errEl.textContent = currentLang === 'zh' ? '密码错误' : 'Wrong password'; errEl.classList.remove('hidden'); pwdInput.value = ''; pwdInput.focus(); } btn.disabled = false; }).catch(() => { errEl.textContent = currentLang === 'zh' ? '网络错误,请重试' : 'Network error, please retry'; errEl.classList.remove('hidden'); btn.disabled = false; }); return false; }; } // Intercept 401 responses globally to show login screen on session expiry const _originalFetch = window.fetch; window.fetch = function(...args) { return _originalFetch.apply(this, args).then(response => { if (response.status === 401) { const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); if (!url.startsWith('/auth/')) { showLoginScreen(); } } return response; }); }; function initApp() { fetch('/api/knowledge/list').then(r => r.json()).then(data => { if (data.status === 'success') _knowledgeTreeData = data.tree || []; }).catch(() => {}); fetch('/api/version').then(r => r.json()).then(data => { APP_VERSION = `v${data.version}`; document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; }).catch(() => { document.getElementById('sidebar-version').textContent = 'CowAgent'; }); chatInput.focus(); } // ===================================================================== // Initialization // ===================================================================== applyTheme(); applyI18n(); fetch('/auth/check').then(r => r.json()).then(data => { if (data.auth_required && !data.authenticated) { showLoginScreen(); } else { initApp(); } }).catch(() => { initApp(); }); requestAnimationFrame(() => { document.body.classList.add('transition-colors', 'duration-200'); });