diff --git a/app.py b/app.py index dd18203..c3315c1 100644 --- a/app.py +++ b/app.py @@ -118,22 +118,51 @@ class ChannelManager: Stop channel(s). If channel_name is given, stop only that channel; otherwise stop all channels. """ + # Pop under lock, then stop outside lock to avoid deadlock with self._lock: names = [channel_name] if channel_name else list(self._channels.keys()) + to_stop = [] for name in names: ch = self._channels.pop(name, None) - self._threads.pop(name, None) - if ch is None: - continue - logger.info(f"[ChannelManager] Stopping channel '{name}'...") - try: - if hasattr(ch, 'stop'): - ch.stop() - except Exception as e: - logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {e}") + th = self._threads.pop(name, None) + to_stop.append((name, ch, th)) if channel_name and self._primary_channel is self._channels.get(channel_name): self._primary_channel = None + for name, ch, th in to_stop: + if ch is None: + logger.warning(f"[ChannelManager] Channel '{name}' not found in managed channels") + if th and th.is_alive(): + self._interrupt_thread(th, name) + continue + logger.info(f"[ChannelManager] Stopping channel '{name}'...") + try: + if hasattr(ch, 'stop'): + ch.stop() + except Exception as e: + logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {e}") + if th and th.is_alive(): + self._interrupt_thread(th, name) + + @staticmethod + def _interrupt_thread(th: threading.Thread, name: str): + """Raise SystemExit in target thread to break blocking loops like start_forever.""" + import ctypes + try: + tid = th.ident + if tid is None: + return + res = ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_ulong(tid), ctypes.py_object(SystemExit) + ) + if res == 1: + logger.info(f"[ChannelManager] Interrupted thread for channel '{name}'") + elif res > 1: + ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None) + logger.warning(f"[ChannelManager] Failed to interrupt thread for channel '{name}'") + except Exception as e: + logger.warning(f"[ChannelManager] Thread interrupt error for '{name}': {e}") + def restart(self, new_channel_name: str): """ Restart a single channel with a new channel type. diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py index f85357d..62df5c8 100644 --- a/bridge/agent_initializer.py +++ b/bridge/agent_initializer.py @@ -145,7 +145,7 @@ class AgentInitializer: # after a restart. The full max_turns budget is reserved for the # live conversation that follows. max_turns = conf().get("agent_max_context_turns", 30) - restore_turns = min(6, max(1, max_turns // 3)) + restore_turns = max(4, max_turns // 5) saved = store.load_messages(session_id, max_turns=restore_turns) if saved: with agent.messages_lock: diff --git a/channel/dingtalk/dingtalk_channel.py b/channel/dingtalk/dingtalk_channel.py index 4c41d3b..31f7701 100644 --- a/channel/dingtalk/dingtalk_channel.py +++ b/channel/dingtalk/dingtalk_channel.py @@ -101,6 +101,8 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler): # 历史消息id暂存,用于幂等控制 self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600)) self._stream_client = None + self._running = False + self._event_loop = None logger.debug("[DingTalk] client_id={}, client_secret={} ".format( self.dingtalk_client_id, self.dingtalk_client_secret)) # 无需群校验和前缀 @@ -114,21 +116,54 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler): self._robot_code = None def startup(self): + import asyncio + self.dingtalk_client_id = conf().get('dingtalk_client_id') + self.dingtalk_client_secret = conf().get('dingtalk_client_secret') + self._running = True credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret) client = dingtalk_stream.DingTalkStreamClient(credential) self._stream_client = client client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self) - logger.info("[DingTalk] ✅ Stream connected, ready to receive messages") - client.start_forever() + logger.info("[DingTalk] ✅ Stream client initialized, ready to receive messages") + _first_connect = True + while self._running: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._event_loop = loop + try: + if not _first_connect: + logger.info("[DingTalk] Reconnecting...") + _first_connect = False + loop.run_until_complete(client.start()) + except (KeyboardInterrupt, SystemExit): + logger.info("[DingTalk] Startup loop received stop signal, exiting") + break + except Exception as e: + if not self._running: + break + logger.warning(f"[DingTalk] Stream connection error: {e}, reconnecting in 3s...") + time.sleep(3) + finally: + self._event_loop = None + try: + loop.close() + except Exception: + pass + logger.info("[DingTalk] Startup loop exited") def stop(self): - if self._stream_client: + import asyncio + logger.info("[DingTalk] stop() called, setting _running=False") + self._running = False + loop = self._event_loop + if loop and not loop.is_closed(): try: - self._stream_client.stop() - logger.info("[DingTalk] Stream client stopped") + loop.call_soon_threadsafe(loop.stop) + logger.info("[DingTalk] Sent stop signal to event loop") except Exception as e: - logger.warning(f"[DingTalk] Error stopping stream client: {e}") - self._stream_client = None + logger.warning(f"[DingTalk] Error stopping event loop: {e}") + self._stream_client = None + logger.info("[DingTalk] stop() completed") def get_access_token(self): """ @@ -465,23 +500,21 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler): async def process(self, callback: dingtalk_stream.CallbackMessage): try: incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data) - + # 缓存 robot_code,用于后续图片下载 if hasattr(incoming_message, 'robot_code'): self._robot_code_cache = incoming_message.robot_code - - # Debug: 打印完整的 event 数据 - logger.debug(f"[DingTalk] ===== Incoming Message Debug =====") - logger.debug(f"[DingTalk] callback.data keys: {callback.data.keys() if hasattr(callback.data, 'keys') else 'N/A'}") - logger.debug(f"[DingTalk] incoming_message attributes: {dir(incoming_message)}") - logger.debug(f"[DingTalk] robot_code: {getattr(incoming_message, 'robot_code', 'N/A')}") - logger.debug(f"[DingTalk] chatbot_corp_id: {getattr(incoming_message, 'chatbot_corp_id', 'N/A')}") - logger.debug(f"[DingTalk] chatbot_user_id: {getattr(incoming_message, 'chatbot_user_id', 'N/A')}") - logger.debug(f"[DingTalk] conversation_id: {getattr(incoming_message, 'conversation_id', 'N/A')}") - logger.debug(f"[DingTalk] Raw callback.data: {callback.data}") - logger.debug(f"[DingTalk] =====================================") - - image_download_handler = self # 传入方法所在的类实例 + + # Filter out stale messages from before channel startup (offline backlog) + create_at = getattr(incoming_message, 'create_at', None) + if create_at: + msg_age_s = time.time() - int(create_at) / 1000 + if msg_age_s > 60: + logger.warning(f"[DingTalk] stale msg filtered (age={msg_age_s:.0f}s), " + f"msg_id={getattr(incoming_message, 'message_id', 'N/A')}") + return AckMessage.STATUS_OK, 'OK' + + image_download_handler = self dingtalk_msg = DingTalkMessage(incoming_message, image_download_handler) if dingtalk_msg.is_group: @@ -490,8 +523,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler): self.handle_single(dingtalk_msg) return AckMessage.STATUS_OK, 'OK' except Exception as e: - logger.error(f"[DingTalk] process error: {e}") - logger.exception(e) # 打印完整堆栈跟踪 + logger.error(f"[DingTalk] process error: {e}", exc_info=True) return AckMessage.STATUS_SYSTEM_EXCEPTION, 'ERROR' @time_checker diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index b9f4908..4b8a300 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -61,6 +61,8 @@ class FeiShuChanel(ChatChannel): # 历史消息id暂存,用于幂等控制 self.receivedMsgs = ExpiredDict(60 * 60 * 7.1) self._http_server = None + self._ws_client = None + self._ws_thread = None logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format( self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode)) # 无需群校验和前缀 @@ -73,12 +75,37 @@ class FeiShuChanel(ChatChannel): raise Exception("lark_oapi not installed") def startup(self): + self.feishu_app_id = conf().get('feishu_app_id') + self.feishu_app_secret = conf().get('feishu_app_secret') + self.feishu_token = conf().get('feishu_token') + self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket') if self.feishu_event_mode == 'websocket': self._startup_websocket() else: self._startup_webhook() def stop(self): + import ctypes + logger.info("[FeiShu] stop() called") + ws_client = self._ws_client + self._ws_client = None + ws_thread = self._ws_thread + self._ws_thread = None + # Interrupt the ws thread first so its blocking start() unblocks + if ws_thread and ws_thread.is_alive(): + try: + tid = ws_thread.ident + if tid: + res = ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_ulong(tid), ctypes.py_object(SystemExit) + ) + if res == 1: + logger.info("[FeiShu] Interrupted ws thread via ctypes") + elif res > 1: + ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None) + except Exception as e: + logger.warning(f"[FeiShu] Error interrupting ws thread: {e}") + # lark.ws.Client has no stop() method; thread interruption above is sufficient if self._http_server: try: self._http_server.stop() @@ -86,6 +113,7 @@ class FeiShuChanel(ChatChannel): except Exception as e: logger.warning(f"[FeiShu] Error stopping HTTP server: {e}") self._http_server = None + logger.info("[FeiShu] stop() completed") def _startup_webhook(self): """启动HTTP服务器接收事件(webhook模式)""" @@ -129,29 +157,26 @@ class FeiShuChanel(ChatChannel): .register_p2_im_message_receive_v1(handle_message_event) \ .build() - # 尝试连接,如果遇到SSL错误则自动禁用证书验证 def start_client_with_retry(): - """启动websocket客户端,自动处理SSL证书错误""" - # 全局禁用SSL证书验证(在导入lark_oapi之前设置) + """Run ws client in this thread with its own event loop to avoid conflicts.""" + import asyncio import ssl as ssl_module - - # 保存原始的SSL上下文创建方法 original_create_default_context = ssl_module.create_default_context def create_unverified_context(*args, **kwargs): - """创建一个不验证证书的SSL上下文""" context = original_create_default_context(*args, **kwargs) context.check_hostname = False context.verify_mode = ssl.CERT_NONE return context - # 尝试正常连接,如果失败则禁用SSL验证 + # Give this thread its own event loop so lark SDK can call run_until_complete + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + for attempt in range(2): try: if attempt == 1: - # 第二次尝试:禁用SSL验证 - logger.warning("[FeiShu] SSL certificate verification disabled due to certificate error. " - "This may happen when using corporate proxy or self-signed certificates.") + logger.warning("[FeiShu] Retrying with SSL verification disabled...") ssl_module.create_default_context = create_unverified_context ssl_module._create_unverified_context = create_unverified_context @@ -161,37 +186,34 @@ class FeiShuChanel(ChatChannel): event_handler=event_handler, log_level=lark.LogLevel.WARNING ) - + self._ws_client = ws_client logger.debug("[FeiShu] Websocket client starting...") ws_client.start() - # 如果成功启动,跳出循环 break + except (SystemExit, KeyboardInterrupt): + logger.info("[FeiShu] Websocket thread received stop signal") + break except Exception as e: error_msg = str(e) - # 检查是否是SSL证书验证错误 - is_ssl_error = "CERTIFICATE_VERIFY_FAILED" in error_msg or "certificate verify failed" in error_msg.lower() - + is_ssl_error = ("CERTIFICATE_VERIFY_FAILED" in error_msg + or "certificate verify failed" in error_msg.lower()) if is_ssl_error and attempt == 0: - # 第一次遇到SSL错误,记录日志并继续循环(下次会禁用验证) - logger.warning(f"[FeiShu] SSL certificate verification failed: {error_msg}") - logger.info("[FeiShu] Retrying connection with SSL verification disabled...") + logger.warning(f"[FeiShu] SSL error: {error_msg}, retrying...") continue - else: - # 其他错误或禁用验证后仍失败,抛出异常 - logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True) - # 恢复原始方法 - ssl_module.create_default_context = original_create_default_context - raise + logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True) + ssl_module.create_default_context = original_create_default_context + break + try: + loop.close() + except Exception: + pass + logger.info("[FeiShu] Websocket thread exited") - # 注意:不恢复原始方法,因为ws_client.start()会持续运行 - - # 在新线程中启动客户端,避免阻塞主线程 ws_thread = threading.Thread(target=start_client_with_retry, daemon=True) + self._ws_thread = ws_thread ws_thread.start() - - # 保持主线程运行 - logger.info("[FeiShu] ✅ Websocket connected, ready to receive messages") + logger.info("[FeiShu] ✅ Websocket thread started, ready to receive messages") ws_thread.join() def _handle_message_event(self, event: dict): @@ -212,6 +234,15 @@ class FeiShuChanel(ChatChannel): return self.receivedMsgs[msg_id] = True + # Filter out stale messages from before channel startup (offline backlog) + import time as _time + create_time_ms = msg.get("create_time") + if create_time_ms: + msg_age_s = _time.time() - int(create_time_ms) / 1000 + if msg_age_s > 60: + logger.warning(f"[FeiShu] stale msg filtered (age={msg_age_s:.0f}s), msg_id={msg_id}") + return + is_group = False chat_type = msg.get("chat_type") diff --git a/channel/web/chat.html b/channel/web/chat.html index a9aabea..886a857 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -427,14 +427,35 @@

View, enable, or disable agent skills

-
-
- + + +
+
+ Built-in Tools +
-

Loading skills...

-

Skills will be displayed here after loading

+
+ + Loading tools... +
+ +
+ + +
+
+ Skills + +
+
+
+ +
+

Loading skills...

+

Skills will be displayed here after loading

+
+
-
@@ -514,8 +535,15 @@

Channels

View and manage messaging channels

+
+ @@ -582,6 +610,32 @@ + + + diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 1a0e720..c90a7fa 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -35,12 +35,22 @@ const I18N = { config_custom_option: '自定义...', skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处', + tools_section_title: '内置工具', tools_loading: '加载工具中...', + skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', + skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', - channels_title: '通道管理', channels_desc: '查看和管理消息通道', - channels_coming: '即将推出', channels_coming_desc: '通道管理功能即将在此提供', + 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: '接入中...', tasks_title: '定时任务', tasks_desc: '查看和管理定时任务', tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供', logs_title: '日志', logs_desc: '实时日志输出 (run.log)', @@ -71,12 +81,22 @@ const I18N = { config_custom_option: 'Custom...', skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills', skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading', + tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...', + skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable', + skill_toggle_error: 'Operation failed, please try again', memory_title: 'Memory', memory_desc: 'View agent memory files and contents', memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here', memory_back: 'Back to list', memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated', - channels_title: 'Channels', channels_desc: 'View and manage messaging channels', - channels_coming: 'Coming Soon', channels_coming_desc: 'Channel management will be available here', + 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...', 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)', @@ -1134,57 +1154,161 @@ function loadConfigView() { // ===================================================================== // Skills View // ===================================================================== -let skillsLoaded = false; +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() { - if (skillsLoaded) return; - fetch('/api/skills').then(r => r.json()).then(data => { + 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 emptyEl = document.getElementById('skills-empty'); - const listEl = document.getElementById('skills-list'); - const skills = data.skills || []; - if (skills.length === 0) { - emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found'; + const tools = data.tools || []; + emptyEl.classList.add('hidden'); + if (tools.length === 0) { + emptyEl.classList.remove('hidden'); + emptyEl.innerHTML = `${currentLang === 'zh' ? '暂无内置工具' : 'No built-in tools'}`; return; } + badge.textContent = tools.length; + badge.classList.remove('hidden'); + listEl.innerHTML = ''; + tools.forEach(tool => { + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start gap-3'; + card.innerHTML = ` +
+ +
+
+
+ ${escapeHtml(tool.name)} +
+

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

+
`; + listEl.appendChild(card); + }); + listEl.classList.remove('hidden'); + toolsLoaded = true; + }).catch(() => { + emptyEl.classList.remove('hidden'); + emptyEl.innerHTML = `${currentLang === 'zh' ? '加载失败' : 'Failed to load'}`; + }); +} + +function loadSkillsSection() { + const emptyEl = document.getElementById('skills-empty'); + const listEl = document.getElementById('skills-list'); + const badge = document.getElementById('skills-count-badge'); + + fetch('/api/skills').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + const skills = data.skills || []; + if (skills.length === 0) { + const p = emptyEl.querySelector('p'); + if (p) p.textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found'; + return; + } + badge.textContent = skills.length; + badge.classList.remove('hidden'); emptyEl.classList.add('hidden'); listEl.innerHTML = ''; - const builtins = skills.filter(s => s.source === 'builtin'); - const customs = skills.filter(s => s.source !== 'builtin'); - - function renderGroup(title, items) { - if (items.length === 0) return; - const header = document.createElement('div'); - header.className = 'sm:col-span-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mt-2'; - header.textContent = title; - listEl.appendChild(header); - items.forEach(sk => { - const card = document.createElement('div'); - card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start gap-3'; - const iconColor = sk.enabled ? 'text-primary-400' : 'text-slate-300 dark:text-slate-600'; - const statusDot = sk.enabled - ? '' - : ''; - card.innerHTML = ` -
- -
-
-
- ${escapeHtml(sk.name)} - ${statusDot} -
-

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

-
`; - listEl.appendChild(card); - }); - } - renderGroup(currentLang === 'zh' ? '内置技能' : 'Built-in Skills', builtins); - renderGroup(currentLang === 'zh' ? '自定义技能' : 'Custom Skills', customs); - skillsLoaded = true; + skills.forEach(sk => { + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start gap-3 transition-opacity'; + card.dataset.skillName = sk.name; + card.dataset.skillDesc = sk.description || ''; + card.dataset.enabled = sk.enabled ? '1' : '0'; + renderSkillCard(card, sk); + listEl.appendChild(card); + }); }).catch(() => {}); } +function renderSkillCard(card, sk) { + const enabled = sk.enabled; + const iconColor = enabled ? 'text-primary-400' : 'text-slate-300 dark:text-slate-600'; + const trackClass = enabled + ? 'bg-primary-400' + : 'bg-slate-200 dark:bg-slate-700'; + const thumbTranslate = enabled ? 'translate-x-3' : 'translate-x-0.5'; + card.innerHTML = ` +
+ +
+
+
+ ${escapeHtml(sk.name)} + +
+

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

+
`; +} + +function toggleSkill(name, currentlyEnabled) { + const action = currentlyEnabled ? 'close' : 'open'; + const card = document.querySelector(`[data-skill-name="${CSS.escape(name)}"]`); + if (card) card.style.opacity = '0.5'; + + fetch('/api/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, name }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + if (card) { + const desc = card.dataset.skillDesc || ''; + card.dataset.enabled = currentlyEnabled ? '0' : '1'; + card.style.opacity = '1'; + renderSkillCard(card, { name, description: desc, enabled: !currentlyEnabled }); + } + } else { + if (card) card.style.opacity = '1'; + alert(currentLang === 'zh' ? '操作失败,请稍后再试' : 'Operation failed, please try again'); + } + }) + .catch(() => { + if (card) card.style.opacity = '1'; + alert(currentLang === 'zh' ? '操作失败,请稍后再试' : 'Operation failed, please try again'); + }); +} + // ===================================================================== // Memory View // ===================================================================== @@ -1257,36 +1381,362 @@ function closeMemoryViewer() { document.getElementById('memory-panel-list').classList.remove('hidden'); } +// ===================================================================== +// Custom Confirm Dialog +// ===================================================================== +function showConfirmDialog({ title, message, okText, cancelText, onConfirm }) { + const overlay = document.getElementById('confirm-dialog-overlay'); + document.getElementById('confirm-dialog-title').textContent = title || ''; + document.getElementById('confirm-dialog-message').textContent = message || ''; + document.getElementById('confirm-dialog-ok').textContent = okText || 'OK'; + document.getElementById('confirm-dialog-cancel').textContent = cancelText || t('channels_cancel'); + + function cleanup() { + overlay.classList.add('hidden'); + okBtn.removeEventListener('click', onOk); + cancelBtn.removeEventListener('click', onCancel); + overlay.removeEventListener('click', onOverlayClick); + } + function onOk() { cleanup(); if (onConfirm) onConfirm(); } + function onCancel() { cleanup(); } + function onOverlayClick(e) { if (e.target === overlay) cleanup(); } + + const okBtn = document.getElementById('confirm-dialog-ok'); + const cancelBtn = document.getElementById('confirm-dialog-cancel'); + okBtn.addEventListener('click', onOk); + cancelBtn.addEventListener('click', onCancel); + overlay.addEventListener('click', onOverlayClick); + overlay.classList.remove('hidden'); +} + // ===================================================================== // Channels View // ===================================================================== +let channelsData = []; + function loadChannelsView() { const container = document.getElementById('channels-content'); - const channelType = appConfig.channel_type || 'web'; - const channelMap = { - web: { name: 'Web', icon: 'fa-globe', color: 'primary' }, - terminal: { name: 'Terminal', icon: 'fa-terminal', color: 'slate' }, - feishu: { name: 'Feishu', icon: 'fa-paper-plane', color: 'blue' }, - dingtalk: { name: 'DingTalk', icon: 'fa-comments', color: 'blue' }, - wechatcom_app: { name: 'WeCom', icon: 'fa-building', color: 'emerald' }, - wechatmp: { name: 'WeChat MP', icon: 'fa-comment-dots', color: 'emerald' }, - wechatmp_service: { name: 'WeChat Service', icon: 'fa-comment-dots', color: 'emerald' }, - }; - const info = channelMap[channelType] || { name: channelType, icon: 'fa-tower-broadcast', color: 'sky' }; - container.innerHTML = ` -
-
- -
-
-
- ${info.name} - - Active + container.innerHTML = `
+ Loading...
`; + + fetch('/api/channels').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + channelsData = data.channels || []; + renderActiveChannels(); + }).catch(() => { + container.innerHTML = '

Failed to load channels

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

${escapeHtml(channelType)}

+

${t('channels_empty')}

+

${t('channels_empty_desc')}

+
`; + return; + } + + activeChannels.forEach(ch => { + const label = (typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label; + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; + card.id = `channel-card-${ch.name}`; + + const fieldsHtml = buildChannelFieldsHtml(ch.name, ch.fields || []); + + card.innerHTML = ` +
+
+ +
+
+
+ ${escapeHtml(label)} + + ${t('channels_connected')} +
+

${escapeHtml(ch.name)}

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

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

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

${t('channels_add')}

+
+
+
+
+ -- + +
+
+
+
+
+
`; + panel.classList.remove('hidden'); + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + const ddEl = document.getElementById('add-channel-select'); + initDropdown(ddEl, ddOptions, '', onAddChannelSelect); +} + +function closeAddChannelPanel() { + const panel = document.getElementById('channels-add-panel'); + if (panel) { + panel.classList.add('hidden'); + panel.innerHTML = ''; + } +} + +function onAddChannelSelect(chName) { + const fieldsContainer = document.getElementById('add-channel-fields'); + const actions = document.getElementById('add-channel-actions'); + + if (!chName) { + fieldsContainer.innerHTML = ''; + actions.classList.add('hidden'); + 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; + 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'); } + }); } // ===================================================================== diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 81920d7..e0ce301 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -304,6 +304,8 @@ class WebChannel(ChatChannel): '/stream', 'StreamHandler', '/chat', 'ChatHandler', '/config', 'ConfigHandler', + '/api/channels', 'ChannelsHandler', + '/api/tools', 'ToolsHandler', '/api/skills', 'SkillsHandler', '/api/memory', 'MemoryHandler', '/api/memory/content', 'MemoryContentHandler', @@ -325,6 +327,8 @@ class WebChannel(ChatChannel): func = web.httpserver.StaticMiddleware(app.wsgifunc()) func = web.httpserver.LogMiddleware(func) server = web.httpserver.WSGIServer(("0.0.0.0", port), func) + # Allow concurrent requests by not blocking on in-flight handler threads + server.daemon_threads = True self._http_server = server try: server.start() @@ -572,6 +576,323 @@ class ConfigHandler: return json.dumps({"status": "error", "message": str(e)}) +class ChannelsHandler: + """API for managing external channel configurations (feishu, dingtalk, etc).""" + + CHANNEL_DEFS = OrderedDict([ + ("feishu", { + "label": {"zh": "飞书", "en": "Feishu"}, + "icon": "fa-paper-plane", + "color": "blue", + "fields": [ + {"key": "feishu_app_id", "label": "App ID", "type": "text"}, + {"key": "feishu_app_secret", "label": "App Secret", "type": "secret"}, + {"key": "feishu_token", "label": "Verification Token", "type": "secret"}, + {"key": "feishu_bot_name", "label": "Bot Name", "type": "text"}, + ], + }), + ("dingtalk", { + "label": {"zh": "钉钉", "en": "DingTalk"}, + "icon": "fa-comments", + "color": "blue", + "fields": [ + {"key": "dingtalk_client_id", "label": "Client ID", "type": "text"}, + {"key": "dingtalk_client_secret", "label": "Client Secret", "type": "secret"}, + ], + }), + ("wechatcom_app", { + "label": {"zh": "企微自建应用", "en": "WeCom App"}, + "icon": "fa-building", + "color": "emerald", + "fields": [ + {"key": "wechatcom_corp_id", "label": "Corp ID", "type": "text"}, + {"key": "wechatcomapp_agent_id", "label": "Agent ID", "type": "text"}, + {"key": "wechatcomapp_secret", "label": "Secret", "type": "secret"}, + {"key": "wechatcomapp_token", "label": "Token", "type": "secret"}, + {"key": "wechatcomapp_aes_key", "label": "AES Key", "type": "secret"}, + {"key": "wechatcomapp_port", "label": "Port", "type": "number", "default": 9898}, + ], + }), + ("wechatmp", { + "label": {"zh": "公众号", "en": "WeChat MP"}, + "icon": "fa-comment-dots", + "color": "emerald", + "fields": [ + {"key": "wechatmp_app_id", "label": "App ID", "type": "text"}, + {"key": "wechatmp_app_secret", "label": "App Secret", "type": "secret"}, + {"key": "wechatmp_token", "label": "Token", "type": "secret"}, + {"key": "wechatmp_aes_key", "label": "AES Key", "type": "secret"}, + {"key": "wechatmp_port", "label": "Port", "type": "number", "default": 8080}, + ], + }), + ]) + + @staticmethod + def _mask_secret(value: str) -> str: + if not value or len(value) <= 8: + return value + return value[:4] + "*" * (len(value) - 8) + value[-4:] + + @staticmethod + def _parse_channel_list(raw) -> list: + if isinstance(raw, list): + return [ch.strip() for ch in raw if ch.strip()] + if isinstance(raw, str): + return [ch.strip() for ch in raw.split(",") if ch.strip()] + return [] + + @classmethod + def _active_channel_set(cls) -> set: + return set(cls._parse_channel_list(conf().get("channel_type", ""))) + + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + local_config = conf() + active_channels = self._active_channel_set() + channels = [] + for ch_name, ch_def in self.CHANNEL_DEFS.items(): + fields_out = [] + for f in ch_def["fields"]: + raw_val = local_config.get(f["key"], f.get("default", "")) + if f["type"] == "secret" and raw_val: + display_val = self._mask_secret(str(raw_val)) + else: + display_val = raw_val + fields_out.append({ + "key": f["key"], + "label": f["label"], + "type": f["type"], + "value": display_val, + "default": f.get("default", ""), + }) + channels.append({ + "name": ch_name, + "label": ch_def["label"], + "icon": ch_def["icon"], + "color": ch_def["color"], + "active": ch_name in active_channels, + "fields": fields_out, + }) + return json.dumps({"status": "success", "channels": channels}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Channels API error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + def POST(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + body = json.loads(web.data()) + action = body.get("action") + channel_name = body.get("channel") + + if not action or not channel_name: + return json.dumps({"status": "error", "message": "action and channel required"}) + + if channel_name not in self.CHANNEL_DEFS: + return json.dumps({"status": "error", "message": f"unknown channel: {channel_name}"}) + + if action == "save": + return self._handle_save(channel_name, body.get("config", {})) + elif action == "connect": + return self._handle_connect(channel_name, body.get("config", {})) + elif action == "disconnect": + return self._handle_disconnect(channel_name) + else: + return json.dumps({"status": "error", "message": f"unknown action: {action}"}) + except Exception as e: + logger.error(f"[WebChannel] Channels POST error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + def _handle_save(self, channel_name: str, updates: dict): + ch_def = self.CHANNEL_DEFS[channel_name] + valid_keys = {f["key"] for f in ch_def["fields"]} + secret_keys = {f["key"] for f in ch_def["fields"] if f["type"] == "secret"} + + local_config = conf() + applied = {} + for key, value in updates.items(): + if key not in valid_keys: + continue + if key in secret_keys: + if not value or (len(value) > 8 and "*" * 4 in value): + continue + field_def = next((f for f in ch_def["fields"] if f["key"] == key), None) + if field_def: + if field_def["type"] == "number": + value = int(value) + elif field_def["type"] == "bool": + value = bool(value) + local_config[key] = value + applied[key] = value + + if not applied: + return json.dumps({"status": "error", "message": "no valid fields to update"}) + + config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))), "config.json") + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + file_cfg = json.load(f) + else: + file_cfg = {} + file_cfg.update(applied) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(file_cfg, f, indent=4, ensure_ascii=False) + + logger.info(f"[WebChannel] Channel '{channel_name}' config updated: {list(applied.keys())}") + + should_restart = False + active_channels = self._active_channel_set() + if channel_name in active_channels: + should_restart = True + try: + import sys + app_module = sys.modules.get('__main__') or sys.modules.get('app') + mgr = getattr(app_module, '_channel_mgr', None) if app_module else None + if mgr: + threading.Thread( + target=mgr.restart, + args=(channel_name,), + daemon=True, + ).start() + logger.info(f"[WebChannel] Channel '{channel_name}' restart triggered") + except Exception as e: + logger.warning(f"[WebChannel] Failed to restart channel '{channel_name}': {e}") + + return json.dumps({ + "status": "success", + "applied": list(applied.keys()), + "restarted": should_restart, + }, ensure_ascii=False) + + def _handle_connect(self, channel_name: str, updates: dict): + """Save config fields, add channel to channel_type, and start it.""" + ch_def = self.CHANNEL_DEFS[channel_name] + valid_keys = {f["key"] for f in ch_def["fields"]} + secret_keys = {f["key"] for f in ch_def["fields"] if f["type"] == "secret"} + + # Feishu connected via web console must use websocket (long connection) mode + if channel_name == "feishu": + updates.setdefault("feishu_event_mode", "websocket") + valid_keys.add("feishu_event_mode") + + local_config = conf() + applied = {} + for key, value in updates.items(): + if key not in valid_keys: + continue + if key in secret_keys: + if not value or (len(value) > 8 and "*" * 4 in value): + continue + field_def = next((f for f in ch_def["fields"] if f["key"] == key), None) + if field_def: + if field_def["type"] == "number": + value = int(value) + elif field_def["type"] == "bool": + value = bool(value) + local_config[key] = value + applied[key] = value + + existing = self._parse_channel_list(conf().get("channel_type", "")) + if channel_name not in existing: + existing.append(channel_name) + new_channel_type = ",".join(existing) + local_config["channel_type"] = new_channel_type + + config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))), "config.json") + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + file_cfg = json.load(f) + else: + file_cfg = {} + file_cfg.update(applied) + file_cfg["channel_type"] = new_channel_type + with open(config_path, "w", encoding="utf-8") as f: + json.dump(file_cfg, f, indent=4, ensure_ascii=False) + + logger.info(f"[WebChannel] Channel '{channel_name}' connecting, channel_type={new_channel_type}") + + def _do_start(): + try: + import sys + app_module = sys.modules.get('__main__') or sys.modules.get('app') + clear_fn = getattr(app_module, '_clear_singleton_cache', None) if app_module else None + mgr = getattr(app_module, '_channel_mgr', None) if app_module else None + if mgr is None: + logger.warning(f"[WebChannel] ChannelManager not available, cannot start '{channel_name}'") + return + # Stop existing instance first if still running (e.g. re-connect without disconnect) + existing_ch = mgr.get_channel(channel_name) + if existing_ch is not None: + logger.info(f"[WebChannel] Stopping existing '{channel_name}' before reconnect...") + mgr.stop(channel_name) + # Always wait for the remote service to release the old connection before + # establishing a new one (DingTalk drops callbacks on duplicate connections) + logger.info(f"[WebChannel] Waiting for '{channel_name}' old connection to close...") + time.sleep(5) + if clear_fn: + clear_fn(channel_name) + logger.info(f"[WebChannel] Starting channel '{channel_name}'...") + mgr.start([channel_name], first_start=False) + logger.info(f"[WebChannel] Channel '{channel_name}' start completed") + except Exception as e: + logger.error(f"[WebChannel] Failed to start channel '{channel_name}': {e}", + exc_info=True) + + threading.Thread(target=_do_start, daemon=True).start() + + return json.dumps({ + "status": "success", + "channel_type": new_channel_type, + }, ensure_ascii=False) + + def _handle_disconnect(self, channel_name: str): + existing = self._parse_channel_list(conf().get("channel_type", "")) + existing = [ch for ch in existing if ch != channel_name] + new_channel_type = ",".join(existing) + + local_config = conf() + local_config["channel_type"] = new_channel_type + + config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))), "config.json") + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + file_cfg = json.load(f) + else: + file_cfg = {} + file_cfg["channel_type"] = new_channel_type + with open(config_path, "w", encoding="utf-8") as f: + json.dump(file_cfg, f, indent=4, ensure_ascii=False) + + def _do_stop(): + try: + import sys + app_module = sys.modules.get('__main__') or sys.modules.get('app') + mgr = getattr(app_module, '_channel_mgr', None) if app_module else None + clear_fn = getattr(app_module, '_clear_singleton_cache', None) if app_module else None + if mgr: + mgr.stop(channel_name) + else: + logger.warning(f"[WebChannel] ChannelManager not found, cannot stop '{channel_name}'") + if clear_fn: + clear_fn(channel_name) + logger.info(f"[WebChannel] Channel '{channel_name}' disconnected, " + f"channel_type={new_channel_type}") + except Exception as e: + logger.warning(f"[WebChannel] Failed to stop channel '{channel_name}': {e}", + exc_info=True) + + threading.Thread(target=_do_stop, daemon=True).start() + + return json.dumps({ + "status": "success", + "channel_type": new_channel_type, + }, ensure_ascii=False) + + def _get_workspace_root(): """Resolve the agent workspace directory.""" from common.utils import expand_path @@ -584,8 +905,19 @@ class ToolsHandler: try: from agent.tools.tool_manager import ToolManager tm = ToolManager() - loaded = list(tm.tool_classes.keys()) - return json.dumps({"status": "success", "tools": loaded}, ensure_ascii=False) + if not tm.tool_classes: + tm.load_tools() + tools = [] + for name, cls in tm.tool_classes.items(): + try: + instance = cls() + tools.append({ + "name": name, + "description": instance.description, + }) + except Exception: + tools.append({"name": name, "description": ""}) + return json.dumps({"status": "success", "tools": tools}, ensure_ascii=False) except Exception as e: logger.error(f"[WebChannel] Tools API error: {e}") return json.dumps({"status": "error", "message": str(e)})