/* ===================================================================== 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_models: '模型', menu_skills: '技能', menu_memory: '记忆', menu_knowledge: '知识', menu_channels: '通道', menu_tasks: '定时', menu_logs: '日志', models_title: '模型管理', models_desc: '统一管理对话、视觉、语音、向量、图像、搜索能力', models_section_vendors: '厂商凭据', models_section_vendors_desc: '一处配置,多个能力共享', models_section_capabilities: '模型能力', models_add_vendor: '添加厂商', models_provider: '厂商', models_model: '模型', models_configured: '已配置', models_not_configured: '未配置', models_pick_to_configure: '选择以配置', models_clear_credential: '清除凭据', models_base_default_hint: '留空将使用官方默认地址', models_base_default: '默认', models_capability_chat: '主模型', models_capability_chat_desc: '驱动对话与 Agent 推理', models_capability_vision: '图像理解', models_capability_vision_desc: '识别图片内容;未指定时自动跟随主模型', models_capability_image: '图像生成', models_capability_image_desc: '生成图片,可固定厂商或跟随主模型', models_auto_using: '当前优先使用', models_capability_asr: '语音识别', models_capability_asr_desc: '语音转文字', models_capability_tts: '语音合成', models_capability_tts_desc: '文字转语音', models_capability_embedding: '向量', models_capability_embedding_desc: '记忆与知识的向量化', models_capability_search: '联网搜索', models_capability_search_desc: '实时网页检索能力', models_strategy_auto: '自动', models_unavailable: '不可用', models_set_via_env: '通过环境变量启用', models_dim_label: '维度', models_save_success: '已保存', models_save_failed: '保存失败', models_cleared: '已清除', models_clear_failed: '清除失败', models_embedding_change_title: '更改向量模型', models_embedding_change_msg: '切换向量模型后,已有索引将失效,需要重建。是否继续?', models_embedding_saved_title: '向量模型已更新', models_embedding_saved_msg: '请在聊天框输入 /memory rebuild-index 重建索引。', models_embedding_saved_ok: '去执行', models_clear_confirm_title: '清除厂商凭据', models_clear_confirm_msg: '确认清除该厂商的 API Key 与 Base URL 吗?相关能力将不再可用。', cancel: '取消', save: '保存', ok: '确定', 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_enable_thinking: '深度思考', config_enable_thinking_hint: '是否启用深度思考模式', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', config_custom_model_hint: '输入自定义模型名称', config_save: '保存', config_saved: '已保存', config_save_error: '保存失败', config_custom_option: '自定义...', config_custom_tip: '接口需遵循 OpenAI API 协议', 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_tab_files: '记忆文件', memory_tab_dreams: '梦境日记', 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: '手动填写', feishu_scan_btn: '一键创建飞书应用', feishu_scan_desc: '使用飞书 App 扫码,自动创建应用并预置全部权限与事件订阅', feishu_scan_replace_desc: '使用飞书 App 扫码创建新机器人,将覆盖当前的 App ID / Secret', feishu_scan_loading: '正在向飞书申请二维码...', feishu_scan_waiting: '等待扫码...', feishu_scan_tip: '二维码 10 分钟内有效,仅供一次扫描', feishu_scan_open_link: '或点击此处在浏览器中打开', feishu_scan_success: '应用创建成功,正在启动通道...', feishu_scan_expired: '二维码已过期,请重试', feishu_scan_denied: '已取消授权', feishu_scan_fail: '创建失败', feishu_scan_retry: '重试', feishu_mode_scan: '扫码创建', feishu_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 的实时输出。', new_chat: '新对话', session_history: '历史会话', today: '今天', yesterday: '昨天', earlier: '更早', delete_session_confirm: '确认删除该会话?所有消息将被清除。', delete_session_title: '删除会话', untitled_session: '新对话', context_cleared: '— 以上内容已从上下文中移除 —', tip_new_chat: '新建对话', tip_clear_context: '清除上下文', tip_attach: '添加附件', attach_menu_file: '上传文件', attach_menu_folder: '上传文件夹', confirm_yes: '确认', confirm_cancel: '取消', error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。', thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时', }, en: { console: 'Console', nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor', menu_chat: 'Chat', menu_config: 'Config', menu_models: 'Models', menu_skills: 'Skills', menu_memory: 'Memory', menu_knowledge: 'Knowledge', menu_channels: 'Channels', menu_tasks: 'Tasks', menu_logs: 'Logs', models_title: 'Models', models_desc: 'Manage chat, vision, voice, embedding, image and search capabilities in one place', models_section_vendors: 'Vendor Credentials', models_section_vendors_desc: 'Configured once, shared by every capability', models_section_capabilities: 'Capabilities', models_add_vendor: 'Add Vendor', models_provider: 'Provider', models_model: 'Model', models_configured: 'configured', models_not_configured: 'not configured', models_pick_to_configure: 'pick to configure', models_clear_credential: 'Clear credentials', models_base_default_hint: 'Leave blank to use the official default base URL', models_base_default: 'Default', models_capability_chat: 'Main Model', models_capability_chat_desc: 'Powers chat and agent reasoning', models_capability_vision: 'Image Understanding', models_capability_vision_desc: 'Reads images; auto-follows main model when unspecified', models_capability_image: 'Image Generation', models_capability_image_desc: 'Generate images; pin a vendor or follow the main model', models_auto_using: 'Preferred', models_capability_asr: 'Speech Recognition', models_capability_asr_desc: 'Voice to text', models_capability_tts: 'Speech Synthesis', models_capability_tts_desc: 'Text to voice', models_capability_embedding: 'Embedding', models_capability_embedding_desc: 'Vectorizes memory and knowledge', models_capability_search: 'Web Search', models_capability_search_desc: 'Real-time web retrieval', models_strategy_auto: 'auto', models_unavailable: 'unavailable', models_set_via_env: 'enable via environment variable', models_dim_label: 'dim', models_save_success: 'Saved', models_save_failed: 'Save failed', models_cleared: 'Cleared', models_clear_failed: 'Clear failed', models_embedding_change_title: 'Change embedding model', models_embedding_change_msg: 'Switching the embedding model invalidates the existing index — a rebuild will be needed. Continue?', models_embedding_saved_title: 'Embedding model updated', models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.', models_embedding_saved_ok: 'Go', models_clear_confirm_title: 'Clear vendor credentials', models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.', cancel: 'Cancel', save: 'Save', ok: 'OK', 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_enable_thinking: 'Deep Thinking', config_enable_thinking_hint: 'Enable deep thinking mode', 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_custom_tip: 'API must follow OpenAI protocol.', 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 tools and 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_tab_files: 'Memory Files', memory_tab_dreams: 'Dream Diary', 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', feishu_scan_btn: 'One-click Create Feishu App', feishu_scan_desc: 'Scan with Feishu App to create an app with all required permissions pre-configured', feishu_scan_replace_desc: 'Scan with Feishu App to create a new bot — will overwrite the current App ID / Secret', feishu_scan_loading: 'Requesting QR code from Feishu...', feishu_scan_waiting: 'Waiting for scan...', feishu_scan_tip: 'QR code expires in 10 minutes, single use only', feishu_scan_open_link: 'Or click here to open in browser', feishu_scan_success: 'App created, starting channel...', feishu_scan_expired: 'QR code expired, please retry', feishu_scan_denied: 'Authorization cancelled', feishu_scan_fail: 'App creation failed', feishu_scan_retry: 'Retry', feishu_mode_scan: 'Scan QR', feishu_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.', new_chat: 'New Chat', session_history: 'History', today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier', delete_session_confirm: 'Delete this session? All messages will be removed.', delete_session_title: 'Delete Session', untitled_session: 'New Chat', context_cleared: '— Context above has been cleared —', tip_new_chat: 'New Chat', tip_clear_context: 'Clear Context', tip_attach: 'Add Attachment', attach_menu_file: 'Upload File', attach_menu_folder: 'Upload Folder', confirm_yes: 'Confirm', confirm_cancel: 'Cancel', error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.', thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration', } }; 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)); }); installCfgTipPortal(); const langLabel = document.getElementById('lang-label'); if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN'; } function toggleLanguage() { currentLang = currentLang === 'zh' ? 'en' : 'zh'; localStorage.setItem('cow_lang', currentLang); applyI18n(); _applyInputTooltips(); } // Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are // appended to so they aren't clipped by overflow:hidden ancestors // (e.g. the config panel's scroll container). let _cfgTipPortalEl = null; let _cfgTipPortalInstalled = false; function installCfgTipPortal() { if (_cfgTipPortalInstalled) return; _cfgTipPortalInstalled = true; const showTip = (target) => { const text = target.getAttribute('data-tooltip'); if (!text) return; if (!_cfgTipPortalEl) { _cfgTipPortalEl = document.createElement('div'); _cfgTipPortalEl.className = 'cfg-tip-floating'; document.body.appendChild(_cfgTipPortalEl); } _cfgTipPortalEl.textContent = text; const rect = target.getBoundingClientRect(); // Render once to measure, then position above the target, centered. _cfgTipPortalEl.style.left = '0px'; _cfgTipPortalEl.style.top = '0px'; _cfgTipPortalEl.classList.add('show'); const tipRect = _cfgTipPortalEl.getBoundingClientRect(); let left = rect.left + rect.width / 2 - tipRect.width / 2; // Clamp horizontally to the viewport with an 8px gutter. left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8)); const top = rect.top - tipRect.height - 6; _cfgTipPortalEl.style.left = left + 'px'; _cfgTipPortalEl.style.top = top + 'px'; }; const hideTip = () => { if (_cfgTipPortalEl) _cfgTipPortalEl.classList.remove('show'); }; document.addEventListener('mouseover', (e) => { const target = e.target.closest('[data-tip-key]'); if (target) showTip(target); }); document.addEventListener('mouseout', (e) => { const target = e.target.closest('[data-tip-key]'); if (target) hideTip(); }); // Hide on scroll/resize so the tooltip doesn't drift away from its anchor. window.addEventListener('scroll', hideTip, true); window.addEventListener('resize', hideTip); } // ===================================================================== // 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' }, models: { group: 'nav_manage', page: 'menu_models' }, 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 // ===================================================================== const FALLBACK_HLJS = { getLanguage() { return false; }, highlight(str) { return { value: escapeHtml(str) }; }, highlightAuto(str) { return { value: escapeHtml(str) }; }, highlightElement() {}, }; function getHljs() { return window.hljs || FALLBACK_HLJS; } function createMd() { const hljsLib = getHljs(); const mdFactory = window.markdownit; if (typeof mdFactory !== 'function') { return { render(text) { return `

${escapeHtml(text || '')}

`; } }; } const md = mdFactory({ html: false, breaks: true, linkify: true, typographer: true, highlight: function(str, lang) { if (lang && hljsLib.getLanguage(lang)) { try { return hljsLib.highlight(str, { language: lang }).value; } catch (_) {} } return hljsLib.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 const IMAGE_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|bmp|svg)$/i; // tested against URL without query string function _toWebUrl(url) { if (/^\/[A-Za-z]/.test(url) && !url.startsWith('/api/')) { return '/api/file?path=' + encodeURIComponent(url); } if (/^file:\/\/\//i.test(url)) { return '/api/file?path=' + encodeURIComponent(url.replace(/^file:\/\/\//i, '/')); } return url; } function _buildVideoHtml(url) { const webUrl = _toWebUrl(url); const fileName = url.split('/').pop().split('?')[0]; return `
` + `` + `` + ` ${escapeHtml(fileName)}
`; } function _openImageLightbox(src) { let overlay = document.getElementById('cow-lightbox'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'cow-lightbox'; overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out;opacity:0;transition:opacity .2s'; overlay.onclick = () => { overlay.style.opacity = '0'; setTimeout(() => overlay.style.display = 'none', 200); }; const img = document.createElement('img'); img.id = 'cow-lightbox-img'; img.style.cssText = 'max-width:92vw;max-height:92vh;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.5);object-fit:contain;'; img.onclick = (e) => e.stopPropagation(); overlay.appendChild(img); document.body.appendChild(overlay); } overlay.querySelector('#cow-lightbox-img').src = src; overlay.style.display = 'flex'; requestAnimationFrame(() => overlay.style.opacity = '1'); } function _buildImageHtml(url) { const webUrl = _toWebUrl(url); const safeUrl = webUrl.replace(/"/g, '"'); return `
` + `image` + `
`; } 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(''); } // Convert image URLs into inline previews. Mirrors injectVideoPlayers but for images. // Handles three cases produced by markdown-it: // 1. ... (bare URL or autolink that linkify turned into an anchor) // 2. (markdown image syntax) — leave as-is, but normalize style // 3. raw URL still present in a text node — only as a safety net function injectImagePreviews(html) { // Step 1: anchor whose href points to an image file -> replace with preview. const step1 = html.replace( /]*>[^<]*<\/a>/gi, (match, url) => IMAGE_EXT_RE.test(url.split('?')[0]) ? _buildImageHtml(url) : match ); // Step 2: bare image URLs left in text nodes (rare — markdown-it's linkify usually catches them). return step1.split(/(<[^>]+>)/).map((chunk, idx) => { if (idx % 2 !== 0) return chunk; return chunk.replace(/https?:\/\/\S+/gi, (url) => { const bare = url.replace(/[),.\s]+$/, ''); return IMAGE_EXT_RE.test(bare.split('?')[0]) ? _buildImageHtml(bare) : url; }); }).join(''); } function _rewriteLocalImgSrc(html) { return html.replace(/]*?)src="([^"]+)"([^>]*?)>/gi, (match, pre, src, post) => { const webSrc = _toWebUrl(src); const safeSrc = webSrc.replace(/"/g, '"'); const hasClick = /onclick/i.test(pre + post); const clickAttr = hasClick ? '' : ` onclick="_openImageLightbox(this.src)" style="cursor:zoom-in;"`; return ``; }); } function renderMarkdown(text) { try { let html = md.render(text); html = _rewriteLocalImgSrc(html); // Order matters: video first (more specific), then image. return injectImagePreviews(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 folderInput = document.getElementById('folder-input'); const attachBtn = document.getElementById('attach-btn'); const attachMenu = document.getElementById('attach-menu'); const attachFolderOption = document.getElementById('attach-folder-option'); const supportsDirectoryUpload = !!folderInput && 'webkitdirectory' in folderInput; if (!supportsDirectoryUpload && attachFolderOption) { attachFolderOption.classList.add('hidden'); } // Smart auto-scroll: pause when user scrolls up, resume when near bottom let _autoScrollEnabled = true; const _SCROLL_THRESHOLD = 80; // px from bottom to re-enable auto-scroll messagesDiv.addEventListener('scroll', () => { const distFromBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight; _autoScrollEnabled = distFromBottom <= _SCROLL_THRESHOLD; _updateScrollToBottomBtn(); }); // Intercept internal navigation links in chat messages messagesDiv.addEventListener('click', (e) => { const copyBtn = e.target.closest('.copy-msg-btn'); if (copyBtn) { e.preventDefault(); const msgRoot = copyBtn.closest('.flex.gap-3'); const answerEl = msgRoot && msgRoot.querySelector('.answer-content'); const rawMd = answerEl && answerEl.dataset.rawMd; if (rawMd) { navigator.clipboard.writeText(rawMd).then(() => { const icon = copyBtn.querySelector('i'); if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); } }); } return; } const a = e.target.closest('a'); if (!a) return; const href = a.getAttribute('href') || ''; if (href === '/memory/dreams') { e.preventDefault(); navigateTo('memory'); setTimeout(() => switchMemoryTab('dreams'), 50); } else if (href === '/memory/MEMORY.md') { e.preventDefault(); navigateTo('memory'); setTimeout(() => { switchMemoryTab('files'); openMemoryFile('MEMORY.md', 'memory'); }, 50); } }); 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) { const suffix = att.file_type === 'directory' && att.file_count ? ` (${att.file_count})` : ''; return `
${escapeHtml(att.file_name)}${suffix}
`; } if (att.file_type === 'image') { return `
${escapeHtml(att.file_name)}
`; } const icon = att.file_type === 'video' ? 'fa-film' : (att.file_type === 'directory' ? 'fa-folder-tree' : 'fa-file-alt'); const suffix = att.file_type === 'directory' && att.file_count ? ` (${att.file_count})` : ''; return `
${escapeHtml(att.file_name)}${suffix}
`; }).join(''); updateSendBtnState(); } function removeAttachment(idx) { if (pendingAttachments[idx]?._uploading) return; pendingAttachments.splice(idx, 1); renderAttachmentPreview(); } function isAttachMenuVisible() { return attachMenu && !attachMenu.classList.contains('hidden'); } function hideAttachMenu() { if (attachMenu) attachMenu.classList.add('hidden'); } function toggleAttachMenu(event) { if (!attachMenu) return; if (event) { event.preventDefault(); event.stopPropagation(); } attachMenu.classList.toggle('hidden'); } function triggerFileUpload() { hideAttachMenu(); fileInput?.click(); } function triggerFolderUpload() { if (!supportsDirectoryUpload) return; hideAttachMenu(); folderInput?.click(); } 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); } function _makeUploadId() { return `dir_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; } function _groupDirectoryFiles(files) { const groups = new Map(); for (const file of Array.from(files || [])) { const relPath = file.webkitRelativePath || file.name; const parts = relPath.split('/').filter(Boolean); const rootName = parts[0] || file.name; if (!groups.has(rootName)) groups.set(rootName, []); groups.get(rootName).push({ file, relPath }); } return groups; } async function handleFolderSelect(files) { if (!files || files.length === 0) return; const groups = _groupDirectoryFiles(files); const groupTasks = []; for (const [rootName, entries] of groups.entries()) { const placeholder = { file_name: rootName, file_type: 'directory', file_count: entries.length, _uploading: true, }; pendingAttachments.push(placeholder); uploadingCount++; renderAttachmentPreview(); const uploadId = _makeUploadId(); groupTasks.push((async () => { try { const formData = new FormData(); formData.append('session_id', sessionId); formData.append('upload_id', uploadId); for (const { file, relPath } of entries) { formData.append('files', file); formData.append('relative_paths', relPath); } const resp = await fetch('/upload', { method: 'POST', body: formData }); const data = await resp.json(); if (data.status !== 'success') { throw new Error(data.message || 'Upload failed'); } if (!data.root_path) { throw new Error('Directory root path missing'); } placeholder.file_path = data.root_path; placeholder.file_name = data.root_name || rootName; delete placeholder._uploading; } catch (e) { console.error('Directory upload failed:', e); const i = pendingAttachments.indexOf(placeholder); if (i !== -1) pendingAttachments.splice(i, 1); } finally { uploadingCount--; } renderAttachmentPreview(); })()); } await Promise.all(groupTasks); } fileInput.addEventListener('change', function() { handleFileSelect(this.files); this.value = ''; }); folderInput.addEventListener('change', function() { handleFolderSelect(this.files); this.value = ''; }); document.addEventListener('click', (e) => { if (!isAttachMenuVisible()) return; if (attachMenu.contains(e.target) || attachBtn.contains(e.target)) return; hideAttachMenu(); }); // 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: '/memory dream ', desc: '手动触发记忆蒸馏 (可指定天数, 默认3)' }, { 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 (e.key === 'Escape' && isAttachMenuVisible()) { hideAttachMenu(); 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'); const isFirstMessage = !!ws; if (ws) ws.remove(); const titleInfo = (isFirstMessage && text) ? { sid: sessionId, userMsg: text } : null; 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, file_count: a.file_count, })); } 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, titleInfo); } 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, titleInfo) { 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 reasoningStartTime = 0; 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) { reasoningStartTime = Date.now(); currentReasoningEl = document.createElement('div'); currentReasoningEl.className = 'agent-step agent-thinking-step'; // During streaming, use a
 with a single text node and
                    // append-only updates. This avoids re-parsing markdown and
                    // re-setting innerHTML on every chunk, which is what causes
                    // the page to crash on long chains-of-thought.
                    currentReasoningEl.innerHTML = `
                        
${t('thinking_in_progress')}
`; stepsEl.appendChild(currentReasoningEl); const preEl = currentReasoningEl.querySelector('.thinking-stream-pre'); preEl.appendChild(document.createTextNode('')); currentReasoningEl._streamTextNode = preEl.firstChild; currentReasoningEl._streamPendingText = ''; currentReasoningEl._streamRafScheduled = false; currentReasoningEl._streamCharsRendered = 0; currentReasoningEl._streamCapped = false; } // Hard cap: once REASONING_RENDER_CAP chars are in the DOM, stop // appending further deltas. The full text is still kept in // `reasoningText` for finalize-time head+tail rendering. if (!currentReasoningEl._streamCapped) { currentReasoningEl._streamPendingText += item.content; if (!currentReasoningEl._streamRafScheduled) { currentReasoningEl._streamRafScheduled = true; const elRef = currentReasoningEl; requestAnimationFrame(() => { elRef._streamRafScheduled = false; if (!elRef.isConnected || !elRef._streamTextNode) return; let pending = elRef._streamPendingText; elRef._streamPendingText = ''; if (!pending) return; const remaining = REASONING_RENDER_CAP - elRef._streamCharsRendered; if (remaining <= 0) { elRef._streamCapped = true; } else { if (pending.length > remaining) { pending = pending.slice(0, remaining); elRef._streamCapped = true; } elRef._streamTextNode.appendData(pending); elRef._streamCharsRendered += pending.length; if (elRef._streamCapped) { elRef._streamTextNode.appendData( '\n\n... [reasoning truncated for display] ...' ); } } scrollChatToBottom(); }); } } } else if (item.type === 'delta') { ensureBotEl(); if (currentReasoningEl) { finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText); 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) { finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText); 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:zoom-in;box-shadow:0 1px 4px rgba(0,0,0,0.1);'; imgEl.onclick = () => _openImageLightbox(imgEl.src); 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'); if (finalText) contentEl.innerHTML = renderMarkdown(finalText); contentEl.dataset.rawMd = finalText || ''; const copyBtn = botEl.querySelector('.copy-msg-btn'); if (copyBtn && finalText) copyBtn.style.display = ''; applyHighlighting(botEl); } scrollChatToBottom(); if (titleInfo) { generateSessionTitle(titleInfo.sid, titleInfo.userMsg, ''); titleInfo = null; } else if (sessionPanelOpen) { loadSessionList(); } } 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) { finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText); 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' : (a.file_type === 'directory' ? 'fa-folder-tree' : 'fa-file-alt'); const suffix = a.file_type === 'directory' && a.file_count ? ` (${a.file_count})` : ''; return `
${escapeHtml(a.file_name)}${suffix}
`; }).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(''); } // Cap for rendering reasoning content in the bubble. Beyond this size, // we skip markdown rendering entirely and show plain text head + tail to // keep the page responsive (very long chains-of-thought can otherwise // stall or crash the browser when re-parsed by marked.js). // Keep this in sync with backend MAX_STORED_REASONING_CHARS and // MAX_REASONING_STREAM_CHARS so storage / SSE / display stay aligned. const REASONING_RENDER_CAP = 4 * 1024; // 4 KB function _truncateReasoningForDisplay(text) { if (!text || text.length <= REASONING_RENDER_CAP) return { text, truncated: false, omitted: 0 }; const half = Math.floor(REASONING_RENDER_CAP / 2); const head = text.slice(0, half); const tail = text.slice(-half); return { text: head + '\n\n... [' + (text.length - head.length - tail.length) + ' chars omitted] ...\n\n' + tail, truncated: true, omitted: text.length - head.length - tail.length, }; } function _renderReasoningBody(text) { // For short reasoning, render as markdown. For long ones, fall back to // an escaped
 block to avoid expensive markdown parsing.
    const { text: shown, truncated } = _truncateReasoningForDisplay(text);
    if (truncated || shown.length > REASONING_RENDER_CAP) {
        return '
' + escapeHtml(shown) + '
'; } return renderMarkdown(shown); } function finalizeThinking(el, startTime, text) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); el.querySelector('.thinking-summary').textContent = t('thinking_done'); const fullDiv = el.querySelector('.thinking-full'); fullDiv.innerHTML = `
${t('thinking_duration')} ${elapsed}s
` + _renderReasoningBody(text); } function renderThinkingHtml(text) { if (!text || !text.trim()) return ''; const full = text.trim(); return `
${t('thinking_done')}
${_renderReasoningBody(full)}
`; } 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)) : ''; const isErr = step.is_error === true; const iconClass = isErr ? 'fas fa-times text-red-400 flex-shrink-0 tool-icon' : 'fas fa-check text-primary-400 flex-shrink-0 tool-icon'; html += `
${escapeHtml(step.name || '')}
Input
${argsStr}
${resultStr ? `
${isErr ? 'Error' : 'Output'}
${resultStr}
` : ''}
`; // If this tool sent a file (send/read tool), render the media inline // so it persists across page refreshes (SSE-only file events are not stored). const mediaHtml = _renderSentFileFromToolResult(step); if (mediaHtml) html += mediaHtml; } } return { stepsHtml: html, lastContentText }; } // Extract file-to-send metadata from a tool's result and render an inline preview. // Returns '' if the result isn't a file_to_send payload. function _renderSentFileFromToolResult(step) { if (!step || !step.result) return ''; let payload; try { payload = typeof step.result === 'string' ? JSON.parse(step.result) : step.result; } catch (_) { return ''; } if (!payload || payload.type !== 'file_to_send' || !payload.path) return ''; const webUrl = _toWebUrl(payload.path); const fileType = payload.file_type || 'file'; const fileName = payload.file_name || payload.path.split('/').pop(); if (fileType === 'image') { return `
${_buildImageHtml(webUrl)}
`; } if (fileType === 'video') { return `
${_buildVideoHtml(webUrl)}
`; } return ``; } 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)}
`; el.querySelector('.answer-content').dataset.rawMd = displayContent; applyHighlighting(el); bindChatKnowledgeLinks(el); return el; } function addUserMessage(content, timestamp, attachments) { const el = createUserMessageEl(content, timestamp, attachments); messagesDiv.appendChild(el); _autoScrollEnabled = true; scrollChatToBottom(true); } 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) } const ctxStartSeq = data.context_start_seq || 0; let dividerInserted = false; 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; // Insert context divider when transitioning from above to below boundary if (ctxStartSeq > 0 && !dividerInserted && msg._seq !== undefined && msg._seq >= ctxStartSeq) { dividerInserted = true; const divider = document.createElement('div'); divider.className = 'context-divider'; divider.innerHTML = `${t('context_cleared')}`; fragment.appendChild(divider); } 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); }); // If context was cleared but no new messages exist yet, append divider at the end if (ctxStartSeq > 0 && !dividerInserted) { const divider = document.createElement('div'); divider.className = 'context-divider'; divider.innerHTML = `${t('context_cleared')}`; fragment.appendChild(divider); } // 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(true)); } 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'); // Show panel and load full session list, then prepend the new session on top const panel = document.getElementById('session-panel'); if (panel && !sessionPanelOpen) { sessionPanelOpen = true; panel.classList.remove('hidden'); _showSessionOverlay(); _persistPanelState(); } const newSid = sessionId; loadSessionList(() => _addOptimisticSessionItem(newSid)); } // ===================================================================== // Session Panel // ===================================================================== const SESSION_PANEL_KEY = 'cow_session_panel_open'; let sessionPanelOpen = localStorage.getItem(SESSION_PANEL_KEY) === '1'; function _persistPanelState() { localStorage.setItem(SESSION_PANEL_KEY, sessionPanelOpen ? '1' : '0'); } function _isMobileView() { return window.innerWidth <= 768; } function _showSessionOverlay() { if (!_isMobileView()) return; const overlay = document.getElementById('session-panel-overlay'); if (overlay) overlay.classList.remove('hidden'); } function _hideSessionOverlay() { const overlay = document.getElementById('session-panel-overlay'); if (overlay) overlay.classList.add('hidden'); } function closeSessionPanel() { const panel = document.getElementById('session-panel'); if (!panel || !sessionPanelOpen) return; sessionPanelOpen = false; panel.classList.add('hidden'); _hideSessionOverlay(); _persistPanelState(); } function toggleSessionPanel() { const panel = document.getElementById('session-panel'); if (!panel) return; sessionPanelOpen = !sessionPanelOpen; panel.classList.toggle('hidden', !sessionPanelOpen); if (sessionPanelOpen) { _showSessionOverlay(); } else { _hideSessionOverlay(); } _persistPanelState(); if (sessionPanelOpen) loadSessionList(); } function openSessionPanel() { const panel = document.getElementById('session-panel'); if (!panel || sessionPanelOpen) return; sessionPanelOpen = true; panel.classList.remove('hidden'); _showSessionOverlay(); _persistPanelState(); loadSessionList(); } function _restoreSessionPanel() { const panel = document.getElementById('session-panel'); if (!panel) return; if (sessionPanelOpen && !_isMobileView()) { panel.classList.remove('hidden'); _showSessionOverlay(); loadSessionList(); } else { panel.classList.add('hidden'); _hideSessionOverlay(); } } function _applyInputTooltips() { const set = (id, key, pos) => { const el = document.getElementById(id); if (!el) return; el.setAttribute('data-tooltip', t(key)); el.removeAttribute('title'); if (pos) el.setAttribute('data-tooltip-pos', pos); }; set('new-chat-btn', 'tip_new_chat'); set('clear-context-btn', 'tip_clear_context'); set('attach-btn', 'tip_attach'); set('session-toggle-btn', 'session_history', 'bottom'); } function _addOptimisticSessionItem(sid) { const container = document.getElementById('session-list'); if (!container) return; const emptyEl = container.querySelector('.session-empty'); if (emptyEl) emptyEl.remove(); document.querySelectorAll('.session-item.active').forEach(el => el.classList.remove('active')); const todayLabel = t('today'); let firstGroup = container.querySelector('.session-group-label'); if (!firstGroup || firstGroup.textContent !== todayLabel) { const header = document.createElement('div'); header.className = 'session-group-label'; header.textContent = todayLabel; container.prepend(header); firstGroup = header; } const title = t('new_chat'); const item = document.createElement('div'); item.className = 'session-item active'; item.dataset.sessionId = sid; item.innerHTML = ` ${escapeHtml(title)} `; item.addEventListener('click', () => switchSession(sid)); firstGroup.insertAdjacentElement('afterend', item); } function _sessionTimeGroup(ts) { const now = new Date(); const d = new Date(ts * 1000); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); if (d >= today) return t('today'); if (d >= yesterday) return t('yesterday'); return t('earlier'); } let _sessionPage = 1; let _sessionHasMore = false; let _sessionLoading = false; const _SESSION_PAGE_SIZE = 50; function loadSessionList(onDone) { const container = document.getElementById('session-list'); if (!container) return; _sessionPage = 1; _sessionHasMore = false; _fetchSessionPage(1, true, onDone); } function _fetchSessionPage(page, clear, onDone) { if (_sessionLoading) return; _sessionLoading = true; const container = document.getElementById('session-list'); if (!container) { _sessionLoading = false; return; } // Remove existing "load more" sentinel before fetching const oldSentinel = container.querySelector('.session-load-more'); if (oldSentinel) oldSentinel.remove(); fetch(`/api/sessions?page=${page}&page_size=${_SESSION_PAGE_SIZE}`) .then(r => r.json()) .then(data => { _sessionLoading = false; if (data.status !== 'success') return; if (clear) container.innerHTML = ''; const sessions = data.sessions || []; _sessionPage = page; _sessionHasMore = !!data.has_more; if (sessions.length === 0 && page === 1) { container.innerHTML = '
' + t('untitled_session') + '
'; if (typeof onDone === 'function') onDone(); return; } // Track last group label already in the container const existingLabels = container.querySelectorAll('.session-group-label'); let lastGroup = existingLabels.length > 0 ? existingLabels[existingLabels.length - 1].textContent : ''; sessions.forEach(s => { const group = _sessionTimeGroup(s.last_active); if (group !== lastGroup) { lastGroup = group; const header = document.createElement('div'); header.className = 'session-group-label'; header.textContent = group; container.appendChild(header); } const item = document.createElement('div'); const isActive = s.session_id === sessionId; item.className = 'session-item' + (isActive ? ' active' : ''); item.dataset.sessionId = s.session_id; const title = s.title || t('untitled_session'); item.innerHTML = ` ${escapeHtml(title)} `; item.addEventListener('click', () => switchSession(s.session_id)); container.appendChild(item); }); if (typeof onDone === 'function') onDone(); }) .catch(() => { _sessionLoading = false; }); } function _onSessionListScroll() { if (!_sessionHasMore || _sessionLoading) return; const container = document.getElementById('session-list'); if (!container) return; // Trigger when scrolled near the bottom (within 60px) if (container.scrollHeight - container.scrollTop - container.clientHeight < 60) { _fetchSessionPage(_sessionPage + 1, false); } } // Attach scroll listener once DOM is ready (function _initSessionScroll() { const el = document.getElementById('session-list'); if (el) { el.addEventListener('scroll', _onSessionListScroll); } else { document.addEventListener('DOMContentLoaded', () => { const el2 = document.getElementById('session-list'); if (el2) el2.addEventListener('scroll', _onSessionListScroll); }); } })(); function switchSession(newSessionId) { if (newSessionId === sessionId) { if (currentView !== 'chat') navigateTo('chat'); return; } Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} }); activeStreams = {}; loadingContainers = {}; sessionId = newSessionId; localStorage.setItem(SESSION_ID_KEY, sessionId); historyPage = 0; historyHasMore = false; historyLoading = false; messagesDiv.innerHTML = ''; loadHistory(1); startPolling(); document.querySelectorAll('.session-item').forEach(el => { el.classList.toggle('active', el.dataset.sessionId === sessionId); }); if (_isMobileView()) closeSessionPanel(); if (currentView !== 'chat') navigateTo('chat'); } function deleteSession(sid) { showConfirmModal(t('delete_session_title'), t('delete_session_confirm'), () => { fetch(`/api/sessions/${encodeURIComponent(sid)}`, { method: 'DELETE' }) .then(r => r.json()) .then(data => { if (data.status !== 'success') return; if (sid === sessionId) { newChat(); } else { loadSessionList(); } }) .catch(() => {}); }); } function showConfirmModal(title, message, onConfirm) { let overlay = document.getElementById('confirm-modal-overlay'); if (overlay) overlay.remove(); overlay = document.createElement('div'); overlay.id = 'confirm-modal-overlay'; overlay.className = 'confirm-overlay'; const modal = document.createElement('div'); modal.className = 'confirm-modal'; modal.innerHTML = `
${escapeHtml(title)}
${escapeHtml(message)}
`; overlay.appendChild(modal); document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('visible')); const close = () => { overlay.classList.remove('visible'); setTimeout(() => overlay.remove(), 200); }; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); modal.querySelector('.confirm-btn-cancel').addEventListener('click', close); modal.querySelector('.confirm-btn-ok').addEventListener('click', () => { close(); onConfirm(); }); } function clearContext() { fetch(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.status !== 'success') return; // Insert a visual divider in the chat const divider = document.createElement('div'); divider.className = 'context-divider'; divider.innerHTML = `${t('context_cleared')}`; messagesDiv.appendChild(divider); scrollChatToBottom(); }) .catch(() => {}); } function generateSessionTitle(sid, userMsg, assistantReply) { fetch(`/api/sessions/${encodeURIComponent(sid)}/generate_title`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_message: userMsg, assistant_reply: assistantReply }), }) .then(r => r.json()) .then(data => { if (data.status === 'success' && sessionPanelOpen) { loadSessionList(); } }) .catch(() => {}); } // ===================================================================== // Utilities // ===================================================================== function formatTime(date) { const now = new Date(); const sameDay = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (sameDay) return time; const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); if (date.getFullYear() === now.getFullYear()) return `${m}-${d} ${time}`; return `${date.getFullYear()}-${m}-${d} ${time}`; } 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(force) { if (force || _autoScrollEnabled) { messagesDiv.scrollTop = messagesDiv.scrollHeight; } } function _updateScrollToBottomBtn() { const btn = document.getElementById('scroll-to-bottom-btn'); if (!btn) return; const distFromBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight; btn.classList.toggle('hidden', distFromBottom <= _SCROLL_THRESHOLD); } function applyHighlighting(container) { const root = container || document; setTimeout(() => { const hljsLib = getHljs(); root.querySelectorAll('pre code').forEach(block => { if (!block.classList.contains('hljs')) { hljsLib.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.dataset.value = opt.value; // Hint is an optional dim secondary label rendered on the right // side of the row (e.g. friendly brand name next to a technical // model id). When absent the row degrades to the original // single-string layout. if (opt.hint) { const labelEl = document.createElement('span'); labelEl.className = 'cfg-dropdown-label'; labelEl.textContent = opt.label; const hintEl = document.createElement('span'); hintEl.className = 'cfg-dropdown-hint'; hintEl.textContent = opt.hint; item.appendChild(labelEl); item.appendChild(hintEl); } else { item.textContent = opt.label; } 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; document.getElementById('cfg-enable-thinking').checked = data.enable_thinking === true; 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 customTip = document.getElementById('cfg-custom-tip'); if (customTip) customTip.classList.toggle('hidden', cfgProviderValue !== 'custom'); 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 const apiBaseInput = document.getElementById('cfg-api-base'); if (p.api_base_key) { document.getElementById('cfg-api-base-wrap').classList.remove('hidden'); apiBaseInput.value = configApiBases[p.api_base_key] || p.api_base_default || ''; // Hint the version-path tail (e.g. /v1) so users are reminded to // include it themselves. We don't auto-rewrite anything server-side. apiBaseInput.placeholder = p.api_base_placeholder || 'https://...'; } else { document.getElementById('cfg-api-base-wrap').classList.add('hidden'); apiBaseInput.value = ''; apiBaseInput.placeholder = 'https://...'; } 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, enable_thinking: document.getElementById('cfg-enable-thinking').checked, }; 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; let memoryCategory = 'memory'; // 'memory' | 'dream' const memoryPageSize = 10; function switchMemoryTab(tab) { document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active')); document.getElementById('memory-tab-' + tab).classList.add('active'); memoryCategory = tab === 'dreams' ? 'dream' : 'memory'; loadMemoryView(1); } function loadMemoryView(page) { page = page || 1; memoryPage = page; fetch(`/api/memory?page=${page}&page_size=${memoryPageSize}&category=${memoryCategory}`).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) { const emptyIcon = emptyEl.querySelector('i'); const emptyTitle = emptyEl.querySelector('p'); if (memoryCategory === 'dream') { emptyIcon.className = 'fas fa-moon text-purple-400 text-xl'; emptyTitle.textContent = currentLang === 'zh' ? '暂无梦境日记' : 'No dream diaries yet'; } else { emptyIcon.className = 'fas fa-brain text-purple-400 text-xl'; emptyTitle.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, memoryCategory); let typeLabel; if (f.type === 'global') { typeLabel = 'Global'; } else if (f.type === 'dream') { typeLabel = 'Dream'; } else { typeLabel = '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, category) { category = category || 'memory'; fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`).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, hideCancel }) { 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'; const cancelBtn = document.getElementById('confirm-dialog-cancel'); cancelBtn.textContent = cancelText || t('channels_cancel'); cancelBtn.classList.toggle('hidden', !!hideCancel); 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'); okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onOverlayClick); overlay.classList.remove('hidden'); } // ===================================================================== // Models View // ===================================================================== // Capability cards rendered on the Models page. Order matters — main model // comes first because it transitively decides defaults for vision and image. // Icon palette is grouped by capability family: // - chat → primary (brand green; the "main" capability) // - vision + image → blue (everything visual) // - asr + tts → amber (everything audio) // - embedding → purple (vectors) // - search → orange (retrieval) // Each card uses an explicit `iconClass` string so Tailwind's CDN JIT can // see the literal class names — dynamic `bg-${color}-50` strings would not // be picked up reliably. const MODELS_CAPABILITY_DEFS = [ { id: 'chat', icon: 'fa-microchip', editable: true, needsModel: true, titleKey: 'models_capability_chat', descKey: 'models_capability_chat_desc', iconChip: 'bg-primary-50 dark:bg-primary-900/30', iconGlyph: 'text-primary-500' }, { id: 'vision', icon: 'fa-eye', editable: true, needsModel: true, titleKey: 'models_capability_vision', descKey: 'models_capability_vision_desc', iconChip: 'bg-blue-50 dark:bg-blue-900/30', iconGlyph: 'text-blue-500' }, { id: 'image', icon: 'fa-image', editable: true, needsModel: true, titleKey: 'models_capability_image', descKey: 'models_capability_image_desc', iconChip: 'bg-blue-50 dark:bg-blue-900/30', iconGlyph: 'text-blue-500' }, { id: 'asr', icon: 'fa-microphone', editable: true, needsModel: false, titleKey: 'models_capability_asr', descKey: 'models_capability_asr_desc', iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' }, { id: 'tts', icon: 'fa-volume-high', editable: true, needsModel: true, titleKey: 'models_capability_tts', descKey: 'models_capability_tts_desc', iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' }, { id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: false, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc', iconChip: 'bg-purple-50 dark:bg-purple-900/30', iconGlyph: 'text-purple-500' }, { id: 'search', icon: 'fa-magnifying-glass', editable: false, needsModel: false, titleKey: 'models_capability_search', descKey: 'models_capability_search_desc', iconChip: 'bg-orange-50 dark:bg-orange-900/30', iconGlyph: 'text-orange-500' }, ]; // Provider logos: when a real SVG exists under static/logos/.svg we use // it; otherwise we fall back to a neutral monogram chip. SVGs are fetched // via with a hidden onerror so layout stays stable when files are // absent. Vendors whose mark is rendered in pure (or near-pure) black are // listed in MODELS_PROVIDER_LOGO_DARK_INVERT — for those, we apply a CSS // invert filter in dark mode so the glyph stays visible against #1A1A1A. const MODELS_PROVIDER_LOGO_PATH = 'assets/logos'; const MODELS_PROVIDER_LOGO_DARK_INVERT = new Set([ 'openai', // black wordmark 'moonshot', // dark monogram 'zhipu', // dark monogram 'custom', // single-color slider glyph ]); let modelsState = { providers: [], capabilities: {} }; // One-shot: { capabilityId, providerId } stashed before a Models reload, // consumed by renderCapabilityBody to preselect a just-configured vendor. let pendingCapabilitySelection = null; // `opts.preserveScroll` keeps the page's vertical scroll position across the // refresh. We capture it before unhiding the loading skeleton (which collapses // content height to zero) and restore it after the new content is mounted. // This matters when the user configures a vendor from inside a capability // card's dropdown — without preservation, the post-save reload bounces them // back to the top of the page, away from the card they were configuring. function loadModelsView(opts) { const loading = document.getElementById('models-loading'); const content = document.getElementById('models-content'); if (!loading || !content) return; const preserveScroll = !!(opts && opts.preserveScroll); // The Models pane has its own scrollable container; capture its position // (not window.scrollY) so we can put the user back exactly where they were. const scroller = document.querySelector('#view-models .overflow-y-auto'); const savedTop = preserveScroll && scroller ? scroller.scrollTop : null; loading.classList.remove('hidden'); content.classList.add('hidden'); fetch('/api/models').then(r => r.json()).then(data => { if (data.status !== 'success') { loading.innerHTML = `${escapeHtml(data.message || 'Failed to load')}`; return; } modelsState.providers = data.providers || []; modelsState.capabilities = data.capabilities || {}; renderModelsView(); loading.classList.add('hidden'); content.classList.remove('hidden'); if (savedTop !== null && scroller) { // Wait one frame for the new layout to settle, otherwise the // restored scrollTop snaps to the previous (smaller) max. requestAnimationFrame(() => { scroller.scrollTop = savedTop; }); } }).catch(err => { loading.innerHTML = `${escapeHtml(String(err))}`; }); } function renderModelsView() { const container = document.getElementById('models-content'); container.innerHTML = ''; container.appendChild(renderVendorsSection()); MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def))); } // ---------- Vendor section (Layer 1) ----------------------------------- function renderVendorsSection() { const wrap = document.createElement('div'); wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; const configured = modelsState.providers.filter(p => p.configured); const header = `

${t('models_section_vendors')}

${t('models_section_vendors_desc')}

${configured.length}/${modelsState.providers.length}
`; let body; if (configured.length === 0) { body = `

${t('models_not_configured')}

`; } else { body = `
${configured.map(renderVendorChip).join('')}
`; } wrap.innerHTML = header + body; return wrap; } function renderVendorChip(p) { // The masked API key is intentionally not surfaced here; it is shown // inside the edit modal so the chip stays uncluttered and scannable. return ` `; } // Render a uniformly-styled logo for a provider. Tries an SVG asset first; if // it 404s the swaps itself for a monogram fallback via onerror. function renderProviderLogo(p, sizePx) { const initial = (p.label || p.id || '?').slice(0, 1).toUpperCase(); const sz = sizePx || 32; const url = `${MODELS_PROVIDER_LOGO_PATH}/${encodeURIComponent(p.id)}.svg`; const fallbackId = `pl-${p.id}-${Math.random().toString(36).slice(2, 8)}`; const imgClass = MODELS_PROVIDER_LOGO_DARK_INVERT.has(p.id) ? 'absolute inset-0 m-auto provider-logo-img provider-logo-invert-dark' : 'absolute inset-0 m-auto provider-logo-img'; return ` ${escapeHtml(initial)} `; } // ---------- Capability cards (Layer 2) --------------------------------- function renderCapabilityCard(def) { const cap = modelsState.capabilities[def.id] || {}; const wrap = document.createElement('div'); wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; wrap.id = `models-card-${def.id}`; const headerRight = renderCapabilityHeaderTag(def, cap); wrap.innerHTML = `

${t(def.titleKey)}

${t(def.descKey)}

${headerRight}
`; const body = wrap.querySelector(`[data-cap-body="${def.id}"]`); renderCapabilityBody(def, cap, body); return wrap; } function renderCapabilityHeaderTag(def, cap) { // Only the search card carries a header tag — it reflects a runtime // resolution result (which vendor responds to web_search), so the chip // is informational, not editable. Other cards used to surface "auto" // and "follows main model" badges, but those duplicated information // already shown in the provider dropdown and the fallback hint below, // so we drop them for visual clarity. if (def.id === 'search') { if (cap.available) { return `${escapeHtml(cap.current_provider || '')}`; } return `${t('models_unavailable')}`; } return ''; } function renderCapabilityBody(def, cap, body) { if (def.id === 'search') { if (cap.available) { body.innerHTML = `

${t('models_configured')}: ${escapeHtml(cap.current_provider)}

`; } else { body.innerHTML = `

${t('models_set_via_env')}: BOCHA_API_KEY

`; } return; } // Editable cards: provider dropdown + (optional) model dropdown + save row const providerOpts = buildCapabilityProviderOptions(def, cap); const providerHtml = `
--
`; // The model-picker container is always emitted so the provider-change // handler can show/hide it; for `auto` capabilities it starts hidden and // gets toggled by setCapabilityModelPickerVisible. const modelHtml = def.needsModel ? `
--
` : ''; const dimHtml = (def.id === 'embedding' && cap.current_dim) ? `

${t('models_dim_label')}: ${cap.current_dim}

` : ''; // Footer layout: a "hint slot" (filled later by renderCapabilityHints for // auto-mode cards) sits on the left while status + save stay anchored on // the right. Keeping them on the same row means the save button hugs the // inputs above instead of being pushed down by a separate hint line. const footer = `
`; body.innerHTML = providerHtml + modelHtml + dimHtml + footer; // The body subtree is detached from `document` at this moment (the parent // wrap is not yet appended), so we must scope lookups to `body` rather // than calling document.getElementById, which would return null and crash // initDropdown's internal querySelector. const provDd = body.querySelector(`#cap-${def.id}-provider`); // initDropdown's option shape is {value, label}; we strip our private // _configured/_tracked fields before handing it over so the helper stays // generic, then re-attach status decorations afterwards. const ddOpts = providerOpts.map(o => ({ value: o.value, label: o.label })); let pendingProvider = null; if (pendingCapabilitySelection && pendingCapabilitySelection.capabilityId === def.id && providerOpts.some(o => o.value === pendingCapabilitySelection.providerId)) { pendingProvider = pendingCapabilitySelection.providerId; pendingCapabilitySelection = null; } // For auto-capable capabilities, an "auto" strategy means the user has // not pinned a vendor; we honor that by selecting the empty-string // sentinel rather than the resolved fallback provider name. // `suggested_provider` is a UI-only preselect for embedding when nothing // is pinned yet — purely cosmetic, not persisted until the user saves. const initialProviderValue = pendingProvider ? pendingProvider : ((cap.strategy === 'auto' && capabilitySupportsAuto(def.id)) ? '' : (cap.current_provider || cap.suggested_provider || (ddOpts[0] && ddOpts[0].value) || '')); initDropdown( provDd, ddOpts, initialProviderValue, (value) => onCapabilityProviderChange(def, value, body) ); decorateCapabilityProviderDropdown(def, provDd, providerOpts); if (def.needsModel) { rebuildCapabilityModelDropdown(def, initialProviderValue, cap.current_model || '', body); // Hide the model picker entirely while the capability is in `auto` // mode — there is nothing useful to pin, and the fallback hint // below explains what'll actually run. setCapabilityModelPickerVisible(def, initialProviderValue !== '' || !capabilitySupportsAuto(def.id), body); } // Inject auto/router-pending hint banners before the action footer. renderCapabilityHints(def, cap, body, initialProviderValue); } // Toggle visibility of the model picker. Used both at first render and // whenever the provider dropdown swings between an explicit vendor and the // "auto" sentinel. We toggle the wrapper rather than re-rendering so the // existing dropdown state survives a round-trip back to a real vendor. function setCapabilityModelPickerVisible(def, visible, scope) { const root = scope || document; const wrap = root.querySelector(`#cap-${def.id}-model-wrap`); if (!wrap) return; wrap.classList.toggle('hidden', !visible); } function renderCapabilityHints(def, cap, body, currentProvider) { // Capabilities that can be in "auto" mode show a fallback hint right // under the inputs so users always know what'd actually be hit. The // image card additionally surfaces a "router pending" warning until the // standalone dispatcher lands. // The hint slot is co-located with the save button in the footer row // (see renderCapabilityBody) so the save button stays close to the // inputs above. We just rewrite the slot's innerHTML — emptying it // when the card leaves auto mode, or rendering a one-line hint when // it's in auto mode. const slot = body.querySelector(`[data-cap-hint="${def.id}"]`); if (!slot) return; slot.innerHTML = ''; if (currentProvider !== '' || !capabilitySupportsAuto(def.id)) return; // The hint mirrors what the runtime would actually pick when in auto // mode. fallback_provider/model are pre-computed on the backend (see // _predict_vision_auto, _predict_image_auto) so we can trust them // here without re-implementing the provider chain. const fbProv = cap.fallback_provider || ''; const fbModel = cap.fallback_model || ''; if (!fbProv && !fbModel) return; // Show the vendor's display label (e.g. "LinkAI") instead of the raw // id ("linkai") when we know it. Falls back to the id when the // provider isn't in our vendor table (rare). const provMeta = modelsState.providers.find(p => p.id === fbProv); const fbProvLabel = (provMeta && provMeta.label) || fbProv; const fbText = fbModel ? `${fbProvLabel} / ${fbModel}` : fbProvLabel; slot.innerHTML = `

${t('models_auto_using')} ${escapeHtml(fbText)}

`; } function buildCapabilityProviderOptions(def, cap) { // Show ALL vendors in capability dropdowns so users can see at a glance // who's configured (green check) and who isn't (gray dot, click to set // up). The list order puts configured vendors first; clicking an // unconfigured row opens the vendor modal in-place. ASR/TTS engines that // aren't tracked by PROVIDER_MODELS (azure/baidu/google etc.) are treated // as "always available" — no credential gate. const knownProviderMap = {}; modelsState.providers.forEach(p => { knownProviderMap[p.id] = p; }); const explicitList = cap.providers && cap.providers.length ? cap.providers : null; let providerIds = explicitList ? explicitList.slice() : modelsState.providers.map(p => p.id); if (cap.current_provider && !providerIds.includes(cap.current_provider)) { providerIds = [cap.current_provider, ...providerIds]; } const opts = providerIds.map(pid => { const meta = knownProviderMap[pid]; const tracked = !!meta; const configured = !tracked || !!meta.configured; return { value: pid, label: (meta && meta.label) || pid, _tracked: tracked, _configured: configured, }; }); opts.sort((a, b) => { if (a._configured === b._configured) return 0; return a._configured ? -1 : 1; }); // Capabilities with a fallback ("auto") strategy expose it as a sentinel // option pinned to the top of the list. We use empty-string as the auto // value so the existing save handler propagates it untouched to the // backend, which interprets "" as "fall back to the main model". if (cap.strategy === 'auto' || cap.strategy === 'specified') { if (capabilitySupportsAuto(def.id)) { opts.unshift({ value: '', label: t('models_strategy_auto'), _tracked: false, _configured: true, _isAuto: true, }); } } return opts; } function capabilitySupportsAuto(capId) { // Embedding is intentionally NOT here: runtime only auto-falls back to // OpenAI/LinkAI, so dressing it up as "auto" hides reality from users. return capId === 'image' || capId === 'vision'; } // After initDropdown renders the capability provider menu, decorate each // row with the right-aligned configuration cue: // - configured rows: nothing extra — the .active marker (a brand-green ✓) // already comes from initDropdown's selected-state CSS for the row the // user currently picked. Other configured rows show no chrome, mirroring // a plain "switch to this" selector. // - unconfigured rows: a subdued gear icon hints at "click to configure". // The row's whole click handler is swapped to launch the vendor modal // in place rather than selecting an unusable value. function decorateCapabilityProviderDropdown(def, ddEl, opts) { if (!ddEl) return; const menu = ddEl.querySelector('.cfg-dropdown-menu'); if (!menu) return; const optByValue = {}; opts.forEach(o => { optByValue[o.value] = o; }); menu.querySelectorAll('.cfg-dropdown-item').forEach(item => { const value = item.dataset.value; const opt = optByValue[value]; if (!opt) return; item.classList.add('cap-provider-item'); if (!opt._configured) item.classList.add('cap-provider-unconfigured'); // Wrap the label so the trailing affordance lines up via flex:auto. const labelText = item.textContent; item.textContent = ''; const labelEl = document.createElement('span'); labelEl.className = 'cap-provider-label'; labelEl.textContent = labelText; item.appendChild(labelEl); if (!opt._configured) { // Trailing gear icon as the "configure this vendor" affordance. const gear = document.createElement('i'); gear.className = 'fas fa-gear cap-provider-gear'; item.appendChild(gear); } if (!opt._configured && opt._tracked) { // Hijack the click: open the vendor modal instead of selecting // an unusable value, and remember which capability the user was // configuring so the post-save reload can preselect the vendor. const newItem = item.cloneNode(true); item.replaceWith(newItem); newItem.addEventListener('click', (e) => { e.stopPropagation(); ddEl.classList.remove('open'); openVendorModal(value, (savedProviderId) => { pendingCapabilitySelection = { capabilityId: def.id, providerId: savedProviderId || value, }; loadModelsView({ preserveScroll: true }); }); }); } }); } // Lightweight decorator for the "add vendor" modal's provider picker: // every configured vendor row gets a trailing brand-green ✓ so the user can // see at a glance who's already set up, without having to read each row. // Unlike decorateCapabilityProviderDropdown we don't hijack clicks here — // picking an unconfigured vendor in this modal *is* the intended action. function decorateVendorModalPicker(ddEl, opts) { if (!ddEl) return; const menu = ddEl.querySelector('.cfg-dropdown-menu'); if (!menu) return; const optByValue = {}; opts.forEach(o => { optByValue[o.value] = o; }); menu.querySelectorAll('.cfg-dropdown-item').forEach(item => { const opt = optByValue[item.dataset.value]; if (!opt) return; // Tag the row so the global active-row ✓ rule is suppressed in CSS // (otherwise configured AND selected rows would render two checks). item.classList.add('vendor-picker-item'); if (!opt._configured) return; const check = document.createElement('i'); check.className = 'fas fa-check vendor-picker-configured-mark'; item.appendChild(check); }); } function rebuildCapabilityModelDropdown(def, providerId, selectedModel, scope) { // `scope` lets the caller (renderCapabilityBody) target a still-detached // subtree. After the card is mounted, callers may pass `document` instead. const root = scope || document; const el = root.querySelector(`#cap-${def.id}-model`); if (!el) return; // Prefer the capability-scoped model list when the backend provides one // (vision / image). It reflects the models the runtime can actually // dispatch to for this capability, instead of the vendor's full chat- // model catalog. Fall back to the generic provider.models for chat / // embedding / tts where any vendor model is fair game. // // Entries may be plain strings or {value, hint} objects (image catalog // uses the latter to surface brand aliases like "Nano Banana 2" next to // the technical Gemini model id). We normalize to {value, label, hint} // before handing off to initDropdown. const cap = modelsState.capabilities[def.id] || {}; const capModelMap = cap.provider_models || {}; let rawList; if (capModelMap[providerId]) { rawList = capModelMap[providerId].slice(); } else { const provider = modelsState.providers.find(p => p.id === providerId); rawList = (provider && provider.models) ? provider.models.slice() : []; } const modelValues = []; const opts = rawList.map(entry => { if (typeof entry === 'string') { modelValues.push(entry); return { value: entry, label: entry }; } modelValues.push(entry.value); return { value: entry.value, label: entry.label || entry.value, hint: entry.hint || '' }; }); opts.push({ value: '__custom__', label: currentLang === 'zh' ? '自定义...' : 'Custom...' }); let initialValue = selectedModel || ''; if (initialValue && !modelValues.includes(initialValue)) { initialValue = '__custom__'; } if (!initialValue && opts.length) initialValue = opts[0].value; initDropdown(el, opts, initialValue, (value) => { const customWrap = document.getElementById(`cap-${def.id}-model-custom-wrap`); if (!customWrap) return; if (value === '__custom__') { customWrap.classList.remove('hidden'); const input = document.getElementById(`cap-${def.id}-model-custom`); if (input && !input.value) input.value = selectedModel || ''; } else { customWrap.classList.add('hidden'); } }); const customWrap = root.querySelector(`#cap-${def.id}-model-custom-wrap`); if (customWrap) { if (initialValue === '__custom__') { customWrap.classList.remove('hidden'); const input = root.querySelector(`#cap-${def.id}-model-custom`); if (input) input.value = selectedModel || ''; } else { customWrap.classList.add('hidden'); } } } function onCapabilityProviderChange(def, providerId, scope) { if (def.needsModel) { // For capabilities that support `auto`, switching to the empty // sentinel hides the model picker entirely so the card reads as // "we'll figure it out"; switching back to a real vendor re-runs // the rebuild against the capability-scoped model list. const isAuto = providerId === '' && capabilitySupportsAuto(def.id); if (!isAuto) { rebuildCapabilityModelDropdown(def, providerId, '', scope); } setCapabilityModelPickerVisible(def, !isAuto, scope); } // Refresh the auto-hint so it disappears once the user pins a vendor // and reappears when they swing back to "auto". renderCapabilityHints // now writes directly into the footer's hint slot, so we just call it // again — no need to clean up stale DOM nodes. const body = scope || document.querySelector(`[data-cap-body="${def.id}"]`); if (body) { const cap = modelsState.capabilities[def.id] || {}; renderCapabilityHints(def, cap, body, providerId); } } function getCapabilityModelValue(def) { if (!def.needsModel) return ''; const dd = document.getElementById(`cap-${def.id}-model`); if (!dd) return ''; const v = getDropdownValue(dd); if (v === '__custom__') { const input = document.getElementById(`cap-${def.id}-model-custom`); return input ? input.value.trim() : ''; } return v || ''; } function saveCapability(capId) { const def = MODELS_CAPABILITY_DEFS.find(d => d.id === capId); if (!def || !def.editable) return; const provDd = document.getElementById(`cap-${capId}-provider`); const provider = provDd ? getDropdownValue(provDd) : ''; // When the user is in auto mode (provider == ""), the model picker is // hidden and any value left in it is stale; persist an empty model so // the backend treats this as "fall back to the runtime chain". const isAuto = provider === '' && capabilitySupportsAuto(capId); const model = isAuto ? '' : getCapabilityModelValue(def); // Embedding changes invalidate any pre-existing vector index because // dimensions / vendor differ. Gate the save behind a confirm, and on // success surface a dedicated info dialog telling the user how to // rebuild — both via the in-app custom dialog, not the native alert. if (capId === 'embedding') { const cap = modelsState.capabilities[capId] || {}; const before = (cap.current_provider || '').trim(); const after = (provider || '').trim(); if (before !== after) { showConfirmDialog({ title: t('models_embedding_change_title'), message: t('models_embedding_change_msg'), okText: t('save'), cancelText: t('cancel'), onConfirm: () => _persistCapability(capId, provider, model, () => { showConfirmDialog({ title: t('models_embedding_saved_title'), message: t('models_embedding_saved_msg'), okText: t('models_embedding_saved_ok'), hideCancel: true, onConfirm: () => { navigateTo('chat'); // Defer focus + value set: navigateTo may // re-render the chat panel; setting value before // the input is mounted would be lost. setTimeout(() => { const input = document.getElementById('chat-input'); if (!input) return; input.value = '/memory rebuild-index'; input.focus(); // Trigger any input listeners (autosize, send-button enable, etc.) input.dispatchEvent(new Event('input', { bubbles: true })); }, 60); }, }); }), }); return; } } _persistCapability(capId, provider, model); } function _persistCapability(capId, provider, model, onAfterSuccess) { fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'set_capability', capability: capId, provider_id: provider, model: model }), }).then(r => r.json()).then(data => { if (data.status === 'success') { // Show "Saved" first, then refresh — loadModelsView would // otherwise rebuild the card and wipe the status span before // the user can register the confirmation. showStatus(`cap-${capId}-status`, 'models_save_success', false); setTimeout(() => { loadModelsView({ preserveScroll: true }); if (onAfterSuccess) onAfterSuccess(); }, 400); } else { showStatus(`cap-${capId}-status`, 'models_save_failed', true); } }).catch(() => showStatus(`cap-${capId}-status`, 'models_save_failed', true)); } // ---------- Vendor credential modal ------------------------------------ let vendorModalState = { providerId: '', onSaved: null }; function openVendorModal(providerId, onSaved) { vendorModalState = { providerId: providerId || '', onSaved: onSaved || null }; const overlay = document.getElementById('vendor-modal-overlay'); const titleEl = document.getElementById('vendor-modal-title'); const subEl = document.getElementById('vendor-modal-subtitle'); const pickerWrap = document.getElementById('vendor-modal-picker-wrap'); const baseWrap = document.getElementById('vendor-modal-base-wrap'); const baseInput = document.getElementById('vendor-modal-base'); const baseHint = document.getElementById('vendor-modal-base-hint'); const keyInput = document.getElementById('vendor-modal-key'); const clearBtn = document.getElementById('vendor-modal-clear'); // Reset any leftover status (e.g. previous "Saved" message) const statusEl = document.getElementById('vendor-modal-status'); if (statusEl) { statusEl.textContent = ''; statusEl.classList.add('opacity-0'); } if (!providerId) { // Add flow — show provider picker, default to the first unconfigured one. // We render every configured vendor with a trailing green ✓ via the // dropdown decorator, mirroring the visual language used by the // capability provider dropdowns. The .active row already shows the // currently selected vendor via its own background highlight, so we // intentionally suppress the global active-row ✓ for this picker // (see CSS) — otherwise configured + selected rows would show two. const unconfigured = modelsState.providers.filter(p => !p.configured); const defaultId = (unconfigured[0] && unconfigured[0].id) || (modelsState.providers[0] && modelsState.providers[0].id) || ''; pickerWrap.classList.remove('hidden'); const pickerEl = document.getElementById('vendor-modal-picker'); const pickerOpts = modelsState.providers.map(p => ({ value: p.id, label: p.label, _configured: !!p.configured, })); initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val)); decorateVendorModalPicker(pickerEl, pickerOpts); fillVendorModalForProvider(defaultId); } else { pickerWrap.classList.add('hidden'); fillVendorModalForProvider(providerId); } overlay.classList.remove('hidden'); document.getElementById('vendor-modal-cancel').onclick = closeVendorModal; document.getElementById('vendor-modal-save').onclick = saveVendorModal; clearBtn.onclick = clearVendorModal; // Once the user edits the masked value, drop the "masked sentinel" dataset // so the save handler treats their input as a real new key. We compare on // the next tick because keydown fires before the new char lands in .value. keyInput.oninput = function () { if (keyInput.dataset.masked === '1' && keyInput.value !== keyInput.dataset.maskedVal) { keyInput.dataset.masked = ''; } }; function onOverlayClick(e) { if (e.target === overlay) { closeVendorModal(); overlay.removeEventListener('click', onOverlayClick); } } overlay.addEventListener('click', onOverlayClick); keyInput.focus(); } function fillVendorModalForProvider(providerId) { const meta = modelsState.providers.find(p => p.id === providerId); if (!meta) return; document.getElementById('vendor-modal-title').textContent = meta.label; document.getElementById('vendor-modal-subtitle').textContent = meta.id; // ----- API Base ----- // Always reflect the *current effective* base as the input value so the // user can see (and edit) what's in use today. Placeholder is reserved // strictly for the "not yet typed anything" state and shows the official // default — never mixed with the actual value. const baseWrap = document.getElementById('vendor-modal-base-wrap'); const baseInput = document.getElementById('vendor-modal-base'); const baseHint = document.getElementById('vendor-modal-base-hint'); if (meta.api_base_field) { baseWrap.classList.remove('hidden'); baseInput.placeholder = meta.api_base_default || meta.api_base_placeholder || ''; baseInput.value = meta.api_base || ''; if (meta.api_base_default) { baseHint.classList.remove('hidden'); baseHint.querySelector('span').textContent = `${t('models_base_default')}: ${meta.api_base_default}`; } else { baseHint.classList.add('hidden'); } } else { baseWrap.classList.add('hidden'); baseInput.value = ''; } // ----- API Key ----- // For configured vendors, surface the masked key as the input *value* so // it shows up in the same dark text as a real entry — making "configured" // visually unambiguous. The masked form (e.g. "sk-r***zRU") is also a // sentinel: the save handler treats untouched masked input as "no change". const keyInput = document.getElementById('vendor-modal-key'); if (meta.configured && meta.api_key_masked) { keyInput.value = meta.api_key_masked; keyInput.dataset.masked = '1'; keyInput.dataset.maskedVal = meta.api_key_masked; keyInput.placeholder = ''; } else { keyInput.value = ''; keyInput.dataset.masked = ''; keyInput.dataset.maskedVal = ''; keyInput.placeholder = 'sk-...'; } const clearBtn = document.getElementById('vendor-modal-clear'); clearBtn.classList.toggle('hidden', !meta.configured); vendorModalState.providerId = providerId; } function closeVendorModal() { document.getElementById('vendor-modal-overlay').classList.add('hidden'); } function saveVendorModal() { const providerId = vendorModalState.providerId; if (!providerId) return; const keyInput = document.getElementById('vendor-modal-key'); const apiBase = document.getElementById('vendor-modal-base').value.trim(); // Treat "input still equals the masked value we surfaced on open" as "no // change" — the backend uses missing/empty api_key to skip the field. let apiKey = keyInput.value.trim(); const masked = keyInput.dataset.masked === '1'; const maskedVal = keyInput.dataset.maskedVal || ''; if (masked && apiKey === maskedVal) { apiKey = ''; } if (!apiKey && !masked) { // First-time setup with no key entered → nudge the user. keyInput.focus(); return; } const btn = document.getElementById('vendor-modal-save'); btn.disabled = true; const payload = { action: 'set_provider', provider_id: providerId, api_base: apiBase }; if (apiKey) payload.api_key = apiKey; fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).then(r => r.json()).then(data => { btn.disabled = false; if (data.status === 'success') { closeVendorModal(); const onSaved = vendorModalState.onSaved; if (onSaved) { try { onSaved(providerId); } catch (e) { /* noop */ } } else { loadModelsView(); } } else { showStatus('vendor-modal-status', 'models_save_failed', true); } }).catch(() => { btn.disabled = false; showStatus('vendor-modal-status', 'models_save_failed', true); }); } function clearVendorModal() { const providerId = vendorModalState.providerId; if (!providerId) return; showConfirmDialog({ title: t('models_clear_confirm_title'), message: t('models_clear_confirm_msg'), okText: t('models_clear_credential'), cancelText: t('cancel'), onConfirm: () => { fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete_provider', provider_id: providerId }), }).then(r => r.json()).then(data => { if (data.status === 'success') { closeVendorModal(); loadModelsView(); } else { showStatus('vendor-modal-status', 'models_clear_failed', true); } }).catch(() => showStatus('vendor-modal-status', 'models_clear_failed', true)); } }); } // ===================================================================== // 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); // 飞书 active 卡片渲染带 Tab 的 panel:手动填写 + 扫码重建(覆盖现有配置) const isFeishu = ch.name === 'feishu'; 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')}

` : ''} ${isFeishu ? buildFeishuPanel(ch, true) : (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(); stopFeishuRegisterPoll(); 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(); stopFeishuRegisterPoll(); 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; } if (chName === 'feishu') { actions.classList.add('hidden'); const ch = channelsData.find(c => c.name === chName); fieldsContainer.innerHTML = buildFeishuPanel(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 // ===================================================================== // NOTE: This is the only remaining external script in the Web Console. // Tencent's WeCom Bot SDK must be loaded from their official CDN — it // performs runtime origin/signature checks and will not work if // self-hosted. The SDK is fetched lazily, only when the user opens the // "WeCom Bot" channel QR-login flow, so the rest of the console works // fully offline. 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 wecomPanel = document.getElementById('wecom-bot-panel'); if (wecomPanel && !wecomPanel.dataset.initialized) { wecomPanel.dataset.initialized = '1'; switchWecomBotMode(wecomPanel.dataset.defaultMode || 'scan'); } const feishuPanel = document.getElementById('feishu-panel'); if (feishuPanel && !feishuPanel.dataset.initialized) { feishuPanel.dataset.initialized = '1'; switchFeishuMode(feishuPanel.dataset.defaultMode || 'scan'); } }); observer.observe(document.body, { childList: true, subtree: true }); }); // ===================================================================== // Feishu One-click App Registration (lark-oapi register_app) // ===================================================================== let _feishuRegisterPollTimer = null; function _feishuHasCreds(ch) { if (!ch || !ch.fields) return false; const idField = ch.fields.find(f => f.key === 'feishu_app_id'); const secretField = ch.fields.find(f => f.key === 'feishu_app_secret'); return !!(idField && idField.value && secretField && secretField.value); } function buildFeishuPanel(ch, isActive) { const scanLabel = t('feishu_mode_scan'); const manualLabel = t('feishu_mode_manual'); // 已有凭据时默认进入手动 Tab,方便修改;否则推荐扫码 const defaultMode = _feishuHasCreds(ch) ? 'manual' : 'scan'; const activeAttr = isActive ? 'data-active="1"' : ''; return `
`; } function switchFeishuMode(mode) { const panel = document.getElementById('feishu-panel'); const scanTab = document.getElementById('feishu-tab-scan'); const manualTab = document.getElementById('feishu-tab-manual'); const content = document.getElementById('feishu-mode-content'); if (!scanTab || !manualTab || !content) return; // 已激活通道卡片中嵌入此 panel 时,没有 add-channel-actions(保存按钮就近渲染) const isActive = panel && panel.dataset.active === '1'; const actions = isActive ? null : document.getElementById('add-channel-actions'); 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'; stopFeishuRegisterPoll(); if (mode === 'scan') { 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}`; if (actions) actions.classList.add('hidden'); // active 卡片下扫码替换的提示文案,强调"创建新机器人会覆盖现有配置" const desc = isActive ? t('feishu_scan_replace_desc') : t('feishu_scan_desc'); content.innerHTML = `

${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 === 'feishu'); const fieldsHtml = buildChannelFieldsHtml('feishu', ch ? ch.fields || [] : []); if (isActive) { // 已接入卡片:内置保存按钮,复用 saveChannelConfig 走 update 流程 content.innerHTML = `
${fieldsHtml}
`; } else { content.innerHTML = `
${fieldsHtml}
`; if (actions) actions.classList.remove('hidden'); } bindSecretFieldEvents(content); } } function stopFeishuRegisterPoll() { if (_feishuRegisterPollTimer) { clearTimeout(_feishuRegisterPollTimer); _feishuRegisterPollTimer = null; } } function startFeishuRegister(targetStatusId) { const statusId = targetStatusId || 'feishu-scan-status'; const statusEl = document.getElementById(statusId); if (statusEl) { statusEl.innerHTML = `

${t('feishu_scan_loading')}

`; } stopFeishuRegisterPoll(); fetch('/api/feishu/register') .then(r => r.json()) .then(data => { if (data.status !== 'success') { renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail')); return; } renderFeishuQr(statusId, data.qr_image, data.qrcode_url); pollFeishuRegisterStatus(statusId); }) .catch(err => { renderFeishuRegisterError(statusId, err.message || t('feishu_scan_fail')); }); } function renderFeishuQr(statusId, qrImage, qrUrl) { const statusEl = document.getElementById(statusId); if (!statusEl) return; const imgHtml = qrImage ? `QR` : `
QR
`; statusEl.innerHTML = `
${imgHtml}

${t('feishu_scan_waiting')}

${t('feishu_scan_tip')}

${qrUrl ? `${t('feishu_scan_open_link')}` : ''}
`; } function renderFeishuRegisterError(statusId, message) { const statusEl = document.getElementById(statusId); if (!statusEl) return; statusEl.innerHTML = `

${message}

`; } function pollFeishuRegisterStatus(statusId) { stopFeishuRegisterPoll(); _feishuRegisterPollTimer = setTimeout(() => { fetch('/api/feishu/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'poll' }) }) .then(r => r.json()) .then(data => { if (data.status !== 'success') { renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail')); return; } const rs = data.register_status; if (rs === 'done') { const statusEl = document.getElementById(statusId); if (statusEl) { statusEl.innerHTML = `

${t('feishu_scan_success')}

`; } connectFeishuAfterRegister(data.app_id, data.app_secret); } else if (rs === 'expired') { renderFeishuRegisterError(statusId, t('feishu_scan_expired')); } else if (rs === 'denied') { renderFeishuRegisterError(statusId, t('feishu_scan_denied')); } else if (rs === 'error') { renderFeishuRegisterError(statusId, data.message || t('feishu_scan_fail')); } else { pollFeishuRegisterStatus(statusId); } }) .catch(() => { pollFeishuRegisterStatus(statusId); }); }, 2000); } function connectFeishuAfterRegister(appId, appSecret) { fetch('/api/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'connect', channel: 'feishu', config: { feishu_app_id: appId, feishu_app_secret: appSecret } }) }) .then(r => r.json()) .then(data => { if (data.status === 'success') { const ch = channelsData.find(c => c.name === 'feishu'); if (ch) { ch.active = true; (ch.fields || []).forEach(f => { if (f.key === 'feishu_app_id') f.value = appId; if (f.key === 'feishu_app_secret') f.value = ChannelsHandler_maskSecret(appSecret); }); } setTimeout(() => renderActiveChannels(), 1500); } }) .catch(() => {}); } // ===================================================================== // 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 logLevelClass(line) { if (/\[CRITICAL\]/.test(line)) return 'log-line-critical'; if (/\[ERROR\]/.test(line)) return 'log-line-error'; if (/\[WARNING\]/.test(line)) return 'log-line-warning'; if (/\[INFO\]/.test(line)) return 'log-line-info'; if (/\[DEBUG\]/.test(line)) return 'log-line-debug'; return ''; } function getHiddenLevels() { const hidden = new Set(); document.querySelectorAll('.log-filter-cb').forEach(function(cb) { if (!cb.checked) hidden.add('log-line-' + cb.dataset.level); }); return hidden; } function applyLogFilter() { const hidden = getHiddenLevels(); document.querySelectorAll('#log-output .log-line').forEach(function(span) { const level = span.classList[1] || ''; span.style.display = hidden.has(level) ? 'none' : ''; }); } function appendLogLines(output, text) { const hidden = getHiddenLevels(); let lastLevelClass = ''; const lines = text.split('\n'); lines.forEach(function(line, i) { if (i === lines.length - 1 && line === '') return; const span = document.createElement('span'); const levelClass = logLevelClass(line) || lastLevelClass; if (logLevelClass(line)) lastLevelClass = levelClass; span.className = 'log-line ' + levelClass; span.textContent = line + '\n'; if (hidden.has(levelClass)) span.style.display = 'none'; output.appendChild(span); }); } document.addEventListener('change', function(e) { if (e.target.classList.contains('log-filter-cb')) applyLogFilter(); }); 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.innerHTML = ''; appendLogLines(output, item.content || ''); output.scrollTop = output.scrollHeight; } else if (item.type === 'line') { appendLogLines(output, 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 === 'models') loadModelsView(); else if (viewId === 'skills') loadSkillsView(); else if (viewId === 'memory') { document.getElementById('memory-panel-viewer').classList.add('hidden'); document.getElementById('memory-panel-list').classList.remove('hidden'); switchMemoryTab('files'); } 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 _knowledgeRootFiles = []; 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 || []; const rootFiles = data.root_files || []; _knowledgeTreeData = tree; _knowledgeRootFiles = rootFiles; 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, rootFiles); // Auto-select the first file (desktop only) if (window.innerWidth >= 768) { const firstFile = rootFiles.length > 0 ? rootFiles[0] : null; const firstGroup = !firstFile ? tree.find(g => g.files && g.files.length > 0) : null; if (firstFile) { openKnowledgeFile(firstFile.name, firstFile.title); } else if (firstGroup) { const gf = firstGroup.files[0]; openKnowledgeFile(firstGroup.dir + '/' + gf.name, gf.title); } } else { document.getElementById('knowledge-content-placeholder').classList.add('hidden'); document.getElementById('knowledge-content-viewer').classList.add('hidden'); } }).catch(() => {}); } function renderKnowledgeTree(tree, rootFilesOrFilter, filter) { const container = document.getElementById('knowledge-tree'); container.innerHTML = ''; let rootFiles, lowerFilter; if (typeof rootFilesOrFilter === 'string') { rootFiles = _knowledgeRootFiles; lowerFilter = (rootFilesOrFilter || '').toLowerCase(); } else { rootFiles = rootFilesOrFilter || _knowledgeRootFiles; lowerFilter = (filter || '').toLowerCase(); } (rootFiles || []).forEach(f => { if (lowerFilter && !f.title.toLowerCase().includes(lowerFilter) && !f.name.toLowerCase().includes(lowerFilter)) return; const fbtn = document.createElement('button'); fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === f.name ? ' active' : ''); fbtn.dataset.path = f.name; fbtn.innerHTML = `${escapeHtml(f.title)}`; fbtn.onclick = () => openKnowledgeFile(f.name, f.title); container.appendChild(fbtn); }); _renderKnowledgeGroups(container, tree, '', lowerFilter, 0); } function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, depth) { const indent = depth * 12; groups.forEach(group => { const groupPath = parentPath ? parentPath + '/' + group.dir : group.dir; const files = (group.files || []).filter(f => !lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter) ); const children = group.children || []; const hasMatchingChildren = lowerFilter ? _hasFilterMatch(children, lowerFilter) : children.length > 0; if (files.length === 0 && !hasMatchingChildren && lowerFilter) return; const div = document.createElement('div'); div.className = 'knowledge-tree-group open'; const fileCount = _countFiles(group); const btn = document.createElement('button'); btn.className = 'knowledge-tree-group-btn'; btn.style.paddingLeft = (8 + indent) + 'px'; btn.innerHTML = `${escapeHtml(group.dir)}${fileCount}`; 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 = groupPath + '/' + f.name; fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : ''); fbtn.dataset.path = fpath; fbtn.style.paddingLeft = (24 + indent) + 'px'; fbtn.innerHTML = `${escapeHtml(f.title)}`; fbtn.onclick = () => openKnowledgeFile(fpath, f.title); items.appendChild(fbtn); }); if (children.length > 0) { _renderKnowledgeGroups(items, children, groupPath, lowerFilter, depth + 1); } div.appendChild(items); container.appendChild(div); }); } function _hasFilterMatch(groups, lowerFilter) { for (const g of groups) { for (const f of (g.files || [])) { if (f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter)) return true; } if (_hasFilterMatch(g.children || [], lowerFilter)) return true; } return false; } function _countFiles(group) { let count = (group.files || []).length; for (const child of (group.children || [])) { count += _countFiles(child); } return count; } function filterKnowledgeTree(query) { renderKnowledgeTree(_knowledgeTreeData, _knowledgeRootFiles, 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 f of _knowledgeRootFiles) { if (f.name === filename) return { path: f.name, title: f.title }; } return _searchFileInGroups(_knowledgeTreeData, '', filename); } function _searchFileInGroups(groups, parentPath, filename) { for (const group of groups) { const groupPath = parentPath ? parentPath + '/' + group.dir : group.dir; for (const f of (group.files || [])) { if (f.name === filename) { return { path: groupPath + '/' + f.name, title: f.title }; } } const found = _searchFileInGroups(group.children || [], groupPath, filename); if (found) return found; } 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(); } } } let _d3LoadPromise = null; function ensureD3Loaded() { if (window.d3) return Promise.resolve(window.d3); if (_d3LoadPromise) return _d3LoadPromise; _d3LoadPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'assets/vendor/d3/d3.min.js'; script.async = true; script.onload = () => resolve(window.d3); script.onerror = () => reject(new Error('Failed to load d3')); document.head.appendChild(script); }); return _d3LoadPromise; } function loadKnowledgeGraph() { _knowledgeGraphLoaded = true; const container = document.getElementById('knowledge-graph-container'); container.innerHTML = '
Loading graph...
'; Promise.all([ ensureD3Loaded(), 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; } container.innerHTML = ''; 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() { applyI18n(); _applyInputTooltips(); _restoreSessionPanel(); fetch('/api/knowledge/list').then(r => r.json()).then(data => { if (data.status === 'success') { _knowledgeTreeData = data.tree || []; _knowledgeRootFiles = data.root_files || []; } }).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'); });