mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
7244 lines
315 KiB
JavaScript
7244 lines
315 KiB
JavaScript
/* =====================================================================
|
||
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: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
|
||
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 <br> 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 <body> 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 `<p>${escapeHtml(text || '')}</p>`;
|
||
}
|
||
};
|
||
}
|
||
const md = mdFactory({
|
||
html: false, breaks: true, linkify: true, typographer: true,
|
||
highlight: function(str, lang) {
|
||
if (lang && hljsLib.getLanguage(lang)) {
|
||
try { return hljsLib.highlight(str, { language: lang }).value; } catch (_) {}
|
||
}
|
||
return hljsLib.highlightAuto(str).value;
|
||
}
|
||
});
|
||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||
return self.renderToken(tokens, idx, options);
|
||
};
|
||
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||
tokens[idx].attrPush(['target', '_blank']);
|
||
tokens[idx].attrPush(['rel', 'noopener noreferrer']);
|
||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||
};
|
||
return md;
|
||
}
|
||
|
||
const md = createMd();
|
||
|
||
const VIDEO_EXT_RE = /\.(?:mp4|webm|mov|avi|mkv)$/i; // tested against URL without query string
|
||
const IMAGE_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|bmp|svg)$/i; // tested against URL without query string
|
||
|
||
function _toWebUrl(url) {
|
||
if (/^\/[A-Za-z]/.test(url) && !url.startsWith('/api/')) {
|
||
return '/api/file?path=' + encodeURIComponent(url);
|
||
}
|
||
if (/^file:\/\/\//i.test(url)) {
|
||
return '/api/file?path=' + encodeURIComponent(url.replace(/^file:\/\/\//i, '/'));
|
||
}
|
||
return url;
|
||
}
|
||
|
||
function _buildVideoHtml(url) {
|
||
const webUrl = _toWebUrl(url);
|
||
const fileName = url.split('/').pop().split('?')[0];
|
||
return `<div style="margin:10px 0;">` +
|
||
`<video controls preload="metadata" ` +
|
||
`style="max-width:100%;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);display:block;">` +
|
||
`<source src="${webUrl}"></video>` +
|
||
`<a href="${webUrl}" target="_blank" ` +
|
||
`style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;font-size:12px;color:#8b8fa8;text-decoration:none;">` +
|
||
`<i class="fas fa-download"></i> ${escapeHtml(fileName)}</a></div>`;
|
||
}
|
||
|
||
function _openImageLightbox(src) {
|
||
let overlay = document.getElementById('cow-lightbox');
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'cow-lightbox';
|
||
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out;opacity:0;transition:opacity .2s';
|
||
overlay.onclick = () => { overlay.style.opacity = '0'; setTimeout(() => overlay.style.display = 'none', 200); };
|
||
const img = document.createElement('img');
|
||
img.id = 'cow-lightbox-img';
|
||
img.style.cssText = 'max-width:92vw;max-height:92vh;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.5);object-fit:contain;';
|
||
img.onclick = (e) => e.stopPropagation();
|
||
overlay.appendChild(img);
|
||
document.body.appendChild(overlay);
|
||
}
|
||
overlay.querySelector('#cow-lightbox-img').src = src;
|
||
overlay.style.display = 'flex';
|
||
requestAnimationFrame(() => overlay.style.opacity = '1');
|
||
}
|
||
|
||
function _buildImageHtml(url) {
|
||
const webUrl = _toWebUrl(url);
|
||
const safeUrl = webUrl.replace(/"/g, '"');
|
||
return `<div style="margin:10px 0;">` +
|
||
`<img src="${safeUrl}" alt="image" loading="lazy" ` +
|
||
`onclick="_openImageLightbox(this.src)" ` +
|
||
`style="max-width:520px;width:100%;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);display:block;cursor:zoom-in;">` +
|
||
`</div>`;
|
||
}
|
||
|
||
function injectVideoPlayers(html) {
|
||
// Step 1: replace markdown-it anchor tags whose href points to a video file.
|
||
const step1 = html.replace(
|
||
/<a\s+href="(https?:\/\/[^"]+)"[^>]*>[^<]*<\/a>/gi,
|
||
(match, url) => VIDEO_EXT_RE.test(url.split('?')[0]) ? _buildVideoHtml(url) : match
|
||
);
|
||
// Step 2: replace any remaining bare video URLs in text nodes (not inside HTML tags).
|
||
// Split on HTML tags to avoid touching src/href attributes already in markup.
|
||
return step1.split(/(<[^>]+>)/).map((chunk, idx) => {
|
||
// Even indices are text nodes; odd indices are HTML tags — leave them untouched.
|
||
if (idx % 2 !== 0) return chunk;
|
||
return chunk.replace(/https?:\/\/\S+/gi, (url) => {
|
||
const bare = url.replace(/[),.\s]+$/, ''); // strip trailing punctuation
|
||
return VIDEO_EXT_RE.test(bare.split('?')[0]) ? _buildVideoHtml(bare) : url;
|
||
});
|
||
}).join('');
|
||
}
|
||
|
||
// Convert image URLs into inline <img> previews. Mirrors injectVideoPlayers but for images.
|
||
// Handles three cases produced by markdown-it:
|
||
// 1. <a href="...image.jpg">...</a> (bare URL or autolink that linkify turned into an anchor)
|
||
// 2. <img src="..."> (markdown image syntax) — leave as-is, but normalize style
|
||
// 3. raw URL still present in a text node — only as a safety net
|
||
function injectImagePreviews(html) {
|
||
// Step 1: anchor whose href points to an image file -> replace with <img> preview.
|
||
const step1 = html.replace(
|
||
/<a\s+href="(https?:\/\/[^"]+)"[^>]*>[^<]*<\/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(/<img\s([^>]*?)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 `<img ${pre}src="${safeSrc}"${post}${clickAttr}>`;
|
||
});
|
||
}
|
||
|
||
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, '<br>'); }
|
||
}
|
||
|
||
// =====================================================================
|
||
// 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 = '<i class="fas fa-stop text-sm"></i>';
|
||
}
|
||
|
||
function resetSendBtnSendMode() {
|
||
activeRequestId = null;
|
||
sendBtnMode = 'send';
|
||
sendBtn.classList.remove('send-btn-cancel');
|
||
sendBtn.title = '';
|
||
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
||
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 `<div class="att-chip att-uploading" data-idx="${idx}">
|
||
<i class="fas fa-spinner fa-spin"></i>
|
||
<span class="att-name">${escapeHtml(att.file_name)}${suffix}</span>
|
||
</div>`;
|
||
}
|
||
if (att.file_type === 'image') {
|
||
return `<div class="att-thumb" data-idx="${idx}">
|
||
<img src="${att.preview_url}" alt="${escapeHtml(att.file_name)}">
|
||
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
||
</div>`;
|
||
}
|
||
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 `<div class="att-chip" data-idx="${idx}">
|
||
<i class="fas ${icon}"></i>
|
||
<span class="att-name">${escapeHtml(att.file_name)}${suffix}</span>
|
||
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
||
</div>`;
|
||
}).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 =
|
||
'<div class="slash-menu-header">Commands</div>' +
|
||
slashFiltered.map((c, i) =>
|
||
`<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` +
|
||
`<span class="cmd">${escapeHtml(c.cmd)}</span>` +
|
||
`<span class="desc">${escapeHtml(t(c.desc))}</span></div>`
|
||
).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 = `
|
||
<div class="max-w-[75%] sm:max-w-[60%]">
|
||
<div class="bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-slate-200 rounded-2xl px-3 py-2 msg-content user-bubble">
|
||
<div class="user-voice-slot"></div>
|
||
${caption ? `<div class="text-xs mt-1.5 leading-snug text-slate-500 dark:text-slate-400 whitespace-pre-wrap break-words">${escapeHtml(caption)}</div>` : ''}
|
||
</div>
|
||
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||
<div class="min-w-0 flex-1 max-w-[85%]">
|
||
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
|
||
<div class="agent-steps"></div>
|
||
<div class="answer-content sse-streaming"></div>
|
||
<div class="media-content"></div>
|
||
<div class="bot-audio-slot"></div>
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-1.5">
|
||
<span class="text-xs text-slate-400 dark:text-slate-500">${formatTime(timestamp)}</span>
|
||
<button class="copy-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${currentLang === 'zh' ? '复制' : 'Copy'}" style="display:none">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
|
||
<i class="fas fa-volume-up"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 <pre> 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 = `
|
||
<div class="thinking-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<i class="fas fa-lightbulb text-amber-400 flex-shrink-0"></i>
|
||
<span class="thinking-summary">${t('thinking_in_progress')}</span>
|
||
<i class="fas fa-chevron-right thinking-chevron"></i>
|
||
</div>
|
||
<div class="thinking-full"><pre class="thinking-stream-pre"></pre></div>`;
|
||
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 = `<div class="agent-content-body">${renderMarkdown(accumulatedText.trim())}</div>`;
|
||
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 = `
|
||
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<i class="fas fa-cog fa-spin text-primary-400 flex-shrink-0 tool-icon"></i>
|
||
<span class="tool-name">${item.tool}</span>
|
||
<i class="fas fa-chevron-right tool-chevron"></i>
|
||
</div>
|
||
<div class="tool-detail">
|
||
<div class="tool-detail-section">
|
||
<div class="tool-detail-label">Input</div>
|
||
<pre class="tool-detail-content">${argsStr}</pre>
|
||
</div>
|
||
<div class="tool-detail-section tool-output-section"></div>
|
||
</div>`;
|
||
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 += ` <span class="tool-time">${item.execution_time}s</span>`;
|
||
}
|
||
|
||
// Fill output section
|
||
const outputSection = currentToolEl.querySelector('.tool-output-section');
|
||
if (outputSection && item.result) {
|
||
outputSection.innerHTML = `
|
||
<div class="tool-detail-label">${isError ? 'Error' : 'Output'}</div>
|
||
<pre class="tool-detail-content ${isError ? 'tool-error-text' : ''}">${escapeHtml(String(item.result))}</pre>`;
|
||
}
|
||
|
||
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 = `<i class="fas fa-file-download" style="color:#6b7280;"></i> ${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 `<img src="${a.preview_url}" alt="${escapeHtml(a.file_name)}" class="user-msg-image">`;
|
||
}
|
||
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 `<div class="user-msg-file"><i class="fas ${icon}"></i> ${escapeHtml(a.file_name)}${suffix}</div>`;
|
||
}).join('');
|
||
attachHtml = `<div class="user-msg-attachments">${items}</div>`;
|
||
}
|
||
|
||
const textHtml = content ? renderMarkdown(content) : '';
|
||
el.innerHTML = `
|
||
<div class="max-w-[75%] sm:max-w-[60%]">
|
||
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
|
||
${attachHtml}${textHtml}
|
||
</div>
|
||
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
||
</div>
|
||
`;
|
||
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 `
|
||
<div class="agent-step agent-tool-step">
|
||
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<i class="fas fa-check text-primary-400 flex-shrink-0 tool-icon"></i>
|
||
<span class="tool-name">${escapeHtml(tc.name || '')}</span>
|
||
<i class="fas fa-chevron-right tool-chevron"></i>
|
||
</div>
|
||
<div class="tool-detail">
|
||
<div class="tool-detail-section">
|
||
<div class="tool-detail-label">Input</div>
|
||
<pre class="tool-detail-content">${argsStr}</pre>
|
||
</div>
|
||
${hasResult ? `
|
||
<div class="tool-detail-section tool-output-section">
|
||
<div class="tool-detail-label">Output</div>
|
||
<pre class="tool-detail-content">${resultStr}</pre>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).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 <pre> block to avoid expensive markdown parsing.
|
||
const { text: shown, truncated } = _truncateReasoningForDisplay(text);
|
||
if (truncated || shown.length > REASONING_RENDER_CAP) {
|
||
return '<pre class="thinking-stream-pre">' + escapeHtml(shown) + '</pre>';
|
||
}
|
||
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 = `<div class="thinking-duration">${t('thinking_duration')} ${elapsed}s</div>` + _renderReasoningBody(text);
|
||
}
|
||
|
||
function renderThinkingHtml(text) {
|
||
if (!text || !text.trim()) return '';
|
||
const full = text.trim();
|
||
return `
|
||
<div class="agent-step agent-thinking-step">
|
||
<div class="thinking-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<i class="fas fa-lightbulb text-amber-400 flex-shrink-0"></i>
|
||
<span class="thinking-summary">${t('thinking_done')}</span>
|
||
<i class="fas fa-chevron-right thinking-chevron"></i>
|
||
</div>
|
||
<div class="thinking-full">${_renderReasoningBody(full)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 += `<div class="agent-step agent-content-step"><div class="agent-content-body">${renderMarkdown(step.content)}</div></div>`;
|
||
}
|
||
} 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 += `
|
||
<div class="agent-step agent-tool-step${isErr ? ' tool-failed' : ''}">
|
||
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<i class="${iconClass}"></i>
|
||
<span class="tool-name">${escapeHtml(step.name || '')}</span>
|
||
<i class="fas fa-chevron-right tool-chevron"></i>
|
||
</div>
|
||
<div class="tool-detail">
|
||
<div class="tool-detail-section">
|
||
<div class="tool-detail-label">Input</div>
|
||
<pre class="tool-detail-content">${argsStr}</pre>
|
||
</div>
|
||
${resultStr ? `
|
||
<div class="tool-detail-section tool-output-section">
|
||
<div class="tool-detail-label">${isErr ? 'Error' : 'Output'}</div>
|
||
<pre class="tool-detail-content${isErr ? ' tool-error-text' : ''}">${resultStr}</pre>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
// 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 `<div class="agent-step">${_buildImageHtml(webUrl)}</div>`;
|
||
}
|
||
if (fileType === 'video') {
|
||
return `<div class="agent-step">${_buildVideoHtml(webUrl)}</div>`;
|
||
}
|
||
return `<div class="agent-step"><a href="${webUrl}" download="${escapeHtml(fileName)}" target="_blank" ` +
|
||
`style="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);">` +
|
||
`<i class="fas fa-file-download" style="color:#6b7280;"></i> ${escapeHtml(fileName)}</a></div>`;
|
||
}
|
||
|
||
// 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 = `
|
||
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||
<div class="min-w-0 flex-1 max-w-[85%]">
|
||
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
|
||
${stepsHtml ? `<div class="agent-steps">${stepsHtml}</div>` : ''}
|
||
<div class="answer-content">${renderMarkdown(displayContent)}</div>
|
||
<div class="bot-audio-slot"></div>
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-1.5">
|
||
<span class="text-xs text-slate-400 dark:text-slate-500">${formatTime(timestamp)}</span>
|
||
<button class="copy-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${currentLang === 'zh' ? '复制' : 'Copy'}">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
|
||
<i class="fas fa-volume-up"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 <audio>. 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 = `
|
||
<button type="button" class="voice-pill-btn" data-state="play" aria-label="play">
|
||
<i class="fas fa-play"></i>
|
||
</button>
|
||
<div class="voice-pill-track"><div class="voice-pill-fill"></div></div>
|
||
<span class="voice-pill-time">0:00</span>
|
||
<audio preload="metadata" src="${audioUrl}"></audio>
|
||
`;
|
||
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 = `<span>${t('context_cleared')}</span>`;
|
||
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 = `<span>${t('context_cleared')}</span>`;
|
||
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 = `<button class="text-xs text-slate-400 dark:text-slate-500 hover:text-primary-400 transition-colors" onclick="loadHistory(historyPage + 1)">Load earlier messages</button>`;
|
||
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 = `
|
||
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3">
|
||
<div class="flex items-center gap-1.5">
|
||
<span class="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style="animation-delay: 0s"></span>
|
||
<span class="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style="animation-delay: 0.2s"></span>
|
||
<span class="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style="animation-delay: 0.4s"></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
|
||
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">${appConfig.title || 'CowAgent'}</h1>
|
||
<p class="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed" data-i18n="welcome_subtitle">${t('welcome_subtitle')}</p>
|
||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<div class="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||
<i class="fas fa-folder-open text-blue-500 text-xs"></i>
|
||
</div>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_sys_title">${t('example_sys_title')}</span>
|
||
</div>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_sys_text">${t('example_sys_text')}</p>
|
||
</div>
|
||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<div class="w-7 h-7 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
|
||
<i class="fas fa-clock text-amber-500 text-xs"></i>
|
||
</div>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_task_title">${t('example_task_title')}</span>
|
||
</div>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_task_text">${t('example_task_text')}</p>
|
||
</div>
|
||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<div class="w-7 h-7 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||
<i class="fas fa-code text-emerald-500 text-xs"></i>
|
||
</div>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_code_title">${t('example_code_title')}</span>
|
||
</div>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_code_text">${t('example_code_text')}</p>
|
||
</div>
|
||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<div class="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
|
||
<i class="fas fa-book text-violet-500 text-xs"></i>
|
||
</div>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_knowledge_title">${t('example_knowledge_title')}</span>
|
||
</div>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_knowledge_text">${t('example_knowledge_text')}</p>
|
||
</div>
|
||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<div class="w-7 h-7 rounded-lg bg-rose-50 dark:bg-rose-900/30 flex items-center justify-center">
|
||
<i class="fas fa-puzzle-piece text-rose-500 text-xs"></i>
|
||
</div>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_skill_title">${t('example_skill_title')}</span>
|
||
</div>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_skill_text">${t('example_skill_text')}</p>
|
||
</div>
|
||
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200" data-send="/help">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<div class="w-7 h-7 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
|
||
<i class="fas fa-terminal text-slate-500 text-xs"></i>
|
||
</div>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_web_title">${t('example_web_title')}</span>
|
||
</div>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_web_text">${t('example_web_text')}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<i class="fas fa-message session-icon"></i>
|
||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${sid}')" title="Delete">
|
||
<i class="fas fa-trash-can"></i>
|
||
</button>
|
||
`;
|
||
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 = '<div class="session-empty">' + t('untitled_session') + '</div>';
|
||
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 = `
|
||
<i class="fas fa-message session-icon"></i>
|
||
<span class="session-title" title="${escapeHtml(title)}">${escapeHtml(title)}</span>
|
||
<button class="session-delete" onclick="event.stopPropagation(); deleteSession('${s.session_id}')" title="Delete">
|
||
<i class="fas fa-trash-can"></i>
|
||
</button>
|
||
`;
|
||
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 = `
|
||
<div class="confirm-title">${escapeHtml(title)}</div>
|
||
<div class="confirm-message">${escapeHtml(message)}</div>
|
||
<div class="confirm-actions">
|
||
<button class="confirm-btn confirm-btn-cancel">${t('confirm_cancel')}</button>
|
||
<button class="confirm-btn confirm-btn-ok">${t('confirm_yes')}</button>
|
||
</div>
|
||
`;
|
||
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 = `<span>${t('context_cleared')}</span>`;
|
||
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 = `<span class="text-sm text-slate-400 dark:text-slate-500">${currentLang === 'zh' ? '暂无内置工具' : 'No built-in tools'}</span>`;
|
||
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 = `
|
||
<div class="w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center flex-shrink-0">
|
||
<i class="fas ${getToolIcon(tool.name)} text-blue-500 dark:text-blue-400 text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 font-mono">${escapeHtml(tool.name)}</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1 line-clamp-2">${escapeHtml(tool.description || '--')}</p>
|
||
</div>`;
|
||
listEl.appendChild(card);
|
||
});
|
||
listEl.classList.remove('hidden');
|
||
toolsLoaded = true;
|
||
}).catch(() => {
|
||
emptyEl.classList.remove('hidden');
|
||
emptyEl.innerHTML = `<span class="text-sm text-slate-400 dark:text-slate-500">${currentLang === 'zh' ? '加载失败' : 'Failed to load'}</span>`;
|
||
});
|
||
}
|
||
|
||
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 = `
|
||
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center flex-shrink-0">
|
||
<i class="fas fa-bolt ${iconColor} text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.display_name || sk.name)}</span>
|
||
<button
|
||
role="switch"
|
||
aria-checked="${enabled}"
|
||
onclick="toggleSkill('${escapeHtml(sk.name)}', ${enabled})"
|
||
class="relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${trackClass}"
|
||
title="${enabled ? (currentLang === 'zh' ? '点击禁用' : 'Click to disable') : (currentLang === 'zh' ? '点击启用' : 'Click to enable')}"
|
||
>
|
||
<span class="inline-block h-3 w-3 mt-0.5 rounded-full bg-white shadow transform transition-transform duration-200 ease-in-out ${thumbTranslate}"></span>
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-slate-400 dark:text-slate-500 line-clamp-2">${escapeHtml(sk.description || '--')}</p>
|
||
</div>`;
|
||
}
|
||
|
||
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 = '<span class="px-2 py-0.5 rounded-full text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">Global</span>';
|
||
} else if (f.type === 'dream') {
|
||
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">Dream</span>';
|
||
} else {
|
||
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">Daily</span>';
|
||
}
|
||
const sizeStr = f.size < 1024 ? f.size + ' B' : (f.size / 1024).toFixed(1) + ' KB';
|
||
tr.innerHTML = `
|
||
<td class="px-4 py-3 text-sm font-mono text-slate-700 dark:text-slate-200">${escapeHtml(f.filename)}</td>
|
||
<td class="px-4 py-3 text-sm">${typeLabel}</td>
|
||
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">${sizeStr}</td>
|
||
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">${escapeHtml(f.updated_at)}</td>`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(total / memoryPageSize);
|
||
const pagEl = document.getElementById('memory-pagination');
|
||
if (totalPages <= 1) { pagEl.innerHTML = ''; return; }
|
||
let pagHtml = `<span>${page} / ${totalPages}</span><div class="flex gap-2">`;
|
||
if (page > 1) pagHtml += `<button onclick="loadMemoryView(${page - 1})" class="px-3 py-1 rounded-lg border border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-xs">Prev</button>`;
|
||
if (page < totalPages) pagHtml += `<button onclick="loadMemoryView(${page + 1})" class="px-3 py-1 rounded-lg border border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-xs">Next</button>`;
|
||
pagHtml += '</div>';
|
||
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/<id>.svg we use
|
||
// it; otherwise we fall back to a neutral monogram chip. SVGs are fetched
|
||
// via <img> 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 = `<span class="text-sm text-red-400">${escapeHtml(data.message || 'Failed to load')}</span>`;
|
||
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 = `<span class="text-sm text-red-400">${escapeHtml(String(err))}</span>`;
|
||
});
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex items-start gap-3 mb-5">
|
||
<div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center flex-shrink-0">
|
||
<i class="fas fa-key text-primary-500 text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('models_section_vendors')}</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t('models_section_vendors_desc')}</p>
|
||
</div>
|
||
<span class="text-xs text-slate-400 dark:text-slate-500 mt-2 flex-shrink-0">${configured.length}/${modelsState.providers.length}</span>
|
||
</div>`;
|
||
|
||
let body;
|
||
if (configured.length === 0) {
|
||
body = `
|
||
<div class="flex flex-col items-center justify-center py-8 px-4 rounded-lg border border-dashed border-slate-200 dark:border-white/10">
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('models_not_configured')}</p>
|
||
<button onclick="openVendorModal('')"
|
||
class="mt-3 px-3 py-1.5 rounded-lg text-xs font-medium bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 hover:bg-primary-100 dark:hover:bg-primary-900/50 cursor-pointer transition-colors">
|
||
<i class="fas fa-plus text-[10px] mr-1"></i>${t('models_add_vendor')}
|
||
</button>
|
||
</div>`;
|
||
} else {
|
||
body = `<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
${configured.map(renderVendorChip).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
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 `
|
||
<button onclick="openVendorModal('${escapeHtml(p.id)}')"
|
||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border border-slate-200 dark:border-white/10
|
||
bg-slate-50 dark:bg-white/5 hover:border-primary-300 dark:hover:border-primary-500/50
|
||
cursor-pointer transition-colors duration-150 text-left">
|
||
${renderProviderLogo(p, 28)}
|
||
<span class="flex-1 min-w-0 text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${escapeHtml(localizedLabel(p.label))}</span>
|
||
<i class="fas fa-pen-to-square text-[11px] text-slate-400 dark:text-slate-500 group-hover:text-primary-500 transition-colors"></i>
|
||
</button>`;
|
||
}
|
||
|
||
// Render a uniformly-styled logo for a provider. Tries an SVG asset first; if
|
||
// it 404s the <img> 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 `
|
||
<span class="relative flex items-center justify-center rounded-lg bg-slate-100 dark:bg-white/10
|
||
text-slate-600 dark:text-slate-300 flex-shrink-0 overflow-hidden"
|
||
style="width:${sz}px;height:${sz}px;">
|
||
<span id="${fallbackId}" class="text-xs font-bold">${escapeHtml(initial)}</span>
|
||
<img src="${url}" alt="" aria-hidden="true"
|
||
class="${imgClass}"
|
||
style="width:${Math.round(sz * 0.65)}px;height:${Math.round(sz * 0.65)}px;"
|
||
onload="(function(el){var f=document.getElementById('${fallbackId}');if(f)f.style.display='none';})(this)"
|
||
onerror="this.remove();">
|
||
</span>`;
|
||
}
|
||
|
||
// ---------- 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 = `
|
||
<div class="flex items-start gap-3 mb-5">
|
||
<div class="w-9 h-9 rounded-lg ${def.iconChip} flex items-center justify-center flex-shrink-0">
|
||
<i class="fas ${def.icon} ${def.iconGlyph} text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t(def.titleKey)}</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">${t(def.descKey)}</p>
|
||
</div>
|
||
${headerRight}
|
||
</div>
|
||
<div class="space-y-4" data-cap-body="${def.id}"></div>`;
|
||
|
||
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 = `
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_search_strategy_label')}</label>
|
||
<div id="cap-search-strategy" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
</div>
|
||
<div id="cap-search-provider-wrap" class="hidden">
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_provider')}</label>
|
||
<div id="cap-search-provider" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
</div>
|
||
<div id="cap-search-summary"></div>
|
||
<div class="flex items-center justify-end gap-3 pt-1">
|
||
<span id="cap-search-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||
<button onclick="saveSearchCapability()"
|
||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
|
||
${t('save')}
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
// 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
|
||
? `<button type="button" id="cap-search-add-btn"
|
||
class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md cursor-pointer
|
||
bg-slate-100 dark:bg-white/5 text-slate-500 dark:text-slate-400
|
||
hover:bg-slate-200 dark:hover:bg-white/10 transition-colors">
|
||
<i class="fas fa-plus text-[10px]"></i>${t('models_search_add_provider')}
|
||
</button>`
|
||
: '';
|
||
|
||
if (configured.length === 0) {
|
||
host.innerHTML = `
|
||
<div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||
<i class="fas fa-circle-info text-[10px] text-amber-500"></i>
|
||
<span>${t('models_search_none_configured')}</span>
|
||
${addBtn}
|
||
</div>
|
||
`;
|
||
} else {
|
||
const chips = configured.map(p => `
|
||
<button type="button" data-search-edit-provider="${p.id}"
|
||
title="${t('models_search_edit_hint')}"
|
||
class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md cursor-pointer
|
||
bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400
|
||
hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors">
|
||
<i class="fas fa-check text-[10px]"></i>${escapeHtml(localizedLabel(p.label))}
|
||
</button>
|
||
`).join('');
|
||
host.innerHTML = `
|
||
<div class="flex items-center flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||
<span>${t('models_search_available_label')}</span>
|
||
${chips}
|
||
${addBtn}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 => `
|
||
<button type="button" data-pid="${p.id}"
|
||
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer
|
||
bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10
|
||
text-sm text-slate-700 dark:text-slate-200 transition-colors">
|
||
<span>${escapeHtml(localizedLabel(p.label))}</span>
|
||
<i class="fas fa-chevron-right text-[10px] text-slate-400"></i>
|
||
</button>
|
||
`).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 = `
|
||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10
|
||
w-full max-w-md mx-4 p-6 shadow-xl">
|
||
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_add_provider')}</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4">${t('models_search_add_desc')}</p>
|
||
<div class="space-y-2">${rows}</div>
|
||
<div class="flex items-center justify-end mt-5">
|
||
<button type="button" onclick="document.getElementById('search-add-modal').remove()"
|
||
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
|
||
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
|
||
${t('cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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
|
||
? `<button type="button" id="search-bocha-clear"
|
||
class="px-3 py-1.5 rounded-md text-xs text-red-500 dark:text-red-400
|
||
hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors">
|
||
${t('models_clear_credential')}
|
||
</button>`
|
||
: '';
|
||
|
||
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 = `
|
||
<div id="search-bocha-modal-card"
|
||
class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10
|
||
w-full max-w-md mx-4 p-6 shadow-xl">
|
||
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-100 mb-1">${t('models_search_bocha_title')}</h3>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-4">${t('models_search_bocha_desc')}</p>
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||
<input id="search-bocha-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||
focus:outline-none focus:border-primary-500 font-mono ${hasKey ? 'cfg-key-masked' : ''}"
|
||
value="${escapeHtml(masked)}"
|
||
data-masked="${hasKey ? '1' : ''}"
|
||
placeholder="sk-..." />
|
||
<div class="flex items-center justify-between gap-3 mt-5">
|
||
<div>${clearBtnHtml}</div>
|
||
<div class="flex items-center gap-3">
|
||
<button type="button" onclick="document.getElementById('search-bocha-modal').remove()"
|
||
class="px-3 py-1.5 rounded-md text-sm text-slate-600 dark:text-slate-300
|
||
hover:bg-slate-100 dark:hover:bg-white/5 transition-colors">
|
||
${t('cancel')}
|
||
</button>
|
||
<button type="button" onclick="_saveBochaKey()"
|
||
class="px-4 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors">
|
||
${t('save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_provider')}</label>
|
||
<div id="cap-${def.id}-provider" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// 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 ? `
|
||
<div id="cap-${def.id}-model-wrap">
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_model')}</label>
|
||
<div id="cap-${def.id}-model" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
<div id="cap-${def.id}-model-custom-wrap" class="mt-2 hidden">
|
||
<input id="cap-${def.id}-model-custom" type="text"
|
||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||
placeholder="custom model name">
|
||
</div>
|
||
</div>` : '';
|
||
|
||
const dimHtml = (def.id === 'embedding' && cap.current_dim) ? `
|
||
<p class="text-xs text-slate-400 dark:text-slate-500">
|
||
<i class="fas fa-cube text-[10px] mr-1"></i>${t('models_dim_label')}: <span class="font-mono">${cap.current_dim}</span>
|
||
</p>` : '';
|
||
|
||
// 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 = `
|
||
<div class="flex items-center justify-between gap-3 pt-1">
|
||
<div data-cap-hint="${def.id}" class="flex-1 min-w-0"></div>
|
||
<div class="flex items-center gap-3 flex-shrink-0">
|
||
<span id="cap-${def.id}-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||
<button onclick="saveCapability('${def.id}')"
|
||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
|
||
${t('save')}
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = `
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('models_voice')}</label>
|
||
<div id="cap-${def.id}-voice" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
<div id="cap-${def.id}-voice-custom-wrap" class="hidden mt-2">
|
||
<input id="cap-${def.id}-voice-custom" type="text"
|
||
class="w-full px-3 py-2 text-sm rounded-md border border-slate-200 dark:border-slate-700
|
||
bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200
|
||
placeholder:text-slate-400 dark:placeholder:text-slate-500
|
||
focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||
placeholder="voice id" />
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${t('voice_reply_mode_label')}</label>
|
||
<div id="voice-reply-mode-dd" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<p class="flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500 min-w-0">
|
||
<i class="fas fa-circle-info text-[10px] flex-shrink-0"></i>
|
||
<span class="flex-shrink-0">${t('models_auto_using')}</span>
|
||
<span class="font-mono text-slate-500 dark:text-slate-400 truncate">${escapeHtml(fbText)}</span>
|
||
</p>`;
|
||
}
|
||
|
||
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 = `<div class="flex items-center gap-2 py-8 justify-center text-slate-400 dark:text-slate-500 text-sm">
|
||
<i class="fas fa-spinner fa-spin text-xs"></i><span>Loading...</span></div>`;
|
||
|
||
fetch('/api/channels').then(r => r.json()).then(data => {
|
||
if (data.status !== 'success') return;
|
||
channelsData = data.channels || [];
|
||
renderActiveChannels();
|
||
}).catch(() => {
|
||
container.innerHTML = '<p class="text-sm text-red-400 py-8 text-center">Failed to load channels</p>';
|
||
});
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex flex-col items-center justify-center py-20">
|
||
<div class="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center mb-4">
|
||
<i class="fas fa-tower-broadcast text-blue-400 text-xl"></i>
|
||
</div>
|
||
<p class="text-slate-500 dark:text-slate-400 font-medium">${t('channels_empty')}</p>
|
||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">${t('channels_empty_desc')}</p>
|
||
</div>`;
|
||
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'
|
||
? `<span class="text-xs text-primary-500">${t('weixin_scan_scanned')}</span>`
|
||
: `<span class="text-xs text-amber-500">${t('weixin_scan_waiting')}</span>`;
|
||
} else if (wecomNeedsCreds) {
|
||
statusDot = 'bg-amber-400 animate-pulse';
|
||
statusText = `<span class="text-xs text-amber-500">${t('channels_connecting')}</span>`;
|
||
} else {
|
||
statusDot = 'bg-primary-400';
|
||
statusText = `<span class="text-xs text-primary-500">${t('channels_connected')}</span>`;
|
||
}
|
||
|
||
card.innerHTML = `
|
||
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds || isFeishu ? ' mb-5' : ''}">
|
||
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
||
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<span class="font-semibold text-slate-800 dark:text-slate-100">${escapeHtml(label)}</span>
|
||
<span class="w-2 h-2 rounded-full ${statusDot}"></span>
|
||
${statusText}
|
||
</div>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(ch.name)}</p>
|
||
</div>
|
||
<button onclick="disconnectChannel('${ch.name}')"
|
||
class="px-3 py-1.5 rounded-lg text-xs font-medium
|
||
bg-red-50 dark:bg-red-900/20 text-red-500 dark:text-red-400
|
||
hover:bg-red-100 dark:hover:bg-red-900/40
|
||
cursor-pointer transition-colors flex-shrink-0">
|
||
${t('channels_disconnect')}
|
||
</button>
|
||
</div>
|
||
${weixinWaiting ? `<div id="weixin-active-qr" class="flex flex-col items-center py-2">
|
||
<button onclick="showWeixinActiveQr()"
|
||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150">
|
||
${t('weixin_scan_title')}
|
||
</button>
|
||
</div>` : ''}
|
||
${wecomNeedsCreds ? `<div id="wecom-active-auth" class="flex flex-col items-center py-2">
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-3">${t('wecom_scan_desc')}</p>
|
||
<button onclick="startWecomBotAuthInCard()"
|
||
class="px-5 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150">
|
||
<i class="fas fa-qrcode mr-2"></i>${t('wecom_scan_btn')}
|
||
</button>
|
||
<div id="wecom-card-scan-status" class="mt-3"></div>
|
||
</div>` : ''}
|
||
${isFeishu ? buildFeishuPanel(ch, true) : (hasFields ? `<div class="space-y-4">
|
||
${fieldsHtml}
|
||
<div class="flex items-center justify-end gap-3 pt-1">
|
||
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||
<button onclick="saveChannelConfig('${ch.name}')"
|
||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
id="ch-save-${ch.name}">${t('channels_save')}</button>
|
||
</div>
|
||
</div>` : '')}`;
|
||
|
||
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 = `<label class="relative inline-flex items-center cursor-pointer">
|
||
<input id="${inputId}" type="checkbox" ${checked} class="sr-only peer" data-field="${f.key}" data-ch="${chName}">
|
||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-checked:bg-primary-400 rounded-full
|
||
after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white
|
||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||
</label>`;
|
||
} else if (f.type === 'secret') {
|
||
inputHtml = `<input id="${inputId}" type="text" value="${escapeHtml(String(f.value || ''))}"
|
||
data-field="${f.key}" data-ch="${chName}" data-masked="${f.value ? '1' : ''}"
|
||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||
focus:outline-none focus:border-primary-500 font-mono transition-colors
|
||
${f.value ? 'cfg-key-masked' : ''}"
|
||
placeholder="${escapeHtml(f.label)}">`;
|
||
} else {
|
||
const inputType = f.type === 'number' ? 'number' : 'text';
|
||
inputHtml = `<input id="${inputId}" type="${inputType}" value="${escapeHtml(String(f.value ?? f.default ?? ''))}"
|
||
data-field="${f.key}" data-ch="${chName}"
|
||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||
placeholder="${escapeHtml(f.label)}">`;
|
||
}
|
||
html += `<div>
|
||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${escapeHtml(f.label)}</label>
|
||
${inputHtml}
|
||
</div>`;
|
||
});
|
||
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 = `<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6 text-center">
|
||
<p class="text-sm text-slate-500 dark:text-slate-400">${currentLang === 'zh' ? '所有通道均已接入' : 'All channels are already connected'}</p>
|
||
<button onclick="closeAddChannelPanel()" class="mt-3 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">${t('channels_cancel')}</button>
|
||
</div>`;
|
||
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 = `
|
||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-primary-200 dark:border-primary-800 p-6">
|
||
<div class="flex items-center gap-3 mb-5">
|
||
<div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||
<i class="fas fa-plus text-primary-500 text-sm"></i>
|
||
</div>
|
||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('channels_add')}</h3>
|
||
</div>
|
||
<div class="mb-4">
|
||
<div id="add-channel-select" class="cfg-dropdown" tabindex="0">
|
||
<div class="cfg-dropdown-selected">
|
||
<span class="cfg-dropdown-text">--</span>
|
||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||
</div>
|
||
<div class="cfg-dropdown-menu"></div>
|
||
</div>
|
||
</div>
|
||
<div id="add-channel-fields" class="space-y-4"></div>
|
||
<div id="add-channel-actions" class="hidden flex items-center justify-end gap-3 pt-4">
|
||
<button onclick="closeAddChannelPanel()"
|
||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||
hover:bg-slate-50 dark:hover:bg-white/5
|
||
cursor-pointer transition-colors duration-150">${t('channels_cancel')}</button>
|
||
<button id="add-channel-submit" onclick="submitAddChannel()"
|
||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">${t('channels_connect_btn')}</button>
|
||
</div>
|
||
</div>`;
|
||
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 = `
|
||
<div id="weixin-qr-panel" class="flex flex-col items-center py-4">
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">${t('weixin_scan_loading')}</p>
|
||
</div>`;
|
||
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 = `
|
||
<div id="weixin-qr-panel" class="flex flex-col items-center py-2">
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">${t('weixin_scan_loading')}</p>
|
||
</div>`;
|
||
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 = `<p class="text-sm text-red-500">${t('weixin_scan_fail')}: ${data.message || ''}</p>`;
|
||
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 = `<p class="text-sm text-red-500">${t('weixin_scan_fail')}</p>`;
|
||
});
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex flex-col items-center">
|
||
<p class="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">${t('weixin_scan_title')}</p>
|
||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-4">${t('weixin_scan_desc')}</p>
|
||
<div class="bg-white p-3 rounded-xl shadow-sm border border-slate-100 dark:border-slate-700 mb-3">
|
||
<img src="${escapeHtml(qrcodeUrl)}" alt="QR Code" class="w-52 h-52" style="image-rendering: pixelated;"/>
|
||
</div>
|
||
<p class="text-xs ${statusColor} mb-1">${statusText}</p>
|
||
<p class="text-xs text-slate-400 dark:text-slate-500">${t('weixin_qr_tip')}</p>
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex flex-col items-center py-4">
|
||
<div class="w-12 h-12 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center mb-3">
|
||
<i class="fas fa-check text-primary-500 text-lg"></i>
|
||
</div>
|
||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400">${t('weixin_scan_success')}</p>
|
||
</div>`;
|
||
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 `
|
||
<div id="wecom-bot-panel" data-default-mode="${defaultMode}">
|
||
<div class="flex items-center justify-center gap-1 mb-5 bg-slate-100 dark:bg-white/5 rounded-lg p-1">
|
||
<button id="wecom-tab-scan" onclick="switchWecomBotMode('scan')"
|
||
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||
bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm">
|
||
${scanLabel}
|
||
</button>
|
||
<button id="wecom-tab-manual" onclick="switchWecomBotMode('manual')"
|
||
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||
text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
|
||
${manualLabel}
|
||
</button>
|
||
</div>
|
||
<div id="wecom-mode-content"></div>
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex flex-col items-center py-4">
|
||
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">${t('wecom_scan_desc')}</p>
|
||
<button onclick="startWecomBotAuth()"
|
||
class="mt-3 px-6 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150">
|
||
<i class="fas fa-qrcode mr-2"></i>${t('wecom_scan_btn')}
|
||
</button>
|
||
<div id="wecom-scan-status" class="mt-3"></div>
|
||
</div>`;
|
||
} 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 = `<div class="space-y-4">${buildChannelFieldsHtml('wecom_bot', ch ? ch.fields || [] : [])}</div>`;
|
||
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 = `
|
||
<div class="flex flex-col items-center py-2">
|
||
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
|
||
<i class="fas fa-check text-emerald-500 text-lg"></i>
|
||
</div>
|
||
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('wecom_scan_success')}</p>
|
||
</div>`;
|
||
}
|
||
connectWecomBotAfterAuth(bot.botid, bot.secret);
|
||
},
|
||
onError: function(err) {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `<p class="text-sm text-red-500">${t('wecom_scan_fail')}: ${err.message || err.code || ''}</p>`;
|
||
}
|
||
}
|
||
});
|
||
}).catch(err => {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `<p class="text-sm text-red-500">SDK load failed: ${err.message}</p>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex flex-col items-center py-2">
|
||
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
|
||
<i class="fas fa-check text-emerald-500 text-lg"></i>
|
||
</div>
|
||
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('wecom_scan_success')}</p>
|
||
</div>`;
|
||
}
|
||
connectWecomBotAfterAuth(bot.botid, bot.secret);
|
||
},
|
||
onError: function(err) {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `<p class="text-sm text-red-500">${t('wecom_scan_fail')}: ${err.message || err.code || ''}</p>`;
|
||
}
|
||
}
|
||
});
|
||
}).catch(err => {
|
||
if (statusEl) {
|
||
statusEl.innerHTML = `<p class="text-sm text-red-500">SDK load failed: ${err.message}</p>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 `
|
||
<div id="feishu-panel" data-default-mode="${defaultMode}" ${activeAttr}>
|
||
<div class="flex items-center justify-center gap-1 mb-5 bg-slate-100 dark:bg-white/5 rounded-lg p-1">
|
||
<button id="feishu-tab-scan" onclick="switchFeishuMode('scan')"
|
||
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||
bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm">
|
||
${scanLabel}
|
||
</button>
|
||
<button id="feishu-tab-manual" onclick="switchFeishuMode('manual')"
|
||
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||
text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
|
||
${manualLabel}
|
||
</button>
|
||
</div>
|
||
<div id="feishu-mode-content"></div>
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div id="feishu-scan-panel" class="flex flex-col items-center py-4">
|
||
<p class="text-sm text-slate-600 dark:text-slate-300 mb-3 text-center">${desc}</p>
|
||
<button onclick="startFeishuRegister()"
|
||
class="mt-2 px-6 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150">
|
||
<i class="fas fa-qrcode mr-2"></i>${t('feishu_scan_btn')}
|
||
</button>
|
||
<div id="feishu-scan-status" class="mt-4 w-full"></div>
|
||
</div>`;
|
||
} 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 = `
|
||
<div class="space-y-4">
|
||
${fieldsHtml}
|
||
<div class="flex items-center justify-end gap-3 pt-1">
|
||
<span id="ch-status-feishu" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||
<button onclick="saveChannelConfig('feishu')"
|
||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
id="ch-save-feishu">${t('channels_save')}</button>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
content.innerHTML = `<div class="space-y-4">${fieldsHtml}</div>`;
|
||
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 = `<p class="text-sm text-slate-500 dark:text-slate-400 text-center">${t('feishu_scan_loading')}</p>`;
|
||
}
|
||
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
|
||
? `<img src="${qrImage}" alt="QR" class="w-44 h-44 rounded-lg border border-slate-200 dark:border-white/10 bg-white p-2"/>`
|
||
: `<div class="w-44 h-44 rounded-lg border border-dashed border-slate-300 flex items-center justify-center text-xs text-slate-400">QR</div>`;
|
||
statusEl.innerHTML = `
|
||
<div class="flex flex-col items-center gap-3">
|
||
${imgHtml}
|
||
<p class="text-xs text-amber-500">${t('feishu_scan_waiting')}</p>
|
||
<p class="text-xs text-slate-400 dark:text-slate-500">${t('feishu_scan_tip')}</p>
|
||
${qrUrl ? `<a href="${qrUrl}" target="_blank" rel="noopener"
|
||
class="text-xs text-blue-500 hover:text-blue-600 underline">${t('feishu_scan_open_link')}</a>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function renderFeishuRegisterError(statusId, message) {
|
||
const statusEl = document.getElementById(statusId);
|
||
if (!statusEl) return;
|
||
statusEl.innerHTML = `
|
||
<div class="flex flex-col items-center gap-2 py-2">
|
||
<p class="text-sm text-red-500 text-center">${message}</p>
|
||
<button onclick="startFeishuRegister('${statusId}')"
|
||
class="mt-1 px-4 py-1.5 rounded-md text-xs font-medium
|
||
bg-slate-100 dark:bg-white/10 text-slate-700 dark:text-slate-200
|
||
hover:bg-slate-200 dark:hover:bg-white/20 cursor-pointer">
|
||
<i class="fas fa-rotate-right mr-1"></i>${t('feishu_scan_retry')}
|
||
</button>
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="flex flex-col items-center py-2">
|
||
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
|
||
<i class="fas fa-check text-emerald-500 text-lg"></i>
|
||
</div>
|
||
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('feishu_scan_success')}</p>
|
||
</div>`;
|
||
}
|
||
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'
|
||
? `<span class="text-xs font-mono text-slate-400">${escapeHtml(task.cron || '')}</span>`
|
||
: `<span class="text-xs text-slate-400">${escapeHtml(task.type || 'once')}</span>`;
|
||
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 = `
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200">${escapeHtml(task.name || task.id || '--')}</span>
|
||
<div class="flex-1"></div>
|
||
${typeLabel}
|
||
</div>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2 line-clamp-2">${escapeHtml(task.prompt || task.description || '')}</p>
|
||
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
||
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||
</div>`;
|
||
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 = `<i class="fas fa-file-lines text-[10px] text-slate-400"></i><span class="truncate">${escapeHtml(f.title)}</span>`;
|
||
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 = `<i class="fas fa-chevron-right chevron"></i><i class="fas fa-folder text-amber-400 text-[11px]"></i><span>${escapeHtml(group.dir)}</span><span class="ml-auto text-[10px] text-slate-400">${fileCount}</span>`;
|
||
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 = `<i class="fas fa-file-lines text-[10px] text-slate-400"></i><span class="truncate">${escapeHtml(f.title)}</span>`;
|
||
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 = '<div class="flex items-center justify-center h-full text-slate-400 text-sm"><i class="fas fa-spinner fa-spin mr-2"></i>Loading graph...</div>';
|
||
|
||
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 = `<div class="flex flex-col items-center justify-center h-full text-slate-400"><i class="fas fa-diagram-project text-3xl mb-3 opacity-40"></i><p class="text-sm">${t('knowledge_empty_hint')}</p></div>`;
|
||
return;
|
||
}
|
||
container.innerHTML = '';
|
||
renderKnowledgeGraph(container, nodes, links);
|
||
}).catch(() => {
|
||
container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400 text-sm">Failed to load graph</div>';
|
||
});
|
||
}
|
||
|
||
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 = `<span class="knowledge-graph-legend-dot" style="background:${colorScale(cat)}"></span>${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');
|
||
});
|