Compare commits

..

8 Commits

Author SHA1 Message Date
zhayujie
e99837a8b9 feat: release 2.0.2 2026-02-27 18:04:00 +08:00
zhayujie
553861a2c4 docs: update README.md 2026-02-27 16:57:18 +08:00
zhayujie
628a85d1be docs: update README.md 2026-02-27 16:48:23 +08:00
zhayujie
2cb54514a4 Merge pull request #2681 from zhayujie/feat-docs
feat: docs update
2026-02-27 16:04:17 +08:00
zhayujie
4cc6d5426b Merge pull request #2680 from zhayujie/feat-web-config
feat: web console config
2026-02-27 14:40:44 +08:00
zhayujie
7d258b5202 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
2026-02-27 14:39:40 +08:00
zhayujie
c8d19ee0bc Merge pull request #2679 from zhayujie/feat-docs
docs: init docs
2026-02-27 12:14:37 +08:00
zhayujie
5edbf4ce32 feat: model and agent config in web console 2026-02-26 21:01:37 +08:00
16 changed files with 2155 additions and 237 deletions

View File

@@ -1,14 +1,21 @@
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="Chatgpt-on-Wechat" width="550" /></p> <p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="Chatgpt-on-Wechat" width="550" /></p>
<p align="center"> <p align="center">
<a href="https://github.com/zhayujie/chatgpt-on-wechat/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/chatgpt-on-wechat" alt="Latest release"></a> <a href="https://github.com/zhayujie/chatgpt-on-wechat/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/chatgpt-on-wechat" alt="Latest release"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/chatgpt-on-wechat" alt="License: MIT"></a> <a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/chatgpt-on-wechat" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat"><img src="https://img.shields.io/github/stars/zhayujie/chatgpt-on-wechat?style=flat-square" alt="Stars"></a> <br/> <a href="https://github.com/zhayujie/chatgpt-on-wechat"><img src="https://img.shields.io/github/stars/zhayujie/chatgpt-on-wechat?style=flat-square" alt="Stars"></a> <br/>
[中文] | [<a href="docs/en/README.md">English</a>]
</p> </p>
**CowAgent** 是基于大模型的超级AI助理能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型能处理文本、语音、图片、文件等多模态消息可接入网页、飞书、钉钉、企业微信应用、微信公众号中使用7*24小时运行于你的个人电脑或服务器中。 **CowAgent** 是基于大模型的超级AI助理能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型能处理文本、语音、图片、文件等多模态消息可接入网页、飞书、钉钉、企业微信应用、微信公众号中使用7*24小时运行于你的个人电脑或服务器中。
📖能力介绍:[CowAgent 2.0](/docs/agent.md) <p align="center">
<a href="https://cowagent.ai/">🌐 官网</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/">📖 文档中心</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 快速开始</a>
</p>
# 简介 # 简介
@@ -30,7 +37,7 @@
## 演示 ## 演示
使用说明(Agent模式)[CowAgent介绍](/docs/agent.md) 使用说明(Agent模式)[CowAgent介绍](https://docs.cowagent.ai/intro/features)
DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4 DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
@@ -58,17 +65,17 @@ DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
# 🏷 更新日志 # 🏷 更新日志
>**2026.02.27** [2.0.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2)Web 控制台全面升级(流式对话、模型/技能/记忆/通道/定时任务/日志管理)、支持多通道同时运行、会话持久化存储、新增多个模型。
>**2026.02.13** [2.0.1版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1),内置 Web Search 工具、智能上下文裁剪策略、运行时信息动态更新、Windows 兼容性适配,修复定时任务记忆丢失、飞书连接等多项问题。
>**2026.02.03** [2.0.0版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0)正式升级为超级Agent助理支持多轮任务决策、具备长期记忆、实现多种系统工具、支持Skills框架新增多种模型并优化了接入渠道。 >**2026.02.03** [2.0.0版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0)正式升级为超级Agent助理支持多轮任务决策、具备长期记忆、实现多种系统工具、支持Skills框架新增多种模型并优化了接入渠道。
>**2025.05.23** [1.7.6版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.6) 优化web网页channel、新增 [AgentMesh](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md)多智能体插件、百度语音合成优化、企微应用`access_token`获取优化、支持`claude-4-sonnet``claude-4-opus`模型 >**2025.05.23** [1.7.6版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.6) 优化web网页channel、新增 [AgentMesh](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md)多智能体插件、百度语音合成优化、企微应用`access_token`获取优化、支持`claude-4-sonnet``claude-4-opus`模型
>**2025.04.11** [1.7.5版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.5) 新增支持 [wechatferry](https://github.com/zhayujie/chatgpt-on-wechat/pull/2562) 协议、新增 deepseek 模型、新增支持腾讯云语音能力、新增支持 ModelScope 和 Gitee-AI API接口 >**2025.04.11** [1.7.5版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.5) 新增支持 [wechatferry](https://github.com/zhayujie/chatgpt-on-wechat/pull/2562) 协议、新增 deepseek 模型、新增支持腾讯云语音能力、新增支持 ModelScope 和 Gitee-AI API接口
>**2024.12.13** [1.7.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.4) 新增 Gemini 2.0 模型、新增web channel、解决内存泄漏问题、解决 `#reloadp` 命令重载不生效问题 更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases)
>**2024.10.31** [1.7.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.3) 程序稳定性提升、数据库功能、Claude模型优化、linkai插件优化、离线通知
更多更新历史请查看: [更新日志](/docs/release/history.md)
<br/> <br/>
@@ -82,7 +89,7 @@ DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh) bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
``` ```
脚本使用说明:[一键运行脚本](https://github.com/zhayujie/chatgpt-on-wechat/wiki/CowAgentQuickStart) 脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)
## 一、准备 ## 一、准备
@@ -659,7 +666,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
- `feishu_event_mode`: 事件接收模式,`websocket`(推荐)或 `webhook` - `feishu_event_mode`: 事件接收模式,`websocket`(推荐)或 `webhook`
- WebSocket 模式需安装依赖:`pip3 install lark-oapi` - WebSocket 模式需安装依赖:`pip3 install lark-oapi`
详细步骤和参数说明参考 [飞书接入](https://docs.link-ai.tech/cow/multi-platform/feishu) 详细步骤和参数说明参考 [飞书接入](https://docs.cowagent.ai/channels/feishu)
</details> </details>
@@ -675,7 +682,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
"dingtalk_client_secret": "CLIENT_SECRET" "dingtalk_client_secret": "CLIENT_SECRET"
} }
``` ```
详细步骤和参数说明参考 [钉钉接入](https://docs.link-ai.tech/cow/multi-platform/dingtalk) 详细步骤和参数说明参考 [钉钉接入](https://docs.cowagent.ai/channels/dingtalk)
</details> </details>
<details> <details>
@@ -694,7 +701,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
"wechatcomapp_aes_key": "AESKEY" "wechatcomapp_aes_key": "AESKEY"
} }
``` ```
详细步骤和参数说明参考 [企微自建应用接入](https://docs.link-ai.tech/cow/multi-platform/wechat-com) 详细步骤和参数说明参考 [企微自建应用接入](https://docs.cowagent.ai/channels/wecom)
</details> </details>
@@ -729,7 +736,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
} }
``` ```
详细步骤和参数说明参考 [微信公众号接入](https://docs.link-ai.tech/cow/multi-platform/wechat-mp) 详细步骤和参数说明参考 [微信公众号接入](https://docs.cowagent.ai/channels/wechatmp)
</details> </details>

47
app.py
View File

@@ -118,22 +118,51 @@ class ChannelManager:
Stop channel(s). If channel_name is given, stop only that channel; Stop channel(s). If channel_name is given, stop only that channel;
otherwise stop all channels. otherwise stop all channels.
""" """
# Pop under lock, then stop outside lock to avoid deadlock
with self._lock: with self._lock:
names = [channel_name] if channel_name else list(self._channels.keys()) names = [channel_name] if channel_name else list(self._channels.keys())
to_stop = []
for name in names: for name in names:
ch = self._channels.pop(name, None) ch = self._channels.pop(name, None)
self._threads.pop(name, None) th = self._threads.pop(name, None)
if ch is None: to_stop.append((name, ch, th))
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 channel_name and self._primary_channel is self._channels.get(channel_name): if channel_name and self._primary_channel is self._channels.get(channel_name):
self._primary_channel = None 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): def restart(self, new_channel_name: str):
""" """
Restart a single channel with a new channel type. Restart a single channel with a new channel type.

View File

@@ -65,30 +65,67 @@ class AgentLLMModel(LLMModel):
LLM Model adapter that uses COW's existing bot infrastructure 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"): def __init__(self, bridge: Bridge, bot_type: str = "chat"):
# Get model name directly from config
from config import conf from config import conf
model_name = conf().get("model", const.GPT_41) super().__init__(model=conf().get("model", const.GPT_41))
super().__init__(model=model_name)
self.bridge = bridge self.bridge = bridge
self.bot_type = bot_type self.bot_type = bot_type
self._bot = None 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 @property
def bot(self): def bot(self):
"""Lazy load the bot and enhance it with tool calling if needed""" """Lazy load the bot, re-create when model changes"""
if self._bot is None: from models.bot_factory import create_bot
# If use_linkai is enabled, use LinkAI bot directly cur_model = self.model
if self._use_linkai: if self._bot is None or self._bot_model != cur_model:
self._bot = self.bridge.find_chat_bot(const.LINKAI) bot_type = self._resolve_bot_type(cur_model)
else: self._bot = create_bot(bot_type)
self._bot = self.bridge.get_bot(self.bot_type) self._bot = add_openai_compatible_support(self._bot)
# Automatically add tool calling support if not present self._bot_model = cur_model
self._bot = add_openai_compatible_support(self._bot)
# Log bot info
bot_name = type(self._bot).__name__
return self._bot return self._bot
def call(self, request: LLMRequest): def call(self, request: LLMRequest):

View File

@@ -145,7 +145,7 @@ class AgentInitializer:
# after a restart. The full max_turns budget is reserved for the # after a restart. The full max_turns budget is reserved for the
# live conversation that follows. # live conversation that follows.
max_turns = conf().get("agent_max_context_turns", 30) 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) saved = store.load_messages(session_id, max_turns=restore_turns)
if saved: if saved:
with agent.messages_lock: with agent.messages_lock:

View File

@@ -101,6 +101,8 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
# 历史消息id暂存用于幂等控制 # 历史消息id暂存用于幂等控制
self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600)) self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600))
self._stream_client = None self._stream_client = None
self._running = False
self._event_loop = None
logger.debug("[DingTalk] client_id={}, client_secret={} ".format( logger.debug("[DingTalk] client_id={}, client_secret={} ".format(
self.dingtalk_client_id, self.dingtalk_client_secret)) self.dingtalk_client_id, self.dingtalk_client_secret))
# 无需群校验和前缀 # 无需群校验和前缀
@@ -114,21 +116,54 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
self._robot_code = None self._robot_code = None
def startup(self): 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) credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret)
client = dingtalk_stream.DingTalkStreamClient(credential) client = dingtalk_stream.DingTalkStreamClient(credential)
self._stream_client = client self._stream_client = client
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self) client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
logger.info("[DingTalk] ✅ Stream connected, ready to receive messages") logger.info("[DingTalk] ✅ Stream client initialized, ready to receive messages")
client.start_forever() _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): 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: try:
self._stream_client.stop() loop.call_soon_threadsafe(loop.stop)
logger.info("[DingTalk] Stream client stopped") logger.info("[DingTalk] Sent stop signal to event loop")
except Exception as e: except Exception as e:
logger.warning(f"[DingTalk] Error stopping stream client: {e}") logger.warning(f"[DingTalk] Error stopping event loop: {e}")
self._stream_client = None self._stream_client = None
logger.info("[DingTalk] stop() completed")
def get_access_token(self): def get_access_token(self):
""" """
@@ -470,18 +505,16 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
if hasattr(incoming_message, 'robot_code'): if hasattr(incoming_message, 'robot_code'):
self._robot_code_cache = incoming_message.robot_code self._robot_code_cache = incoming_message.robot_code
# Debug: 打印完整的 event 数据 # Filter out stale messages from before channel startup (offline backlog)
logger.debug(f"[DingTalk] ===== Incoming Message Debug =====") create_at = getattr(incoming_message, 'create_at', None)
logger.debug(f"[DingTalk] callback.data keys: {callback.data.keys() if hasattr(callback.data, 'keys') else 'N/A'}") if create_at:
logger.debug(f"[DingTalk] incoming_message attributes: {dir(incoming_message)}") msg_age_s = time.time() - int(create_at) / 1000
logger.debug(f"[DingTalk] robot_code: {getattr(incoming_message, 'robot_code', 'N/A')}") if msg_age_s > 60:
logger.debug(f"[DingTalk] chatbot_corp_id: {getattr(incoming_message, 'chatbot_corp_id', 'N/A')}") logger.warning(f"[DingTalk] stale msg filtered (age={msg_age_s:.0f}s), "
logger.debug(f"[DingTalk] chatbot_user_id: {getattr(incoming_message, 'chatbot_user_id', 'N/A')}") f"msg_id={getattr(incoming_message, 'message_id', 'N/A')}")
logger.debug(f"[DingTalk] conversation_id: {getattr(incoming_message, 'conversation_id', 'N/A')}") return AckMessage.STATUS_OK, 'OK'
logger.debug(f"[DingTalk] Raw callback.data: {callback.data}")
logger.debug(f"[DingTalk] =====================================")
image_download_handler = self # 传入方法所在的类实例 image_download_handler = self
dingtalk_msg = DingTalkMessage(incoming_message, image_download_handler) dingtalk_msg = DingTalkMessage(incoming_message, image_download_handler)
if dingtalk_msg.is_group: if dingtalk_msg.is_group:
@@ -490,8 +523,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
self.handle_single(dingtalk_msg) self.handle_single(dingtalk_msg)
return AckMessage.STATUS_OK, 'OK' return AckMessage.STATUS_OK, 'OK'
except Exception as e: except Exception as e:
logger.error(f"[DingTalk] process error: {e}") logger.error(f"[DingTalk] process error: {e}", exc_info=True)
logger.exception(e) # 打印完整堆栈跟踪
return AckMessage.STATUS_SYSTEM_EXCEPTION, 'ERROR' return AckMessage.STATUS_SYSTEM_EXCEPTION, 'ERROR'
@time_checker @time_checker

View File

@@ -61,6 +61,8 @@ class FeiShuChanel(ChatChannel):
# 历史消息id暂存用于幂等控制 # 历史消息id暂存用于幂等控制
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1) self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
self._http_server = None self._http_server = None
self._ws_client = None
self._ws_thread = None
logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format( 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)) 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") raise Exception("lark_oapi not installed")
def startup(self): 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': if self.feishu_event_mode == 'websocket':
self._startup_websocket() self._startup_websocket()
else: else:
self._startup_webhook() self._startup_webhook()
def stop(self): 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: if self._http_server:
try: try:
self._http_server.stop() self._http_server.stop()
@@ -86,6 +113,7 @@ class FeiShuChanel(ChatChannel):
except Exception as e: except Exception as e:
logger.warning(f"[FeiShu] Error stopping HTTP server: {e}") logger.warning(f"[FeiShu] Error stopping HTTP server: {e}")
self._http_server = None self._http_server = None
logger.info("[FeiShu] stop() completed")
def _startup_webhook(self): def _startup_webhook(self):
"""启动HTTP服务器接收事件(webhook模式)""" """启动HTTP服务器接收事件(webhook模式)"""
@@ -129,29 +157,26 @@ class FeiShuChanel(ChatChannel):
.register_p2_im_message_receive_v1(handle_message_event) \ .register_p2_im_message_receive_v1(handle_message_event) \
.build() .build()
# 尝试连接如果遇到SSL错误则自动禁用证书验证
def start_client_with_retry(): def start_client_with_retry():
"""启动websocket客户端自动处理SSL证书错误""" """Run ws client in this thread with its own event loop to avoid conflicts."""
# 全局禁用SSL证书验证在导入lark_oapi之前设置 import asyncio
import ssl as ssl_module import ssl as ssl_module
# 保存原始的SSL上下文创建方法
original_create_default_context = ssl_module.create_default_context original_create_default_context = ssl_module.create_default_context
def create_unverified_context(*args, **kwargs): def create_unverified_context(*args, **kwargs):
"""创建一个不验证证书的SSL上下文"""
context = original_create_default_context(*args, **kwargs) context = original_create_default_context(*args, **kwargs)
context.check_hostname = False context.check_hostname = False
context.verify_mode = ssl.CERT_NONE context.verify_mode = ssl.CERT_NONE
return context 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): for attempt in range(2):
try: try:
if attempt == 1: if attempt == 1:
# 第二次尝试禁用SSL验证 logger.warning("[FeiShu] Retrying with SSL verification disabled...")
logger.warning("[FeiShu] SSL certificate verification disabled due to certificate error. "
"This may happen when using corporate proxy or self-signed certificates.")
ssl_module.create_default_context = create_unverified_context ssl_module.create_default_context = create_unverified_context
ssl_module._create_unverified_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_id,
self.feishu_app_secret, self.feishu_app_secret,
event_handler=event_handler, 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...") logger.debug("[FeiShu] Websocket client starting...")
ws_client.start() ws_client.start()
# 如果成功启动,跳出循环
break break
except (SystemExit, KeyboardInterrupt):
logger.info("[FeiShu] Websocket thread received stop signal")
break
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
# 检查是否是SSL证书验证错误 is_ssl_error = ("CERTIFICATE_VERIFY_FAILED" in error_msg
is_ssl_error = "CERTIFICATE_VERIFY_FAILED" in error_msg or "certificate verify failed" in error_msg.lower() or "certificate verify failed" in error_msg.lower())
if is_ssl_error and attempt == 0: if is_ssl_error and attempt == 0:
# 第一次遇到SSL错误记录日志并继续循环下次会禁用验证 logger.warning(f"[FeiShu] SSL error: {error_msg}, retrying...")
logger.warning(f"[FeiShu] SSL certificate verification failed: {error_msg}")
logger.info("[FeiShu] Retrying connection with SSL verification disabled...")
continue continue
else: logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
# 其他错误或禁用验证后仍失败,抛出异常 ssl_module.create_default_context = original_create_default_context
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True) break
# 恢复原始方法 try:
ssl_module.create_default_context = original_create_default_context loop.close()
raise except Exception:
pass
logger.info("[FeiShu] Websocket thread exited")
# 注意不恢复原始方法因为ws_client.start()会持续运行
# 在新线程中启动客户端,避免阻塞主线程
ws_thread = threading.Thread(target=start_client_with_retry, daemon=True) ws_thread = threading.Thread(target=start_client_with_retry, daemon=True)
self._ws_thread = ws_thread
ws_thread.start() ws_thread.start()
logger.info("[FeiShu] ✅ Websocket thread started, ready to receive messages")
# 保持主线程运行
logger.info("[FeiShu] ✅ Websocket connected, ready to receive messages")
ws_thread.join() ws_thread.join()
def _handle_message_event(self, event: dict): def _handle_message_event(self, event: dict):
@@ -212,6 +234,15 @@ class FeiShuChanel(ChatChannel):
return return
self.receivedMsgs[msg_id] = True 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 is_group = False
chat_type = msg.get("chat_type") chat_type = msg.get("chat_type")

View File

@@ -294,68 +294,122 @@
</div> </div>
</div> </div>
<div class="grid gap-6"> <div class="grid gap-6">
<!-- Model Config Card --> <!-- Model Config Card -->
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6"> <div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-4"> <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"> <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-microchip text-primary-500 text-sm"></i> <i class="fas fa-microchip text-primary-500 text-sm"></i>
</div> </div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">Model Configuration</h3> <h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_model">Model Configuration</h3>
</div> </div>
<div class="space-y-4"> <div class="space-y-5">
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5"> <!-- Provider -->
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0">Model</span> <div>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-model">--</span> <label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_provider">Provider</label>
<div id="cfg-provider" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
</div>
<!-- Model -->
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_model_name">Model</label>
<div id="cfg-model-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 id="cfg-model-custom-wrap" class="mt-2 hidden">
<input id="cfg-model-custom" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
data-i18n-placeholder="config_custom_model_hint" placeholder="Enter custom model name">
</div>
</div>
<!-- API Key -->
<div id="cfg-api-key-wrap">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<div class="relative">
<input id="cfg-api-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 pr-10 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 cfg-key-masked"
placeholder="sk-...">
<button type="button" id="cfg-api-key-toggle"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600
dark:hover:text-slate-300 cursor-pointer transition-colors p-1"
onclick="toggleApiKeyVisibility()">
<i class="fas fa-eye text-xs"></i>
</button>
</div>
</div>
<!-- API Base -->
<div id="cfg-api-base-wrap" class="hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="cfg-api-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://...">
</div>
<!-- Save Model Button -->
<div class="flex items-center justify-end gap-3 pt-1">
<span id="cfg-model-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button id="cfg-model-save"
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"
onclick="saveModelConfig()" data-i18n="config_save">Save</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Agent Config Card --> <!-- Agent Config Card -->
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6"> <div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-4"> <div class="flex items-center gap-3 mb-5">
<div class="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center"> <div class="w-9 h-9 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
<i class="fas fa-robot text-emerald-500 text-sm"></i> <i class="fas fa-robot text-emerald-500 text-sm"></i>
</div> </div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_agent">Agent Configuration</h3> <h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_agent">Agent Configuration</h3>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5"> <div>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_agent_enabled">Agent Mode</span> <label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_tokens">Max Context Tokens</label>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1" id="cfg-agent">--</span> <input id="cfg-max-tokens" type="number" min="1000" max="200000" step="1000"
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">
</div> </div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5"> <div>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_tokens">Max Tokens</span> <label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_turns">Max Context Turns</label>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-tokens">--</span> <input id="cfg-max-turns" type="number" min="1" max="100" step="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">
</div> </div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5"> <div>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_turns">Max Turns</span> <label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="config_max_steps">Max Steps</label>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-turns">--</span> <input id="cfg-max-steps" type="number" min="1" max="50" step="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">
</div> </div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5"> <div class="flex items-center justify-end gap-3 pt-1">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_max_steps">Max Steps</span> <span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-max-steps">--</span> <button id="cfg-agent-save"
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"
onclick="saveAgentConfig()" data-i18n="config_save">Save</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Channel Config Card -->
<div class="placeholder-card bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
<i class="fas fa-tower-broadcast text-amber-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_channel">Channel Configuration</h3>
</div>
<div class="space-y-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-slate-50 dark:bg-white/5">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 w-32 flex-shrink-0" data-i18n="config_channel_type">Channel Type</span>
<span class="text-sm text-slate-700 dark:text-slate-200 flex-1 font-mono" id="cfg-channel">--</span>
</div>
</div>
</div>
</div>
<!-- Coming Soon Banner -->
<div class="mt-6 p-4 rounded-xl bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800/50 flex items-center gap-3">
<i class="fas fa-info-circle text-primary-500"></i>
<span class="text-sm text-primary-700 dark:text-primary-300" data-i18n="config_coming_soon">Full editing capability coming soon. Currently displaying read-only configuration.</span>
</div> </div>
</div> </div>
</div> </div>
@@ -373,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> <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> </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"> <!-- Built-in Tools Section -->
<i class="fas fa-bolt text-amber-400 text-xl"></i> <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> </div>
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p> <div id="tools-empty" class="flex items-center gap-2 py-4 text-slate-400 dark:text-slate-500 text-sm">
<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> <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>
<div id="skills-list" class="grid gap-4 sm:grid-cols-2"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -460,8 +535,15 @@
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="channels_title">Channels</h2> <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> <p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="channels_desc">View and manage messaging channels</p>
</div> </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>
<div id="channels-content" class="grid gap-4"></div> <div id="channels-content" class="grid gap-4"></div>
<div id="channels-add-panel" class="hidden mt-4"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -528,6 +610,32 @@
</div><!-- /main-content --> </div><!-- /main-content -->
</div><!-- /app --> </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> <script src="assets/js/console.js"></script>
</body> </body>
</html> </html>

View File

@@ -222,6 +222,121 @@
/* Tool failed state */ /* Tool failed state */
.agent-tool-step.tool-failed .tool-name { color: #f87171; } .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 */
#chat-input { #chat-input {
resize: none; height: 42px; max-height: 180px; resize: none; height: 42px; max-height: 180px;

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@ from bridge.context import *
from bridge.reply import Reply, ReplyType from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix from channel.chat_channel import ChatChannel, check_prefix
from channel.chat_message import ChatMessage from channel.chat_message import ChatMessage
from collections import OrderedDict
from common import const
from common.log import logger from common.log import logger
from common.singleton import singleton from common.singleton import singleton
from config import conf from config import conf
@@ -302,6 +304,8 @@ class WebChannel(ChatChannel):
'/stream', 'StreamHandler', '/stream', 'StreamHandler',
'/chat', 'ChatHandler', '/chat', 'ChatHandler',
'/config', 'ConfigHandler', '/config', 'ConfigHandler',
'/api/channels', 'ChannelsHandler',
'/api/tools', 'ToolsHandler',
'/api/skills', 'SkillsHandler', '/api/skills', 'SkillsHandler',
'/api/memory', 'MemoryHandler', '/api/memory', 'MemoryHandler',
'/api/memory/content', 'MemoryContentHandler', '/api/memory/content', 'MemoryContentHandler',
@@ -323,6 +327,8 @@ class WebChannel(ChatChannel):
func = web.httpserver.StaticMiddleware(app.wsgifunc()) func = web.httpserver.StaticMiddleware(app.wsgifunc())
func = web.httpserver.LogMiddleware(func) func = web.httpserver.LogMiddleware(func)
server = web.httpserver.WSGIServer(("0.0.0.0", port), 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 self._http_server = server
try: try:
server.start() server.start()
@@ -379,16 +385,137 @@ class ChatHandler:
class ConfigHandler: 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): 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: try:
local_config = conf() local_config = conf()
use_agent = local_config.get("agent", False) use_agent = local_config.get("agent", False)
title = "CowAgent" if use_agent else "AI Assistant"
if use_agent: api_bases = {}
title = "CowAgent" api_keys_masked = {}
else: for pid, pinfo in self.PROVIDER_MODELS.items():
title = "AI Assistant" 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({ return json.dumps({
"status": "success", "status": "success",
@@ -396,14 +523,375 @@ class ConfigHandler:
"title": title, "title": title,
"model": local_config.get("model", ""), "model": local_config.get("model", ""),
"channel_type": local_config.get("channel_type", ""), "channel_type": local_config.get("channel_type", ""),
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", ""), "agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000),
"agent_max_context_turns": local_config.get("agent_max_context_turns", ""), "agent_max_context_turns": local_config.get("agent_max_context_turns", 30),
"agent_max_steps": local_config.get("agent_max_steps", ""), "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: except Exception as e:
logger.error(f"Error getting config: {e}") logger.error(f"Error getting config: {e}")
return json.dumps({"status": "error", "message": str(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(): def _get_workspace_root():
"""Resolve the agent workspace directory.""" """Resolve the agent workspace directory."""
@@ -411,6 +899,30 @@ def _get_workspace_root():
return expand_path(conf().get("agent_workspace", "~/cow")) 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: class SkillsHandler:
def GET(self): def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')
@@ -426,6 +938,30 @@ class SkillsHandler:
logger.error(f"[WebChannel] Skills API error: {e}") logger.error(f"[WebChannel] Skills API error: {e}")
return json.dumps({"status": "error", "message": str(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: class MemoryHandler:
def GET(self): def GET(self):

View File

@@ -169,6 +169,7 @@
"group": "发布记录", "group": "发布记录",
"pages": [ "pages": [
"releases/overview", "releases/overview",
"releases/v2.0.2",
"releases/v2.0.1", "releases/v2.0.1",
"releases/v2.0.0" "releases/v2.0.0"
] ]

173
docs/en/README.md Normal file
View File

@@ -0,0 +1,173 @@
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="550" /></p>
<p align="center">
<a href="https://github.com/zhayujie/chatgpt-on-wechat/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/chatgpt-on-wechat" alt="Latest release"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/chatgpt-on-wechat" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat"><img src="https://img.shields.io/github/stars/zhayujie/chatgpt-on-wechat?style=flat-square" alt="Stars"></a> <br/>
[<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/README.md">中文</a>] | [English]
</p>
**CowAgent** is an AI super assistant powered by LLMs, capable of autonomous task planning, operating computers and external resources, creating and executing Skills, and continuously growing with long-term memory. It supports flexible model switching, handles text, voice, images, and files, and can be integrated into Web, Feishu, DingTalk, WeCom, and WeChat Official Account — running 7×24 hours on your personal computer or server.
<p align="center">
<a href="https://cowagent.ai/">🌐 Website</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/en/intro/index">📖 Docs</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/en/guide/quick-start">🚀 Quick Start</a>
</p>
## Introduction
> CowAgent is both an out-of-the-box AI super assistant and a highly extensible Agent framework. You can extend it with new model interfaces, channels, built-in tools, and the Skills system to flexibly implement various customization needs.
-**Autonomous Task Planning**: Understands complex tasks and autonomously plans execution, continuously thinking and invoking tools until goals are achieved. Supports accessing files, terminal, browser, schedulers, and other system resources via tools.
-**Long-term Memory**: Automatically persists conversation memory to local files and databases, including core memory and daily memory, with keyword and vector retrieval support.
-**Skills System**: Implements a Skills creation and execution engine with multiple built-in skills, and supports custom Skills development through natural language conversation.
-**Multimodal Messages**: Supports parsing, processing, generating, and sending text, images, voice, files, and other message types.
-**Multiple Model Support**: Supports OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao, and other mainstream model providers.
-**Multi-platform Deployment**: Runs on local computers or servers, integrable into Web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
-**Knowledge Base**: Integrates enterprise knowledge base capabilities via the [LinkAI](https://link-ai.tech) platform.
## Disclaimer
1. This project follows the [MIT License](/LICENSE) and is intended for technical research and learning. Users must comply with local laws, regulations, policies, and corporate bylaws. Any illegal or rights-infringing use is prohibited.
2. Agent mode consumes more tokens than normal chat mode. Choose models based on effectiveness and cost. Agent has access to the host OS — please deploy in trusted environments.
3. CowAgent focuses on open-source development and does not participate in, authorize, or issue any cryptocurrency.
## Changelog
> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0) — Full upgrade to AI super assistant with multi-step task planning, long-term memory, built-in tools, Skills framework, new models, and optimized channels.
> **2025.05.23:** [v1.7.6](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.6) — Web channel optimization, AgentMesh multi-agent plugin, Baidu TTS, claude-4-sonnet/opus support.
> **2025.04.11:** [v1.7.5](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.5) — wechatferry protocol, DeepSeek model, Tencent Cloud voice, ModelScope and Gitee-AI support.
> **2024.12.13:** [v1.7.4](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.4) — Gemini 2.0 model, Web channel, memory leak fix.
Full changelog: [Release Notes](https://docs.cowagent.ai/en/releases/overview)
<br/>
## 🚀 Quick Start
The project provides a one-click script for installation, configuration, startup, and management:
```bash
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
```
After running, the Web service starts by default. Access `http://localhost:9899/chat` to chat.
Script usage: [One-click Install](https://docs.cowagent.ai/en/guide/quick-start)
### Manual Installation
**1. Clone the project**
```bash
git clone https://github.com/zhayujie/chatgpt-on-wechat
cd chatgpt-on-wechat/
```
**2. Install dependencies**
```bash
pip3 install -r requirements.txt
pip3 install -r requirements-optional.txt # optional but recommended
```
**3. Configure**
```bash
cp config-template.json config.json
```
Fill in your model API key and channel type in `config.json`. See the [configuration docs](https://docs.cowagent.ai/en/guide/manual-install) for details.
**4. Run**
```bash
python3 app.py
```
For server background run:
```bash
nohup python3 app.py & tail -f nohup.out
```
### Docker Deployment
```bash
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
# Edit docker-compose.yml with your config
sudo docker compose up -d
sudo docker logs -f chatgpt-on-wechat
```
<br/>
## Models
Supports mainstream model providers. Recommended models for Agent mode:
| Provider | Recommended Model |
| --- | --- |
| MiniMax | `MiniMax-M2.5` |
| GLM | `glm-5` |
| Kimi | `kimi-k2.5` |
| Doubao | `doubao-seed-2-0-code-preview-260215` |
| Qwen | `qwen3.5-plus` |
| Claude | `claude-sonnet-4-6` |
| Gemini | `gemini-3.1-pro-preview` |
| OpenAI | `gpt-4.1` |
| DeepSeek | `deepseek-chat` |
For detailed configuration of each model, see the [Models documentation](https://docs.cowagent.ai/en/models/index).
<br/>
## Channels
Supports multiple platforms. Set `channel_type` in `config.json` to switch:
| Channel | `channel_type` | Docs |
| --- | --- | --- |
| Web (default) | `web` | [Web Channel](https://docs.cowagent.ai/en/channels/web) |
| Feishu | `feishu` | [Feishu Setup](https://docs.cowagent.ai/en/channels/feishu) |
| DingTalk | `dingtalk` | [DingTalk Setup](https://docs.cowagent.ai/en/channels/dingtalk) |
| WeCom App | `wechatcom_app` | [WeCom Setup](https://docs.cowagent.ai/en/channels/wecom) |
| WeChat MP | `wechatmp` / `wechatmp_service` | [WeChat MP Setup](https://docs.cowagent.ai/en/channels/wechatmp) |
| Terminal | `terminal` | — |
Multiple channels can be enabled simultaneously, separated by commas: `"channel_type": "feishu,dingtalk"`.
<br/>
## Enterprise Services
<a href="https://link-ai.tech" target="_blank"><img width="720" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
> [LinkAI](https://link-ai.tech/) is a one-stop AI agent platform for enterprises and developers, integrating multimodal LLMs, knowledge bases, Agent plugins, and workflows. Supports one-click integration with mainstream platforms, SaaS and private deployment.
<br/>
## 🔗 Related Projects
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything): Lightweight and highly extensible LLM application framework supporting Slack, Telegram, Discord, Gmail, and more.
- [AgentMesh](https://github.com/MinimalFuture/AgentMesh): Open-source Multi-Agent framework for complex problem solving through agent team collaboration.
## 🔎 FAQ
FAQs: <https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs>
## 🛠️ Contributing
Welcome to add new channels, referring to the [Feishu channel](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) as an example. Also welcome to contribute new Skills, referring to the [Skill Creator docs](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md).
## ✉ Contact
Welcome to submit PRs and Issues, and support the project with a 🌟 Star. For questions, check the [FAQ list](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) or search [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues).
## 🌟 Contributors
![cow contributors](https://contrib.rocks/image?repo=zhayujie/chatgpt-on-wechat&max=1000)

View File

@@ -3,7 +3,7 @@ title: 项目介绍
description: CowAgent - 基于大模型的超级AI助理 description: CowAgent - 基于大模型的超级AI助理
--- ---
<img src="https://cdn.link-ai.tech/doc/78c5dd674e2c828642ecc0406669fed7.png" alt="CowAgent" width="600px"/> <img src="https://cdn.link-ai.tech/doc/78c5dd674e2c828642ecc0406669fed7.png" alt="CowAgent" width="500px"/>
**CowAgent** 是基于大模型的超级AI助理能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。 **CowAgent** 是基于大模型的超级AI助理能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。

View File

@@ -5,7 +5,8 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 | | 版本 | 日期 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| [2.0.1](/releases/v2.0.1) | 2026.02.27 | 内置 Web Search 工具、智能上下文管理、多项修复 | | [2.0.2](/releases/v2.0.2) | 2026.02.27 | Web 控制台升级、多通道同时运行、会话持久化 |
| [2.0.1](/releases/v2.0.1) | 2026.02.13 | 内置 Web Search 工具、智能上下文管理、多项修复 |
| [2.0.0](/releases/v2.0.0) | 2026.02.03 | 全面升级为超级 Agent 助理 | | [2.0.0](/releases/v2.0.0) | 2026.02.03 | 全面升级为超级 Agent 助理 |
| 1.7.6 | 2025.05.23 | Web Channel 优化、AgentMesh 多智能体插件 | | 1.7.6 | 2025.05.23 | Web Channel 优化、AgentMesh 多智能体插件 |
| 1.7.5 | 2025.04.11 | DeepSeek 模型 | | 1.7.5 | 2025.04.11 | DeepSeek 模型 |

View File

@@ -3,7 +3,7 @@ title: v2.0.1
description: CowAgent 2.0.1 - 内置 Web Search、智能上下文管理、多项修复 description: CowAgent 2.0.1 - 内置 Web Search、智能上下文管理、多项修复
--- ---
**发布日期**2026.02 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.0..2.0.1) **发布日期**2026.02 | [GitHub Release](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1) | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.0..2.0.1)
## 新特性 ## 新特性

98
docs/releases/v2.0.2.mdx Normal file
View File

@@ -0,0 +1,98 @@
---
title: v2.0.2
description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会话持久化
---
## ✨ 重点更新
### 🖥️ Web 控制台升级
本次对 Web 控制台进行了全面升级支持流式对话输出、工具执行过程和思考过程的可视化展示并支持对模型、技能、记忆、通道、Agent 配置的在线查看和管理。
#### 对话界面
支持流式输出,可实时展示 Agent 的思考过程Reasoning和工具调用过程Tool Calls更直观地观察 Agent 的决策过程:
![对话界面](https://cdn.link-ai.tech/doc/20260227180120.png)
#### 模型管理
支持在线管理模型配置,无需手动编辑配置文件:
![模型管理](https://cdn.link-ai.tech/doc/20260227173811.png)
#### 技能管理
支持在线查看和管理 Agent 技能Skills
![技能管理](https://cdn.link-ai.tech/doc/20260227173403.png)
#### 记忆管理
支持在线查看和管理 Agent 记忆:
![记忆管理](https://cdn.link-ai.tech/doc/20260227173349.png)
#### 通道管理
支持在线管理接入通道,支持实时连接/断开操作:
![通道管理](https://cdn.link-ai.tech/doc/20260227173331.png)
#### 定时任务
支持在线查看和管理定时任务包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理:
![定时任务](https://cdn.link-ai.tech/doc/20260227173704.png)
#### 日志
支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题:
![日志](https://cdn.link-ai.tech/doc/20260227173514.png)
相关提交:[f1a1413](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1a1413), [c0702c8](https://github.com/zhayujie/chatgpt-on-wechat/commit/c0702c8), [394853c](https://github.com/zhayujie/chatgpt-on-wechat/commit/394853c), [1c71c4e](https://github.com/zhayujie/chatgpt-on-wechat/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/chatgpt-on-wechat/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/chatgpt-on-wechat/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5)
### 🔀 多通道同时运行
支持多个接入通道如飞书、钉钉、企微应用、Web 等)同时运行,每个通道在独立子线程中启动,互不干扰。
配置方式:在 `config.json` 中通过 `channel_type` 配置多个通道,以逗号分隔,也可在 Web 控制台的通道管理页面中实时连接或断开各通道。
```json
{
"channel_type": "web,feishu,dingtalk"
}
```
相关提交:[4694594](https://github.com/zhayujie/chatgpt-on-wechat/commit/4694594), [7cce224](https://github.com/zhayujie/chatgpt-on-wechat/commit/7cce224), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5), [c9adddb](https://github.com/zhayujie/chatgpt-on-wechat/commit/c9adddb)
### 💾 会话持久化
会话历史支持持久化存储至本地 SQLite 数据库服务重启后会话上下文自动恢复不再丢失。Web 控制台中的历史对话记录也会同步恢复展示。
相关提交:[29bfbec](https://github.com/zhayujie/chatgpt-on-wechat/commit/29bfbec), [9917552](https://github.com/zhayujie/chatgpt-on-wechat/commit/9917552), [925d728](https://github.com/zhayujie/chatgpt-on-wechat/commit/925d728)
### 🤖 新增模型
- **Gemini 3.1 Pro Preview**:新增 `gemini-3.1-pro-preview` 模型支持 ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad))
- **Claude 4.6 Sonnet**:新增 `claude-4.6-sonnet` 模型支持 ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad))
- **Qwen3.5 Plus**:新增 `qwen3.5-plus` 模型支持 ([e59a289](https://github.com/zhayujie/chatgpt-on-wechat/commit/e59a289))
- **MiniMax M2.5**:新增 `Minimax-M2.5` 模型支持 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538))
- **GLM-5**:新增 `glm-5` 模型支持 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538))
- **Kimi K2.5**:新增 `kimi-k2.5` 模型支持 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538))
- **Doubao 2.0 Code**:新增 `doubao-2.0-code` 编程专用模型 ([ab28ee5](https://github.com/zhayujie/chatgpt-on-wechat/commit/ab28ee5))
- **DashScope 模型**:新增阿里云 DashScope 模型名称支持 ([ce58f23](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce58f23))
### 🌐 新增官网和文档中心
- **官网上线**[cowagent.ai](https://cowagent.ai/)
- **文档中心上线**[docs.cowagent.ai](https://docs.cowagent.ai/)
### 🐛 问题修复
- **Gemini 钉钉图片识别**:修复 Gemini 在钉钉通道中无法处理图片标记的问题 ([05a3304](https://github.com/zhayujie/chatgpt-on-wechat/commit/05a3304)) ([#2670](https://github.com/zhayujie/chatgpt-on-wechat/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114)
- **启动脚本依赖**:修复 `run.sh` 脚本的依赖安装问题 ([b6fc9fa](https://github.com/zhayujie/chatgpt-on-wechat/commit/b6fc9fa))
- **裸异常捕获**:将代码中的 `bare except` 替换为 `except Exception`,提升异常处理规范性 ([adca89b](https://github.com/zhayujie/chatgpt-on-wechat/commit/adca89b)) ([#2674](https://github.com/zhayujie/chatgpt-on-wechat/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018)
**发布日期**2026.02.27 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.1...master)