mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-02-28 17:01:08 +08:00
feat(channels): add multi-channel management UI with real-time connect/disconnect
- Web console Channels page: display active channels as config cards, support save/connect/disconnect with real-time start/stop of channel processes - Custom dropdown for channel selection (consistent with model selector style), custom confirmation dialog for disconnect - Fix channel stop: use sys.modules['__main__'] to access live ChannelManager - Fix web request pending: move stop logic outside lock, set daemon_threads=True - Fix reconnect: new asyncio event loop per startup, ctypes thread interrupt, 5s grace period before re-establishing remote connection - Filter stale offline messages (>60s) pushed after reconnect
This commit is contained in:
47
app.py
47
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -427,14 +427,35 @@
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="skills_desc">View, enable, or disable agent skills</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="skills-empty" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-bolt text-amber-400 text-xl"></i>
|
||||
|
||||
<!-- Built-in Tools Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="tools_section_title">Built-in Tools</span>
|
||||
<span id="tools-count-badge" class="hidden px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="skills_loading_desc">Skills will be displayed here after loading</p>
|
||||
<div id="tools-empty" class="flex items-center gap-2 py-4 text-slate-400 dark:text-slate-500 text-sm">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
<span data-i18n="tools_loading">Loading tools...</span>
|
||||
</div>
|
||||
<div id="tools-list" class="grid gap-3 sm:grid-cols-2 hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Section -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500" data-i18n="skills_section_title">Skills</span>
|
||||
<span id="skills-count-badge" class="hidden px-2 py-0.5 rounded-full text-xs bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400"></span>
|
||||
</div>
|
||||
<div id="skills-empty" class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
|
||||
<i class="fas fa-bolt text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="skills_loading_desc">Skills will be displayed here after loading</p>
|
||||
</div>
|
||||
<div id="skills-list" class="grid gap-4 sm:grid-cols-2"></div>
|
||||
</div>
|
||||
<div id="skills-list" class="grid gap-4 sm:grid-cols-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -514,8 +535,15 @@
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="channels_title">Channels</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="channels_desc">View and manage messaging channels</p>
|
||||
</div>
|
||||
<button id="add-channel-btn" onclick="openAddChannelPanel()"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
|
||||
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
<span data-i18n="channels_add">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="channels-content" class="grid gap-4"></div>
|
||||
<div id="channels-add-panel" class="hidden mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,6 +610,32 @@
|
||||
</div><!-- /main-content -->
|
||||
</div><!-- /app -->
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
w-full max-w-sm mx-4 overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-red-50 dark:bg-red-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-triangle-exclamation text-red-500"></i>
|
||||
</div>
|
||||
<h3 id="confirm-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||
</div>
|
||||
<p id="confirm-dialog-message" class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed ml-[52px]"></p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
||||
<button id="confirm-dialog-cancel"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150"></button>
|
||||
<button id="confirm-dialog-ok"
|
||||
class="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 = `<span class="text-sm text-slate-400 dark:text-slate-500">${currentLang === 'zh' ? '暂无内置工具' : 'No built-in tools'}</span>`;
|
||||
return;
|
||||
}
|
||||
badge.textContent = tools.length;
|
||||
badge.classList.remove('hidden');
|
||||
listEl.innerHTML = '';
|
||||
tools.forEach(tool => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start gap-3';
|
||||
card.innerHTML = `
|
||||
<div class="w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas ${getToolIcon(tool.name)} text-blue-500 dark:text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 font-mono">${escapeHtml(tool.name)}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1 line-clamp-2">${escapeHtml(tool.description || '--')}</p>
|
||||
</div>`;
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
listEl.classList.remove('hidden');
|
||||
toolsLoaded = true;
|
||||
}).catch(() => {
|
||||
emptyEl.classList.remove('hidden');
|
||||
emptyEl.innerHTML = `<span class="text-sm text-slate-400 dark:text-slate-500">${currentLang === 'zh' ? '加载失败' : 'Failed to load'}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
function loadSkillsSection() {
|
||||
const emptyEl = document.getElementById('skills-empty');
|
||||
const listEl = document.getElementById('skills-list');
|
||||
const badge = document.getElementById('skills-count-badge');
|
||||
|
||||
fetch('/api/skills').then(r => r.json()).then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
const skills = data.skills || [];
|
||||
if (skills.length === 0) {
|
||||
const p = emptyEl.querySelector('p');
|
||||
if (p) p.textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found';
|
||||
return;
|
||||
}
|
||||
badge.textContent = skills.length;
|
||||
badge.classList.remove('hidden');
|
||||
emptyEl.classList.add('hidden');
|
||||
listEl.innerHTML = '';
|
||||
|
||||
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
|
||||
? '<span class="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0 mt-1"></span>'
|
||||
: '<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 flex-shrink-0 mt-1"></span>';
|
||||
card.innerHTML = `
|
||||
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-bolt ${iconColor} text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate">${escapeHtml(sk.name)}</span>
|
||||
${statusDot}
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1 line-clamp-2">${escapeHtml(sk.description || '--')}</p>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-bolt ${iconColor} text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.name)}</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked="${enabled}"
|
||||
onclick="toggleSkill('${escapeHtml(sk.name)}', ${enabled})"
|
||||
class="relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${trackClass}"
|
||||
title="${enabled ? (currentLang === 'zh' ? '点击禁用' : 'Click to disable') : (currentLang === 'zh' ? '点击启用' : 'Click to enable')}"
|
||||
>
|
||||
<span class="inline-block h-3 w-3 mt-0.5 rounded-full bg-white shadow transform transition-transform duration-200 ease-in-out ${thumbTranslate}"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 line-clamp-2">${escapeHtml(sk.description || '--')}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleSkill(name, currentlyEnabled) {
|
||||
const action = currentlyEnabled ? 'close' : 'open';
|
||||
const card = document.querySelector(`[data-skill-name="${CSS.escape(name)}"]`);
|
||||
if (card) card.style.opacity = '0.5';
|
||||
|
||||
fetch('/api/skills', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, name })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (card) {
|
||||
const desc = card.dataset.skillDesc || '';
|
||||
card.dataset.enabled = currentlyEnabled ? '0' : '1';
|
||||
card.style.opacity = '1';
|
||||
renderSkillCard(card, { name, description: desc, enabled: !currentlyEnabled });
|
||||
}
|
||||
} else {
|
||||
if (card) card.style.opacity = '1';
|
||||
alert(currentLang === 'zh' ? '操作失败,请稍后再试' : 'Operation failed, please try again');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (card) card.style.opacity = '1';
|
||||
alert(currentLang === 'zh' ? '操作失败,请稍后再试' : 'Operation failed, please try again');
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Memory View
|
||||
// =====================================================================
|
||||
@@ -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 = `
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-${info.color}-50 dark:bg-${info.color}-900/20 flex items-center justify-center">
|
||||
<i class="fas ${info.icon} text-${info.color}-500 text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-slate-800 dark:text-slate-100">${info.name}</span>
|
||||
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||
<span class="text-xs text-primary-500">Active</span>
|
||||
container.innerHTML = `<div class="flex items-center gap-2 py-8 justify-center text-slate-400 dark:text-slate-500 text-sm">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i><span>Loading...</span></div>`;
|
||||
|
||||
fetch('/api/channels').then(r => r.json()).then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
channelsData = data.channels || [];
|
||||
renderActiveChannels();
|
||||
}).catch(() => {
|
||||
container.innerHTML = '<p class="text-sm text-red-400 py-8 text-center">Failed to load channels</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderActiveChannels() {
|
||||
const container = document.getElementById('channels-content');
|
||||
container.innerHTML = '';
|
||||
closeAddChannelPanel();
|
||||
|
||||
const activeChannels = channelsData.filter(ch => ch.active);
|
||||
|
||||
if (activeChannels.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-tower-broadcast text-blue-400 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(channelType)}</p>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium">${t('channels_empty')}</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">${t('channels_empty_desc')}</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
activeChannels.forEach(ch => {
|
||||
const label = (typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
|
||||
card.id = `channel-card-${ch.name}`;
|
||||
|
||||
const fieldsHtml = buildChannelFieldsHtml(ch.name, ch.fields || []);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-4 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-slate-800 dark:text-slate-100">${escapeHtml(label)}</span>
|
||||
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||
<span class="text-xs text-primary-500">${t('channels_connected')}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(ch.name)}</p>
|
||||
</div>
|
||||
<button onclick="disconnectChannel('${ch.name}')"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium
|
||||
bg-red-50 dark:bg-red-900/20 text-red-500 dark:text-red-400
|
||||
hover:bg-red-100 dark:hover:bg-red-900/40
|
||||
cursor-pointer transition-colors flex-shrink-0">
|
||||
${t('channels_disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
${fieldsHtml}
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
<button onclick="saveChannelConfig('${ch.name}')"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
id="ch-save-${ch.name}">${t('channels_save')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.appendChild(card);
|
||||
bindSecretFieldEvents(card);
|
||||
});
|
||||
}
|
||||
|
||||
function buildChannelFieldsHtml(chName, fields) {
|
||||
let html = '';
|
||||
fields.forEach(f => {
|
||||
const inputId = `ch-${chName}-${f.key}`;
|
||||
let inputHtml = '';
|
||||
if (f.type === 'bool') {
|
||||
const checked = f.value ? 'checked' : '';
|
||||
inputHtml = `<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input id="${inputId}" type="checkbox" ${checked} class="sr-only peer" data-field="${f.key}" data-ch="${chName}">
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-checked:bg-primary-400 rounded-full
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white
|
||||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>`;
|
||||
} else if (f.type === 'secret') {
|
||||
inputHtml = `<input id="${inputId}" type="text" value="${escapeHtml(String(f.value || ''))}"
|
||||
data-field="${f.key}" data-ch="${chName}" data-masked="${f.value ? '1' : ''}"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors
|
||||
${f.value ? 'cfg-key-masked' : ''}"
|
||||
placeholder="${escapeHtml(f.label)}">`;
|
||||
} else {
|
||||
const inputType = f.type === 'number' ? 'number' : 'text';
|
||||
inputHtml = `<input id="${inputId}" type="${inputType}" value="${escapeHtml(String(f.value ?? f.default ?? ''))}"
|
||||
data-field="${f.key}" data-ch="${chName}"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
placeholder="${escapeHtml(f.label)}">`;
|
||||
}
|
||||
html += `<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${escapeHtml(f.label)}</label>
|
||||
${inputHtml}
|
||||
</div>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function bindSecretFieldEvents(container) {
|
||||
container.querySelectorAll('input[data-masked="1"]').forEach(inp => {
|
||||
inp.addEventListener('focus', function() {
|
||||
if (this.dataset.masked === '1') {
|
||||
this.value = '';
|
||||
this.dataset.masked = '';
|
||||
this.classList.remove('cfg-key-masked');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showChannelStatus(chName, msgKey, isError) {
|
||||
const el = document.getElementById(`ch-status-${chName}`);
|
||||
if (!el) return;
|
||||
el.textContent = t(msgKey);
|
||||
el.classList.toggle('text-red-500', !!isError);
|
||||
el.classList.toggle('text-primary-500', !isError);
|
||||
el.classList.remove('opacity-0');
|
||||
setTimeout(() => el.classList.add('opacity-0'), 2500);
|
||||
}
|
||||
|
||||
function saveChannelConfig(chName) {
|
||||
const card = document.getElementById(`channel-card-${chName}`);
|
||||
if (!card) return;
|
||||
|
||||
const updates = {};
|
||||
card.querySelectorAll('input[data-ch="' + chName + '"]').forEach(inp => {
|
||||
const key = inp.dataset.field;
|
||||
if (inp.type === 'checkbox') {
|
||||
updates[key] = inp.checked;
|
||||
} else {
|
||||
if (inp.dataset.masked === '1') return;
|
||||
updates[key] = inp.value;
|
||||
}
|
||||
});
|
||||
|
||||
const btn = document.getElementById(`ch-save-${chName}`);
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
fetch('/api/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'save', channel: chName, config: updates })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
showChannelStatus(chName, data.restarted ? 'channels_restarted' : 'channels_saved', false);
|
||||
} else {
|
||||
showChannelStatus(chName, 'channels_save_error', true);
|
||||
}
|
||||
})
|
||||
.catch(() => showChannelStatus(chName, 'channels_save_error', true))
|
||||
.finally(() => { if (btn) btn.disabled = false; });
|
||||
}
|
||||
|
||||
function disconnectChannel(chName) {
|
||||
const ch = channelsData.find(c => c.name === chName);
|
||||
const label = ch ? ((typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label) : chName;
|
||||
|
||||
showConfirmDialog({
|
||||
title: t('channels_disconnect'),
|
||||
message: t('channels_disconnect_confirm'),
|
||||
okText: t('channels_disconnect'),
|
||||
cancelText: t('channels_cancel'),
|
||||
onConfirm: () => {
|
||||
fetch('/api/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'disconnect', channel: chName })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (ch) ch.active = false;
|
||||
renderActiveChannels();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Add channel panel ---
|
||||
function openAddChannelPanel() {
|
||||
const panel = document.getElementById('channels-add-panel');
|
||||
const activeNames = new Set(channelsData.filter(c => c.active).map(c => c.name));
|
||||
const available = channelsData.filter(c => !activeNames.has(c.name));
|
||||
|
||||
if (available.length === 0) {
|
||||
panel.innerHTML = `<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6 text-center">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">${currentLang === 'zh' ? '所有通道均已接入' : 'All channels are already connected'}</p>
|
||||
<button onclick="closeAddChannelPanel()" class="mt-3 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">${t('channels_cancel')}</button>
|
||||
</div>`;
|
||||
panel.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const ddOptions = [
|
||||
{ value: '', label: t('channels_select_placeholder') },
|
||||
...available.map(ch => {
|
||||
const label = (typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label;
|
||||
return { value: ch.name, label: `${label} (${ch.name})` };
|
||||
})
|
||||
];
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-primary-200 dark:border-primary-800 p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<i class="fas fa-plus text-primary-500 text-sm"></i>
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100">${t('channels_add')}</h3>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div id="add-channel-select" class="cfg-dropdown" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||
</div>
|
||||
<div class="cfg-dropdown-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="add-channel-fields" class="space-y-4"></div>
|
||||
<div id="add-channel-actions" class="hidden flex items-center justify-end gap-3 pt-4">
|
||||
<button onclick="closeAddChannelPanel()"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150">${t('channels_cancel')}</button>
|
||||
<button id="add-channel-submit" onclick="submitAddChannel()"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">${t('channels_connect_btn')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
panel.classList.remove('hidden');
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
|
||||
const ddEl = document.getElementById('add-channel-select');
|
||||
initDropdown(ddEl, ddOptions, '', onAddChannelSelect);
|
||||
}
|
||||
|
||||
function closeAddChannelPanel() {
|
||||
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'); }
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
|
||||
@@ -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)})
|
||||
|
||||
Reference in New Issue
Block a user