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_bridge.py b/bridge/agent_bridge.py index 5d33c75..d7401ef 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -65,30 +65,67 @@ class AgentLLMModel(LLMModel): LLM Model adapter that uses COW's existing bot infrastructure """ + _MODEL_BOT_TYPE_MAP = { + "wenxin": const.BAIDU, "wenxin-4": const.BAIDU, + "xunfei": const.XUNFEI, const.QWEN: const.QWEN, + const.MODELSCOPE: const.MODELSCOPE, + } + _MODEL_PREFIX_MAP = [ + ("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE), + ("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI), + ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), + ("doubao", const.DOUBAO), + ] + def __init__(self, bridge: Bridge, bot_type: str = "chat"): - # Get model name directly from config from config import conf - model_name = conf().get("model", const.GPT_41) - super().__init__(model=model_name) + super().__init__(model=conf().get("model", const.GPT_41)) self.bridge = bridge self.bot_type = bot_type self._bot = None - self._use_linkai = conf().get("use_linkai", False) and conf().get("linkai_api_key") - + self._bot_model = None + + @property + def model(self): + from config import conf + return conf().get("model", const.GPT_41) + + @model.setter + def model(self, value): + pass + + def _resolve_bot_type(self, model_name: str) -> str: + """Resolve bot type from model name, matching Bridge.__init__ logic.""" + from config import conf + if conf().get("use_linkai", False) and conf().get("linkai_api_key"): + return const.LINKAI + if not model_name or not isinstance(model_name, str): + return const.CHATGPT + if model_name in self._MODEL_BOT_TYPE_MAP: + return self._MODEL_BOT_TYPE_MAP[model_name] + if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]: + return const.MiniMax + if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]: + return const.QWEN_DASHSCOPE + if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]: + return const.MOONSHOT + if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]: + return const.CHATGPT + for prefix, btype in self._MODEL_PREFIX_MAP: + if model_name.startswith(prefix): + return btype + return const.CHATGPT + @property def bot(self): - """Lazy load the bot and enhance it with tool calling if needed""" - if self._bot is None: - # If use_linkai is enabled, use LinkAI bot directly - if self._use_linkai: - self._bot = self.bridge.find_chat_bot(const.LINKAI) - else: - self._bot = self.bridge.get_bot(self.bot_type) - # Automatically add tool calling support if not present - self._bot = add_openai_compatible_support(self._bot) - - # Log bot info - bot_name = type(self._bot).__name__ + """Lazy load the bot, re-create when model changes""" + from models.bot_factory import create_bot + cur_model = self.model + if self._bot is None or self._bot_model != cur_model: + bot_type = self._resolve_bot_type(cur_model) + self._bot = create_bot(bot_type) + self._bot = add_openai_compatible_support(self._bot) + self._bot_model = cur_model return self._bot def call(self, request: LLMRequest): 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 1a6a1fc..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 @@ -159,39 +184,36 @@ class FeiShuChanel(ChatChannel): self.feishu_app_id, self.feishu_app_secret, event_handler=event_handler, - log_level=lark.LogLevel.DEBUG if conf().get("debug") else lark.LogLevel.WARNING + 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 904eb45..886a857 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -294,68 +294,122 @@
+ -
-
+
+

Model Configuration

-
-
- Model - -- +
+ +
+ +
+
+ -- + +
+
+
+
+ +
+ +
+
+ -- + +
+
+
+ +
+ +
+ +
+ + +
+
+ + + +
+ +
+ -
-
+
+

Agent Configuration

-
- Agent Mode - -- +
+ +
-
- Max Tokens - -- +
+ +
-
- Max Turns - -- +
+ +
-
- Max Steps - -- +
+ +
- -
-
-
- -
-

Channel Configuration

-
-
-
- Channel Type - -- -
-
-
-
- -
- - Full editing capability coming soon. Currently displaying read-only configuration. +
@@ -373,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

+
+
-
@@ -460,8 +535,15 @@

Channels

View and manage messaging channels

+
+
@@ -528,6 +610,32 @@ + + + diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 2390569..8d0442f 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -222,6 +222,121 @@ /* Tool failed state */ .agent-tool-step.tool-failed .tool-name { color: #f87171; } +/* Config form controls */ +#view-config input[type="text"], +#view-config input[type="number"], +#view-config input[type="password"] { + height: 40px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} +#view-config input:focus { + border-color: #4ABE6E; + box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12); +} +#view-config input[type="text"]:hover, +#view-config input[type="number"]:hover, +#view-config input[type="password"]:hover { + border-color: #94a3b8; +} +.dark #view-config input[type="text"]:hover, +.dark #view-config input[type="number"]:hover, +.dark #view-config input[type="password"]:hover { + border-color: #64748b; +} + +/* Custom dropdown */ +.cfg-dropdown { + position: relative; + outline: none; +} +.cfg-dropdown-selected { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding: 0 0.75rem; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 0.875rem; + color: #1e293b; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + user-select: none; +} +.dark .cfg-dropdown-selected { + border-color: #475569; + background: rgba(255, 255, 255, 0.05); + color: #f1f5f9; +} +.cfg-dropdown-selected:hover { border-color: #94a3b8; } +.dark .cfg-dropdown-selected:hover { border-color: #64748b; } +.cfg-dropdown.open .cfg-dropdown-selected, +.cfg-dropdown:focus .cfg-dropdown-selected { + border-color: #4ABE6E; + box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12); +} +.cfg-dropdown-arrow { + font-size: 0.625rem; + color: #94a3b8; + transition: transform 0.2s ease; + flex-shrink: 0; + margin-left: 0.5rem; +} +.cfg-dropdown.open .cfg-dropdown-arrow { transform: rotate(180deg); } +.cfg-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 50; + max-height: 240px; + overflow-y: auto; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + background: #ffffff; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04); + padding: 4px; +} +.dark .cfg-dropdown-menu { + border-color: #334155; + background: #1e1e1e; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4); +} +.cfg-dropdown.open .cfg-dropdown-menu { display: block; } +.cfg-dropdown-item { + display: flex; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + font-size: 0.875rem; + color: #334155; + cursor: pointer; + transition: background 0.15s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dark .cfg-dropdown-item { color: #cbd5e1; } +.cfg-dropdown-item:hover { background: #f1f5f9; } +.dark .cfg-dropdown-item:hover { background: rgba(255, 255, 255, 0.08); } +.cfg-dropdown-item.active { + background: rgba(74, 190, 110, 0.1); + color: #228547; + font-weight: 500; +} +.dark .cfg-dropdown-item.active { + background: rgba(74, 190, 110, 0.15); + color: #74E9A4; +} + +/* API Key masking via CSS (avoids browser password prompts) */ +.cfg-key-masked { + -webkit-text-security: disc; + text-security: disc; +} + /* Chat Input */ #chat-input { resize: none; height: 42px; max-height: 180px; diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 7320d7e..c90a7fa 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -28,15 +28,29 @@ const I18N = { config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token', config_max_turns: '最大轮次', config_max_steps: '最大步数', config_channel_type: '通道类型', - config_coming_soon: '完整编辑功能即将推出,当前为只读展示。', + config_provider: '模型厂商', config_model_name: '模型', + config_custom_model_hint: '输入自定义模型名称', + config_save: '保存', config_saved: '已保存', + config_save_error: '保存失败', + 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)', @@ -60,15 +74,29 @@ const I18N = { config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens', config_max_turns: 'Max Turns', config_max_steps: 'Max Steps', config_channel_type: 'Channel Type', - config_coming_soon: 'Full editing capability coming soon. Currently displaying read-only configuration.', + 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...', 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)', @@ -236,7 +264,7 @@ let isPolling = false; let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource let isComposing = false; -let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' }; +let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; const SESSION_ID_KEY = 'cow_session_id'; @@ -268,14 +296,8 @@ fetch('/config').then(r => r.json()).then(data => { appConfig = data; const title = data.title || 'CowAgent'; document.getElementById('welcome-title').textContent = title; - document.getElementById('cfg-model').textContent = data.model || '--'; - document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled'; - document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--'; - document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--'; - document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--'; - document.getElementById('cfg-channel').textContent = data.channel_type || '--'; + initConfigView(data); } - // Load conversation history after config is ready loadHistory(1); }).catch(() => { loadHistory(1); }); @@ -820,72 +842,473 @@ function applyHighlighting(container) { // ===================================================================== // Config View // ===================================================================== +let configProviders = {}; +let configApiBases = {}; +let configApiKeys = {}; +let configCurrentModel = ''; +let cfgProviderValue = ''; +let cfgModelValue = ''; + +// --- Custom dropdown helper --- +function initDropdown(el, options, selectedValue, onChange) { + const textEl = el.querySelector('.cfg-dropdown-text'); + const menuEl = el.querySelector('.cfg-dropdown-menu'); + const selEl = el.querySelector('.cfg-dropdown-selected'); + + el._ddValue = selectedValue || ''; + el._ddOnChange = onChange; + + function render() { + menuEl.innerHTML = ''; + options.forEach(opt => { + const item = document.createElement('div'); + item.className = 'cfg-dropdown-item' + (opt.value === el._ddValue ? ' active' : ''); + item.textContent = opt.label; + item.dataset.value = opt.value; + item.addEventListener('click', (e) => { + e.stopPropagation(); + el._ddValue = opt.value; + textEl.textContent = opt.label; + menuEl.querySelectorAll('.cfg-dropdown-item').forEach(i => i.classList.remove('active')); + item.classList.add('active'); + el.classList.remove('open'); + if (el._ddOnChange) el._ddOnChange(opt.value); + }); + menuEl.appendChild(item); + }); + const sel = options.find(o => o.value === el._ddValue); + textEl.textContent = sel ? sel.label : (options[0] ? options[0].label : '--'); + if (!sel && options[0]) el._ddValue = options[0].value; + } + + render(); + + if (!el._ddBound) { + selEl.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.cfg-dropdown.open').forEach(d => { if (d !== el) d.classList.remove('open'); }); + el.classList.toggle('open'); + }); + el._ddBound = true; + } +} + +document.addEventListener('click', () => { + document.querySelectorAll('.cfg-dropdown.open').forEach(d => d.classList.remove('open')); +}); + +function getDropdownValue(el) { return el._ddValue || ''; } + +// --- Config init --- +function initConfigView(data) { + configProviders = data.providers || {}; + configApiBases = data.api_bases || {}; + configApiKeys = data.api_keys || {}; + configCurrentModel = data.model || ''; + + const providerEl = document.getElementById('cfg-provider'); + const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: p.label })); + + const detected = 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 || 30; + document.getElementById('cfg-max-steps').value = data.agent_max_steps || 15; +} + +function detectProvider(model) { + if (!model) return Object.keys(configProviders)[0] || ''; + for (const [pid, p] of Object.entries(configProviders)) { + if (pid === 'linkai') continue; + if (p.models && p.models.includes(model)) return pid; + } + return Object.keys(configProviders)[0] || ''; +} + +function onProviderChange(pid) { + cfgProviderValue = pid || getDropdownValue(document.getElementById('cfg-provider')); + const p = configProviders[cfgProviderValue]; + if (!p) return; + + const modelEl = document.getElementById('cfg-model-select'); + const modelOpts = (p.models || []).map(m => ({ value: m, label: m })); + modelOpts.push({ value: '__custom__', label: t('config_custom_option') }); + + initDropdown(modelEl, modelOpts, modelOpts[0] ? modelOpts[0].value : '', onModelSelectChange); + + // API Key + const keyField = p.api_key_field; + const keyWrap = document.getElementById('cfg-api-key-wrap'); + const keyInput = document.getElementById('cfg-api-key'); + if (keyField) { + keyWrap.classList.remove('hidden'); + keyInput.classList.add('cfg-key-masked'); + const maskedVal = configApiKeys[keyField] || ''; + keyInput.value = maskedVal; + keyInput.dataset.field = keyField; + keyInput.dataset.masked = maskedVal ? '1' : ''; + keyInput.dataset.maskedVal = maskedVal; + const toggleIcon = document.querySelector('#cfg-api-key-toggle i'); + if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs'; + + if (!keyInput._cfgBound) { + keyInput.addEventListener('focus', function() { + if (this.dataset.masked === '1') { + this.value = ''; + this.dataset.masked = ''; + this.classList.remove('cfg-key-masked'); + } + }); + keyInput.addEventListener('blur', function() { + if (!this.value.trim() && this.dataset.maskedVal) { + this.value = this.dataset.maskedVal; + this.dataset.masked = '1'; + this.classList.add('cfg-key-masked'); + } + }); + keyInput.addEventListener('input', function() { + this.dataset.masked = ''; + }); + keyInput._cfgBound = true; + } + } else { + keyWrap.classList.add('hidden'); + keyInput.value = ''; + keyInput.dataset.field = ''; + } + + // API Base + if (p.api_base_key) { + document.getElementById('cfg-api-base-wrap').classList.remove('hidden'); + document.getElementById('cfg-api-base').value = configApiBases[p.api_base_key] || p.api_base_default || ''; + } else { + document.getElementById('cfg-api-base-wrap').classList.add('hidden'); + document.getElementById('cfg-api-base').value = ''; + } + + onModelSelectChange(modelOpts[0] ? modelOpts[0].value : ''); +} + +function onModelSelectChange(val) { + cfgModelValue = val || getDropdownValue(document.getElementById('cfg-model-select')); + const customWrap = document.getElementById('cfg-model-custom-wrap'); + if (cfgModelValue === '__custom__') { + customWrap.classList.remove('hidden'); + document.getElementById('cfg-model-custom').focus(); + } else { + customWrap.classList.add('hidden'); + document.getElementById('cfg-model-custom').value = ''; + } +} + +function syncModelSelection(model) { + const p = configProviders[cfgProviderValue]; + if (!p) return; + + const modelEl = document.getElementById('cfg-model-select'); + if (p.models && p.models.includes(model)) { + const modelOpts = (p.models || []).map(m => ({ value: m, label: m })); + modelOpts.push({ value: '__custom__', label: t('config_custom_option') }); + initDropdown(modelEl, modelOpts, model, onModelSelectChange); + cfgModelValue = model; + document.getElementById('cfg-model-custom-wrap').classList.add('hidden'); + } else { + cfgModelValue = '__custom__'; + const modelOpts = (p.models || []).map(m => ({ value: m, label: m })); + modelOpts.push({ value: '__custom__', label: t('config_custom_option') }); + initDropdown(modelEl, modelOpts, '__custom__', onModelSelectChange); + document.getElementById('cfg-model-custom-wrap').classList.remove('hidden'); + document.getElementById('cfg-model-custom').value = model; + } +} + +function getSelectedModel() { + if (cfgModelValue === '__custom__') { + return document.getElementById('cfg-model-custom').value.trim(); + } + return cfgModelValue; +} + +function toggleApiKeyVisibility() { + const input = document.getElementById('cfg-api-key'); + const icon = document.querySelector('#cfg-api-key-toggle i'); + if (input.classList.contains('cfg-key-masked')) { + input.classList.remove('cfg-key-masked'); + icon.className = 'fas fa-eye-slash text-xs'; + } else { + input.classList.add('cfg-key-masked'); + icon.className = 'fas fa-eye text-xs'; + } +} + +function showStatus(elId, msgKey, isError) { + const el = document.getElementById(elId); + el.textContent = t(msgKey); + el.classList.toggle('text-red-500', !!isError); + el.classList.toggle('text-primary-500', !isError); + el.classList.remove('opacity-0'); + setTimeout(() => el.classList.add('opacity-0'), 2500); +} + +function saveModelConfig() { + const model = getSelectedModel(); + if (!model) return; + + const updates = { model: model }; + const p = configProviders[cfgProviderValue]; + updates.use_linkai = (cfgProviderValue === 'linkai'); + if (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) || 30, + agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 15, + }; + + 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 loadConfigView() { fetch('/config').then(r => r.json()).then(data => { if (data.status !== 'success') return; - document.getElementById('cfg-model').textContent = data.model || '--'; - document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled'; - document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--'; - document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--'; - document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--'; - document.getElementById('cfg-channel').textContent = data.channel_type || '--'; + appConfig = data; + initConfigView(data); }).catch(() => {}); } // ===================================================================== // 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 // ===================================================================== @@ -958,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'); } + }); } // ===================================================================== @@ -1089,7 +1838,8 @@ navigateTo = function(viewId) { _origNavigateTo(viewId); // Lazy-load view data - if (viewId === 'skills') loadSkillsView(); + if (viewId === 'config') loadConfigView(); + else if (viewId === 'skills') loadSkillsView(); else if (viewId === 'memory') { // Always start from the list panel when navigating to memory document.getElementById('memory-panel-viewer').classList.add('hidden'); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index f167c70..e0ce301 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -14,6 +14,8 @@ from bridge.context import * from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel, check_prefix from channel.chat_message import ChatMessage +from collections import OrderedDict +from common import const from common.log import logger from common.singleton import singleton from config import conf @@ -302,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', @@ -323,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() @@ -379,16 +385,137 @@ class ChatHandler: class ConfigHandler: + + _RECOMMENDED_MODELS = [ + const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, + const.GLM_5, const.GLM_4_7, + const.QWEN3_MAX, const.QWEN35_PLUS, + const.KIMI_K2_5, const.KIMI_K2, + const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE, + const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET, + const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, + const.GPT_5, const.GPT_41, const.GPT_4o, + const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER, + ] + + PROVIDER_MODELS = OrderedDict([ + ("minimax", { + "label": "MiniMax", + "api_key_field": "minimax_api_key", + "api_base_key": None, + "api_base_default": None, + "models": [const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING], + }), + ("glm-4", { + "label": "智谱AI", + "api_key_field": "zhipu_ai_api_key", + "api_base_key": "zhipu_ai_api_base", + "api_base_default": "https://open.bigmodel.cn/api/paas/v4", + "models": [const.GLM_5, const.GLM_4_7], + }), + ("dashscope", { + "label": "通义千问", + "api_key_field": "dashscope_api_key", + "api_base_key": None, + "api_base_default": None, + "models": [const.QWEN3_MAX, const.QWEN35_PLUS], + }), + ("moonshot", { + "label": "Kimi", + "api_key_field": "moonshot_api_key", + "api_base_key": "moonshot_base_url", + "api_base_default": "https://api.moonshot.cn/v1", + "models": [const.KIMI_K2_5, const.KIMI_K2], + }), + ("doubao", { + "label": "豆包", + "api_key_field": "ark_api_key", + "api_base_key": "ark_base_url", + "api_base_default": "https://ark.cn-beijing.volces.com/api/v3", + "models": [const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE], + }), + ("claudeAPI", { + "label": "Claude", + "api_key_field": "claude_api_key", + "api_base_key": "claude_api_base", + "api_base_default": "https://api.anthropic.com/v1", + "models": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET], + }), + ("gemini", { + "label": "Gemini", + "api_key_field": "gemini_api_key", + "api_base_key": "gemini_api_base", + "api_base_default": "https://generativelanguage.googleapis.com", + "models": [const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE], + }), + ("openAI", { + "label": "OpenAI", + "api_key_field": "open_ai_api_key", + "api_base_key": "open_ai_api_base", + "api_base_default": "https://api.openai.com/v1", + "models": [const.GPT_5, const.GPT_41, const.GPT_4o], + }), + ("deepseek", { + "label": "DeepSeek", + "api_key_field": "open_ai_api_key", + "api_base_key": None, + "api_base_default": None, + "models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER], + }), + ("linkai", { + "label": "LinkAI", + "api_key_field": "linkai_api_key", + "api_base_key": None, + "api_base_default": None, + "models": _RECOMMENDED_MODELS, + }), + ]) + + EDITABLE_KEYS = { + "model", "use_linkai", + "open_ai_api_base", "claude_api_base", "gemini_api_base", + "zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", + "open_ai_api_key", "claude_api_key", "gemini_api_key", + "zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key", + "ark_api_key", "minimax_api_key", "linkai_api_key", + "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", + } + + @staticmethod + def _mask_key(value: str) -> str: + """Mask the middle part of an API key for display.""" + if not value or len(value) <= 8: + return value + return value[:4] + "*" * (len(value) - 8) + value[-4:] + def GET(self): - """Return configuration info for the web console.""" + """Return configuration info and provider/model metadata.""" + web.header('Content-Type', 'application/json; charset=utf-8') try: local_config = conf() use_agent = local_config.get("agent", False) + title = "CowAgent" if use_agent else "AI Assistant" - if use_agent: - title = "CowAgent" - else: - title = "AI Assistant" + api_bases = {} + api_keys_masked = {} + for pid, pinfo in self.PROVIDER_MODELS.items(): + base_key = pinfo.get("api_base_key") + if base_key: + api_bases[base_key] = local_config.get(base_key, pinfo["api_base_default"]) + key_field = pinfo.get("api_key_field") + if key_field and key_field not in api_keys_masked: + raw = local_config.get(key_field, "") + api_keys_masked[key_field] = self._mask_key(raw) if raw else "" + + providers = {} + for pid, p in self.PROVIDER_MODELS.items(): + providers[pid] = { + "label": p["label"], + "models": p["models"], + "api_base_key": p["api_base_key"], + "api_base_default": p["api_base_default"], + "api_key_field": p.get("api_key_field"), + } return json.dumps({ "status": "success", @@ -396,14 +523,375 @@ class ConfigHandler: "title": title, "model": local_config.get("model", ""), "channel_type": local_config.get("channel_type", ""), - "agent_max_context_tokens": local_config.get("agent_max_context_tokens", ""), - "agent_max_context_turns": local_config.get("agent_max_context_turns", ""), - "agent_max_steps": local_config.get("agent_max_steps", ""), - }) + "agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000), + "agent_max_context_turns": local_config.get("agent_max_context_turns", 30), + "agent_max_steps": local_config.get("agent_max_steps", 15), + "api_bases": api_bases, + "api_keys": api_keys_masked, + "providers": providers, + }, ensure_ascii=False) except Exception as e: logger.error(f"Error getting config: {e}") return json.dumps({"status": "error", "message": str(e)}) + def POST(self): + """Update configuration values in memory and persist to config.json.""" + web.header('Content-Type', 'application/json; charset=utf-8') + try: + data = json.loads(web.data()) + updates = data.get("updates", {}) + if not updates: + return json.dumps({"status": "error", "message": "no updates provided"}) + + local_config = conf() + applied = {} + for key, value in updates.items(): + if key not in self.EDITABLE_KEYS: + continue + if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"): + value = int(value) + if key == "use_linkai": + value = bool(value) + local_config[key] = value + applied[key] = value + + if not applied: + return json.dumps({"status": "error", "message": "no valid keys 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] Config updated: {list(applied.keys())}") + return json.dumps({"status": "success", "applied": applied}, ensure_ascii=False) + except Exception as e: + logger.error(f"Error updating config: {e}") + 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.""" @@ -411,6 +899,30 @@ def _get_workspace_root(): return expand_path(conf().get("agent_workspace", "~/cow")) +class ToolsHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + from agent.tools.tool_manager import ToolManager + tm = ToolManager() + 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)}) + + class SkillsHandler: def GET(self): web.header('Content-Type', 'application/json; charset=utf-8') @@ -426,6 +938,30 @@ class SkillsHandler: logger.error(f"[WebChannel] Skills API error: {e}") return json.dumps({"status": "error", "message": str(e)}) + def POST(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + from agent.skills.service import SkillService + from agent.skills.manager import SkillManager + body = json.loads(web.data()) + action = body.get("action") + name = body.get("name") + if not action or not name: + return json.dumps({"status": "error", "message": "action and name are required"}) + workspace_root = _get_workspace_root() + manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills")) + service = SkillService(manager) + if action == "open": + service.open({"name": name}) + elif action == "close": + service.close({"name": name}) + else: + return json.dumps({"status": "error", "message": f"unknown action: {action}"}) + return json.dumps({"status": "success"}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Skills POST error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + class MemoryHandler: def GET(self):