/* =====================================================================
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_voice: '音色',
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_search_strategy_label: '策略',
models_search_strategy_fixed: '指定',
models_search_strategy_auto_hint: '从已配置厂商中自动选择',
models_search_strategy_fixed_hint: '指定使用搜索厂商',
models_pending_config: '待配置',
models_search_available_label: '可用搜索厂商:',
models_search_none_configured: '暂未启用任何搜索厂商,点击添加',
models_search_add_provider: '添加厂商',
models_search_add_desc: '选择一个搜索厂商进行配置',
models_search_bocha_title: '配置博查 API Key',
models_search_bocha_desc: '前往博查开放平台创建 API Key',
models_search_edit_hint: '点击修改配置',
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_pick_provider: '待选择',
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: '查看全部命令',
slash_help: '显示命令帮助',
slash_status: '查看运行状态',
slash_context: '查看对话上下文',
slash_context_clear: '清除对话上下文',
slash_skill_list: '查看已安装技能',
slash_skill_list_remote: '浏览技能广场',
slash_skill_search: '搜索技能',
slash_skill_install: '安装技能 (名称或 GitHub URL)',
slash_skill_uninstall: '卸载技能',
slash_skill_info: '查看技能详情',
slash_skill_enable: '启用技能',
slash_skill_disable: '禁用技能',
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
slash_knowledge: '查看知识库统计',
slash_knowledge_list: '查看知识库文件树',
slash_knowledge_on: '开启知识库',
slash_knowledge_off: '关闭知识库',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置',
config_language: '语言', config_language_hint: '界面展示、命令文案、系统报错等使用的语言(与右上角切换同步)',
config_model_advanced: '高级配置',
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: '上传文件',
mic_idle_title: '点击录音 / 再按一次结束',
mic_recording_title: '录音中,再次点击结束',
mic_busy_title: '识别中…',
mic_permission_denied: '无法访问麦克风,请检查浏览器权限',
mic_too_short: '录音太短,请重试',
mic_error: '语音识别失败',
speak_msg: '朗读这段回复',
voice_reply_mode_label: '语音回复策略',
voice_reply_off: '关闭',
voice_reply_if_voice: '仅语音问/语音答',
voice_reply_always: '总是语音回复',
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, image, voice, embedding and search capabilities in one place',
models_section_vendors: 'Vendor Credentials',
models_section_vendors_desc: 'Configured once, shared by multiple model capabilities',
models_section_capabilities: 'Capabilities',
models_add_vendor: 'Add Vendor',
models_provider: 'Provider',
models_model: 'Model',
models_voice: 'Voice',
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: 'Used for basic chat and agent reasoning',
models_capability_vision: 'Image Understanding',
models_capability_vision_desc: 'Recognizes image content, used by image recognition tools',
models_capability_image: 'Image Generation',
models_capability_image_desc: 'Generates images, used by image generation skills',
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: 'Used for vectorized retrieval of memory and knowledge',
models_capability_search: 'Web Search',
models_capability_search_desc: 'Real-time web retrieval, used by search tools',
models_strategy_auto: 'auto',
models_search_strategy_label: 'Strategy',
models_search_strategy_fixed: 'Pinned',
models_search_strategy_auto_hint: 'Auto-pick from configured providers',
models_search_strategy_fixed_hint: 'Always use a specific provider',
models_pending_config: 'Pending setup',
models_search_available_label: 'Available:',
models_search_none_configured: 'No search provider enabled yet — click add.',
models_search_add_provider: 'Add provider',
models_search_add_desc: 'Pick a search provider to configure',
models_search_bocha_title: 'Configure Bocha API Key',
models_search_bocha_desc: 'Create a key at the Bocha open platform.',
models_search_edit_hint: 'Click to edit',
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_pick_provider: 'Pick a provider',
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',
slash_help: 'Show this help',
slash_status: 'Show running status',
slash_context: 'Show conversation context',
slash_context_clear: 'Clear conversation context',
slash_skill_list: 'List installed skills',
slash_skill_list_remote: 'Browse Skill Hub',
slash_skill_search: 'Search skills',
slash_skill_install: 'Install a skill (name or GitHub URL)',
slash_skill_uninstall: 'Uninstall a skill',
slash_skill_info: 'Show skill details',
slash_skill_enable: 'Enable a skill',
slash_skill_disable: 'Disable a skill',
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
slash_knowledge: 'Show knowledge base stats',
slash_knowledge_list: 'Show knowledge base file tree',
slash_knowledge_on: 'Enable knowledge base',
slash_knowledge_off: 'Disable knowledge base',
slash_config: 'Show current config',
slash_cancel: 'Abort the running Agent task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
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_language: 'Language', config_language_hint: 'Language for the UI, command text, system messages and more (synced with the top-right switch)',
config_model_advanced: 'Advanced',
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',
mic_idle_title: 'Click to record, click again to stop',
mic_recording_title: 'Recording, click to stop',
mic_busy_title: 'Transcribing…',
mic_permission_denied: 'Cannot access microphone — check browser permissions',
mic_too_short: 'Recording too short, please retry',
mic_error: 'Speech recognition failed',
speak_msg: 'Read this reply aloud',
voice_reply_mode_label: 'Voice reply policy',
voice_reply_off: 'Off',
voice_reply_if_voice: 'Voice only if voice input',
voice_reply_always: 'Always reply with voice',
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',
}
};
// Resolve language by priority: user choice (localStorage) -> backend-detected
// (cow_lang) -> browser language -> 'zh'. Shares __cowResolveLang__ defined in
// chat.html; falls back to a local resolver if loaded standalone.
let currentLang = (typeof window.__cowResolveLang__ === 'function')
? window.__cowResolveLang__()
: (function () {
const norm = (raw) => {
if (!raw) return '';
const v = String(raw).trim().toLowerCase();
if (v === 'auto') return '';
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
};
return norm(localStorage.getItem('cow_lang'))
|| norm(window.__COW_DEFAULT_LANG__)
|| norm(navigator.language)
|| 'zh';
})();
function t(key) {
return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key;
}
// Resolve a localized label that may be either a plain string or
// a {zh, en} object returned by the backend.
function localizedLabel(label) {
if (label && typeof label === 'object') {
return label[currentLang] || label.en || label.zh || '';
}
return label || '';
}
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';
}
// Single entry point for switching language. Updates the in-memory language,
// persists the user choice locally, re-renders the UI, and binds the choice to
// the backend `cow_lang` config so logs / agent replies / CLI follow suit.
function setLanguage(lang) {
const next = (lang === 'en') ? 'en' : 'zh';
if (next === currentLang) {
// Still persist + sync in case storage/backend drifted from the UI.
syncLanguageToBackend(next);
return;
}
currentLang = next;
localStorage.setItem('cow_lang', currentLang);
applyI18n();
_applyInputTooltips();
// Re-render views whose DOM is built in JS (data-i18n alone does not
// cover strings interpolated via t() into innerHTML).
try { rerenderDynamicViews(); } catch (e) {}
// Keep the language switch button and config selector visually in sync.
try { updateLangControls(); } catch (e) {}
syncLanguageToBackend(currentLang);
}
// Persist the language to the backend `cow_lang` config (best-effort; the UI
// has already switched locally, so a network failure is non-blocking).
function syncLanguageToBackend(lang) {
try {
fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { cow_lang: lang } })
}).catch(() => {});
} catch (e) {}
}
// Reflect the current language on both the top-right toggle and the config
// selector (if present), so the two entry points stay synchronized.
function updateLangControls() {
const langLabel = document.getElementById('lang-label');
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
// The config language picker is the custom .cfg-dropdown component. Only
// sync it once it has been initialized (i.e. the config panel was opened).
const sel = document.getElementById('cfg-lang-select');
if (sel && sel._ddValue !== undefined && sel._ddValue !== currentLang) {
sel._ddValue = currentLang;
const textEl = sel.querySelector('.cfg-dropdown-text');
if (textEl) textEl.textContent = currentLang === 'zh' ? '中文' : 'English';
sel.querySelectorAll('.cfg-dropdown-item').forEach(i => {
i.classList.toggle('active', i.dataset.value === currentLang);
});
}
}
function toggleLanguage() {
setLanguage(currentLang === 'zh' ? 'en' : 'zh');
}
// Refresh JS-rendered views after a language switch. Each branch uses the
// lightweight in-memory re-render path (no extra network round-trips).
function rerenderDynamicViews() {
if (currentView === 'models' && typeof renderModelsView === 'function'
&& modelsState && (modelsState.providers || modelsState.capabilities)) {
renderModelsView();
}
}
// 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 `]*>[^<]*<\/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');
}
// ---------------- Mic button: in-page voice input via the configured ASR provider ----------------
(function setupMicButton() {
const micBtn = document.getElementById('mic-btn');
if (!micBtn) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia ||
typeof window.MediaRecorder === 'undefined') {
micBtn.style.display = 'none';
return;
}
let mediaRecorder = null;
let stream = null;
let chunks = [];
let recording = false;
const setIdle = () => {
recording = false;
micBtn.classList.remove('text-red-500', 'animate-pulse');
micBtn.classList.add('text-slate-400');
micBtn.querySelector('i').className = 'fas fa-microphone text-sm';
micBtn.title = t('mic_idle_title');
};
const setRecording = () => {
recording = true;
micBtn.classList.remove('text-slate-400');
micBtn.classList.add('text-red-500', 'animate-pulse');
micBtn.querySelector('i').className = 'fas fa-stop text-sm';
micBtn.title = t('mic_recording_title');
};
const setBusy = () => {
micBtn.classList.remove('text-red-500', 'animate-pulse', 'text-slate-400');
micBtn.classList.add('text-primary-500');
micBtn.querySelector('i').className = 'fas fa-spinner fa-spin text-sm';
micBtn.title = t('mic_busy_title');
};
const pickMimeType = () => {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
];
for (const m of candidates) {
if (window.MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) {
return m;
}
}
return '';
};
const stopStream = () => {
if (stream) {
stream.getTracks().forEach(t => t.stop());
stream = null;
}
};
let _micTipTimer = null;
const flashError = (msg) => {
console.warn('[mic]', msg);
// Pop a small bubble above the mic so the user actually notices it.
// The mic lives inside a relatively-positioned wrapper around the
// textarea (see chat.html), so we hang the tip off that wrapper.
const wrapper = micBtn.parentElement;
if (!wrapper) return;
let tip = wrapper.querySelector('.mic-tip');
if (!tip) {
tip = document.createElement('div');
tip.className = 'mic-tip absolute right-1 bottom-full mb-2 px-2 py-1 rounded-md '
+ 'text-xs text-white bg-slate-800/90 dark:bg-slate-700/90 shadow-md '
+ 'pointer-events-none whitespace-nowrap z-10';
wrapper.appendChild(tip);
}
tip.textContent = msg;
tip.style.opacity = '1';
if (_micTipTimer) clearTimeout(_micTipTimer);
_micTipTimer = setTimeout(() => {
tip.style.opacity = '0';
tip.style.transition = 'opacity 200ms';
setTimeout(() => tip.remove(), 250);
}, 2000);
};
const upload = async (blob, ext) => {
setBusy();
const fd = new FormData();
fd.append('file', blob, `recording.${ext}`);
try {
const resp = await fetch('/api/voice/asr', { method: 'POST', body: fd });
const data = await resp.json();
if (data.status === 'success' && data.text) {
// Voice-message UX: drop the recording into the conversation
// as a playable bubble with the caption underneath, then
// dispatch the recognised text through the regular send path.
sendVoiceMessage(data.text, data.audio_url);
} else {
flashError(data.message || t('mic_error'));
}
} catch (e) {
flashError(t('mic_error') + ': ' + e.message);
} finally {
setIdle();
}
};
const start = async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
flashError(t('mic_permission_denied'));
return;
}
chunks = [];
const mimeType = pickMimeType();
try {
mediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
} catch (e) {
stopStream();
flashError(t('mic_error') + ': ' + e.message);
return;
}
mediaRecorder.ondataavailable = (ev) => {
if (ev.data && ev.data.size > 0) chunks.push(ev.data);
};
mediaRecorder.onstop = () => {
stopStream();
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
// Map mime -> extension so the server picks the right file suffix.
const mt = (mediaRecorder.mimeType || 'audio/webm').split(';')[0];
const extMap = {
'audio/webm': 'webm', 'audio/ogg': 'ogg',
'audio/mp4': 'm4a', 'audio/mpeg': 'mp3',
};
const ext = extMap[mt] || 'webm';
// 256 bytes ~ container header only, no actual audio. Anything
// below that we treat as "tapped by mistake".
if (blob.size < 256) {
setIdle();
flashError(t('mic_too_short'));
return;
}
upload(blob, ext);
};
// timeslice=250ms: force the recorder to flush a chunk every 250ms.
// Without it some browsers wait for stop() before producing any data,
// which loses the audio on very short taps.
mediaRecorder.start(250);
recordStartedAt = Date.now();
setRecording();
};
let recordStartedAt = 0;
const stopWithMinDuration = () => {
const elapsed = Date.now() - recordStartedAt;
const minMs = 350;
if (elapsed < minMs) {
// Give the recorder a moment to capture at least one chunk
// before we tell it to stop.
setTimeout(() => stop(), minMs - elapsed);
} else {
stop();
}
};
const stop = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
};
micBtn.addEventListener('click', () => {
if (recording) {
stopWithMinDuration();
} else {
start();
}
});
setIdle();
})();
// 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 = '';
// While an SSE stream is in flight, the send button morphs into a cancel
// button. Only one in-flight request is supported at a time.
let activeRequestId = null;
let sendBtnMode = 'send'; // 'send' | 'cancel'
function setSendBtnCancelMode(requestId) {
activeRequestId = requestId;
sendBtnMode = 'cancel';
sendBtn.disabled = false;
sendBtn.classList.add('send-btn-cancel');
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
sendBtn.innerHTML = ' ';
}
function resetSendBtnSendMode() {
activeRequestId = null;
sendBtnMode = 'send';
sendBtn.classList.remove('send-btn-cancel');
sendBtn.title = '';
sendBtn.innerHTML = ' ';
updateSendBtnState();
}
function requestCancel() {
const reqId = activeRequestId;
if (!reqId) return;
fetch('/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: reqId, session_id: sessionId, lang: currentLang }),
}).catch(err => {
console.warn('[cancel] request failed', err);
});
// Optimistic UI lock so the click visibly registers before the SSE
// "cancelled" event arrives.
sendBtn.disabled = true;
sendBtn.title = (currentLang === 'zh' ? '已中止' : 'Cancelled');
}
// Button click is the only path to Cancel. Pressing Enter still calls
// sendMessage() so users can submit "/cancel" as a regular slash command.
sendBtn.addEventListener('click', () => {
if (sendBtnMode === 'cancel') {
requestCancel();
} else {
sendMessage();
}
});
function updateSendBtnState() {
if (sendBtnMode === 'cancel') {
// Don't downgrade a Cancel button on input edits.
return;
}
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 `
×
`;
}
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 ───────────────────────────────────────
// desc holds an i18n key, resolved via t() at render time so the menu follows
// the current UI language.
const SLASH_COMMANDS = [
{ cmd: '/help', desc: 'slash_help' },
{ cmd: '/status', desc: 'slash_status' },
{ cmd: '/context', desc: 'slash_context' },
{ cmd: '/context clear', desc: 'slash_context_clear' },
{ cmd: '/skill list', desc: 'slash_skill_list' },
{ cmd: '/skill list --remote', desc: 'slash_skill_list_remote' },
{ cmd: '/skill search ', desc: 'slash_skill_search' },
{ cmd: '/skill install ', desc: 'slash_skill_install' },
{ cmd: '/skill uninstall ', desc: 'slash_skill_uninstall' },
{ cmd: '/skill info ', desc: 'slash_skill_info' },
{ cmd: '/skill enable ', desc: 'slash_skill_enable' },
{ cmd: '/skill disable ', desc: 'slash_skill_disable' },
{ cmd: '/memory dream ', desc: 'slash_memory_dream' },
{ cmd: '/knowledge', desc: 'slash_knowledge' },
{ cmd: '/knowledge list', desc: 'slash_knowledge_list' },
{ cmd: '/knowledge on', desc: 'slash_knowledge_on' },
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
{ cmd: '/config', desc: 'slash_config' },
{ cmd: '/cancel', desc: 'slash_cancel' },
{ cmd: '/logs', desc: 'slash_logs' },
{ cmd: '/version', desc: 'slash_version' },
];
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 =
'' +
slashFiltered.map((c, i) =>
``
).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();
}
});
});
// Voice-message variant of sendMessage(): renders a playable audio bubble
// with the ASR caption, then dispatches the recognised text to /message
// through the same SSE/loading flow as a typed message.
function sendVoiceMessage(text, audioUrl) {
text = (text || '').trim();
if (!text) return;
inputHistory.push(text);
historyIdx = -1;
historySavedDraft = '';
const ws = document.getElementById('welcome-screen');
const isFirstMessage = !!ws;
if (ws) ws.remove();
const titleInfo = isFirstMessage ? { sid: sessionId, userMsg: text } : null;
const timestamp = new Date();
addUserVoiceMessage(audioUrl, text, timestamp);
const loadingEl = addLoadingIndicator();
const body = {
session_id: sessionId,
message: text,
stream: true,
timestamp: timestamp.toISOString(),
is_voice: true,
lang: currentLang,
};
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.inline_reply) {
// Synchronous fast-path reply (e.g. /cancel); skip SSE.
loadingEl.remove();
addBotMessage(data.inline_reply, new Date());
} else if (data.stream) {
setSendBtnCancelMode(data.request_id);
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
} else {
loadingContainers[data.request_id] = loadingEl;
}
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
})
.catch(err => {
if (attempt < MAX_RETRIES) {
setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1));
return;
}
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
});
}
postWithRetry(0);
}
function addUserVoiceMessage(audioUrl, caption, timestamp) {
const el = document.createElement('div');
el.className = 'flex justify-end px-4 sm:px-6 py-3';
// Voice-message bubble: compact voice pill on top, ASR caption beneath.
// The bubble keeps the same primary tint as a normal user message so
// it visually slots into the conversation flow.
el.innerHTML = `
${caption ? `
${escapeHtml(caption)}
` : ''}
${formatTime(timestamp)}
`;
el.querySelector('.user-voice-slot').appendChild(renderVoicePill(audioUrl));
messagesDiv.appendChild(el);
_autoScrollEnabled = true;
scrollChatToBottom(true);
}
function sendMessage() {
// Do NOT branch on sendBtnMode here: Enter should always send (so
// typing "/cancel" submits normally). Cancel is wired only to the
// send button's pointer click — see send-btn listener above.
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(), lang: currentLang };
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.inline_reply) {
// Channel handled synchronously (e.g. /cancel fast-path);
// render as a bot bubble and skip SSE entirely.
loadingEl.remove();
addBotMessage(data.inline_reply, new Date());
} else if (data.stream) {
setSendBtnCancelMode(data.request_id);
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
} else {
loadingContainers[data.request_id] = loadingEl;
}
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
})
.catch(err => {
if (err.name === 'AbortError') {
loadingEl.remove();
addBotMessage(t('error_timeout'), new Date());
resetSendBtnSendMode();
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());
resetSendBtnSendMode();
});
}
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 = `
`;
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 = `
`;
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 = `
`;
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 === 'cancelled') {
// Agent acknowledged the stop; mark the bubble. A trailing
// "done" still arrives with the partial answer.
ensureBotEl();
if (currentReasoningEl) {
finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText);
currentReasoningEl = null;
reasoningText = '';
}
if (!botEl.querySelector('.agent-cancelled-tag')) {
const tag = document.createElement('div');
tag.className = 'agent-cancelled-tag text-xs text-amber-600 dark:text-amber-400 mt-1';
tag.textContent = (currentLang === 'zh') ? '已中止' : 'Cancelled';
stepsEl.appendChild(tag);
}
resetSendBtnSendMode();
} else if (item.type === 'done') {
// Don't close the stream yet: the backend keeps it open
// for a short tail to deliver async attachments such as
// TTS audio (`voice_attach`). It will close the stream on
// its own via onerror once the tail expires.
done = true;
resetSendBtnSendMode();
const finalTextRaw = item.content || accumulatedText;
const finalText = localizeCancelMarker(finalTextRaw);
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 = finalTextRaw || '';
const copyBtn = botEl.querySelector('.copy-msg-btn');
if (copyBtn && finalText) copyBtn.style.display = '';
applyHighlighting(botEl);
}
renderBotSpeakerButton(botEl, finalText);
scrollChatToBottom();
if (titleInfo) {
generateSessionTitle(titleInfo.sid, titleInfo.userMsg, '');
titleInfo = null;
} else if (sessionPanelOpen) {
loadSessionList();
}
} else if (item.type === 'voice_attach') {
// TTS finished — attach a playable audio element to the
// current bot bubble. The stream closes right after.
if (botEl && item.url) {
attachAudioToBotBubble(botEl, item.url, { autoplay: true });
}
es.close();
delete activeStreams[requestId];
} else if (item.type === 'error') {
done = true;
es.close();
delete activeStreams[requestId];
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
};
es.onerror = function() {
es.close();
delete activeStreams[requestId];
if (done) {
// Normal close after the post-done tail expired; nothing to do.
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);
}
resetSendBtnSendMode();
};
}
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 ` `;
}
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 `
`;
}).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 `
${_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 += `
`;
// 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 ``;
}
// Cosmetic translator for cancel markers persisted in history.
// History keeps the English canonical form for the LLM; only display is localized.
function localizeCancelMarker(text) {
if (!text) return text;
if (currentLang !== 'zh') return text;
return text
.replace(/_\(Cancelled by user\)_/g, '_(用户已中止)_')
.replace(/_\(Cancelled\)_/g, '_(已中止)_');
}
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 = localizeCancelMarker(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 = `
${stepsHtml ? `
${stepsHtml}
` : ''}
${renderMarkdown(displayContent)}
${formatTime(timestamp)}
`;
el.querySelector('.answer-content').dataset.rawMd = displayContent;
// Existing TTS attachment (history replay): mount the player up-front.
const existingAudio = msg && msg.extras && msg.extras.audio && msg.extras.audio.url;
if (existingAudio) {
attachAudioToBotBubble(el, existingAudio, { autoplay: false });
}
renderBotSpeakerButton(el, displayContent);
applyHighlighting(el);
bindChatKnowledgeLinks(el);
return el;
}
// Append (or replace) a small audio player inside a bot bubble's
// dedicated `.bot-audio-slot`. Used by both live TTS pushes and history
// replay. Silent failures: never throws.
function attachAudioToBotBubble(botEl, audioUrl, opts) {
try {
if (!botEl || !audioUrl) return;
const slot = botEl.querySelector('.bot-audio-slot');
if (!slot) return;
slot.innerHTML = '';
slot.style.marginTop = '6px';
const pill = renderVoicePill(audioUrl, { autoplay: !!(opts && opts.autoplay) });
slot.appendChild(pill);
const speakBtn = botEl.querySelector('.speak-msg-btn');
if (speakBtn) speakBtn.style.display = 'none';
} catch (_) { /* silent */ }
}
// Build a compact play/pause + progress + duration pill that wraps a
// hidden . Returns the root element; safe to embed anywhere.
function renderVoicePill(audioUrl, opts) {
opts = opts || {};
const wrap = document.createElement('div');
wrap.className = 'voice-pill';
wrap.innerHTML = `
0:00
`;
const btn = wrap.querySelector('.voice-pill-btn');
const fill = wrap.querySelector('.voice-pill-fill');
const timeEl = wrap.querySelector('.voice-pill-time');
const audio = wrap.querySelector('audio');
const fmt = (s) => {
if (!isFinite(s) || s < 0) s = 0;
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return `${m}:${r < 10 ? '0' : ''}${r}`;
};
const setIcon = (state) => {
btn.dataset.state = state;
btn.querySelector('i').className = state === 'pause' ? 'fas fa-pause' : 'fas fa-play';
btn.setAttribute('aria-label', state === 'pause' ? 'pause' : 'play');
};
audio.addEventListener('loadedmetadata', () => {
if (audio.duration && isFinite(audio.duration)) timeEl.textContent = fmt(audio.duration);
});
audio.addEventListener('timeupdate', () => {
const dur = audio.duration || 0;
if (dur > 0) {
fill.style.width = `${Math.min(100, (audio.currentTime / dur) * 100)}%`;
timeEl.textContent = fmt(dur - audio.currentTime);
}
});
audio.addEventListener('ended', () => {
setIcon('play');
fill.style.width = '0%';
timeEl.textContent = fmt(audio.duration || 0);
});
audio.addEventListener('play', () => setIcon('pause'));
audio.addEventListener('pause', () => setIcon('play'));
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (audio.paused) {
audio.play().catch(() => {});
} else {
audio.pause();
}
});
if (opts.autoplay) {
// Autoplay may be blocked by the browser; fall back silently and
// let the user tap the play button.
const tryPlay = () => audio.play().catch(() => {});
if (audio.readyState >= 2) tryPlay();
else audio.addEventListener('canplay', tryPlay, { once: true });
}
return wrap;
}
// Show the manual "read aloud" button when TTS is configured but the
// bubble has no audio yet. Lazily probes capability via /api/models so
// we don't expose the button when nothing can synthesize speech.
function renderBotSpeakerButton(botEl, text) {
if (!botEl || !text || !text.trim()) return;
const btn = botEl.querySelector('.speak-msg-btn');
if (!btn) return;
if (botEl.querySelector('.bot-audio-slot audio')) return;
_isTtsReady().then(ready => {
if (!ready) return;
btn.style.display = '';
btn.onclick = () => _triggerManualTts(btn, botEl, text);
});
}
let _ttsReadyPromise = null;
let _ttsReadyTs = 0;
function _isTtsReady() {
// Cache for 30s to avoid hammering /api/models on every bubble.
if (_ttsReadyPromise && Date.now() - _ttsReadyTs < 30000) {
return _ttsReadyPromise;
}
_ttsReadyTs = Date.now();
_ttsReadyPromise = fetch('/api/models')
.then(r => r.json())
.then(data => {
const tts = data && data.capabilities && data.capabilities.tts;
if (!tts) return false;
return Boolean(tts.current_provider || tts.suggested_provider);
})
.catch(() => false);
return _ttsReadyPromise;
}
function _triggerManualTts(btn, botEl, text) {
if (btn.dataset.busy === '1') return;
btn.dataset.busy = '1';
const icon = btn.querySelector('i');
const prev = icon ? icon.className : '';
if (icon) icon.className = 'fas fa-spinner fa-spin';
fetch('/api/voice/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, session_id: sessionId }),
})
.then(r => r.json())
.then(data => {
if (data && data.status === 'success' && data.audio_url) {
attachAudioToBotBubble(botEl, data.audio_url, { autoplay: true });
}
})
.catch(() => {})
.finally(() => {
btn.dataset.busy = '0';
if (icon) icon.className = prev || 'fas fa-volume-up';
});
}
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 = `Load earlier messages `;
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 = `
`;
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 = `
${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)}
${t('confirm_cancel')}
${t('confirm_yes')}
`;
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, opts) {
// opts.placeholder: when set AND selectedValue is empty, render that text
// in a dim style instead of auto-selecting options[0]. Useful for
// "pick or empty" capabilities (asr / embedding) where we want the
// user to make an explicit choice.
opts = opts || {};
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);
if (sel) {
textEl.textContent = sel.label;
textEl.classList.remove('text-slate-400', 'dark:text-slate-500');
} else if (opts.placeholder && !el._ddValue) {
// No selection yet — show the placeholder in muted style.
// Do NOT write a fallback value, so the dropdown stays
// "unsaved" until the user explicitly picks.
textEl.textContent = opts.placeholder;
textEl.classList.add('text-slate-400', 'dark:text-slate-500');
} else {
textEl.textContent = options[0] ? options[0].label : '--';
textEl.classList.remove('text-slate-400', 'dark:text-slate-500');
if (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: localizedLabel(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;
// Reflect the current UI language (already resolved, may include the user's
// local choice) on the selector so it stays in sync with the top-right toggle.
const langSel = document.getElementById('cfg-lang-select');
if (langSel) {
initDropdown(
langSel,
[{ value: 'zh', label: '中文' }, { value: 'en', label: 'English' }],
currentLang,
(val) => setLanguage(val)
);
}
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 += `Prev `;
if (page < totalPages) pagHtml += `Next `;
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: true, 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')}
${t('models_add_vendor')}
`;
} 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 `
${renderProviderLogo(p, 28)}
${escapeHtml(localizedLabel(p.label))}
`;
}
// 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 = (localizedLabel(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) {
return '';
}
function _searchProviderLabel(cap, providerId) {
const list = (cap && cap.providers) || [];
const hit = list.find(p => p.id === providerId);
return hit ? localizedLabel(hit.label) : providerId;
}
// Search card body: strategy picker + (when fixed) provider picker + a
// status row that surfaces which providers are ready and how to add the
// missing ones. Three of the four backends piggy-back on model-vendor
// credentials (zhipu / qianfan / linkai); bocha owns its own key under
// tools.web_search and gets its own minimal credential modal.
function renderSearchCapability(def, cap, body) {
const providers = cap.providers || [];
const configuredIds = cap.configured_providers || [];
const hasAny = configuredIds.length > 0;
const strategy = cap.strategy || 'auto';
body.innerHTML = `
${t('models_search_strategy_label')}
${t('save')}
`;
// Strategy dropdown — when no provider is configured the strategy
// value is meaningless, so we show a "待配置" placeholder instead of
// a default selection. Once any provider gets configured the saved
// strategy (or "auto") becomes the active value.
initDropdown(
body.querySelector('#cap-search-strategy'),
[
{ value: 'auto', label: t('models_strategy_auto'), hint: t('models_search_strategy_auto_hint') },
{ value: 'fixed', label: t('models_search_strategy_fixed'), hint: t('models_search_strategy_fixed_hint') },
],
hasAny ? strategy : '',
(value) => _onSearchStrategyChange(cap, value, body),
hasAny ? null : { placeholder: t('models_pending_config') },
);
// Provider dropdown — populated with configured providers only;
// unconfigured ones cannot be pinned (they'd silently fall back).
const provOpts = configuredIds.map(id => ({
value: id,
label: _searchProviderLabel(cap, id),
}));
if (provOpts.length === 0) provOpts.push({ value: '', label: '--' });
initDropdown(
body.querySelector('#cap-search-provider'),
provOpts,
cap.fixed_provider || configuredIds[0] || '',
() => {},
);
_renderSearchSummary(body, cap);
_setSearchProviderPickerVisible(body, strategy === 'fixed' && hasAny);
}
function _onSearchStrategyChange(cap, value, body) {
const configuredIds = cap.configured_providers || [];
_setSearchProviderPickerVisible(body, value === 'fixed' && configuredIds.length > 0);
}
function _setSearchProviderPickerVisible(body, visible) {
const wrap = body.querySelector('#cap-search-provider-wrap');
if (!wrap) return;
if (visible) wrap.classList.remove('hidden');
else wrap.classList.add('hidden');
}
// Search summary line: just lists configured providers + a trailing "+
// add" button. Unconfigured backends are hidden — the user picks one from
// a small chooser when they click add. Empty state surfaces the same add
// button as a primary CTA.
function _renderSearchSummary(body, cap) {
const host = body.querySelector('#cap-search-summary');
if (!host) return;
const providers = cap.providers || [];
const configured = providers.filter(p => p.configured);
const missing = providers.filter(p => !p.configured);
const addBtn = missing.length
? `
${t('models_search_add_provider')}
`
: '';
if (configured.length === 0) {
host.innerHTML = `
${t('models_search_none_configured')}
${addBtn}
`;
} else {
const chips = configured.map(p => `
${escapeHtml(localizedLabel(p.label))}
`).join('');
host.innerHTML = `
${t('models_search_available_label')}
${chips}
${addBtn}
`;
}
const addBtnEl = host.querySelector('#cap-search-add-btn');
if (addBtnEl) {
addBtnEl.addEventListener('click', (ev) => {
ev.preventDefault();
openSearchAddProviderPicker(missing);
});
}
host.querySelectorAll('[data-search-edit-provider]').forEach(el => {
el.addEventListener('click', (ev) => {
ev.preventDefault();
const pid = el.getAttribute('data-search-edit-provider');
const meta = (cap.providers || []).find(p => p.id === pid);
_launchSearchProviderConfig(pid, meta);
});
});
}
// Two-step add flow: click "+ 添加厂商" -> chooser dialog -> per-provider
// credential editor. Bocha lands on the dedicated key modal; the others
// piggy-back on the existing vendor credential modal.
function openSearchAddProviderPicker(missingProviders) {
if (!missingProviders || missingProviders.length === 0) return;
if (missingProviders.length === 1) {
_launchSearchProviderConfig(missingProviders[0].id);
return;
}
const existing = document.getElementById('search-add-modal');
if (existing) existing.remove();
const rows = missingProviders.map(p => `
${escapeHtml(localizedLabel(p.label))}
`).join('');
const modal = document.createElement('div');
modal.id = 'search-add-modal';
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm';
modal.innerHTML = `
${t('models_search_add_provider')}
${t('models_search_add_desc')}
${rows}
${t('cancel')}
`;
document.body.appendChild(modal);
modal.querySelectorAll('[data-pid]').forEach(el => {
el.addEventListener('click', () => {
const pid = el.getAttribute('data-pid');
modal.remove();
_launchSearchProviderConfig(pid);
});
});
}
function _launchSearchProviderConfig(providerId, providerMeta) {
if (providerId === 'bocha') {
openSearchBochaModal(providerMeta);
} else {
openVendorModal(providerId, () => loadModelsView({ preserveScroll: true }));
}
}
function saveSearchCapability() {
const strategyDd = document.getElementById('cap-search-strategy');
const providerDd = document.getElementById('cap-search-provider');
const strategy = strategyDd ? getDropdownValue(strategyDd) : 'auto';
const provider = (strategy === 'fixed' && providerDd) ? getDropdownValue(providerDd) : '';
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'set_capability',
capability: 'search',
strategy,
provider,
}),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
showStatus('cap-search-status', 'models_save_success', false);
setTimeout(() => loadModelsView({ preserveScroll: true }), 400);
} else {
showStatus('cap-search-status', 'models_save_failed', true);
}
}).catch(() => showStatus('cap-search-status', 'models_save_failed', true));
}
// Minimal bocha API-key modal. Reuses the existing vendor-modal markup
// helpers would be nice, but bocha isn't in PROVIDER_MODELS (it's not a
// model vendor), so we render a tiny dedicated dialog.
function openSearchBochaModal(providerMeta) {
const existing = document.getElementById('search-bocha-modal');
if (existing) existing.remove();
let masked = (providerMeta && providerMeta.api_key_masked) || '';
if (!masked) {
const searchCap = (modelsState && modelsState.capabilities && modelsState.capabilities.search) || {};
const bocha = (searchCap.providers || []).find(p => p.id === 'bocha');
if (bocha && bocha.api_key_masked) masked = bocha.api_key_masked;
}
const hasKey = !!masked;
const clearBtnHtml = hasKey
? `
${t('models_clear_credential')}
`
: '';
const modal = document.createElement('div');
modal.id = 'search-bocha-modal';
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm';
modal.innerHTML = `
${t('models_search_bocha_title')}
${t('models_search_bocha_desc')}
API Key
${clearBtnHtml}
${t('cancel')}
${t('save')}
`;
document.body.appendChild(modal);
// Reset masked sentinel as soon as the user starts editing so the save
// handler can tell apart "kept the existing key" vs "typed a new one".
const input = document.getElementById('search-bocha-key');
if (input) {
const unmask = () => {
if (input.dataset.masked === '1') {
input.value = '';
input.dataset.masked = '';
input.classList.remove('cfg-key-masked');
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Tab' || e.key === 'Escape') return;
unmask();
});
input.addEventListener('paste', unmask);
if (!hasKey) setTimeout(() => input.focus(), 50);
}
const clearBtn = document.getElementById('search-bocha-clear');
if (clearBtn) clearBtn.addEventListener('click', _clearBochaKey);
modal.addEventListener('mousedown', (e) => {
if (e.target === modal) modal.remove();
});
const onKey = (e) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', onKey);
}
};
document.addEventListener('keydown', onKey);
}
function _saveBochaKey() {
const input = document.getElementById('search-bocha-key');
if (!input) return;
// Untouched masked value => no change requested; close silently.
if (input.dataset.masked === '1') {
const modal = document.getElementById('search-bocha-modal');
if (modal) modal.remove();
return;
}
const apiKey = input.value.trim();
if (!apiKey) {
input.focus();
return;
}
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_search_credential', api_key: apiKey }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
const modal = document.getElementById('search-bocha-modal');
if (modal) modal.remove();
loadModelsView({ preserveScroll: true });
}
});
}
function _clearBochaKey() {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_search_credential', api_key: '' }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
const modal = document.getElementById('search-bocha-modal');
if (modal) modal.remove();
loadModelsView({ preserveScroll: true });
}
});
}
function renderCapabilityBody(def, cap, body) {
if (def.id === 'search') {
renderSearchCapability(def, cap, body);
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;
// TTS: mount reply-mode above provider; defer off-mode toggle to the end.
if (def.id === 'tts') {
renderVoiceReplyMode(body, cap.reply_mode || 'off', { skipVisibilityToggle: true });
// Voice-timbre picker depends on provider+model; rebuilt by callbacks.
const modelWrap = body.querySelector(`#cap-${def.id}-model-wrap`);
if (modelWrap) {
const voiceWrap = document.createElement('div');
voiceWrap.id = `cap-${def.id}-voice-wrap`;
voiceWrap.innerHTML = `
${t('models_voice')}
`;
modelWrap.parentNode.insertBefore(voiceWrap, modelWrap.nextSibling);
}
}
// `body` is still detached from `document`; scope lookups locally.
const provDd = body.querySelector(`#cap-${def.id}-provider`);
// Strip private fields before handing to the generic initDropdown helper.
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;
}
// Auto strategy => leave empty sentinel selected. `suggested_provider`
// is a UI-only preselect (not persisted until the user clicks Save).
// No current + no suggestion => leave unselected with a placeholder.
//
// Pending-config takes priority over both "auto" and "pick provider":
// when no real (non-sentinel) configured option exists, surfacing
// "auto" or "pick" misleads the user — there's nothing to auto-route
// to or pick from. Force a "待配置" placeholder instead so all
// capabilities behave consistently on a fresh environment.
const hasConfiguredOpt = providerOpts.some(o => !o._isAuto && o._configured);
const noSelectionAndNoHint = !cap.current_provider && !cap.suggested_provider;
let initialProviderValue;
let dropdownPlaceholder = null;
if (!hasConfiguredOpt) {
initialProviderValue = '';
dropdownPlaceholder = { placeholder: t('models_pending_config') };
} else {
initialProviderValue = pendingProvider
? pendingProvider
: ((cap.strategy === 'auto' && capabilitySupportsAuto(def.id))
? ''
: (cap.current_provider
|| cap.suggested_provider
|| (noSelectionAndNoHint ? '' : (ddOpts[0] && ddOpts[0].value))
|| ''));
if (noSelectionAndNoHint) {
dropdownPlaceholder = { placeholder: t('models_pick_provider') };
}
}
initDropdown(
provDd,
ddOpts,
initialProviderValue,
(value) => onCapabilityProviderChange(def, value, body),
dropdownPlaceholder,
);
decorateCapabilityProviderDropdown(def, provDd, providerOpts);
if (def.needsModel) {
rebuildCapabilityModelDropdown(def, initialProviderValue, cap.current_model || '', body);
// Hide model picker in auto mode — fallback hint below covers it.
setCapabilityModelPickerVisible(def, initialProviderValue !== '' || !capabilitySupportsAuto(def.id), body);
}
if (def.id === 'tts') {
rebuildCapabilityVoiceDropdown(
initialProviderValue,
cap.current_voice || '',
body,
cap.current_model || ''
);
}
// Inject auto/router-pending hint banners before the action footer.
renderCapabilityHints(def, cap, body, initialProviderValue);
if (def.id === 'tts') {
_setTtsConfigVisible(body, (cap.reply_mode || 'off') !== 'off');
}
}
// TTS reply-policy dropdown (off / voice_if_voice / always). Persists on
// change. When off, hides the rest of the TTS card.
function renderVoiceReplyMode(host, currentMode, options) {
options = options || {};
const opts = [
{ value: 'off', label: t('voice_reply_off') },
{ value: 'voice_if_voice', label: t('voice_reply_if_voice') },
{ value: 'always', label: t('voice_reply_always') },
];
const wrap = document.createElement('div');
wrap.id = 'voice-reply-mode-wrap';
wrap.innerHTML = `
${t('voice_reply_mode_label')}
`;
host.prepend(wrap);
const dd = wrap.querySelector('#voice-reply-mode-dd');
const valid = ['off', 'voice_if_voice', 'always'];
const initial = valid.includes(currentMode) ? currentMode : 'off';
if (!options.skipVisibilityToggle) _setTtsConfigVisible(host, initial !== 'off');
initDropdown(dd, opts, initial, (mode) => {
if (!valid.includes(mode)) return;
_setTtsConfigVisible(host, mode !== 'off');
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_voice_reply_mode', mode }),
})
.then(r => r.json())
.then(data => {
if (data && data.status === 'success') {
_ttsReadyPromise = null; // force re-probe on next bubble
}
})
.catch(() => {});
});
}
// Show/hide everything in the TTS card below the reply-mode dropdown.
function _setTtsConfigVisible(host, visible) {
if (!host) return;
Array.from(host.children).forEach((child) => {
if (child.id === 'voice-reply-mode-wrap') return;
child.classList.toggle('hidden', !visible);
});
}
// Toggle wrapper visibility instead of re-rendering so dropdown state survives.
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 && localizedLabel(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 && localizedLabel(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".
// Skip the sentinel when no real vendor is configured — "auto" would
// route to nothing useful and the renderer will show "待配置" instead.
const hasAnyConfigured = opts.some(o => o._configured);
if ((cap.strategy === 'auto' || cap.strategy === 'specified') && hasAnyConfigured) {
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) {
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');
}
}
// TTS voice catalog may be scoped per engine model (aggregating
// gateways). Rebuild the voice picker whenever the model changes.
if (def.id === 'tts') {
const provDd = document.getElementById('cap-tts-provider');
const provId = provDd ? getDropdownValue(provDd) : '';
rebuildCapabilityVoiceDropdown(provId, '', null, value);
}
});
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');
}
}
}
// TTS-only: rebuild the voice timbre picker against the provider's
// curated voice list. Hidden when no provider is picked.
//
// Each voice entry may be:
// - a bare string (code = label)
// - {value, label, hint?} so we can show a friendly Chinese name
// while persisting the raw API code that the runtime sends.
function rebuildCapabilityVoiceDropdown(providerId, selectedVoice, scope, modelId) {
const root = scope || document;
const wrap = root.querySelector(`#cap-tts-voice-wrap`);
const el = root.querySelector(`#cap-tts-voice`);
if (!wrap || !el) return;
const cap = modelsState.capabilities.tts || {};
const voicesByProvider = cap.provider_voices || {};
let raw = (providerId && voicesByProvider[providerId]) || [];
// Some providers (gateways) scope voices by engine model id.
if (raw && !Array.isArray(raw) && typeof raw === 'object') {
const activeModel = modelId
|| (root.querySelector(`#cap-tts-model`) ? getDropdownValue(root.querySelector(`#cap-tts-model`)) : '');
raw = (activeModel && raw[activeModel]) || [];
}
if (!raw || raw.length === 0) {
wrap.classList.add('hidden');
return;
}
wrap.classList.remove('hidden');
// Voice picker: friendly name on the left, raw API code as right-hand
// hint. Persisted/sent value is always the raw code.
const codes = [];
const opts = raw.map(entry => {
if (typeof entry === 'string') {
codes.push(entry);
return { value: entry, label: entry };
}
codes.push(entry.value);
const code = entry.value;
const desc = entry.hint || entry.label || code;
return {
value: code,
label: desc,
hint: desc === code ? '' : code,
};
});
opts.push({ value: '__custom__', label: currentLang === 'zh' ? '自定义' : 'Custom' });
// Off-catalog values route through the custom branch.
let initial = selectedVoice || '';
const isCustom = initial && !codes.includes(initial);
if (isCustom) initial = '__custom__';
if (!initial) initial = codes[0];
initDropdown(el, opts, initial, (value) => {
const customWrap = root.querySelector(`#cap-tts-voice-custom-wrap`);
if (!customWrap) return;
if (value === '__custom__') {
customWrap.classList.remove('hidden');
const input = root.querySelector(`#cap-tts-voice-custom`);
if (input && !input.value) input.value = isCustom ? selectedVoice : '';
} else {
customWrap.classList.add('hidden');
}
});
const customWrap = root.querySelector(`#cap-tts-voice-custom-wrap`);
if (customWrap) {
if (initial === '__custom__') {
customWrap.classList.remove('hidden');
const input = root.querySelector(`#cap-tts-voice-custom`);
if (input) input.value = isCustom ? selectedVoice : '';
} else {
customWrap.classList.add('hidden');
}
}
}
function onCapabilityProviderChange(def, providerId, scope) {
if (def.needsModel) {
// Empty sentinel hides the model picker (capability is in auto mode).
const isAuto = providerId === '' && capabilitySupportsAuto(def.id);
if (!isAuto) {
rebuildCapabilityModelDropdown(def, providerId, '', scope);
}
setCapabilityModelPickerVisible(def, !isAuto, scope);
}
if (def.id === 'tts') {
rebuildCapabilityVoiceDropdown(providerId, '', scope);
}
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;
// Search has its own form (strategy + provider, no model picker).
if (capId === 'search') { saveSearchCapability(); 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);
// TTS carries an extra voice timbre (supports free-text custom ids).
let voice = '';
if (capId === 'tts' && !isAuto) {
const voiceDd = document.getElementById(`cap-${capId}-voice`);
voice = voiceDd ? getDropdownValue(voiceDd) : '';
if (voice === '__custom__') {
const input = document.getElementById(`cap-${capId}-voice-custom`);
voice = input ? input.value.trim() : '';
}
}
// 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, undefined, { voice });
}
function _persistCapability(capId, provider, model, onAfterSuccess, extras) {
const payload = { action: 'set_capability', capability: capId, provider_id: provider, model: model };
if (extras && extras.voice !== undefined) payload.voice = extras.voice;
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
// Flash "Saved" before reload so the status survives the rebuild.
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: localizedLabel(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 = localizedLabel(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 || '';
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)}
${t('channels_disconnect')}
${weixinWaiting ? `
${t('weixin_scan_title')}
` : ''}
${wecomNeedsCreds ? `` : ''}
${isFeishu ? buildFeishuPanel(ch, true) : (hasFields ? `
${fieldsHtml}
${t('channels_save')}
` : '')}`;
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 += `
${escapeHtml(f.label)}
${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'}
${t('channels_cancel')}
`;
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_cancel')}
${t('channels_connect_btn')}
`;
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')}
${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')}
${t('wecom_scan_btn')}
`;
} 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 `
${scanLabel}
${manualLabel}
`;
}
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}
${t('feishu_scan_btn')}
`;
} 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}
${t('channels_save')}
`;
} 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
`;
statusEl.innerHTML = `
`;
}
function renderFeishuRegisterError(statusId, message) {
const statusEl = document.getElementById(statusId);
if (!statusEl) return;
statusEl.innerHTML = `
${message}
${t('feishu_scan_retry')}
`;
}
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');
});