mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-03 19:17:10 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e99837a8b9 | ||
|
|
553861a2c4 | ||
|
|
628a85d1be | ||
|
|
2cb54514a4 | ||
|
|
4cc6d5426b | ||
|
|
7d258b5202 | ||
|
|
c8d19ee0bc | ||
|
|
5edbf4ce32 |
31
README.md
31
README.md
@@ -4,11 +4,18 @@
|
|||||||
<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> ·
|
||||||
|
<a href="https://docs.cowagent.ai/">📖 文档中心</a> ·
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
35
app.py
35
app.py
@@ -118,12 +118,22 @@ 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)
|
||||||
|
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:
|
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
|
continue
|
||||||
logger.info(f"[ChannelManager] Stopping channel '{name}'...")
|
logger.info(f"[ChannelManager] Stopping channel '{name}'...")
|
||||||
try:
|
try:
|
||||||
@@ -131,8 +141,27 @@ class ChannelManager:
|
|||||||
ch.stop()
|
ch.stop()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {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 th and th.is_alive():
|
||||||
self._primary_channel = None
|
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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
|
||||||
# Automatically add tool calling support if not present
|
|
||||||
self._bot = add_openai_compatible_support(self._bot)
|
self._bot = add_openai_compatible_support(self._bot)
|
||||||
|
self._bot_model = cur_model
|
||||||
# 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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
||||||
# 恢复原始方法
|
|
||||||
ssl_module.create_default_context = original_create_default_context
|
ssl_module.create_default_context = original_create_default_context
|
||||||
raise
|
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)
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +427,29 @@
|
|||||||
<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 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>
|
</div>
|
||||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="skills_loading">Loading skills...</p>
|
<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>
|
<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>
|
||||||
@@ -384,6 +458,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
<!-- VIEW: Memory -->
|
<!-- VIEW: Memory -->
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Version — update this before each release
|
// Version — update this before each release
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
const APP_VERSION = 'v2.0.1';
|
const APP_VERSION = 'v2.0.2';
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// i18n
|
// i18n
|
||||||
@@ -28,15 +28,29 @@ const I18N = {
|
|||||||
config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token',
|
config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token',
|
||||||
config_max_turns: '最大轮次', config_max_steps: '最大步数',
|
config_max_turns: '最大轮次', config_max_steps: '最大步数',
|
||||||
config_channel_type: '通道类型',
|
config_channel_type: '通道类型',
|
||||||
config_coming_soon: '完整编辑功能即将推出,当前为只读展示。',
|
config_provider: '模型厂商', config_model_name: '模型',
|
||||||
|
config_custom_model_hint: '输入自定义模型名称',
|
||||||
|
config_save: '保存', config_saved: '已保存',
|
||||||
|
config_save_error: '保存失败',
|
||||||
|
config_custom_option: '自定义...',
|
||||||
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能',
|
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能',
|
||||||
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
|
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_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
|
||||||
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
|
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
|
||||||
memory_back: '返回列表',
|
memory_back: '返回列表',
|
||||||
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间',
|
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间',
|
||||||
channels_title: '通道管理', channels_desc: '查看和管理消息通道',
|
channels_title: '通道管理', channels_desc: '管理已接入的消息通道',
|
||||||
channels_coming: '即将推出', channels_coming_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_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
||||||
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||||
@@ -60,15 +74,29 @@ const I18N = {
|
|||||||
config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens',
|
config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens',
|
||||||
config_max_turns: 'Max Turns', config_max_steps: 'Max Steps',
|
config_max_turns: 'Max Turns', config_max_steps: 'Max Steps',
|
||||||
config_channel_type: 'Channel Type',
|
config_channel_type: 'Channel Type',
|
||||||
config_coming_soon: 'Full editing capability coming soon. Currently displaying read-only configuration.',
|
config_provider: 'Provider', config_model_name: 'Model',
|
||||||
|
config_custom_model_hint: 'Enter custom model name',
|
||||||
|
config_save: 'Save', config_saved: 'Saved',
|
||||||
|
config_save_error: 'Save failed',
|
||||||
|
config_custom_option: 'Custom...',
|
||||||
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills',
|
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills',
|
||||||
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
|
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_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_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here',
|
||||||
memory_back: 'Back to list',
|
memory_back: 'Back to list',
|
||||||
memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated',
|
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_title: 'Channels', channels_desc: 'Manage connected messaging channels',
|
||||||
channels_coming: 'Coming Soon', channels_coming_desc: 'Channel management will be available here',
|
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_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
||||||
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||||
@@ -236,7 +264,7 @@ let isPolling = false;
|
|||||||
let loadingContainers = {};
|
let loadingContainers = {};
|
||||||
let activeStreams = {}; // request_id -> EventSource
|
let activeStreams = {}; // request_id -> EventSource
|
||||||
let isComposing = false;
|
let isComposing = false;
|
||||||
let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' };
|
let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} };
|
||||||
|
|
||||||
const SESSION_ID_KEY = 'cow_session_id';
|
const SESSION_ID_KEY = 'cow_session_id';
|
||||||
|
|
||||||
@@ -268,14 +296,8 @@ fetch('/config').then(r => r.json()).then(data => {
|
|||||||
appConfig = data;
|
appConfig = data;
|
||||||
const title = data.title || 'CowAgent';
|
const title = data.title || 'CowAgent';
|
||||||
document.getElementById('welcome-title').textContent = title;
|
document.getElementById('welcome-title').textContent = title;
|
||||||
document.getElementById('cfg-model').textContent = data.model || '--';
|
initConfigView(data);
|
||||||
document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled';
|
|
||||||
document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--';
|
|
||||||
document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--';
|
|
||||||
document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
|
|
||||||
document.getElementById('cfg-channel').textContent = data.channel_type || '--';
|
|
||||||
}
|
}
|
||||||
// Load conversation history after config is ready
|
|
||||||
loadHistory(1);
|
loadHistory(1);
|
||||||
}).catch(() => { loadHistory(1); });
|
}).catch(() => { loadHistory(1); });
|
||||||
|
|
||||||
@@ -820,70 +842,471 @@ function applyHighlighting(container) {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Config View
|
// Config View
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
let configProviders = {};
|
||||||
|
let configApiBases = {};
|
||||||
|
let configApiKeys = {};
|
||||||
|
let configCurrentModel = '';
|
||||||
|
let cfgProviderValue = '';
|
||||||
|
let cfgModelValue = '';
|
||||||
|
|
||||||
|
// --- Custom dropdown helper ---
|
||||||
|
function initDropdown(el, options, selectedValue, onChange) {
|
||||||
|
const textEl = el.querySelector('.cfg-dropdown-text');
|
||||||
|
const menuEl = el.querySelector('.cfg-dropdown-menu');
|
||||||
|
const selEl = el.querySelector('.cfg-dropdown-selected');
|
||||||
|
|
||||||
|
el._ddValue = selectedValue || '';
|
||||||
|
el._ddOnChange = onChange;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
menuEl.innerHTML = '';
|
||||||
|
options.forEach(opt => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'cfg-dropdown-item' + (opt.value === el._ddValue ? ' active' : '');
|
||||||
|
item.textContent = opt.label;
|
||||||
|
item.dataset.value = opt.value;
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
el._ddValue = opt.value;
|
||||||
|
textEl.textContent = opt.label;
|
||||||
|
menuEl.querySelectorAll('.cfg-dropdown-item').forEach(i => i.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
el.classList.remove('open');
|
||||||
|
if (el._ddOnChange) el._ddOnChange(opt.value);
|
||||||
|
});
|
||||||
|
menuEl.appendChild(item);
|
||||||
|
});
|
||||||
|
const sel = options.find(o => o.value === el._ddValue);
|
||||||
|
textEl.textContent = sel ? sel.label : (options[0] ? options[0].label : '--');
|
||||||
|
if (!sel && options[0]) el._ddValue = options[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
if (!el._ddBound) {
|
||||||
|
selEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.querySelectorAll('.cfg-dropdown.open').forEach(d => { if (d !== el) d.classList.remove('open'); });
|
||||||
|
el.classList.toggle('open');
|
||||||
|
});
|
||||||
|
el._ddBound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.cfg-dropdown.open').forEach(d => d.classList.remove('open'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function getDropdownValue(el) { return el._ddValue || ''; }
|
||||||
|
|
||||||
|
// --- Config init ---
|
||||||
|
function initConfigView(data) {
|
||||||
|
configProviders = data.providers || {};
|
||||||
|
configApiBases = data.api_bases || {};
|
||||||
|
configApiKeys = data.api_keys || {};
|
||||||
|
configCurrentModel = data.model || '';
|
||||||
|
|
||||||
|
const providerEl = document.getElementById('cfg-provider');
|
||||||
|
const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: p.label }));
|
||||||
|
|
||||||
|
const detected = detectProvider(configCurrentModel);
|
||||||
|
cfgProviderValue = detected || (providerOpts[0] ? providerOpts[0].value : '');
|
||||||
|
|
||||||
|
initDropdown(providerEl, providerOpts, cfgProviderValue, onProviderChange);
|
||||||
|
|
||||||
|
onProviderChange(cfgProviderValue);
|
||||||
|
syncModelSelection(configCurrentModel);
|
||||||
|
|
||||||
|
document.getElementById('cfg-max-tokens').value = data.agent_max_context_tokens || 50000;
|
||||||
|
document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 30;
|
||||||
|
document.getElementById('cfg-max-steps').value = data.agent_max_steps || 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectProvider(model) {
|
||||||
|
if (!model) return Object.keys(configProviders)[0] || '';
|
||||||
|
for (const [pid, p] of Object.entries(configProviders)) {
|
||||||
|
if (pid === 'linkai') continue;
|
||||||
|
if (p.models && p.models.includes(model)) return pid;
|
||||||
|
}
|
||||||
|
return Object.keys(configProviders)[0] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProviderChange(pid) {
|
||||||
|
cfgProviderValue = pid || getDropdownValue(document.getElementById('cfg-provider'));
|
||||||
|
const p = configProviders[cfgProviderValue];
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
const modelEl = document.getElementById('cfg-model-select');
|
||||||
|
const modelOpts = (p.models || []).map(m => ({ value: m, label: m }));
|
||||||
|
modelOpts.push({ value: '__custom__', label: t('config_custom_option') });
|
||||||
|
|
||||||
|
initDropdown(modelEl, modelOpts, modelOpts[0] ? modelOpts[0].value : '', onModelSelectChange);
|
||||||
|
|
||||||
|
// API Key
|
||||||
|
const keyField = p.api_key_field;
|
||||||
|
const keyWrap = document.getElementById('cfg-api-key-wrap');
|
||||||
|
const keyInput = document.getElementById('cfg-api-key');
|
||||||
|
if (keyField) {
|
||||||
|
keyWrap.classList.remove('hidden');
|
||||||
|
keyInput.classList.add('cfg-key-masked');
|
||||||
|
const maskedVal = configApiKeys[keyField] || '';
|
||||||
|
keyInput.value = maskedVal;
|
||||||
|
keyInput.dataset.field = keyField;
|
||||||
|
keyInput.dataset.masked = maskedVal ? '1' : '';
|
||||||
|
keyInput.dataset.maskedVal = maskedVal;
|
||||||
|
const toggleIcon = document.querySelector('#cfg-api-key-toggle i');
|
||||||
|
if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs';
|
||||||
|
|
||||||
|
if (!keyInput._cfgBound) {
|
||||||
|
keyInput.addEventListener('focus', function() {
|
||||||
|
if (this.dataset.masked === '1') {
|
||||||
|
this.value = '';
|
||||||
|
this.dataset.masked = '';
|
||||||
|
this.classList.remove('cfg-key-masked');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
keyInput.addEventListener('blur', function() {
|
||||||
|
if (!this.value.trim() && this.dataset.maskedVal) {
|
||||||
|
this.value = this.dataset.maskedVal;
|
||||||
|
this.dataset.masked = '1';
|
||||||
|
this.classList.add('cfg-key-masked');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
keyInput.addEventListener('input', function() {
|
||||||
|
this.dataset.masked = '';
|
||||||
|
});
|
||||||
|
keyInput._cfgBound = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keyWrap.classList.add('hidden');
|
||||||
|
keyInput.value = '';
|
||||||
|
keyInput.dataset.field = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Base
|
||||||
|
if (p.api_base_key) {
|
||||||
|
document.getElementById('cfg-api-base-wrap').classList.remove('hidden');
|
||||||
|
document.getElementById('cfg-api-base').value = configApiBases[p.api_base_key] || p.api_base_default || '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('cfg-api-base-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('cfg-api-base').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onModelSelectChange(modelOpts[0] ? modelOpts[0].value : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModelSelectChange(val) {
|
||||||
|
cfgModelValue = val || getDropdownValue(document.getElementById('cfg-model-select'));
|
||||||
|
const customWrap = document.getElementById('cfg-model-custom-wrap');
|
||||||
|
if (cfgModelValue === '__custom__') {
|
||||||
|
customWrap.classList.remove('hidden');
|
||||||
|
document.getElementById('cfg-model-custom').focus();
|
||||||
|
} else {
|
||||||
|
customWrap.classList.add('hidden');
|
||||||
|
document.getElementById('cfg-model-custom').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncModelSelection(model) {
|
||||||
|
const p = configProviders[cfgProviderValue];
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
const modelEl = document.getElementById('cfg-model-select');
|
||||||
|
if (p.models && p.models.includes(model)) {
|
||||||
|
const modelOpts = (p.models || []).map(m => ({ value: m, label: m }));
|
||||||
|
modelOpts.push({ value: '__custom__', label: t('config_custom_option') });
|
||||||
|
initDropdown(modelEl, modelOpts, model, onModelSelectChange);
|
||||||
|
cfgModelValue = model;
|
||||||
|
document.getElementById('cfg-model-custom-wrap').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
cfgModelValue = '__custom__';
|
||||||
|
const modelOpts = (p.models || []).map(m => ({ value: m, label: m }));
|
||||||
|
modelOpts.push({ value: '__custom__', label: t('config_custom_option') });
|
||||||
|
initDropdown(modelEl, modelOpts, '__custom__', onModelSelectChange);
|
||||||
|
document.getElementById('cfg-model-custom-wrap').classList.remove('hidden');
|
||||||
|
document.getElementById('cfg-model-custom').value = model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedModel() {
|
||||||
|
if (cfgModelValue === '__custom__') {
|
||||||
|
return document.getElementById('cfg-model-custom').value.trim();
|
||||||
|
}
|
||||||
|
return cfgModelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleApiKeyVisibility() {
|
||||||
|
const input = document.getElementById('cfg-api-key');
|
||||||
|
const icon = document.querySelector('#cfg-api-key-toggle i');
|
||||||
|
if (input.classList.contains('cfg-key-masked')) {
|
||||||
|
input.classList.remove('cfg-key-masked');
|
||||||
|
icon.className = 'fas fa-eye-slash text-xs';
|
||||||
|
} else {
|
||||||
|
input.classList.add('cfg-key-masked');
|
||||||
|
icon.className = 'fas fa-eye text-xs';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(elId, msgKey, isError) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
el.textContent = t(msgKey);
|
||||||
|
el.classList.toggle('text-red-500', !!isError);
|
||||||
|
el.classList.toggle('text-primary-500', !isError);
|
||||||
|
el.classList.remove('opacity-0');
|
||||||
|
setTimeout(() => el.classList.add('opacity-0'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveModelConfig() {
|
||||||
|
const model = getSelectedModel();
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
const updates = { model: model };
|
||||||
|
const p = configProviders[cfgProviderValue];
|
||||||
|
updates.use_linkai = (cfgProviderValue === 'linkai');
|
||||||
|
if (p && p.api_base_key) {
|
||||||
|
const base = document.getElementById('cfg-api-base').value.trim();
|
||||||
|
if (base) updates[p.api_base_key] = base;
|
||||||
|
}
|
||||||
|
if (p && p.api_key_field) {
|
||||||
|
const keyInput = document.getElementById('cfg-api-key');
|
||||||
|
const rawVal = keyInput.value.trim();
|
||||||
|
if (rawVal && keyInput.dataset.masked !== '1') {
|
||||||
|
updates[p.api_key_field] = rawVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('cfg-model-save');
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ updates })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
configCurrentModel = model;
|
||||||
|
if (data.applied) {
|
||||||
|
const keyInput = document.getElementById('cfg-api-key');
|
||||||
|
Object.entries(data.applied).forEach(([k, v]) => {
|
||||||
|
if (k === 'model') return;
|
||||||
|
if (k.includes('api_key')) {
|
||||||
|
const masked = v.length > 8
|
||||||
|
? v.substring(0, 4) + '*'.repeat(v.length - 8) + v.substring(v.length - 4)
|
||||||
|
: v;
|
||||||
|
configApiKeys[k] = masked;
|
||||||
|
if (keyInput.dataset.field === k) {
|
||||||
|
keyInput.value = masked;
|
||||||
|
keyInput.dataset.masked = '1';
|
||||||
|
keyInput.dataset.maskedVal = masked;
|
||||||
|
keyInput.classList.add('cfg-key-masked');
|
||||||
|
const toggleIcon = document.querySelector('#cfg-api-key-toggle i');
|
||||||
|
if (toggleIcon) toggleIcon.className = 'fas fa-eye text-xs';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
configApiBases[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showStatus('cfg-model-status', 'config_saved', false);
|
||||||
|
} else {
|
||||||
|
showStatus('cfg-model-status', 'config_save_error', true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => showStatus('cfg-model-status', 'config_save_error', true))
|
||||||
|
.finally(() => { btn.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAgentConfig() {
|
||||||
|
const updates = {
|
||||||
|
agent_max_context_tokens: parseInt(document.getElementById('cfg-max-tokens').value) || 50000,
|
||||||
|
agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 30,
|
||||||
|
agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = document.getElementById('cfg-agent-save');
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ updates })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
showStatus('cfg-agent-status', 'config_saved', false);
|
||||||
|
} else {
|
||||||
|
showStatus('cfg-agent-status', 'config_save_error', true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => showStatus('cfg-agent-status', 'config_save_error', true))
|
||||||
|
.finally(() => { btn.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
function loadConfigView() {
|
function loadConfigView() {
|
||||||
fetch('/config').then(r => r.json()).then(data => {
|
fetch('/config').then(r => r.json()).then(data => {
|
||||||
if (data.status !== 'success') return;
|
if (data.status !== 'success') return;
|
||||||
document.getElementById('cfg-model').textContent = data.model || '--';
|
appConfig = data;
|
||||||
document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled';
|
initConfigView(data);
|
||||||
document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--';
|
|
||||||
document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--';
|
|
||||||
document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
|
|
||||||
document.getElementById('cfg-channel').textContent = data.channel_type || '--';
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Skills View
|
// 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() {
|
function loadSkillsView() {
|
||||||
if (skillsLoaded) return;
|
loadToolsSection();
|
||||||
fetch('/api/skills').then(r => r.json()).then(data => {
|
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;
|
if (data.status !== 'success') return;
|
||||||
const emptyEl = document.getElementById('skills-empty');
|
const tools = data.tools || [];
|
||||||
const listEl = document.getElementById('skills-list');
|
emptyEl.classList.add('hidden');
|
||||||
const skills = data.skills || [];
|
if (tools.length === 0) {
|
||||||
if (skills.length === 0) {
|
emptyEl.classList.remove('hidden');
|
||||||
emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found';
|
emptyEl.innerHTML = `<span class="text-sm text-slate-400 dark:text-slate-500">${currentLang === 'zh' ? '暂无内置工具' : 'No built-in tools'}</span>`;
|
||||||
return;
|
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');
|
emptyEl.classList.add('hidden');
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
const builtins = skills.filter(s => s.source === 'builtin');
|
skills.forEach(sk => {
|
||||||
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');
|
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.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';
|
||||||
const iconColor = sk.enabled ? 'text-primary-400' : 'text-slate-300 dark:text-slate-600';
|
card.dataset.skillName = sk.name;
|
||||||
const statusDot = sk.enabled
|
card.dataset.skillDesc = sk.description || '';
|
||||||
? '<span class="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0 mt-1"></span>'
|
card.dataset.enabled = sk.enabled ? '1' : '0';
|
||||||
: '<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 flex-shrink-0 mt-1"></span>';
|
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 = `
|
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">
|
<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>
|
<i class="fas fa-bolt ${iconColor} text-sm"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate">${escapeHtml(sk.name)}</span>
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.name)}</span>
|
||||||
${statusDot}
|
<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>
|
</div>
|
||||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1 line-clamp-2">${escapeHtml(sk.description || '--')}</p>
|
<p class="text-xs text-slate-400 dark:text-slate-500 line-clamp-2">${escapeHtml(sk.description || '--')}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
listEl.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
renderGroup(currentLang === 'zh' ? '内置技能' : 'Built-in Skills', builtins);
|
|
||||||
renderGroup(currentLang === 'zh' ? '自定义技能' : 'Custom Skills', customs);
|
function toggleSkill(name, currentlyEnabled) {
|
||||||
skillsLoaded = true;
|
const action = currentlyEnabled ? 'close' : 'open';
|
||||||
}).catch(() => {});
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -958,36 +1381,362 @@ function closeMemoryViewer() {
|
|||||||
document.getElementById('memory-panel-list').classList.remove('hidden');
|
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
|
// Channels View
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
let channelsData = [];
|
||||||
|
|
||||||
function loadChannelsView() {
|
function loadChannelsView() {
|
||||||
const container = document.getElementById('channels-content');
|
const container = document.getElementById('channels-content');
|
||||||
const channelType = appConfig.channel_type || 'web';
|
container.innerHTML = `<div class="flex items-center gap-2 py-8 justify-center text-slate-400 dark:text-slate-500 text-sm">
|
||||||
const channelMap = {
|
<i class="fas fa-spinner fa-spin text-xs"></i><span>Loading...</span></div>`;
|
||||||
web: { name: 'Web', icon: 'fa-globe', color: 'primary' },
|
|
||||||
terminal: { name: 'Terminal', icon: 'fa-terminal', color: 'slate' },
|
fetch('/api/channels').then(r => r.json()).then(data => {
|
||||||
feishu: { name: 'Feishu', icon: 'fa-paper-plane', color: 'blue' },
|
if (data.status !== 'success') return;
|
||||||
dingtalk: { name: 'DingTalk', icon: 'fa-comments', color: 'blue' },
|
channelsData = data.channels || [];
|
||||||
wechatcom_app: { name: 'WeCom', icon: 'fa-building', color: 'emerald' },
|
renderActiveChannels();
|
||||||
wechatmp: { name: 'WeChat MP', icon: 'fa-comment-dots', color: 'emerald' },
|
}).catch(() => {
|
||||||
wechatmp_service: { name: 'WeChat Service', icon: 'fa-comment-dots', color: 'emerald' },
|
container.innerHTML = '<p class="text-sm text-red-400 py-8 text-center">Failed to load channels</p>';
|
||||||
};
|
});
|
||||||
const info = channelMap[channelType] || { name: channelType, icon: 'fa-tower-broadcast', color: 'sky' };
|
}
|
||||||
|
|
||||||
|
function renderActiveChannels() {
|
||||||
|
const container = document.getElementById('channels-content');
|
||||||
|
container.innerHTML = '';
|
||||||
|
closeAddChannelPanel();
|
||||||
|
|
||||||
|
const activeChannels = channelsData.filter(ch => ch.active);
|
||||||
|
|
||||||
|
if (activeChannels.length === 0) {
|
||||||
container.innerHTML = `
|
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="flex flex-col items-center justify-center py-20">
|
||||||
<div class="w-12 h-12 rounded-xl bg-${info.color}-50 dark:bg-${info.color}-900/20 flex items-center justify-center">
|
<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 ${info.icon} text-${info.color}-500 text-lg"></i>
|
<i class="fas fa-tower-broadcast text-blue-400 text-xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p class="text-slate-500 dark:text-slate-400 font-medium">${t('channels_empty')}</p>
|
||||||
|
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">${t('channels_empty_desc')}</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeChannels.forEach(ch => {
|
||||||
|
const label = (typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en) : ch.label;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6';
|
||||||
|
card.id = `channel-card-${ch.name}`;
|
||||||
|
|
||||||
|
const fieldsHtml = buildChannelFieldsHtml(ch.name, ch.fields || []);
|
||||||
|
|
||||||
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold text-slate-800 dark:text-slate-100">${info.name}</span>
|
<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="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||||
<span class="text-xs text-primary-500">Active</span>
|
<span class="text-xs text-primary-500">${t('channels_connected')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(channelType)}</p>
|
<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>
|
||||||
</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'); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -1089,7 +1838,8 @@ navigateTo = function(viewId) {
|
|||||||
_origNavigateTo(viewId);
|
_origNavigateTo(viewId);
|
||||||
|
|
||||||
// Lazy-load view data
|
// Lazy-load view data
|
||||||
if (viewId === 'skills') loadSkillsView();
|
if (viewId === 'config') loadConfigView();
|
||||||
|
else if (viewId === 'skills') loadSkillsView();
|
||||||
else if (viewId === 'memory') {
|
else if (viewId === 'memory') {
|
||||||
// Always start from the list panel when navigating to memory
|
// Always start from the list panel when navigating to memory
|
||||||
document.getElementById('memory-panel-viewer').classList.add('hidden');
|
document.getElementById('memory-panel-viewer').classList.add('hidden');
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
173
docs/en/README.md
Normal 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> ·
|
||||||
|
<a href="https://docs.cowagent.ai/en/intro/index">📖 Docs</a> ·
|
||||||
|
<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
|
||||||
|
|
||||||
|

|
||||||
@@ -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、拥有长期记忆并不断成长。
|
||||||
|
|
||||||
|
|||||||
@@ -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 模型 |
|
||||||
|
|||||||
@@ -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
98
docs/releases/v2.0.2.mdx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
title: v2.0.2
|
||||||
|
description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会话持久化
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 重点更新
|
||||||
|
|
||||||
|
### 🖥️ Web 控制台升级
|
||||||
|
|
||||||
|
本次对 Web 控制台进行了全面升级,支持流式对话输出、工具执行过程和思考过程的可视化展示,并支持对模型、技能、记忆、通道、Agent 配置的在线查看和管理。
|
||||||
|
|
||||||
|
#### 对话界面
|
||||||
|
|
||||||
|
支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 模型管理
|
||||||
|
|
||||||
|
支持在线管理模型配置,无需手动编辑配置文件:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 技能管理
|
||||||
|
|
||||||
|
支持在线查看和管理 Agent 技能(Skills):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 记忆管理
|
||||||
|
|
||||||
|
支持在线查看和管理 Agent 记忆:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 通道管理
|
||||||
|
|
||||||
|
支持在线管理接入通道,支持实时连接/断开操作:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 定时任务
|
||||||
|
|
||||||
|
支持在线查看和管理定时任务,包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 日志
|
||||||
|
|
||||||
|
支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
相关提交:[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)
|
||||||
Reference in New Issue
Block a user