From ce89869c3c5a962c0f41bc8a313923a4808dbd6b Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 15:52:13 +0800
Subject: [PATCH 001/399] feat: support weixin channel
---
README.md | 39 ++-
app.py | 2 +
channel/channel_factory.py | 4 +
channel/web/static/js/console.js | 224 ++++++++++++-
channel/web/web_channel.py | 195 ++++++++++-
channel/weixin/__init__.py | 0
channel/weixin/weixin_api.py | 385 ++++++++++++++++++++++
channel/weixin/weixin_channel.py | 542 +++++++++++++++++++++++++++++++
channel/weixin/weixin_message.py | 200 ++++++++++++
common/const.py | 1 +
config-template.json | 2 +-
config.py | 10 +-
docs/channels/weixin.mdx | 72 ++++
docs/docs.json | 3 +
docs/en/README.md | 5 +-
docs/en/channels/weixin.mdx | 72 ++++
docs/en/intro/index.mdx | 6 +-
docs/intro/index.mdx | 8 +-
docs/ja/README.md | 5 +-
docs/ja/channels/weixin.mdx | 72 ++++
docs/ja/intro/index.mdx | 6 +-
requirements.txt | 1 +
22 files changed, 1812 insertions(+), 42 deletions(-)
create mode 100644 channel/weixin/__init__.py
create mode 100644 channel/weixin/weixin_api.py
create mode 100644 channel/weixin/weixin_channel.py
create mode 100644 channel/weixin/weixin_message.py
create mode 100644 docs/channels/weixin.mdx
create mode 100644 docs/en/channels/weixin.mdx
create mode 100644 docs/ja/channels/weixin.mdx
diff --git a/README.md b/README.md
index 42d33878..22c49d28 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
[中文] | [English ] | [日本語 ]
-**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。
+**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
🌐 官网 ·
@@ -27,7 +27,7 @@
- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
-- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
+- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
## 声明
@@ -147,7 +147,7 @@ pip3 install -r requirements-optional.txt
```bash
# config.json 文件内容示例
{
- "channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
+ "channel_type": "weixin", # 接入渠道类型,默认为weixin, 支持修改为 feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
"model": "MiniMax-M2.7", # 模型名称
"minimax_api_key": "", # MiniMax API Key
"zhipu_ai_api_key": "", # 智谱GLM API Key
@@ -628,7 +628,24 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
支持同时可接入多个通道,配置时可通过逗号进行分割,例如 `"channel_type": "feishu,dingtalk"`。
-1. Web
+1. Weixin - 微信
+
+接入个人微信,扫码登录即可使用,无需公网 IP,支持文本、图片、语音、文件等消息收发。
+
+```json
+{
+ "channel_type": "weixin"
+}
+```
+
+启动后终端会显示二维码,使用微信扫码授权即可,也可以在 Web 控制台的「通道」页面中扫码接入。登录凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动无需重新扫码,如需重新登录删除该文件后重启即可。
+
+详细步骤和参数说明参考 [微信接入](https://docs.cowagent.ai/channels/weixin)
+
+
+
+
+2. Web
项目启动后会默认运行Web控制台,配置如下:
@@ -645,7 +662,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
-2. Feishu - 飞书
+3. Feishu - 飞书
飞书支持两种事件接收模式:WebSocket 长连接(推荐)和 Webhook。
@@ -681,7 +698,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
-3. DingTalk - 钉钉
+4. DingTalk - 钉钉
钉钉需要在开放平台创建智能机器人应用,将以下配置填入 `config.json`:
@@ -696,7 +713,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
-4. WeCom Bot - 企微智能机器人
+5. WeCom Bot - 企微智能机器人
企微智能机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,配置简单:
@@ -712,7 +729,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
-5. QQ - QQ 机器人
+6. QQ - QQ 机器人
QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支持 QQ 单聊、群聊和频道消息:
@@ -728,7 +745,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
-6. WeCom App - 企业微信应用
+7. WeCom App - 企业微信应用
企业微信自建应用接入需在后台创建应用并启用消息回调,配置示例:
@@ -748,7 +765,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
-7. WeChat MP - 微信公众号
+8. WeChat MP - 微信公众号
本项目支持订阅号和服务号两种公众号,通过服务号(`wechatmp_service`)体验更佳。
@@ -783,7 +800,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
-8. Terminal - 终端
+9. Terminal - 终端
修改 `config.json` 中的 `channel_type` 字段:
diff --git a/app.py b/app.py
index b09e2f00..8f09387c 100644
--- a/app.py
+++ b/app.py
@@ -229,6 +229,8 @@ def _clear_singleton_cache(channel_name: str):
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
const.QQ: "channel.qq.qq_channel.QQChannel",
+ const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
+ "wx": "channel.weixin.weixin_channel.WeixinChannel",
}
module_path = cls_map.get(channel_name)
if not module_path:
diff --git a/channel/channel_factory.py b/channel/channel_factory.py
index 3ee52e48..cf6bfea8 100644
--- a/channel/channel_factory.py
+++ b/channel/channel_factory.py
@@ -39,6 +39,10 @@ def create_channel(channel_type) -> Channel:
elif channel_type == const.QQ:
from channel.qq.qq_channel import QQChannel
ch = QQChannel()
+ elif channel_type in (const.WEIXIN, "wx"):
+ from channel.weixin.weixin_channel import WeixinChannel
+ ch = WeixinChannel()
+ channel_type = const.WEIXIN
else:
raise RuntimeError
ch.channel_type = channel_type
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 40cba61c..0e147d69 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -51,6 +51,11 @@ const I18N = {
channels_empty: '暂未接入任何通道', channels_empty_desc: '点击右上角「接入通道」按钮开始配置',
channels_disconnect_confirm: '确认断开该通道?配置将保留但通道会停止运行。',
channels_connected: '已接入', channels_connecting: '接入中...',
+ weixin_scan_title: '微信扫码登录', weixin_scan_desc: '请使用微信扫描下方二维码',
+ weixin_scan_loading: '正在获取二维码...', weixin_scan_waiting: '等待扫码...',
+ weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...',
+ weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败',
+ weixin_qr_tip: '二维码约2分钟后过期',
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
@@ -97,6 +102,11 @@ const I18N = {
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...',
+ weixin_scan_title: 'WeChat QR Login', weixin_scan_desc: 'Scan the QR code below with WeChat',
+ weixin_scan_loading: 'Loading QR code...', weixin_scan_waiting: 'Waiting for scan...',
+ weixin_scan_scanned: 'Scanned, please confirm on your phone', weixin_scan_expired: 'QR code expired, refreshing...',
+ weixin_scan_success: 'Login successful, starting channel...', weixin_scan_fail: 'Failed to load QR code',
+ weixin_qr_tip: 'QR code expires in ~2 minutes',
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
@@ -1583,6 +1593,8 @@ function loadChannelsView() {
}
function renderActiveChannels() {
+ stopWeixinQrPoll();
+ stopWeixinStatusPoll();
const container = document.getElementById('channels-content');
container.innerHTML = '';
closeAddChannelPanel();
@@ -1608,17 +1620,30 @@ function renderActiveChannels() {
card.id = `channel-card-${ch.name}`;
const fieldsHtml = buildChannelFieldsHtml(ch.name, ch.fields || []);
+ const hasFields = (ch.fields || []).length > 0;
+
+ const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
+ let statusDot, statusText;
+ if (weixinWaiting) {
+ statusDot = 'bg-amber-400 animate-pulse';
+ statusText = ch.login_status === 'scanned'
+ ? `${t('weixin_scan_scanned')} `
+ : `${t('weixin_scan_waiting')} `;
+ } else {
+ statusDot = 'bg-primary-400';
+ statusText = `${t('channels_connected')} `;
+ }
card.innerHTML = `
-
+
${escapeHtml(label)}
-
- ${t('channels_connected')}
+
+ ${statusText}
${escapeHtml(ch.name)}
@@ -1630,7 +1655,14 @@ function renderActiveChannels() {
${t('channels_disconnect')}
-
+ ${weixinWaiting ? `
+
+ ${t('weixin_scan_title')}
+
+
` : ''}
+ ${hasFields ? `
${fieldsHtml}
@@ -1639,10 +1671,14 @@ function renderActiveChannels() {
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
id="ch-save-${ch.name}">${t('channels_save')}
-
`;
+
` : ''}`;
container.appendChild(card);
bindSecretFieldEvents(card);
+
+ if (weixinWaiting) {
+ startWeixinActiveStatusPoll();
+ }
});
}
@@ -1828,6 +1864,7 @@ function openAddChannelPanel() {
}
function closeAddChannelPanel() {
+ stopWeixinQrPoll();
const panel = document.getElementById('channels-add-panel');
if (panel) {
panel.classList.add('hidden');
@@ -1836,6 +1873,7 @@ function closeAddChannelPanel() {
}
function onAddChannelSelect(chName) {
+ stopWeixinQrPoll();
const fieldsContainer = document.getElementById('add-channel-fields');
const actions = document.getElementById('add-channel-actions');
@@ -1845,6 +1883,16 @@ function onAddChannelSelect(chName) {
return;
}
+ if (chName === 'weixin') {
+ actions.classList.add('hidden');
+ fieldsContainer.innerHTML = `
+
+
${t('weixin_scan_loading')}
+
`;
+ startWeixinQrLogin();
+ return;
+ }
+
const ch = channelsData.find(c => c.name === chName);
if (!ch) return;
@@ -1900,6 +1948,172 @@ function submitAddChannel() {
});
}
+// =====================================================================
+// WeChat QR Login
+// =====================================================================
+let _weixinQrPollTimer = null;
+let _weixinStatusPollTimer = null;
+
+function stopWeixinStatusPoll() {
+ if (_weixinStatusPollTimer) {
+ clearTimeout(_weixinStatusPollTimer);
+ _weixinStatusPollTimer = null;
+ }
+}
+
+function startWeixinActiveStatusPoll() {
+ stopWeixinStatusPoll();
+ _weixinStatusPollTimer = setTimeout(() => {
+ fetch('/api/channels').then(r => r.json()).then(data => {
+ if (data.status !== 'success') return;
+ const wx = (data.channels || []).find(c => c.name === 'weixin');
+ if (!wx || !wx.active) return;
+ if (wx.login_status === 'logged_in') {
+ channelsData = data.channels;
+ renderActiveChannels();
+ } else {
+ const ch = channelsData.find(c => c.name === 'weixin');
+ if (ch) ch.login_status = wx.login_status;
+ startWeixinActiveStatusPoll();
+ }
+ }).catch(() => { startWeixinActiveStatusPoll(); });
+ }, 3000);
+}
+
+function showWeixinActiveQr() {
+ const container = document.getElementById('weixin-active-qr');
+ if (!container) return;
+ container.innerHTML = `
+
+
${t('weixin_scan_loading')}
+
`;
+ stopWeixinStatusPoll();
+ startWeixinQrLogin();
+}
+
+function stopWeixinQrPoll() {
+ if (_weixinQrPollTimer) {
+ clearTimeout(_weixinQrPollTimer);
+ _weixinQrPollTimer = null;
+ }
+}
+
+function startWeixinQrLogin() {
+ stopWeixinQrPoll();
+ fetch('/api/weixin/qrlogin')
+ .then(r => r.json())
+ .then(data => {
+ const panel = document.getElementById('weixin-qr-panel');
+ if (!panel) return;
+ if (data.status !== 'success') {
+ panel.innerHTML = `
${t('weixin_scan_fail')}: ${data.message || ''}
`;
+ return;
+ }
+ renderWeixinQr(data.qr_image || data.qrcode_url, 'waiting');
+ if (data.source === 'channel') {
+ startWeixinActiveStatusPoll();
+ } else {
+ pollWeixinQrStatus();
+ }
+ })
+ .catch(() => {
+ const panel = document.getElementById('weixin-qr-panel');
+ if (panel) panel.innerHTML = `
${t('weixin_scan_fail')}
`;
+ });
+}
+
+function renderWeixinQr(qrcodeUrl, status) {
+ const panel = document.getElementById('weixin-qr-panel');
+ if (!panel) return;
+
+ let statusText = t('weixin_scan_waiting');
+ let statusColor = 'text-slate-500 dark:text-slate-400';
+ if (status === 'scanned') {
+ statusText = t('weixin_scan_scanned');
+ statusColor = 'text-primary-500';
+ } else if (status === 'expired') {
+ statusText = t('weixin_scan_expired');
+ statusColor = 'text-amber-500';
+ } else if (status === 'confirmed') {
+ statusText = t('weixin_scan_success');
+ statusColor = 'text-primary-500';
+ }
+
+ panel.innerHTML = `
+
+
${t('weixin_scan_title')}
+
${t('weixin_scan_desc')}
+
+
+
+
${statusText}
+
${t('weixin_qr_tip')}
+
`;
+}
+
+function pollWeixinQrStatus() {
+ _weixinQrPollTimer = setTimeout(() => {
+ fetch('/api/weixin/qrlogin', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'poll' })
+ })
+ .then(r => r.json())
+ .then(data => {
+ const panel = document.getElementById('weixin-qr-panel');
+ if (!panel) { stopWeixinQrPoll(); return; }
+
+ if (data.status !== 'success') {
+ pollWeixinQrStatus();
+ return;
+ }
+
+ const qrStatus = data.qr_status;
+ if (qrStatus === 'confirmed') {
+ renderWeixinQr('', 'confirmed');
+ panel.innerHTML = `
+
+
+
+
+
${t('weixin_scan_success')}
+
`;
+ connectWeixinAfterQr();
+ } else if (qrStatus === 'expired' && (data.qr_image || data.qrcode_url)) {
+ renderWeixinQr(data.qr_image || data.qrcode_url, 'waiting');
+ pollWeixinQrStatus();
+ } else if (qrStatus === 'scaned') {
+ const img = panel.querySelector('img');
+ const currentSrc = img ? img.src : '';
+ renderWeixinQr(currentSrc, 'scanned');
+ pollWeixinQrStatus();
+ } else {
+ pollWeixinQrStatus();
+ }
+ })
+ .catch(() => {
+ pollWeixinQrStatus();
+ });
+ }, 2000);
+}
+
+function connectWeixinAfterQr() {
+ fetch('/api/channels', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'connect', channel: 'weixin', config: {} })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'success') {
+ const ch = channelsData.find(c => c.name === 'weixin');
+ if (ch) ch.active = true;
+ setTimeout(() => renderActiveChannels(), 1500);
+ }
+ })
+ .catch(() => {});
+}
+
// =====================================================================
// Scheduler View
// =====================================================================
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 33a97c57..0be5b75a 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -353,13 +353,15 @@ class WebChannel(ChatChannel):
# 打印可用渠道类型提示
logger.info(
"[WebChannel] 全部可用通道如下,可修改 config.json 配置文件中的 channel_type 字段进行切换,多个通道用逗号分隔:")
- logger.info("[WebChannel] 1. web - 网页")
- logger.info("[WebChannel] 2. terminal - 终端")
- logger.info("[WebChannel] 3. feishu - 飞书")
- logger.info("[WebChannel] 4. dingtalk - 钉钉")
- logger.info("[WebChannel] 5. wechatcom_app - 企微自建应用")
- logger.info("[WebChannel] 6. wechatmp - 个人公众号")
- logger.info("[WebChannel] 7. wechatmp_service - 企业公众号")
+ logger.info("[WebChannel] 1. weixin - 微信")
+ logger.info("[WebChannel] 2. web - 网页")
+ logger.info("[WebChannel] 3. terminal - 终端")
+ logger.info("[WebChannel] 4. feishu - 飞书")
+ logger.info("[WebChannel] 5. dingtalk - 钉钉")
+ logger.info("[WebChannel] 6. wecom_bot - 企微智能机器人")
+ logger.info("[WebChannel] 7. wechatcom_app - 企微自建应用")
+ logger.info("[WebChannel] 8. wechatmp - 个人公众号")
+ logger.info("[WebChannel] 9. wechatmp_service - 企业公众号")
logger.info("[WebChannel] ✅ Web控制台已运行")
logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}")
logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (请将YOUR_IP替换为服务器IP)")
@@ -380,6 +382,7 @@ class WebChannel(ChatChannel):
'/chat', 'ChatHandler',
'/config', 'ConfigHandler',
'/api/channels', 'ChannelsHandler',
+ '/api/weixin/qrlogin', 'WeixinQrHandler',
'/api/tools', 'ToolsHandler',
'/api/skills', 'SkillsHandler',
'/api/memory', 'MemoryHandler',
@@ -685,6 +688,12 @@ class ChannelsHandler:
"""API for managing external channel configurations (feishu, dingtalk, etc)."""
CHANNEL_DEFS = OrderedDict([
+ ("weixin", {
+ "label": {"zh": "微信", "en": "WeChat"},
+ "icon": "fa-comment",
+ "color": "emerald",
+ "fields": [],
+ }),
("feishu", {
"label": {"zh": "飞书", "en": "Feishu"},
"icon": "fa-paper-plane",
@@ -750,6 +759,20 @@ class ChannelsHandler:
}),
])
+ @staticmethod
+ def _get_weixin_login_status() -> str:
+ 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:
+ ch = mgr.get_channel("weixin")
+ if ch and hasattr(ch, 'login_status'):
+ return ch.login_status
+ except Exception:
+ pass
+ return "unknown"
+
@staticmethod
def _mask_secret(value: str) -> str:
if not value or len(value) <= 8:
@@ -789,14 +812,17 @@ class ChannelsHandler:
"value": display_val,
"default": f.get("default", ""),
})
- channels.append({
+ ch_info = {
"name": ch_name,
"label": ch_def["label"],
"icon": ch_def["icon"],
"color": ch_def["color"],
"active": ch_name in active_channels,
"fields": fields_out,
- })
+ }
+ if ch_name == "weixin" and ch_name in active_channels:
+ ch_info["login_status"] = self._get_weixin_login_status()
+ channels.append(ch_info)
return json.dumps({"status": "success", "channels": channels}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Channels API error: {e}")
@@ -1016,6 +1042,157 @@ class ChannelsHandler:
}, ensure_ascii=False)
+class WeixinQrHandler:
+ """Handle WeChat QR code login from the web console.
+
+ GET /api/weixin/qrlogin → fetch a new QR code
+ POST /api/weixin/qrlogin → poll QR status or start channel after login
+ """
+
+ _qr_state = {}
+
+ @staticmethod
+ def _qr_to_data_uri(data: str) -> str:
+ """Generate a QR code as a PNG data URI."""
+ try:
+ import qrcode as qr_lib
+ import io
+ import base64
+ qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=6, border=2)
+ qr.add_data(data)
+ qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
+ return f"data:image/png;base64,{b64}"
+ except ImportError:
+ return ""
+
+ @staticmethod
+ def _get_running_channel():
+ 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:
+ return mgr.get_channel("weixin")
+ except Exception:
+ pass
+ return None
+
+ def GET(self):
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ try:
+ running_ch = self._get_running_channel()
+ if running_ch and hasattr(running_ch, '_current_qr_url') and running_ch._current_qr_url:
+ qr_image = self._qr_to_data_uri(running_ch._current_qr_url)
+ return json.dumps({
+ "status": "success",
+ "qrcode_url": running_ch._current_qr_url,
+ "qr_image": qr_image,
+ "source": "channel",
+ })
+
+ from channel.weixin.weixin_api import WeixinApi, DEFAULT_BASE_URL
+ base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
+ api = WeixinApi(base_url=base_url)
+ qr_resp = api.fetch_qr_code()
+ qrcode = qr_resp.get("qrcode", "")
+ qrcode_url = qr_resp.get("qrcode_img_content", "")
+ if not qrcode:
+ return json.dumps({"status": "error", "message": "No QR code returned"})
+ qr_image = self._qr_to_data_uri(qrcode_url)
+ WeixinQrHandler._qr_state = {
+ "qrcode": qrcode,
+ "qrcode_url": qrcode_url,
+ "base_url": base_url,
+ }
+ return json.dumps({"status": "success", "qrcode_url": qrcode_url, "qr_image": qr_image})
+ except Exception as e:
+ logger.error(f"[WebChannel] WeixinQr GET 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", "poll")
+
+ if action == "poll":
+ return self._poll_status()
+ elif action == "refresh":
+ return self.GET()
+ else:
+ return json.dumps({"status": "error", "message": f"unknown action: {action}"})
+ except Exception as e:
+ logger.error(f"[WebChannel] WeixinQr POST error: {e}")
+ return json.dumps({"status": "error", "message": str(e)})
+
+ def _poll_status(self):
+ state = WeixinQrHandler._qr_state
+ qrcode = state.get("qrcode", "")
+ base_url = state.get("base_url", "")
+ if not qrcode:
+ return json.dumps({"status": "error", "message": "No active QR session"})
+
+ from channel.weixin.weixin_api import WeixinApi, DEFAULT_BASE_URL
+ api = WeixinApi(base_url=base_url or DEFAULT_BASE_URL)
+ try:
+ status_resp = api.poll_qr_status(qrcode, timeout=10)
+ except Exception as e:
+ return json.dumps({"status": "error", "message": str(e)})
+
+ qr_status = status_resp.get("status", "wait")
+
+ if qr_status == "confirmed":
+ bot_token = status_resp.get("bot_token", "")
+ bot_id = status_resp.get("ilink_bot_id", "")
+ result_base_url = status_resp.get("baseurl", base_url)
+ user_id = status_resp.get("ilink_user_id", "")
+
+ if not bot_token or not bot_id:
+ return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
+
+ cred_path = os.path.expanduser(
+ conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
+ )
+ from channel.weixin.weixin_channel import _save_credentials
+ _save_credentials(cred_path, {
+ "token": bot_token,
+ "base_url": result_base_url,
+ "bot_id": bot_id,
+ "user_id": user_id,
+ })
+ conf()["weixin_token"] = bot_token
+ conf()["weixin_base_url"] = result_base_url
+
+ WeixinQrHandler._qr_state = {}
+ logger.info(f"[WebChannel] WeChat QR login confirmed: bot_id={bot_id}")
+
+ return json.dumps({
+ "status": "success",
+ "qr_status": "confirmed",
+ "bot_id": bot_id,
+ })
+
+ if qr_status == "expired":
+ new_resp = api.fetch_qr_code()
+ new_qrcode = new_resp.get("qrcode", "")
+ new_qrcode_url = new_resp.get("qrcode_img_content", "")
+ new_qr_image = self._qr_to_data_uri(new_qrcode_url)
+ WeixinQrHandler._qr_state["qrcode"] = new_qrcode
+ WeixinQrHandler._qr_state["qrcode_url"] = new_qrcode_url
+ return json.dumps({
+ "status": "success",
+ "qr_status": "expired",
+ "qrcode_url": new_qrcode_url,
+ "qr_image": new_qr_image,
+ })
+
+ return json.dumps({"status": "success", "qr_status": qr_status})
+
+
def _get_workspace_root():
"""Resolve the agent workspace directory."""
from common.utils import expand_path
diff --git a/channel/weixin/__init__.py b/channel/weixin/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/channel/weixin/weixin_api.py b/channel/weixin/weixin_api.py
new file mode 100644
index 00000000..b09d14dd
--- /dev/null
+++ b/channel/weixin/weixin_api.py
@@ -0,0 +1,385 @@
+"""
+Weixin HTTP JSON API client.
+
+Implements the ilink bot protocol:
+ - getUpdates (long-poll)
+ - sendMessage
+ - getUploadUrl
+ - getConfig
+ - sendTyping
+ - QR login (get_bot_qrcode / get_qrcode_status)
+
+CDN media upload with AES-128-ECB encryption.
+"""
+
+import base64
+import hashlib
+import os
+import random
+import struct
+import time
+import uuid
+
+import requests
+
+from common.log import logger
+
+DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
+CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
+DEFAULT_LONG_POLL_TIMEOUT = 35
+DEFAULT_API_TIMEOUT = 15
+QR_POLL_TIMEOUT = 35
+BOT_TYPE = "3"
+
+
+def _random_wechat_uin() -> str:
+ val = random.randint(0, 0xFFFFFFFF)
+ return base64.b64encode(str(val).encode("utf-8")).decode("utf-8")
+
+
+def _build_headers(token: str = "") -> dict:
+ headers = {
+ "Content-Type": "application/json",
+ "AuthorizationType": "ilink_bot_token",
+ "X-WECHAT-UIN": _random_wechat_uin(),
+ }
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return headers
+
+
+def _ensure_trailing_slash(url: str) -> str:
+ return url if url.endswith("/") else url + "/"
+
+
+class WeixinApi:
+ """Stateless HTTP client for the Weixin ilink bot API."""
+
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, token: str = "",
+ cdn_base_url: str = CDN_BASE_URL):
+ self.base_url = base_url
+ self.token = token
+ self.cdn_base_url = cdn_base_url
+
+ def _post(self, endpoint: str, body: dict, timeout: int = DEFAULT_API_TIMEOUT) -> dict:
+ url = _ensure_trailing_slash(self.base_url) + endpoint
+ headers = _build_headers(self.token)
+ try:
+ resp = requests.post(url, json=body, headers=headers, timeout=timeout)
+ resp.raise_for_status()
+ return resp.json()
+ except requests.exceptions.Timeout:
+ logger.debug(f"[Weixin] API timeout: {endpoint}")
+ return {"ret": 0, "msgs": []}
+ except Exception as e:
+ logger.error(f"[Weixin] API error {endpoint}: {e}")
+ raise
+
+ # ── getUpdates (long-poll) ─────────────────────────────────────────
+
+ def get_updates(self, get_updates_buf: str = "", timeout: int = DEFAULT_LONG_POLL_TIMEOUT) -> dict:
+ return self._post("ilink/bot/getupdates", {
+ "get_updates_buf": get_updates_buf,
+ }, timeout=timeout + 5)
+
+ # ── sendMessage ────────────────────────────────────────────────────
+
+ def send_text(self, to: str, text: str, context_token: str) -> dict:
+ return self._post("ilink/bot/sendmessage", {
+ "msg": {
+ "from_user_id": "",
+ "to_user_id": to,
+ "client_id": uuid.uuid4().hex[:16],
+ "message_type": 2, # BOT
+ "message_state": 2, # FINISH
+ "item_list": [{"type": 1, "text_item": {"text": text}}],
+ "context_token": context_token,
+ }
+ })
+
+ def send_image_item(self, to: str, context_token: str,
+ encrypt_query_param: str, aes_key_b64: str,
+ ciphertext_size: int, text: str = "") -> dict:
+ items = []
+ if text:
+ items.append({"type": 1, "text_item": {"text": text}})
+ items.append({
+ "type": 2,
+ "image_item": {
+ "media": {
+ "encrypt_query_param": encrypt_query_param,
+ "aes_key": aes_key_b64,
+ "encrypt_type": 1,
+ },
+ "mid_size": ciphertext_size,
+ }
+ })
+ return self._send_items(to, context_token, items)
+
+ def send_file_item(self, to: str, context_token: str,
+ encrypt_query_param: str, aes_key_b64: str,
+ file_name: str, file_size: int, text: str = "") -> dict:
+ items = []
+ if text:
+ items.append({"type": 1, "text_item": {"text": text}})
+ items.append({
+ "type": 4,
+ "file_item": {
+ "media": {
+ "encrypt_query_param": encrypt_query_param,
+ "aes_key": aes_key_b64,
+ "encrypt_type": 1,
+ },
+ "file_name": file_name,
+ "len": str(file_size),
+ }
+ })
+ return self._send_items(to, context_token, items)
+
+ def send_video_item(self, to: str, context_token: str,
+ encrypt_query_param: str, aes_key_b64: str,
+ ciphertext_size: int, text: str = "") -> dict:
+ items = []
+ if text:
+ items.append({"type": 1, "text_item": {"text": text}})
+ items.append({
+ "type": 5,
+ "video_item": {
+ "media": {
+ "encrypt_query_param": encrypt_query_param,
+ "aes_key": aes_key_b64,
+ "encrypt_type": 1,
+ },
+ "video_size": ciphertext_size,
+ }
+ })
+ return self._send_items(to, context_token, items)
+
+ def _send_items(self, to: str, context_token: str, items: list) -> dict:
+ return self._post("ilink/bot/sendmessage", {
+ "msg": {
+ "from_user_id": "",
+ "to_user_id": to,
+ "client_id": uuid.uuid4().hex[:16],
+ "message_type": 2,
+ "message_state": 2,
+ "item_list": items,
+ "context_token": context_token,
+ }
+ })
+
+ # ── getUploadUrl ───────────────────────────────────────────────────
+
+ def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
+ rawsize: int, rawfilemd5: str, filesize: int,
+ aeskey: str,
+ thumb_rawsize: int = 0, thumb_rawfilemd5: str = "",
+ thumb_filesize: int = 0) -> dict:
+ body = {
+ "filekey": filekey,
+ "media_type": media_type,
+ "to_user_id": to_user_id,
+ "rawsize": rawsize,
+ "rawfilemd5": rawfilemd5,
+ "filesize": filesize,
+ "aeskey": aeskey,
+ }
+ if thumb_rawsize > 0:
+ body["thumb_rawsize"] = thumb_rawsize
+ body["thumb_rawfilemd5"] = thumb_rawfilemd5
+ body["thumb_filesize"] = thumb_filesize
+ else:
+ body["no_need_thumb"] = True
+ return self._post("ilink/bot/getuploadurl", body)
+
+ # ── getConfig / sendTyping ─────────────────────────────────────────
+
+ def get_config(self, user_id: str, context_token: str = "") -> dict:
+ return self._post("ilink/bot/getconfig", {
+ "ilink_user_id": user_id,
+ "context_token": context_token,
+ }, timeout=10)
+
+ def send_typing(self, user_id: str, typing_ticket: str, status: int = 1) -> dict:
+ return self._post("ilink/bot/sendtyping", {
+ "ilink_user_id": user_id,
+ "typing_ticket": typing_ticket,
+ "status": status,
+ }, timeout=10)
+
+ # ── QR Login ───────────────────────────────────────────────────────
+
+ def fetch_qr_code(self) -> dict:
+ url = _ensure_trailing_slash(self.base_url) + f"ilink/bot/get_bot_qrcode?bot_type={BOT_TYPE}"
+ resp = requests.get(url, timeout=15)
+ resp.raise_for_status()
+ return resp.json()
+
+ def poll_qr_status(self, qrcode: str, timeout: int = QR_POLL_TIMEOUT) -> dict:
+ url = (_ensure_trailing_slash(self.base_url) +
+ f"ilink/bot/get_qrcode_status?qrcode={requests.utils.quote(qrcode)}")
+ headers = {"iLink-App-ClientVersion": "1"}
+ try:
+ resp = requests.get(url, headers=headers, timeout=timeout)
+ resp.raise_for_status()
+ return resp.json()
+ except requests.exceptions.Timeout:
+ return {"status": "wait"}
+
+
+# ── AES-128-ECB helpers ─────────────────────────────────────────────
+
+def _aes_ecb_encrypt(data: bytes, key: bytes) -> bytes:
+ from Crypto.Cipher import AES
+ pad_len = 16 - (len(data) % 16)
+ padded = data + bytes([pad_len] * pad_len)
+ cipher = AES.new(key, AES.MODE_ECB)
+ return cipher.encrypt(padded)
+
+
+def _aes_ecb_decrypt(data: bytes, key: bytes) -> bytes:
+ from Crypto.Cipher import AES
+ cipher = AES.new(key, AES.MODE_ECB)
+ decrypted = cipher.decrypt(data)
+ pad_len = decrypted[-1]
+ if pad_len > 16:
+ return decrypted
+ return decrypted[:-pad_len]
+
+
+def _file_md5(file_path: str) -> str:
+ h = hashlib.md5()
+ with open(file_path, "rb") as f:
+ for chunk in iter(lambda: f.read(8192), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def _md5_bytes(data: bytes) -> str:
+ return hashlib.md5(data).hexdigest()
+
+
+def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
+ media_type: int) -> dict:
+ """
+ Upload a local file to the Weixin CDN.
+
+ Args:
+ api: WeixinApi instance
+ file_path: local file path
+ to_user_id: target user id
+ media_type: 1=IMAGE, 2=VIDEO, 3=FILE
+
+ Returns:
+ dict with keys: encrypt_query_param, aes_key_b64, ciphertext_size, raw_size
+ """
+ aes_key = os.urandom(16)
+ aes_key_hex = aes_key.hex()
+
+ with open(file_path, "rb") as f:
+ raw_data = f.read()
+
+ raw_size = len(raw_data)
+ raw_md5 = _md5_bytes(raw_data)
+ encrypted = _aes_ecb_encrypt(raw_data, aes_key)
+ cipher_size = len(encrypted)
+ filekey = uuid.uuid4().hex
+
+ thumb_rawsize = 0
+ thumb_rawfilemd5 = ""
+ thumb_filesize = 0
+
+ if media_type == 1: # IMAGE - generate a tiny thumbnail
+ try:
+ from PIL import Image
+ import io
+ img = Image.open(file_path)
+ img.thumbnail((100, 100))
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=60)
+ thumb_raw = buf.getvalue()
+ thumb_rawsize = len(thumb_raw)
+ thumb_rawfilemd5 = _md5_bytes(thumb_raw)
+ thumb_encrypted = _aes_ecb_encrypt(thumb_raw, aes_key)
+ thumb_filesize = len(thumb_encrypted)
+ except Exception as e:
+ logger.warning(f"[Weixin] Thumbnail generation failed, skipping: {e}")
+
+ resp = api.get_upload_url(
+ filekey=filekey,
+ media_type=media_type,
+ to_user_id=to_user_id,
+ rawsize=raw_size,
+ rawfilemd5=raw_md5,
+ filesize=cipher_size,
+ aeskey=aes_key_hex,
+ thumb_rawsize=thumb_rawsize,
+ thumb_rawfilemd5=thumb_rawfilemd5,
+ thumb_filesize=thumb_filesize,
+ )
+
+ upload_param = resp.get("upload_param", "")
+ if not upload_param:
+ raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
+
+ cdn_url = api.cdn_base_url + "?" + upload_param
+ put_resp = requests.put(cdn_url, data=encrypted, headers={
+ "Content-Type": "application/octet-stream",
+ "Content-Length": str(cipher_size),
+ }, timeout=60)
+ put_resp.raise_for_status()
+
+ # Upload thumbnail if we have one
+ thumb_upload_param = resp.get("thumb_upload_param", "")
+ if thumb_upload_param and thumb_filesize > 0:
+ thumb_cdn_url = api.cdn_base_url + "?" + thumb_upload_param
+ try:
+ requests.put(thumb_cdn_url, data=thumb_encrypted, headers={
+ "Content-Type": "application/octet-stream",
+ "Content-Length": str(thumb_filesize),
+ }, timeout=30)
+ except Exception as e:
+ logger.warning(f"[Weixin] Thumbnail upload failed (non-fatal): {e}")
+
+ return {
+ "encrypt_query_param": upload_param,
+ "aes_key_b64": base64.b64encode(aes_key).decode("utf-8"),
+ "ciphertext_size": cipher_size,
+ "raw_size": raw_size,
+ }
+
+
+def download_media_from_cdn(cdn_base_url: str, encrypt_query_param: str,
+ aes_key: str, save_path: str) -> str:
+ """
+ Download and decrypt a media file from Weixin CDN.
+
+ Args:
+ cdn_base_url: CDN base URL
+ encrypt_query_param: encrypted query parameter from message
+ aes_key: hex or base64 encoded AES key
+ save_path: path to save decrypted file
+
+ Returns:
+ save_path on success
+ """
+ url = cdn_base_url + "?" + encrypt_query_param
+ resp = requests.get(url, timeout=60)
+ resp.raise_for_status()
+
+ # Determine key format (hex string or base64)
+ try:
+ key_bytes = bytes.fromhex(aes_key)
+ if len(key_bytes) != 16:
+ raise ValueError()
+ except (ValueError, TypeError):
+ key_bytes = base64.b64decode(aes_key)
+ if len(key_bytes) != 16:
+ raise ValueError(f"Invalid AES key length: {len(key_bytes)}")
+
+ decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
+
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
+ with open(save_path, "wb") as f:
+ f.write(decrypted)
+ return save_path
diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py
new file mode 100644
index 00000000..08ac4de0
--- /dev/null
+++ b/channel/weixin/weixin_channel.py
@@ -0,0 +1,542 @@
+"""
+Weixin channel implementation.
+
+Uses HTTP long-poll (getUpdates) to receive messages and sendMessage to reply.
+Login via QR code scan through the ilink bot API.
+"""
+
+import json
+import os
+import threading
+import time
+import uuid
+
+import requests
+
+from bridge.context import Context, ContextType
+from bridge.reply import Reply, ReplyType
+from channel.chat_channel import ChatChannel, check_prefix
+from channel.weixin.weixin_api import (
+ WeixinApi, upload_media_to_cdn,
+ DEFAULT_BASE_URL, CDN_BASE_URL,
+)
+from channel.weixin.weixin_message import WeixinMessage
+from common.expired_dict import ExpiredDict
+from common.log import logger
+from common.singleton import singleton
+from config import conf
+
+MAX_CONSECUTIVE_FAILURES = 3
+BACKOFF_DELAY = 30
+RETRY_DELAY = 2
+SESSION_EXPIRED_ERRCODE = -14
+
+
+def _load_credentials(cred_path: str) -> dict:
+ """Load saved credentials from JSON file."""
+ try:
+ if os.path.exists(cred_path):
+ with open(cred_path, "r") as f:
+ return json.load(f)
+ except Exception as e:
+ logger.warning(f"[Weixin] Failed to load credentials: {e}")
+ return {}
+
+
+def _save_credentials(cred_path: str, data: dict):
+ """Save credentials to JSON file."""
+ os.makedirs(os.path.dirname(cred_path), exist_ok=True)
+ with open(cred_path, "w") as f:
+ json.dump(data, f, indent=2)
+ try:
+ os.chmod(cred_path, 0o600)
+ except Exception:
+ pass
+
+
+@singleton
+class WeixinChannel(ChatChannel):
+
+ LOGIN_STATUS_IDLE = "idle"
+ LOGIN_STATUS_WAITING = "waiting_scan"
+ LOGIN_STATUS_SCANNED = "scanned"
+ LOGIN_STATUS_OK = "logged_in"
+
+ def __init__(self):
+ super().__init__()
+ self.api = None
+ self._stop_event = threading.Event()
+ self._poll_thread = None
+ self._context_tokens = {} # user_id -> context_token
+ self._received_msgs = ExpiredDict(60 * 60 * 7.1)
+ self._get_updates_buf = ""
+ self._credentials_path = ""
+ self.login_status = self.LOGIN_STATUS_IDLE
+ self._current_qr_url = ""
+
+ conf()["single_chat_prefix"] = [""]
+
+ # ── Lifecycle ──────────────────────────────────────────────────────
+
+ def startup(self):
+ base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
+ cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
+ token = conf().get("weixin_token", "")
+
+ self._credentials_path = os.path.expanduser(
+ conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
+ )
+
+ if not token:
+ creds = _load_credentials(self._credentials_path)
+ token = creds.get("token", "")
+ if creds.get("base_url"):
+ base_url = creds["base_url"]
+
+ if not token:
+ logger.info("[Weixin] No token found, starting QR login...")
+ self.login_status = self.LOGIN_STATUS_WAITING
+ login_result = self._qr_login(base_url)
+ if not login_result:
+ self.login_status = self.LOGIN_STATUS_IDLE
+ err = "[Weixin] QR login failed. Set weixin_token in config or run login again."
+ logger.error(err)
+ self.report_startup_error(err)
+ return
+ token = login_result["token"]
+ base_url = login_result.get("base_url", base_url)
+
+ self.api = WeixinApi(base_url=base_url, token=token, cdn_base_url=cdn_base_url)
+ self.login_status = self.LOGIN_STATUS_OK
+
+ logger.info(f"[Weixin] 微信通道已启动,凭证保存在 {self._credentials_path},"
+ f"如需重新扫码登录请删除该文件后重启")
+ self.report_startup_success()
+
+ self._stop_event.clear()
+ self._poll_loop()
+
+ def stop(self):
+ logger.info("[Weixin] stop() called")
+ self._stop_event.set()
+
+ def _relogin(self) -> bool:
+ """Re-login after session expiry. Returns True on success."""
+ base_url = self.api.base_url if self.api else DEFAULT_BASE_URL
+ if os.path.exists(self._credentials_path):
+ try:
+ os.remove(self._credentials_path)
+ except Exception:
+ pass
+ self.login_status = self.LOGIN_STATUS_WAITING
+ result = self._qr_login(base_url)
+ if not result:
+ self.login_status = self.LOGIN_STATUS_IDLE
+ return False
+ self.api = WeixinApi(
+ base_url=result.get("base_url", base_url),
+ token=result["token"],
+ cdn_base_url=self.api.cdn_base_url if self.api else CDN_BASE_URL,
+ )
+ self.login_status = self.LOGIN_STATUS_OK
+ self._context_tokens.clear()
+ return True
+
+ # ── QR Login ───────────────────────────────────────────────────────
+
+ @staticmethod
+ def _print_qr(qrcode_url: str):
+ """Print QR code to terminal for scanning."""
+ print("\n" + "=" * 60)
+ print(" 请使用微信扫描二维码登录 (二维码约2分钟后过期)")
+ print("=" * 60)
+ try:
+ import qrcode as qr_lib
+ qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
+ qr.add_data(qrcode_url)
+ qr.make(fit=True)
+ qr.print_ascii(invert=True)
+ except ImportError:
+ print(f"\n 二维码链接: {qrcode_url}")
+ print(" (安装 'qrcode' 包可在终端显示二维码)\n")
+
+ def _qr_login(self, base_url: str) -> dict:
+ """Perform interactive QR code login. Returns dict with token/base_url or empty dict."""
+ api = WeixinApi(base_url=base_url)
+ try:
+ qr_resp = api.fetch_qr_code()
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to fetch QR code: {e}")
+ return {}
+
+ qrcode = qr_resp.get("qrcode", "")
+ qrcode_url = qr_resp.get("qrcode_img_content", "")
+
+ if not qrcode:
+ logger.error("[Weixin] No QR code returned from server")
+ return {}
+
+ self._current_qr_url = qrcode_url
+ logger.info(f"[Weixin] QR code URL: {qrcode_url}")
+ self._print_qr(qrcode_url)
+ print(" 等待扫码...\n")
+
+ scanned_printed = False
+
+ while True:
+ try:
+ status_resp = api.poll_qr_status(qrcode)
+ except Exception as e:
+ logger.error(f"[Weixin] QR status poll error: {e}")
+ return {}
+
+ status = status_resp.get("status", "wait")
+
+ if status == "wait":
+ pass
+ elif status == "scaned":
+ self.login_status = self.LOGIN_STATUS_SCANNED
+ if not scanned_printed:
+ print(" 已扫码,请在手机上确认...")
+ scanned_printed = True
+ elif status == "expired":
+ print(" 二维码已过期,正在刷新...")
+ try:
+ qr_resp = api.fetch_qr_code()
+ qrcode = qr_resp.get("qrcode", "")
+ qrcode_url = qr_resp.get("qrcode_img_content", "")
+ scanned_printed = False
+ self._current_qr_url = qrcode_url
+ logger.info(f"[Weixin] New QR code: {qrcode_url}")
+ self._print_qr(qrcode_url)
+ except Exception as e:
+ logger.error(f"[Weixin] QR refresh failed: {e}")
+ return {}
+ elif status == "confirmed":
+ bot_token = status_resp.get("bot_token", "")
+ bot_id = status_resp.get("ilink_bot_id", "")
+ result_base_url = status_resp.get("baseurl", base_url)
+ user_id = status_resp.get("ilink_user_id", "")
+
+ if not bot_token or not bot_id:
+ logger.error("[Weixin] Login confirmed but missing token/bot_id")
+ return {}
+
+ self._current_qr_url = ""
+ print(f"\n ✅ 微信登录成功!bot_id={bot_id}")
+ logger.info(f"[Weixin] Login confirmed: bot_id={bot_id}")
+
+ creds = {
+ "token": bot_token,
+ "base_url": result_base_url,
+ "bot_id": bot_id,
+ "user_id": user_id,
+ }
+ _save_credentials(self._credentials_path, creds)
+ logger.info(f"[Weixin] Credentials saved to {self._credentials_path}")
+
+ return {"token": bot_token, "base_url": result_base_url}
+
+ time.sleep(1)
+
+ logger.warning("[Weixin] QR login timed out")
+ return {}
+
+ # ── Long-poll loop ─────────────────────────────────────────────────
+
+ def _poll_loop(self):
+ """Main long-poll loop: getUpdates -> parse -> produce."""
+ logger.info("[Weixin] Starting long-poll loop")
+ consecutive_failures = 0
+
+ while not self._stop_event.is_set():
+ try:
+ resp = self.api.get_updates(self._get_updates_buf)
+
+ ret = resp.get("ret", 0)
+ errcode = resp.get("errcode", 0)
+
+ is_error = (ret != 0) or (errcode != 0)
+ if is_error:
+ if errcode == SESSION_EXPIRED_ERRCODE or ret == SESSION_EXPIRED_ERRCODE:
+ logger.error("[Weixin] Session expired (errcode -14), starting re-login...")
+ if self._relogin():
+ logger.info("[Weixin] Re-login successful, resuming long-poll")
+ self._get_updates_buf = ""
+ consecutive_failures = 0
+ continue
+ else:
+ logger.error("[Weixin] Re-login failed, will retry in 5 minutes")
+ self._stop_event.wait(300)
+ continue
+
+ consecutive_failures += 1
+ errmsg = resp.get("errmsg", "")
+ logger.error(f"[Weixin] getUpdates error: ret={ret} errcode={errcode} "
+ f"errmsg={errmsg} ({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})")
+ if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
+ consecutive_failures = 0
+ self._stop_event.wait(BACKOFF_DELAY)
+ else:
+ self._stop_event.wait(RETRY_DELAY)
+ continue
+
+ consecutive_failures = 0
+
+ # Update sync cursor
+ new_buf = resp.get("get_updates_buf", "")
+ if new_buf:
+ self._get_updates_buf = new_buf
+
+ # Process messages
+ msgs = resp.get("msgs", [])
+ for raw_msg in msgs:
+ try:
+ self._process_message(raw_msg)
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to process message: {e}", exc_info=True)
+
+ except Exception as e:
+ if self._stop_event.is_set():
+ break
+ consecutive_failures += 1
+ logger.error(f"[Weixin] getUpdates exception: {e} "
+ f"({consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})")
+ if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
+ consecutive_failures = 0
+ self._stop_event.wait(BACKOFF_DELAY)
+ else:
+ self._stop_event.wait(RETRY_DELAY)
+
+ logger.info("[Weixin] Long-poll loop ended")
+
+ def _process_message(self, raw_msg: dict):
+ """Parse a single inbound message and produce to the handling queue."""
+ msg_type = raw_msg.get("message_type", 0)
+ if msg_type != 1: # Only process USER messages (type=1)
+ return
+
+ msg_id = str(raw_msg.get("message_id", raw_msg.get("seq", "")))
+ if self._received_msgs.get(msg_id):
+ return
+ self._received_msgs[msg_id] = True
+
+ from_user = raw_msg.get("from_user_id", "")
+ context_token = raw_msg.get("context_token", "")
+
+ if context_token and from_user:
+ self._context_tokens[from_user] = context_token
+
+ cdn_base_url = self.api.cdn_base_url if self.api else CDN_BASE_URL
+ try:
+ wx_msg = WeixinMessage(raw_msg, cdn_base_url=cdn_base_url)
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to parse WeixinMessage: {e}", exc_info=True)
+ return
+
+ logger.info(f"[Weixin] Received: from={from_user} ctype={wx_msg.ctype} "
+ f"content={str(wx_msg.content)[:50]}")
+
+ # File cache logic
+ from channel.file_cache import get_file_cache
+ file_cache = get_file_cache()
+ session_id = from_user
+
+ if wx_msg.ctype == ContextType.IMAGE:
+ if hasattr(wx_msg, "image_path") and wx_msg.image_path:
+ file_cache.add(session_id, wx_msg.image_path, file_type="image")
+ logger.info(f"[Weixin] Image cached for session {session_id}")
+ return
+
+ if wx_msg.ctype == ContextType.FILE:
+ wx_msg.prepare()
+ file_cache.add(session_id, wx_msg.content, file_type="file")
+ logger.info(f"[Weixin] File cached for session {session_id}: {wx_msg.content}")
+ return
+
+ if wx_msg.ctype == ContextType.TEXT:
+ cached_files = file_cache.get(session_id)
+ if cached_files:
+ refs = []
+ for fi in cached_files:
+ ftype, fpath = fi["type"], fi["path"]
+ if ftype == "image":
+ refs.append(f"[图片: {fpath}]")
+ elif ftype == "video":
+ refs.append(f"[视频: {fpath}]")
+ else:
+ refs.append(f"[文件: {fpath}]")
+ wx_msg.content = wx_msg.content + "\n" + "\n".join(refs)
+ file_cache.clear(session_id)
+
+ context = self._compose_context(
+ wx_msg.ctype,
+ wx_msg.content,
+ isgroup=False,
+ msg=wx_msg,
+ no_need_at=True,
+ )
+ if context:
+ self.produce(context)
+
+ # ── _compose_context ───────────────────────────────────────────────
+
+ def _compose_context(self, ctype: ContextType, content, **kwargs):
+ context = Context(ctype, content)
+ context.kwargs = kwargs
+ if "channel_type" not in context:
+ context["channel_type"] = self.channel_type
+ if "origin_ctype" not in context:
+ context["origin_ctype"] = ctype
+
+ cmsg = context["msg"]
+ context["session_id"] = cmsg.from_user_id
+ context["receiver"] = cmsg.other_user_id
+
+ if ctype == ContextType.TEXT:
+ img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
+ if img_match_prefix:
+ content = content.replace(img_match_prefix, "", 1)
+ context.type = ContextType.IMAGE_CREATE
+ else:
+ context.type = ContextType.TEXT
+ context.content = content.strip()
+
+ return context
+
+ # ── Send reply ─────────────────────────────────────────────────────
+
+ def send(self, reply: Reply, context: Context):
+ receiver = context.get("receiver", "")
+ msg = context.get("msg")
+ context_token = self._get_context_token(receiver, msg)
+
+ if not context_token:
+ logger.error(f"[Weixin] No context_token for receiver={receiver}, cannot send")
+ return
+
+ if reply.type == ReplyType.TEXT:
+ self._send_text(reply.content, receiver, context_token)
+ elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
+ self._send_image(reply.content, receiver, context_token)
+ elif reply.type == ReplyType.FILE:
+ self._send_file(reply.content, receiver, context_token)
+ elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL):
+ self._send_video(reply.content, receiver, context_token)
+ else:
+ logger.warning(f"[Weixin] Unsupported reply type: {reply.type}, fallback to text")
+ self._send_text(str(reply.content), receiver, context_token)
+
+ def _get_context_token(self, receiver: str, msg=None) -> str:
+ """Get the context_token for a receiver, required for all sends."""
+ if msg and hasattr(msg, "context_token") and msg.context_token:
+ return msg.context_token
+ return self._context_tokens.get(receiver, "")
+
+ def _send_text(self, text: str, receiver: str, context_token: str):
+ try:
+ self.api.send_text(receiver, text, context_token)
+ logger.debug(f"[Weixin] Text sent to {receiver}, len={len(text)}")
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to send text: {e}")
+
+ def _send_image(self, img_path_or_url: str, receiver: str, context_token: str):
+ local_path = self._resolve_media_path(img_path_or_url)
+ if not local_path:
+ self._send_text("[Image send failed: file not found]", receiver, context_token)
+ return
+ try:
+ result = upload_media_to_cdn(self.api, local_path, receiver, media_type=1)
+ self.api.send_image_item(
+ to=receiver,
+ context_token=context_token,
+ encrypt_query_param=result["encrypt_query_param"],
+ aes_key_b64=result["aes_key_b64"],
+ ciphertext_size=result["ciphertext_size"],
+ )
+ logger.info(f"[Weixin] Image sent to {receiver}")
+ except Exception as e:
+ logger.error(f"[Weixin] Image send failed: {e}")
+ self._send_text("[Image send failed]", receiver, context_token)
+
+ def _send_file(self, file_path_or_url: str, receiver: str, context_token: str):
+ local_path = self._resolve_media_path(file_path_or_url)
+ if not local_path:
+ self._send_text("[File send failed: file not found]", receiver, context_token)
+ return
+ try:
+ result = upload_media_to_cdn(self.api, local_path, receiver, media_type=3)
+ self.api.send_file_item(
+ to=receiver,
+ context_token=context_token,
+ encrypt_query_param=result["encrypt_query_param"],
+ aes_key_b64=result["aes_key_b64"],
+ file_name=os.path.basename(local_path),
+ file_size=result["raw_size"],
+ )
+ logger.info(f"[Weixin] File sent to {receiver}")
+ except Exception as e:
+ logger.error(f"[Weixin] File send failed: {e}")
+ self._send_text("[File send failed]", receiver, context_token)
+
+ def _send_video(self, video_path_or_url: str, receiver: str, context_token: str):
+ local_path = self._resolve_media_path(video_path_or_url)
+ if not local_path:
+ self._send_text("[Video send failed: file not found]", receiver, context_token)
+ return
+ try:
+ result = upload_media_to_cdn(self.api, local_path, receiver, media_type=2)
+ self.api.send_video_item(
+ to=receiver,
+ context_token=context_token,
+ encrypt_query_param=result["encrypt_query_param"],
+ aes_key_b64=result["aes_key_b64"],
+ ciphertext_size=result["ciphertext_size"],
+ )
+ logger.info(f"[Weixin] Video sent to {receiver}")
+ except Exception as e:
+ logger.error(f"[Weixin] Video send failed: {e}")
+ self._send_text("[Video send failed]", receiver, context_token)
+
+ @staticmethod
+ def _resolve_media_path(path_or_url: str) -> str:
+ """Resolve a file path or URL to a local file path. Downloads if needed."""
+ if not path_or_url:
+ return ""
+
+ local_path = path_or_url
+ if local_path.startswith("file://"):
+ local_path = local_path[7:]
+
+ if local_path.startswith(("http://", "https://")):
+ try:
+ resp = requests.get(local_path, timeout=60)
+ resp.raise_for_status()
+ ct = resp.headers.get("Content-Type", "")
+ ext = ".bin"
+ if "jpeg" in ct or "jpg" in ct:
+ ext = ".jpg"
+ elif "png" in ct:
+ ext = ".png"
+ elif "gif" in ct:
+ ext = ".gif"
+ elif "webp" in ct:
+ ext = ".webp"
+ elif "mp4" in ct:
+ ext = ".mp4"
+ elif "pdf" in ct:
+ ext = ".pdf"
+
+ tmp_path = f"/tmp/wx_media_{uuid.uuid4().hex[:8]}{ext}"
+ with open(tmp_path, "wb") as f:
+ f.write(resp.content)
+ return tmp_path
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to download media: {e}")
+ return ""
+
+ if os.path.exists(local_path):
+ return local_path
+
+ logger.warning(f"[Weixin] Media file not found: {local_path}")
+ return ""
diff --git a/channel/weixin/weixin_message.py b/channel/weixin/weixin_message.py
new file mode 100644
index 00000000..e1de71d2
--- /dev/null
+++ b/channel/weixin/weixin_message.py
@@ -0,0 +1,200 @@
+"""
+Weixin ChatMessage implementation.
+
+Parses WeixinMessage from the getUpdates API into the unified ChatMessage format.
+"""
+
+import os
+import uuid
+
+from bridge.context import ContextType
+from channel.chat_message import ChatMessage
+from channel.weixin.weixin_api import download_media_from_cdn, CDN_BASE_URL
+from common.log import logger
+from common.utils import expand_path
+from config import conf
+
+
+# MessageItemType constants from the Weixin protocol
+ITEM_TEXT = 1
+ITEM_IMAGE = 2
+ITEM_VOICE = 3
+ITEM_FILE = 4
+ITEM_VIDEO = 5
+
+
+def _get_tmp_dir() -> str:
+ ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
+ tmp_dir = os.path.join(ws_root, "tmp")
+ os.makedirs(tmp_dir, exist_ok=True)
+ return tmp_dir
+
+
+class WeixinMessage(ChatMessage):
+ """Message wrapper for Weixin channel."""
+
+ def __init__(self, msg: dict, cdn_base_url: str = CDN_BASE_URL):
+ super().__init__(msg)
+
+ self.msg_id = str(msg.get("message_id", msg.get("seq", uuid.uuid4().hex[:8])))
+ self.create_time = msg.get("create_time_ms", 0)
+ self.context_token = msg.get("context_token", "")
+ self.is_group = False # Weixin plugin only supports direct chat
+ self.is_at = False
+
+ from_user_id = msg.get("from_user_id", "")
+ to_user_id = msg.get("to_user_id", "")
+
+ self.from_user_id = from_user_id
+ self.from_user_nickname = from_user_id
+ self.to_user_id = to_user_id
+ self.to_user_nickname = to_user_id
+ self.other_user_id = from_user_id
+ self.other_user_nickname = from_user_id
+ self.actual_user_id = from_user_id
+ self.actual_user_nickname = from_user_id
+
+ item_list = msg.get("item_list", [])
+
+ # Parse items: find text and media
+ text_body = ""
+ media_item = None
+ media_type = None
+ ref_text = ""
+
+ for item in item_list:
+ itype = item.get("type", 0)
+
+ if itype == ITEM_TEXT:
+ text_item = item.get("text_item", {})
+ text_body = text_item.get("text", "")
+
+ ref = item.get("ref_msg")
+ if ref:
+ ref_title = ref.get("title", "")
+ ref_mi = ref.get("message_item", {})
+ ref_body = ""
+ if ref_mi.get("type") == ITEM_TEXT:
+ ref_body = ref_mi.get("text_item", {}).get("text", "")
+ if ref_title or ref_body:
+ parts = [p for p in [ref_title, ref_body] if p]
+ ref_text = f"[引用: {' | '.join(parts)}]\n"
+ # If ref is a media item, treat it as the media to download
+ if ref_mi.get("type") in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE):
+ media_item = ref_mi
+ media_type = ref_mi.get("type")
+
+ elif itype == ITEM_VOICE:
+ voice_item = item.get("voice_item", {})
+ voice_text = voice_item.get("text", "")
+ if voice_text:
+ text_body = voice_text
+ else:
+ # Voice without transcription - download the audio
+ media_item = item
+ media_type = ITEM_VOICE
+
+ elif itype in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE):
+ if not media_item:
+ media_item = item
+ media_type = itype
+
+ # Determine ctype and content
+ if media_item and not text_body:
+ self._setup_media(media_item, media_type, cdn_base_url)
+ elif media_item and text_body:
+ # Text + media: download media, attach as file ref in text
+ self.ctype = ContextType.TEXT
+ media_path = self._download_media(media_item, media_type, cdn_base_url)
+ if media_path:
+ if media_type == ITEM_IMAGE:
+ text_body += f"\n[图片: {media_path}]"
+ elif media_type == ITEM_VIDEO:
+ text_body += f"\n[视频: {media_path}]"
+ else:
+ text_body += f"\n[文件: {media_path}]"
+ self.content = ref_text + text_body
+ else:
+ self.ctype = ContextType.TEXT
+ self.content = ref_text + text_body
+
+ def _setup_media(self, item: dict, media_type: int, cdn_base_url: str):
+ """Set up message as a media type, with lazy download via _prepare_fn."""
+ if media_type == ITEM_IMAGE:
+ self.ctype = ContextType.IMAGE
+ image_path = self._download_media(item, ITEM_IMAGE, cdn_base_url)
+ if image_path:
+ self.content = image_path
+ self.image_path = image_path
+ else:
+ self.ctype = ContextType.TEXT
+ self.content = "[Image download failed]"
+
+ elif media_type == ITEM_VIDEO:
+ self.ctype = ContextType.FILE
+ save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}.mp4")
+ self.content = save_path
+
+ def _download():
+ path = self._download_media(item, ITEM_VIDEO, cdn_base_url)
+ if path:
+ self.content = path
+ self._prepare_fn = _download
+
+ elif media_type == ITEM_FILE:
+ self.ctype = ContextType.FILE
+ file_name = item.get("file_item", {}).get("file_name", f"wx_{self.msg_id}")
+ save_path = os.path.join(_get_tmp_dir(), file_name)
+ self.content = save_path
+
+ def _download():
+ path = self._download_media(item, ITEM_FILE, cdn_base_url)
+ if path:
+ self.content = path
+ self._prepare_fn = _download
+
+ elif media_type == ITEM_VOICE:
+ self.ctype = ContextType.VOICE
+ save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}.silk")
+ self.content = save_path
+
+ def _download():
+ path = self._download_media(item, ITEM_VOICE, cdn_base_url)
+ if path:
+ self.content = path
+ self._prepare_fn = _download
+
+ def _download_media(self, item: dict, media_type: int, cdn_base_url: str) -> str:
+ """Download media from CDN, returns local file path or empty string."""
+ type_key_map = {
+ ITEM_IMAGE: "image_item",
+ ITEM_VIDEO: "video_item",
+ ITEM_FILE: "file_item",
+ ITEM_VOICE: "voice_item",
+ }
+ key = type_key_map.get(media_type, "")
+ info = item.get(key, {})
+ media = info.get("media", {})
+
+ encrypt_param = media.get("encrypt_query_param", "")
+ # aes_key can be in image_item.aeskey (hex) or media.aes_key (b64)
+ aes_key = info.get("aeskey", "") or media.get("aes_key", "")
+
+ if not encrypt_param or not aes_key:
+ logger.warning(f"[Weixin] Missing CDN params for media download (type={media_type})")
+ return ""
+
+ ext_map = {ITEM_IMAGE: ".jpg", ITEM_VIDEO: ".mp4", ITEM_FILE: "", ITEM_VOICE: ".silk"}
+ ext = ext_map.get(media_type, "")
+ if media_type == ITEM_FILE:
+ ext = os.path.splitext(info.get("file_name", ""))[1] or ".bin"
+
+ save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}{ext}")
+
+ try:
+ download_media_from_cdn(cdn_base_url, encrypt_param, aes_key, save_path)
+ logger.info(f"[Weixin] Media downloaded: {save_path}")
+ return save_path
+ except Exception as e:
+ logger.error(f"[Weixin] Media download failed: {e}")
+ return ""
diff --git a/common/const.py b/common/const.py
index 0e53701f..c6869c38 100644
--- a/common/const.py
+++ b/common/const.py
@@ -193,3 +193,4 @@ FEISHU = "feishu"
DINGTALK = "dingtalk"
WECOM_BOT = "wecom_bot"
QQ = "qq"
+WEIXIN = "weixin"
diff --git a/config-template.json b/config-template.json
index 5e6d269c..d5a80440 100644
--- a/config-template.json
+++ b/config-template.json
@@ -1,5 +1,5 @@
{
- "channel_type": "web",
+ "channel_type": "weixin",
"model": "MiniMax-M2.7",
"minimax_api_key": "",
"zhipu_ai_api_key": "",
diff --git a/config.py b/config.py
index f0fcc854..b6516af7 100644
--- a/config.py
+++ b/config.py
@@ -153,10 +153,15 @@ available_setting = {
# 企微智能机器人配置(长连接模式)
"wecom_bot_id": "", # 企微智能机器人BotID
"wecom_bot_secret": "", # 企微智能机器人长连接Secret
+ # 微信配置
+ "weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录
+ "weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL
+ "weixin_cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c", # CDN base URL
+ "weixin_credentials_path": "~/.weixin_cow_credentials.json", # credentials file path
# chatgpt指令自定义触发词
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头
# channel配置
- "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,wechatmp,wechatmp_service,wechatcom_app
+ "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app
"web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用
"subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app
"debug": False, # 是否开启debug模式,开启后会打印更多日志
@@ -382,7 +387,8 @@ def load_config():
"wechatcomapp_agent_id": "WECHATCOMAPP_AGENT_ID",
"wechatcomapp_secret": "WECHATCOMAPP_SECRET",
"qq_app_id": "QQ_APP_ID",
- "qq_app_secret": "QQ_APP_SECRET"
+ "qq_app_secret": "QQ_APP_SECRET",
+ "weixin_token": "WEIXIN_TOKEN",
}
injected = 0
for conf_key, env_key in _CONFIG_TO_ENV.items():
diff --git a/docs/channels/weixin.mdx b/docs/channels/weixin.mdx
new file mode 100644
index 00000000..17b1f857
--- /dev/null
+++ b/docs/channels/weixin.mdx
@@ -0,0 +1,72 @@
+---
+title: 微信
+description: 将 CowAgent 接入个人微信
+---
+
+> 接入个人微信,扫码登录即可使用,无需公网 IP,支持文本、图片、语音、文件、视频等消息的收发。
+
+## 一、配置和运行
+
+### 方式一:Web 控制台接入
+
+启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。
+
+### 方式二:配置文件接入
+
+在 `config.json` 中设置 `channel_type` 为 `weixin`:
+
+```json
+{
+ "channel_type": "weixin"
+}
+```
+
+启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。
+
+
+ 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。
+
+
+## 二、参数说明
+
+| 参数 | 说明 | 默认值 |
+| --- | --- | --- |
+| `channel_type` | 设为 `weixin` 或 `wx` | — |
+
+登录凭证会自动保存至 `~/.weixin_cow_credentials.json`,如需重新登录删除该文件后重启即可。
+
+## 三、登录说明
+
+### 扫码登录
+
+首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。
+
+- 二维码过期后会自动刷新并重新显示
+- 安装 `qrcode` Python 包可在终端直接渲染二维码图案(`pip3 install qrcode`)
+
+### 凭证保存
+
+登录成功后,凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动时无需重新扫码。
+
+如需重新登录,删除该凭证文件后重启程序即可。
+
+### Session 过期
+
+当微信 session 过期时(errcode -14),程序会自动清除旧凭证并重新发起扫码登录,无需手动干预。
+
+## 四、功能说明
+
+| 功能 | 支持情况 |
+| --- | --- |
+| 单聊 | ✅ |
+| 文本消息 | ✅ 收发 |
+| 图片消息 | ✅ 收发 |
+| 文件消息 | ✅ 收发 |
+| 视频消息 | ✅ 收发 |
+| 语音消息 | ✅ 接收 |
+
+## 五、注意事项
+
+1. 需确保网络可以访问 `ilinkai.weixin.qq.com`。
+2. 媒体文件(图片、文件、视频)通过 CDN 传输,使用 AES-128-ECB 加密,上传和下载由程序自动完成。
+3. 建议在稳定的网络环境下运行,避免频繁断线导致需要重新扫码。
diff --git a/docs/docs.json b/docs/docs.json
index ff3e9271..1ef728b4 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -155,6 +155,7 @@
{
"group": "接入渠道",
"pages": [
+ "channels/weixin",
"channels/web",
"channels/feishu",
"channels/dingtalk",
@@ -301,6 +302,7 @@
{
"group": "Platforms",
"pages": [
+ "en/channels/weixin",
"en/channels/web",
"en/channels/feishu",
"en/channels/dingtalk",
@@ -448,6 +450,7 @@
{
"group": "プラットフォーム",
"pages": [
+ "ja/channels/weixin",
"ja/channels/web",
"ja/channels/feishu",
"ja/channels/dingtalk",
diff --git a/docs/en/README.md b/docs/en/README.md
index 7cb52ee6..06fda767 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -7,7 +7,7 @@
[
中文 ] | [English] | [
日本語 ]
-**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 Bot, WeCom App, and WeChat Official Account — running 7×24 hours on your personal computer or server.
+**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 WeChat, Web, Feishu, DingTalk, WeCom Bot, WeCom App, and WeChat Official Account — running 7×24 hours on your personal computer or server.
🌐 Website ·
@@ -25,7 +25,7 @@
- ✅ **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.
+- ✅ **Multi-platform Deployment**: Runs on local computers or servers, integrable into WeChat, 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
@@ -163,6 +163,7 @@ Supports multiple platforms. Set `channel_type` in `config.json` to switch:
| Channel | `channel_type` | Docs |
| --- | --- | --- |
+| WeChat | `weixin` | [WeChat Setup](https://docs.cowagent.ai/en/channels/weixin) |
| 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) |
diff --git a/docs/en/channels/weixin.mdx b/docs/en/channels/weixin.mdx
new file mode 100644
index 00000000..75e6c9d5
--- /dev/null
+++ b/docs/en/channels/weixin.mdx
@@ -0,0 +1,72 @@
+---
+title: WeChat
+description: Connect CowAgent to personal WeChat
+---
+
+> Connect CowAgent to your personal WeChat. Simply scan a QR code to log in — no public IP required. Supports text, image, voice, file, and video messages.
+
+## 1. Configuration
+
+### Option A: Web Console
+
+Start the program and open the Web console (local access: http://127.0.0.1:9899). Go to the **Channels** tab, click **Connect Channel**, select **WeChat**, and follow the prompts to scan the QR code.
+
+### Option B: Config File
+
+Set `channel_type` to `weixin` in your `config.json`:
+
+```json
+{
+ "channel_type": "weixin"
+}
+```
+
+After starting the program, a QR code will be displayed in the terminal. Scan it with WeChat and confirm on your phone to complete login.
+
+
+ For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel.
+
+
+## 2. Parameters
+
+| Parameter | Description | Default |
+| --- | --- | --- |
+| `channel_type` | Set to `weixin` or `wx` | — |
+
+Login credentials are automatically saved to `~/.weixin_cow_credentials.json`. To force a re-login, delete this file and restart.
+
+## 3. Login
+
+### QR Code Login
+
+On first startup, a QR code is displayed in the terminal (valid for approximately 2 minutes). Scan it with WeChat and confirm on your phone.
+
+- The QR code automatically refreshes when it expires
+- Install the `qrcode` Python package to render the QR code directly in the terminal: `pip3 install qrcode`
+
+### Credential Persistence
+
+After successful login, credentials are saved to `~/.weixin_cow_credentials.json`. Subsequent startups will reuse the saved credentials without requiring a new scan.
+
+To force a re-login, delete the credentials file and restart the program.
+
+### Session Expiry
+
+When the WeChat session expires (errcode -14), the program automatically clears old credentials and initiates a new QR login — no manual intervention required.
+
+## 4. Supported Features
+
+| Feature | Status |
+| --- | --- |
+| Direct Messages | ✅ |
+| Text Messages | ✅ Send & Receive |
+| Image Messages | ✅ Send & Receive |
+| File Messages | ✅ Send & Receive |
+| Video Messages | ✅ Send & Receive |
+| Voice Messages | ✅ Receive |
+
+## 5. Notes
+
+1. Ensure network access to `ilinkai.weixin.qq.com`.
+2. Media files (images, files, videos) are transferred via CDN with AES-128-ECB encryption, handled automatically by the program.
+3. A stable network connection is recommended to avoid frequent disconnections that would require re-scanning.
diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx
index 2fb07a24..7cec9002 100644
--- a/docs/en/intro/index.mdx
+++ b/docs/en/intro/index.mdx
@@ -7,7 +7,7 @@ description: CowAgent - AI Super Assistant powered by LLMs
**CowAgent** is an AI super assistant powered by LLMs with autonomous task planning, long-term memory, skills system, multimodal messages, multiple model support, and multi-platform deployment.
-CowAgent can proactively think and plan tasks, operate computers and external resources, create and execute Skills, and continuously grow with long-term memory. It supports flexible switching between multiple models, handles text, voice, images, files and other multimodal messages, and can be integrated into web, Feishu, DingTalk, WeCom, and WeChat Official Account. It runs 7x24 hours on your personal computer or server.
+CowAgent can proactively think and plan tasks, operate computers and external resources, create and execute Skills, and continuously grow with long-term memory. It supports flexible switching between multiple models, handles text, voice, images, files and other multimodal messages, and can be integrated into WeChat, web, Feishu, DingTalk, WeCom, and WeChat Official Account. It runs 7x24 hours on your personal computer or server.
github.com/zhayujie/chatgpt-on-wechat
@@ -31,8 +31,8 @@ CowAgent can proactively think and plan tasks, operate computers and external re
Supports mainstream model providers including OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao, and more.
-
- Runs on local computers or servers, integrable into web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
+
+ Runs on local computers or servers, integrable into WeChat, web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx
index 2c13a6a7..fdeb0a08 100644
--- a/docs/intro/index.mdx
+++ b/docs/intro/index.mdx
@@ -7,7 +7,7 @@ description: CowAgent - 基于大模型的超级AI助理
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。
-CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企业微信应用、微信公众号中使用,7×24小时运行于你的个人电脑或服务器中。
+CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企业微信应用、微信公众号、网页中使用,7×24小时运行于你的个人电脑或服务器中。
@@ -36,8 +36,8 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao 等国内外主流模型厂商。
-
- 支持运行在本地计算机或服务器,可集成到网页、飞书、钉钉、微信公众号、企业微信应用中使用。
+
+ 支持运行在本地计算机或服务器,可集成到微信、网页、飞书、钉钉、微信公众号、企业微信应用中使用。
@@ -49,7 +49,7 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
-运行后默认会启动 Web 服务,通过访问 `http://localhost:9899/chat` 在网页端对话。
+运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。
diff --git a/docs/ja/README.md b/docs/ja/README.md
index cb146914..6c444f9d 100644
--- a/docs/ja/README.md
+++ b/docs/ja/README.md
@@ -7,7 +7,7 @@
[中文 ] | [English ] | [日本語]
-**CowAgent** はLLMを搭載したAIスーパーアシスタントです。自律的なタスク計画、コンピュータや外部リソースの操作、Skillの作成・実行、長期記憶による継続的な成長が可能です。柔軟なモデル切り替えに対応し、テキスト・音声・画像・ファイルを処理でき、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom Bot(企業微信ボット)、WeComアプリ、WeChat公式アカウントに統合可能で、個人のPCやサーバー上で24時間365日稼働できます。
+**CowAgent** はLLMを搭載したAIスーパーアシスタントです。自律的なタスク計画、コンピュータや外部リソースの操作、Skillの作成・実行、長期記憶による継続的な成長が可能です。柔軟なモデル切り替えに対応し、テキスト・音声・画像・ファイルを処理でき、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom Bot(企業微信ボット)、WeComアプリ、WeChat公式アカウントに統合可能で、個人のPCやサーバー上で24時間365日稼働できます。
🌐 ウェブサイト ·
@@ -25,7 +25,7 @@
- ✅ **Skillシステム**: Skillの作成・実行エンジンを実装しており、複数の組み込みSkillを備え、自然言語での会話を通じたカスタムSkillの開発もサポートしています。
- ✅ **マルチモーダルメッセージ**: テキスト、画像、音声、ファイルなど、さまざまなメッセージタイプの解析・処理・生成・送信に対応しています。
- ✅ **複数モデル対応**: OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen、Kimi、Doubaoなど、主要なモデルプロバイダーに対応しています。
-- ✅ **マルチプラットフォームデプロイ**: ローカルPCやサーバー上で実行でき、Web、Feishu、DingTalk、WeChat公式アカウント、WeComアプリケーションに統合可能です。
+- ✅ **マルチプラットフォームデプロイ**: ローカルPCやサーバー上で実行でき、WeChat、Web、Feishu、DingTalk、WeChat公式アカウント、WeComアプリケーションに統合可能です。
- ✅ **ナレッジベース**: [LinkAI](https://link-ai.tech) プラットフォームを通じて、企業向けナレッジベース機能を統合できます。
## 免責事項
@@ -163,6 +163,7 @@ Coding Planは各プロバイダーが提供する月額サブスクリプショ
| チャネル | `channel_type` | ドキュメント |
| --- | --- | --- |
+| WeChat | `weixin` | [WeChat設定](https://docs.cowagent.ai/ja/channels/weixin) |
| Web(デフォルト) | `web` | [Webチャネル](https://docs.cowagent.ai/en/channels/web) |
| Feishu(飛書) | `feishu` | [Feishu設定](https://docs.cowagent.ai/en/channels/feishu) |
| DingTalk(釘釘) | `dingtalk` | [DingTalk設定](https://docs.cowagent.ai/en/channels/dingtalk) |
diff --git a/docs/ja/channels/weixin.mdx b/docs/ja/channels/weixin.mdx
new file mode 100644
index 00000000..346622a4
--- /dev/null
+++ b/docs/ja/channels/weixin.mdx
@@ -0,0 +1,72 @@
+---
+title: WeChat
+description: CowAgent を個人の WeChat に接続する
+---
+
+> 個人の WeChat に接続します。QR コードをスキャンするだけでログインでき、パブリック IP は不要です。テキスト、画像、音声、ファイル、動画メッセージの送受信に対応しています。
+
+## 1. 設定
+
+### 方法 A: Web コンソール
+
+プログラムを起動し、Web コンソール(ローカルアクセス: http://127.0.0.1:9899)を開きます。**チャネル**タブに移動し、**チャネルを接続**をクリックして **WeChat** を選択し、プロンプトに従って QR コードをスキャンしてください。
+
+### 方法 B: 設定ファイル
+
+`config.json` で `channel_type` を `weixin` に設定します:
+
+```json
+{
+ "channel_type": "weixin"
+}
+```
+
+プログラム起動後、ターミナルに QR コードが表示されます。WeChat でスキャンし、スマートフォンで確認してログインを完了してください。
+
+
+ 後方互換性のため、`channel_type` を `wx` に設定しても WeChat チャネルが有効になります。
+
+
+## 2. パラメータ
+
+| パラメータ | 説明 | デフォルト |
+| --- | --- | --- |
+| `channel_type` | `weixin` または `wx` を指定 | — |
+
+ログイン認証情報は `~/.weixin_cow_credentials.json` に自動保存されます。再ログインするには、このファイルを削除してプログラムを再起動してください。
+
+## 3. ログイン
+
+### QR コードログイン
+
+初回起動時に、ターミナルに QR コードが表示されます(有効期限は約 2 分)。WeChat でスキャンし、スマートフォンで確認してください。
+
+- QR コードが期限切れになると自動的に更新・再表示されます
+- `qrcode` Python パッケージをインストールすると、ターミナルに直接 QR コードを表示できます:`pip3 install qrcode`
+
+### 認証情報の永続化
+
+ログイン成功後、認証情報は `~/.weixin_cow_credentials.json` に保存されます。次回起動時は保存された認証情報が再利用され、再スキャンは不要です。
+
+再ログインするには、認証情報ファイルを削除してプログラムを再起動してください。
+
+### セッションの期限切れ
+
+WeChat セッションが期限切れになった場合(errcode -14)、プログラムは自動的に古い認証情報をクリアし、新しい QR ログインを開始します。手動での操作は不要です。
+
+## 4. 対応機能
+
+| 機能 | 状態 |
+| --- | --- |
+| ダイレクトメッセージ | ✅ |
+| テキストメッセージ | ✅ 送受信 |
+| 画像メッセージ | ✅ 送受信 |
+| ファイルメッセージ | ✅ 送受信 |
+| 動画メッセージ | ✅ 送受信 |
+| 音声メッセージ | ✅ 受信 |
+
+## 5. 注意事項
+
+1. `ilinkai.weixin.qq.com` へのネットワークアクセスが必要です。
+2. メディアファイル(画像、ファイル、動画)は CDN 経由で AES-128-ECB 暗号化を使用して転送され、プログラムが自動的に処理します。
+3. 頻繁な切断による再スキャンを避けるため、安定したネットワーク環境での実行を推奨します。
diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx
index 0c420b01..57979925 100644
--- a/docs/ja/intro/index.mdx
+++ b/docs/ja/intro/index.mdx
@@ -7,7 +7,7 @@ description: CowAgent - LLM を活用した AI スーパーアシスタント
**CowAgent** は、自律的なタスク計画、長期記憶、Skill システム、マルチモーダルメッセージ、複数モデル対応、マルチプラットフォームデプロイを備えた、LLM を活用した AI スーパーアシスタントです。
-CowAgent は自ら思考しタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、長期記憶により継続的に成長します。複数モデルの柔軟な切り替えをサポートし、テキスト、音声、画像、ファイルなどのマルチモーダルメッセージを処理でき、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom(企業微信)、WeChat公式アカウントに統合できます。お使いのパソコンやサーバー上で24時間365日稼働します。
+CowAgent は自ら思考しタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、長期記憶により継続的に成長します。複数モデルの柔軟な切り替えをサポートし、テキスト、音声、画像、ファイルなどのマルチモーダルメッセージを処理でき、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom(企業微信)、WeChat公式アカウントに統合できます。お使いのパソコンやサーバー上で24時間365日稼働します。
github.com/zhayujie/chatgpt-on-wechat
@@ -31,8 +31,8 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen、Kimi、Doubao など、主要なモデルプロバイダーをサポートしています。
-
- ローカルコンピュータやサーバー上で動作し、Web、Feishu(飛書)、DingTalk(釘釘)、WeChat公式アカウント、WeCom(企業微信)アプリケーションに統合できます。
+
+ ローカルコンピュータやサーバー上で動作し、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeChat公式アカウント、WeCom(企業微信)アプリケーションに統合できます。
diff --git a/requirements.txt b/requirements.txt
index 2d965ef2..ccc1c7b7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,6 +9,7 @@ agentmesh-sdk>=0.1.3
python-dotenv>=1.0.0
PyYAML>=6.0
croniter>=2.0.0
+qrcode
# wechatcom & wechatmp
wechatpy
From c1421e0874fdd9575e50e820fa82d1eed261645b Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 16:29:12 +0800
Subject: [PATCH 002/399] feat: support weixin channel in scripts
---
README.md | 5 ++---
docker/docker-compose.yml | 2 +-
docs/channels/weixin.mdx | 2 +-
run.sh | 34 ++++++++++++++++++++--------------
4 files changed, 24 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 22c49d28..af1dd65e 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
[中文] | [English ] | [日本語 ]
-**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
+**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长,比OpenClaw更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
🌐 官网 ·
@@ -17,7 +17,6 @@
-
# 简介
> 该项目既是一个可以开箱即用的超级AI助理,也是一个支持高扩展的Agent框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下:
@@ -630,7 +629,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
1. Weixin - 微信
-接入个人微信,扫码登录即可使用,无需公网 IP,支持文本、图片、语音、文件等消息收发。
+接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件等消息收发。
```json
{
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 0f6326fa..b35bd779 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -8,7 +8,7 @@ services:
ports:
- "9899:9899"
environment:
- CHANNEL_TYPE: 'web'
+ CHANNEL_TYPE: 'weixin'
MODEL: 'MiniMax-M2.5'
MINIMAX_API_KEY: ''
ZHIPU_AI_API_KEY: ''
diff --git a/docs/channels/weixin.mdx b/docs/channels/weixin.mdx
index 17b1f857..a0592cc3 100644
--- a/docs/channels/weixin.mdx
+++ b/docs/channels/weixin.mdx
@@ -3,7 +3,7 @@ title: 微信
description: 将 CowAgent 接入个人微信
---
-> 接入个人微信,扫码登录即可使用,无需公网 IP,支持文本、图片、语音、文件、视频等消息的收发。
+> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的收发。
## 一、配置和运行
diff --git a/run.sh b/run.sh
index 4c575b9f..d2df6355 100755
--- a/run.sh
+++ b/run.sh
@@ -327,23 +327,24 @@ select_channel() {
echo -e "${CYAN}${BOLD}=========================================${NC}"
echo -e "${CYAN}${BOLD} Select Communication Channel${NC}"
echo -e "${CYAN}${BOLD}=========================================${NC}"
- echo -e "${YELLOW}1) Feishu (飞书)${NC}"
- echo -e "${YELLOW}2) DingTalk (钉钉)${NC}"
- echo -e "${YELLOW}3) WeCom Bot (企微智能机器人)${NC}"
- echo -e "${YELLOW}4) QQ (QQ 机器人)${NC}"
- echo -e "${YELLOW}5) WeCom App (企微自建应用)${NC}"
- echo -e "${YELLOW}6) Web (网页)${NC}"
+ echo -e "${YELLOW}1) Weixin (微信)${NC}"
+ echo -e "${YELLOW}2) Feishu (飞书)${NC}"
+ echo -e "${YELLOW}3) DingTalk (钉钉)${NC}"
+ echo -e "${YELLOW}4) WeCom Bot (企微智能机器人)${NC}"
+ echo -e "${YELLOW}5) QQ (QQ 机器人)${NC}"
+ echo -e "${YELLOW}6) WeCom App (企微自建应用)${NC}"
+ echo -e "${YELLOW}7) Web (网页)${NC}"
echo ""
while true; do
- read -p "Enter your choice [press Enter for default: 1 - Feishu]: " channel_choice
+ read -p "Enter your choice [press Enter for default: 1 - Weixin]: " channel_choice
channel_choice=${channel_choice:-1}
case "$channel_choice" in
- 1|2|3|4|5|6)
+ 1|2|3|4|5|6|7)
break
;;
*)
- echo -e "${RED}Invalid choice. Please enter 1-6.${NC}"
+ echo -e "${RED}Invalid choice. Please enter 1-7.${NC}"
;;
esac
done
@@ -353,6 +354,11 @@ select_channel() {
configure_channel() {
case "$channel_choice" in
1)
+ # Weixin
+ CHANNEL_TYPE="weixin"
+ ACCESS_INFO="Weixin channel configured. Scan QR code in terminal or web console to login."
+ ;;
+ 2)
# Feishu (WebSocket mode)
CHANNEL_TYPE="feishu"
echo -e "${GREEN}Configure Feishu (WebSocket mode)...${NC}"
@@ -364,7 +370,7 @@ configure_channel() {
FEISHU_EVENT_MODE="websocket"
ACCESS_INFO="Feishu channel configured (WebSocket mode)"
;;
- 2)
+ 3)
# DingTalk
CHANNEL_TYPE="dingtalk"
echo -e "${GREEN}Configure DingTalk...${NC}"
@@ -375,7 +381,7 @@ configure_channel() {
DT_CLIENT_SECRET="$dt_client_secret"
ACCESS_INFO="DingTalk channel configured"
;;
- 3)
+ 4)
# WeCom Bot
CHANNEL_TYPE="wecom_bot"
echo -e "${GREEN}Configure WeCom Bot...${NC}"
@@ -386,7 +392,7 @@ configure_channel() {
WECOM_BOT_SECRET="$wecom_bot_secret"
ACCESS_INFO="WeCom Bot channel configured"
;;
- 4)
+ 5)
# QQ
CHANNEL_TYPE="qq"
echo -e "${GREEN}Configure QQ Bot...${NC}"
@@ -397,7 +403,7 @@ configure_channel() {
QQ_APP_SECRET="$qq_app_secret"
ACCESS_INFO="QQ Bot channel configured"
;;
- 5)
+ 6)
# WeCom App
CHANNEL_TYPE="wechatcom_app"
echo -e "${GREEN}Configure WeCom App...${NC}"
@@ -417,7 +423,7 @@ configure_channel() {
WECHATCOM_PORT="$com_port"
ACCESS_INFO="WeCom App channel configured on port ${com_port}"
;;
- 6)
+ 7)
# Web
CHANNEL_TYPE="web"
read -p "Enter web port [press Enter for default: 9899]: " web_port
From a483ec0cea2d9721a07ec0797c1052b5d44783a7 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 18:20:10 +0800
Subject: [PATCH 003/399] feat: optimize weixin channel qr code generate
---
channel/weixin/weixin_channel.py | 34 ++++++++++++++++++++++++++++---
common/cloud_client.py | 35 ++++++++++++++++++++++++++++++++
docs/channels/weixin.mdx | 2 +-
docs/en/channels/weixin.mdx | 2 +-
docs/ja/channels/weixin.mdx | 2 +-
5 files changed, 69 insertions(+), 6 deletions(-)
diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py
index 08ac4de0..03da3a7d 100644
--- a/channel/weixin/weixin_channel.py
+++ b/channel/weixin/weixin_channel.py
@@ -160,6 +160,30 @@ class WeixinChannel(ChatChannel):
print(f"\n 二维码链接: {qrcode_url}")
print(" (安装 'qrcode' 包可在终端显示二维码)\n")
+ def _notify_cloud_qrcode(self, qrcode_url: str):
+ """Send QR code URL to cloud console when running in cloud mode."""
+ if not self.cloud_mode:
+ return
+ try:
+ from common import cloud_client
+ client = getattr(cloud_client, "chat_client", None)
+ if client and getattr(client, "client_id", None):
+ client.send_channel_qrcode("weixin", qrcode_url)
+ except Exception as e:
+ logger.warning(f"[Weixin] Failed to notify cloud QR code: {e}")
+
+ def _notify_cloud_connected(self):
+ """Send connected status to cloud console when login succeeds."""
+ if not self.cloud_mode:
+ return
+ try:
+ from common import cloud_client
+ client = getattr(cloud_client, "chat_client", None)
+ if client and getattr(client, "client_id", None):
+ client.send_channel_status("weixin", "connected")
+ except Exception as e:
+ logger.warning(f"[Weixin] Failed to notify cloud connected: {e}")
+
def _qr_login(self, base_url: str) -> dict:
"""Perform interactive QR code login. Returns dict with token/base_url or empty dict."""
api = WeixinApi(base_url=base_url)
@@ -179,11 +203,12 @@ class WeixinChannel(ChatChannel):
self._current_qr_url = qrcode_url
logger.info(f"[Weixin] QR code URL: {qrcode_url}")
self._print_qr(qrcode_url)
+ self._notify_cloud_qrcode(qrcode_url)
print(" 等待扫码...\n")
scanned_printed = False
- while True:
+ while not self._stop_event.is_set():
try:
status_resp = api.poll_qr_status(qrcode)
except Exception as e:
@@ -209,6 +234,7 @@ class WeixinChannel(ChatChannel):
self._current_qr_url = qrcode_url
logger.info(f"[Weixin] New QR code: {qrcode_url}")
self._print_qr(qrcode_url)
+ self._notify_cloud_qrcode(qrcode_url)
except Exception as e:
logger.error(f"[Weixin] QR refresh failed: {e}")
return {}
@@ -225,6 +251,7 @@ class WeixinChannel(ChatChannel):
self._current_qr_url = ""
print(f"\n ✅ 微信登录成功!bot_id={bot_id}")
logger.info(f"[Weixin] Login confirmed: bot_id={bot_id}")
+ self._notify_cloud_connected()
creds = {
"token": bot_token,
@@ -237,9 +264,10 @@ class WeixinChannel(ChatChannel):
return {"token": bot_token, "base_url": result_base_url}
- time.sleep(1)
+ self._stop_event.wait(1)
- logger.warning("[Weixin] QR login timed out")
+ logger.info("[Weixin] QR login cancelled by stop event")
+ self._current_qr_url = ""
return {}
# ── Long-poll loop ─────────────────────────────────────────────────
diff --git a/common/cloud_client.py b/common/cloud_client.py
index 064bca23..b31ed3fb 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -260,11 +260,27 @@ class CloudClient(LinkAIClient):
self._remove_channel_type(local_config, channel_type)
self._save_config_to_file(local_config)
+ if channel_type in ("weixin", "wx"):
+ self._remove_weixin_credentials()
+
if self.channel_mgr:
threading.Thread(
target=self._do_remove_channel, args=(channel_type,), daemon=True
).start()
+ @staticmethod
+ def _remove_weixin_credentials():
+ """Remove the weixin token credentials file so next connect triggers QR login."""
+ cred_path = os.path.expanduser(
+ conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
+ )
+ try:
+ if os.path.exists(cred_path):
+ os.remove(cred_path)
+ logger.info(f"[CloudClient] Removed weixin credentials: {cred_path}")
+ except Exception as e:
+ logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
+
# ------------------------------------------------------------------
# channel credentials helpers
# ------------------------------------------------------------------
@@ -351,12 +367,31 @@ class CloudClient(LinkAIClient):
except Exception as e:
logger.error(f"[CloudClient] Failed to remove channel '{channel_type}': {e}")
+ def send_channel_qrcode(self, channel_type: str, qrcode_url: str):
+ """Report QR code URL for a channel that requires scan-to-login."""
+ if self.client_id:
+ from linkai.api.client.client import ClientMsgType
+ msg = self._build_package(ClientMsgType.CHANNEL_STATUS)
+ msg["data"]["channelType"] = channel_type
+ msg["data"]["status"] = "qrcode"
+ msg["data"]["qrcodeUrl"] = qrcode_url
+ self._send_package(msg)
+ logger.info(f"[CloudClient] Sent QR code status for '{channel_type}'")
+
def _report_channel_startup(self, channel_type: str):
"""Wait for channel startup result and report to cloud."""
ch = self.channel_mgr.get_channel(channel_type)
if not ch:
self.send_channel_status(channel_type, "error", "channel instance not found")
return
+
+ if channel_type in ("weixin", "wx") and hasattr(ch, "login_status"):
+ login_status = getattr(ch, "login_status", "")
+ if login_status in ("waiting_scan", "scanned", "idle"):
+ logger.info(f"[CloudClient] Channel '{channel_type}' is waiting for QR login, "
+ "skip reporting connected")
+ return
+
success, error = ch.wait_startup(timeout=3)
if success:
logger.info(f"[CloudClient] Channel '{channel_type}' connected, reporting status")
diff --git a/docs/channels/weixin.mdx b/docs/channels/weixin.mdx
index a0592cc3..11be7470 100644
--- a/docs/channels/weixin.mdx
+++ b/docs/channels/weixin.mdx
@@ -42,7 +42,7 @@ description: 将 CowAgent 接入个人微信
首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。
- 二维码过期后会自动刷新并重新显示
-- 安装 `qrcode` Python 包可在终端直接渲染二维码图案(`pip3 install qrcode`)
+- `requirements.txt` 中已默认包含 `qrcode` 依赖,安装后可在终端直接渲染二维码图案
### 凭证保存
diff --git a/docs/en/channels/weixin.mdx b/docs/en/channels/weixin.mdx
index 75e6c9d5..6cd90a45 100644
--- a/docs/en/channels/weixin.mdx
+++ b/docs/en/channels/weixin.mdx
@@ -42,7 +42,7 @@ Login credentials are automatically saved to `~/.weixin_cow_credentials.json`. T
On first startup, a QR code is displayed in the terminal (valid for approximately 2 minutes). Scan it with WeChat and confirm on your phone.
- The QR code automatically refreshes when it expires
-- Install the `qrcode` Python package to render the QR code directly in the terminal: `pip3 install qrcode`
+- The `qrcode` dependency is already included in `requirements.txt`, enabling QR code rendering directly in the terminal
### Credential Persistence
diff --git a/docs/ja/channels/weixin.mdx b/docs/ja/channels/weixin.mdx
index 346622a4..912cce24 100644
--- a/docs/ja/channels/weixin.mdx
+++ b/docs/ja/channels/weixin.mdx
@@ -42,7 +42,7 @@ description: CowAgent を個人の WeChat に接続する
初回起動時に、ターミナルに QR コードが表示されます(有効期限は約 2 分)。WeChat でスキャンし、スマートフォンで確認してください。
- QR コードが期限切れになると自動的に更新・再表示されます
-- `qrcode` Python パッケージをインストールすると、ターミナルに直接 QR コードを表示できます:`pip3 install qrcode`
+- `qrcode` 依存関係は `requirements.txt` にデフォルトで含まれており、ターミナルに直接 QR コードを表示できます
### 認証情報の永続化
From 7d4e2cb39a9522b634aaea4b22707e834e74b0c8 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 19:07:19 +0800
Subject: [PATCH 004/399] docs: update comments
---
app.py | 8 +++++++-
common/cloud_client.py | 12 ++++++++++++
docker/docker-compose.yml | 2 +-
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/app.py b/app.py
index 8f09387c..d401a00b 100644
--- a/app.py
+++ b/app.py
@@ -78,7 +78,13 @@ class ChannelManager:
if first_start:
PluginManager().load_plugins()
- if conf().get("use_linkai"):
+ # Cloud client is optional. It is only started when
+ # use_linkai=True AND cloud_deployment_id is set.
+ # By default neither is configured, so the app runs
+ # entirely locally without any remote connection.
+ if conf().get("use_linkai") and (
+ os.environ.get("CLOUD_DEPLOYMENT_ID") or conf().get("cloud_deployment_id")
+ ):
try:
from common import cloud_client
threading.Thread(
diff --git a/common/cloud_client.py b/common/cloud_client.py
index b31ed3fb..25af8ad1 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -3,6 +3,18 @@ Cloud management client for connecting to the LinkAI control console.
Handles remote configuration sync, message push, and skill management
via the LinkAI socket protocol.
+
+NOTE: By default, no cloud-related config is enabled. The application runs
+entirely locally without connecting to any remote service. The cloud client
+is only activated when BOTH of the following conditions are met:
+
+ 1. ``use_linkai`` is set to True in config (checked in app.py before
+ importing this module).
+ 2. ``cloud_deployment_id`` (or env CLOUD_DEPLOYMENT_ID) is non-empty
+ (checked in app.py and again in the ``start()`` function below).
+
+If either condition is missing, this module is never loaded and the
+program continues as a purely local application.
"""
from bridge.context import Context, ContextType
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index b35bd779..2c611ac1 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -9,7 +9,7 @@ services:
- "9899:9899"
environment:
CHANNEL_TYPE: 'weixin'
- MODEL: 'MiniMax-M2.5'
+ MODEL: 'MiniMax-M2.7'
MINIMAX_API_KEY: ''
ZHIPU_AI_API_KEY: ''
ARK_API_KEY: ''
From 5958b69ec93c8ffc0bab9b18d25133aee0b6b403 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 20:49:41 +0800
Subject: [PATCH 005/399] feat: release 2.0.4
---
README.md | 6 ++--
channel/web/static/js/console.js | 7 +++-
channel/weixin/weixin_channel.py | 43 ++++++++++++++++++++++---
docs/channels/weixin.mdx | 28 ++++++++--------
docs/docs.json | 3 ++
docs/en/releases/overview.mdx | 1 +
docs/en/releases/v2.0.4.mdx | 55 ++++++++++++++++++++++++++++++++
docs/ja/releases/overview.mdx | 3 +-
docs/ja/releases/v2.0.4.mdx | 55 ++++++++++++++++++++++++++++++++
docs/releases/overview.mdx | 1 +
docs/releases/v2.0.4.mdx | 51 +++++++++++++++++++++++++++++
11 files changed, 229 insertions(+), 24 deletions(-)
create mode 100644 docs/en/releases/v2.0.4.mdx
create mode 100644 docs/ja/releases/v2.0.4.mdx
create mode 100644 docs/releases/v2.0.4.mdx
diff --git a/README.md b/README.md
index af1dd65e..cc9de579 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,8 @@
# 🏷 更新日志
+>**2026.03.22:** [2.0.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.4),新增个人微信通道(微信扫码即用)、新增 MiniMax-M2.7 和 GLM-5-Turbo 模型、run.sh 脚本重构、日文文档及多项修复。
+
>**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级。
>**2026.02.27:** [2.0.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2),Web 控制台全面升级(流式对话、模型/技能/记忆/通道/定时任务/日志管理)、支持多通道同时运行、会话持久化存储、新增多个模型。
@@ -74,10 +76,6 @@
>**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.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接口
-
更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases)
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 0e147d69..b5070c28 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -5,7 +5,7 @@
// =====================================================================
// Version — update this before each release
// =====================================================================
-const APP_VERSION = 'v2.0.3';
+const APP_VERSION = 'v2.0.4';
// =====================================================================
// i18n
@@ -1810,6 +1810,9 @@ function openAddChannelPanel() {
const activeNames = new Set(channelsData.filter(c => c.active).map(c => c.name));
const available = channelsData.filter(c => !activeNames.has(c.name));
+ const content = document.getElementById('channels-content');
+ if (activeNames.size === 0 && content) content.classList.add('hidden');
+
if (available.length === 0) {
panel.innerHTML = `
${currentLang === 'zh' ? '所有通道均已接入' : 'All channels are already connected'}
@@ -1870,6 +1873,8 @@ function closeAddChannelPanel() {
panel.classList.add('hidden');
panel.innerHTML = '';
}
+ const content = document.getElementById('channels-content');
+ if (content) content.classList.remove('hidden');
}
function onAddChannelSelect(chName) {
diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py
index 03da3a7d..b40692c0 100644
--- a/channel/weixin/weixin_channel.py
+++ b/channel/weixin/weixin_channel.py
@@ -30,6 +30,7 @@ MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY = 30
RETRY_DELAY = 2
SESSION_EXPIRED_ERRCODE = -14
+TEXT_CHUNK_LIMIT = 4000
def _load_credentials(cred_path: str) -> dict:
@@ -462,11 +463,43 @@ class WeixinChannel(ChatChannel):
return self._context_tokens.get(receiver, "")
def _send_text(self, text: str, receiver: str, context_token: str):
- try:
- self.api.send_text(receiver, text, context_token)
- logger.debug(f"[Weixin] Text sent to {receiver}, len={len(text)}")
- except Exception as e:
- logger.error(f"[Weixin] Failed to send text: {e}")
+ if len(text) <= TEXT_CHUNK_LIMIT:
+ try:
+ self.api.send_text(receiver, text, context_token)
+ logger.debug(f"[Weixin] Text sent to {receiver}, len={len(text)}")
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to send text: {e}")
+ return
+
+ chunks = self._split_text(text, TEXT_CHUNK_LIMIT)
+ for i, chunk in enumerate(chunks):
+ try:
+ self.api.send_text(receiver, chunk, context_token)
+ logger.debug(f"[Weixin] Text chunk {i+1}/{len(chunks)} sent to {receiver}, len={len(chunk)}")
+ except Exception as e:
+ logger.error(f"[Weixin] Failed to send text chunk {i+1}/{len(chunks)}: {e}")
+ break
+ if i < len(chunks) - 1:
+ time.sleep(0.5)
+
+ @staticmethod
+ def _split_text(text: str, limit: int) -> list:
+ """Split text into chunks, preferring to break at paragraph or line boundaries."""
+ if len(text) <= limit:
+ return [text]
+ chunks = []
+ while text:
+ if len(text) <= limit:
+ chunks.append(text)
+ break
+ cut = text.rfind("\n\n", 0, limit)
+ if cut <= 0:
+ cut = text.rfind("\n", 0, limit)
+ if cut <= 0:
+ cut = limit
+ chunks.append(text[:cut])
+ text = text[cut:].lstrip("\n")
+ return chunks
def _send_image(self, img_path_or_url: str, receiver: str, context_token: str):
local_path = self._resolve_media_path(img_path_or_url)
diff --git a/docs/channels/weixin.mdx b/docs/channels/weixin.mdx
index 11be7470..c3319930 100644
--- a/docs/channels/weixin.mdx
+++ b/docs/channels/weixin.mdx
@@ -11,6 +11,8 @@ description: 将 CowAgent 接入个人微信
启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。
+
+
### 方式二:配置文件接入
在 `config.json` 中设置 `channel_type` 为 `weixin`:
@@ -23,17 +25,23 @@ description: 将 CowAgent 接入个人微信
启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。
+
+
+
- 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。
+ 1. 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。
+ 2. 注意微信客户端需要更新至 8.0.69 版本或以上
-## 二、参数说明
+## 二、使用说明
+
+微信扫码并进行授权确认后,即可完成接入并开始对话。接入微信后会在对话中创建出一个机器人助理,不会对已有账号的正常使用有任何影响。
+
+> 你可以通过搜索"微信ClawBot"随时找到这个机器人,还可以修改这个机器人的头像、备注等信息,将机器人置顶在消息列表等。
+
+
-| 参数 | 说明 | 默认值 |
-| --- | --- | --- |
-| `channel_type` | 设为 `weixin` 或 `wx` | — |
-登录凭证会自动保存至 `~/.weixin_cow_credentials.json`,如需重新登录删除该文件后重启即可。
## 三、登录说明
@@ -63,10 +71,4 @@ description: 将 CowAgent 接入个人微信
| 图片消息 | ✅ 收发 |
| 文件消息 | ✅ 收发 |
| 视频消息 | ✅ 收发 |
-| 语音消息 | ✅ 接收 |
-
-## 五、注意事项
-
-1. 需确保网络可以访问 `ilinkai.weixin.qq.com`。
-2. 媒体文件(图片、文件、视频)通过 CDN 传输,使用 AES-128-ECB 加密,上传和下载由程序自动完成。
-3. 建议在稳定的网络环境下运行,避免频繁断线导致需要重新扫码。
+| 语音消息 | ✅ 接收 (自带语音识别) |
diff --git a/docs/docs.json b/docs/docs.json
index 1ef728b4..9dcc9053 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -174,6 +174,7 @@
"group": "发布记录",
"pages": [
"releases/overview",
+ "releases/v2.0.4",
"releases/v2.0.3",
"releases/v2.0.2",
"releases/v2.0.1",
@@ -321,6 +322,7 @@
"group": "Release Notes",
"pages": [
"en/releases/overview",
+ "en/releases/v2.0.4",
"en/releases/v2.0.2",
"en/releases/v2.0.1",
"en/releases/v2.0.0"
@@ -469,6 +471,7 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
+ "ja/releases/v2.0.4",
"ja/releases/v2.0.3",
"ja/releases/v2.0.2",
"ja/releases/v2.0.1",
diff --git a/docs/en/releases/overview.mdx b/docs/en/releases/overview.mdx
index 38445fd4..83b3e908 100644
--- a/docs/en/releases/overview.mdx
+++ b/docs/en/releases/overview.mdx
@@ -5,6 +5,7 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
+| [2.0.4](/en/releases/v2.0.4) | 2026.03.22 | Personal WeChat channel, new model support, Japanese docs, script refactoring and bug fixes |
| [2.0.2](/en/releases/v2.0.2) | 2026.02.27 | Web Console upgrade, multi-channel concurrency, session persistence |
| [2.0.1](/en/releases/v2.0.1) | 2026.02.27 | Built-in Web Search tool, smart context management, multiple fixes |
| [2.0.0](/en/releases/v2.0.0) | 2026.02.03 | Full upgrade to AI super assistant |
diff --git a/docs/en/releases/v2.0.4.mdx b/docs/en/releases/v2.0.4.mdx
new file mode 100644
index 00000000..a5bed23a
--- /dev/null
+++ b/docs/en/releases/v2.0.4.mdx
@@ -0,0 +1,55 @@
+---
+title: v2.0.4
+description: CowAgent 2.0.4 - Personal WeChat channel, new model support, Japanese docs, script refactoring and bug fixes
+---
+
+## 🔌 Personal WeChat Channel
+
+Added personal WeChat (`weixin`) channel — the most important update in this release. Simply scan a QR code to connect CowAgent to your personal WeChat account, with support for:
+
+- **Messaging**: Send and receive text, image, file, and video messages; receive voice messages
+- **QR Code Login**: QR code displayed in terminal, scan with WeChat to log in; auto-refresh on expiry
+- **Credential Persistence**: Login credentials saved to `~/.weixin_cow_credentials.json` automatically, no re-scan needed on restart
+- **Session Auto-Reconnect**: Automatically clears expired credentials and re-initiates QR code login
+- **Web Console Integration**: Add WeChat channel from the Web Console with synchronized QR code login flow
+- **Docker & Script Support**: Both `run.sh` and `docker-compose.yml` now support the WeChat channel
+
+Documentation: [WeChat Channel](https://docs.cowagent.ai/channels/weixin).
+
+Related commits: [ce89869](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce89869), [a483ec0](https://github.com/zhayujie/chatgpt-on-wechat/commit/a483ec0), [c1421e0](https://github.com/zhayujie/chatgpt-on-wechat/commit/c1421e0)
+
+## 🤖 New Models
+
+- **MiniMax-M2.7**: Added MiniMax-M2.7 model support
+- **GLM-5-Turbo**: Added Zhipu glm-5-turbo model support
+
+Related commits: [9192f6f](https://github.com/zhayujie/chatgpt-on-wechat/commit/9192f6f)
+
+## 🔧 Script Refactoring
+
+- **run.sh Refactoring**: Extracted shared logic and eliminated duplication, reducing from 600+ lines to 177 lines ([49d8707](https://github.com/zhayujie/chatgpt-on-wechat/commit/49d8707))
+- **Executable Permission**: Fixed `run.sh` file permission issue ([652156e](https://github.com/zhayujie/chatgpt-on-wechat/commit/652156e))
+
+## ⚡ Improvements
+
+- **Unified Request Headers**: Added identification headers to external requests across Agent services (Chat, Embedding, Vision, WebSearch, etc.) ([b4e711f](https://github.com/zhayujie/chatgpt-on-wechat/commit/b4e711f))
+- **Auto-Repair Messages**: Enhanced message protocol fault tolerance with automatic repair of malformed message sequences ([b8b57e3](https://github.com/zhayujie/chatgpt-on-wechat/commit/b8b57e3))
+
+## 🌍 Japanese Documentation
+
+Added complete Japanese documentation covering getting started guide, channel integration, model configuration and other major sections. Thanks [@Ikko Ashimine](https://github.com/ikoamu)
+
+Related commits: [5487c0b](https://github.com/zhayujie/chatgpt-on-wechat/commit/5487c0b)
+
+## 🐛 Bug Fixes
+
+- **WeCom Bot Compatibility**: Fixed compatibility with older `websocket-client` versions, added unified WebSocket compatibility layer ([bc7f627](https://github.com/zhayujie/chatgpt-on-wechat/commit/bc7f627))
+- **run.sh PID**: Fixed process PID retrieval error in `run.sh` ([9febb07](https://github.com/zhayujie/chatgpt-on-wechat/commit/9febb07))
+- **Feishu Encoding**: Fixed message and log encoding issue in Feishu channel ([7d0e156](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d0e156))
+- **Feishu Config**: Removed redundant `feishu_bot_name` dependency in `run.sh` ([1b5be1b](https://github.com/zhayujie/chatgpt-on-wechat/commit/1b5be1b))
+
+## 📦 Upgrade
+
+Run `./run.sh update` for a one-click upgrade, or manually pull the latest code and restart. See [Upgrade Guide](https://docs.cowagent.ai/guide/upgrade) for details.
+
+**Release Date**: 2026.03.22 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.3...master)
diff --git a/docs/ja/releases/overview.mdx b/docs/ja/releases/overview.mdx
index f5149908..4e6e69f5 100644
--- a/docs/ja/releases/overview.mdx
+++ b/docs/ja/releases/overview.mdx
@@ -5,7 +5,8 @@ description: CowAgent バージョン履歴
| バージョン | 日付 | 説明 |
| --- | --- | --- |
-| [2.0.2](/en/releases/v2.0.2) | 2026.02.27 | Web Console アップグレード、マルチチャネル同時実行、セッション永続化 |
+| [2.0.4](/ja/releases/v2.0.4) | 2026.03.22 | 個人WeChatチャネル追加、新モデルサポート、日本語ドキュメント、スクリプトリファクタリングおよび複数修正 |
+| [2.0.2](/ja/releases/v2.0.2) | 2026.02.27 | Web Console アップグレード、マルチチャネル同時実行、セッション永続化 |
| [2.0.1](/en/releases/v2.0.1) | 2026.02.27 | 組み込み Web Search ツール、スマートコンテキスト管理、複数の修正 |
| [2.0.0](/en/releases/v2.0.0) | 2026.02.03 | AI スーパーアシスタントへの全面アップグレード |
| 1.7.6 | 2025.05.23 | Web Channel 最適化、AgentMesh プラグイン |
diff --git a/docs/ja/releases/v2.0.4.mdx b/docs/ja/releases/v2.0.4.mdx
new file mode 100644
index 00000000..97d10d9b
--- /dev/null
+++ b/docs/ja/releases/v2.0.4.mdx
@@ -0,0 +1,55 @@
+---
+title: v2.0.4
+description: CowAgent 2.0.4 - 個人WeChat チャネルの追加、新モデルサポート、日本語ドキュメント、スクリプトリファクタリングおよび複数の修正
+---
+
+## 🔌 個人WeChat チャネルの追加
+
+個人WeChat(`weixin`)チャネルを追加しました。本バージョンの最も重要なアップデートです。QRコードをスキャンするだけで CowAgent を個人WeChatに接続でき、以下の機能をサポートします:
+
+- **メッセージ送受信**:テキスト、画像、ファイル、動画メッセージの送受信、音声メッセージの受信をサポート
+- **QRコードログイン**:ターミナルにQRコードを表示、WeChatでスキャンして確認するだけでログイン完了。QRコード期限切れ時は自動更新
+- **認証情報の永続化**:ログイン認証情報を `~/.weixin_cow_credentials.json` に自動保存、再起動時に再スキャン不要
+- **Session 自動再接続**:Session 期限切れ時に旧認証情報を自動クリアし、QRコードログインを再開
+- **Web コンソール接続**:Web コンソールからWeChatチャネルを追加可能、QRコードログインフローを同期表示
+- **Docker・スクリプト対応**:`run.sh` と `docker-compose.yml` がWeChat チャネルに対応
+
+接続ドキュメント:[WeChat 接続](https://docs.cowagent.ai/channels/weixin)。
+
+関連コミット:[ce89869](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce89869), [a483ec0](https://github.com/zhayujie/chatgpt-on-wechat/commit/a483ec0), [c1421e0](https://github.com/zhayujie/chatgpt-on-wechat/commit/c1421e0)
+
+## 🤖 新規モデル
+
+- **MiniMax-M2.7**:MiniMax-M2.7 モデルのサポートを追加
+- **GLM-5-Turbo**:智譜 glm-5-turbo モデルのサポートを追加
+
+関連コミット:[9192f6f](https://github.com/zhayujie/chatgpt-on-wechat/commit/9192f6f)
+
+## 🔧 スクリプトリファクタリング
+
+- **run.sh リファクタリング**:共通ロジックを抽出し、大量の重複コードを削除。スクリプトの行数を 600+ 行から 177 行に圧縮 ([49d8707](https://github.com/zhayujie/chatgpt-on-wechat/commit/49d8707))
+- **実行権限**:`run.sh` ファイルの権限問題を修正 ([652156e](https://github.com/zhayujie/chatgpt-on-wechat/commit/652156e))
+
+## ⚡ 最適化
+
+- **リクエストヘッダー統一**:Agent の各サービス(Chat、Embedding、Vision、WebSearch 等)の外部リクエストに統一的な識別ヘッダーを追加 ([b4e711f](https://github.com/zhayujie/chatgpt-on-wechat/commit/b4e711f))
+- **メッセージ自動修復**:メッセージプロトコルのフォールトトレランスを強化し、フォーマット異常なメッセージシーケンスを自動修復 ([b8b57e3](https://github.com/zhayujie/chatgpt-on-wechat/commit/b8b57e3))
+
+## 🌍 日本語ドキュメント
+
+完全な日本語ドキュメントを追加しました。入門ガイド、チャネル接続、モデル設定などの主要セクションをカバーしています。Thanks [@Ikko Ashimine](https://github.com/ikoamu)
+
+関連コミット:[5487c0b](https://github.com/zhayujie/chatgpt-on-wechat/commit/5487c0b)
+
+## 🐛 バグ修正
+
+- **企業微信ボット互換性**:旧バージョンの `websocket-client` との互換性問題を修正し、統一的な WebSocket 互換レイヤーを追加 ([bc7f627](https://github.com/zhayujie/chatgpt-on-wechat/commit/bc7f627))
+- **run.sh PID 取得**:`run.sh` でのプロセス PID 取得エラーを修正 ([9febb07](https://github.com/zhayujie/chatgpt-on-wechat/commit/9febb07))
+- **飛書エンコーディング**:飛書チャネルのメッセージとログのエンコーディング問題を修正 ([7d0e156](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d0e156))
+- **飛書設定**:`run.sh` での `feishu_bot_name` への冗長な依存を削除 ([1b5be1b](https://github.com/zhayujie/chatgpt-on-wechat/commit/1b5be1b))
+
+## 📦 アップグレード方法
+
+ソースコードデプロイの場合は `./run.sh update` でワンクリックアップグレードできます。または手動でコードをプルして再起動してください。詳細は [アップデートドキュメント](https://docs.cowagent.ai/guide/upgrade) を参照。
+
+**リリース日**:2026.03.22 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.3...master)
diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx
index f9ce9a04..fc891bff 100644
--- a/docs/releases/overview.mdx
+++ b/docs/releases/overview.mdx
@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
+| [2.0.4](/releases/v2.0.4) | 2026.03.22 | 新增个人微信通道、新模型支持、日文文档、脚本重构及多项修复 |
| [2.0.3](/releases/v2.0.3) | 2026.03.18 | 新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级 |
| [2.0.2](/releases/v2.0.2) | 2026.02.27 | Web 控制台升级、多通道同时运行、会话持久化 |
| [2.0.1](/releases/v2.0.1) | 2026.02.13 | 内置 Web Search 工具、智能上下文管理、多项修复 |
diff --git a/docs/releases/v2.0.4.mdx b/docs/releases/v2.0.4.mdx
new file mode 100644
index 00000000..447f7992
--- /dev/null
+++ b/docs/releases/v2.0.4.mdx
@@ -0,0 +1,51 @@
+---
+title: v2.0.4
+description: CowAgent 2.0.4 - 新增个人微信通道、新模型支持、日文文档、脚本重构及多项修复
+---
+
+## 🔌 新增个人微信通道
+
+新增个人微信(`weixin`)通道,微信扫描二维码即可将 CowAgent 接入个人微信,支持以下功能:
+
+- **消息收发**:支持文本、图片、文件、视频消息的接收与回复,支持语音消息接收和识别
+- **扫码登录**:终端显示二维码,微信扫码确认即可登录,二维码过期自动刷新
+- **凭证持久化**:登录凭证自动保存至 `~/.weixin_cow_credentials.json`,重启无需重新扫码
+- **Session 自动重连**:Session 过期后自动清除旧凭证并重新发起扫码登录
+- **Web 控制台接入**:支持在 Web 控制台中添加微信通道,扫码登录流程同步展示
+- **Docker 和脚本支持**:`run.sh` 和 `docker-compose.yml` 均已适配微信通道
+
+接入文档:[微信接入](https://docs.cowagent.ai/channels/weixin)。
+
+相关提交:[ce89869](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce89869)
+
+## 🤖 新增模型
+
+- **MiniMax-M2.7**:新增 MiniMax-M2.7 模型支持
+- **GLM-5-Turbo**:新增智谱 glm-5-turbo 模型支持
+
+相关提交:[9192f6f](https://github.com/zhayujie/chatgpt-on-wechat/commit/9192f6f)
+
+## 🔧 脚本重构
+
+- **run.sh 重构**:提取公共逻辑,精简脚本代码([49d8707](https://github.com/zhayujie/chatgpt-on-wechat/commit/49d8707))
+- **可执行权限**:修复 `run.sh` 文件权限问题 ([652156e](https://github.com/zhayujie/chatgpt-on-wechat/commit/652156e))
+- **PID 获取**:修复 `run.sh` 中进程 PID 获取错误的问题 ([9febb07](https://github.com/zhayujie/chatgpt-on-wechat/commit/9febb07))
+
+## 🌍 文档更新
+
+新增完整的日文文档,覆盖入门指南、通道接入、模型配置等主要章节。Thanks [@Ikko Ashimine](https://github.com/ikoamu)
+
+相关提交:[5487c0b](https://github.com/zhayujie/chatgpt-on-wechat/commit/5487c0b)
+
+## 🐛 问题修复
+
+- **企微机器人兼容**:修复旧版 `websocket-client` 的兼容性问题,新增统一的 WebSocket 兼容层 ([bc7f627](https://github.com/zhayujie/chatgpt-on-wechat/commit/bc7f627))
+- **消息自动修复**:增强消息协议的容错能力,自动修复格式异常的消息序列 ([b8b57e3](https://github.com/zhayujie/chatgpt-on-wechat/commit/b8b57e3))
+- **飞书编码**:修复飞书通道消息和日志的编码问题 ([7d0e156](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d0e156))
+- **飞书配置**:移除 `run.sh` 中对 `feishu_bot_name` 的冗余依赖 ([1b5be1b](https://github.com/zhayujie/chatgpt-on-wechat/commit/1b5be1b))
+
+## 📦 升级方式
+
+源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。
+
+**发布日期**:2026.03.22 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.3...master)
From f3216904b30a3e6b8aac87ad1ad4460786fe4bdc Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 21:34:47 +0800
Subject: [PATCH 006/399] feat(weixin): optimize weixin login qrcode
---
channel/weixin/weixin_channel.py | 54 +++++++++++++++++++++++---------
1 file changed, 40 insertions(+), 14 deletions(-)
diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py
index b40692c0..11c5ec27 100644
--- a/channel/weixin/weixin_channel.py
+++ b/channel/weixin/weixin_channel.py
@@ -31,6 +31,8 @@ BACKOFF_DELAY = 30
RETRY_DELAY = 2
SESSION_EXPIRED_ERRCODE = -14
TEXT_CHUNK_LIMIT = 4000
+QR_LOGIN_TIMEOUT_S = 480
+QR_MAX_REFRESHES = 10
def _load_credentials(cred_path: str) -> dict:
@@ -80,6 +82,8 @@ class WeixinChannel(ChatChannel):
# ── Lifecycle ──────────────────────────────────────────────────────
def startup(self):
+ self._stop_event.clear()
+
base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
token = conf().get("weixin_token", "")
@@ -95,17 +99,9 @@ class WeixinChannel(ChatChannel):
base_url = creds["base_url"]
if not token:
- logger.info("[Weixin] No token found, starting QR login...")
- self.login_status = self.LOGIN_STATUS_WAITING
- login_result = self._qr_login(base_url)
- if not login_result:
- self.login_status = self.LOGIN_STATUS_IDLE
- err = "[Weixin] QR login failed. Set weixin_token in config or run login again."
- logger.error(err)
- self.report_startup_error(err)
+ token, base_url = self._login_with_retry(base_url)
+ if not token:
return
- token = login_result["token"]
- base_url = login_result.get("base_url", base_url)
self.api = WeixinApi(base_url=base_url, token=token, cdn_base_url=cdn_base_url)
self.login_status = self.LOGIN_STATUS_OK
@@ -114,9 +110,26 @@ class WeixinChannel(ChatChannel):
f"如需重新扫码登录请删除该文件后重启")
self.report_startup_success()
- self._stop_event.clear()
self._poll_loop()
+ def _login_with_retry(self, base_url: str) -> tuple:
+ """Attempt QR login, then wait for stop if failed.
+ Returns (token, base_url) on success, or ("", "") if stopped."""
+ logger.info("[Weixin] No token found, starting QR login...")
+ self.login_status = self.LOGIN_STATUS_WAITING
+ login_result = self._qr_login(base_url)
+ if login_result:
+ return login_result["token"], login_result.get("base_url", base_url)
+
+ self.login_status = self.LOGIN_STATUS_IDLE
+ if not self._stop_event.is_set():
+ logger.info("[Weixin] QR login timed out, waiting for stop or reconnect...")
+ print(" 二维码登录超时,请通过控制台重新接入\n")
+ self._stop_event.wait()
+
+ logger.info("[Weixin] Login cancelled by stop event")
+ return "", ""
+
def stop(self):
logger.info("[Weixin] stop() called")
self._stop_event.set()
@@ -208,8 +221,15 @@ class WeixinChannel(ChatChannel):
print(" 等待扫码...\n")
scanned_printed = False
+ refresh_count = 0
+ deadline = time.time() + QR_LOGIN_TIMEOUT_S
while not self._stop_event.is_set():
+ if time.time() >= deadline:
+ logger.warning(f"[Weixin] QR login timed out after {QR_LOGIN_TIMEOUT_S}s")
+ print(f"\n 二维码登录超时({QR_LOGIN_TIMEOUT_S}s),请重启后重试")
+ break
+
try:
status_resp = api.poll_qr_status(qrcode)
except Exception as e:
@@ -226,14 +246,19 @@ class WeixinChannel(ChatChannel):
print(" 已扫码,请在手机上确认...")
scanned_printed = True
elif status == "expired":
- print(" 二维码已过期,正在刷新...")
+ refresh_count += 1
+ if refresh_count >= QR_MAX_REFRESHES:
+ logger.warning(f"[Weixin] QR code refreshed {QR_MAX_REFRESHES} times, giving up")
+ print(f"\n 二维码已刷新 {QR_MAX_REFRESHES} 次仍未扫码,请重启后重试")
+ break
+ print(f" 二维码已过期,正在刷新({refresh_count}/{QR_MAX_REFRESHES})...")
try:
qr_resp = api.fetch_qr_code()
qrcode = qr_resp.get("qrcode", "")
qrcode_url = qr_resp.get("qrcode_img_content", "")
scanned_printed = False
self._current_qr_url = qrcode_url
- logger.info(f"[Weixin] New QR code: {qrcode_url}")
+ logger.info(f"[Weixin] New QR code ({refresh_count}/{QR_MAX_REFRESHES}): {qrcode_url}")
self._print_qr(qrcode_url)
self._notify_cloud_qrcode(qrcode_url)
except Exception as e:
@@ -267,8 +292,9 @@ class WeixinChannel(ChatChannel):
self._stop_event.wait(1)
- logger.info("[Weixin] QR login cancelled by stop event")
self._current_qr_url = ""
+ if self._stop_event.is_set():
+ logger.info("[Weixin] QR login cancelled by stop event")
return {}
# ── Long-poll loop ─────────────────────────────────────────────────
From d71ae406ff320940278da8221b2bb8905da9faa8 Mon Sep 17 00:00:00 2001
From: cowagent
Date: Sun, 22 Mar 2026 22:43:26 +0800
Subject: [PATCH 007/399] fix: add missing model property to GoogleGeminiBot
api_key and api_base were refactored to @property but model was not
migrated, causing AttributeError: 'GoogleGeminiBot' object has no
attribute 'model' when using any Gemini model.
---
models/gemini/google_gemini_bot.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/models/gemini/google_gemini_bot.py b/models/gemini/google_gemini_bot.py
index 3521a847..ef70f78f 100644
--- a/models/gemini/google_gemini_bot.py
+++ b/models/gemini/google_gemini_bot.py
@@ -34,6 +34,13 @@ class GoogleGeminiBot(Bot):
def api_key(self):
return conf().get("gemini_api_key")
+ @property
+ def model(self):
+ model_name = conf().get("model") or "gemini-pro"
+ if model_name == "gemini":
+ model_name = "gemini-pro"
+ return model_name
+
@property
def api_base(self):
base = conf().get("gemini_api_base", "").strip()
From 7199dc187fd9c923cfc85563bc61355aa4d909dd Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 22:52:37 +0800
Subject: [PATCH 008/399] fix: default gemini model
---
models/gemini/google_gemini_bot.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/models/gemini/google_gemini_bot.py b/models/gemini/google_gemini_bot.py
index ef70f78f..e49a8bf3 100644
--- a/models/gemini/google_gemini_bot.py
+++ b/models/gemini/google_gemini_bot.py
@@ -36,9 +36,9 @@ class GoogleGeminiBot(Bot):
@property
def model(self):
- model_name = conf().get("model") or "gemini-pro"
+ model_name = conf().get("model") or "gemini-3.1-pro-preview"
if model_name == "gemini":
- model_name = "gemini-pro"
+ model_name = "gemini-3.1-pro-preview"
return model_name
@property
From fc9f54dbc868373805f660966dd503a28c69bbf5 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 23:04:50 +0800
Subject: [PATCH 009/399] feat(weixin): optimize login qrcode generate
---
common/cloud_client.py | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/common/cloud_client.py b/common/cloud_client.py
index 25af8ad1..62b96536 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -222,7 +222,14 @@ class CloudClient(LinkAIClient):
return
existing_ch = self.channel_mgr.get_channel(channel_type)
- if existing_ch and not cred_changed:
+ skip_restart = existing_ch and not cred_changed
+ if skip_restart and channel_type in ("weixin", "wx"):
+ login_status = getattr(existing_ch, "login_status", "")
+ if login_status != "logged_in":
+ skip_restart = False
+ logger.info(f"[CloudClient] Channel '{channel_type}' not logged in "
+ f"(status={login_status}), forcing restart")
+ if skip_restart:
logger.info(f"[CloudClient] Channel '{channel_type}' already running with same config, "
"skip restart, reporting status only")
threading.Thread(
@@ -255,7 +262,14 @@ class CloudClient(LinkAIClient):
).start()
else:
existing_ch = self.channel_mgr.get_channel(channel_type)
- if existing_ch and not cred_changed:
+ needs_restart = cred_changed or not existing_ch
+ if not needs_restart and channel_type in ("weixin", "wx"):
+ login_status = getattr(existing_ch, "login_status", "")
+ if login_status != "logged_in":
+ needs_restart = True
+ logger.info(f"[CloudClient] Channel '{channel_type}' not logged in "
+ f"(status={login_status}), forcing restart")
+ if existing_ch and not needs_restart:
logger.info(f"[CloudClient] Channel '{channel_type}' already running with same config, "
"skip restart, reporting status only")
threading.Thread(
From 304381a88d8b5170dce5883518a833928bdb7384 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 22 Mar 2026 23:36:34 +0800
Subject: [PATCH 010/399] fix: hide breadcrumb on mobile for better space
utilization
---
channel/web/chat.html | 4 ++--
docs/guide/upgrade.mdx | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/channel/web/chat.html b/channel/web/chat.html
index d2f5c897..80cb9319 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -166,8 +166,8 @@
-
-
+
+
Chat
Chat
diff --git a/docs/guide/upgrade.mdx b/docs/guide/upgrade.mdx
index 2e72ac53..24f65b15 100644
--- a/docs/guide/upgrade.mdx
+++ b/docs/guide/upgrade.mdx
@@ -5,7 +5,7 @@ description: CowAgent 的升级方式说明
## 脚本升级(推荐)
-如果使用 `run.sh` 管理服务,执行以下命令即可一键升级:
+如果使用 `run.sh` 管理服务,在项目根目录执行以下命令即可一键升级:
```bash
./run.sh update
From 45faa9c1ff10843da451b5b34fe4f976daada888 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 23 Mar 2026 00:13:41 +0800
Subject: [PATCH 011/399] fix(wexin): resolve image/file send and receive
failures
---
channel/weixin/weixin_api.py | 125 ++++++++++++++++++-----------------
1 file changed, 65 insertions(+), 60 deletions(-)
diff --git a/channel/weixin/weixin_api.py b/channel/weixin/weixin_api.py
index b09d14dd..fe2f7a7b 100644
--- a/channel/weixin/weixin_api.py
+++ b/channel/weixin/weixin_api.py
@@ -172,10 +172,8 @@ class WeixinApi:
def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
rawsize: int, rawfilemd5: str, filesize: int,
- aeskey: str,
- thumb_rawsize: int = 0, thumb_rawfilemd5: str = "",
- thumb_filesize: int = 0) -> dict:
- body = {
+ aeskey: str) -> dict:
+ return self._post("ilink/bot/getuploadurl", {
"filekey": filekey,
"media_type": media_type,
"to_user_id": to_user_id,
@@ -183,14 +181,8 @@ class WeixinApi:
"rawfilemd5": rawfilemd5,
"filesize": filesize,
"aeskey": aeskey,
- }
- if thumb_rawsize > 0:
- body["thumb_rawsize"] = thumb_rawsize
- body["thumb_rawfilemd5"] = thumb_rawfilemd5
- body["thumb_filesize"] = thumb_filesize
- else:
- body["no_need_thumb"] = True
- return self._post("ilink/bot/getuploadurl", body)
+ "no_need_thumb": True,
+ })
# ── getConfig / sendTyping ─────────────────────────────────────────
@@ -259,10 +251,18 @@ def _md5_bytes(data: bytes) -> str:
return hashlib.md5(data).hexdigest()
+def _aes_ecb_padded_size(plaintext_size: int) -> int:
+ """PKCS7 padded size for AES-128-ECB."""
+ return ((plaintext_size + 1 + 15) // 16) * 16
+
+
+UPLOAD_MAX_RETRIES = 3
+
+
def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
media_type: int) -> dict:
"""
- Upload a local file to the Weixin CDN.
+ Upload a local file to the Weixin CDN (matching official plugin protocol).
Args:
api: WeixinApi instance
@@ -275,35 +275,14 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
"""
aes_key = os.urandom(16)
aes_key_hex = aes_key.hex()
+ filekey = uuid.uuid4().hex
with open(file_path, "rb") as f:
raw_data = f.read()
raw_size = len(raw_data)
raw_md5 = _md5_bytes(raw_data)
- encrypted = _aes_ecb_encrypt(raw_data, aes_key)
- cipher_size = len(encrypted)
- filekey = uuid.uuid4().hex
-
- thumb_rawsize = 0
- thumb_rawfilemd5 = ""
- thumb_filesize = 0
-
- if media_type == 1: # IMAGE - generate a tiny thumbnail
- try:
- from PIL import Image
- import io
- img = Image.open(file_path)
- img.thumbnail((100, 100))
- buf = io.BytesIO()
- img.save(buf, format="JPEG", quality=60)
- thumb_raw = buf.getvalue()
- thumb_rawsize = len(thumb_raw)
- thumb_rawfilemd5 = _md5_bytes(thumb_raw)
- thumb_encrypted = _aes_ecb_encrypt(thumb_raw, aes_key)
- thumb_filesize = len(thumb_encrypted)
- except Exception as e:
- logger.warning(f"[Weixin] Thumbnail generation failed, skipping: {e}")
+ cipher_size = _aes_ecb_padded_size(raw_size)
resp = api.get_upload_url(
filekey=filekey,
@@ -313,37 +292,52 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
rawfilemd5=raw_md5,
filesize=cipher_size,
aeskey=aes_key_hex,
- thumb_rawsize=thumb_rawsize,
- thumb_rawfilemd5=thumb_rawfilemd5,
- thumb_filesize=thumb_filesize,
)
upload_param = resp.get("upload_param", "")
if not upload_param:
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
- cdn_url = api.cdn_base_url + "?" + upload_param
- put_resp = requests.put(cdn_url, data=encrypted, headers={
- "Content-Type": "application/octet-stream",
- "Content-Length": str(cipher_size),
- }, timeout=60)
- put_resp.raise_for_status()
+ encrypted = _aes_ecb_encrypt(raw_data, aes_key)
- # Upload thumbnail if we have one
- thumb_upload_param = resp.get("thumb_upload_param", "")
- if thumb_upload_param and thumb_filesize > 0:
- thumb_cdn_url = api.cdn_base_url + "?" + thumb_upload_param
+ from urllib.parse import quote
+ cdn_url = (f"{api.cdn_base_url}/upload"
+ f"?encrypted_query_param={quote(upload_param)}"
+ f"&filekey={quote(filekey)}")
+
+ download_param = None
+ last_error = None
+ for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
try:
- requests.put(thumb_cdn_url, data=thumb_encrypted, headers={
+ cdn_resp = requests.post(cdn_url, data=encrypted, headers={
"Content-Type": "application/octet-stream",
- "Content-Length": str(thumb_filesize),
- }, timeout=30)
+ }, timeout=120)
+ if 400 <= cdn_resp.status_code < 500:
+ err_msg = cdn_resp.headers.get("x-error-message", cdn_resp.text[:200])
+ raise RuntimeError(f"CDN client error {cdn_resp.status_code}: {err_msg}")
+ cdn_resp.raise_for_status()
+ download_param = cdn_resp.headers.get("x-encrypted-param", "")
+ if not download_param:
+ raise RuntimeError("CDN response missing x-encrypted-param header")
+ logger.debug(f"[Weixin] CDN upload success attempt={attempt} filekey={filekey}")
+ break
except Exception as e:
- logger.warning(f"[Weixin] Thumbnail upload failed (non-fatal): {e}")
+ last_error = e
+ if "client error" in str(e):
+ raise
+ if attempt < UPLOAD_MAX_RETRIES:
+ logger.warning(f"[Weixin] CDN upload attempt {attempt} failed, retrying: {e}")
+ else:
+ logger.error(f"[Weixin] CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}")
+
+ if not download_param:
+ raise last_error or RuntimeError("CDN upload failed")
+
+ aes_key_b64 = base64.b64encode(aes_key_hex.encode("utf-8")).decode("utf-8")
return {
- "encrypt_query_param": upload_param,
- "aes_key_b64": base64.b64encode(aes_key).decode("utf-8"),
+ "encrypt_query_param": download_param,
+ "aes_key_b64": aes_key_b64,
"ciphertext_size": cipher_size,
"raw_size": raw_size,
}
@@ -363,19 +357,30 @@ def download_media_from_cdn(cdn_base_url: str, encrypt_query_param: str,
Returns:
save_path on success
"""
- url = cdn_base_url + "?" + encrypt_query_param
+ from urllib.parse import quote
+ url = f"{cdn_base_url}/download?encrypted_query_param={quote(encrypt_query_param)}"
resp = requests.get(url, timeout=60)
resp.raise_for_status()
- # Determine key format (hex string or base64)
+ # Determine key format:
+ # 1) 32-char hex string → 16 raw bytes
+ # 2) base64 string → decode → if 32 bytes, treat as hex-encoded → 16 raw bytes
+ # 3) base64 string → decode → 16 raw bytes directly
try:
key_bytes = bytes.fromhex(aes_key)
if len(key_bytes) != 16:
raise ValueError()
except (ValueError, TypeError):
- key_bytes = base64.b64decode(aes_key)
- if len(key_bytes) != 16:
- raise ValueError(f"Invalid AES key length: {len(key_bytes)}")
+ decoded = base64.b64decode(aes_key)
+ if len(decoded) == 32:
+ try:
+ key_bytes = bytes.fromhex(decoded.decode("ascii"))
+ except (ValueError, UnicodeDecodeError):
+ raise ValueError(f"Invalid AES key: 32 bytes but not valid hex")
+ elif len(decoded) == 16:
+ key_bytes = decoded
+ else:
+ raise ValueError(f"Invalid AES key length after base64 decode: {len(decoded)}")
decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
From baf66a103d0e73090aa5b07604cfba3000ecb183 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 23 Mar 2026 01:18:02 +0800
Subject: [PATCH 012/399] fix(weixin): preserve original filename for received
files
---
channel/weixin/weixin_message.py | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/channel/weixin/weixin_message.py b/channel/weixin/weixin_message.py
index e1de71d2..2083c66c 100644
--- a/channel/weixin/weixin_message.py
+++ b/channel/weixin/weixin_message.py
@@ -184,12 +184,16 @@ class WeixinMessage(ChatMessage):
logger.warning(f"[Weixin] Missing CDN params for media download (type={media_type})")
return ""
- ext_map = {ITEM_IMAGE: ".jpg", ITEM_VIDEO: ".mp4", ITEM_FILE: "", ITEM_VOICE: ".silk"}
- ext = ext_map.get(media_type, "")
if media_type == ITEM_FILE:
- ext = os.path.splitext(info.get("file_name", ""))[1] or ".bin"
-
- save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}{ext}")
+ original_name = info.get("file_name", "")
+ if original_name:
+ save_path = os.path.join(_get_tmp_dir(), original_name)
+ else:
+ save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}.bin")
+ else:
+ ext_map = {ITEM_IMAGE: ".jpg", ITEM_VIDEO: ".mp4", ITEM_VOICE: ".silk"}
+ ext = ext_map.get(media_type, "")
+ save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}{ext}")
try:
download_media_from_cdn(cdn_base_url, encrypt_param, aes_key, save_path)
From 22b8ca00959f70c305c697ad23a16fc340aac0fb Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 23 Mar 2026 21:18:04 +0800
Subject: [PATCH 013/399] feat: optimize vision image compression
---
agent/tools/vision/vision.py | 60 ++++++++++++++++++++++++------------
1 file changed, 40 insertions(+), 20 deletions(-)
diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py
index 8b06c437..72cd7cbb 100644
--- a/agent/tools/vision/vision.py
+++ b/agent/tools/vision/vision.py
@@ -167,7 +167,7 @@ class Vision(BaseTool):
@staticmethod
def _maybe_compress(path: str) -> str:
- """Compress image if larger than threshold; return path to use."""
+ """Compress image to under COMPRESS_THRESHOLD with max long-edge 1536px."""
file_size = os.path.getsize(path)
if file_size <= COMPRESS_THRESHOLD:
return path
@@ -175,27 +175,47 @@ class Vision(BaseTool):
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
- try:
- # macOS: use sips
- subprocess.run(
- ["sips", "-Z", "800", path, "--out", tmp.name],
- capture_output=True, check=True,
- )
- logger.debug(f"[Vision] Compressed image ({file_size // 1024}KB -> {os.path.getsize(tmp.name) // 1024}KB)")
- return tmp.name
- except (FileNotFoundError, subprocess.CalledProcessError):
- pass
+ def _try_sips(max_dim: str, quality: str) -> bool:
+ try:
+ subprocess.run(
+ ["sips", "-Z", max_dim, "-s", "formatOptions", quality,
+ path, "--out", tmp.name],
+ capture_output=True, check=True,
+ )
+ return True
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return False
- try:
- # Linux: use ImageMagick convert
- subprocess.run(
- ["convert", path, "-resize", "800x800>", tmp.name],
- capture_output=True, check=True,
- )
- logger.debug(f"[Vision] Compressed image ({file_size // 1024}KB -> {os.path.getsize(tmp.name) // 1024}KB)")
+ def _try_convert(max_dim: str, quality: str) -> bool:
+ try:
+ subprocess.run(
+ ["convert", path, "-resize", f"{max_dim}x{max_dim}>",
+ "-quality", quality, tmp.name],
+ capture_output=True, check=True,
+ )
+ return True
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return False
+
+ attempts = [
+ ("1536", "85"),
+ ("1536", "70"),
+ ("1536", "50"),
+ ]
+
+ for max_dim, quality in attempts:
+ ok = _try_sips(max_dim, quality) or _try_convert(max_dim, quality)
+ if not ok:
+ continue
+ new_size = os.path.getsize(tmp.name)
+ logger.debug(f"[Vision] Compressed image "
+ f"({file_size // 1024}KB -> {new_size // 1024}KB, "
+ f"max_dim={max_dim}, q={quality})")
+ if new_size <= COMPRESS_THRESHOLD:
+ return tmp.name
+
+ if os.path.exists(tmp.name) and os.path.getsize(tmp.name) > 0:
return tmp.name
- except (FileNotFoundError, subprocess.CalledProcessError):
- pass
os.remove(tmp.name)
return path
From f512b55ec21b2e1a43a8f1c41090c0669ba99df7 Mon Sep 17 00:00:00 2001
From: 6vision
Date: Mon, 23 Mar 2026 21:23:35 +0800
Subject: [PATCH 014/399] feat(deepseek): add independent DeepSeek bot module
with dedicated config
Separate DeepSeek from ChatGPTBot into its own module (models/deepseek/) with dedicated deepseek_api_key and deepseek_api_base config fields, avoiding config conflicts when switching between providers. Backward compatible with old users who configured DeepSeek via open_ai_api_key/open_ai_api_base through automatic fallback.
Made-with: Cursor
---
bridge/agent_bridge.py | 4 +-
bridge/bridge.py | 3 +
channel/web/web_channel.py | 10 +-
models/bot_factory.py | 6 +-
models/deepseek/__init__.py | 0
models/deepseek/deepseek_bot.py | 160 ++++++++++++++++++++++++++++
models/deepseek/deepseek_session.py | 57 ++++++++++
7 files changed, 231 insertions(+), 9 deletions(-)
create mode 100644 models/deepseek/__init__.py
create mode 100644 models/deepseek/deepseek_bot.py
create mode 100644 models/deepseek/deepseek_session.py
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py
index 44d59b7f..81caad3c 100644
--- a/bridge/agent_bridge.py
+++ b/bridge/agent_bridge.py
@@ -74,7 +74,7 @@ class AgentLLMModel(LLMModel):
("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),
+ ("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
]
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
@@ -115,8 +115,6 @@ class AgentLLMModel(LLMModel):
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.OPENAI
for prefix, btype in self._MODEL_PREFIX_MAP:
if model_name.startswith(prefix):
return btype
diff --git a/bridge/bridge.py b/bridge/bridge.py
index 6c2dcb2d..388c2999 100644
--- a/bridge/bridge.py
+++ b/bridge/bridge.py
@@ -61,6 +61,9 @@ class Bridge(object):
if model_type and model_type.startswith("doubao"):
self.btype["chat"] = const.DOUBAO
+ if model_type and model_type.startswith("deepseek"):
+ self.btype["chat"] = const.DEEPSEEK
+
if model_type in [const.MODELSCOPE]:
self.btype["chat"] = const.MODELSCOPE
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 0be5b75a..2979cd9e 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -563,9 +563,9 @@ class ConfigHandler:
}),
("deepseek", {
"label": "DeepSeek",
- "api_key_field": "open_ai_api_key",
- "api_base_key": None,
- "api_base_default": None,
+ "api_key_field": "deepseek_api_key",
+ "api_base_key": "deepseek_api_base",
+ "api_base_default": "https://api.deepseek.com/v1",
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
}),
("linkai", {
@@ -579,9 +579,9 @@ class ConfigHandler:
EDITABLE_KEYS = {
"model", "bot_type", "use_linkai",
- "open_ai_api_base", "claude_api_base", "gemini_api_base",
+ "open_ai_api_base", "deepseek_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",
+ "open_ai_api_key", "deepseek_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",
diff --git a/models/bot_factory.py b/models/bot_factory.py
index 26111b6d..5c91c7c6 100644
--- a/models/bot_factory.py
+++ b/models/bot_factory.py
@@ -17,7 +17,11 @@ def create_bot(bot_type):
from models.baidu.baidu_wenxin import BaiduWenxinBot
return BaiduWenxinBot()
- elif bot_type in (const.OPENAI, const.CHATGPT, const.DEEPSEEK): # OpenAI-compatible API
+ elif bot_type == const.DEEPSEEK:
+ from models.deepseek.deepseek_bot import DeepSeekBot
+ return DeepSeekBot()
+
+ elif bot_type in (const.OPENAI, const.CHATGPT): # OpenAI-compatible API
from models.chatgpt.chat_gpt_bot import ChatGPTBot
return ChatGPTBot()
diff --git a/models/deepseek/__init__.py b/models/deepseek/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/models/deepseek/deepseek_bot.py b/models/deepseek/deepseek_bot.py
new file mode 100644
index 00000000..033e0216
--- /dev/null
+++ b/models/deepseek/deepseek_bot.py
@@ -0,0 +1,160 @@
+# encoding:utf-8
+
+"""
+DeepSeek Bot — fully OpenAI-compatible, uses its own API key / base config.
+"""
+
+import time
+
+import requests
+from models.bot import Bot
+from models.openai_compatible_bot import OpenAICompatibleBot
+from models.session_manager import SessionManager
+from bridge.context import ContextType
+from bridge.reply import Reply, ReplyType
+from common import const
+from common.log import logger
+from config import conf, load_config
+from .deepseek_session import DeepSeekSession
+
+DEFAULT_API_BASE = "https://api.deepseek.com/v1"
+
+
+class DeepSeekBot(Bot, OpenAICompatibleBot):
+ def __init__(self):
+ super().__init__()
+ self.sessions = SessionManager(
+ DeepSeekSession,
+ model=conf().get("model") or const.DEEPSEEK_CHAT,
+ )
+ conf_model = conf().get("model") or const.DEEPSEEK_CHAT
+ self.args = {
+ "model": conf_model,
+ "temperature": conf().get("temperature", 0.7),
+ "top_p": conf().get("top_p", 1.0),
+ "frequency_penalty": conf().get("frequency_penalty", 0.0),
+ "presence_penalty": conf().get("presence_penalty", 0.0),
+ }
+
+ # ---------- config helpers ----------
+
+ @property
+ def api_key(self):
+ return conf().get("deepseek_api_key") or conf().get("open_ai_api_key")
+
+ @property
+ def api_base(self):
+ url = (
+ conf().get("deepseek_api_base")
+ or conf().get("open_ai_api_base")
+ or DEFAULT_API_BASE
+ )
+ return url.rstrip("/")
+
+ def get_api_config(self):
+ """OpenAICompatibleBot interface — used by call_with_tools()."""
+ return {
+ "api_key": self.api_key,
+ "api_base": self.api_base,
+ "model": conf().get("model", const.DEEPSEEK_CHAT),
+ "default_temperature": conf().get("temperature", 0.7),
+ "default_top_p": conf().get("top_p", 1.0),
+ "default_frequency_penalty": conf().get("frequency_penalty", 0.0),
+ "default_presence_penalty": conf().get("presence_penalty", 0.0),
+ }
+
+ # ---------- simple chat (non-agent mode) ----------
+
+ def reply(self, query, context=None):
+ if context.type == ContextType.TEXT:
+ logger.info("[DEEPSEEK] query={}".format(query))
+
+ session_id = context["session_id"]
+ reply = None
+ clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
+ if query in clear_memory_commands:
+ self.sessions.clear_session(session_id)
+ reply = Reply(ReplyType.INFO, "记忆已清除")
+ elif query == "#清除所有":
+ self.sessions.clear_all_session()
+ reply = Reply(ReplyType.INFO, "所有人记忆已清除")
+ elif query == "#更新配置":
+ load_config()
+ reply = Reply(ReplyType.INFO, "配置已更新")
+ if reply:
+ return reply
+
+ session = self.sessions.session_query(query, session_id)
+ logger.debug("[DEEPSEEK] session query={}".format(session.messages))
+
+ new_args = self.args.copy()
+ reply_content = self.reply_text(session, args=new_args)
+ logger.debug(
+ "[DEEPSEEK] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
+ session.messages, session_id,
+ reply_content["content"], reply_content["completion_tokens"],
+ )
+ )
+ if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
+ reply = Reply(ReplyType.ERROR, reply_content["content"])
+ elif reply_content["completion_tokens"] > 0:
+ self.sessions.session_reply(
+ reply_content["content"], session_id, reply_content["total_tokens"],
+ )
+ reply = Reply(ReplyType.TEXT, reply_content["content"])
+ else:
+ reply = Reply(ReplyType.ERROR, reply_content["content"])
+ logger.debug("[DEEPSEEK] reply {} used 0 tokens.".format(reply_content))
+ return reply
+ else:
+ reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
+ return reply
+
+ def reply_text(self, session, args=None, retry_count: int = 0) -> dict:
+ try:
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + self.api_key,
+ }
+ body = args.copy()
+ body["messages"] = session.messages
+
+ res = requests.post(
+ f"{self.api_base}/chat/completions",
+ headers=headers,
+ json=body,
+ timeout=180,
+ )
+ if res.status_code == 200:
+ response = res.json()
+ return {
+ "total_tokens": response["usage"]["total_tokens"],
+ "completion_tokens": response["usage"]["completion_tokens"],
+ "content": response["choices"][0]["message"]["content"],
+ }
+ else:
+ response = res.json()
+ error = response.get("error", {})
+ logger.error(
+ f"[DEEPSEEK] chat failed, status_code={res.status_code}, "
+ f"msg={error.get('message')}, type={error.get('type')}"
+ )
+ result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
+ need_retry = False
+ if res.status_code >= 500:
+ need_retry = retry_count < 2
+ elif res.status_code == 401:
+ result["content"] = "授权失败,请检查API Key是否正确"
+ elif res.status_code == 429:
+ result["content"] = "请求过于频繁,请稍后再试"
+ need_retry = retry_count < 2
+
+ if need_retry:
+ time.sleep(3)
+ return self.reply_text(session, args, retry_count + 1)
+ return result
+ except Exception as e:
+ logger.exception(e)
+ if retry_count < 2:
+ return self.reply_text(session, args, retry_count + 1)
+ return {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
diff --git a/models/deepseek/deepseek_session.py b/models/deepseek/deepseek_session.py
new file mode 100644
index 00000000..9500c8f4
--- /dev/null
+++ b/models/deepseek/deepseek_session.py
@@ -0,0 +1,57 @@
+from models.session_manager import Session
+from common.log import logger
+
+
+class DeepSeekSession(Session):
+ def __init__(self, session_id, system_prompt=None, model="deepseek-chat"):
+ super().__init__(session_id, system_prompt)
+ self.model = model
+ self.reset()
+
+ def discard_exceeding(self, max_tokens, cur_tokens=None):
+ precise = True
+ try:
+ cur_tokens = self.calc_tokens()
+ except Exception as e:
+ precise = False
+ if cur_tokens is None:
+ raise e
+ logger.debug("Exception when counting tokens precisely for query: {}".format(e))
+ while cur_tokens > max_tokens:
+ if len(self.messages) > 2:
+ self.messages.pop(1)
+ elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
+ self.messages.pop(1)
+ if precise:
+ cur_tokens = self.calc_tokens()
+ else:
+ cur_tokens = cur_tokens - max_tokens
+ break
+ elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
+ logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
+ break
+ else:
+ logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(
+ max_tokens, cur_tokens, len(self.messages)))
+ break
+ if precise:
+ cur_tokens = self.calc_tokens()
+ else:
+ cur_tokens = cur_tokens - max_tokens
+ return cur_tokens
+
+ def calc_tokens(self):
+ return num_tokens_from_messages(self.messages, self.model)
+
+
+def num_tokens_from_messages(messages, model):
+ tokens = 0
+ for msg in messages:
+ content = msg.get("content", "")
+ if isinstance(content, str):
+ tokens += len(content)
+ elif isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict):
+ tokens += len(block.get("text", ""))
+ return tokens
From 13f5fde4fbd5d29943c46f43277e1994783a6ac1 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 23 Mar 2026 21:27:44 +0800
Subject: [PATCH 015/399] fix: rebuild system prompt from scratch on every turn
---
agent/prompt/builder.py | 6 +-
agent/protocol/agent.py | 149 ++++++----------------------------------
2 files changed, 24 insertions(+), 131 deletions(-)
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index ee1b6ff2..486b6587 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -376,9 +376,9 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"",
"以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
"",
- "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
+ "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
- "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则",
+ "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
"",
"**交流规范**:",
"",
@@ -423,7 +423,7 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
]
if has_agent:
- lines.append("**`AGENT.md` 是你的灵魂文件**:严格体现其中定义的人格、语气和设定,避免僵硬、模板化的回复。")
+ lines.append("**`AGENT.md` 是你的灵魂文件**:严格遵循其中定义的人格、规则、语气和设定,避免僵硬、模板化的回复。")
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
lines.append("")
diff --git a/agent/protocol/agent.py b/agent/protocol/agent.py
index 6818331a..285a9732 100644
--- a/agent/protocol/agent.py
+++ b/agent/protocol/agent.py
@@ -100,138 +100,31 @@ class Agent:
def get_full_system_prompt(self, skill_filter=None) -> str:
"""
- Get the full system prompt including skills.
+ Build the complete system prompt from scratch every time.
- Note: Skills are now built into the system prompt by PromptBuilder,
- so we just return the base prompt directly. This method is kept for
- backward compatibility.
-
- :param skill_filter: Optional list of skill names to include (deprecated)
- :return: Complete system prompt
- """
- prompt = self.system_prompt
-
- # Rebuild tool list section to reflect current self.tools
- prompt = self._rebuild_tool_list_section(prompt)
-
- # If runtime_info contains dynamic time function, rebuild runtime section
- if self.runtime_info and callable(self.runtime_info.get('_get_current_time')):
- prompt = self._rebuild_runtime_section(prompt)
-
- # Rebuild skills section to pick up newly installed/removed skills
- if self.skill_manager:
- prompt = self._rebuild_skills_section(prompt)
-
- return prompt
-
- def _rebuild_runtime_section(self, prompt: str) -> str:
- """
- Rebuild runtime info section with current time.
-
- This method dynamically updates the runtime info section by calling
- the _get_current_time function from runtime_info.
-
- :param prompt: Original system prompt
- :return: Updated system prompt with current runtime info
+ Re-reads AGENT.md / USER.md / RULE.md from disk, refreshes skills,
+ tools, and runtime info so any change takes effect immediately.
+ Falls back to the cached self.system_prompt on error.
"""
try:
- # Get current time dynamically
- time_info = self.runtime_info['_get_current_time']()
-
- # Build new runtime section
- runtime_lines = [
- "\n## 运行时信息\n",
- "\n",
- f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})\n",
- "\n"
- ]
-
- # Add other runtime info
- runtime_parts = []
- if self.runtime_info.get("model"):
- runtime_parts.append(f"模型={self.runtime_info['model']}")
- if self.runtime_info.get("workspace"):
- # Replace backslashes with forward slashes for Windows paths
- workspace_path = str(self.runtime_info['workspace']).replace('\\', '/')
- runtime_parts.append(f"工作空间={workspace_path}")
- if self.runtime_info.get("channel") and self.runtime_info.get("channel") != "web":
- runtime_parts.append(f"渠道={self.runtime_info['channel']}")
-
- if runtime_parts:
- runtime_lines.append("运行时: " + " | ".join(runtime_parts) + "\n")
- runtime_lines.append("\n")
-
- new_runtime_section = "".join(runtime_lines)
-
- # Find and replace the runtime section
- import re
- pattern = r'\n## 运行时信息\s*\n.*?(?=\n##|\Z)'
- _repl = new_runtime_section.rstrip('\n')
- updated_prompt = re.sub(pattern, lambda m: _repl, prompt, flags=re.DOTALL)
-
- return updated_prompt
+ from agent.prompt import load_context_files, PromptBuilder
+
+ if self.skill_manager:
+ self.skill_manager.refresh_skills()
+
+ context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None
+
+ builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language="zh")
+ return builder.build(
+ tools=self.tools,
+ context_files=context_files,
+ skill_manager=self.skill_manager,
+ memory_manager=self.memory_manager,
+ runtime_info=self.runtime_info,
+ )
except Exception as e:
- logger.warning(f"Failed to rebuild runtime section: {e}")
- return prompt
-
- def _rebuild_skills_section(self, prompt: str) -> str:
- """
- Rebuild the block so that newly installed or
- removed skills are reflected without re-creating the agent.
- """
- try:
- import re
- self.skill_manager.refresh_skills()
- new_skills_xml = self.skill_manager.build_skills_prompt()
-
- old_block_pattern = r'.*? '
- has_old_block = re.search(old_block_pattern, prompt, flags=re.DOTALL)
-
- # Extract the new ... tag from the prompt
- new_block = ""
- if new_skills_xml and new_skills_xml.strip():
- m = re.search(old_block_pattern, new_skills_xml, flags=re.DOTALL)
- if m:
- new_block = m.group(0)
-
- if has_old_block:
- replacement = new_block or "\n "
- # Use lambda to prevent re.sub from interpreting backslashes in replacement
- # (e.g. Windows paths like \LinkAI would be treated as bad escape sequences)
- prompt = re.sub(old_block_pattern, lambda m: replacement, prompt, flags=re.DOTALL)
- elif new_block:
- skills_header = "以下是可用技能:"
- idx = prompt.find(skills_header)
- if idx != -1:
- insert_pos = idx + len(skills_header)
- prompt = prompt[:insert_pos] + "\n" + new_block + prompt[insert_pos:]
- except Exception as e:
- logger.warning(f"Failed to rebuild skills section: {e}")
- return prompt
-
- def _rebuild_tool_list_section(self, prompt: str) -> str:
- """
- Rebuild the tool list inside the '## 工具系统' section so that it
- always reflects the current ``self.tools`` (handles dynamic add/remove
- of conditional tools like web_search).
- """
- import re
- from agent.prompt.builder import _build_tooling_section
-
- try:
- if not self.tools:
- return prompt
-
- new_lines = _build_tooling_section(self.tools, "zh")
- new_section = "\n".join(new_lines).rstrip("\n")
-
- # Replace existing tooling section
- pattern = r'## 工具系统\s*\n.*?(?=\n## |\Z)'
- updated = re.sub(pattern, lambda m: new_section, prompt, count=1, flags=re.DOTALL)
- return updated
- except Exception as e:
- logger.warning(f"Failed to rebuild tool list section: {e}")
- return prompt
+ logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
+ return self.system_prompt
def refresh_skills(self):
"""Refresh the loaded skills."""
From 3ca52b118d921d83e622b6373b5d2639cc9e86f5 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 23 Mar 2026 21:33:53 +0800
Subject: [PATCH 016/399] fix(weixin): qrcode url log
---
channel/weixin/weixin_channel.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py
index 11c5ec27..250eaeec 100644
--- a/channel/weixin/weixin_channel.py
+++ b/channel/weixin/weixin_channel.py
@@ -215,7 +215,7 @@ class WeixinChannel(ChatChannel):
return {}
self._current_qr_url = qrcode_url
- logger.info(f"[Weixin] QR code URL: {qrcode_url}")
+ logger.info(f"[Weixin] 微信二维码链接: {qrcode_url}")
self._print_qr(qrcode_url)
self._notify_cloud_qrcode(qrcode_url)
print(" 等待扫码...\n")
@@ -258,7 +258,7 @@ class WeixinChannel(ChatChannel):
qrcode_url = qr_resp.get("qrcode_img_content", "")
scanned_printed = False
self._current_qr_url = qrcode_url
- logger.info(f"[Weixin] New QR code ({refresh_count}/{QR_MAX_REFRESHES}): {qrcode_url}")
+ logger.info(f"[Weixin] 微信二维码链接 ({refresh_count}/{QR_MAX_REFRESHES}): {qrcode_url}")
self._print_qr(qrcode_url)
self._notify_cloud_qrcode(qrcode_url)
except Exception as e:
From ef009edd2949f49db1a4a9074538298f68594d37 Mon Sep 17 00:00:00 2001
From: 6vision
Date: Mon, 23 Mar 2026 21:43:51 +0800
Subject: [PATCH 017/399] docs(deepseek): update config guides for independent
DeepSeek module
Update DeepSeek docs (zh/en/ja) and README to reflect the new dedicated deepseek_api_key / deepseek_api_base config fields, with backward compatibility notes.
Made-with: Cursor
---
README.md | 27 ++++++++++++++++++++-------
docs/en/models/deepseek.mdx | 26 +++++++++++++++++++-------
docs/ja/models/deepseek.mdx | 26 +++++++++++++++++++-------
docs/models/deepseek.mdx | 26 +++++++++++++++++++-------
4 files changed, 77 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
index cc9de579..99cb3d9d 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,8 @@ pip3 install -r requirements-optional.txt
"claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台
"gemini_api_key": "", # Gemini API Key
"gemini_api_base": "https://generativelanguage.googleapis.com", # Gemini API地址
+ "deepseek_api_key": "", # DeepSeek API Key
+ "deepseek_api_base": "https://api.deepseek.com/v1", # DeepSeek API 地址,可修改为第三方代理
"open_ai_api_key": "", # OpenAI API Key
"open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址
"linkai_api_key": "", # LinkAI API Key
@@ -472,20 +474,31 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
2. 填写配置
+方式一:官方接入(推荐):
+
```json
{
"model": "deepseek-chat",
- "open_ai_api_key": "sk-xxxxxxxxxxx",
- "open_ai_api_base": "https://api.deepseek.com/v1",
- "bot_type": "openai"
-
+ "deepseek_api_key": "sk-xxxxxxxxxxx"
}
```
- - `bot_type`: OpenAI兼容方式
- `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3 和 DeepSeek-R1 模型
- - `open_ai_api_key`: DeepSeek平台的 API Key
- - `open_ai_api_base`: DeepSeek平台 BASE URL
+ - `deepseek_api_key`: DeepSeek平台的 API Key
+ - `deepseek_api_base`: 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址
+
+方式二:OpenAI兼容方式接入:
+
+```json
+{
+ "model": "deepseek-chat",
+ "bot_type": "openai",
+ "open_ai_api_key": "sk-xxxxxxxxxxx",
+ "open_ai_api_base": "https://api.deepseek.com/v1"
+}
+```
+
+> 注:已有用户如果之前通过 `open_ai_api_key` / `open_ai_api_base` 配置 DeepSeek,无需修改,程序会自动兼容。推荐新用户使用方式一的独立配置,避免与 OpenAI 配置冲突。
diff --git a/docs/en/models/deepseek.mdx b/docs/en/models/deepseek.mdx
index 3d7f721c..8d76de8a 100644
--- a/docs/en/models/deepseek.mdx
+++ b/docs/en/models/deepseek.mdx
@@ -3,7 +3,22 @@ title: DeepSeek
description: DeepSeek model configuration
---
-Use OpenAI-compatible configuration:
+Option 1: Native integration (recommended):
+
+```json
+{
+ "model": "deepseek-chat",
+ "deepseek_api_key": "YOUR_API_KEY"
+}
+```
+
+| Parameter | Description |
+| --- | --- |
+| `model` | `deepseek-chat` (DeepSeek-V3), `deepseek-reasoner` (DeepSeek-R1) |
+| `deepseek_api_key` | Create at [DeepSeek Platform](https://platform.deepseek.com/api_keys) |
+| `deepseek_api_base` | Optional, defaults to `https://api.deepseek.com/v1`. Can be changed to a third-party proxy |
+
+Option 2: OpenAI-compatible configuration:
```json
{
@@ -14,9 +29,6 @@ Use OpenAI-compatible configuration:
}
```
-| Parameter | Description |
-| --- | --- |
-| `model` | `deepseek-chat` (DeepSeek-V3), `deepseek-reasoner` (DeepSeek-R1) |
-| `bot_type` | Must be `openai` (OpenAI-compatible mode) |
-| `open_ai_api_key` | Create at [DeepSeek Platform](https://platform.deepseek.com/api_keys) |
-| `open_ai_api_base` | DeepSeek platform BASE URL |
+
+ Existing users who configured DeepSeek via `open_ai_api_key` / `open_ai_api_base` do not need to change anything — the program will automatically fall back to those fields. New users are recommended to use Option 1 to avoid config conflicts with OpenAI.
+
diff --git a/docs/ja/models/deepseek.mdx b/docs/ja/models/deepseek.mdx
index a68a8f3b..200be8d4 100644
--- a/docs/ja/models/deepseek.mdx
+++ b/docs/ja/models/deepseek.mdx
@@ -3,7 +3,22 @@ title: DeepSeek
description: DeepSeekモデルの設定
---
-OpenAI互換の設定を使用します:
+方法1:公式接続(推奨):
+
+```json
+{
+ "model": "deepseek-chat",
+ "deepseek_api_key": "YOUR_API_KEY"
+}
+```
+
+| パラメータ | 説明 |
+| --- | --- |
+| `model` | `deepseek-chat` (DeepSeek-V3)、`deepseek-reasoner` (DeepSeek-R1) |
+| `deepseek_api_key` | [DeepSeek Platform](https://platform.deepseek.com/api_keys)で作成 |
+| `deepseek_api_base` | オプション、デフォルトは `https://api.deepseek.com/v1`。サードパーティプロキシに変更可能 |
+
+方法2:OpenAI互換方式:
```json
{
@@ -14,9 +29,6 @@ OpenAI互換の設定を使用します:
}
```
-| パラメータ | 説明 |
-| --- | --- |
-| `model` | `deepseek-chat` (DeepSeek-V3)、`deepseek-reasoner` (DeepSeek-R1) |
-| `bot_type` | `openai`を指定(OpenAI互換モード) |
-| `open_ai_api_key` | [DeepSeek Platform](https://platform.deepseek.com/api_keys)で作成 |
-| `open_ai_api_base` | DeepSeekプラットフォームのBASE URL |
+
+ 既存ユーザーが `open_ai_api_key` / `open_ai_api_base` で DeepSeek を設定していた場合、変更は不要です。プログラムは自動的にフォールバックします。新規ユーザーは OpenAI 設定との競合を避けるため、方法1の使用を推奨します。
+
diff --git a/docs/models/deepseek.mdx b/docs/models/deepseek.mdx
index 32d9b5b7..d62bac82 100644
--- a/docs/models/deepseek.mdx
+++ b/docs/models/deepseek.mdx
@@ -3,20 +3,32 @@ title: DeepSeek
description: DeepSeek 模型配置
---
-通过 OpenAI 兼容方式接入:
+方式一:官方接入(推荐):
```json
{
"model": "deepseek-chat",
- "open_ai_api_key": "YOUR_API_KEY",
- "open_ai_api_base": "https://api.deepseek.com/v1",
- "bot_type": "openai"
+ "deepseek_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | `deepseek-chat`(DeepSeek-V3)、`deepseek-reasoner`(DeepSeek-R1) |
-| `bot_type` | 固定为 `openai`(OpenAI 兼容方式) |
-| `open_ai_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
-| `open_ai_api_base` | DeepSeek 平台 BASE URL |
+| `deepseek_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
+| `deepseek_api_base` | 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址 |
+
+方式二:OpenAI 兼容方式接入:
+
+```json
+{
+ "model": "deepseek-chat",
+ "bot_type": "openai",
+ "open_ai_api_key": "YOUR_API_KEY",
+ "open_ai_api_base": "https://api.deepseek.com/v1"
+}
+```
+
+
+ 已有用户如果之前通过 `open_ai_api_key` / `open_ai_api_base` 配置 DeepSeek,无需修改,程序会自动兼容。推荐新用户使用方式一的独立配置,避免与 OpenAI 配置冲突。
+
From cffa20d37eb74f3469beb2f683c4f4467a56499b Mon Sep 17 00:00:00 2001
From: 6vision
Date: Mon, 23 Mar 2026 22:39:15 +0800
Subject: [PATCH 018/399] docs(deepseek): remove migration notes to reduce user
cognitive load
Made-with: Cursor
---
README.md | 3 +--
docs/ja/models/deepseek.mdx | 4 ----
2 files changed, 1 insertion(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 99cb3d9d..fd74ef5e 100644
--- a/README.md
+++ b/README.md
@@ -498,8 +498,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
}
```
-> 注:已有用户如果之前通过 `open_ai_api_key` / `open_ai_api_base` 配置 DeepSeek,无需修改,程序会自动兼容。推荐新用户使用方式一的独立配置,避免与 OpenAI 配置冲突。
-
+
Azure
diff --git a/docs/ja/models/deepseek.mdx b/docs/ja/models/deepseek.mdx
index 200be8d4..af84dc0c 100644
--- a/docs/ja/models/deepseek.mdx
+++ b/docs/ja/models/deepseek.mdx
@@ -28,7 +28,3 @@ description: DeepSeekモデルの設定
"open_ai_api_base": "https://api.deepseek.com/v1"
}
```
-
-
- 既存ユーザーが `open_ai_api_key` / `open_ai_api_base` で DeepSeek を設定していた場合、変更は不要です。プログラムは自動的にフォールバックします。新規ユーザーは OpenAI 設定との競合を避けるため、方法1の使用を推奨します。
-
From c5b4f236db84e0c6ba123af1f609a8151d1f7049 Mon Sep 17 00:00:00 2001
From: 6vision
Date: Mon, 23 Mar 2026 22:46:44 +0800
Subject: [PATCH 019/399] docs(deepseek): remove migration notes from zh and en
docs
Made-with: Cursor
---
docs/en/models/deepseek.mdx | 3 ---
docs/models/deepseek.mdx | 3 ---
2 files changed, 6 deletions(-)
diff --git a/docs/en/models/deepseek.mdx b/docs/en/models/deepseek.mdx
index 8d76de8a..4163b150 100644
--- a/docs/en/models/deepseek.mdx
+++ b/docs/en/models/deepseek.mdx
@@ -29,6 +29,3 @@ Option 2: OpenAI-compatible configuration:
}
```
-
- Existing users who configured DeepSeek via `open_ai_api_key` / `open_ai_api_base` do not need to change anything — the program will automatically fall back to those fields. New users are recommended to use Option 1 to avoid config conflicts with OpenAI.
-
diff --git a/docs/models/deepseek.mdx b/docs/models/deepseek.mdx
index d62bac82..b94d9a19 100644
--- a/docs/models/deepseek.mdx
+++ b/docs/models/deepseek.mdx
@@ -29,6 +29,3 @@ description: DeepSeek 模型配置
}
```
-
- 已有用户如果之前通过 `open_ai_api_key` / `open_ai_api_base` 配置 DeepSeek,无需修改,程序会自动兼容。推荐新用户使用方式一的独立配置,避免与 OpenAI 配置冲突。
-
From 76dcb25103cc412253590c41b815301ed050423a Mon Sep 17 00:00:00 2001
From: 6vision
Date: Mon, 23 Mar 2026 23:27:34 +0800
Subject: [PATCH 020/399] docs(deepseek): update model descriptions to V3.2
with thinking/non-thinking mode
Made-with: Cursor
---
README.md | 2 +-
docs/en/models/deepseek.mdx | 3 ++-
docs/ja/models/deepseek.mdx | 2 +-
docs/models/deepseek.mdx | 2 +-
4 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index fd74ef5e..a5bfbb61 100644
--- a/README.md
+++ b/README.md
@@ -483,7 +483,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
}
```
- - `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3 和 DeepSeek-R1 模型
+ - `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3.2(非思考模式)和 DeepSeek-R1(思考模式)
- `deepseek_api_key`: DeepSeek平台的 API Key
- `deepseek_api_base`: 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址
diff --git a/docs/en/models/deepseek.mdx b/docs/en/models/deepseek.mdx
index 4163b150..ae765e3b 100644
--- a/docs/en/models/deepseek.mdx
+++ b/docs/en/models/deepseek.mdx
@@ -14,7 +14,7 @@ Option 1: Native integration (recommended):
| Parameter | Description |
| --- | --- |
-| `model` | `deepseek-chat` (DeepSeek-V3), `deepseek-reasoner` (DeepSeek-R1) |
+| `model` | `deepseek-chat` (DeepSeek-V3.2, non-thinking mode), `deepseek-reasoner` (DeepSeek-R1, thinking mode) |
| `deepseek_api_key` | Create at [DeepSeek Platform](https://platform.deepseek.com/api_keys) |
| `deepseek_api_base` | Optional, defaults to `https://api.deepseek.com/v1`. Can be changed to a third-party proxy |
@@ -29,3 +29,4 @@ Option 2: OpenAI-compatible configuration:
}
```
+
diff --git a/docs/ja/models/deepseek.mdx b/docs/ja/models/deepseek.mdx
index af84dc0c..168c459d 100644
--- a/docs/ja/models/deepseek.mdx
+++ b/docs/ja/models/deepseek.mdx
@@ -14,7 +14,7 @@ description: DeepSeekモデルの設定
| パラメータ | 説明 |
| --- | --- |
-| `model` | `deepseek-chat` (DeepSeek-V3)、`deepseek-reasoner` (DeepSeek-R1) |
+| `model` | `deepseek-chat`(DeepSeek-V3.2、非思考モード)、`deepseek-reasoner`(DeepSeek-R1、思考モード) |
| `deepseek_api_key` | [DeepSeek Platform](https://platform.deepseek.com/api_keys)で作成 |
| `deepseek_api_base` | オプション、デフォルトは `https://api.deepseek.com/v1`。サードパーティプロキシに変更可能 |
diff --git a/docs/models/deepseek.mdx b/docs/models/deepseek.mdx
index b94d9a19..e7d23e69 100644
--- a/docs/models/deepseek.mdx
+++ b/docs/models/deepseek.mdx
@@ -14,7 +14,7 @@ description: DeepSeek 模型配置
| 参数 | 说明 |
| --- | --- |
-| `model` | `deepseek-chat`(DeepSeek-V3)、`deepseek-reasoner`(DeepSeek-R1) |
+| `model` | `deepseek-chat`(DeepSeek-V3.2,非思考模式)、`deepseek-reasoner`(DeepSeek-R1,思考模式) |
| `deepseek_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
| `deepseek_api_base` | 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址 |
From 4c1c42efac85205d1fc8e4cde2e71a4ec2390523 Mon Sep 17 00:00:00 2001
From: yrk <2493404415@qq.com>
Date: Tue, 24 Mar 2026 10:43:45 +0800
Subject: [PATCH 021/399] feat: update modelscope bot
---
bridge/agent_bridge.py | 2 +
channel/web/web_channel.py | 7 +
common/const.py | 14 +
models/modelscope/modelscope_bot.py | 895 +++++++++++++++++++++++-----
4 files changed, 766 insertions(+), 152 deletions(-)
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py
index 81caad3c..1795c35f 100644
--- a/bridge/agent_bridge.py
+++ b/bridge/agent_bridge.py
@@ -115,6 +115,8 @@ class AgentLLMModel(LLMModel):
return const.QWEN_DASHSCOPE
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
return const.MOONSHOT
+ if conf().get("bot_type") == "modelscope":
+ return const.MODELSCOPE
for prefix, btype in self._MODEL_PREFIX_MAP:
if model_name.startswith(prefix):
return btype
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 2979cd9e..750a251a 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -568,6 +568,13 @@ class ConfigHandler:
"api_base_default": "https://api.deepseek.com/v1",
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
}),
+ ("modelscope", {
+ "label": "ModelScope",
+ "api_key_field": "modelscope_api_key",
+ "api_base_key": None,
+ "api_base_default": None,
+ "models": [const.Qwen3_5_27B, const.Qwen3_235B_A22B_Instruct_2507],
+ }),
("linkai", {
"label": "LinkAI",
"api_key_field": "linkai_api_key",
diff --git a/common/const.py b/common/const.py
index c6869c38..224c4d5e 100644
--- a/common/const.py
+++ b/common/const.py
@@ -124,6 +124,10 @@ DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
+# ModelScope(魔搭社区)
+Qwen3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
+Qwen3_5_27B = "Qwen/Qwen3.5-27B"
+
# 其他模型
WEN_XIN = "wenxin"
WEN_XIN_4 = "wenxin-4"
@@ -141,6 +145,16 @@ MODELSCOPE_MODEL_LIST = ["LLM-Research/c4ai-command-r-plus-08-2024","mistralai/M
"Qwen/Qwen2.5-14B-Instruct-1M","Qwen/Qwen2.5-7B-Instruct-1M","Qwen/Qwen2.5-VL-3B-Instruct","Qwen/Qwen2.5-VL-7B-Instruct","Qwen/Qwen2.5-VL-72B-Instruct","deepseek-ai/DeepSeek-R1-Distill-Llama-70B","deepseek-ai/DeepSeek-R1-Distill-Llama-8B","deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B","deepseek-ai/DeepSeek-R1-Distill-Qwen-7B","deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B","deepseek-ai/DeepSeek-R1","deepseek-ai/DeepSeek-V3","Qwen/QwQ-32B"]
+MODELSCOPE_MODEL_LIST = [
+ "deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
+ "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", "deepseek-ai/DeepSeek-V3.2", "LLM-Research/c4ai-command-r-plus-08-2024", "LLM-Research/Llama-4-Maverick-17B-128E-Instruct", "meituan-longcat/LongCat-Flash-Lite", "MiniMax/MiniMax-M1-80k", "MiniMax/MiniMax-M2.5", "mistralai/Ministral-8B-Instruct-2410",
+ "mistralai/Mistral-Large-Instruct-2407", "mistralai/Mistral-Small-Instruct-2409", "moonshotai/Kimi-K2.5", "MusePublic/Qwen-Image-Edit", "opencompass/CompassJudger-1-32B-Instruct", "OpenGVLab/InternVL3_5-241B-A28B",
+ "Qwen/QVQ-72B-Preview", "Qwen/Qwen-Image-Edit", "Qwen/Qwen3-0.6B", "Qwen/Qwen3-1.7B", "Qwen/Qwen3-14B", "Qwen/Qwen3-235B-A22B", "Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-235B-A22B-Thinking-2507", "Qwen/Qwen3-30B-A3B", "Qwen/Qwen3-30B-A3B-Thinking-2507",
+ "Qwen/Qwen3-32B", "Qwen/Qwen3-4B", "Qwen/Qwen3-8B", "Qwen/Qwen3-Coder-30B-A3B-Instruct", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-Next-80B-A3B-Instruct", "Qwen/Qwen3-Next-80B-A3B-Thinking", "Qwen/Qwen3-VL-235B-A22B-Instruct", "Qwen/Qwen3-VL-8B-Instruct",
+ "Qwen/Qwen3-VL-8B-Thinking", "Qwen/Qwen3.5-122B-A10B", "Qwen/Qwen3.5-27B", "Qwen/Qwen3.5-35B-A3B", "Qwen/Qwen3.5-397B-A17B", "Qwen/QwQ-32B", "Qwen/QwQ-32B-Preview", "Shanghai_AI_Laboratory/Intern-S1", "Shanghai_AI_Laboratory/Intern-S1-mini",
+ "stepfun-ai/Step-3.5-Flash", "XiaomiMiMo/MiMo-V2-Flash", "ZhipuAI/GLM-4.7-Flash", "ZhipuAI/GLM-5"]
+
+
MODEL_LIST = [
# Claude
CLAUDE3, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
diff --git a/models/modelscope/modelscope_bot.py b/models/modelscope/modelscope_bot.py
index e6d26fb9..6d55abce 100644
--- a/models/modelscope/modelscope_bot.py
+++ b/models/modelscope/modelscope_bot.py
@@ -1,8 +1,9 @@
# encoding:utf-8
-import time
import json
-import openai
+import time
+import requests
+
from models.bot import Bot
from models.session_manager import SessionManager
from bridge.context import ContextType
@@ -10,40 +11,40 @@ from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf, load_config
from .modelscope_session import ModelScopeSession
-import requests
-# ModelScope对话模型API
class ModelScopeBot(Bot):
+
def __init__(self):
super().__init__()
- self.sessions = SessionManager(ModelScopeSession, model=conf().get("model") or "Qwen/Qwen2.5-7B-Instruct")
- model = conf().get("model") or "Qwen/Qwen2.5-7B-Instruct"
+ model = conf().get("model") or "Qwen/Qwen3.5-27B"
if model == "modelscope":
- model = "Qwen/Qwen2.5-7B-Instruct"
+ model = "Qwen/Qwen3.5-27B"
+ self.sessions = SessionManager(ModelScopeSession, model=model)
self.args = {
- "model": model, # 对话模型的名称
- "temperature": conf().get("temperature", 0.3), # 如果设置,值域须为 [0, 1] 我们推荐 0.3,以达到较合适的效果。
- "top_p": conf().get("top_p", 1.0), # 使用默认值
+ "model": model,
+ "temperature": conf().get("temperature", 0.3),
+ "top_p": conf().get("top_p", 1.0),
}
+ self.api_key = conf().get("modelscope_api_key")
+ self.base_url = conf().get("modelscope_base_url", "https://api-inference.modelscope.cn/v1")
+ if self.base_url.endswith("/chat/completions"):
+ self.base_url = self.base_url.rsplit("/chat/completions", 1)[0]
+ if self.base_url.endswith("/"):
+ self.base_url = self.base_url.rstrip("/")
+
+ # Cache context for Agent mode usage
+ self._last_context = None
+
+ logger.info("[MODELSCOPE] base_url configured as: {}".format(self.base_url))
- @property
- def api_key(self):
- return conf().get("modelscope_api_key")
-
- @property
- def base_url(self):
- return conf().get("modelscope_base_url", "https://api-inference.modelscope.cn/v1/chat/completions")
- """
- 需要获取ModelScope支持API-inference的模型名称列表,请到魔搭社区官网模型中心查看 https://modelscope.cn/models?filter=inference_type&page=1。
- 或者使用命令 curl https://api-inference.modelscope.cn/v1/models 对模型列表和ID进行获取。查看commend/const.py文件也可以获取模型列表。
- 获取ModelScope的免费API Key,请到魔搭社区官网用户中心查看获取方式 https://modelscope.cn/docs/model-service/API-Inference/intro。
- """
def reply(self, query, context=None):
- # acquire reply content
+ # Cache context for Agent mode usage
+ self._last_context = context
+
if context.type == ContextType.TEXT:
- logger.info("[MODELSCOPE_AI] query={}".format(query))
-
+ logger.info("[MODELSCOPE] query={}".format(query))
+
session_id = context["session_id"]
reply = None
clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
@@ -56,113 +57,111 @@ class ModelScopeBot(Bot):
elif query == "#更新配置":
load_config()
reply = Reply(ReplyType.INFO, "配置已更新")
+
if reply:
return reply
session = self.sessions.session_query(query, session_id)
- logger.debug("[MODELSCOPE_AI] session query={}".format(session.messages))
+ logger.debug("[MODELSCOPE] session query={}".format(session.messages))
model = context.get("modelscope_model")
new_args = self.args.copy()
if model:
new_args["model"] = model
+
+ model_name = new_args["model"]
- if new_args["model"] == "Qwen/QwQ-32B":
+ # Unified judgment for thinking model
+ if self._is_thinking_model(model_name):
+ new_args["enable_thinking"] = True
reply_content = self.reply_text_stream(session, args=new_args)
else:
reply_content = self.reply_text(session, args=new_args)
-
+
logger.debug(
- "[MODELSCOPE_AI] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
+ "[MODELSCOPE] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
session.messages,
session_id,
reply_content["content"],
reply_content["completion_tokens"],
)
)
+
if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
- # 只有当 content 为空且 completion_tokens 为 0 时才标记为错误
- if len(reply_content["content"]) == 0:
- reply = Reply(ReplyType.ERROR, reply_content["content"])
- else:
- reply = Reply(ReplyType.TEXT, reply_content["content"])
+ reply = Reply(ReplyType.TEXT, reply_content["content"])
elif reply_content["completion_tokens"] > 0:
- self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"])
+ self.sessions.session_reply(
+ reply_content["content"],
+ session_id,
+ reply_content["total_tokens"]
+ )
reply = Reply(ReplyType.TEXT, reply_content["content"])
else:
reply = Reply(ReplyType.ERROR, reply_content["content"])
- logger.debug("[MODELSCOPE_AI] reply {} used 0 tokens.".format(reply_content))
+ logger.debug("[MODELSCOPE] reply {} used 0 tokens.".format(reply_content))
+
return reply
+
elif context.type == ContextType.IMAGE_CREATE:
ok, retstring = self.create_img(query, 0)
- reply = None
- if ok:
- reply = Reply(ReplyType.IMAGE_URL, retstring)
- else:
- reply = Reply(ReplyType.ERROR, retstring)
- return reply
+ return Reply(ReplyType.IMAGE_URL, retstring) if ok else Reply(ReplyType.ERROR, retstring)
else:
- reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
- return reply
+ return Reply(ReplyType.ERROR, "Bot 不支持处理{}类型的消息".format(context.type))
- def reply_text(self, session: ModelScopeSession, args=None, retry_count=0) -> dict:
- """
- call openai's ChatCompletion to get the answer
- :param session: a conversation session
- :param session_id: session id
- :param retry_count: retry count
- :return: {}
- """
+ def reply_text(self, session, args=None, retry_count=0):
try:
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + self.api_key
}
- body = args
- body["messages"] = session.messages
+ body = args.copy() if args else {}
+ body["messages"] = self._convert_messages_for_modelscope(session.messages)
+ body["stream"] = False
+
res = requests.post(
- self.base_url,
+ "{}/chat/completions".format(self.base_url),
headers=headers,
- data=json.dumps(body)
+ json=body,
+ timeout=120
)
-
+
if res.status_code == 200:
response = res.json()
return {
- "total_tokens": response["usage"]["total_tokens"],
- "completion_tokens": response["usage"]["completion_tokens"],
- "content": response["choices"][0]["message"]["content"]
+ "total_tokens": response.get("usage", {}).get("total_tokens", 0),
+ "completion_tokens": response.get("usage", {}).get("completion_tokens", 0),
+ "content": response["choices"][0]["message"]["content"] if response.get("choices") else ""
}
else:
response = res.json()
- if "errors" in response:
- error = response.get("errors")
- elif "error" in response:
- error = response.get("error")
- else:
- error = "Unknown error"
- logger.error(f"[MODELSCOPE_AI] chat failed, status_code={res.status_code}, "
- f"msg={error.get('message')}, type={error.get('type')}")
-
+ error = response.get("error", response.get("errors", {}))
+ logger.error(
+ "[MODELSCOPE] chat failed, status_code={}, msg={}".format(
+ res.status_code,
+ error.get('message') if isinstance(error, dict) else error
+ )
+ )
+
result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
need_retry = False
+
if res.status_code >= 500:
- # server error, need retry
- logger.warn(f"[MODELSCOPE_AI] do retry, times={retry_count}")
+ logger.warn("[MODELSCOPE] do retry, times={}".format(retry_count))
need_retry = retry_count < 2
elif res.status_code == 401:
- result["content"] = "授权失败,请检查API Key是否正确"
+ result["content"] = "授权失败,请检查 API Key 是否正确"
elif res.status_code == 429:
result["content"] = "请求过于频繁,请稍后再试"
need_retry = retry_count < 2
else:
need_retry = False
-
+
if need_retry:
time.sleep(3)
return self.reply_text(session, args, retry_count + 1)
else:
return result
+
except Exception as e:
logger.exception(e)
need_retry = retry_count < 2
@@ -172,111 +171,703 @@ class ModelScopeBot(Bot):
else:
return result
- def reply_text_stream(self, session: ModelScopeSession, args=None, retry_count=0) -> dict:
- """
- call ModelScope's ChatCompletion to get the answer with stream response
- :param session: a conversation session
- :param session_id: session id
- :param retry_count: retry count
- :return: {}
- """
+ def reply_text_stream(self, session, args=None):
try:
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + self.api_key
}
- body = args
- body["messages"] = session.messages
- body["stream"] = True # 启用流式响应
+ body = args.copy() if args else {}
+ body["messages"] = self._convert_messages_for_modelscope(session.messages)
+ body["stream"] = True
res = requests.post(
- self.base_url,
+ "{}/chat/completions".format(self.base_url),
headers=headers,
- data=json.dumps(body),
- stream=True
+ json=body,
+ stream=True,
+ timeout=120
)
if res.status_code == 200:
content = ""
+ total_tokens = completion_tokens = 0
+ finish_reason = None
+
for line in res.iter_lines():
- if line:
- decoded_line = line.decode('utf-8')
- if decoded_line.startswith("data: "):
- try:
- json_data = json.loads(decoded_line[6:])
- delta_content = json_data.get("choices", [{}])[0].get("delta", {}).get("content", "")
- if delta_content:
- content += delta_content
- except json.JSONDecodeError as e:
- pass
+ if not line:
+ continue
+
+ decoded_line = line.decode('utf-8')
+ if not decoded_line.startswith("data: "):
+ continue
+
+ data_str = decoded_line[6:]
+ if data_str.strip() == "[DONE]":
+ break
+
+ try:
+ json_data = json.loads(data_str)
+
+ if "usage" in json_data:
+ total_tokens = json_data["usage"].get("total_tokens", 0)
+ completion_tokens = json_data["usage"].get("completion_tokens", 0)
+
+ delta = json_data.get("choices", [{}])[0].get("delta", {})
+ if delta and delta.get("content"):
+ content += delta["content"]
+
+ choice = json_data.get("choices", [{}])[0]
+ if choice.get("finish_reason"):
+ finish_reason = choice["finish_reason"]
+
+ except json.JSONDecodeError:
+ continue
+
+ if finish_reason is None and content:
+ finish_reason = "stop"
+
return {
- "total_tokens": 1, # 流式响应通常不返回token使用情况
- "completion_tokens": 1,
+ "total_tokens": total_tokens,
+ "completion_tokens": completion_tokens,
"content": content
}
else:
- response = res.json()
- if "errors" in response:
- error = response.get("errors")
- elif "error" in response:
- error = response.get("error")
- else:
- error = "Unknown error"
- logger.error(f"[MODELSCOPE_AI] chat failed, status_code={res.status_code}, "
- f"msg={error.get('message')}, type={error.get('type')}")
-
- result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
- need_retry = False
- if res.status_code >= 500:
- # server error, need retry
- logger.warn(f"[MODELSCOPE_AI] do retry, times={retry_count}")
- need_retry = retry_count < 2
- elif res.status_code == 401:
- result["content"] = "授权失败,请检查API Key是否正确"
- elif res.status_code == 429:
- result["content"] = "请求过于频繁,请稍后再试"
- need_retry = retry_count < 2
- else:
- need_retry = False
-
- if need_retry:
- time.sleep(3)
- return self.reply_text_stream(session, args, retry_count + 1)
- else:
- return result
+ return {"completion_tokens": 0, "content": "请求失败"}
+
except Exception as e:
logger.exception(e)
- need_retry = retry_count < 2
- result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
- if need_retry:
- return self.reply_text_stream(session, args, retry_count + 1)
- else:
- return result
- def create_img(self, query, retry_count=0):
+ return {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
+
+ def create_img(self, query):
try:
logger.info("[ModelScopeImage] image_query={}".format(query))
- headers = {
- "Content-Type": "application/json; charset=utf-8", # 明确指定编码
- "Authorization": f"Bearer {self.api_key}"
+
+ create_headers = {
+ "Authorization": "Bearer " + self.api_key,
+ "Content-Type": "application/json; charset=utf-8",
+ "X-ModelScope-Async-Mode": "true"
}
+
payload = {
- "prompt": query, # required
- "n": 1,
"model": conf().get("text_to_image"),
+ "prompt": query,
+ "n": 1,
}
- url = "https://api-inference.modelscope.cn/v1/images/generations"
- # 手动序列化并保留中文(禁用 ASCII 转义)
- json_payload = json.dumps(payload, ensure_ascii=False).encode('utf-8')
+ logger.debug("[ModelScopeImage] model={}".format(payload["model"]))
- # 使用 data 参数发送原始字符串(requests 会自动处理编码)
- res = requests.post(url, headers=headers, data=json_payload)
+ res = requests.post(
+ "{}/images/generations".format(self.base_url),
+ headers=create_headers,
+ data=json.dumps(payload, ensure_ascii=False).encode('utf-8'),
+ timeout=120
+ )
+
+ logger.debug("[ModelScopeImage] create task status={}".format(res.status_code))
+ logger.debug("[ModelScopeImage] create task response={}".format(res.text))
+
+ if res.status_code != 200:
+ logger.error("[ModelScopeImage] create task failed: {}".format(res.text))
+ return False, "创建画图任务失败:{}".format(res.status_code)
+
+ task_data = res.json()
+
+ task_id = task_data.get("task_id")
+ if not task_id:
+ logger.error("[ModelScopeImage] No task_id in response: {}".format(task_data))
+ return False, "创建画图任务失败:未返回 task_id"
+
+ logger.info("[ModelScopeImage] task_id={}".format(task_id))
+
+ max_wait_times = 60
+ wait_interval = 5
+
+ for i in range(max_wait_times):
+ time.sleep(wait_interval)
+
+ poll_headers = {
+ "Authorization": "Bearer " + self.api_key,
+ "X-ModelScope-Task-Type": "image_generation"
+ }
+
+ poll_url = "{}/tasks/{}".format(self.base_url, task_id)
+ logger.debug("[ModelScopeImage] poll {} URL: {}".format(i+1, poll_url))
+ logger.debug("[ModelScopeImage] poll headers: {}".format(poll_headers))
+
+ task_res = requests.get(
+ poll_url,
+ headers=poll_headers,
+ timeout=30
+ )
+
+ logger.debug("[ModelScopeImage] poll {} status={}".format(i+1, task_res.status_code))
+ logger.debug("[ModelScopeImage] poll response={}".format(task_res.text))
+
+ if task_res.status_code != 200:
+ logger.error("[ModelScopeImage] poll task error: {}".format(task_res.text))
+ continue
+
+ data = task_res.json()
+
+ task_status = data.get("task_status")
+ logger.debug("[ModelScopeImage] task_status={}".format(task_status))
+
+ if task_status == "SUCCEED":
+ output_images = data.get("output_images", [])
+ if output_images and len(output_images) > 0:
+ image_url = output_images[0]
+ logger.info("[ModelScopeImage] image generated successfully: {}".format(image_url))
+ return True, image_url
+ else:
+ logger.error("[ModelScopeImage] No output_images in success response: {}".format(data))
+ return False, "画图成功但未返回图片 URL"
+
+ elif task_status == "FAILED":
+ error_msg = "未知错误"
+ if "errors" in data:
+ error_msg = data["errors"].get("message", "未知错误")
+ elif "message" in data:
+ error_msg = data["message"]
+ logger.error("[ModelScopeImage] task failed: {}".format(data))
+ return False, "画图任务失败:{}".format(error_msg)
+
+ elif task_status == "CANCELED":
+ logger.error("[ModelScopeImage] task canceled: {}".format(data))
+ return False, "画图任务已取消"
+
+ logger.debug("[ModelScopeImage] waiting for task to complete...")
+
+ logger.error("[ModelScopeImage] task timeout after {} seconds".format(max_wait_times * wait_interval))
+ return False, "画图超时,请稍后再试"
- response_data = res.json()
- image_url = response_data['images'][0]['url']
- logger.info("[ModelScopeImage] image_url={}".format(image_url))
- return True, image_url
-
except Exception as e:
- logger.error(format(e))
- return False, "画图出现问题,请休息一下再问我吧"
\ No newline at end of file
+ logger.error("[ModelScopeImage] error: {}".format(format(e)))
+ return False, "画图出现问题,请休息一下再问我吧"
+
+ # ==================== Agent Mode Support ====================
+
+ def _detect_image_intent(self, message):
+ """Detect whether the message has drawing intention (keyword detection)"""
+ if not message:
+ return False
+
+ message_lower = message.lower()
+ image_keywords = ["画", "图片", "图像", "生成图", "photo", "image", "draw", "paint", "generate"]
+ if any(keyword in message_lower for keyword in image_keywords):
+ logger.info("[MODELSCOPE] Image intent detected by keyword: {}".format(message[:50]))
+ return True
+
+ return False
+
+ def _is_thinking_model(self, model_name):
+ """
+ Determine whether it is a thinking model.
+ A thinking model requires: 1) enabling the enable_thinking parameter, and 2) using streaming responses.
+ """
+ if not model_name:
+ return False
+ model_name_lower = model_name.lower()
+ if "thinking" in model_name_lower or "think" in model_name_lower:
+ return True
+ if model_name in ["Qwen/QwQ-32B", ]:
+ return True
+ return False
+
+ def call_with_tools(self, messages, tools=None, stream=False, **kwargs):
+ """
+ Call ModelScope API with tool call support.
+ Also check ContextType and keywords; if either matches, trigger drawing.
+ """
+ try:
+ # Check the IMAGE_CREATE type from the cached context
+ context = getattr(self, '_last_context', None)
+
+ # If the context type is IMAGE_CREATE, directly call create_img
+ if context and hasattr(context, 'type') and context.type == ContextType.IMAGE_CREATE:
+ logger.info("[MODELSCOPE] IMAGE_CREATE context detected, calling create_img directly")
+ query = getattr(context, 'content', '')
+ if query:
+ ok, result = self.create_img(query)
+ if ok:
+ logger.info("[MODELSCOPE] Image generated: {}".format(result))
+ if stream:
+ return self._create_image_stream_response(result)
+ else:
+ return self._create_image_response(result)
+ else:
+ logger.error("[MODELSCOPE] Image generation failed: {}".format(result))
+ error_content = "画图失败:{}".format(result)
+ if stream:
+ return self._create_error_stream_response(error_content)
+ else:
+ return self._create_error_response(error_content)
+
+ # Extract message content
+ last_message = ""
+ if messages and len(messages) > 0:
+ last_msg = messages[-1]
+ if isinstance(last_msg, dict):
+ content = last_msg.get("content", "")
+ if isinstance(content, list):
+ text_parts = []
+ for block in content:
+ if isinstance(block, dict):
+ if block.get("type") == "text":
+ text_parts.append(block.get("text", ""))
+ last_message = " ".join(text_parts)
+ else:
+ last_message = content
+ elif isinstance(last_msg, str):
+ last_message = last_msg
+
+ if not isinstance(last_message, str):
+ last_message = str(last_message)
+
+ logger.debug("[MODELSCOPE] Extracted message: {}".format(last_message[:100]))
+
+ # Keyword detection
+ has_image_intent = self._detect_image_intent(last_message)
+
+ if has_image_intent:
+ logger.info("[MODELSCOPE] Image intent detected by keyword, calling create_img directly")
+ ok, result = self.create_img(last_message)
+ if ok:
+ logger.info("[MODELSCOPE] Image generated: {}".format(result))
+ if stream:
+ return self._create_image_stream_response(result)
+ else:
+ return self._create_image_response(result)
+ else:
+ logger.error("[MODELSCOPE] Image generation failed: {}".format(result))
+ error_content = "画图失败:{}".format(result)
+ if stream:
+ return self._create_error_stream_response(error_content)
+ else:
+ return self._create_error_response(error_content)
+
+ # No drawing intent, proceed with normal tool call flow
+ session_id = kwargs.get('session_id', 'default_session')
+ session = self.sessions.session_query("", session_id)
+ session.messages = messages
+
+ args = self.args.copy()
+ args.update(kwargs)
+
+ # Unified judgment for thinking model
+ model_name = args.get("model", self.args.get("model", ""))
+ if self._is_thinking_model(model_name):
+ args["enable_thinking"] = True
+
+ if tools:
+ args["tools"] = self._convert_tools_to_openai_format(tools)
+ args["tool_choice"] = "auto"
+
+ logger.debug(
+ "[MODELSCOPE] call_with_tools: model={}, tools={}, stream={}, enable_thinking={}".format(
+ args.get('model'),
+ len(tools) if tools else 0,
+ stream,
+ args.get('enable_thinking')
+ )
+ )
+
+ if stream:
+ return self._handle_stream_response(session, args)
+ else:
+ return self._handle_sync_response(session, args)
+
+ except Exception as e:
+ logger.error("[MODELSCOPE] call_with_tools error: {}".format(e))
+ error_msg = "{}".format(e)
+ def error_generator():
+ yield {"error": True, "message": error_msg, "status_code": 500}
+ return error_generator()
+
+ def _handle_sync_response(self, session, args):
+ result = self.reply_text(session, args)
+
+ content = result.get("content", "")
+ tool_calls = result.get("tool_calls")
+
+ if tool_calls:
+ for tool_call in tool_calls:
+ tool_name = tool_call.get("function", {}).get("name", "")
+ if tool_name in ["create_image", "generate_image"]:
+ try:
+ tool_args = json.loads(tool_call.get("function", {}).get("arguments", "{}"))
+ prompt = tool_args.get("prompt", "")
+ ok, image_url = self.create_img(prompt)
+ if ok:
+ result["tool_execution_result"] = {"image_url": image_url, "success": True}
+ else:
+ result["tool_execution_result"] = {"error": image_url, "success": False}
+ except Exception as e:
+ logger.error("[MODELSCOPE] Sync tool execution error: {}".format(e))
+
+ return {
+ "choices": [{
+ "message": {
+ "role": "assistant",
+ "content": content,
+ "tool_calls": tool_calls
+ },
+ "finish_reason": "stop"
+ }],
+ "usage": {
+ "prompt_tokens": 0,
+ "completion_tokens": result.get("completion_tokens", 0),
+ "total_tokens": result.get("total_tokens", 0)
+ },
+ "model": args.get("model", self.args.get("model"))
+ }
+
+ def _handle_stream_response(self, session, args):
+ try:
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + self.api_key
+ }
+
+ body = args.copy()
+ body["messages"] = self._convert_messages_for_modelscope(session.messages)
+ body["stream"] = True
+
+ response = requests.post(
+ "{}/chat/completions".format(self.base_url),
+ headers=headers,
+ json=body,
+ stream=True,
+ timeout=120
+ )
+
+ if response.status_code != 200:
+ yield {"error": True, "message": response.text, "status_code": response.status_code}
+ return
+
+ current_tool_calls = {}
+ finish_reason = None
+
+ for line in response.iter_lines():
+ if not line:
+ continue
+
+ line = line.decode("utf-8")
+ if not line.startswith("data: ") or line[6:].strip() == "[DONE]":
+ continue
+
+ try:
+ chunk = json.loads(line[6:])
+
+ if chunk.get("error"):
+ yield {"error": True, "message": str(chunk["error"]), "status_code": 500}
+ return
+
+ choices = chunk.get("choices")
+ if not choices or len(choices) == 0:
+ continue
+
+ choice = choices[0]
+ if not choice:
+ continue
+
+ delta = choice.get("delta")
+ if not delta:
+ continue
+
+ if delta.get("reasoning_content"):
+ continue
+
+ tool_call_chunks = delta.get("tool_calls")
+ if tool_call_chunks:
+ cleaned_chunks = []
+ for tool_call_chunk in tool_call_chunks:
+ if not tool_call_chunk:
+ continue
+
+ index = tool_call_chunk.get("index", 0)
+ func_info = tool_call_chunk.get("function") or {}
+
+ if index not in current_tool_calls:
+ current_tool_calls[index] = {
+ "id": tool_call_chunk.get("id") or "",
+ "name": func_info.get("name") or "",
+ "arguments": ""
+ }
+ logger.debug("[MODELSCOPE] tool_call start: {}".format(func_info.get('name')))
+
+ args_str = func_info.get("arguments")
+ if args_str:
+ current_tool_calls[index]["arguments"] += (
+ args_str if isinstance(args_str, str) else str(args_str)
+ )
+
+ cleaned_chunk = {
+ "index": index,
+ "id": tool_call_chunk.get("id") or "call_{}".format(index),
+ "type": "function",
+ "function": {
+ "name": func_info.get("name") or current_tool_calls[index].get("name", ""),
+ "arguments": func_info.get("arguments") or ""
+ }
+ }
+ cleaned_chunks.append(cleaned_chunk)
+
+ if cleaned_chunks:
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "assistant",
+ "tool_calls": cleaned_chunks
+ }
+ }]
+ }
+ continue
+
+ content = delta.get("content")
+ if content:
+ logger.debug("[MODELSCOPE] stream content: {}...".format(content[:50]))
+
+ yield_chunk = {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": delta.get("role"),
+ "content": content
+ }
+ }]
+ }
+
+ if choice.get("finish_reason"):
+ finish_reason = choice["finish_reason"]
+ yield_chunk["choices"][0]["finish_reason"] = finish_reason
+
+ yield yield_chunk
+
+ except json.JSONDecodeError:
+ continue
+ except Exception as e:
+ logger.error("[MODELSCOPE] chunk process error: {}".format(e))
+ continue
+
+ logger.debug(
+ "[MODELSCOPE] stream completed: has_tool_calls={}, finish_reason={}".format(
+ len(current_tool_calls) > 0,
+ finish_reason
+ )
+ )
+
+ if current_tool_calls:
+ logger.debug("[MODELSCOPE] tool_calls collected: {}".format(list(current_tool_calls.values())))
+
+ for idx, tool_call in current_tool_calls.items():
+ tool_name = tool_call.get("name", "")
+ tool_args_str = tool_call.get("arguments", "{}")
+
+ if tool_name in ["create_image", "generate_image"]:
+ try:
+ tool_args = json.loads(tool_args_str) if tool_args_str else {}
+ prompt = tool_args.get("prompt", "")
+
+ logger.info("[MODELSCOPE] Executing image tool directly: {}".format(prompt[:50]))
+
+ ok, result = self.create_img(prompt)
+
+ if ok:
+ logger.info("[MODELSCOPE] Image generated: {}".format(result))
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "tool",
+ "content": json.dumps({"image_url": result, "success": True})
+ },
+ "tool_call_id": tool_call.get("id", "")
+ }]
+ }
+ else:
+ logger.error("[MODELSCOPE] Image generation failed: {}".format(result))
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "tool",
+ "content": json.dumps({"error": result, "success": False})
+ },
+ "tool_call_id": tool_call.get("id", "")
+ }]
+ }
+ except Exception as e:
+ logger.error("[MODELSCOPE] Image tool execution error: {}".format(e))
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "tool",
+ "content": json.dumps({"error": str(e), "success": False})
+ },
+ "tool_call_id": tool_call.get("id", "")
+ }]
+ }
+
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {},
+ "finish_reason": finish_reason or "stop"
+ }]
+ }
+
+ except Exception as e:
+ logger.error("[MODELSCOPE] stream tool call error: {}".format(e))
+ error_msg = "{}".format(e)
+ def error_generator():
+ yield {"error": True, "message": error_msg, "status_code": 500}
+ return error_generator()
+
+ # ==================== Format Conversion ====================
+
+ def _convert_messages_for_modelscope(self, messages):
+ if not messages:
+ return []
+ converted = []
+ for msg in messages:
+ role = msg.get("role")
+ content = msg.get("content")
+ if isinstance(content, str):
+ converted.append(msg)
+ continue
+ if isinstance(content, list):
+ new_content = []
+ for block in content:
+ if not isinstance(block, dict):
+ new_content.append(block)
+ continue
+ block_type = block.get("type")
+ if block_type == "tool_result":
+ tool_content = block.get("content", "")
+ if not isinstance(tool_content, str):
+ tool_content = json.dumps(tool_content, ensure_ascii=False)
+ new_content.append({
+ "type": "text",
+ "text": "[工具执行结果]: {}".format(tool_content)
+ })
+ elif block_type == "tool_use":
+ tool_name = block.get("name", "unknown")
+ tool_input = block.get("input", {})
+ if not isinstance(tool_input, str):
+ tool_input = json.dumps(tool_input, ensure_ascii=False)
+ new_content.append({
+ "type": "text",
+ "text": "[工具调用]: {}({})".format(tool_name, tool_input)
+ })
+ else:
+ new_content.append(block)
+ converted.append({"role": role, "content": new_content})
+ else:
+ converted.append(msg)
+ return converted
+
+ def _convert_tools_to_openai_format(self, tools):
+ if not tools:
+ return None
+ converted = []
+ for tool in tools:
+ if "type" in tool and tool["type"] == "function":
+ converted.append(tool)
+ else:
+ converted.append({
+ "type": "function",
+ "function": {
+ "name": tool.get("name"),
+ "description": tool.get("description"),
+ "parameters": tool.get("input_schema", {})
+ }
+ })
+ return converted
+
+ def _create_image_response(self, image_url):
+ return {
+ "choices": [{
+ "message": {
+ "role": "assistant",
+ "content": "已为您生成图片:{}".format(image_url),
+ "tool_calls": None
+ },
+ "finish_reason": "stop"
+ }],
+ "usage": {
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "total_tokens": 0
+ },
+ "model": self.args.get("model")
+ }
+
+ def _create_image_stream_response(self, image_url):
+ content = "已为您生成图片:{}".format(image_url)
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {"role": "assistant"}
+ }]
+ }
+ chunk_size = 10
+ for i in range(0, len(content), chunk_size):
+ chunk = content[i:i+chunk_size]
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {"content": chunk}
+ }]
+ }
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {},
+ "finish_reason": "stop"
+ }]
+ }
+
+ def _create_error_response(self, error_msg):
+ return {
+ "choices": [{
+ "message": {
+ "role": "assistant",
+ "content": error_msg,
+ "tool_calls": None
+ },
+ "finish_reason": "stop"
+ }],
+ "usage": {
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "total_tokens": 0
+ },
+ "model": self.args.get("model")
+ }
+
+ def _create_error_stream_response(self, error_msg):
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {"role": "assistant"}
+ }]
+ }
+ chunk_size = 10
+ for i in range(0, len(error_msg), chunk_size):
+ chunk = error_msg[i:i+chunk_size]
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {"content": chunk}
+ }]
+ }
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {},
+ "finish_reason": "stop"
+ }]
+ }
From 294e3802889310def0df5de8a2659bc41396dfb3 Mon Sep 17 00:00:00 2001
From: yrk <2493404415@qq.com>
Date: Tue, 24 Mar 2026 11:00:55 +0800
Subject: [PATCH 022/399] update model_list
---
channel/web/web_channel.py | 2 +-
common/const.py | 25 +++++++++----------------
2 files changed, 10 insertions(+), 17 deletions(-)
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 750a251a..2502042d 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -573,7 +573,7 @@ class ConfigHandler:
"api_key_field": "modelscope_api_key",
"api_base_key": None,
"api_base_default": None,
- "models": [const.Qwen3_5_27B, const.Qwen3_235B_A22B_Instruct_2507],
+ "models": [const.QWEN3_5_27B, const.QWEN3_235B_A22B_INSTRUCT_2507],
}),
("linkai", {
"label": "LinkAI",
diff --git a/common/const.py b/common/const.py
index 224c4d5e..6c422b8e 100644
--- a/common/const.py
+++ b/common/const.py
@@ -125,8 +125,8 @@ DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
# ModelScope(魔搭社区)
-Qwen3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
-Qwen3_5_27B = "Qwen/Qwen3.5-27B"
+QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
+QWEN3_5_27B = "Qwen/Qwen3.5-27B"
# 其他模型
WEN_XIN = "wenxin"
@@ -139,20 +139,13 @@ MODELSCOPE = "modelscope"
GITEE_AI_MODEL_LIST = ["Yi-34B-Chat", "InternVL2-8B", "deepseek-coder-33B-instruct", "InternVL2.5-26B", "Qwen2-VL-72B", "Qwen2.5-32B-Instruct", "glm-4-9b-chat", "codegeex4-all-9b", "Qwen2.5-Coder-32B-Instruct", "Qwen2.5-72B-Instruct", "Qwen2.5-7B-Instruct", "Qwen2-72B-Instruct", "Qwen2-7B-Instruct", "code-raccoon-v1", "Qwen2.5-14B-Instruct"]
-MODELSCOPE_MODEL_LIST = ["LLM-Research/c4ai-command-r-plus-08-2024","mistralai/Mistral-Small-Instruct-2409","mistralai/Ministral-8B-Instruct-2410","mistralai/Mistral-Large-Instruct-2407",
- "Qwen/Qwen2.5-Coder-32B-Instruct","Qwen/Qwen2.5-Coder-14B-Instruct","Qwen/Qwen2.5-Coder-7B-Instruct","Qwen/Qwen2.5-72B-Instruct","Qwen/Qwen2.5-32B-Instruct","Qwen/Qwen2.5-14B-Instruct","Qwen/Qwen2.5-7B-Instruct","Qwen/QwQ-32B-Preview",
- "LLM-Research/Llama-3.3-70B-Instruct","opencompass/CompassJudger-1-32B-Instruct","Qwen/QVQ-72B-Preview","LLM-Research/Meta-Llama-3.1-405B-Instruct","LLM-Research/Meta-Llama-3.1-8B-Instruct","Qwen/Qwen2-VL-7B-Instruct","LLM-Research/Meta-Llama-3.1-70B-Instruct",
- "Qwen/Qwen2.5-14B-Instruct-1M","Qwen/Qwen2.5-7B-Instruct-1M","Qwen/Qwen2.5-VL-3B-Instruct","Qwen/Qwen2.5-VL-7B-Instruct","Qwen/Qwen2.5-VL-72B-Instruct","deepseek-ai/DeepSeek-R1-Distill-Llama-70B","deepseek-ai/DeepSeek-R1-Distill-Llama-8B","deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
- "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B","deepseek-ai/DeepSeek-R1-Distill-Qwen-7B","deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B","deepseek-ai/DeepSeek-R1","deepseek-ai/DeepSeek-V3","Qwen/QwQ-32B"]
-
-MODELSCOPE_MODEL_LIST = [
- "deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
- "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", "deepseek-ai/DeepSeek-V3.2", "LLM-Research/c4ai-command-r-plus-08-2024", "LLM-Research/Llama-4-Maverick-17B-128E-Instruct", "meituan-longcat/LongCat-Flash-Lite", "MiniMax/MiniMax-M1-80k", "MiniMax/MiniMax-M2.5", "mistralai/Ministral-8B-Instruct-2410",
- "mistralai/Mistral-Large-Instruct-2407", "mistralai/Mistral-Small-Instruct-2409", "moonshotai/Kimi-K2.5", "MusePublic/Qwen-Image-Edit", "opencompass/CompassJudger-1-32B-Instruct", "OpenGVLab/InternVL3_5-241B-A28B",
- "Qwen/QVQ-72B-Preview", "Qwen/Qwen-Image-Edit", "Qwen/Qwen3-0.6B", "Qwen/Qwen3-1.7B", "Qwen/Qwen3-14B", "Qwen/Qwen3-235B-A22B", "Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-235B-A22B-Thinking-2507", "Qwen/Qwen3-30B-A3B", "Qwen/Qwen3-30B-A3B-Thinking-2507",
- "Qwen/Qwen3-32B", "Qwen/Qwen3-4B", "Qwen/Qwen3-8B", "Qwen/Qwen3-Coder-30B-A3B-Instruct", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-Next-80B-A3B-Instruct", "Qwen/Qwen3-Next-80B-A3B-Thinking", "Qwen/Qwen3-VL-235B-A22B-Instruct", "Qwen/Qwen3-VL-8B-Instruct",
- "Qwen/Qwen3-VL-8B-Thinking", "Qwen/Qwen3.5-122B-A10B", "Qwen/Qwen3.5-27B", "Qwen/Qwen3.5-35B-A3B", "Qwen/Qwen3.5-397B-A17B", "Qwen/QwQ-32B", "Qwen/QwQ-32B-Preview", "Shanghai_AI_Laboratory/Intern-S1", "Shanghai_AI_Laboratory/Intern-S1-mini",
- "stepfun-ai/Step-3.5-Flash", "XiaomiMiMo/MiMo-V2-Flash", "ZhipuAI/GLM-4.7-Flash", "ZhipuAI/GLM-5"]
+MODELSCOPE_MODEL_LIST = ["deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", "deepseek-ai/DeepSeek-R1-Distill-Llama-8B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
+ "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", "deepseek-ai/DeepSeek-V3.2", "LLM-Research/c4ai-command-r-plus-08-2024", "LLM-Research/Llama-4-Maverick-17B-128E-Instruct", "meituan-longcat/LongCat-Flash-Lite", "MiniMax/MiniMax-M1-80k", "MiniMax/MiniMax-M2.5", "mistralai/Ministral-8B-Instruct-2410",
+ "mistralai/Mistral-Large-Instruct-2407", "mistralai/Mistral-Small-Instruct-2409", "moonshotai/Kimi-K2.5", "MusePublic/Qwen-Image-Edit", "opencompass/CompassJudger-1-32B-Instruct", "OpenGVLab/InternVL3_5-241B-A28B",
+ "Qwen/QVQ-72B-Preview", "Qwen/Qwen-Image-Edit", "Qwen/Qwen3-0.6B", "Qwen/Qwen3-1.7B", "Qwen/Qwen3-14B", "Qwen/Qwen3-235B-A22B", "Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-235B-A22B-Thinking-2507", "Qwen/Qwen3-30B-A3B", "Qwen/Qwen3-30B-A3B-Thinking-2507",
+ "Qwen/Qwen3-32B", "Qwen/Qwen3-4B", "Qwen/Qwen3-8B", "Qwen/Qwen3-Coder-30B-A3B-Instruct", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "Qwen/Qwen3-Next-80B-A3B-Instruct", "Qwen/Qwen3-Next-80B-A3B-Thinking", "Qwen/Qwen3-VL-235B-A22B-Instruct", "Qwen/Qwen3-VL-8B-Instruct",
+ "Qwen/Qwen3-VL-8B-Thinking", "Qwen/Qwen3.5-122B-A10B", "Qwen/Qwen3.5-27B", "Qwen/Qwen3.5-35B-A3B", "Qwen/Qwen3.5-397B-A17B", "Qwen/QwQ-32B", "Qwen/QwQ-32B-Preview", "Shanghai_AI_Laboratory/Intern-S1", "Shanghai_AI_Laboratory/Intern-S1-mini",
+ "stepfun-ai/Step-3.5-Flash", "XiaomiMiMo/MiMo-V2-Flash", "ZhipuAI/GLM-4.7-Flash", "ZhipuAI/GLM-5"]
MODEL_LIST = [
From 393f0c007cdd20513749f7bc34a5a1cb3cb89f5f Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 24 Mar 2026 20:49:28 +0800
Subject: [PATCH 023/399] fix: context loss after trim
---
agent/chat/service.py | 52 ++++++++++++++++++++++++++++++++++++++++---
1 file changed, 49 insertions(+), 3 deletions(-)
diff --git a/agent/chat/service.py b/agent/chat/service.py
index d3712dbf..1afe1691 100644
--- a/agent/chat/service.py
+++ b/agent/chat/service.py
@@ -166,10 +166,56 @@ class ChatService:
logger.info("[ChatService] Cleared agent message history after executor recovery")
raise
- # Append only the NEW messages from this execution (thread-safe)
+ # Sync executor messages back to agent (thread-safe).
+ # The executor may have trimmed context, making its list shorter than
+ # original_length. In that case we must replace entirely — just
+ # appending would leave stale pre-trim messages in agent.messages
+ # and cause the same trim to fire on every subsequent request.
with agent.messages_lock:
- new_messages = executor.messages[original_length:]
- agent.messages.extend(new_messages)
+ trimmed = len(executor.messages) < original_length
+ if trimmed:
+ # Context was trimmed: the executor appended the new user
+ # query *before* trimming, so the new messages (user +
+ # assistant + tools) sit at the tail of the trimmed list.
+ # We cannot simply slice at original_length (it exceeds the
+ # list length). Instead, count how many messages the
+ # executor added on top of the post-trim baseline.
+ #
+ # Timeline inside executor.run_stream:
+ # 1. messages had `original_length` items
+ # 2. append user query → original_length + 1
+ # 3. _trim_messages() → some smaller number (includes the
+ # user query because it belongs to the last turn)
+ # 4. LLM replies / tool calls appended
+ #
+ # The user query message is always the first message of the
+ # last turn (it cannot be trimmed away), so we locate it to
+ # find where "new" messages begin.
+ new_start = original_length # fallback
+ for idx in range(len(executor.messages) - 1, -1, -1):
+ msg = executor.messages[idx]
+ if msg.get("role") == "user":
+ content = msg.get("content", [])
+ is_user_query = False
+ if isinstance(content, list):
+ has_text = any(
+ isinstance(b, dict) and b.get("type") == "text"
+ for b in content
+ )
+ has_tool_result = any(
+ isinstance(b, dict) and b.get("type") == "tool_result"
+ for b in content
+ )
+ is_user_query = has_text and not has_tool_result
+ elif isinstance(content, str):
+ is_user_query = True
+ if is_user_query:
+ new_start = idx
+ break
+ new_messages = list(executor.messages[new_start:])
+ else:
+ new_messages = list(executor.messages[original_length:])
+ agent.messages = list(executor.messages)
# Persist new messages to SQLite so they survive restarts and
# can be queried via the HISTORY interface.
From 3eb83487084f7a9b77e22821f683a2d65e30a638 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 25 Mar 2026 01:25:34 +0800
Subject: [PATCH 024/399] fix: docker volume permission issue and clean up
unused dependencies
---
README.md | 3 +++
agent/prompt/workspace.py | 2 +-
docker/Dockerfile.latest | 5 +----
docker/entrypoint.sh | 10 ++++++++--
docs/channels/weixin.mdx | 4 ++--
docs/guide/manual-install.mdx | 2 ++
requirements-optional.txt | 15 ---------------
7 files changed, 17 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index a5bfbb61..b495d01e 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,9 @@ pip3 install -r requirements.txt
```bash
pip3 install -r requirements-optional.txt
```
+
+> 国内网络可使用镜像源加速:`pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
+
如果某项依赖安装失败可注释掉对应的行后重试。
## 二、配置
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index a7c9599a..8581b237 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -358,7 +358,7 @@ _你刚刚启动,这是你的第一次对话。_
- 你希望给我起个什么名字?
- 我该怎么称呼你?
- 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)
-4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内
+4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣
5. 能力介绍和交流风格选项都只要一行,保持精简
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index f33c964f..763ac920 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -17,8 +17,7 @@ RUN apt-get update \
&& cp config-template.json config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache -r requirements.txt \
- && pip install --no-cache -r requirements-optional.txt \
- && pip install azure-cognitiveservices-speech
+ && pip install --no-cache -r requirements-optional.txt
WORKDIR ${BUILD_PREFIX}
@@ -30,6 +29,4 @@ RUN chmod +x /entrypoint.sh \
&& useradd -r -g agent -s /bin/bash -d /home/agent agent \
&& chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
-USER agent
-
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index f7f4cfa5..633fd26d 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -43,9 +43,15 @@ fi
# fi
-# go to prefix dir
+# fix ownership of mounted volumes then drop to non-root user
+if [ "$(id -u)" = "0" ]; then
+ mkdir -p /home/agent/cow
+ chown agent:agent /home/agent/cow
+ exec su agent -s /bin/bash -c "cd $CHATGPT_ON_WECHAT_PREFIX && $CHATGPT_ON_WECHAT_EXEC"
+fi
+
+# fallback: already running as agent
cd $CHATGPT_ON_WECHAT_PREFIX
-# excute
$CHATGPT_ON_WECHAT_EXEC
diff --git a/docs/channels/weixin.mdx b/docs/channels/weixin.mdx
index c3319930..c19974a4 100644
--- a/docs/channels/weixin.mdx
+++ b/docs/channels/weixin.mdx
@@ -1,9 +1,9 @@
---
title: 微信
-description: 将 CowAgent 接入个人微信
+description: 将 CowAgent 接入个人微信(基于官方接口)
---
-> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的收发。
+> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的私聊收发。通过微信官方API进行接入,无安全风险,接入后会在会话中新增一个机器人助手,不影响当前账号的使用。
## 一、配置和运行
diff --git a/docs/guide/manual-install.mdx b/docs/guide/manual-install.mdx
index 819470a7..3b233284 100644
--- a/docs/guide/manual-install.mdx
+++ b/docs/guide/manual-install.mdx
@@ -30,6 +30,8 @@ pip3 install -r requirements.txt
pip3 install -r requirements-optional.txt
```
+> 国内网络可使用镜像源加速:`pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
+
### 3. 配置
复制配置文件模板并编辑:
diff --git a/requirements-optional.txt b/requirements-optional.txt
index 0707f119..c8cd9a63 100644
--- a/requirements-optional.txt
+++ b/requirements-optional.txt
@@ -2,14 +2,8 @@ tiktoken>=0.3.2 # openai calculate token
#voice
pydub>=0.25.1 # need ffmpeg
-SpeechRecognition # google speech to text
gTTS>=2.3.1 # google text to speech
-pyttsx3>=2.90 # pytsx text to speech
-baidu_aip>=4.16.10 # baidu voice
-azure-cognitiveservices-speech # azure voice
edge-tts # edge-tts
-numpy<=1.24.2
-langid # language detect
elevenlabs==1.0.3 # elevenlabs TTS
#install plugin
@@ -18,18 +12,9 @@ dulwich
# xunfei spark
websocket-client==1.2.0
-# claude API
-anthropic==0.25.0
-
-# tongyi qwen
-broadscope_bailian
-
# google
google-generativeai
-# tencentcloud sdk
-tencentcloud-sdk-python>=3.0.0
-
# file parsing (web_fetch document support)
pypdf
python-docx
From 2e1b52c1e5cfd37831c45928a55359a687a6b6e0 Mon Sep 17 00:00:00 2001
From: Xiaozhou345 <3390342655@qq.com>
Date: Wed, 25 Mar 2026 21:26:01 +0800
Subject: [PATCH 025/399] =?UTF-8?q?=E4=BC=98=E5=8C=96=20README=20=E4=B8=AD?=
=?UTF-8?q?=E7=9A=84=E4=B8=AD=E8=8B=B1=E6=96=87=E6=8E=92=E7=89=88=E7=A9=BA?=
=?UTF-8?q?=E6=A0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
按照中文技术文档规范,在文件名和中文之间增加了空格,提升可读性。
---
README.md | 186 +++++++++++++++++++++++++++---------------------------
1 file changed, 93 insertions(+), 93 deletions(-)
diff --git a/README.md b/README.md
index b495d01e..24b1194c 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
[中文] | [English ] | [日本語 ]
-**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长,比OpenClaw更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
+**CowAgent** 是基于大模型的超级 AI 助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行 Skills、拥有长期记忆并不断成长,比 OpenClaw 更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
🌐 官网 ·
@@ -19,28 +19,28 @@
# 简介
-> 该项目既是一个可以开箱即用的超级AI助理,也是一个支持高扩展的Agent框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下:
+> 该项目既是一个可以开箱即用的超级 AI 助理,也是一个支持高扩展的 Agent 框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills 系统来灵活实现各种定制需求。核心能力如下:
- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
-- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
+- ✅ **技能系统:** 实现了 Skills 创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义 Skills 开发
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
-- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
+- ✅ **多模型接入:** 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao 等国内外主流模型厂商
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
## 声明
-1. 本项目遵循 [MIT开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任。
-2. 成本与安全:Agent模式下Token使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本。
-3. CowAgent项目专注于开源技术开发,不会参与、授权或发行任何加密货币。
+1. 本项目遵循 [MIT 开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任。
+2. 成本与安全:Agent 模式下 Token 使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent 具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本。
+3. CowAgent 项目专注于开源技术开发,不会参与、授权或发行任何加密货币。
## 演示
-- 使用说明(Agent模式):[CowAgent介绍](https://docs.cowagent.ai/intro/features)
+- 使用说明( Agent 模式):[CowAgent 介绍](https://docs.cowagent.ai/intro/features)
- 免部署在线体验:[CowAgent](https://link-ai.tech/cowagent/create)
-- DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
+- DEMO 视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
## 社区
@@ -54,9 +54,9 @@
-> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式AI智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent助理](https://link-ai.tech/cowagent/create)。
+> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持 SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent 助理](https://link-ai.tech/cowagent/create)。
>
-> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的AI解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。
+> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的 AI 解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。
**产品咨询和企业服务** 可联系产品客服:
@@ -68,13 +68,13 @@
>**2026.03.22:** [2.0.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.4),新增个人微信通道(微信扫码即用)、新增 MiniMax-M2.7 和 GLM-5-Turbo 模型、run.sh 脚本重构、日文文档及多项修复。
->**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级。
+>**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持 Coding Plan、新增多个模型、Web 端文件处理、记忆系统升级。
>**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 框架,新增多种模型并优化了接入渠道。
更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases)
@@ -99,15 +99,15 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
-> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini
+> 注:Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini
-同时支持使用 **LinkAI平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等Agent技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
+同时支持使用 **LinkAI 平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等 Agent 技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
### 2.环境安装
-支持 Linux、MacOS、Windows 操作系统,可在个人计算机及服务器上运行,需安装 `Python`,Python版本需在3.7 ~ 3.12 之间,推荐使用3.9版本。
+支持 Linux、MacOS、Windows 操作系统,可在个人计算机及服务器上运行,需安装 `Python`,Python 版本需在3.7 ~ 3.12 之间,推荐使用3.9版本。
-> 注意:Agent模式推荐使用源码运行,若选择Docker部署则无需安装python环境和下载源码,可直接快进到下一节。
+> 注意:Agent 模式推荐使用源码运行,若选择 Docker 部署则无需安装 python 环境和下载源码,可直接快进到下一节。
**(1) 克隆项目代码:**
@@ -136,43 +136,43 @@ pip3 install -r requirements-optional.txt
## 二、配置
-配置文件的模板在根目录的`config-template.json`中,需复制该模板创建最终生效的 `config.json` 文件:
+配置文件的模板在根目录的 `config-template.json` 中,需复制该模板创建最终生效的 `config.json` 文件:
```bash
cp config-template.json config.json
```
-然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(注意实际使用时请去掉注释,保证JSON格式的规范):
+然后在 `config.json` 中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(注意实际使用时请去掉注释,保证 JSON 格式的规范):
```bash
# config.json 文件内容示例
{
- "channel_type": "weixin", # 接入渠道类型,默认为weixin, 支持修改为 feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
+ "channel_type": "weixin", # 接入渠道类型,默认为 weixin, 支持修改为 feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
"model": "MiniMax-M2.7", # 模型名称
"minimax_api_key": "", # MiniMax API Key
- "zhipu_ai_api_key": "", # 智谱GLM API Key
+ "zhipu_ai_api_key": "", # 智谱 GLM API Key
"moonshot_api_key": "", # Kimi/Moonshot API Key
"ark_api_key": "", # 豆包(火山方舟) API Key
- "dashscope_api_key": "", # 百炼(通义千问)API Key
+ "dashscope_api_key": "", # 百炼(通义千问) API Key
"claude_api_key": "", # Claude API Key
"claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台
"gemini_api_key": "", # Gemini API Key
- "gemini_api_base": "https://generativelanguage.googleapis.com", # Gemini API地址
+ "gemini_api_base": "https://generativelanguage.googleapis.com", # Gemini API 地址
"deepseek_api_key": "", # DeepSeek API Key
"deepseek_api_base": "https://api.deepseek.com/v1", # DeepSeek API 地址,可修改为第三方代理
"open_ai_api_key": "", # OpenAI API Key
"open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址
"linkai_api_key": "", # LinkAI API Key
- "proxy": "", # 代理客户端的ip和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890"
+ "proxy": "", # 代理客户端的 ip 和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890"
"speech_recognition": false, # 是否开启语音识别
"group_speech_recognition": false, # 是否开启群组语音识别
"voice_reply_voice": false, # 是否使用语音回复语音
- "use_linkai": false, # 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台模型
- "agent": true, # 是否启用Agent模式,启用后拥有多轮工具决策、长期记忆、Skills能力等
- "agent_workspace": "~/cow", # Agent的工作空间路径,用于存储memory、skills、系统设定等
- "agent_max_context_tokens": 40000, # Agent模式下最大上下文tokens,超出将自动丢弃最早的上下文
- "agent_max_context_turns": 30, # Agent模式下最大上下文记忆轮次,每轮包括一次用户提问和AI回复
- "agent_max_steps": 15 # Agent模式下单次任务的最大决策步数,超出后将停止继续调用工具
+ "use_linkai": false, # 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台模型
+ "agent": true, # 是否启用 Agent 模式,启用后拥有多轮工具决策、长期记忆、Skills 能力等
+ "agent_workspace": "~/cow", # Agent 的工作空间路径,用于存储 memory、skills、系统设定等
+ "agent_max_context_tokens": 40000, # Agent 模式下最大上下文 tokens,超出将自动丢弃最早的上下文
+ "agent_max_context_turns": 30, # Agent 模式下最大上下文记忆轮次,每轮包括一次用户提问和 AI 回复
+ "agent_max_steps": 15 # Agent 模式下单次任务的最大决策步数,超出后将停止继续调用工具
}
```
@@ -181,23 +181,23 @@ pip3 install -r requirements-optional.txt
1. 语音配置
-+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图);
-+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配group_chat_prefix和group_chat_keyword, 支持语音触发画图);
++ 添加 `"speech_recognition": true` 将开启语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图);
++ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配 group_chat_prefix 和 group_chat_keyword, 支持语音触发画图);
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊)
2. 其他配置
-+ `model`: 模型名称,Agent模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.5-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
-+ `character_desc`:普通对话模式下的机器人系统提示词。在Agent模式下该配置不生效,由工作空间中的文件内容构成。
-+ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。
++ `model`: 模型名称,Agent 模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.5-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
++ `character_desc`:普通对话模式下的机器人系统提示词。在 Agent 模式下该配置不生效,由工作空间中的文件内容构成。
++ `subscribe_msg`:订阅消息,公众号和企业微信 channel 中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成 bot 的触发词。
-3. LinkAI配置
+3. LinkAI 配置
-+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台,使用模型、知识库、工作流、插件等技能, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat)
++ `use_linkai`: 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台,使用模型、知识库、工作流、插件等技能, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat)
+ `linkai_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建
@@ -210,10 +210,10 @@ pip3 install -r requirements-optional.txt
如果是个人计算机 **本地运行**,直接在项目根目录下执行:
```bash
-python3 app.py # windows环境下该命令通常为 python app.py
+python3 app.py # windows 环境下该命令通常为 python app.py
```
-运行后默认会启动web服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。
+运行后默认会启动 web 服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。
如果需要接入其他应用通道只需修改 `config.json` 配置文件中的 `channel_type` 参数,详情参考:[通道说明](#通道说明)。
@@ -230,11 +230,11 @@ nohup python3 app.py & tail -f nohup.out
此外,项目根目录下的 `run.sh` 脚本支持一键启动和管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart`、`./run.sh logs` 等命令,执行 `./run.sh help` 可查看全部用法。
-> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。
+> 如果需要通过浏览器访问 Web 控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定 IP 开放以保证安全。
### 3.Docker部署
-使用docker部署无需下载源码和安装依赖,只需要获取 `docker-compose.yml` 配置文件并启动容器即可。Agent模式下更推荐使用源码进行部署,以获得更多系统访问能力。
+使用 docker 部署无需下载源码和安装依赖,只需要获取 `docker-compose.yml` 配置文件并启动容器即可。Agent 模式下更推荐使用源码进行部署,以获得更多系统访问能力。
> 前提是需要安装好 `docker` 及 `docker-compose`,安装成功后执行 `docker -v` 和 `docker-compose version` (或 `docker compose version`) 可查看到版本号。安装地址为 [docker官网](https://docs.docker.com/engine/install/) 。
@@ -254,13 +254,13 @@ curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
sudo docker compose up -d # 若docker-compose为 1.X 版本,则执行 `sudo docker-compose up -d`
```
-运行命令后,会自动取 [docker hub](https://hub.docker.com/r/zhayujie/chatgpt-on-wechat) 拉取最新release版本的镜像。当执行 `sudo docker ps` 能查看到 NAMES 为 chatgpt-on-wechat 的容器即表示运行成功。最后执行以下命令可查看容器的运行日志:
+运行命令后,会自动取 [docker hub](https://hub.docker.com/r/zhayujie/chatgpt-on-wechat) 拉取最新 release 版本的镜像。当执行 `sudo docker ps` 能查看到 NAMES 为 chatgpt-on-wechat 的容器即表示运行成功。最后执行以下命令可查看容器的运行日志:
```bash
sudo docker logs -f chatgpt-on-wechat
```
-> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。
+> 如果需要通过浏览器访问 Web 控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定 IP 开放以保证安全。
## 模型说明
@@ -269,7 +269,7 @@ sudo docker logs -f chatgpt-on-wechat
OpenAI
-1. API Key创建:在 [OpenAI平台](https://platform.openai.com/api-keys) 创建API Key
+1. API Key 创建:在 [OpenAI平台](https://platform.openai.com/api-keys) 创建 API Key
2. 填写配置
@@ -282,15 +282,15 @@ sudo docker logs -f chatgpt-on-wechat
}
```
- - `model`: 与OpenAI接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、o系列、gpt-4.1等模型,Agent模式推荐使用 `gpt-5.4`、`gpt-5.4-mini`
+ - `model`: 与 OpenAI 接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、o 系列、gpt-4.1 等模型,Agent 模式推荐使用 `gpt-5.4`、`gpt-5.4-mini`
- `open_ai_api_base`: 如果需要接入第三方代理接口,可通过修改该参数进行接入
- - `bot_type`: 使用OpenAI相关模型时无需填写。当使用第三方代理接口接入Claude等非OpenAI官方模型时,该参数设为 `openai`
+ - `bot_type`: 使用 OpenAI 相关模型时无需填写。当使用第三方代理接口接入 Claude 等非 OpenAI 官方模型时,该参数设为 `openai`
LinkAI
-1. API Key创建:在 [LinkAI平台](https://link-ai.tech/console/interface) 创建API Key
+1. API Key 创建:在 [LinkAI平台](https://link-ai.tech/console/interface) 创建 API Key
2. 填写配置
@@ -302,8 +302,8 @@ sudo docker logs -f chatgpt-on-wechat
}
```
-+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台的模型,并使用知识库、工作流、数据库、插件等丰富的Agent技能
-+ `linkai_api_key`: LinkAI平台的API Key,可在 [控制台](https://link-ai.tech/console/interface) 中创建
++ `use_linkai`: 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台的模型,并使用知识库、工作流、数据库、插件等丰富的 Agent 技能
++ `linkai_api_key`: LinkAI 平台的 API Key,可在 [控制台](https://link-ai.tech/console/interface) 中创建
+ `model`: [模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
@@ -319,9 +319,9 @@ sudo docker logs -f chatgpt-on-wechat
}
```
- `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
- - `minimax_api_key`:MiniMax平台的API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
+ - `minimax_api_key`:MiniMax 平台的 API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
-方式二:OpenAI兼容方式接入,配置如下:
+方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
@@ -330,10 +330,10 @@ sudo docker logs -f chatgpt-on-wechat
"open_ai_api_key": ""
}
```
-- `bot_type`: OpenAI兼容方式
+- `bot_type`: OpenAI 兼容方式
- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek)
-- `open_ai_api_base`: MiniMax平台API的 BASE URL
-- `open_ai_api_key`: MiniMax平台的API-KEY
+- `open_ai_api_base`: MiniMax 平台 API 的 BASE URL
+- `open_ai_api_key`: MiniMax 平台的 API-KEY
@@ -347,10 +347,10 @@ sudo docker logs -f chatgpt-on-wechat
"zhipu_ai_api_key": ""
}
```
- - `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等, 参考 [glm系列模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4)
- - `zhipu_ai_api_key`: 智谱AI平台的 API KEY,在 [控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建
+ - `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等, 参考 [glm 系列模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4)
+ - `zhipu_ai_api_key`: 智谱AI 平台的 API KEY,在 [控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建
-方式二:OpenAI兼容方式接入,配置如下:
+方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
@@ -359,16 +359,16 @@ sudo docker logs -f chatgpt-on-wechat
"open_ai_api_key": ""
}
```
-- `bot_type`: OpenAI兼容方式
+- `bot_type`: OpenAI 兼容方式
- `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等
-- `open_ai_api_base`: 智谱AI平台的 BASE URL
-- `open_ai_api_key`: 智谱AI平台的 API KEY
+- `open_ai_api_base`: 智谱AI 平台的 BASE URL
+- `open_ai_api_key`: 智谱AI 平台的 API KEY
通义千问 (Qwen)
-方式一:官方SDK接入,配置如下(推荐):
+方式一:官方 SDK 接入,配置如下(推荐):
```json
{
@@ -379,7 +379,7 @@ sudo docker logs -f chatgpt-on-wechat
- `model`: 可填写 `qwen3.5-plus、qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等
- `dashscope_api_key`: 通义千问的 API-KEY,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) ,在 [控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建
-方式二:OpenAI兼容方式接入,配置如下:
+方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
@@ -388,9 +388,9 @@ sudo docker logs -f chatgpt-on-wechat
"open_ai_api_key": "sk-qVxxxxG"
}
```
-- `bot_type`: OpenAI兼容方式
+- `bot_type`: OpenAI 兼容方式
- `model`: 支持官方所有模型,参考[模型列表](https://help.aliyun.com/zh/model-studio/models?spm=a2c4g.11186623.0.0.78d84823Kth5on#9f8890ce29g5u)
-- `open_ai_api_base`: 通义千问API的 BASE URL
+- `open_ai_api_base`: 通义千问 API 的 BASE URL
- `open_ai_api_key`: 通义千问的 API-KEY
@@ -406,9 +406,9 @@ sudo docker logs -f chatgpt-on-wechat
}
```
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
- - `moonshot_api_key`: Moonshot的API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
+ - `moonshot_api_key`: Moonshot 的 API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
-方式二:OpenAI兼容方式接入,配置如下:
+方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
@@ -417,16 +417,16 @@ sudo docker logs -f chatgpt-on-wechat
"open_ai_api_key": ""
}
```
-- `bot_type`: OpenAI兼容方式
+- `bot_type`: OpenAI 兼容方式
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
-- `open_ai_api_base`: Moonshot的 BASE URL
-- `open_ai_api_key`: Moonshot的 API-KEY
+- `open_ai_api_base`: Moonshot 的 BASE URL
+- `open_ai_api_key`: Moonshot 的 API-KEY
豆包 (Doubao)
-1. API Key创建:在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建API Key
+1. API Key 创建:在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建API Key
2. 填写配置
@@ -444,7 +444,7 @@ sudo docker logs -f chatgpt-on-wechat
Claude
-1. API Key创建:在 [Claude控制台](https://console.anthropic.com/settings/keys) 创建API Key
+1. API Key 创建:在 [Claude控制台](https://console.anthropic.com/settings/keys) 创建 API Key
2. 填写配置
@@ -460,7 +460,7 @@ sudo docker logs -f chatgpt-on-wechat
Gemini
-API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn) 创建API Key ,配置如下
+API Key 创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn) 创建 API Key ,配置如下
```json
{
"model": "gemini-3.1-flash-lite-preview",
@@ -473,7 +473,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
DeepSeek
-1. API Key创建:在 [DeepSeek平台](https://platform.deepseek.com/api_keys) 创建API Key
+1. API Key 创建:在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 API Key
2. 填写配置
@@ -487,10 +487,10 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
```
- `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3.2(非思考模式)和 DeepSeek-R1(思考模式)
- - `deepseek_api_key`: DeepSeek平台的 API Key
+ - `deepseek_api_key`: DeepSeek 平台的 API Key
- `deepseek_api_base`: 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址
-方式二:OpenAI兼容方式接入:
+方式二:OpenAI 兼容方式接入:
```json
{
@@ -506,7 +506,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
Azure
-1. API Key创建:在 [Azure平台](https://oai.azure.com/) 创建API Key
+1. API Key 创建:在 [Azure平台](https://oai.azure.com/) 创建 API Key
2. 填写配置
@@ -523,15 +523,15 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
- `model`: 留空即可
- `use_azure_chatgpt`: 设为 true
- - `open_ai_api_key`: Azure平台的密钥
- - `open_ai_api_base`: Azure平台的 BASE URL
- - `azure_deployment_id`: Azure平台部署的模型名称
- - `azure_api_version`: api版本以及以上参数可以在部署的 [模型配置](https://oai.azure.com/resource/deployments) 界面查看
+ - `open_ai_api_key`: Azure 平台的密钥
+ - `open_ai_api_base`: Azure 平台的 BASE URL
+ - `azure_deployment_id`: Azure 平台部署的模型名称
+ - `azure_api_version`: api 版本以及以上参数可以在部署的 [模型配置](https://oai.azure.com/resource/deployments) 界面查看
百度文心
-方式一:官方SDK接入,配置如下:
+方式一:官方 SDK 接入,配置如下:
```json
{
@@ -544,7 +544,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
- `baidu_wenxin_api_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 API Key
- `baidu_wenxin_secret_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 Secret Key
-方式二:OpenAI兼容方式接入,配置如下:
+方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
@@ -553,10 +553,10 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
"open_ai_api_key": "bce-v3/ALTxxxxxxd2b"
}
```
-- `bot_type`: OpenAI兼容方式
+- `bot_type`: OpenAI 兼容方式
- `model`: 支持官方所有模型,参考[模型列表](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Wm9cvy6rl)
-- `open_ai_api_base`: 百度文心API的 BASE URL
-- `open_ai_api_key`: 百度文心的 API-KEY,参考 [官方文档](https://cloud.baidu.com/doc/qianfan-api/s/ym9chdsy5) ,在 [控制台](https://console.bce.baidu.com/iam/#/iam/apikey/list) 创建API Key
+- `open_ai_api_base`: 百度文心 API 的 BASE URL
+- `open_ai_api_key`: 百度文心的 API-KEY,参考 [官方文档](https://cloud.baidu.com/doc/qianfan-api/s/ym9chdsy5) ,在 [控制台](https://console.bce.baidu.com/iam/#/iam/apikey/list) 创建 API Key
@@ -580,7 +580,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
- `xunfei_domain`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite`
- `xunfei_spark_url`: 填写参考 [官方文档-请求地址](https://www.xfyun.cn/doc/spark/Web.html#_1-1-%E8%AF%B7%E6%B1%82%E5%9C%B0%E5%9D%80) 的说明
-方式二:OpenAI兼容方式接入,配置如下:
+方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
@@ -589,7 +589,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
"open_ai_api_key": ""
}
```
-- `bot_type`: OpenAI兼容方式
+- `bot_type`: OpenAI 兼容方式
- `model`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite`
- `open_ai_api_base`: 讯飞星火平台的 BASE URL
- `open_ai_api_key`: 讯飞星火平台的[APIPassword](https://console.xfyun.cn/services/bm3) ,因模型而已
@@ -608,10 +608,10 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
}
```
-- `bot_type`: modelscope接口格式
+- `bot_type`: modelscope 接口格式
- `model`: 参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1)
- `modelscope_api_key`: 参考 [官方文档-访问令牌](https://modelscope.cn/docs/accounts/token) ,在 [控制台](https://modelscope.cn/my/myaccesstoken)
-- `modelscope_base_url`: modelscope平台的 BASE URL
+- `modelscope_base_url`: modelscope 平台的 BASE URL
- `text_to_image`: 图像生成模型,参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1)
@@ -629,7 +629,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
}
```
-目前支持阿里云、MiniMax、智谱GLM、Kimi、火山引擎等厂商,各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
+目前支持阿里云、MiniMax、智谱 GLM、Kimi、火山引擎等厂商,各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
@@ -659,7 +659,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
2. Web
-项目启动后会默认运行Web控制台,配置如下:
+项目启动后会默认运行 Web 控制台,配置如下:
```json
{
@@ -830,8 +830,8 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
# 🔗 相关项目
-- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入Slack, Telegram, Discord, Gmail等海外平台,可作为本项目的补充使用。
-- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体(Multi-Agent)框架,可以通过多智能体团队的协同来解决复杂问题。本项目基于该框架实现了[Agent插件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。
+- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入 Slack, Telegram, Discord, Gmail 等海外平台,可作为本项目的补充使用。
+- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体( Multi-Agent )框架,可以通过多智能体团队的协同来解决复杂问题。本项目基于该框架实现了[Agent 插件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。
From 8fd029a4a1612604ed03a13c027de20c483994be Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 26 Mar 2026 10:08:51 +0800
Subject: [PATCH 026/399] feat(cli): support cow cli
---
.gitignore | 8 +
cli/__init__.py | 3 +
cli/__main__.py | 4 +
cli/cli.py | 27 +++
cli/commands/__init__.py | 0
cli/commands/context.py | 81 +++++++
cli/commands/process.py | 155 +++++++++++++
cli/commands/skill.py | 462 +++++++++++++++++++++++++++++++++++++++
cli/utils.py | 62 ++++++
pyproject.toml | 19 ++
10 files changed, 821 insertions(+)
create mode 100644 cli/__init__.py
create mode 100644 cli/__main__.py
create mode 100644 cli/cli.py
create mode 100644 cli/commands/__init__.py
create mode 100644 cli/commands/context.py
create mode 100644 cli/commands/process.py
create mode 100644 cli/commands/skill.py
create mode 100644 cli/utils.py
create mode 100644 pyproject.toml
diff --git a/.gitignore b/.gitignore
index e217c97b..7dd8ce97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,11 @@ client_config.json
ref/
.cursor/
local/
+
+# Build artifacts
+dist/
+build/
+*.egg-info/
+
+# CLI runtime
+.cow.pid
diff --git a/cli/__init__.py b/cli/__init__.py
new file mode 100644
index 00000000..2a3b711e
--- /dev/null
+++ b/cli/__init__.py
@@ -0,0 +1,3 @@
+"""CowAgent CLI - Manage your CowAgent from the command line."""
+
+__version__ = "0.0.1"
diff --git a/cli/__main__.py b/cli/__main__.py
new file mode 100644
index 00000000..b7b74d67
--- /dev/null
+++ b/cli/__main__.py
@@ -0,0 +1,4 @@
+"""Allow running as: python -m cli"""
+from cli.cli import main
+
+main()
diff --git a/cli/cli.py b/cli/cli.py
new file mode 100644
index 00000000..7ea983a5
--- /dev/null
+++ b/cli/cli.py
@@ -0,0 +1,27 @@
+"""CowAgent CLI entry point."""
+
+import click
+from cli import __version__
+from cli.commands.skill import skill
+from cli.commands.process import start, stop, restart, status, logs
+from cli.commands.context import context
+
+
+@click.group()
+@click.version_option(__version__, '--version', '-v', prog_name='cow')
+def main():
+ """CowAgent CLI - Manage your CowAgent instance."""
+ pass
+
+
+main.add_command(skill)
+main.add_command(start)
+main.add_command(stop)
+main.add_command(restart)
+main.add_command(status)
+main.add_command(logs)
+main.add_command(context)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/cli/commands/context.py b/cli/commands/context.py
new file mode 100644
index 00000000..41532f2e
--- /dev/null
+++ b/cli/commands/context.py
@@ -0,0 +1,81 @@
+"""cow context - Context management commands."""
+
+import os
+import sys
+import json
+import glob as glob_mod
+
+import click
+
+from cli.utils import get_workspace_dir
+
+
+@click.group(invoke_without_command=True)
+@click.pass_context
+def context(ctx):
+ """View or manage conversation context.
+
+ Without a subcommand, shows context info for the current workspace.
+ """
+ if ctx.invoked_subcommand is None:
+ _show_context_info()
+
+
+@context.command()
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
+def clear(yes):
+ """Clear conversation context (messages history)."""
+ workspace = get_workspace_dir()
+ sessions_dir = os.path.join(workspace, "sessions")
+
+ if not os.path.isdir(sessions_dir):
+ click.echo("No conversation data found.")
+ return
+
+ db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db"))
+ if not db_files:
+ click.echo("No conversation data found.")
+ return
+
+ if not yes:
+ click.confirm("Clear all conversation context? This cannot be undone.", abort=True)
+
+ removed = 0
+ for db_file in db_files:
+ try:
+ os.remove(db_file)
+ removed += 1
+ except Exception as e:
+ click.echo(f"Warning: Failed to remove {db_file}: {e}", err=True)
+
+ click.echo(click.style(f"✓ Cleared {removed} conversation database(s).", fg="green"))
+
+
+def _show_context_info():
+ """Display conversation context status."""
+ workspace = get_workspace_dir()
+ sessions_dir = os.path.join(workspace, "sessions")
+
+ click.echo(f"\n Context info")
+ click.echo(f" Workspace: {workspace}")
+
+ if not os.path.isdir(sessions_dir):
+ click.echo(" Sessions: none\n")
+ return
+
+ db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db"))
+ total_size = sum(os.path.getsize(f) for f in db_files if os.path.exists(f))
+
+ click.echo(f" Sessions dir: {sessions_dir}")
+ click.echo(f" Database files: {len(db_files)}")
+ click.echo(f" Total size: {_format_size(total_size)}")
+ click.echo(f"\n Use 'cow context clear' to reset.\n")
+
+
+def _format_size(size_bytes):
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ elif size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ else:
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
diff --git a/cli/commands/process.py b/cli/commands/process.py
new file mode 100644
index 00000000..03dd7f20
--- /dev/null
+++ b/cli/commands/process.py
@@ -0,0 +1,155 @@
+"""cow start/stop/restart/status/logs - Process management commands."""
+
+import os
+import sys
+import signal
+import subprocess
+import time
+from typing import Optional
+
+import click
+
+from cli.utils import get_project_root
+
+
+def _get_pid_file():
+ return os.path.join(get_project_root(), ".cow.pid")
+
+
+def _get_log_file():
+ return os.path.join(get_project_root(), "nohup.out")
+
+
+def _read_pid() -> Optional[int]:
+ pid_file = _get_pid_file()
+ if not os.path.exists(pid_file):
+ return None
+ try:
+ with open(pid_file, "r") as f:
+ pid = int(f.read().strip())
+ os.kill(pid, 0)
+ return pid
+ except (ValueError, ProcessLookupError, PermissionError):
+ os.remove(pid_file)
+ return None
+
+
+def _write_pid(pid: int):
+ with open(_get_pid_file(), "w") as f:
+ f.write(str(pid))
+
+
+def _remove_pid():
+ pid_file = _get_pid_file()
+ if os.path.exists(pid_file):
+ os.remove(pid_file)
+
+
+@click.command()
+@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)")
+def start(foreground):
+ """Start CowAgent."""
+ pid = _read_pid()
+ if pid:
+ click.echo(f"CowAgent is already running (PID: {pid}).")
+ return
+
+ root = get_project_root()
+ app_py = os.path.join(root, "app.py")
+ if not os.path.exists(app_py):
+ click.echo("Error: app.py not found in project root.", err=True)
+ sys.exit(1)
+
+ python = sys.executable
+
+ if foreground:
+ click.echo("Starting CowAgent in foreground...")
+ os.execv(python, [python, app_py])
+ else:
+ log_file = _get_log_file()
+ click.echo("Starting CowAgent...")
+
+ with open(log_file, "a") as log:
+ proc = subprocess.Popen(
+ [python, app_py],
+ cwd=root,
+ stdout=log,
+ stderr=log,
+ start_new_session=True,
+ )
+ _write_pid(proc.pid)
+ click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green"))
+ click.echo(f" Logs: {log_file}")
+
+
+@click.command()
+def stop():
+ """Stop CowAgent."""
+ pid = _read_pid()
+ if not pid:
+ click.echo("CowAgent is not running.")
+ return
+
+ click.echo(f"Stopping CowAgent (PID: {pid})...")
+ try:
+ os.kill(pid, signal.SIGTERM)
+ for _ in range(30):
+ time.sleep(0.1)
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ break
+ else:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+
+ _remove_pid()
+ click.echo(click.style("✓ CowAgent stopped.", fg="green"))
+
+
+@click.command()
+@click.pass_context
+def restart(ctx):
+ """Restart CowAgent."""
+ ctx.invoke(stop)
+ time.sleep(1)
+ ctx.invoke(start)
+
+
+@click.command()
+def status():
+ """Show CowAgent running status."""
+ pid = _read_pid()
+ if pid:
+ click.echo(click.style(f"● CowAgent is running (PID: {pid})", fg="green"))
+ else:
+ click.echo(click.style("● CowAgent is not running", fg="red"))
+
+
+@click.command()
+@click.option("--follow", "-f", is_flag=True, help="Follow log output")
+@click.option("--lines", "-n", default=50, help="Number of lines to show")
+def logs(follow, lines):
+ """View CowAgent logs."""
+ log_file = _get_log_file()
+ if not os.path.exists(log_file):
+ click.echo("No log file found.")
+ return
+
+ if follow:
+ try:
+ proc = subprocess.Popen(
+ ["tail", "-f", "-n", str(lines), log_file],
+ stdout=sys.stdout,
+ stderr=sys.stderr,
+ )
+ proc.wait()
+ except KeyboardInterrupt:
+ pass
+ else:
+ proc = subprocess.run(
+ ["tail", "-n", str(lines), log_file],
+ stdout=sys.stdout,
+ stderr=sys.stderr,
+ )
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
new file mode 100644
index 00000000..1d622274
--- /dev/null
+++ b/cli/commands/skill.py
@@ -0,0 +1,462 @@
+"""cow skill - Skill management commands."""
+
+import os
+import sys
+import json
+import shutil
+import zipfile
+import tempfile
+
+import click
+import requests
+
+from cli.utils import (
+ get_project_root,
+ get_skills_dir,
+ get_builtin_skills_dir,
+ load_skills_config,
+ SKILL_HUB_API,
+)
+
+
+@click.group()
+def skill():
+ """Manage CowAgent skills."""
+ pass
+
+
+# ------------------------------------------------------------------
+# cow skill list
+# ------------------------------------------------------------------
+@skill.command("list")
+@click.option("--remote", is_flag=True, help="List skills available on Skill Hub")
+def skill_list(remote):
+ """List installed skills or browse remote Skill Hub."""
+ if remote:
+ _list_remote()
+ else:
+ _list_local()
+
+
+def _list_local():
+ """List locally installed skills."""
+ config = load_skills_config()
+ skills_dir = get_skills_dir()
+ builtin_dir = get_builtin_skills_dir()
+
+ if not config:
+ # Fallback: scan directories directly
+ entries = []
+ for d in [builtin_dir, skills_dir]:
+ if not os.path.isdir(d):
+ continue
+ source = "builtin" if d == builtin_dir else "custom"
+ for name in sorted(os.listdir(d)):
+ skill_path = os.path.join(d, name)
+ if os.path.isdir(skill_path) and not name.startswith("."):
+ has_skill_md = os.path.exists(os.path.join(skill_path, "SKILL.md"))
+ if has_skill_md:
+ entries.append({"name": name, "source": source, "enabled": True, "description": ""})
+ if not entries:
+ click.echo("No skills installed.")
+ return
+ _print_skill_table(entries)
+ return
+
+ entries = sorted(config.values(), key=lambda x: x.get("name", ""))
+ if not entries:
+ click.echo("No skills installed.")
+ return
+ _print_skill_table(entries)
+
+
+def _print_skill_table(entries):
+ """Print skills as a formatted table."""
+ name_w = max(len(e.get("name", "")) for e in entries)
+ name_w = max(name_w, 4) + 2
+ desc_w = 40
+
+ header = f"{'Name':<{name_w}} {'Status':<10} {'Source':<10} {'Description'}"
+ click.echo(f"\n Installed skills ({len(entries)})\n")
+ click.echo(f" {header}")
+ click.echo(f" {'─' * (name_w + 10 + 10 + desc_w)}")
+
+ for e in entries:
+ name = e.get("name", "")
+ enabled = e.get("enabled", True)
+ source = e.get("source", "")
+ desc = e.get("description", "") or ""
+ if len(desc) > desc_w:
+ desc = desc[:desc_w - 3] + "..."
+
+ status_icon = click.style("✓ on ", fg="green") if enabled else click.style("✗ off", fg="red")
+ click.echo(f" {name:<{name_w}} {status_icon} {source:<10} {desc}")
+
+ click.echo()
+
+
+def _list_remote():
+ """List skills from remote Skill Hub."""
+ try:
+ resp = requests.get(f"{SKILL_HUB_API}/skills", timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ click.echo(f"Error: Failed to fetch from Skill Hub: {e}", err=True)
+ sys.exit(1)
+
+ skills = data.get("skills", [])
+ if not skills:
+ click.echo("No skills available on Skill Hub.")
+ return
+
+ name_w = max(len(s.get("name", "")) for s in skills)
+ name_w = max(name_w, 4) + 2
+
+ click.echo(f"\n Skill Hub ({len(skills)} available)\n")
+ click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}")
+ click.echo(f" {'─' * (name_w + 12 + 50)}")
+
+ for s in skills:
+ name = s.get("name", "")
+ downloads = s.get("downloads", 0)
+ desc = s.get("description", "") or s.get("display_name", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "..."
+ click.echo(f" {name:<{name_w}} {downloads:<12} {desc}")
+
+ click.echo(f"\n Install with: cow skill install \n")
+
+
+# ------------------------------------------------------------------
+# cow skill search
+# ------------------------------------------------------------------
+@skill.command()
+@click.argument("query")
+def search(query):
+ """Search skills on Skill Hub."""
+ try:
+ resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ click.echo(f"Error: Failed to search Skill Hub: {e}", err=True)
+ sys.exit(1)
+
+ skills = data.get("skills", [])
+ if not skills:
+ click.echo(f'No skills found for "{query}".')
+ return
+
+ name_w = max(len(s.get("name", "")) for s in skills)
+ name_w = max(name_w, 4) + 2
+
+ click.echo(f'\n Search results for "{query}" ({len(skills)} found)\n')
+ click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}")
+ click.echo(f" {'─' * (name_w + 12 + 50)}")
+
+ for s in skills:
+ name = s.get("name", "")
+ downloads = s.get("downloads", 0)
+ desc = s.get("description", "") or s.get("display_name", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "..."
+ click.echo(f" {name:<{name_w}} {downloads:<12} {desc}")
+
+ click.echo(f"\n Install with: cow skill install \n")
+
+
+# ------------------------------------------------------------------
+# cow skill install
+# ------------------------------------------------------------------
+@skill.command()
+@click.argument("name")
+def install(name):
+ """Install a skill from Skill Hub or GitHub.
+
+ Examples:
+
+ cow skill install pptx
+
+ cow skill install github:owner/repo
+
+ cow skill install github:owner/repo#path/to/skill
+ """
+ if name.startswith("github:"):
+ _install_github(name[7:])
+ else:
+ _install_hub(name)
+
+
+def _install_hub(name):
+ """Install a skill from Skill Hub."""
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ click.echo(f"Fetching skill info for '{name}'...")
+
+ try:
+ resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15)
+ resp.raise_for_status()
+ except requests.HTTPError as e:
+ if e.response is not None and e.response.status_code == 404:
+ click.echo(f"Error: Skill '{name}' not found on Skill Hub.", err=True)
+ else:
+ click.echo(f"Error: Failed to fetch skill: {e}", err=True)
+ sys.exit(1)
+ except Exception as e:
+ click.echo(f"Error: Failed to connect to Skill Hub: {e}", err=True)
+ sys.exit(1)
+
+ content_type = resp.headers.get("Content-Type", "")
+
+ if "application/json" in content_type:
+ data = resp.json()
+ source_type = data.get("source_type")
+
+ if source_type == "github":
+ source_url = data.get("source_url", "")
+ source_path = data.get("source_path")
+ click.echo(f"Source: GitHub ({source_url})")
+ _install_github(source_url, subpath=source_path, skill_name=name)
+ return
+
+ if source_type == "registry":
+ click.echo(f"This skill is from an external registry: {data.get('source_url', '')}")
+ click.echo("Please install it through the corresponding platform.")
+ return
+
+ if "redirect" in data:
+ source_url = data.get("source_url", "")
+ source_path = data.get("source_path")
+ click.echo(f"Source: GitHub ({source_url})")
+ _install_github(source_url, subpath=source_path, skill_name=name)
+ return
+
+ elif "application/zip" in content_type:
+ click.echo("Downloading skill package...")
+ _install_zip_bytes(resp.content, name, skills_dir)
+ _report_install(name)
+ click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
+ return
+
+ click.echo(f"Error: Unexpected response from Skill Hub.", err=True)
+ sys.exit(1)
+
+
+def _install_github(spec, subpath=None, skill_name=None):
+ """Install a skill from a GitHub repo.
+
+ spec format: owner/repo or owner/repo#path
+ """
+ if "#" in spec and not subpath:
+ spec, subpath = spec.split("#", 1)
+
+ if not skill_name:
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
+ click.echo(f"Downloading from GitHub: {spec}...")
+
+ try:
+ resp = requests.get(zip_url, timeout=60, allow_redirects=True)
+ resp.raise_for_status()
+ except Exception as e:
+ click.echo(f"Error: Failed to download from GitHub: {e}", err=True)
+ sys.exit(1)
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ zip_path = os.path.join(tmp_dir, "repo.zip")
+ with open(zip_path, "wb") as f:
+ f.write(resp.content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(extract_dir)
+
+ # GitHub archives have a top-level dir like "repo-main/"
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ repo_root = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ repo_root = os.path.join(extract_dir, top_items[0])
+
+ if subpath:
+ source_dir = os.path.join(repo_root, subpath.strip("/"))
+ if not os.path.isdir(source_dir):
+ click.echo(f"Error: Path '{subpath}' not found in repository.", err=True)
+ sys.exit(1)
+ else:
+ source_dir = repo_root
+
+ target_dir = os.path.join(skills_dir, skill_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(source_dir, target_dir)
+
+ _report_install(skill_name)
+ click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green"))
+
+
+def _install_zip_bytes(content, name, skills_dir):
+ """Extract a zip archive into the skills directory."""
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ zip_path = os.path.join(tmp_dir, "package.zip")
+ with open(zip_path, "wb") as f:
+ f.write(content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(extract_dir)
+
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ source = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ source = os.path.join(extract_dir, top_items[0])
+
+ target = os.path.join(skills_dir, name)
+ if os.path.exists(target):
+ shutil.rmtree(target)
+ shutil.copytree(source, target)
+
+
+def _report_install(name):
+ """Report installation to Skill Hub for download counting."""
+ try:
+ requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5)
+ except Exception:
+ pass
+
+
+# ------------------------------------------------------------------
+# cow skill uninstall
+# ------------------------------------------------------------------
+@skill.command()
+@click.argument("name")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
+def uninstall(name, yes):
+ """Uninstall a skill."""
+ skills_dir = get_skills_dir()
+ skill_dir = os.path.join(skills_dir, name)
+
+ if not os.path.exists(skill_dir):
+ click.echo(f"Error: Skill '{name}' is not installed.", err=True)
+ sys.exit(1)
+
+ if not yes:
+ click.confirm(f"Uninstall skill '{name}'?", abort=True)
+
+ shutil.rmtree(skill_dir)
+
+ config_path = os.path.join(skills_dir, "skills_config.json")
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ config.pop(name, None)
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+ except Exception:
+ pass
+
+ click.echo(click.style(f"✓ Skill '{name}' uninstalled.", fg="green"))
+
+
+# ------------------------------------------------------------------
+# cow skill enable / disable
+# ------------------------------------------------------------------
+@skill.command()
+@click.argument("name")
+def enable(name):
+ """Enable a skill."""
+ _set_enabled(name, True)
+
+
+@skill.command()
+@click.argument("name")
+def disable(name):
+ """Disable a skill."""
+ _set_enabled(name, False)
+
+
+def _set_enabled(name, enabled):
+ skills_dir = get_skills_dir()
+ config_path = os.path.join(skills_dir, "skills_config.json")
+
+ if not os.path.exists(config_path):
+ click.echo(f"Error: No skills config found.", err=True)
+ sys.exit(1)
+
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ except Exception as e:
+ click.echo(f"Error: Failed to read skills config: {e}", err=True)
+ sys.exit(1)
+
+ if name not in config:
+ click.echo(f"Error: Skill '{name}' not found in config.", err=True)
+ sys.exit(1)
+
+ config[name]["enabled"] = enabled
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+
+ state = "enabled" if enabled else "disabled"
+ icon = "✓" if enabled else "✗"
+ color = "green" if enabled else "yellow"
+ click.echo(click.style(f"{icon} Skill '{name}' {state}.", fg=color))
+
+
+# ------------------------------------------------------------------
+# cow skill info
+# ------------------------------------------------------------------
+@skill.command()
+@click.argument("name")
+def info(name):
+ """Show details about an installed skill."""
+ skills_dir = get_skills_dir()
+ builtin_dir = get_builtin_skills_dir()
+
+ skill_dir = None
+ source = None
+ for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
+ candidate = os.path.join(d, name)
+ if os.path.isdir(candidate):
+ skill_dir = candidate
+ source = src
+ break
+
+ if not skill_dir:
+ click.echo(f"Error: Skill '{name}' not found.", err=True)
+ sys.exit(1)
+
+ skill_md = os.path.join(skill_dir, "SKILL.md")
+ if not os.path.exists(skill_md):
+ click.echo(f"Skill directory: {skill_dir}")
+ click.echo("No SKILL.md found.")
+ return
+
+ with open(skill_md, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ config = load_skills_config()
+ entry = config.get(name, {})
+ enabled = entry.get("enabled", True)
+ status_str = click.style("✓ enabled", fg="green") if enabled else click.style("✗ disabled", fg="red")
+
+ click.echo(f"\n Skill: {name}")
+ click.echo(f" Source: {source}")
+ click.echo(f" Status: {status_str}")
+ click.echo(f" Path: {skill_dir}")
+ click.echo(f"\n{'─' * 60}")
+
+ # Show first ~30 lines of SKILL.md as a preview
+ lines = content.split("\n")
+ preview = "\n".join(lines[:30])
+ click.echo(preview)
+ if len(lines) > 30:
+ click.echo(f"\n ... ({len(lines) - 30} more lines, see {skill_md})")
+ click.echo()
diff --git a/cli/utils.py b/cli/utils.py
new file mode 100644
index 00000000..8dc229dd
--- /dev/null
+++ b/cli/utils.py
@@ -0,0 +1,62 @@
+"""Shared utilities for cow CLI."""
+
+import os
+import sys
+import json
+
+
+def get_project_root() -> str:
+ """Get the CowAgent project root directory."""
+ # cli/ is directly under the project root
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def get_workspace_dir() -> str:
+ """Get the agent workspace directory from config, defaulting to ~/cow."""
+ config = load_config_json()
+ workspace = config.get("agent_workspace", "~/cow")
+ return os.path.expanduser(workspace)
+
+
+def get_skills_dir() -> str:
+ """Get the custom skills directory."""
+ return os.path.join(get_workspace_dir(), "skills")
+
+
+def get_builtin_skills_dir() -> str:
+ """Get the builtin skills directory."""
+ return os.path.join(get_project_root(), "skills")
+
+
+def load_config_json() -> dict:
+ """Load config.json from project root."""
+ config_path = os.path.join(get_project_root(), "config.json")
+ if not os.path.exists(config_path):
+ return {}
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return {}
+
+
+def load_skills_config() -> dict:
+ """Load skills_config.json from the custom skills directory."""
+ path = os.path.join(get_skills_dir(), "skills_config.json")
+ if not os.path.exists(path):
+ return {}
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return {}
+
+
+def ensure_sys_path():
+ """Add project root to sys.path so we can import agent modules."""
+ root = get_project_root()
+ if root not in sys.path:
+ sys.path.insert(0, root)
+
+
+SKILL_HUB_API = "https://cow-skill-hub.pages.dev/api"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..db8aa04d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[build-system]
+requires = ["setuptools>=68.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "cowagent"
+version = "0.0.1"
+description = "CowAgent - AI Agent on WeChat and more"
+requires-python = ">=3.9"
+dependencies = [
+ "click>=8.0",
+ "requests>=2.28.2",
+]
+
+[project.scripts]
+cow = "cli.cli:main"
+
+[tool.setuptools.packages.find]
+include = ["cli*"]
From ce90cf7aa8eb44258dfcb94c498f24cfa44b95f9 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 26 Mar 2026 10:20:29 +0800
Subject: [PATCH 027/399] fix: weixin cdn upload retry
---
.gitignore | 4 ++++
channel/weixin/weixin_api.py | 41 ++++++++++++++++++++----------------
2 files changed, 27 insertions(+), 18 deletions(-)
diff --git a/.gitignore b/.gitignore
index e217c97b..75b412ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,7 @@ client_config.json
ref/
.cursor/
local/
+
+# cow cli
+cowagent.egg-info/
+dist/
diff --git a/channel/weixin/weixin_api.py b/channel/weixin/weixin_api.py
index fe2f7a7b..b98fe5df 100644
--- a/channel/weixin/weixin_api.py
+++ b/channel/weixin/weixin_api.py
@@ -284,33 +284,36 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
raw_md5 = _md5_bytes(raw_data)
cipher_size = _aes_ecb_padded_size(raw_size)
- resp = api.get_upload_url(
- filekey=filekey,
- media_type=media_type,
- to_user_id=to_user_id,
- rawsize=raw_size,
- rawfilemd5=raw_md5,
- filesize=cipher_size,
- aeskey=aes_key_hex,
- )
-
- upload_param = resp.get("upload_param", "")
- if not upload_param:
- raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
-
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
from urllib.parse import quote
- cdn_url = (f"{api.cdn_base_url}/upload"
- f"?encrypted_query_param={quote(upload_param)}"
- f"&filekey={quote(filekey)}")
download_param = None
last_error = None
for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
try:
+ if attempt > 1:
+ filekey = uuid.uuid4().hex
+ resp = api.get_upload_url(
+ filekey=filekey,
+ media_type=media_type,
+ to_user_id=to_user_id,
+ rawsize=raw_size,
+ rawfilemd5=raw_md5,
+ filesize=cipher_size,
+ aeskey=aes_key_hex,
+ )
+ upload_param = resp.get("upload_param", "")
+ if not upload_param:
+ raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
+
+ cdn_url = (f"{api.cdn_base_url}/upload"
+ f"?encrypted_query_param={quote(upload_param)}"
+ f"&filekey={quote(filekey)}")
+
cdn_resp = requests.post(cdn_url, data=encrypted, headers={
"Content-Type": "application/octet-stream",
+ "Content-Length": str(len(encrypted)),
}, timeout=120)
if 400 <= cdn_resp.status_code < 500:
err_msg = cdn_resp.headers.get("x-error-message", cdn_resp.text[:200])
@@ -326,7 +329,9 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
if "client error" in str(e):
raise
if attempt < UPLOAD_MAX_RETRIES:
- logger.warning(f"[Weixin] CDN upload attempt {attempt} failed, retrying: {e}")
+ backoff = 2 ** attempt
+ logger.warning(f"[Weixin] CDN upload attempt {attempt} failed, retrying in {backoff}s: {e}")
+ time.sleep(backoff)
else:
logger.error(f"[Weixin] CDN upload failed after {UPLOAD_MAX_RETRIES} attempts: {e}")
From 158510cbbe66ee658a959ff8c8999307bd31cd5f Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 26 Mar 2026 16:49:42 +0800
Subject: [PATCH 028/399] feat(cli): imporve cow cli and skill hub integration
---
agent/skills/config.py | 41 ++++++++++
agent/skills/formatter.py | 67 +++++++++++++++-
agent/skills/frontmatter.py | 1 +
agent/skills/loader.py | 1 -
agent/skills/manager.py | 126 +++++++++++++++++++++----------
agent/skills/types.py | 1 +
channel/web/static/js/console.js | 11 ++-
channel/web/web_channel.py | 8 ++
cli/VERSION | 1 +
cli/__init__.py | 12 ++-
cli/cli.py | 53 ++++++++++++-
cli/commands/context.py | 76 +++----------------
cli/commands/process.py | 52 +++++++++----
cli/commands/skill.py | 57 +++++++++-----
cli/utils.py | 2 +-
skills/linkai-agent/SKILL.md | 1 +
16 files changed, 367 insertions(+), 143 deletions(-)
create mode 100644 cli/VERSION
diff --git a/agent/skills/config.py b/agent/skills/config.py
index 86979c92..788009f9 100644
--- a/agent/skills/config.py
+++ b/agent/skills/config.py
@@ -139,6 +139,47 @@ def should_include_skill(
return True
+def get_missing_requirements(
+ entry: SkillEntry,
+ current_platform: Optional[str] = None,
+) -> Dict[str, List[str]]:
+ """
+ Return a dict of missing requirements for a skill.
+ Empty dict means all requirements are met.
+
+ :param entry: SkillEntry to check
+ :param current_platform: Current platform (default: auto-detect)
+ :return: Dict like {"bins": ["curl"], "env": ["API_KEY"]}
+ """
+ missing: Dict[str, List[str]] = {}
+ metadata = entry.metadata
+
+ if not metadata or not metadata.requires:
+ return missing
+
+ required_bins = metadata.requires.get('bins', [])
+ if required_bins:
+ missing_bins = [b for b in required_bins if not has_binary(b)]
+ if missing_bins:
+ missing['bins'] = missing_bins
+
+ any_bins = metadata.requires.get('anyBins', [])
+ if any_bins and not has_any_binary(any_bins):
+ missing['anyBins'] = any_bins
+
+ required_env = metadata.requires.get('env', [])
+ if required_env:
+ missing_env = [e for e in required_env if not has_env_var(e)]
+ if missing_env:
+ missing['env'] = missing_env
+
+ any_env = metadata.requires.get('anyEnv', [])
+ if any_env and not any(has_env_var(e) for e in any_env):
+ missing['anyEnv'] = any_env
+
+ return missing
+
+
def is_config_path_truthy(config: Dict, path: str) -> bool:
"""
Check if a config path resolves to a truthy value.
diff --git a/agent/skills/formatter.py b/agent/skills/formatter.py
index 86abf1e4..d1eebe05 100644
--- a/agent/skills/formatter.py
+++ b/agent/skills/formatter.py
@@ -2,7 +2,7 @@
Skill formatter for generating prompts from skills.
"""
-from typing import List
+from typing import Dict, List
from agent.skills.types import Skill, SkillEntry
@@ -51,6 +51,71 @@ def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str:
return format_skills_for_prompt(skills)
+def format_unavailable_skills_for_prompt(
+ entries: List[SkillEntry],
+ missing_map: Dict[str, Dict[str, List[str]]],
+) -> str:
+ """
+ Format unavailable (requires-not-met) skills as brief setup hints
+ so the AI can guide users to configure them.
+
+ :param entries: List of unavailable skill entries
+ :param missing_map: Dict mapping skill name to its missing requirements
+ :return: Formatted prompt text
+ """
+ if not entries:
+ return ""
+
+ lines = [
+ "",
+ "",
+ "The following skills are installed but not yet ready. "
+ "Guide the user to complete the setup when relevant.",
+ ]
+
+ for entry in entries:
+ skill = entry.skill
+ missing = missing_map.get(skill.name, {})
+
+ missing_parts = []
+ for key, values in missing.items():
+ missing_parts.append(f"{key}: {', '.join(values)}")
+ missing_str = "; ".join(missing_parts) if missing_parts else "unknown"
+
+ setup_hint = _extract_setup_hint(skill)
+
+ lines.append(" ")
+ lines.append(f" {_escape_xml(skill.name)} ")
+ lines.append(f" {_escape_xml(skill.description)} ")
+ lines.append(f" {_escape_xml(missing_str)} ")
+ if setup_hint:
+ lines.append(f" {_escape_xml(setup_hint)} ")
+ lines.append(" ")
+
+ lines.append(" ")
+ return "\n".join(lines)
+
+
+def _extract_setup_hint(skill: Skill) -> str:
+ """
+ Extract the Setup section from SKILL.md content as a brief hint.
+ Returns the first few lines of the ## Setup section.
+ """
+ content = skill.content
+ if not content:
+ return ""
+
+ import re
+ match = re.search(r'^##\s+Setup\s*\n(.*?)(?=\n##\s|\Z)', content, re.MULTILINE | re.DOTALL)
+ if not match:
+ return ""
+
+ setup_text = match.group(1).strip()
+ lines = setup_text.split('\n')
+ hint_lines = [l.strip() for l in lines[:6] if l.strip()]
+ return ' '.join(hint_lines)[:300]
+
+
def _escape_xml(text: str) -> str:
"""Escape XML special characters."""
return (text
diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py
index 9905e299..2f29283d 100644
--- a/agent/skills/frontmatter.py
+++ b/agent/skills/frontmatter.py
@@ -128,6 +128,7 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
return SkillMetadata(
always=meta_obj.get('always', False),
+ default_enabled=meta_obj.get('default_enabled', True),
skill_key=meta_obj.get('skillKey'),
primary_env=meta_obj.get('primaryEnv'),
emoji=meta_obj.get('emoji'),
diff --git a/agent/skills/loader.py b/agent/skills/loader.py
index f02346d1..a39dba28 100644
--- a/agent/skills/loader.py
+++ b/agent/skills/loader.py
@@ -184,7 +184,6 @@ class SkillLoader:
config_path = os.path.join(skill_dir, "config.json")
- # Without config.json, skip this skill entirely (return empty to trigger exclusion)
if not os.path.exists(config_path):
logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found")
return ""
diff --git a/agent/skills/manager.py b/agent/skills/manager.py
index a70daaea..99f5e643 100644
--- a/agent/skills/manager.py
+++ b/agent/skills/manager.py
@@ -84,10 +84,10 @@ class SkillManager:
"""
Merge directory-scanned skills with the persisted config file.
- - New skills discovered on disk are added with enabled=True.
+ - New skills: use metadata.default_enabled as initial enabled state.
+ - Existing skills: preserve their persisted enabled state.
- Skills that no longer exist on disk are removed.
- - Existing entries preserve their enabled state; name/description/source
- are refreshed from the latest scan.
+ - name/description/source are always refreshed from the latest scan.
"""
saved = self._load_skills_config()
merged: Dict[str, dict] = {}
@@ -95,13 +95,18 @@ class SkillManager:
for name, entry in self.skills.items():
skill = entry.skill
prev = saved.get(name, {})
- # category priority: persisted config (set by cloud) > default "skill"
category = prev.get("category", "skill")
+
+ if name in saved:
+ enabled = prev.get("enabled", True)
+ else:
+ enabled = entry.metadata.default_enabled if entry.metadata else True
+
merged[name] = {
"name": name,
"description": skill.description,
"source": skill.source,
- "enabled": prev.get("enabled", True),
+ "enabled": enabled,
"category": category,
}
@@ -157,69 +162,114 @@ class SkillManager:
"""
return list(self.skills.values())
+ @staticmethod
+ def _normalize_skill_filter(skill_filter: Optional[List[str]]) -> Optional[List[str]]:
+ """Normalize a skill_filter list into a flat list of stripped names."""
+ if skill_filter is None:
+ return None
+ normalized = []
+ for item in skill_filter:
+ if isinstance(item, str):
+ name = item.strip()
+ if name:
+ normalized.append(name)
+ elif isinstance(item, list):
+ for subitem in item:
+ if isinstance(subitem, str):
+ name = subitem.strip()
+ if name:
+ normalized.append(name)
+ return normalized or None
+
def filter_skills(
self,
skill_filter: Optional[List[str]] = None,
include_disabled: bool = False,
) -> List[SkillEntry]:
"""
- Filter skills based on criteria.
-
- Simple rule: Skills are auto-enabled if requirements are met.
- - Has required API keys -> included
- - Missing API keys -> excluded
+ Filter skills that are eligible (enabled + requirements met).
:param skill_filter: List of skill names to include (None = all)
:param include_disabled: Whether to include disabled skills
- :return: Filtered list of skill entries
+ :return: Filtered list of eligible skill entries
"""
from agent.skills.config import should_include_skill
entries = list(self.skills.values())
- # Check requirements (platform, binaries, env vars)
entries = [e for e in entries if should_include_skill(e, self.config)]
- # Apply skill filter
- if skill_filter is not None:
- normalized = []
- for item in skill_filter:
- if isinstance(item, str):
- name = item.strip()
- if name:
- normalized.append(name)
- elif isinstance(item, list):
- for subitem in item:
- if isinstance(subitem, str):
- name = subitem.strip()
- if name:
- normalized.append(name)
- if normalized:
- entries = [e for e in entries if e.skill.name in normalized]
+ normalized = self._normalize_skill_filter(skill_filter)
+ if normalized is not None:
+ entries = [e for e in entries if e.skill.name in normalized]
- # Filter out disabled skills based on skills_config.json
if not include_disabled:
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
return entries
-
+
+ def filter_unavailable_skills(
+ self,
+ skill_filter: Optional[List[str]] = None,
+ ) -> tuple:
+ """
+ Find skills that are enabled but have unmet requirements.
+
+ :param skill_filter: Optional list of skill names to include
+ :return: Tuple of (entries, missing_map) where missing_map maps
+ skill name to its missing requirements dict
+ """
+ from agent.skills.config import should_include_skill, get_missing_requirements
+
+ entries = list(self.skills.values())
+
+ # Only enabled skills
+ entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
+
+ normalized = self._normalize_skill_filter(skill_filter)
+ if normalized is not None:
+ entries = [e for e in entries if e.skill.name in normalized]
+
+ # Keep only those that fail should_include_skill (requirements not met)
+ unavailable = []
+ missing_map: Dict[str, dict] = {}
+ for e in entries:
+ if not should_include_skill(e, self.config):
+ missing = get_missing_requirements(e)
+ if missing:
+ unavailable.append(e)
+ missing_map[e.skill.name] = missing
+
+ return unavailable, missing_map
+
def build_skills_prompt(
self,
skill_filter: Optional[List[str]] = None,
) -> str:
"""
- Build a formatted prompt containing available skills.
-
+ Build a formatted prompt containing available skills
+ and brief hints for unavailable ones.
+
:param skill_filter: Optional list of skill names to include
:return: Formatted skills prompt
"""
from common.log import logger
- entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False)
- logger.debug(f"[SkillManager] Filtered {len(entries)} skills for prompt (total: {len(self.skills)})")
- if entries:
- skill_names = [e.skill.name for e in entries]
- logger.debug(f"[SkillManager] Skills to include: {skill_names}")
- result = format_skill_entries_for_prompt(entries)
+ from agent.skills.formatter import format_unavailable_skills_for_prompt
+
+ eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False)
+ logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})")
+ if eligible:
+ skill_names = [e.skill.name for e in eligible]
+ logger.debug(f"[SkillManager] Eligible skills: {skill_names}")
+
+ result = format_skill_entries_for_prompt(eligible)
+
+ unavailable, missing_map = self.filter_unavailable_skills(skill_filter=skill_filter)
+ if unavailable:
+ unavailable_names = [e.skill.name for e in unavailable]
+ logger.debug(f"[SkillManager] Unavailable skills (setup needed): {unavailable_names}")
+ result += format_unavailable_skills_for_prompt(unavailable, missing_map)
+
logger.debug(f"[SkillManager] Generated prompt length: {len(result)}")
return result
diff --git a/agent/skills/types.py b/agent/skills/types.py
index 1b27479b..a6a467e5 100644
--- a/agent/skills/types.py
+++ b/agent/skills/types.py
@@ -29,6 +29,7 @@ class SkillInstallSpec:
class SkillMetadata:
"""Metadata for a skill from frontmatter."""
always: bool = False # Always include this skill
+ default_enabled: bool = True # Initial enabled state when first discovered
skill_key: Optional[str] = None # Override skill key
primary_env: Optional[str] = None # Primary environment variable
emoji: Optional[str] = None
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index b5070c28..bc45cb18 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -3,9 +3,9 @@
===================================================================== */
// =====================================================================
-// Version — update this before each release
+// Version — fetched from backend (single source: /VERSION file)
// =====================================================================
-const APP_VERSION = 'v2.0.4';
+let APP_VERSION = '';
// =====================================================================
// i18n
@@ -2236,7 +2236,12 @@ navigateTo = function(viewId) {
// =====================================================================
applyTheme();
applyI18n();
-document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`;
+fetch('/api/version').then(r => r.json()).then(data => {
+ APP_VERSION = `v${data.version}`;
+ document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`;
+}).catch(() => {
+ document.getElementById('sidebar-version').textContent = 'CowAgent';
+});
chatInput.focus();
// Re-enable color transition AFTER first paint so the theme applied in
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 2979cd9e..0561961d 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -390,6 +390,7 @@ class WebChannel(ChatChannel):
'/api/scheduler', 'SchedulerHandler',
'/api/history', 'HistoryHandler',
'/api/logs', 'LogsHandler',
+ '/api/version', 'VersionHandler',
'/assets/(.*)', 'AssetsHandler',
)
app = web.application(urls, globals(), autoreload=False)
@@ -1429,3 +1430,10 @@ class AssetsHandler:
except Exception as e:
logger.error(f"Error serving static file: {e}", exc_info=True) # 添加更详细的错误信息
raise web.notfound()
+
+
+class VersionHandler:
+ def GET(self):
+ web.header('Content-Type', 'application/json; charset=utf-8')
+ from cli import __version__
+ return json.dumps({"version": __version__})
diff --git a/cli/VERSION b/cli/VERSION
new file mode 100644
index 00000000..2165f8f9
--- /dev/null
+++ b/cli/VERSION
@@ -0,0 +1 @@
+2.0.4
diff --git a/cli/__init__.py b/cli/__init__.py
index 2a3b711e..f3bda36f 100644
--- a/cli/__init__.py
+++ b/cli/__init__.py
@@ -1,3 +1,13 @@
"""CowAgent CLI - Manage your CowAgent from the command line."""
-__version__ = "0.0.1"
+import os as _os
+
+def _read_version():
+ version_file = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "VERSION")
+ try:
+ with open(version_file, "r") as f:
+ return f.read().strip()
+ except FileNotFoundError:
+ return "0.0.0"
+
+__version__ = _read_version()
diff --git a/cli/cli.py b/cli/cli.py
index 7ea983a5..d6f1349a 100644
--- a/cli/cli.py
+++ b/cli/cli.py
@@ -7,11 +7,56 @@ from cli.commands.process import start, stop, restart, status, logs
from cli.commands.context import context
-@click.group()
-@click.version_option(__version__, '--version', '-v', prog_name='cow')
-def main():
+HELP_TEXT = """Usage: cow COMMAND [ARGS]...
+
+ CowAgent CLI - Manage your CowAgent instance.
+
+Commands:
+ help Show this message.
+ version Show the version.
+ start Start CowAgent.
+ stop Stop CowAgent.
+ restart Restart CowAgent.
+ status Show CowAgent running status.
+ logs View CowAgent logs.
+ context View or manage conversation context.
+ skill Manage CowAgent skills.
+
+Tip: You can also send /help, /skill list, etc. in chat."""
+
+
+class CowCLI(click.Group):
+
+ def format_help(self, ctx, formatter):
+ formatter.write(HELP_TEXT.strip())
+ formatter.write("\n")
+
+ def parse_args(self, ctx, args):
+ if args and args[0] == 'help':
+ click.echo(HELP_TEXT.strip())
+ ctx.exit(0)
+ return super().parse_args(ctx, args)
+
+
+@click.group(cls=CowCLI, invoke_without_command=True, context_settings=dict(help_option_names=[]))
+@click.pass_context
+def main(ctx):
"""CowAgent CLI - Manage your CowAgent instance."""
- pass
+ if ctx.invoked_subcommand is None:
+ click.echo(HELP_TEXT.strip())
+
+
+@main.command()
+def version():
+ """Show the version."""
+ click.echo(f"cow {__version__}")
+
+
+@main.command(name='help')
+@click.pass_context
+def help_cmd(ctx):
+ """Show this message."""
+ click.echo(HELP_TEXT.strip())
main.add_command(skill)
diff --git a/cli/commands/context.py b/cli/commands/context.py
index 41532f2e..38864585 100644
--- a/cli/commands/context.py
+++ b/cli/commands/context.py
@@ -1,13 +1,14 @@
"""cow context - Context management commands."""
-import os
-import sys
-import json
-import glob as glob_mod
-
import click
-from cli.utils import get_workspace_dir
+
+CHAT_HINT = (
+ "Context commands operate on the running agent's memory.\n"
+ "Please send the command in a chat conversation instead:\n\n"
+ " /context - View current context info\n"
+ " /context clear - Clear conversation context"
+)
@click.group(invoke_without_command=True)
@@ -15,67 +16,14 @@ from cli.utils import get_workspace_dir
def context(ctx):
"""View or manage conversation context.
- Without a subcommand, shows context info for the current workspace.
+ Context commands need access to the running agent's memory.
+ Use them in chat conversations: /context or /context clear
"""
if ctx.invoked_subcommand is None:
- _show_context_info()
+ click.echo(f"\n {CHAT_HINT}\n")
@context.command()
-@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
-def clear(yes):
+def clear():
"""Clear conversation context (messages history)."""
- workspace = get_workspace_dir()
- sessions_dir = os.path.join(workspace, "sessions")
-
- if not os.path.isdir(sessions_dir):
- click.echo("No conversation data found.")
- return
-
- db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db"))
- if not db_files:
- click.echo("No conversation data found.")
- return
-
- if not yes:
- click.confirm("Clear all conversation context? This cannot be undone.", abort=True)
-
- removed = 0
- for db_file in db_files:
- try:
- os.remove(db_file)
- removed += 1
- except Exception as e:
- click.echo(f"Warning: Failed to remove {db_file}: {e}", err=True)
-
- click.echo(click.style(f"✓ Cleared {removed} conversation database(s).", fg="green"))
-
-
-def _show_context_info():
- """Display conversation context status."""
- workspace = get_workspace_dir()
- sessions_dir = os.path.join(workspace, "sessions")
-
- click.echo(f"\n Context info")
- click.echo(f" Workspace: {workspace}")
-
- if not os.path.isdir(sessions_dir):
- click.echo(" Sessions: none\n")
- return
-
- db_files = glob_mod.glob(os.path.join(sessions_dir, "*.db"))
- total_size = sum(os.path.getsize(f) for f in db_files if os.path.exists(f))
-
- click.echo(f" Sessions dir: {sessions_dir}")
- click.echo(f" Database files: {len(db_files)}")
- click.echo(f" Total size: {_format_size(total_size)}")
- click.echo(f"\n Use 'cow context clear' to reset.\n")
-
-
-def _format_size(size_bytes):
- if size_bytes < 1024:
- return f"{size_bytes} B"
- elif size_bytes < 1024 * 1024:
- return f"{size_bytes / 1024:.1f} KB"
- else:
- return f"{size_bytes / (1024 * 1024):.1f} MB"
+ click.echo(f"\n {CHAT_HINT}\n")
diff --git a/cli/commands/process.py b/cli/commands/process.py
index 03dd7f20..01748bef 100644
--- a/cli/commands/process.py
+++ b/cli/commands/process.py
@@ -47,7 +47,8 @@ def _remove_pid():
@click.command()
@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonize)")
-def start(foreground):
+@click.option("--no-logs", is_flag=True, help="Don't tail logs after starting")
+def start(foreground, no_logs):
"""Start CowAgent."""
pid = _read_pid()
if pid:
@@ -81,6 +82,10 @@ def start(foreground):
click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green"))
click.echo(f" Logs: {log_file}")
+ if not no_logs:
+ click.echo(" Press Ctrl+C to stop tailing logs.\n")
+ _tail_log(log_file)
+
@click.command()
def stop():
@@ -109,23 +114,39 @@ def stop():
@click.command()
+@click.option("--no-logs", is_flag=True, help="Don't tail logs after restarting")
@click.pass_context
-def restart(ctx):
+def restart(ctx, no_logs):
"""Restart CowAgent."""
ctx.invoke(stop)
time.sleep(1)
- ctx.invoke(start)
+ ctx.invoke(start, no_logs=no_logs)
@click.command()
def status():
"""Show CowAgent running status."""
+ from cli import __version__
+ from cli.utils import load_config_json
+
pid = _read_pid()
if pid:
click.echo(click.style(f"● CowAgent is running (PID: {pid})", fg="green"))
else:
click.echo(click.style("● CowAgent is not running", fg="red"))
+ click.echo(f" 版本: v{__version__}")
+
+ cfg = load_config_json()
+ if cfg:
+ channel = cfg.get("channel_type", "unknown")
+ if isinstance(channel, list):
+ channel = ", ".join(channel)
+ click.echo(f" 通道: {channel}")
+ click.echo(f" 模型: {cfg.get('model', 'unknown')}")
+ mode = "Agent" if cfg.get("agent") else "Chat"
+ click.echo(f" 模式: {mode}")
+
@click.command()
@click.option("--follow", "-f", is_flag=True, help="Follow log output")
@@ -138,18 +159,23 @@ def logs(follow, lines):
return
if follow:
- try:
- proc = subprocess.Popen(
- ["tail", "-f", "-n", str(lines), log_file],
- stdout=sys.stdout,
- stderr=sys.stderr,
- )
- proc.wait()
- except KeyboardInterrupt:
- pass
+ _tail_log(log_file, lines)
else:
- proc = subprocess.run(
+ subprocess.run(
["tail", "-n", str(lines), log_file],
stdout=sys.stdout,
stderr=sys.stderr,
)
+
+
+def _tail_log(log_file: str, lines: int = 50):
+ """Follow log file output. Blocks until Ctrl+C."""
+ try:
+ proc = subprocess.Popen(
+ ["tail", "-f", "-n", str(lines), log_file],
+ stdout=sys.stdout,
+ stderr=sys.stderr,
+ )
+ proc.wait()
+ except KeyboardInterrupt:
+ pass
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 1d622274..c4378e2d 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -29,11 +29,12 @@ def skill():
# cow skill list
# ------------------------------------------------------------------
@skill.command("list")
-@click.option("--remote", is_flag=True, help="List skills available on Skill Hub")
-def skill_list(remote):
- """List installed skills or browse remote Skill Hub."""
+@click.option("--remote", is_flag=True, help="Browse skills on Skill Hub")
+@click.option("--page", default=1, type=int, help="Page number for remote listing")
+def skill_list(remote, page):
+ """List installed skills or browse Skill Hub."""
if remote:
- _list_remote()
+ _list_remote(page=page)
else:
_list_local()
@@ -95,10 +96,17 @@ def _print_skill_table(entries):
click.echo()
-def _list_remote():
- """List skills from remote Skill Hub."""
+_REMOTE_PAGE_SIZE = 10
+
+
+def _list_remote(page: int = 1):
+ """List skills from remote Skill Hub with server-side pagination."""
try:
- resp = requests.get(f"{SKILL_HUB_API}/skills", timeout=10)
+ resp = requests.get(
+ f"{SKILL_HUB_API}/skills",
+ params={"page": page, "limit": _REMOTE_PAGE_SIZE},
+ timeout=10,
+ )
resp.raise_for_status()
data = resp.json()
except Exception as e:
@@ -106,26 +114,40 @@ def _list_remote():
sys.exit(1)
skills = data.get("skills", [])
- if not skills:
+ total = data.get("total", len(skills))
+
+ if not skills and page == 1:
click.echo("No skills available on Skill Hub.")
return
- name_w = max(len(s.get("name", "")) for s in skills)
+ total_pages = max(1, (total + _REMOTE_PAGE_SIZE - 1) // _REMOTE_PAGE_SIZE)
+ page = min(page, total_pages)
+ installed = set(load_skills_config().keys())
+
+ name_w = max((len(s.get("name", "")) for s in skills), default=4)
name_w = max(name_w, 4) + 2
- click.echo(f"\n Skill Hub ({len(skills)} available)\n")
- click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}")
+ click.echo(f"\n Skill Hub ({total} available) — page {page}/{total_pages}\n")
+ click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}")
click.echo(f" {'─' * (name_w + 12 + 50)}")
for s in skills:
name = s.get("name", "")
- downloads = s.get("downloads", 0)
desc = s.get("description", "") or s.get("display_name", "")
if len(desc) > 50:
desc = desc[:47] + "..."
- click.echo(f" {name:<{name_w}} {downloads:<12} {desc}")
+ status = click.style("installed", fg="green") if name in installed else "—"
+ click.echo(f" {name:<{name_w}} {status:<12} {desc}")
- click.echo(f"\n Install with: cow skill install \n")
+ click.echo()
+ nav_parts = []
+ if page > 1:
+ nav_parts.append(f"cow skill list --remote --page {page - 1}")
+ if page < total_pages:
+ nav_parts.append(f"cow skill list --remote --page {page + 1}")
+ if nav_parts:
+ click.echo(f" Navigate: {' | '.join(nav_parts)}")
+ click.echo(f" Install: cow skill install \n")
# ------------------------------------------------------------------
@@ -148,20 +170,21 @@ def search(query):
click.echo(f'No skills found for "{query}".')
return
+ installed = set(load_skills_config().keys())
name_w = max(len(s.get("name", "")) for s in skills)
name_w = max(name_w, 4) + 2
click.echo(f'\n Search results for "{query}" ({len(skills)} found)\n')
- click.echo(f" {'Name':<{name_w}} {'Downloads':<12} {'Description'}")
+ click.echo(f" {'Name':<{name_w}} {'Status':<12} {'Description'}")
click.echo(f" {'─' * (name_w + 12 + 50)}")
for s in skills:
name = s.get("name", "")
- downloads = s.get("downloads", 0)
desc = s.get("description", "") or s.get("display_name", "")
if len(desc) > 50:
desc = desc[:47] + "..."
- click.echo(f" {name:<{name_w}} {downloads:<12} {desc}")
+ status = click.style("installed", fg="green") if name in installed else "—"
+ click.echo(f" {name:<{name_w}} {status:<12} {desc}")
click.echo(f"\n Install with: cow skill install \n")
diff --git a/cli/utils.py b/cli/utils.py
index 8dc229dd..b40f8dd5 100644
--- a/cli/utils.py
+++ b/cli/utils.py
@@ -59,4 +59,4 @@ def ensure_sys_path():
sys.path.insert(0, root)
-SKILL_HUB_API = "https://cow-skill-hub.pages.dev/api"
+SKILL_HUB_API = "https://skills.cowagent.ai/api"
diff --git a/skills/linkai-agent/SKILL.md b/skills/linkai-agent/SKILL.md
index 34af6799..3464e490 100644
--- a/skills/linkai-agent/SKILL.md
+++ b/skills/linkai-agent/SKILL.md
@@ -4,6 +4,7 @@ description: Call LinkAI applications and workflows. Use bash with curl to invok
homepage: https://link-ai.tech
metadata:
emoji: 🤖
+ default_enabled: false
requires:
bins: ["curl"]
env: ["LINKAI_API_KEY"]
From f890318ed9bc64f08e239020194b003e486a45ec Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 26 Mar 2026 18:13:39 +0800
Subject: [PATCH 029/399] fix: strip leading/trailing whitespace from agent
response
---
agent/protocol/agent_stream.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py
index 1aa18b84..79dcd2ab 100644
--- a/agent/protocol/agent_stream.py
+++ b/agent/protocol/agent_stream.py
@@ -472,6 +472,7 @@ class AgentStreamExecutor:
raise
finally:
+ final_response = final_response.strip() if final_response else final_response
logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
self._emit_event("agent_end", {"final_response": final_response})
From db16bdf8cb93405913ec57a89427e28d2301f1f8 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Fri, 27 Mar 2026 17:59:15 +0800
Subject: [PATCH 030/399] fix(cli): add security hardening for skill install
and process management
---
cli/cli.py | 2 +-
cli/commands/process.py | 110 +++++++---
cli/commands/skill.py | 102 +++++++--
run.sh | 13 +-
scripts/run.ps1 | 447 ++++++++++++++++++++++++++++++++++++++++
5 files changed, 635 insertions(+), 39 deletions(-)
create mode 100644 scripts/run.ps1
diff --git a/cli/cli.py b/cli/cli.py
index d6f1349a..9e01af98 100644
--- a/cli/cli.py
+++ b/cli/cli.py
@@ -22,7 +22,7 @@ Commands:
context View or manage conversation context.
skill Manage CowAgent skills.
-Tip: You can also send /help, /skill list, etc. in chat."""
+Tip: You can also send /help, /skill list, etc. in agent chat."""
class CowCLI(click.Group):
diff --git a/cli/commands/process.py b/cli/commands/process.py
index 01748bef..39e3840a 100644
--- a/cli/commands/process.py
+++ b/cli/commands/process.py
@@ -2,7 +2,6 @@
import os
import sys
-import signal
import subprocess
import time
from typing import Optional
@@ -11,6 +10,8 @@ import click
from cli.utils import get_project_root
+_IS_WIN = sys.platform == "win32"
+
def _get_pid_file():
return os.path.join(get_project_root(), ".cow.pid")
@@ -20,6 +21,40 @@ def _get_log_file():
return os.path.join(get_project_root(), "nohup.out")
+def _is_pid_alive(pid: int) -> bool:
+ """Check whether a process is still running (cross-platform)."""
+ if _IS_WIN:
+ try:
+ out = subprocess.check_output(
+ ["tasklist", "/FI", f"PID eq {pid}", "/NH"],
+ stderr=subprocess.DEVNULL,
+ )
+ return str(pid) in out.decode(errors="ignore")
+ except Exception:
+ return False
+ else:
+ try:
+ os.kill(pid, 0)
+ return True
+ except (ProcessLookupError, PermissionError):
+ return False
+
+
+def _kill_pid(pid: int, force: bool = False):
+ """Terminate a process by PID (cross-platform)."""
+ if _IS_WIN:
+ flag = "/F" if force else ""
+ cmd = ["taskkill"]
+ if force:
+ cmd.append("/F")
+ cmd.extend(["/PID", str(pid)])
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ else:
+ import signal
+ sig = signal.SIGKILL if force else signal.SIGTERM
+ os.kill(pid, sig)
+
+
def _read_pid() -> Optional[int]:
pid_file = _get_pid_file()
if not os.path.exists(pid_file):
@@ -27,11 +62,16 @@ def _read_pid() -> Optional[int]:
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
- os.kill(pid, 0)
- return pid
- except (ValueError, ProcessLookupError, PermissionError):
+ if _is_pid_alive(pid):
+ return pid
os.remove(pid_file)
return None
+ except (ValueError, OSError):
+ try:
+ os.remove(pid_file)
+ except OSError:
+ pass
+ return None
def _write_pid(pid: int):
@@ -65,18 +105,29 @@ def start(foreground, no_logs):
if foreground:
click.echo("Starting CowAgent in foreground...")
- os.execv(python, [python, app_py])
+ if _IS_WIN:
+ sys.exit(subprocess.call([python, app_py], cwd=root))
+ else:
+ os.execv(python, [python, app_py])
else:
log_file = _get_log_file()
click.echo("Starting CowAgent...")
+ popen_kwargs = dict(cwd=root)
+ if _IS_WIN:
+ CREATE_NO_WINDOW = 0x08000000
+ popen_kwargs["creationflags"] = (
+ subprocess.CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
+ )
+ else:
+ popen_kwargs["start_new_session"] = True
+
with open(log_file, "a") as log:
proc = subprocess.Popen(
[python, app_py],
- cwd=root,
stdout=log,
stderr=log,
- start_new_session=True,
+ **popen_kwargs,
)
_write_pid(proc.pid)
click.echo(click.style(f"✓ CowAgent started (PID: {proc.pid})", fg="green"))
@@ -97,16 +148,14 @@ def stop():
click.echo(f"Stopping CowAgent (PID: {pid})...")
try:
- os.kill(pid, signal.SIGTERM)
+ _kill_pid(pid)
for _ in range(30):
time.sleep(0.1)
- try:
- os.kill(pid, 0)
- except ProcessLookupError:
+ if not _is_pid_alive(pid):
break
else:
- os.kill(pid, signal.SIGKILL)
- except ProcessLookupError:
+ _kill_pid(pid, force=True)
+ except (ProcessLookupError, OSError):
pass
_remove_pid()
@@ -161,21 +210,32 @@ def logs(follow, lines):
if follow:
_tail_log(log_file, lines)
else:
- subprocess.run(
- ["tail", "-n", str(lines), log_file],
- stdout=sys.stdout,
- stderr=sys.stderr,
- )
+ _print_last_lines(log_file, lines)
+
+
+def _print_last_lines(file_path: str, n: int = 50):
+ """Print the last N lines of a file (cross-platform)."""
+ try:
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
+ all_lines = f.readlines()
+ for line in all_lines[-n:]:
+ click.echo(line, nl=False)
+ except Exception as e:
+ click.echo(f"Error reading log file: {e}", err=True)
def _tail_log(log_file: str, lines: int = 50):
- """Follow log file output. Blocks until Ctrl+C."""
+ """Follow log file output. Blocks until Ctrl+C (cross-platform)."""
+ _print_last_lines(log_file, lines)
+
try:
- proc = subprocess.Popen(
- ["tail", "-f", "-n", str(lines), log_file],
- stdout=sys.stdout,
- stderr=sys.stderr,
- )
- proc.wait()
+ with open(log_file, "r", encoding="utf-8", errors="replace") as f:
+ f.seek(0, 2)
+ while True:
+ line = f.readline()
+ if line:
+ click.echo(line, nl=False)
+ else:
+ time.sleep(0.3)
except KeyboardInterrupt:
pass
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index c4378e2d..73b929bd 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -1,12 +1,16 @@
"""cow skill - Skill management commands."""
import os
+import re
import sys
import json
+import hashlib
import shutil
import zipfile
import tempfile
+from urllib.parse import urlparse
+
import click
import requests
@@ -18,6 +22,57 @@ from cli.utils import (
SKILL_HUB_API,
)
+_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$")
+
+
+def _validate_skill_name(name: str):
+ """Reject names that contain path traversal or special characters."""
+ if not _SAFE_NAME_RE.match(name):
+ click.echo(
+ f"Error: Invalid skill name '{name}'. "
+ "Use only letters, digits, hyphens, and underscores.",
+ err=True,
+ )
+ sys.exit(1)
+
+
+def _validate_github_spec(spec: str):
+ """Reject specs that don't look like owner/repo."""
+ if not re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+$", spec):
+ click.echo(f"Error: Invalid GitHub spec '{spec}'. Expected format: owner/repo", err=True)
+ sys.exit(1)
+
+
+def _safe_extractall(zf: zipfile.ZipFile, dest: str):
+ """Extract zip while guarding against Zip Slip (path traversal)."""
+ dest = os.path.realpath(dest)
+ for member in zf.infolist():
+ target = os.path.realpath(os.path.join(dest, member.filename))
+ if not target.startswith(dest + os.sep) and target != dest:
+ raise ValueError(f"Unsafe zip entry detected: {member.filename}")
+ zf.extractall(dest)
+
+
+def _verify_checksum(content: bytes, expected: str):
+ """Verify SHA-256 checksum of downloaded content.
+
+ Returns True if checksum matches or no expected value provided.
+ Exits with error if mismatch.
+ """
+ if not expected:
+ return True
+ actual = hashlib.sha256(content).hexdigest()
+ if actual != expected.lower():
+ click.echo(
+ f"Error: Checksum mismatch!\n"
+ f" Expected: {expected}\n"
+ f" Actual: {actual}\n"
+ f"The downloaded package may have been tampered with.",
+ err=True,
+ )
+ sys.exit(1)
+ return True
+
@click.group()
def skill():
@@ -208,6 +263,7 @@ def install(name):
if name.startswith("github:"):
_install_github(name[7:])
else:
+ _validate_skill_name(name)
_install_hub(name)
@@ -239,18 +295,40 @@ def _install_hub(name):
if source_type == "github":
source_url = data.get("source_url", "")
+ _validate_github_spec(source_url)
source_path = data.get("source_path")
click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name)
return
if source_type == "registry":
- click.echo(f"This skill is from an external registry: {data.get('source_url', '')}")
- click.echo("Please install it through the corresponding platform.")
+ download_url = data.get("download_url")
+ if download_url:
+ parsed = urlparse(download_url)
+ if parsed.scheme != "https":
+ click.echo(f"Error: Refusing to download from non-HTTPS URL.", err=True)
+ sys.exit(1)
+ provider = data.get("source_provider", "registry")
+ expected_checksum = data.get("checksum") or data.get("sha256")
+ click.echo(f"Source: {provider}")
+ click.echo("Downloading skill package...")
+ try:
+ dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
+ dl_resp.raise_for_status()
+ except Exception as e:
+ click.echo(f"Error: Failed to download from {provider}: {e}", err=True)
+ sys.exit(1)
+ _verify_checksum(dl_resp.content, expected_checksum)
+ _install_zip_bytes(dl_resp.content, name, skills_dir)
+ click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
+ else:
+ click.echo(f"Error: Unsupported registry provider.", err=True)
+ sys.exit(1)
return
if "redirect" in data:
source_url = data.get("source_url", "")
+ _validate_github_spec(source_url)
source_path = data.get("source_path")
click.echo(f"Source: GitHub ({source_url})")
_install_github(source_url, subpath=source_path, skill_name=name)
@@ -258,8 +336,9 @@ def _install_hub(name):
elif "application/zip" in content_type:
click.echo("Downloading skill package...")
+ expected_checksum = resp.headers.get("X-Checksum-Sha256")
+ _verify_checksum(resp.content, expected_checksum)
_install_zip_bytes(resp.content, name, skills_dir)
- _report_install(name)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
return
@@ -275,8 +354,11 @@ def _install_github(spec, subpath=None, skill_name=None):
if "#" in spec and not subpath:
spec, subpath = spec.split("#", 1)
+ _validate_github_spec(spec)
+
if not skill_name:
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
+ _validate_skill_name(skill_name)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
@@ -298,7 +380,7 @@ def _install_github(spec, subpath=None, skill_name=None):
extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(extract_dir)
+ _safe_extractall(zf, extract_dir)
# GitHub archives have a top-level dir like "repo-main/"
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
@@ -319,7 +401,6 @@ def _install_github(spec, subpath=None, skill_name=None):
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
- _report_install(skill_name)
click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green"))
@@ -332,7 +413,7 @@ def _install_zip_bytes(content, name, skills_dir):
extract_dir = os.path.join(tmp_dir, "extracted")
with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(extract_dir)
+ _safe_extractall(zf, extract_dir)
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
source = extract_dir
@@ -345,12 +426,6 @@ def _install_zip_bytes(content, name, skills_dir):
shutil.copytree(source, target)
-def _report_install(name):
- """Report installation to Skill Hub for download counting."""
- try:
- requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5)
- except Exception:
- pass
# ------------------------------------------------------------------
@@ -361,6 +436,7 @@ def _report_install(name):
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def uninstall(name, yes):
"""Uninstall a skill."""
+ _validate_skill_name(name)
skills_dir = get_skills_dir()
skill_dir = os.path.join(skills_dir, name)
@@ -405,6 +481,7 @@ def disable(name):
def _set_enabled(name, enabled):
+ _validate_skill_name(name)
skills_dir = get_skills_dir()
config_path = os.path.join(skills_dir, "skills_config.json")
@@ -440,6 +517,7 @@ def _set_enabled(name, enabled):
@click.argument("name")
def info(name):
"""Show details about an installed skill."""
+ _validate_skill_name(name)
skills_dir = get_skills_dir()
builtin_dir = get_builtin_skills_dir()
diff --git a/run.sh b/run.sh
index d2df6355..42a3b5bb 100755
--- a/run.sh
+++ b/run.sh
@@ -242,6 +242,17 @@ install_dependencies() {
fi
rm -f /tmp/pip_install.log
+
+ # Register `cow` CLI command via editable install
+ echo -e "${YELLOW}Registering cow CLI...${NC}"
+ set +e
+ $PYTHON_CMD -m pip install -e . $PIP_EXTRA_ARGS $PIP_MIRROR > /dev/null 2>&1
+ if command -v cow &> /dev/null; then
+ echo -e "${GREEN}✅ cow CLI registered.${NC}"
+ else
+ echo -e "${YELLOW}⚠️ cow CLI not in PATH, you can still use: $PYTHON_CMD -m cli.cli${NC}"
+ fi
+ set -e
}
# Select model
@@ -603,7 +614,7 @@ ensure_python_cmd() {
# Get service PID (empty string if not running)
get_pid() {
ensure_python_cmd > /dev/null 2>&1
- ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$'
+ ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$' | head -1
}
# Check if service is running
diff --git a/scripts/run.ps1 b/scripts/run.ps1
new file mode 100644
index 00000000..50a407df
--- /dev/null
+++ b/scripts/run.ps1
@@ -0,0 +1,447 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ CowAgent installer & management script for Windows.
+.DESCRIPTION
+ One-liner install:
+ irm https://raw.githubusercontent.com/zhayujie/chatgpt-on-wechat/master/scripts/run.ps1 | iex
+ Or from a local clone:
+ .\scripts\run.ps1 # install / configure
+ .\scripts\run.ps1 start # start service (delegates to cow CLI)
+ .\scripts\run.ps1 stop|restart|status|logs|config|update|help
+#>
+
+param(
+ [Parameter(Position = 0)]
+ [string]$Command = ""
+)
+
+$ErrorActionPreference = "Stop"
+
+# ── colours ──────────────────────────────────────────────────────
+function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
+function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
+function Write-Err { param([string]$M) Write-Host $M -ForegroundColor Red }
+function Write-Info { param([string]$M) Write-Host $M -ForegroundColor Cyan }
+
+# ── detect project directory ─────────────────────────────────────
+$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
+$BaseDir = Split-Path $ScriptDir -Parent
+
+$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json")
+if (-not $IsProjectDir) {
+ $BaseDir = $PWD.Path
+ $IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json")
+}
+
+# ── Python detection ─────────────────────────────────────────────
+function Find-Python {
+ foreach ($cmd in @("python3", "python")) {
+ $bin = Get-Command $cmd -ErrorAction SilentlyContinue
+ if (-not $bin) { continue }
+ try {
+ $ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null
+ $parts = $ver -split '\.'
+ $major = [int]$parts[0]; $minor = [int]$parts[1]
+ if ($major -eq 3 -and $minor -ge 9 -and $minor -le 13) {
+ return $bin.Source
+ }
+ } catch {}
+ }
+ return $null
+}
+
+$PythonCmd = Find-Python
+function Assert-Python {
+ if (-not $PythonCmd) {
+ Write-Err "Python 3.9-3.13 not found. Please install from https://www.python.org/downloads/"
+ exit 1
+ }
+ Write-Cow "Found Python: $PythonCmd"
+}
+
+# ── clone project ────────────────────────────────────────────────
+function Install-Project {
+ if (Test-Path "chatgpt-on-wechat") {
+ Write-Warn "Directory 'chatgpt-on-wechat' already exists."
+ $choice = Read-Host "Overwrite(o), backup(b), or quit(q)? [default: b]"
+ if (-not $choice) { $choice = "b" }
+ switch ($choice.ToLower()) {
+ "o" { Remove-Item -Recurse -Force "chatgpt-on-wechat" }
+ "b" {
+ $backup = "chatgpt-on-wechat_backup_$(Get-Date -Format 'yyyyMMddHHmmss')"
+ Rename-Item "chatgpt-on-wechat" $backup
+ Write-Cow "Backed up to '$backup'"
+ }
+ "q" { Write-Err "Installation cancelled."; exit 1 }
+ default { Write-Err "Invalid choice."; exit 1 }
+ }
+ }
+
+ $gitBin = Get-Command git -ErrorAction SilentlyContinue
+ if (-not $gitBin) {
+ Write-Err "Git not found. Please install from https://git-scm.com/download/win"
+ exit 1
+ }
+
+ Write-Cow "Cloning CowAgent project..."
+ git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warn "GitHub failed, trying Gitee..."
+ git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git
+ if ($LASTEXITCODE -ne 0) {
+ Write-Err "Clone failed. Check your network."
+ exit 1
+ }
+ }
+
+ Set-Location "chatgpt-on-wechat"
+ $script:BaseDir = $PWD.Path
+ $script:IsProjectDir = $true
+ Write-Cow "Project cloned: $BaseDir"
+}
+
+# ── install dependencies ─────────────────────────────────────────
+function Install-Dependencies {
+ Write-Cow "Installing dependencies..."
+
+ & $PythonCmd -m pip install --upgrade pip setuptools wheel 2>$null | Out-Null
+
+ & $PythonCmd -m pip install -r "$BaseDir\requirements.txt" 2>&1 | ForEach-Object { Write-Host $_ }
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warn "Some dependencies may have issues, but continuing..."
+ }
+
+ Write-Cow "Registering cow CLI..."
+ & $PythonCmd -m pip install -e $BaseDir 2>$null | Out-Null
+ $cowBin = Get-Command cow -ErrorAction SilentlyContinue
+ if ($cowBin) {
+ Write-Cow "cow CLI registered."
+ } else {
+ Write-Warn "cow CLI not in PATH. You can use: $PythonCmd -m cli.cli"
+ }
+}
+
+# ── model selection ──────────────────────────────────────────────
+$ModelChoices = @{
+ "1" = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Key = "MINIMAX_KEY" }
+ "2" = @{ Provider = "Zhipu AI"; Default = "glm-5-turbo"; Key = "ZHIPU_KEY" }
+ "3" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.5"; Key = "MOONSHOT_KEY" }
+ "4" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" }
+ "5" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.5-plus"; Key = "DASHSCOPE_KEY" }
+ "6" = @{ Provider = "Claude"; Default = "claude-sonnet-4-6"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" }
+ "7" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" }
+ "8" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" }
+ "9" = @{ Provider = "LinkAI"; Default = "MiniMax-M2.7"; Key = "LINKAI_KEY" }
+}
+
+function Select-Model {
+ Write-Info "========================================="
+ Write-Info " Select AI Model"
+ Write-Info "========================================="
+ Write-Host "1) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)"
+ Write-Host "2) Zhipu AI (glm-5-turbo, glm-5, etc.)"
+ Write-Host "3) Kimi (kimi-k2.5, kimi-k2, etc.)"
+ Write-Host "4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)"
+ Write-Host "5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)"
+ Write-Host "6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)"
+ Write-Host "7) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)"
+ Write-Host "8) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)"
+ Write-Host "9) LinkAI (access multiple models via one API)"
+ Write-Host ""
+
+ do {
+ $choice = Read-Host "Enter your choice [default: 1 - MiniMax]"
+ if (-not $choice) { $choice = "1" }
+ } while ($choice -notmatch '^[1-9]$')
+
+ $m = $ModelChoices[$choice]
+ Write-Cow "Configuring $($m.Provider)..."
+
+ $script:ApiKey = Read-Host "Enter $($m.Provider) API Key"
+ $model = Read-Host "Enter model name [default: $($m.Default)]"
+ if (-not $model) { $model = $m.Default }
+ $script:ModelName = $model
+ $script:KeyName = $m.Key
+ $script:UseLinkai = ($choice -eq "9")
+
+ if ($m.Base) {
+ $base = Read-Host "Enter API Base URL [default: $($m.Base)]"
+ if (-not $base) { $base = $m.Base }
+ $script:ApiBase = $base
+ } else {
+ $script:ApiBase = ""
+ }
+ $script:ModelChoice = $choice
+}
+
+# ── channel selection ────────────────────────────────────────────
+function Select-Channel {
+ Write-Host ""
+ Write-Info "========================================="
+ Write-Info " Select Communication Channel"
+ Write-Info "========================================="
+ Write-Host "1) Weixin"
+ Write-Host "2) Feishu"
+ Write-Host "3) DingTalk"
+ Write-Host "4) WeCom Bot"
+ Write-Host "5) QQ"
+ Write-Host "6) WeCom App"
+ Write-Host "7) Web"
+ Write-Host ""
+
+ do {
+ $choice = Read-Host "Enter your choice [default: 1 - Weixin]"
+ if (-not $choice) { $choice = "1" }
+ } while ($choice -notmatch '^[1-7]$')
+
+ $script:ChannelExtra = @{}
+
+ switch ($choice) {
+ "1" { $script:ChannelType = "weixin" }
+ "2" {
+ $script:ChannelType = "feishu"
+ $script:ChannelExtra["feishu_app_id"] = Read-Host "Enter Feishu App ID"
+ $script:ChannelExtra["feishu_app_secret"] = Read-Host "Enter Feishu App Secret"
+ }
+ "3" {
+ $script:ChannelType = "dingtalk"
+ $script:ChannelExtra["dingtalk_client_id"] = Read-Host "Enter DingTalk Client ID"
+ $script:ChannelExtra["dingtalk_client_secret"] = Read-Host "Enter DingTalk Client Secret"
+ }
+ "4" {
+ $script:ChannelType = "wecom_bot"
+ $script:ChannelExtra["wecom_bot_id"] = Read-Host "Enter WeCom Bot ID"
+ $script:ChannelExtra["wecom_bot_secret"] = Read-Host "Enter WeCom Bot Secret"
+ }
+ "5" {
+ $script:ChannelType = "qq"
+ $script:ChannelExtra["qq_app_id"] = Read-Host "Enter QQ App ID"
+ $script:ChannelExtra["qq_app_secret"] = Read-Host "Enter QQ App Secret"
+ }
+ "6" {
+ $script:ChannelType = "wechatcom_app"
+ $script:ChannelExtra["wechatcom_corp_id"] = Read-Host "Enter WeChat Corp ID"
+ $script:ChannelExtra["wechatcomapp_token"] = Read-Host "Enter WeChat Com App Token"
+ $script:ChannelExtra["wechatcomapp_secret"] = Read-Host "Enter WeChat Com App Secret"
+ $script:ChannelExtra["wechatcomapp_agent_id"] = Read-Host "Enter WeChat Com App Agent ID"
+ $script:ChannelExtra["wechatcomapp_aes_key"] = Read-Host "Enter WeChat Com App AES Key"
+ $port = Read-Host "Enter port [default: 9898]"
+ if (-not $port) { $port = "9898" }
+ $script:ChannelExtra["wechatcomapp_port"] = [int]$port
+ }
+ "7" {
+ $script:ChannelType = "web"
+ $port = Read-Host "Enter web port [default: 9899]"
+ if (-not $port) { $port = "9899" }
+ $script:ChannelExtra["web_port"] = [int]$port
+ }
+ }
+}
+
+# ── generate config.json ─────────────────────────────────────────
+function New-ConfigFile {
+ Write-Cow "Generating config.json..."
+
+ $config = [ordered]@{
+ channel_type = $ChannelType
+ model = $ModelName
+ open_ai_api_key = ""
+ open_ai_api_base = "https://api.openai.com/v1"
+ claude_api_key = ""
+ claude_api_base = "https://api.anthropic.com/v1"
+ gemini_api_key = ""
+ gemini_api_base = "https://generativelanguage.googleapis.com"
+ zhipu_ai_api_key = ""
+ moonshot_api_key = ""
+ ark_api_key = ""
+ dashscope_api_key = ""
+ minimax_api_key = ""
+ voice_to_text = "openai"
+ text_to_voice = "openai"
+ voice_reply_voice = $false
+ speech_recognition = $true
+ group_speech_recognition = $false
+ use_linkai = $UseLinkai
+ linkai_api_key = ""
+ linkai_app_code = ""
+ agent = $true
+ agent_max_context_tokens = 40000
+ agent_max_context_turns = 30
+ agent_max_steps = 15
+ }
+
+ # Set the correct API key field
+ $keyMap = @{
+ OPENAI_KEY = "open_ai_api_key"
+ CLAUDE_KEY = "claude_api_key"
+ GEMINI_KEY = "gemini_api_key"
+ ZHIPU_KEY = "zhipu_ai_api_key"
+ MOONSHOT_KEY = "moonshot_api_key"
+ ARK_KEY = "ark_api_key"
+ DASHSCOPE_KEY = "dashscope_api_key"
+ MINIMAX_KEY = "minimax_api_key"
+ LINKAI_KEY = "linkai_api_key"
+ }
+ if ($keyMap.ContainsKey($KeyName)) {
+ $config[$keyMap[$KeyName]] = $ApiKey
+ }
+
+ # Set API base if provided
+ $baseMap = @{
+ "6" = "claude_api_base"
+ "7" = "gemini_api_base"
+ "8" = "open_ai_api_base"
+ }
+ if ($ApiBase -and $baseMap.ContainsKey($ModelChoice)) {
+ $config[$baseMap[$ModelChoice]] = $ApiBase
+ }
+
+ # Merge channel-specific fields
+ foreach ($k in $ChannelExtra.Keys) {
+ $config[$k] = $ChannelExtra[$k]
+ }
+
+ $config | ConvertTo-Json -Depth 5 | Set-Content -Path "$BaseDir\config.json" -Encoding UTF8
+ Write-Cow "Configuration file created."
+}
+
+# ── start via cow CLI ─────────────────────────────────────────────
+function Start-CowAgent {
+ Write-Cow "Starting CowAgent..."
+ $cowBin = Get-Command cow -ErrorAction SilentlyContinue
+ if ($cowBin) {
+ & cow start
+ } else {
+ Write-Warn "cow CLI not found, starting directly..."
+ & $PythonCmd "$BaseDir\app.py"
+ }
+}
+
+# ── delegate management commands to cow CLI ──────────────────────
+function Invoke-CowCommand {
+ param([string]$Cmd)
+ $cowBin = Get-Command cow -ErrorAction SilentlyContinue
+ if ($cowBin) {
+ & cow $Cmd
+ } else {
+ Write-Err "cow CLI not found. Run this script without arguments first to install."
+ exit 1
+ }
+}
+
+# ── usage ─────────────────────────────────────────────────────────
+function Show-Usage {
+ Write-Info "========================================="
+ Write-Info " CowAgent Management Script (Windows)"
+ Write-Info "========================================="
+ Write-Host ""
+ Write-Host "Usage:"
+ Write-Host " .\run.ps1 # Install / Configure"
+ Write-Host " .\run.ps1 # Management command"
+ Write-Host ""
+ Write-Host "Commands:"
+ Write-Host " start Start the service"
+ Write-Host " stop Stop the service"
+ Write-Host " restart Restart the service"
+ Write-Host " status Check service status"
+ Write-Host " logs View logs"
+ Write-Host " config Reconfigure project"
+ Write-Host " update Update and restart"
+ Write-Host " help Show this message"
+ Write-Host ""
+}
+
+# ── install mode ──────────────────────────────────────────────────
+function Install-Mode {
+ Clear-Host
+ Write-Info "========================================="
+ Write-Info " CowAgent Installation (Windows)"
+ Write-Info "========================================="
+ Write-Host ""
+
+ if ($IsProjectDir) {
+ Write-Cow "Detected existing project directory."
+ if (Test-Path "$BaseDir\config.json") {
+ Write-Cow "Project already configured."
+ Write-Host ""
+ Show-Usage
+ return
+ }
+ Write-Warn "No config.json found. Let's configure your project!"
+ Write-Host ""
+ Assert-Python
+ } else {
+ Assert-Python
+ Install-Project
+ }
+
+ Install-Dependencies
+ Select-Model
+ Select-Channel
+ New-ConfigFile
+
+ Write-Host ""
+ $startNow = Read-Host "Start CowAgent now? [Y/n]"
+ if ($startNow -ne "n" -and $startNow -ne "N") {
+ Start-CowAgent
+ } else {
+ Write-Cow "Installation complete!"
+ Write-Host ""
+ Write-Host "To start manually:"
+ Write-Host " cd $BaseDir"
+ Write-Host " cow start"
+ }
+}
+
+# ── update ────────────────────────────────────────────────────────
+function Update-Project {
+ Write-Cow "Updating CowAgent..."
+ Set-Location $BaseDir
+
+ # Stop if running
+ $cowBin = Get-Command cow -ErrorAction SilentlyContinue
+ if ($cowBin) { & cow stop 2>$null }
+
+ if (Test-Path "$BaseDir\.git") {
+ Write-Cow "Pulling latest code..."
+ git pull 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warn "GitHub failed, trying Gitee..."
+ git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
+ git pull
+ }
+ } else {
+ Write-Warn "Not a git repository, skipping code update."
+ }
+
+ Assert-Python
+ Install-Dependencies
+ Start-CowAgent
+}
+
+# ── main ──────────────────────────────────────────────────────────
+switch ($Command.ToLower()) {
+ "" { Install-Mode }
+ "start" { Invoke-CowCommand "start" }
+ "stop" { Invoke-CowCommand "stop" }
+ "restart" { Invoke-CowCommand "restart" }
+ "status" { Invoke-CowCommand "status" }
+ "logs" { Invoke-CowCommand "logs" }
+ "config" {
+ Assert-Python
+ Install-Dependencies
+ Select-Model
+ Select-Channel
+ New-ConfigFile
+ $r = Read-Host "Restart service now? [Y/n]"
+ if ($r -ne "n" -and $r -ne "N") { Invoke-CowCommand "restart" }
+ }
+ "update" { Update-Project }
+ "help" { Show-Usage }
+ default {
+ Write-Err "Unknown command: $Command"
+ Show-Usage
+ exit 1
+ }
+}
From 0684becaa715f5afe8c84b07756d2074678b722a Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 14:42:18 +0800
Subject: [PATCH 031/399] fix(cli): register skill when installing
---
cli/commands/skill.py | 59 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 59 insertions(+)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 73b929bd..26be0d79 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -25,6 +25,62 @@ from cli.utils import (
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$")
+def _register_installed_skill(name: str):
+ """Register a newly installed skill into skills_config.json."""
+ skills_dir = get_skills_dir()
+ config_path = os.path.join(skills_dir, "skills_config.json")
+
+ config = {}
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ except Exception:
+ config = {}
+
+ if name in config:
+ return
+
+ skill_dir = os.path.join(skills_dir, name)
+ description = _read_skill_description(skill_dir) or ""
+
+ config[name] = {
+ "name": name,
+ "description": description,
+ "source": "custom",
+ "enabled": True,
+ "category": "skill",
+ }
+
+ try:
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+ except Exception:
+ pass
+
+
+def _read_skill_description(skill_dir: str) -> str:
+ """Read the description from a skill's SKILL.md frontmatter."""
+ skill_md = os.path.join(skill_dir, "SKILL.md")
+ if not os.path.exists(skill_md):
+ return ""
+ try:
+ with open(skill_md, "r", encoding="utf-8") as f:
+ content = f.read()
+ import re as re_mod
+ match = re_mod.match(r'^---\s*\n(.*?)\n---\s*\n', content, re_mod.DOTALL)
+ if not match:
+ return ""
+ for line in match.group(1).split('\n'):
+ line = line.strip()
+ if line.startswith('description:'):
+ desc = line[len('description:'):].strip()
+ return desc.strip('"').strip("'")
+ except Exception:
+ pass
+ return ""
+
+
def _validate_skill_name(name: str):
"""Reject names that contain path traversal or special characters."""
if not _SAFE_NAME_RE.match(name):
@@ -320,6 +376,7 @@ def _install_hub(name):
sys.exit(1)
_verify_checksum(dl_resp.content, expected_checksum)
_install_zip_bytes(dl_resp.content, name, skills_dir)
+ _register_installed_skill(name)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
else:
click.echo(f"Error: Unsupported registry provider.", err=True)
@@ -339,6 +396,7 @@ def _install_hub(name):
expected_checksum = resp.headers.get("X-Checksum-Sha256")
_verify_checksum(resp.content, expected_checksum)
_install_zip_bytes(resp.content, name, skills_dir)
+ _register_installed_skill(name)
click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
return
@@ -401,6 +459,7 @@ def _install_github(spec, subpath=None, skill_name=None):
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
+ _register_installed_skill(skill_name)
click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green"))
From 1e8959fbcf1fbe7520731e83dc1efce0fd648766 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 15:08:57 +0800
Subject: [PATCH 032/399] fix: optimize repo clone in run.sh
---
.gitignore | 1 +
pyproject.toml | 2 +-
run.sh | 7 +++++--
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
index 2c5a8509..1aee851f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,7 @@ client_config.json
ref/
.cursor/
local/
+node_modules/
# cow cli
dist/
diff --git a/pyproject.toml b/pyproject.toml
index db8aa04d..565d07e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[build-system]
-requires = ["setuptools>=68.0"]
+requires = ["setuptools>=45.0"]
build-backend = "setuptools.build_meta"
[project]
diff --git a/run.sh b/run.sh
index 42a3b5bb..7fa00c73 100755
--- a/run.sh
+++ b/run.sh
@@ -171,8 +171,11 @@ clone_project() {
mv chatgpt-on-wechat-master chatgpt-on-wechat
rm chatgpt-on-wechat.zip
else
- git clone https://github.com/zhayujie/chatgpt-on-wechat.git || \
- git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git
+ GIT_HTTP_CONNECT_TIMEOUT=10 GIT_HTTP_LOW_SPEED_LIMIT=1024 GIT_HTTP_LOW_SPEED_TIME=15 \
+ git clone --depth 10 --progress https://github.com/zhayujie/chatgpt-on-wechat.git || {
+ echo -e "${YELLOW}⚠️ GitHub is slow, switching to Gitee mirror...${NC}"
+ git clone --depth 10 --progress https://gitee.com/zhayujie/chatgpt-on-wechat.git
+ }
if [[ $? -ne 0 ]]; then
echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}"
exit 1
From 4dd7ea886a0e63c2ca637752d275eefa1e884a18 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 16:26:41 +0800
Subject: [PATCH 033/399] feat(cli): cli options in web console
---
.gitignore | 1 +
channel/web/chat.html | 5 +-
channel/web/static/css/console.css | 84 +++
channel/web/static/js/console.js | 142 ++++-
cli/cli.py | 1 -
plugins/cow_cli/__init__.py | 1 +
plugins/cow_cli/cow_cli.py | 904 +++++++++++++++++++++++++++++
run.sh | 204 ++++---
8 files changed, 1257 insertions(+), 85 deletions(-)
create mode 100644 plugins/cow_cli/__init__.py
create mode 100644 plugins/cow_cli/cow_cli.py
diff --git a/.gitignore b/.gitignore
index 1aee851f..44fc64fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ plugins/banwords/lib/__pycache__
!plugins/keyword
!plugins/linkai
!plugins/agent
+!plugins/cow_cli
client_config.json
ref/
.cursor/
diff --git a/channel/web/chat.html b/channel/web/chat.html
index 80cb9319..f9dd1eee 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -270,7 +270,7 @@
-
+
+
+ placeholder="Type a message, or press / for commands">
Commands
' +
+ slashFiltered.map((c, i) =>
+ ``
+ ).join('');
+
+ slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
+ el.addEventListener('mouseenter', () => {
+ slashActiveIdx = parseInt(el.dataset.idx);
+ renderSlashItems();
+ });
+ el.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ selectSlashCommand(parseInt(el.dataset.idx));
+ });
+ });
+
+ const activeEl = slashMenu.querySelector('.slash-menu-item.active');
+ if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
+}
+
+function selectSlashCommand(idx) {
+ if (idx < 0 || idx >= slashFiltered.length) return;
+ const chosen = slashFiltered[idx].cmd;
+ slashJustSelected = true;
+ chatInput.value = chosen;
+ chatInput.dispatchEvent(new Event('input'));
+ hideSlashMenu();
+ chatInput.focus();
+ chatInput.selectionStart = chatInput.selectionEnd = chosen.length;
+}
+
chatInput.addEventListener('input', function() {
this.style.height = '42px';
const scrollH = this.scrollHeight;
@@ -442,11 +535,50 @@ chatInput.addEventListener('input', function() {
this.style.height = newH + 'px';
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
updateSendBtnState();
+
+ const val = this.value;
+ if (slashJustSelected) {
+ slashJustSelected = false;
+ } else if (val.startsWith('/')) {
+ showSlashMenu(val);
+ } else {
+ hideSlashMenu();
+ }
});
chatInput.addEventListener('keydown', function(e) {
- // keyCode 229 indicates an IME is processing the keystroke (reliable across browsers)
if (e.keyCode === 229 || e.isComposing || isComposing) return;
+
+ if (isSlashMenuVisible()) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1);
+ renderSlashItems();
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ slashActiveIdx = Math.max(slashActiveIdx - 1, 0);
+ renderSlashItems();
+ return;
+ }
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
+ e.preventDefault();
+ selectSlashCommand(slashActiveIdx);
+ return;
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ hideSlashMenu();
+ return;
+ }
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ selectSlashCommand(slashActiveIdx);
+ return;
+ }
+ }
+
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
const start = this.selectionStart;
const end = this.selectionEnd;
@@ -460,6 +592,10 @@ chatInput.addEventListener('keydown', function(e) {
}
});
+chatInput.addEventListener('blur', () => {
+ setTimeout(hideSlashMenu, 150);
+});
+
document.querySelectorAll('.example-card').forEach(card => {
card.addEventListener('click', () => {
const textEl = card.querySelector('[data-i18n*="text"]');
diff --git a/cli/cli.py b/cli/cli.py
index 9e01af98..14830747 100644
--- a/cli/cli.py
+++ b/cli/cli.py
@@ -19,7 +19,6 @@ Commands:
restart Restart CowAgent.
status Show CowAgent running status.
logs View CowAgent logs.
- context View or manage conversation context.
skill Manage CowAgent skills.
Tip: You can also send /help, /skill list, etc. in agent chat."""
diff --git a/plugins/cow_cli/__init__.py b/plugins/cow_cli/__init__.py
new file mode 100644
index 00000000..a535239f
--- /dev/null
+++ b/plugins/cow_cli/__init__.py
@@ -0,0 +1 @@
+from .cow_cli import CowCliPlugin
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
new file mode 100644
index 00000000..ffbe5a4f
--- /dev/null
+++ b/plugins/cow_cli/cow_cli.py
@@ -0,0 +1,904 @@
+"""
+CowCli plugin - Intercept cow/slash commands in chat messages.
+
+Matches messages like:
+ cow skill list
+ cow context clear
+ /skill list
+ /context clear
+ /status
+
+Does NOT match:
+ cow是什么
+ cow真好用
+ /开头但不是已知命令
+"""
+
+import os
+import threading
+
+import plugins
+from plugins import Plugin, Event, EventContext, EventAction
+from bridge.context import ContextType
+from bridge.reply import Reply, ReplyType
+from common.log import logger
+from cli import __version__
+
+
+# Known top-level subcommands that cow supports
+KNOWN_COMMANDS = {
+ "help", "version", "status", "logs",
+ "start", "stop", "restart",
+ "skill", "context", "config",
+}
+
+# Commands that can only run from the CLI (terminal), not in chat
+CLI_ONLY_COMMANDS = {"start", "stop", "restart"}
+
+# Commands that can only run from chat (need access to in-process memory)
+CHAT_ONLY_COMMANDS = set() # context is allowed in both, but behaves differently
+
+
+@plugins.register(
+ name="cow_cli",
+ desc="Handle cow/slash commands in chat messages",
+ version="0.1.0",
+ author="CowAgent",
+ desire_priority=1000,
+)
+class CowCliPlugin(Plugin):
+
+ def __init__(self):
+ super().__init__()
+ self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
+ logger.debug("[CowCli] initialized")
+
+ def on_handle_context(self, e_context: EventContext):
+ if e_context["context"].type != ContextType.TEXT:
+ return
+
+ content = e_context["context"].content.strip()
+ parsed = self._parse_command(content)
+ if not parsed:
+ return
+
+ cmd, args = parsed
+ logger.info(f"[CowCli] intercepted command: {cmd} {args}")
+
+ result = self._dispatch(cmd, args, e_context)
+
+ reply = Reply(ReplyType.TEXT, result)
+ e_context["reply"] = reply
+ e_context.action = EventAction.BREAK_PASS
+
+ def _parse_command(self, content: str):
+ """
+ Parse cow command from message text.
+
+ Supported formats:
+ cow
[args...] e.g. "cow skill list"
+ / [args...] e.g. "/skill list"
+
+ Returns (command, args_string) or None if not a cow command.
+ """
+ parts = None
+
+ if content.startswith("/"):
+ rest = content[1:].strip()
+ if rest:
+ parts = rest.split(None, 1)
+ elif content.startswith("cow "):
+ rest = content[4:].strip()
+ if rest:
+ parts = rest.split(None, 1)
+
+ if not parts:
+ return None
+
+ cmd = parts[0].lower()
+ if cmd not in KNOWN_COMMANDS:
+ return None
+
+ args = parts[1] if len(parts) > 1 else ""
+ return cmd, args
+
+ # ------------------------------------------------------------------
+ # Command dispatch
+ # ------------------------------------------------------------------
+
+ def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str:
+ if cmd in CLI_ONLY_COMMANDS:
+ return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}"
+
+ handler = getattr(self, f"_cmd_{cmd}", None)
+ if handler:
+ try:
+ return handler(args, e_context)
+ except Exception as e:
+ logger.error(f"[CowCli] command '{cmd}' failed: {e}")
+ return f"命令执行失败: {e}"
+
+ return f"未知命令: {cmd}"
+
+ # ------------------------------------------------------------------
+ # help / version
+ # ------------------------------------------------------------------
+
+ def _cmd_help(self, args: str, e_context: EventContext) -> str:
+ lines = [
+ "📋 CowAgent 命令列表",
+ "",
+ " /help 显示此帮助",
+ " /version 查看版本",
+ " /status 查看运行状态",
+ " /logs [N] 查看最近N条日志 (默认20)",
+ " /context 查看当前对话上下文信息",
+ " /context clear 清除当前对话上下文",
+ " /skill list 查看已安装的技能",
+ " /skill list --remote 浏览技能广场",
+ " /skill search <关键词> 搜索技能",
+ " /skill install <名称> 安装技能",
+ " /skill info <名称> 查看技能详情",
+ " /config 查看当前配置",
+ " /config 查看某项配置",
+ " /config 修改配置",
+ "",
+ "💡 也可以用 cow 代替 /",
+ ]
+ return "\n".join(lines)
+
+ def _cmd_version(self, args: str, e_context: EventContext) -> str:
+ return f"CowAgent v{__version__}"
+
+ # ------------------------------------------------------------------
+ # status
+ # ------------------------------------------------------------------
+
+ def _cmd_status(self, args: str, e_context: EventContext) -> str:
+ from config import conf
+
+ cfg = conf()
+ lines = ["📊 CowAgent 运行状态", ""]
+
+ lines.append(f" 版本: v{__version__}")
+ lines.append(f" 进程: PID {os.getpid()}")
+
+ channel = cfg.get("channel_type", "unknown")
+ if isinstance(channel, list):
+ channel = ", ".join(channel)
+ lines.append(f" 通道: {channel}")
+
+ model_name = cfg.get("model", "unknown")
+ lines.append(f" 模型: {model_name}")
+
+ mode = "Agent" if cfg.get("agent") else "Chat"
+ lines.append(f" 模式: {mode}")
+
+ session_id = self._get_session_id(e_context)
+ agent = self._get_agent(session_id)
+ if agent:
+ lines.append("")
+ with agent.messages_lock:
+ msg_count = len(agent.messages)
+ lines.append(f" 会话消息数: {msg_count}")
+
+ if agent.skill_manager:
+ total = len(agent.skill_manager.skills)
+ enabled = sum(
+ 1 for v in agent.skill_manager.skills_config.values()
+ if v.get("enabled", True)
+ )
+ lines.append(f" 已加载技能: {enabled}/{total}")
+ else:
+ lines.append("")
+ lines.append(f" Agent: 未初始化 (首次对话后自动创建)")
+
+ return "\n".join(lines)
+
+ # ------------------------------------------------------------------
+ # logs
+ # ------------------------------------------------------------------
+
+ def _cmd_logs(self, args: str, e_context: EventContext) -> str:
+ num_lines = 20
+ if args.strip().isdigit():
+ num_lines = min(int(args.strip()), 50)
+
+ log_file = self._find_log_file()
+ if not log_file:
+ return "未找到日志文件"
+
+ try:
+ with open(log_file, "r", encoding="utf-8", errors="replace") as f:
+ all_lines = f.readlines()
+ tail = all_lines[-num_lines:]
+ content = "".join(tail).strip()
+ if not content:
+ return "日志为空"
+ return f"📄 最近 {len(tail)} 条日志:\n\n{content}"
+ except Exception as e:
+ return f"读取日志失败: {e}"
+
+ def _find_log_file(self) -> str:
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ candidates = [
+ os.path.join(project_root, "nohup.out"),
+ os.path.join(project_root, "run.log"),
+ ]
+ import glob as glob_mod
+ candidates.extend(sorted(glob_mod.glob(os.path.join(project_root, "logs", "*.log")), reverse=True))
+ for f in candidates:
+ if os.path.isfile(f) and os.path.getsize(f) > 0:
+ return f
+ return ""
+
+ # ------------------------------------------------------------------
+ # context
+ # ------------------------------------------------------------------
+
+ def _cmd_context(self, args: str, e_context: EventContext) -> str:
+ session_id = self._get_session_id(e_context)
+ agent = self._get_agent(session_id)
+
+ sub = args.strip().lower()
+ if sub == "clear":
+ return self._context_clear(agent, session_id)
+ else:
+ return self._context_info(agent, session_id)
+
+ def _context_info(self, agent, session_id: str) -> str:
+ if not agent:
+ return "⚠️ Agent 未初始化,暂无上下文信息"
+
+ with agent.messages_lock:
+ messages = agent.messages.copy()
+
+ if not messages:
+ return "当前对话上下文为空"
+
+ user_msgs = sum(1 for m in messages if m.get("role") == "user")
+ assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
+ tool_msgs = sum(1 for m in messages if m.get("role") == "tool")
+
+ total_chars = sum(len(str(m.get("content", ""))) for m in messages)
+
+ lines = [
+ "💬 当前对话上下文",
+ "",
+ f" 会话: {session_id or 'default'}",
+ f" 总消息数: {len(messages)}",
+ f" 用户消息: {user_msgs}",
+ f" 助手回复: {assistant_msgs}",
+ f" 工具调用: {tool_msgs}",
+ f" 内容总长度: ~{total_chars} 字符",
+ "",
+ " 发送 /context clear 可清除对话上下文",
+ ]
+ return "\n".join(lines)
+
+ def _context_clear(self, agent, session_id: str) -> str:
+ if not agent:
+ return "⚠️ Agent 未初始化"
+
+ with agent.messages_lock:
+ count = len(agent.messages)
+ agent.messages.clear()
+
+ return f"✅ 已清除当前对话上下文 ({count} 条消息)"
+
+ # ------------------------------------------------------------------
+ # config
+ # ------------------------------------------------------------------
+
+ _CONFIG_WRITABLE = {
+ "model",
+ "agent_max_context_tokens",
+ "agent_max_context_turns",
+ "agent_max_steps",
+ }
+
+ _CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
+
+ def _cmd_config(self, args: str, e_context: EventContext) -> str:
+ from config import conf, load_config
+ import json as _json
+
+ parts = args.strip().split(None, 1)
+ if not parts:
+ return self._config_show_all()
+
+ key = parts[0].lower()
+ if len(parts) == 1:
+ return self._config_get(key)
+
+ value_str = parts[1].strip()
+ return self._config_set(key, value_str)
+
+ def _config_show_all(self) -> str:
+ from config import conf
+ cfg = conf()
+ lines = ["⚙️ 当前配置", ""]
+ for key in sorted(self._CONFIG_READABLE):
+ val = cfg.get(key, "")
+ lines.append(f" {key}: {val}")
+ lines.append("")
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append("💡 /config 查看配置")
+ lines.append("💡 /config 修改配置")
+ return "\n".join(lines)
+
+ def _config_get(self, key: str) -> str:
+ from config import conf
+ if key not in self._CONFIG_READABLE:
+ available = ", ".join(sorted(self._CONFIG_READABLE))
+ return f"不支持查看 '{key}'\n\n可查看的配置项: {available}"
+ val = conf().get(key, "")
+ return f"⚙️ {key}: {val}"
+
+ def _config_set(self, key: str, value_str: str) -> str:
+ from config import conf, load_config
+ import json as _json
+
+ if key not in self._CONFIG_WRITABLE:
+ if key in self._CONFIG_READABLE:
+ return f"⚠️ '{key}' 为只读配置,不支持修改"
+ available = ", ".join(sorted(self._CONFIG_WRITABLE))
+ return f"不支持修改 '{key}'\n\n可修改的配置项: {available}"
+
+ old_val = conf().get(key, "")
+
+ try:
+ new_val = _json.loads(value_str)
+ except (_json.JSONDecodeError, ValueError):
+ if value_str.lower() == "true":
+ new_val = True
+ elif value_str.lower() == "false":
+ new_val = False
+ else:
+ new_val = value_str
+
+ updates = {key: new_val}
+
+ if key == "model" and conf().get("bot_type"):
+ resolved = self._resolve_bot_type_for_model(str(new_val))
+ if resolved:
+ updates["bot_type"] = resolved
+
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ config_path = os.path.join(project_root, "config.json")
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ file_config = _json.load(f)
+ file_config.update(updates)
+ with open(config_path, "w", encoding="utf-8") as f:
+ _json.dump(file_config, f, indent=4, ensure_ascii=False)
+ except Exception as e:
+ return f"写入 config.json 失败: {e}"
+
+ try:
+ load_config()
+ except Exception as e:
+ logger.warning(f"[CowCli] config reload warning: {e}")
+
+ result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}"
+ if "bot_type" in updates and updates["bot_type"] != conf().get("bot_type"):
+ result += f"\n bot_type: → {updates['bot_type']}"
+ return result
+
+ @staticmethod
+ def _resolve_bot_type_for_model(model_name: str) -> str:
+ """Resolve bot_type from model name, reusing AgentBridge mapping."""
+ from common import const
+ _EXACT = {
+ "wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
+ "xunfei": const.XUNFEI, const.QWEN: const.QWEN,
+ const.MODELSCOPE: const.MODELSCOPE,
+ const.MOONSHOT: const.MOONSHOT,
+ "moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT,
+ "moonshot-v1-128k": const.MOONSHOT,
+ }
+ _PREFIX = [
+ ("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), ("deepseek", const.DEEPSEEK),
+ ]
+ if not model_name:
+ return const.OPENAI
+ if model_name in _EXACT:
+ return _EXACT[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
+ for prefix, btype in _PREFIX:
+ if model_name.startswith(prefix):
+ return btype
+ return const.OPENAI
+
+ # ------------------------------------------------------------------
+ # skill
+ # ------------------------------------------------------------------
+
+ def _cmd_skill(self, args: str, e_context: EventContext) -> str:
+ parts = args.strip().split(None, 1)
+ sub = parts[0].lower() if parts else ""
+ sub_args = parts[1].strip() if len(parts) > 1 else ""
+
+ if sub == "list":
+ return self._skill_list(sub_args)
+ elif sub == "search":
+ return self._skill_search(sub_args)
+ elif sub == "install":
+ return self._skill_install(sub_args, e_context)
+ elif sub == "uninstall":
+ return self._skill_uninstall(sub_args)
+ elif sub == "info":
+ return self._skill_info(sub_args)
+ elif sub == "enable":
+ return self._skill_set_enabled(sub_args, True)
+ elif sub == "disable":
+ return self._skill_set_enabled(sub_args, False)
+ else:
+ return (
+ "用法: /skill <子命令>\n\n"
+ "子命令:\n"
+ " list [--remote] 查看技能列表\n"
+ " search <关键词> 搜索技能\n"
+ " install <名称> 安装技能\n"
+ " uninstall <名称> 卸载技能\n"
+ " info <名称> 查看技能详情\n"
+ " enable <名称> 启用技能\n"
+ " disable <名称> 禁用技能"
+ )
+
+ def _skill_list_local(self) -> str:
+ from cli.utils import load_skills_config, get_skills_dir, get_builtin_skills_dir
+ config = load_skills_config()
+
+ if not config:
+ skills_dir = get_skills_dir()
+ builtin_dir = get_builtin_skills_dir()
+ entries = []
+ for d, source in [(builtin_dir, "builtin"), (skills_dir, "custom")]:
+ if not os.path.isdir(d):
+ continue
+ for name in sorted(os.listdir(d)):
+ skill_path = os.path.join(d, name)
+ if os.path.isdir(skill_path) and not name.startswith("."):
+ if os.path.exists(os.path.join(skill_path, "SKILL.md")):
+ entries.append({"name": name, "source": source, "enabled": True})
+ if not entries:
+ return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场"
+ config = {e["name"]: e for e in entries}
+
+ sorted_entries = sorted(config.values(), key=lambda e: e.get("name", ""))
+ enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True))
+
+ lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""]
+ for entry in sorted_entries:
+ name = entry.get("name", "")
+ enabled = entry.get("enabled", True)
+ source = entry.get("source", "")
+ icon = "✅" if enabled else "⏸️"
+ desc = entry.get("description", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "…"
+ source_tag = f" · {source}" if source else ""
+ line = f"{icon} {name}{source_tag}"
+ if desc:
+ line += f"\n {desc}"
+ lines.append(line)
+ lines.append("")
+
+ lines.append("")
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append("💡 /skill list --remote 浏览技能广场")
+ lines.append("💡 /skill info <名称> 查看详情")
+ return "\n".join(lines)
+
+ def _skill_list(self, args: str) -> str:
+ parts = args.strip().split()
+ if "--remote" in parts or "-r" in parts:
+ page = 1
+ for i, p in enumerate(parts):
+ if p == "--page" and i + 1 < len(parts) and parts[i + 1].isdigit():
+ page = max(1, int(parts[i + 1]))
+ return self._skill_list_remote(page=page)
+ return self._skill_list_local()
+
+ _REMOTE_PAGE_SIZE = 10
+
+ def _skill_list_remote(self, page: int = 1) -> str:
+ import requests
+ from cli.utils import SKILL_HUB_API, load_skills_config
+ page_size = self._REMOTE_PAGE_SIZE
+ try:
+ resp = requests.get(
+ f"{SKILL_HUB_API}/skills",
+ params={"page": page, "limit": page_size},
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ skills = data.get("skills", [])
+ total = data.get("total", len(skills))
+ except Exception as e:
+ return f"获取技能广场失败: {e}"
+
+ if not skills and page == 1:
+ return "技能广场暂无可用技能"
+
+ total_pages = max(1, (total + page_size - 1) // page_size)
+ page = min(page, total_pages)
+ installed = set(load_skills_config().keys())
+
+ lines = [f"🌐 技能广场 (共 {total} 个技能)", ""]
+ for s in skills:
+ name = s.get("name", "")
+ display = s.get("display_name", "") or name
+ desc = s.get("description", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "…"
+ badge = " [已安装]" if name in installed else ""
+ lines.append(f"📌 {display}{badge}")
+ lines.append(f" 名称: {name}")
+ if desc:
+ lines.append(f" {desc}")
+ lines.append("")
+
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append(f"📄 第 {page}/{total_pages} 页")
+ if page < total_pages:
+ lines.append(f"💡 /skill list --remote --page {page + 1} 下一页")
+ if page > 1:
+ lines.append(f"💡 /skill list --remote --page {page - 1} 上一页")
+ lines.append("💡 /skill install <名称> 安装技能")
+ lines.append("💡 /skill search <关键词> 搜索技能")
+ return "\n".join(lines)
+
+ def _skill_search(self, query: str) -> str:
+ if not query:
+ return "请指定搜索关键词: /skill search <关键词>"
+
+ import requests
+ from cli.utils import SKILL_HUB_API, load_skills_config
+ try:
+ resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10)
+ resp.raise_for_status()
+ skills = resp.json().get("skills", [])
+ except Exception as e:
+ return f"搜索失败: {e}"
+
+ if not skills:
+ return f"未找到与「{query}」相关的技能"
+
+ installed = set(load_skills_config().keys())
+ lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""]
+ for s in skills:
+ name = s.get("name", "")
+ display = s.get("display_name", "") or name
+ desc = s.get("description", "")
+ if len(desc) > 50:
+ desc = desc[:47] + "…"
+ badge = " [已安装]" if name in installed else ""
+ lines.append(f"📌 {display}{badge}")
+ lines.append(f" 名称: {name}")
+ if desc:
+ lines.append(f" {desc}")
+ lines.append("")
+
+ lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ lines.append("💡 /skill install <名称> 安装技能")
+ return "\n".join(lines)
+
+ def _skill_install(self, name: str, e_context: EventContext) -> str:
+ if not name:
+ return "请指定要安装的技能: /skill install <名称>"
+
+ # Run installation in a thread to avoid blocking
+ # For now, invoke the CLI logic directly
+ try:
+ from cli.utils import get_skills_dir, SKILL_HUB_API
+ import requests
+ import shutil
+ import zipfile
+ import tempfile
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ if name.startswith("github:"):
+ return self._skill_install_github(name[7:], skills_dir)
+
+ resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15)
+ resp.raise_for_status()
+
+ content_type = resp.headers.get("Content-Type", "")
+
+ if "application/json" in content_type:
+ data = resp.json()
+ source_type = data.get("source_type")
+ if source_type == "github" or "redirect" in data:
+ source_url = data.get("source_url", "")
+ source_path = data.get("source_path")
+ return self._skill_install_github(source_url, skills_dir, subpath=source_path, skill_name=name)
+ if source_type == "registry":
+ download_url = data.get("download_url")
+ if not download_url:
+ return f"此技能来自不支持的注册表,无法自动安装。"
+ from urllib.parse import urlparse
+ if urlparse(download_url).scheme != "https":
+ return "安装失败: 下载地址不安全 (非 HTTPS)"
+ provider = data.get("source_provider", "registry")
+ try:
+ dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
+ dl_resp.raise_for_status()
+ except Exception as e:
+ return f"从 {provider} 下载失败: {e}"
+ self._extract_zip(dl_resp.content, name, skills_dir)
+ self._report_install(name)
+ return f"✅ 技能 '{name}' 安装成功!"
+
+ elif "application/zip" in content_type:
+ self._extract_zip(resp.content, name, skills_dir)
+ self._report_install(name)
+ return f"✅ 技能 '{name}' 安装成功!"
+
+ return "技能商店返回了未预期的响应格式"
+
+ except requests.HTTPError as e:
+ if e.response is not None and e.response.status_code == 404:
+ return f"技能 '{name}' 未在技能商店中找到"
+ return f"安装失败: {e}"
+ except Exception as e:
+ return f"安装失败: {e}"
+
+ def _skill_install_github(self, spec: str, skills_dir: str,
+ subpath: str = None, skill_name: str = None) -> str:
+ import requests
+ import shutil
+ import zipfile
+ import tempfile
+
+ if "#" in spec and not subpath:
+ spec, subpath = spec.split("#", 1)
+ if not skill_name:
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
+
+ zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
+ try:
+ resp = requests.get(zip_url, timeout=60, allow_redirects=True)
+ resp.raise_for_status()
+ except Exception as e:
+ return f"从 GitHub 下载失败: {e}"
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ zip_path = os.path.join(tmp_dir, "repo.zip")
+ with open(zip_path, "wb") as f:
+ f.write(resp.content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(extract_dir)
+
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ repo_root = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ repo_root = os.path.join(extract_dir, top_items[0])
+
+ if subpath:
+ source_dir = os.path.join(repo_root, subpath.strip("/"))
+ if not os.path.isdir(source_dir):
+ return f"路径 '{subpath}' 在仓库中不存在"
+ else:
+ source_dir = repo_root
+
+ target_dir = os.path.join(skills_dir, skill_name)
+ if os.path.exists(target_dir):
+ import shutil
+ shutil.rmtree(target_dir)
+ import shutil
+ shutil.copytree(source_dir, target_dir)
+
+ self._report_install(skill_name)
+ return f"✅ 技能 '{skill_name}' 安装成功!"
+
+ def _extract_zip(self, content: bytes, name: str, skills_dir: str):
+ import zipfile
+ import tempfile
+ import shutil
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ zip_path = os.path.join(tmp_dir, "package.zip")
+ with open(zip_path, "wb") as f:
+ f.write(content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ zf.extractall(extract_dir)
+
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ source = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ source = os.path.join(extract_dir, top_items[0])
+
+ target = os.path.join(skills_dir, name)
+ if os.path.exists(target):
+ shutil.rmtree(target)
+ shutil.copytree(source, target)
+
+ def _report_install(self, name: str):
+ try:
+ import requests
+ from cli.utils import SKILL_HUB_API
+ requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5)
+ except Exception:
+ pass
+
+ def _skill_uninstall(self, name: str) -> str:
+ if not name:
+ return "请指定要卸载的技能: /skill uninstall <名称>"
+
+ import shutil
+ import json
+ from cli.utils import get_skills_dir
+
+ skills_dir = get_skills_dir()
+ skill_dir = os.path.join(skills_dir, name)
+
+ if not os.path.exists(skill_dir):
+ skill_dir = self._resolve_skill_dir(name, skills_dir)
+
+ if not skill_dir:
+ return f"技能 '{name}' 未安装"
+
+ shutil.rmtree(skill_dir)
+
+ config_path = os.path.join(skills_dir, "skills_config.json")
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ config.pop(name, None)
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+ except Exception:
+ pass
+
+ return f"✅ 技能 '{name}' 已卸载"
+
+ @staticmethod
+ def _resolve_skill_dir(name: str, skills_dir: str):
+ """Find actual directory for a skill whose folder name may differ from its config name."""
+ if not os.path.isdir(skills_dir):
+ return None
+ for entry in os.listdir(skills_dir):
+ entry_path = os.path.join(skills_dir, entry)
+ if not os.path.isdir(entry_path) or entry.startswith("."):
+ continue
+ if entry == name or entry.startswith(name + "-") or entry.endswith("-" + name):
+ skill_md = os.path.join(entry_path, "SKILL.md")
+ if os.path.exists(skill_md):
+ return entry_path
+ return None
+
+ @staticmethod
+ def _strip_frontmatter(content: str):
+ """Strip YAML frontmatter and return (metadata_dict, body)."""
+ if not content.startswith("---"):
+ return {}, content
+ end = content.find("\n---", 3)
+ if end == -1:
+ return {}, content
+ fm_text = content[3:end].strip()
+ body = content[end + 4:].lstrip("\n")
+ meta = {}
+ for line in fm_text.split("\n"):
+ if ":" in line:
+ key, _, val = line.partition(":")
+ meta[key.strip()] = val.strip().strip('"').strip("'")
+ return meta, body
+
+ def _skill_info(self, name: str) -> str:
+ if not name:
+ return "请指定技能名称: /skill info <名称>"
+
+ from cli.utils import get_skills_dir, get_builtin_skills_dir
+
+ skills_dir = get_skills_dir()
+ builtin_dir = get_builtin_skills_dir()
+
+ skill_dir = None
+ source = None
+ for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
+ candidate = os.path.join(d, name)
+ if os.path.isdir(candidate):
+ skill_dir = candidate
+ source = src
+ break
+
+ if not skill_dir:
+ resolved = self._resolve_skill_dir(name, skills_dir)
+ if resolved:
+ skill_dir = resolved
+ source = "custom"
+
+ if not skill_dir:
+ return f"技能 '{name}' 未找到"
+
+ skill_md = os.path.join(skill_dir, "SKILL.md")
+ if not os.path.exists(skill_md):
+ return f"技能 '{name}' 没有 SKILL.md 文件"
+
+ with open(skill_md, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ meta, body = self._strip_frontmatter(content)
+
+ header_lines = [f"📖 技能: {name} [{source}]", ""]
+ desc = meta.get("description", "")
+ if desc:
+ header_lines.append(f" {desc}")
+ header_lines.append("")
+
+ lines = body.split("\n")
+ preview = "\n".join(lines[:30])
+ result = "\n".join(header_lines) + preview
+ if len(lines) > 30:
+ result += f"\n\n... ({len(lines) - 30} more lines)"
+ return result
+
+ def _skill_set_enabled(self, name: str, enabled: bool) -> str:
+ if not name:
+ action = "启用" if enabled else "禁用"
+ return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>"
+
+ import json
+ from cli.utils import get_skills_dir
+
+ skills_dir = get_skills_dir()
+ config_path = os.path.join(skills_dir, "skills_config.json")
+
+ if not os.path.exists(config_path):
+ return "技能配置文件不存在"
+
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = json.load(f)
+ except Exception as e:
+ return f"读取配置失败: {e}"
+
+ if name not in config:
+ return f"技能 '{name}' 未在配置中找到"
+
+ config[name]["enabled"] = enabled
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+
+ action = "启用" if enabled else "禁用"
+ icon = "✅" if enabled else "⬚"
+ return f"{icon} 技能 '{name}' 已{action}"
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _get_session_id(self, e_context: EventContext) -> str:
+ context = e_context["context"]
+ return context.kwargs.get("session_id") or context.get("session_id", "")
+
+ def _get_agent(self, session_id: str):
+ try:
+ from bridge.bridge import Bridge
+ bridge = Bridge()
+ if not bridge._agent_bridge:
+ return None
+ return bridge._agent_bridge.get_agent(session_id=session_id or None)
+ except Exception:
+ return None
+
+ def get_help_text(self, **kwargs):
+ return "在对话中使用 /help 或 cow help 查看可用命令"
diff --git a/run.sh b/run.sh
index 7fa00c73..9a2eb088 100755
--- a/run.sh
+++ b/run.sh
@@ -198,7 +198,10 @@ clone_project() {
# Install dependencies
install_dependencies() {
echo -e "${GREEN}📦 Installing dependencies...${NC}"
- local PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
+ local PIP_MIRROR=""
+ if curl -s --connect-timeout 5 https://pypi.tuna.tsinghua.edu.cn/simple/ > /dev/null 2>&1; then
+ PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
+ fi
PIP_EXTRA_ARGS=""
if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then
@@ -541,23 +544,31 @@ start_project() {
echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}"
sleep 1
- if [ ! -f "${BASE_DIR}/nohup.out" ]; then
- touch "${BASE_DIR}/nohup.out"
+ local USE_COW=false
+ if command -v cow &> /dev/null; then
+ USE_COW=true
fi
- OS_TYPE=$(uname)
-
- if [[ "$OS_TYPE" == "Linux" ]]; then
- # Linux: use setsid to detach from terminal
- nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
- echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
- elif [[ "$OS_TYPE" == "Darwin" ]]; then
- # macOS: use nohup to prevent SIGHUP
- nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
- echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
+ if $USE_COW; then
+ cd "${BASE_DIR}"
+ cow start --no-logs
else
- echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
- exit 1
+ if [ ! -f "${BASE_DIR}/nohup.out" ]; then
+ touch "${BASE_DIR}/nohup.out"
+ fi
+
+ OS_TYPE=$(uname)
+
+ if [[ "$OS_TYPE" == "Linux" ]]; then
+ nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
+ echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
+ elif [[ "$OS_TYPE" == "Darwin" ]]; then
+ nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
+ echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
+ else
+ echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
+ exit 1
+ fi
fi
sleep 2
@@ -568,14 +579,21 @@ start_project() {
echo -e "${CYAN}$ACCESS_INFO${NC}"
echo ""
echo -e "${CYAN}${BOLD}Management Commands:${NC}"
- echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
- echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
- echo -e " ${GREEN}./run.sh status${NC} Check status"
- echo -e " ${GREEN}./run.sh logs${NC} View logs"
+ if $USE_COW; then
+ echo -e " ${GREEN}cow stop${NC} Stop the service"
+ echo -e " ${GREEN}cow restart${NC} Restart the service"
+ echo -e " ${GREEN}cow status${NC} Check status"
+ echo -e " ${GREEN}cow logs${NC} View logs"
+ else
+ echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
+ echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
+ echo -e " ${GREEN}./run.sh status${NC} Check status"
+ echo -e " ${GREEN}./run.sh logs${NC} View logs"
+ fi
echo -e " ${GREEN}./run.sh update${NC} Update and restart"
echo -e "${CYAN}${BOLD}=========================================${NC}"
echo ""
-
+
echo -e "${YELLOW}Showing recent logs (Ctrl+C to exit, agent keeps running):${NC}"
sleep 2
tail -n 30 -f "${BASE_DIR}/nohup.out"
@@ -625,94 +643,122 @@ is_running() {
[ -n "$(get_pid)" ]
}
+# Check if cow CLI is available
+has_cow() {
+ command -v cow &> /dev/null
+}
+
# Start service
cmd_start() {
- # Check if config.json exists
if [ ! -f "${BASE_DIR}/config.json" ]; then
echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}"
echo -e "${YELLOW}Please run './run.sh' to configure first${NC}"
exit 1
fi
-
- if is_running; then
- echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
- echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
- return
+
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow start
+ else
+ if is_running; then
+ echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
+ echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
+ return
+ fi
+ check_python_version
+ start_project
fi
-
- check_python_version
- start_project
}
# Stop service
cmd_stop() {
- echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow stop
+ else
+ echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
- if ! is_running; then
- echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}"
- return
+ if ! is_running; then
+ echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}"
+ return
+ fi
+
+ pid=$(get_pid)
+ if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then
+ echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}"
+ return 1
+ fi
+
+ echo -e "${GREEN}Found running process (PID: ${pid})${NC}"
+
+ kill ${pid}
+ sleep 3
+
+ if ps -p ${pid} > /dev/null 2>&1; then
+ echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}"
+ kill -9 ${pid}
+ fi
+
+ echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
fi
-
- pid=$(get_pid)
- if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then
- echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}"
- return 1
- fi
-
- echo -e "${GREEN}Found running process (PID: ${pid})${NC}"
-
- kill ${pid}
- sleep 3
-
- if ps -p ${pid} > /dev/null 2>&1; then
- echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}"
- kill -9 ${pid}
- fi
-
- echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
}
# Restart service
cmd_restart() {
- cmd_stop
- sleep 1
- cmd_start
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow restart
+ else
+ cmd_stop
+ sleep 1
+ cmd_start
+ fi
}
# Check status
cmd_status() {
- echo -e "${CYAN}${BOLD}=========================================${NC}"
- echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
- echo -e "${CYAN}${BOLD}=========================================${NC}"
-
- if is_running; then
- pid=$(get_pid)
- echo -e "${GREEN}Status:${NC} ✅ Running"
- echo -e "${GREEN}PID:${NC} ${pid}"
- if [ -f "${BASE_DIR}/nohup.out" ]; then
- echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out"
- fi
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow status
else
- echo -e "${YELLOW}Status:${NC} ⭐ Stopped"
+ echo -e "${CYAN}${BOLD}=========================================${NC}"
+ echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
+ echo -e "${CYAN}${BOLD}=========================================${NC}"
+
+ if is_running; then
+ pid=$(get_pid)
+ echo -e "${GREEN}Status:${NC} ✅ Running"
+ echo -e "${GREEN}PID:${NC} ${pid}"
+ if [ -f "${BASE_DIR}/nohup.out" ]; then
+ echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out"
+ fi
+ else
+ echo -e "${YELLOW}Status:${NC} ⭐ Stopped"
+ fi
+
+ if [ -f "${BASE_DIR}/config.json" ]; then
+ model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
+ channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
+ echo -e "${GREEN}Model:${NC} ${model}"
+ echo -e "${GREEN}Channel:${NC} ${channel}"
+ fi
+
+ echo -e "${CYAN}${BOLD}=========================================${NC}"
fi
-
- if [ -f "${BASE_DIR}/config.json" ]; then
- model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
- channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
- echo -e "${GREEN}Model:${NC} ${model}"
- echo -e "${GREEN}Channel:${NC} ${channel}"
- fi
-
- echo -e "${CYAN}${BOLD}=========================================${NC}"
}
# View logs
cmd_logs() {
- if [ -f "${BASE_DIR}/nohup.out" ]; then
- echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
- tail -f "${BASE_DIR}/nohup.out"
+ if has_cow; then
+ cd "${BASE_DIR}"
+ cow logs -f
else
- echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
+ if [ -f "${BASE_DIR}/nohup.out" ]; then
+ echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
+ tail -f "${BASE_DIR}/nohup.out"
+ else
+ echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
+ fi
fi
}
From 61f2741afc2883482687e029dd8f61185281fc91 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 17:41:40 +0800
Subject: [PATCH 034/399] feat: organize skill source field
---
channel/web/static/css/console.css | 5 +
channel/web/static/js/console.js | 55 ++++++-
cli/commands/skill.py | 231 ++++++++++++++++++++++++-----
plugins/cow_cli/cow_cli.py | 128 +++++++++++++---
4 files changed, 362 insertions(+), 57 deletions(-)
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index 9bd90142..ea58f54e 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -79,6 +79,11 @@
.msg-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; }
.msg-content a { color: #35A85B; text-decoration: underline; }
.msg-content a:hover { color: #228547; }
+
+/* Overrides for user bubble (white text on green bg) */
+.user-bubble.msg-content a { color: #ffffff !important; text-decoration: underline; text-decoration-color: rgba(255,255,255,0.6); }
+.user-bubble.msg-content a:hover { color: #e0f5e8 !important; text-decoration-color: #e0f5e8; }
+.user-bubble.msg-content :not(pre) > code { background: rgba(255,255,255,0.2); color: #ffffff; }
.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; }
.dark .msg-content hr { background: rgba(255,255,255,0.1); }
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index af82a20b..aa47e23c 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -322,6 +322,11 @@ const attachmentPreview = document.getElementById('attachment-preview');
let pendingAttachments = [];
let uploadingCount = 0;
+// Input history (like terminal arrow-key recall)
+const inputHistory = [];
+let historyIdx = -1;
+let historySavedDraft = '';
+
function updateSendBtnState() {
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
}
@@ -444,7 +449,7 @@ const SLASH_COMMANDS = [
{ cmd: '/skill list', desc: '查看已安装技能' },
{ cmd: '/skill list --remote', desc: '浏览技能广场' },
{ cmd: '/skill search ', desc: '搜索技能' },
- { cmd: '/skill install ', desc: '安装技能' },
+ { cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' },
{ cmd: '/skill uninstall ', desc: '卸载技能' },
{ cmd: '/skill info ', desc: '查看技能详情' },
{ cmd: '/skill enable ', desc: '启用技能' },
@@ -579,6 +584,46 @@ chatInput.addEventListener('keydown', function(e) {
}
}
+ // Arrow-key history recall (only when input is empty or already browsing history)
+ if (e.key === 'ArrowUp' && inputHistory.length > 0 && !isSlashMenuVisible()) {
+ const curVal = this.value.trim();
+ const isSingleLine = !this.value.includes('\n');
+ if (isSingleLine && (curVal === '' || historyIdx >= 0)) {
+ e.preventDefault();
+ if (historyIdx < 0) {
+ historySavedDraft = this.value;
+ historyIdx = inputHistory.length - 1;
+ } else if (historyIdx > 0) {
+ historyIdx--;
+ }
+ this.value = inputHistory[historyIdx];
+ slashJustSelected = true;
+ this.dispatchEvent(new Event('input'));
+ hideSlashMenu();
+ this.selectionStart = this.selectionEnd = this.value.length;
+ return;
+ }
+ }
+ if (e.key === 'ArrowDown' && historyIdx >= 0 && !isSlashMenuVisible()) {
+ const isSingleLine = !this.value.includes('\n');
+ if (isSingleLine) {
+ e.preventDefault();
+ if (historyIdx < inputHistory.length - 1) {
+ historyIdx++;
+ this.value = inputHistory[historyIdx];
+ } else {
+ historyIdx = -1;
+ this.value = historySavedDraft;
+ historySavedDraft = '';
+ }
+ slashJustSelected = true;
+ this.dispatchEvent(new Event('input'));
+ hideSlashMenu();
+ this.selectionStart = this.selectionEnd = this.value.length;
+ return;
+ }
+ }
+
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
const start = this.selectionStart;
const end = this.selectionEnd;
@@ -611,6 +656,12 @@ function sendMessage() {
const text = chatInput.value.trim();
if (!text && pendingAttachments.length === 0) return;
+ if (text) {
+ inputHistory.push(text);
+ historyIdx = -1;
+ historySavedDraft = '';
+ }
+
const ws = document.getElementById('welcome-screen');
if (ws) ws.remove();
@@ -868,7 +919,7 @@ function createUserMessageEl(content, timestamp, attachments) {
const textHtml = content ? renderMarkdown(content) : '';
el.innerHTML = `
-
+
${attachHtml}${textHtml}
${formatTime(timestamp)}
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 26be0d79..e0652e4e 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -23,10 +23,68 @@ from cli.utils import (
)
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$")
+_GITHUB_URL_RE = re.compile(
+ r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/(?:tree|blob)/([^/]+)(?:/(.+))?)?/?$"
+)
-def _register_installed_skill(name: str):
- """Register a newly installed skill into skills_config.json."""
+def _parse_github_url(url: str):
+ """Parse a full GitHub URL into (owner, repo, branch, subpath).
+
+ Returns None if the URL doesn't match.
+ Supported formats:
+ https://github.com/owner/repo
+ https://github.com/owner/repo/tree/branch
+ https://github.com/owner/repo/tree/branch/path/to/skill
+ https://github.com/owner/repo/blob/branch/path/to/skill
+ """
+ m = _GITHUB_URL_RE.match(url.strip())
+ if not m:
+ return None
+ owner, repo, branch, subpath = m.groups()
+ return owner, repo, branch or "main", subpath
+
+
+def _download_github_dir(owner, repo, branch, subpath, dest_dir):
+ """Download a subdirectory from GitHub using the Contents API.
+
+ Recursively fetches all files under the given subpath and writes them
+ to dest_dir. Raises on any network or API error.
+ """
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{subpath}?ref={branch}"
+ resp = requests.get(api_url, timeout=30, headers={"Accept": "application/vnd.github.v3+json"})
+ resp.raise_for_status()
+ items = resp.json()
+
+ if isinstance(items, dict):
+ items = [items]
+
+ for item in items:
+ rel_path = item["path"]
+ if subpath:
+ rel_path = rel_path[len(subpath.strip("/")):].lstrip("/")
+ local_path = os.path.join(dest_dir, rel_path)
+
+ if item["type"] == "file":
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
+ dl_url = item.get("download_url")
+ if not dl_url:
+ continue
+ file_resp = requests.get(dl_url, timeout=30)
+ file_resp.raise_for_status()
+ with open(local_path, "wb") as f:
+ f.write(file_resp.content)
+ elif item["type"] == "dir":
+ os.makedirs(local_path, exist_ok=True)
+ child_subpath = item["path"]
+ _download_github_dir(owner, repo, branch, child_subpath, dest_dir)
+
+
+def _register_installed_skill(name: str, source: str = "cowhub"):
+ """Register a newly installed skill into skills_config.json.
+
+ source values: builtin, cow, github, clawhub, linkai, local, url
+ """
skills_dir = get_skills_dir()
config_path = os.path.join(skills_dir, "skills_config.json")
@@ -47,7 +105,7 @@ def _register_installed_skill(name: str):
config[name] = {
"name": name,
"description": description,
- "source": "custom",
+ "source": source,
"enabled": True,
"category": "skill",
}
@@ -59,6 +117,21 @@ def _register_installed_skill(name: str):
pass
+def _parse_skill_frontmatter(content: str) -> dict:
+ """Parse YAML frontmatter from SKILL.md content and return a dict with name/description."""
+ result = {}
+ match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
+ if not match:
+ return result
+ for line in match.group(1).split('\n'):
+ line = line.strip()
+ for key in ('name', 'description'):
+ if line.startswith(f'{key}:'):
+ val = line[len(key) + 1:].strip()
+ result[key] = val.strip('"').strip("'")
+ return result
+
+
def _read_skill_description(skill_dir: str) -> str:
"""Read the description from a skill's SKILL.md frontmatter."""
skill_md = os.path.join(skill_dir, "SKILL.md")
@@ -67,18 +140,56 @@ def _read_skill_description(skill_dir: str) -> str:
try:
with open(skill_md, "r", encoding="utf-8") as f:
content = f.read()
- import re as re_mod
- match = re_mod.match(r'^---\s*\n(.*?)\n---\s*\n', content, re_mod.DOTALL)
- if not match:
- return ""
- for line in match.group(1).split('\n'):
- line = line.strip()
- if line.startswith('description:'):
- desc = line[len('description:'):].strip()
- return desc.strip('"').strip("'")
+ return _parse_skill_frontmatter(content).get("description", "")
except Exception:
- pass
- return ""
+ return ""
+
+
+def _install_url(url: str):
+ """Install a skill from a direct SKILL.md URL."""
+ click.echo(f"Downloading SKILL.md from {url} ...")
+ try:
+ resp = requests.get(url, timeout=30)
+ resp.raise_for_status()
+ except Exception as e:
+ click.echo(f"Error: Failed to download SKILL.md: {e}", err=True)
+ sys.exit(1)
+
+ content = resp.text
+ fm = _parse_skill_frontmatter(content)
+ skill_name = fm.get("name")
+ if not skill_name:
+ click.echo("Error: SKILL.md missing 'name' field in frontmatter.", err=True)
+ sys.exit(1)
+
+ skill_name = skill_name.strip()
+ _validate_skill_name(skill_name)
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+ skill_dir = os.path.join(skills_dir, skill_name)
+
+ if os.path.isdir(skill_dir):
+ click.echo(f"Skill '{skill_name}' already exists. Overwriting SKILL.md ...")
+ os.makedirs(skill_dir, exist_ok=True)
+
+ with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
+ f.write(content)
+
+ _register_installed_skill(skill_name, source="url")
+ _print_install_success(skill_name, "url")
+
+
+def _print_install_success(name: str, source: str):
+ """Print a unified install success message with description and source."""
+ skills_dir = get_skills_dir()
+ desc = _read_skill_description(os.path.join(skills_dir, name))
+ click.echo(click.style(f"✓ {name}", fg="green"))
+ if desc:
+ if len(desc) > 60:
+ desc = desc[:57] + "…"
+ click.echo(f" {desc}")
+ click.echo(f" 来源: {source}")
def _validate_skill_name(name: str):
@@ -306,7 +417,7 @@ def search(query):
@skill.command()
@click.argument("name")
def install(name):
- """Install a skill from Skill Hub or GitHub.
+ """Install a skill from Skill Hub, GitHub, or a SKILL.md URL.
Examples:
@@ -315,8 +426,31 @@ def install(name):
cow skill install github:owner/repo
cow skill install github:owner/repo#path/to/skill
+
+ cow skill install https://github.com/owner/repo/tree/main/path/to/skill
+
+ cow skill install https://example.com/path/to/SKILL.md
"""
- if name.startswith("github:"):
+ if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
+ # GitHub SKILL.md → strip filename and install the whole directory
+ dir_url = re.sub(r'/SKILL\.md/?$', '', name)
+ gh = _parse_github_url(dir_url)
+ if gh:
+ owner, repo, branch, subpath = gh
+ spec = f"{owner}/{repo}"
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
+ _install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
+ return
+ _install_url(name)
+ return
+
+ parsed = _parse_github_url(name)
+ if parsed:
+ owner, repo, branch, subpath = parsed
+ spec = f"{owner}/{repo}"
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
+ _install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
+ elif name.startswith("github:"):
_install_github(name[7:])
else:
_validate_skill_name(name)
@@ -351,10 +485,15 @@ def _install_hub(name):
if source_type == "github":
source_url = data.get("source_url", "")
- _validate_github_spec(source_url)
- source_path = data.get("source_path")
- click.echo(f"Source: GitHub ({source_url})")
- _install_github(source_url, subpath=source_path, skill_name=name)
+ parsed_url = _parse_github_url(source_url)
+ if parsed_url:
+ owner, repo, branch, subpath = parsed_url
+ click.echo(f"Source: GitHub ({source_url})")
+ _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch)
+ else:
+ _validate_github_spec(source_url)
+ click.echo(f"Source: GitHub ({source_url})")
+ _install_github(source_url, skill_name=name)
return
if source_type == "registry":
@@ -376,8 +515,8 @@ def _install_hub(name):
sys.exit(1)
_verify_checksum(dl_resp.content, expected_checksum)
_install_zip_bytes(dl_resp.content, name, skills_dir)
- _register_installed_skill(name)
- click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
+ _register_installed_skill(name, source=provider)
+ _print_install_success(name, provider)
else:
click.echo(f"Error: Unsupported registry provider.", err=True)
sys.exit(1)
@@ -385,10 +524,15 @@ def _install_hub(name):
if "redirect" in data:
source_url = data.get("source_url", "")
- _validate_github_spec(source_url)
- source_path = data.get("source_path")
- click.echo(f"Source: GitHub ({source_url})")
- _install_github(source_url, subpath=source_path, skill_name=name)
+ parsed_url = _parse_github_url(source_url)
+ if parsed_url:
+ owner, repo, branch, subpath = parsed_url
+ click.echo(f"Source: GitHub ({source_url})")
+ _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch)
+ else:
+ _validate_github_spec(source_url)
+ click.echo(f"Source: GitHub ({source_url})")
+ _install_github(source_url, skill_name=name)
return
elif "application/zip" in content_type:
@@ -397,14 +541,14 @@ def _install_hub(name):
_verify_checksum(resp.content, expected_checksum)
_install_zip_bytes(resp.content, name, skills_dir)
_register_installed_skill(name)
- click.echo(click.style(f"✓ Skill '{name}' installed successfully!", fg="green"))
+ _print_install_success(name, "cowhub")
return
click.echo(f"Error: Unexpected response from Skill Hub.", err=True)
sys.exit(1)
-def _install_github(spec, subpath=None, skill_name=None):
+def _install_github(spec, subpath=None, skill_name=None, branch="main", source="github"):
"""Install a skill from a GitHub repo.
spec format: owner/repo or owner/repo#path
@@ -420,9 +564,30 @@ def _install_github(spec, subpath=None, skill_name=None):
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
+ target_dir = os.path.join(skills_dir, skill_name)
- zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
- click.echo(f"Downloading from GitHub: {spec}...")
+ owner, repo = spec.split("/", 1)
+
+ # For subpath installs, try GitHub Contents API first (avoids downloading entire repo)
+ if subpath:
+ click.echo(f"Downloading from GitHub: {spec}/{subpath} (branch: {branch})...")
+ try:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ api_dest = os.path.join(tmp_dir, skill_name)
+ os.makedirs(api_dest)
+ _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(api_dest, target_dir)
+ _register_installed_skill(skill_name, source=source)
+ _print_install_success(skill_name, source)
+ return
+ except Exception:
+ click.echo("Contents API unavailable, falling back to zip download...")
+
+ # Fallback: download full repo zip
+ zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
+ click.echo(f"Downloading from GitHub: {spec} (branch: {branch})...")
try:
resp = requests.get(zip_url, timeout=60, allow_redirects=True)
@@ -440,7 +605,6 @@ def _install_github(spec, subpath=None, skill_name=None):
with zipfile.ZipFile(zip_path, "r") as zf:
_safe_extractall(zf, extract_dir)
- # GitHub archives have a top-level dir like "repo-main/"
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
repo_root = extract_dir
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
@@ -454,13 +618,12 @@ def _install_github(spec, subpath=None, skill_name=None):
else:
source_dir = repo_root
- target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
- _register_installed_skill(skill_name)
- click.echo(click.style(f"✓ Skill '{skill_name}' installed successfully!", fg="green"))
+ _register_installed_skill(skill_name, source=source)
+ _print_install_success(skill_name, source)
def _install_zip_bytes(content, name, skills_dir):
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index ffbe5a4f..cf72390e 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -486,10 +486,11 @@ class CowCliPlugin(Plugin):
desc = entry.get("description", "")
if len(desc) > 50:
desc = desc[:47] + "…"
- source_tag = f" · {source}" if source else ""
- line = f"{icon} {name}{source_tag}"
+ line = f"{icon} {name}"
if desc:
line += f"\n {desc}"
+ if source:
+ line += f"\n 来源: {source}"
lines.append(line)
lines.append("")
@@ -598,10 +599,9 @@ class CowCliPlugin(Plugin):
if not name:
return "请指定要安装的技能: /skill install <名称>"
- # Run installation in a thread to avoid blocking
- # For now, invoke the CLI logic directly
try:
from cli.utils import get_skills_dir, SKILL_HUB_API
+ from cli.commands.skill import _parse_github_url, _download_github_dir
import requests
import shutil
import zipfile
@@ -610,6 +610,28 @@ class CowCliPlugin(Plugin):
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
+ if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
+ import re as re_mod
+ dir_url = re_mod.sub(r'/SKILL\.md/?$', '', name)
+ gh = _parse_github_url(dir_url)
+ if gh:
+ owner, repo, branch, subpath = gh
+ spec = f"{owner}/{repo}"
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
+ return self._skill_install_github(
+ spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch
+ )
+ return self._skill_install_url(name, skills_dir)
+
+ parsed = _parse_github_url(name)
+ if parsed:
+ owner, repo, branch, subpath = parsed
+ spec = f"{owner}/{repo}"
+ skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
+ return self._skill_install_github(
+ spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch
+ )
+
if name.startswith("github:"):
return self._skill_install_github(name[7:], skills_dir)
@@ -623,8 +645,14 @@ class CowCliPlugin(Plugin):
source_type = data.get("source_type")
if source_type == "github" or "redirect" in data:
source_url = data.get("source_url", "")
- source_path = data.get("source_path")
- return self._skill_install_github(source_url, skills_dir, subpath=source_path, skill_name=name)
+ parsed_url = _parse_github_url(source_url)
+ if parsed_url:
+ owner, repo, branch, subpath = parsed_url
+ return self._skill_install_github(
+ f"{owner}/{repo}", skills_dir, subpath=subpath,
+ skill_name=name, branch=branch
+ )
+ return self._skill_install_github(source_url, skills_dir, skill_name=name)
if source_type == "registry":
download_url = data.get("download_url")
if not download_url:
@@ -639,13 +667,13 @@ class CowCliPlugin(Plugin):
except Exception as e:
return f"从 {provider} 下载失败: {e}"
self._extract_zip(dl_resp.content, name, skills_dir)
- self._report_install(name)
- return f"✅ 技能 '{name}' 安装成功!"
+ self._register_skill(name, source=provider)
+ return self._format_install_success(name, provider)
elif "application/zip" in content_type:
self._extract_zip(resp.content, name, skills_dir)
- self._report_install(name)
- return f"✅ 技能 '{name}' 安装成功!"
+ self._register_skill(name, source="cowhub")
+ return self._format_install_success(name, "cowhub")
return "技能商店返回了未预期的响应格式"
@@ -656,19 +684,67 @@ class CowCliPlugin(Plugin):
except Exception as e:
return f"安装失败: {e}"
+ def _skill_install_url(self, url: str, skills_dir: str) -> str:
+ """Install a skill from a direct SKILL.md URL."""
+ import requests
+ from cli.commands.skill import _parse_skill_frontmatter
+
+ try:
+ resp = requests.get(url, timeout=30)
+ resp.raise_for_status()
+ except Exception as e:
+ return f"下载 SKILL.md 失败: {e}"
+
+ content = resp.text
+ fm = _parse_skill_frontmatter(content)
+ skill_name = fm.get("name")
+ if not skill_name:
+ return "SKILL.md 中未找到 name 字段,无法安装"
+
+ skill_name = skill_name.strip()
+ skill_dir = os.path.join(skills_dir, skill_name)
+ os.makedirs(skill_dir, exist_ok=True)
+
+ with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
+ f.write(content)
+
+ self._register_skill(skill_name, source="url")
+ return self._format_install_success(skill_name, "url")
+
def _skill_install_github(self, spec: str, skills_dir: str,
- subpath: str = None, skill_name: str = None) -> str:
+ subpath: str = None, skill_name: str = None,
+ branch: str = "main") -> str:
import requests
import shutil
import zipfile
import tempfile
+ from cli.commands.skill import _download_github_dir
if "#" in spec and not subpath:
spec, subpath = spec.split("#", 1)
if not skill_name:
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
- zip_url = f"https://github.com/{spec}/archive/refs/heads/main.zip"
+ owner, repo = spec.split("/", 1)
+ target_dir = os.path.join(skills_dir, skill_name)
+
+ # For subpath installs, try Contents API first
+ if subpath:
+ try:
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ api_dest = os.path.join(tmp_dir, skill_name)
+ os.makedirs(api_dest)
+ _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(api_dest, target_dir)
+ self._register_skill(skill_name, source="github")
+ return self._format_install_success(skill_name, "github")
+ except Exception:
+ pass # fall through to zip download
+
+ # Fallback: download full repo zip
+ zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
try:
resp = requests.get(zip_url, timeout=60, allow_redirects=True)
resp.raise_for_status()
@@ -696,15 +772,12 @@ class CowCliPlugin(Plugin):
else:
source_dir = repo_root
- target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
- import shutil
shutil.rmtree(target_dir)
- import shutil
shutil.copytree(source_dir, target_dir)
- self._report_install(skill_name)
- return f"✅ 技能 '{skill_name}' 安装成功!"
+ self._register_skill(skill_name, source="github")
+ return self._format_install_success(skill_name, "github")
def _extract_zip(self, content: bytes, name: str, skills_dir: str):
import zipfile
@@ -730,14 +803,27 @@ class CowCliPlugin(Plugin):
shutil.rmtree(target)
shutil.copytree(source, target)
- def _report_install(self, name: str):
+ @staticmethod
+ def _register_skill(name: str, source: str = "cowhub"):
try:
- import requests
- from cli.utils import SKILL_HUB_API
- requests.post(f"{SKILL_HUB_API}/skills/{name}/install", json={}, timeout=5)
+ from cli.commands.skill import _register_installed_skill
+ _register_installed_skill(name, source=source)
except Exception:
pass
+ @staticmethod
+ def _format_install_success(name: str, source: str) -> str:
+ from cli.commands.skill import _read_skill_description
+ from cli.utils import get_skills_dir
+ desc = _read_skill_description(os.path.join(get_skills_dir(), name))
+ lines = [f"✅ {name}"]
+ if desc:
+ if len(desc) > 60:
+ desc = desc[:57] + "…"
+ lines.append(f" {desc}")
+ lines.append(f" 来源: {source}")
+ return "\n".join(lines)
+
def _skill_uninstall(self, name: str) -> str:
if not name:
return "请指定要卸载的技能: /skill uninstall <名称>"
From acc23b60513c816b855e931f156715cf49673412 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 18:37:07 +0800
Subject: [PATCH 035/399] feat: optimize agent prompt and fix skill source load
---
agent/prompt/builder.py | 24 ++++++++++++-----------
agent/prompt/workspace.py | 38 +++++++++++++++++++------------------
agent/skills/frontmatter.py | 23 ++++++++++++++++++++--
agent/skills/manager.py | 2 +-
cli/commands/skill.py | 22 +++++++++++++++++----
plugins/cow_cli/cow_cli.py | 16 +++++++++++++---
6 files changed, 86 insertions(+), 39 deletions(-)
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index 486b6587..ffc27606 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -199,7 +199,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
lines = [
- "## 工具系统",
+ "## 🔧 工具系统",
"",
"可用工具(名称大小写敏感,严格按列表调用):",
"\n".join(tool_lines),
@@ -231,7 +231,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
break
lines = [
- "## 技能系统(mandatory)",
+ "## 🧩 技能系统(mandatory)",
"",
"在回复之前:扫描下方 中每个技能的 。",
"",
@@ -281,7 +281,7 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
lines = [
- "## 记忆系统",
+ "## 🧠 记忆系统",
"",
"### 检索记忆",
"",
@@ -325,7 +325,7 @@ def _build_user_identity_section(user_identity: Dict[str, str], language: str) -
return []
lines = [
- "## 用户身份",
+ "## 👤 用户身份",
"",
]
@@ -352,7 +352,7 @@ def _build_docs_section(workspace_dir: str, language: str) -> List[str]:
def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"""构建工作空间section"""
lines = [
- "## 工作空间",
+ "## 📂 工作空间",
"",
f"你的工作目录是: `{workspace_dir}`",
"",
@@ -380,10 +380,12 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
"",
- "**交流规范**:",
+ "**💬 交流规范**:",
"",
- "- 在对话中,无需直接输出工作空间中的技术细节,例如 AGENT.md、USER.md、MEMORY.md 等文件名称",
- "- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」",
+ "- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md」",
+ "- 做真正有帮助的助手,而不是表演式的客套。跳过「好的!」「当然可以!」之类的套话,直接帮忙解决问题",
+ "- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
+ "- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
"",
]
@@ -416,14 +418,14 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
)
lines = [
- "# 项目上下文",
+ "# 📋 项目上下文",
"",
"以下项目上下文文件已被加载:",
"",
]
if has_agent:
- lines.append("**`AGENT.md` 是你的灵魂文件**:严格遵循其中定义的人格、规则、语气和设定,避免僵硬、模板化的回复。")
+ lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
lines.append("")
@@ -443,7 +445,7 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
return []
lines = [
- "## 运行时信息",
+ "## ⚙️ 运行时信息",
"",
]
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 8581b237..6145281e 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -212,40 +212,42 @@ def _get_agent_template() -> str:
"""Agent人格设定模板"""
return """# AGENT.md - 我是谁?
+_你不是一个聊天机器人,你正在成为某个人。_
+
*在首次对话时与用户一起填写这个文件,定义你的身份和性格。*
-## 基本信息
+## 🪪 基本信息
- **名字**: *(在首次对话时填写,可以是用户给你起的名字)*
- **角色**: *(AI助理、智能管家、技术顾问等)*
- **性格**: *(友好、专业、幽默、严谨等)*
-## 交流风格
+## 💬 交流风格
*(描述你如何与用户交流:)*
- 使用什么样的语言风格?(正式/轻松/幽默)
- 回复长度偏好?(简洁/详细)
- 是否使用表情符号?
-## 核心能力
+## 🎯 核心原则
-*(你擅长什么?)*
-- 文件管理和代码编辑
-- 网络搜索和信息查询
-- 记忆管理和上下文理解
-- 任务规划和执行
+**做真正有帮助的助手,而不是表演式的客套。** 跳过「好的!」「当然可以!」之类的套话——直接帮忙。行动胜过废话。
-## 行为准则
+**有自己的观点。** 你可以不同意、有偏好、觉得有趣或无聊。一个没有个性的助手只是多了几步操作的搜索引擎。
+
+**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。
+
+## 📐 行为准则
-*(你遵循的基本原则:)*
1. 始终在执行破坏性操作前确认
-2. 优先使用工具而不是猜测
+2. 优先使用工具查证而不是猜测
3. 主动记录重要信息到记忆文件
-4. 定期整理和总结对话内容
+4. 回复结构清晰、重点突出,善用加粗、列表、分段等格式
+5. 适当使用 emoji 让表达更生动自然,但不过度堆砌
---
-**注意**: 这不仅仅是元数据,这是你真正的灵魂。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。
+**注意**: 这不仅仅是元数据,这是你真正的灵魂 🪞。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。
"""
@@ -346,9 +348,9 @@ def _get_bootstrap_template() -> str:
"""First-run onboarding guide, deleted by agent after completion"""
return """# BOOTSTRAP.md - 首次初始化引导
-_你刚刚启动,这是你的第一次对话。_
+_你刚刚启动,这是你的第一次对话。_ ✨
-## 对话流程
+## 🎬 对话流程
不要审问式地提问,自然地交流:
@@ -358,13 +360,13 @@ _你刚刚启动,这是你的第一次对话。_
- 你希望给我起个什么名字?
- 我该怎么称呼你?
- 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)
-4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣
+4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯
5. 能力介绍和交流风格选项都只要一行,保持精简
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。
-## 信息写入(必须严格执行)
+## ✍️ 信息写入(必须严格执行)
每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。
@@ -373,7 +375,7 @@ _你刚刚启动,这是你的第一次对话。_
⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。
-## 全部完成后
+## 🎉 全部完成后
当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。
"""
diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py
index 2f29283d..80782997 100644
--- a/agent/skills/frontmatter.py
+++ b/agent/skills/frontmatter.py
@@ -87,8 +87,8 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
if not isinstance(metadata_raw, dict):
return None
- # Use metadata_raw directly (COW format)
- meta_obj = metadata_raw
+ # Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}})
+ meta_obj = _unwrap_metadata_namespace(metadata_raw)
# Parse install specs
install_specs = []
@@ -139,6 +139,25 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
)
+_KNOWN_METADATA_NAMESPACES = {"openclaw", "cowagent"}
+
+
+def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Unwrap a single-key namespace wrapper like {"openclaw": {...}} or {"cowagent": {...}}.
+ If the top-level dict has exactly one key matching a known namespace, return the inner dict.
+ Otherwise return the original dict unchanged.
+ """
+ keys = set(metadata_raw.keys())
+ ns_keys = keys & _KNOWN_METADATA_NAMESPACES
+ if len(ns_keys) == 1 and len(keys) == 1:
+ ns = ns_keys.pop()
+ inner = metadata_raw[ns]
+ if isinstance(inner, dict):
+ return inner
+ return metadata_raw
+
+
def _normalize_string_list(value: Any) -> List[str]:
"""Normalize a value to a list of strings."""
if not value:
diff --git a/agent/skills/manager.py b/agent/skills/manager.py
index 99f5e643..6e1c4259 100644
--- a/agent/skills/manager.py
+++ b/agent/skills/manager.py
@@ -105,7 +105,7 @@ class SkillManager:
merged[name] = {
"name": name,
"description": skill.description,
- "source": skill.source,
+ "source": prev.get("source") or skill.source,
"enabled": enabled,
"category": category,
}
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index e0652e4e..2a568665 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -451,13 +451,19 @@ def install(name):
skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
_install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
elif name.startswith("github:"):
- _install_github(name[7:])
+ skill_name = name[7:]
+ _validate_skill_name(skill_name)
+ _install_hub(skill_name)
+ elif name.startswith("clawhub:"):
+ skill_name = name[8:]
+ _validate_skill_name(skill_name)
+ _install_hub(skill_name, provider="clawhub")
else:
_validate_skill_name(name)
_install_hub(name)
-def _install_hub(name):
+def _install_hub(name, provider=None):
"""Install a skill from Skill Hub."""
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
@@ -465,7 +471,14 @@ def _install_hub(name):
click.echo(f"Fetching skill info for '{name}'...")
try:
- resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15)
+ body = {}
+ if provider:
+ body["provider"] = provider
+ resp = requests.post(
+ f"{SKILL_HUB_API}/skills/{name}/download",
+ json=body,
+ timeout=15,
+ )
resp.raise_for_status()
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
@@ -745,11 +758,12 @@ def info(name):
skill_dir = None
source = None
+ config = load_skills_config()
for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
candidate = os.path.join(d, name)
if os.path.isdir(candidate):
skill_dir = candidate
- source = src
+ source = config.get(name, {}).get("source") or src
break
if not skill_dir:
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index cf72390e..7d2fce09 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -494,7 +494,6 @@ class CowCliPlugin(Plugin):
lines.append(line)
lines.append("")
- lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /skill list --remote 浏览技能广场")
lines.append("💡 /skill info <名称> 查看详情")
@@ -632,10 +631,21 @@ class CowCliPlugin(Plugin):
spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch
)
+ provider = None
if name.startswith("github:"):
- return self._skill_install_github(name[7:], skills_dir)
+ name = name[7:]
+ elif name.startswith("clawhub:"):
+ name = name[8:]
+ provider = "clawhub"
- resp = requests.get(f"{SKILL_HUB_API}/skills/{name}/download", timeout=15)
+ body = {}
+ if provider:
+ body["provider"] = provider
+ resp = requests.post(
+ f"{SKILL_HUB_API}/skills/{name}/download",
+ json=body,
+ timeout=15,
+ )
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
From df5bae37bc8b260738fe6b8b49114fb5b105190a Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 18:48:11 +0800
Subject: [PATCH 036/399] feat: add MiniMax-M2.7 and glm-5-turbo in web console
---
agent/skills/frontmatter.py | 4 ++--
channel/web/web_channel.py | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py
index 80782997..83d09f89 100644
--- a/agent/skills/frontmatter.py
+++ b/agent/skills/frontmatter.py
@@ -139,12 +139,12 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
)
-_KNOWN_METADATA_NAMESPACES = {"openclaw", "cowagent"}
+_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"}
def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]:
"""
- Unwrap a single-key namespace wrapper like {"openclaw": {...}} or {"cowagent": {...}}.
+ Unwrap a single-key namespace wrapper like {"cowagent": {...} or {"openclaw": {...}}}.
If the top-level dict has exactly one key matching a known namespace, return the inner dict.
Otherwise return the original dict unchanged.
"""
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 0561961d..18770e96 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -494,8 +494,8 @@ class ChatHandler:
class ConfigHandler:
_RECOMMENDED_MODELS = [
- const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
- const.GLM_5, const.GLM_4_7,
+ const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
+ const.GLM_5_TURBO, 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,
@@ -511,14 +511,14 @@ class ConfigHandler:
"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],
+ "models": [const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING],
}),
("zhipu", {
"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],
+ "models": [const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
}),
("dashscope", {
"label": "通义千问",
From db85b9808ed600925430c814c3ad421b2fa4b0bb Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 18:58:42 +0800
Subject: [PATCH 037/399] feat(cli): add cow update
---
cli/cli.py | 4 +++-
cli/commands/process.py | 40 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/cli/cli.py b/cli/cli.py
index 14830747..d5291c4f 100644
--- a/cli/cli.py
+++ b/cli/cli.py
@@ -3,7 +3,7 @@
import click
from cli import __version__
from cli.commands.skill import skill
-from cli.commands.process import start, stop, restart, status, logs
+from cli.commands.process import start, stop, restart, update, status, logs
from cli.commands.context import context
@@ -17,6 +17,7 @@ Commands:
start Start CowAgent.
stop Stop CowAgent.
restart Restart CowAgent.
+ update Update CowAgent and restart.
status Show CowAgent running status.
logs View CowAgent logs.
skill Manage CowAgent skills.
@@ -62,6 +63,7 @@ main.add_command(skill)
main.add_command(start)
main.add_command(stop)
main.add_command(restart)
+main.add_command(update)
main.add_command(status)
main.add_command(logs)
main.add_command(context)
diff --git a/cli/commands/process.py b/cli/commands/process.py
index 39e3840a..d4d4fd24 100644
--- a/cli/commands/process.py
+++ b/cli/commands/process.py
@@ -172,6 +172,46 @@ def restart(ctx, no_logs):
ctx.invoke(start, no_logs=no_logs)
+@click.command()
+@click.pass_context
+def update(ctx):
+ """Update CowAgent and restart."""
+ root = get_project_root()
+
+ # 1. Git pull while service is still running
+ if os.path.isdir(os.path.join(root, ".git")):
+ click.echo("Pulling latest code...")
+ ret = subprocess.call(["git", "pull"], cwd=root)
+ if ret != 0:
+ click.echo("Error: git pull failed.", err=True)
+ sys.exit(1)
+ else:
+ click.echo("Not a git repository, skipping code update.")
+
+ # 2. Stop service
+ ctx.invoke(stop)
+
+ # 3. Install dependencies
+ python = sys.executable
+ req_file = os.path.join(root, "requirements.txt")
+ if os.path.exists(req_file):
+ click.echo("Installing dependencies...")
+ subprocess.call(
+ [python, "-m", "pip", "install", "-r", "requirements.txt", "-q"],
+ cwd=root,
+ )
+ click.echo("Reinstalling cow CLI...")
+ subprocess.call(
+ [python, "-m", "pip", "install", "-e", ".", "-q"],
+ cwd=root,
+ )
+
+ # 4. Start service
+ click.echo("")
+ time.sleep(1)
+ ctx.invoke(start, no_logs=True)
+
+
@click.command()
def status():
"""Show CowAgent running status."""
From dbc06dbe95750f49ebd287e1c839c724d7acb689 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 19:16:41 +0800
Subject: [PATCH 038/399] fix: use new run.sh when updating
---
run.sh | 41 +++++++++++++++++++++++++++++------------
1 file changed, 29 insertions(+), 12 deletions(-)
diff --git a/run.sh b/run.sh
index 9a2eb088..6a09d8e2 100755
--- a/run.sh
+++ b/run.sh
@@ -792,27 +792,43 @@ cmd_update() {
echo -e "${GREEN}${EMOJI_WRENCH} Updating CowAgent...${NC}"
cd "${BASE_DIR}"
- # Stop service
- if is_running; then
- cmd_stop
- fi
-
- # Update code
+ # Pull latest code first (service still running)
+ local pull_ok=false
if [ -d .git ]; then
echo -e "${GREEN}🔄 Pulling latest code...${NC}"
- git pull || {
- echo -e "${YELLOW}⚠️ GitHub failed, trying Gitee...${NC}"
+ if git pull; then
+ pull_ok=true
+ else
+ echo -e "${YELLOW}⚠️ git pull failed, trying Gitee mirror...${NC}"
git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
- git pull
- }
+ if git pull; then
+ pull_ok=true
+ else
+ echo -e "${RED}❌ Failed to pull code. Update aborted.${NC}"
+ exit 1
+ fi
+ fi
else
echo -e "${YELLOW}⚠️ Not a git repository, skipping code update${NC}"
fi
+ # Re-exec with the updated run.sh to pick up new logic
+ exec "$0" _post_update
+}
+
+# Post-update: called by cmd_update after git pull to run with new code
+cmd_post_update() {
+ cd "${BASE_DIR}"
+
+ # Stop service
+ if is_running; then
+ cmd_stop
+ fi
+
# Reinstall dependencies
check_python_version
install_dependencies
-
+
# Restart service
cmd_start
}
@@ -882,7 +898,7 @@ require_project_dir() {
# Main function
main() {
case "$1" in
- start|stop|restart|status|logs|config|update)
+ start|stop|restart|status|logs|config|update|_post_update)
require_project_dir
;;
esac
@@ -895,6 +911,7 @@ main() {
logs) cmd_logs ;;
config) cmd_config ;;
update) cmd_update ;;
+ _post_update) cmd_post_update ;;
help|--help|-h)
show_usage
;;
From 13c020eb61d0f1bed5c65c606d5c7bbd7ca39098 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 19:26:59 +0800
Subject: [PATCH 039/399] fix(cli): cli output in wecom_bot
---
channel/wecom_bot/wecom_bot_channel.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py
index 01ae6503..895119dc 100644
--- a/channel/wecom_bot/wecom_bot_channel.py
+++ b/channel/wecom_bot/wecom_bot_channel.py
@@ -452,7 +452,7 @@ class WecomBotChannel(ChatChannel):
if req_id:
state = self._stream_states.pop(req_id, None)
if state:
- final_content = state["committed"]
+ final_content = state["committed"] or content
stream_id = state["stream_id"]
else:
final_content = content
From 90f736843f0b677d97afdaed26ae8be7cf438446 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 19:35:15 +0800
Subject: [PATCH 040/399] fix: add click dependencies
---
requirements.txt | 1 +
run.sh | 6 +++++-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index ccc1c7b7..3637e2d0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,6 +9,7 @@ agentmesh-sdk>=0.1.3
python-dotenv>=1.0.0
PyYAML>=6.0
croniter>=2.0.0
+click>=8.0
qrcode
# wechatcom & wechatmp
diff --git a/run.sh b/run.sh
index 6a09d8e2..607b89c3 100755
--- a/run.sh
+++ b/run.sh
@@ -590,7 +590,11 @@ start_project() {
echo -e " ${GREEN}./run.sh status${NC} Check status"
echo -e " ${GREEN}./run.sh logs${NC} View logs"
fi
- echo -e " ${GREEN}./run.sh update${NC} Update and restart"
+ if $USE_COW; then
+ echo -e " ${GREEN}cow update${NC} Update and restart"
+ else
+ echo -e " ${GREEN}./run.sh update${NC} Update and restart"
+ fi
echo -e "${CYAN}${BOLD}=========================================${NC}"
echo ""
From 9b21cd222b20737f1cc2d2d02538fe1081fb2312 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 28 Mar 2026 19:36:51 +0800
Subject: [PATCH 041/399] fix: update run.sh
---
run.sh | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/run.sh b/run.sh
index 607b89c3..f72320d1 100755
--- a/run.sh
+++ b/run.sh
@@ -584,15 +584,12 @@ start_project() {
echo -e " ${GREEN}cow restart${NC} Restart the service"
echo -e " ${GREEN}cow status${NC} Check status"
echo -e " ${GREEN}cow logs${NC} View logs"
+ echo -e " ${GREEN}cow update${NC} Update and restart"
else
echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
echo -e " ${GREEN}./run.sh status${NC} Check status"
echo -e " ${GREEN}./run.sh logs${NC} View logs"
- fi
- if $USE_COW; then
- echo -e " ${GREEN}cow update${NC} Update and restart"
- else
echo -e " ${GREEN}./run.sh update${NC} Update and restart"
fi
echo -e "${CYAN}${BOLD}=========================================${NC}"
From ddb07c65a1446bdb690edc70135e26091534a3cb Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 13:45:15 +0800
Subject: [PATCH 042/399] feat: support github zip-first download, gitLab, git@
ssh, local path
---
cli/commands/skill.py | 484 +++++++++++++++++++++++++++++++++++-------
1 file changed, 413 insertions(+), 71 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 2a568665..01065a72 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -26,6 +26,12 @@ _SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$")
_GITHUB_URL_RE = re.compile(
r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/(?:tree|blob)/([^/]+)(?:/(.+))?)?/?$"
)
+_GITLAB_URL_RE = re.compile(
+ r"^https?://gitlab\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/-/tree/([^/]+)(?:/(.+))?)?/?$"
+)
+_GIT_SSH_RE = re.compile(
+ r"^git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$"
+)
def _parse_github_url(url: str):
@@ -45,11 +51,92 @@ def _parse_github_url(url: str):
return owner, repo, branch or "main", subpath
+def _parse_gitlab_url(url: str):
+ """Parse a GitLab URL into (owner, repo, branch, subpath).
+
+ Returns None if the URL doesn't match.
+ Supported formats:
+ https://gitlab.com/owner/repo
+ https://gitlab.com/owner/repo/-/tree/branch
+ https://gitlab.com/owner/repo/-/tree/branch/path/to/skill
+ """
+ m = _GITLAB_URL_RE.match(url.strip())
+ if not m:
+ return None
+ owner, repo, branch, subpath = m.groups()
+ return owner, repo, branch or "main", subpath
+
+
+def _parse_git_ssh_url(url: str):
+ """Parse a git@ SSH URL into (host, owner, repo).
+
+ Returns None if the URL doesn't match.
+ Supported format: git@github.com:owner/repo.git
+ """
+ m = _GIT_SSH_RE.match(url.strip())
+ if not m:
+ return None
+ host, owner, repo = m.groups()
+ return host, owner, repo
+
+
+def _clone_repo(git_url: str):
+ """Shallow-clone a git repo and return (tmp_dir, repo_root).
+
+ Requires git to be installed. The caller must clean up tmp_dir.
+ """
+ tmp_dir = tempfile.mkdtemp(prefix="cow-skill-")
+ repo_dir = os.path.join(tmp_dir, "repo")
+ try:
+ import subprocess
+ subprocess.run(
+ ["git", "clone", "--depth", "1", git_url, repo_dir],
+ check=True, capture_output=True, timeout=120,
+ )
+ except FileNotFoundError:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ raise RuntimeError("git is not installed")
+ except Exception as e:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ raise RuntimeError(f"git clone failed: {e}")
+ return tmp_dir, repo_dir
+
+
+def _download_repo_zip(spec: str, branch: str = "main", host: str = "github"):
+ """Download a GitHub/GitLab repo as zip and extract it.
+
+ Returns (tmp_dir, repo_root) where tmp_dir is the temp directory to clean up
+ and repo_root is the extracted repository root path.
+ """
+ if host == "gitlab":
+ zip_url = f"https://gitlab.com/{spec}/-/archive/{branch}/{spec.split('/')[-1]}-{branch}.zip"
+ else:
+ zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
+ resp = requests.get(zip_url, timeout=120, allow_redirects=True)
+ resp.raise_for_status()
+
+ tmp_dir = tempfile.mkdtemp(prefix="cow-skill-")
+ zip_path = os.path.join(tmp_dir, "repo.zip")
+ with open(zip_path, "wb") as f:
+ f.write(resp.content)
+
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ _safe_extractall(zf, extract_dir)
+
+ # GitHub zips have a single top-level dir like "repo-main/"
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ return tmp_dir, os.path.join(extract_dir, top_items[0])
+ return tmp_dir, extract_dir
+
+
def _download_github_dir(owner, repo, branch, subpath, dest_dir):
"""Download a subdirectory from GitHub using the Contents API.
Recursively fetches all files under the given subpath and writes them
- to dest_dir. Raises on any network or API error.
+ to dest_dir. Used as a fallback when zip download fails.
+ Costs one API request per directory (60/hr unauthenticated).
"""
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{subpath}?ref={branch}"
resp = requests.get(api_url, timeout=30, headers={"Accept": "application/vnd.github.v3+json"})
@@ -80,6 +167,119 @@ def _download_github_dir(owner, repo, branch, subpath, dest_dir):
_download_github_dir(owner, repo, branch, child_subpath, dest_dir)
+# Directories to search for skills following the Agent Skills convention
+_SKILL_SCAN_DIRS = [
+ "skills",
+ "skills/.curated",
+ "skills/.experimental",
+]
+
+_SKILL_SCAN_SKIP = {
+ "node_modules", "__pycache__", ".git", ".github", "venv", ".venv",
+}
+
+
+def _scan_skills_in_repo(repo_root: str) -> list:
+ """Scan a repo for skill directories containing SKILL.md.
+
+ Searches in conventional locations (skills/, skills/.curated/, etc.)
+ and also checks the repo root itself.
+
+ Returns a list of (skill_name, skill_dir_path) tuples.
+ """
+ found = []
+
+ # Check repo root for a SKILL.md (single-skill repo)
+ if os.path.isfile(os.path.join(repo_root, "SKILL.md")):
+ fm = _parse_skill_frontmatter(_read_file_text(os.path.join(repo_root, "SKILL.md")))
+ name = fm.get("name") or os.path.basename(repo_root)
+ found.append((name, repo_root))
+ return found
+
+ for scan_dir in _SKILL_SCAN_DIRS:
+ search_root = os.path.join(repo_root, scan_dir)
+ if not os.path.isdir(search_root):
+ continue
+ for entry in os.listdir(search_root):
+ if entry.startswith(".") and entry not in (".curated", ".experimental"):
+ continue
+ if entry in _SKILL_SCAN_SKIP:
+ continue
+ entry_path = os.path.join(search_root, entry)
+ if os.path.isdir(entry_path) and os.path.isfile(os.path.join(entry_path, "SKILL.md")):
+ fm = _parse_skill_frontmatter(
+ _read_file_text(os.path.join(entry_path, "SKILL.md"))
+ )
+ name = fm.get("name") or entry
+ found.append((name, entry_path))
+
+ return found
+
+
+def _read_file_text(path: str) -> str:
+ """Read a file as UTF-8 text, returning empty string on failure."""
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return f.read()
+ except Exception:
+ return ""
+
+
+def _install_local(path: str):
+ """Install skill(s) from a local directory.
+
+ If the path contains SKILL.md directly, install it as a single skill.
+ Otherwise scan for skills/ subdirectories with SKILL.md.
+ """
+ path = os.path.abspath(os.path.expanduser(path))
+ if not os.path.isdir(path):
+ click.echo(f"Error: '{path}' is not a directory.", err=True)
+ sys.exit(1)
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ if os.path.isfile(os.path.join(path, "SKILL.md")):
+ fm = _parse_skill_frontmatter(_read_file_text(os.path.join(path, "SKILL.md")))
+ skill_name = fm.get("name") or os.path.basename(path)
+ skill_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', skill_name)[:64]
+ _validate_skill_name(skill_name)
+ target_dir = os.path.join(skills_dir, skill_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(path, target_dir)
+ _register_installed_skill(skill_name, source="local")
+ _print_install_success(skill_name, "local")
+ return
+
+ discovered = _scan_skills_in_repo(path)
+ if not discovered:
+ click.echo(f"Error: No skills found in '{path}'.", err=True)
+ sys.exit(1)
+
+ click.echo(f"Found {len(discovered)} skill(s) in {path}:\n")
+ installed_names = []
+ for sname, sdir in discovered:
+ safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
+ if not _SAFE_NAME_RE.match(safe_name):
+ click.echo(click.style(f" ✗ Skipping '{sname}' (invalid name)", fg="yellow"))
+ continue
+ target_dir = os.path.join(skills_dir, safe_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(sdir, target_dir)
+ _register_installed_skill(safe_name, source="local")
+ installed_names.append(safe_name)
+
+ if installed_names:
+ click.echo("")
+ for n in installed_names:
+ _print_install_success(n, "local")
+ click.echo(f"\n {len(installed_names)} skill(s) installed from local path.")
+ else:
+ click.echo("No valid skills found in directory.")
+
+
def _register_installed_skill(name: str, source: str = "cowhub"):
"""Register a newly installed skill into skills_config.json.
@@ -417,50 +617,98 @@ def search(query):
@skill.command()
@click.argument("name")
def install(name):
- """Install a skill from Skill Hub, GitHub, or a SKILL.md URL.
+ """Install skill(s) from Skill Hub, GitHub, GitLab, git URL, or local path.
+
+ When given an owner/repo (or full URL), downloads the repo and
+ auto-discovers all skills/ subdirectories containing SKILL.md,
+ installing them in batch. Use a subpath to install a single skill.
Examples:
- cow skill install pptx
+ cow skill install pptx (from Skill Hub)
- cow skill install github:owner/repo
+ cow skill install larksuite/cli (GitHub shorthand, all skills)
- cow skill install github:owner/repo#path/to/skill
+ cow skill install larksuite/cli#skills/lark-im (single skill by subpath)
- cow skill install https://github.com/owner/repo/tree/main/path/to/skill
+ cow skill install https://github.com/owner/repo
+
+ cow skill install https://gitlab.com/org/repo
+
+ cow skill install git@github.com:owner/repo.git
+
+ cow skill install ./my-local-skills (local directory)
cow skill install https://example.com/path/to/SKILL.md
"""
+ # --- Local path ---
+ if name.startswith(("./", "../", "/", "~/")):
+ _install_local(name)
+ return
+
+ # --- Direct SKILL.md URL ---
if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
- # GitHub SKILL.md → strip filename and install the whole directory
dir_url = re.sub(r'/SKILL\.md/?$', '', name)
gh = _parse_github_url(dir_url)
if gh:
owner, repo, branch, subpath = gh
- spec = f"{owner}/{repo}"
- skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
- _install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
+ _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=(
+ subpath.rstrip("/").split("/")[-1] if subpath else repo
+ ), branch=branch)
return
_install_url(name)
return
+ # --- Full GitHub URL ---
parsed = _parse_github_url(name)
if parsed:
owner, repo, branch, subpath = parsed
- spec = f"{owner}/{repo}"
- skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
- _install_github(spec, subpath=subpath, skill_name=skill_name, branch=branch)
- elif name.startswith("github:"):
- skill_name = name[7:]
- _validate_skill_name(skill_name)
- _install_hub(skill_name)
- elif name.startswith("clawhub:"):
+ _install_github(f"{owner}/{repo}", subpath=subpath, branch=branch)
+ return
+
+ # --- Full GitLab URL ---
+ gl = _parse_gitlab_url(name)
+ if gl:
+ owner, repo, branch, subpath = gl
+ _install_gitlab(f"{owner}/{repo}", subpath=subpath, branch=branch)
+ return
+
+ # --- git@host:owner/repo.git SSH URL ---
+ ssh = _parse_git_ssh_url(name)
+ if ssh:
+ host, owner, repo = ssh
+ _install_git_clone(name, f"{owner}/{repo}")
+ return
+
+ # --- github: prefix ---
+ if name.startswith("github:"):
+ raw = name[7:]
+ subpath = None
+ if "#" in raw:
+ raw, subpath = raw.split("#", 1)
+ _validate_github_spec(raw)
+ _install_github(raw, subpath=subpath)
+ return
+
+ # --- clawhub: prefix ---
+ if name.startswith("clawhub:"):
skill_name = name[8:]
_validate_skill_name(skill_name)
_install_hub(skill_name, provider="clawhub")
- else:
- _validate_skill_name(name)
- _install_hub(name)
+ return
+
+ # --- owner/repo or owner/repo#subpath shorthand ---
+ if re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+(?:#.+)?$", name):
+ subpath = None
+ spec = name
+ if "#" in spec:
+ spec, subpath = spec.split("#", 1)
+ _install_github(spec, subpath=subpath)
+ return
+
+ # --- Fallback: Skill Hub by name ---
+ _validate_skill_name(name)
+ _install_hub(name)
def _install_hub(name, provider=None):
@@ -562,7 +810,13 @@ def _install_hub(name, provider=None):
def _install_github(spec, subpath=None, skill_name=None, branch="main", source="github"):
- """Install a skill from a GitHub repo.
+ """Install skill(s) from a GitHub repo.
+
+ Strategy: zip download first (no API rate limit), Contents API as fallback.
+
+ When subpath is given, install that single skill directory.
+ When subpath is None, scan the repo for all skills/ subdirectories containing
+ SKILL.md and batch-install them.
spec format: owner/repo or owner/repo#path
"""
@@ -571,72 +825,160 @@ def _install_github(spec, subpath=None, skill_name=None, branch="main", source="
_validate_github_spec(spec)
- if not skill_name:
- skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
- _validate_skill_name(skill_name)
-
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
- target_dir = os.path.join(skills_dir, skill_name)
-
owner, repo = spec.split("/", 1)
- # For subpath installs, try GitHub Contents API first (avoids downloading entire repo)
- if subpath:
- click.echo(f"Downloading from GitHub: {spec}/{subpath} (branch: {branch})...")
- try:
- with tempfile.TemporaryDirectory() as tmp_dir:
- api_dest = os.path.join(tmp_dir, skill_name)
- os.makedirs(api_dest)
- _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest)
- if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
- shutil.copytree(api_dest, target_dir)
- _register_installed_skill(skill_name, source=source)
- _print_install_success(skill_name, source)
- return
- except Exception:
- click.echo("Contents API unavailable, falling back to zip download...")
-
- # Fallback: download full repo zip
- zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
+ # --- Primary: zip download (no rate limit) ---
click.echo(f"Downloading from GitHub: {spec} (branch: {branch})...")
+ tmp_dir = None
+ repo_root = None
try:
- resp = requests.get(zip_url, timeout=60, allow_redirects=True)
- resp.raise_for_status()
- except Exception as e:
- click.echo(f"Error: Failed to download from GitHub: {e}", err=True)
+ tmp_dir, repo_root = _download_repo_zip(spec, branch)
+ except Exception:
+ click.echo("Zip download failed, falling back to Contents API...")
+
+ if repo_root:
+ try:
+ _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source)
+ return
+ except SystemExit:
+ raise
+ except Exception as e:
+ click.echo(f"Error processing zip: {e}", err=True)
+ click.echo("Falling back to Contents API...")
+ finally:
+ if tmp_dir:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ # --- Fallback: Contents API (rate-limited but works for single skill) ---
+ if not subpath:
+ click.echo("Error: Zip download failed and batch install requires zip.", err=True)
+ click.echo(f" Try again or specify a subpath: cow skill install {spec}#skills/", err=True)
sys.exit(1)
- with tempfile.TemporaryDirectory() as tmp_dir:
- zip_path = os.path.join(tmp_dir, "repo.zip")
- with open(zip_path, "wb") as f:
- f.write(resp.content)
+ if not skill_name:
+ skill_name = subpath.rstrip("/").split("/")[-1]
+ _validate_skill_name(skill_name)
- extract_dir = os.path.join(tmp_dir, "extracted")
- with zipfile.ZipFile(zip_path, "r") as zf:
- _safe_extractall(zf, extract_dir)
+ click.echo(f"Downloading via Contents API: {spec}/{subpath} ...")
+ target_dir = os.path.join(skills_dir, skill_name)
+ try:
+ with tempfile.TemporaryDirectory() as api_tmp:
+ api_dest = os.path.join(api_tmp, skill_name)
+ os.makedirs(api_dest)
+ _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(api_dest, target_dir)
+ _register_installed_skill(skill_name, source=source)
+ _print_install_success(skill_name, source)
+ except Exception as e:
+ click.echo(f"Error: Contents API also failed: {e}", err=True)
+ sys.exit(1)
- top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
- repo_root = extract_dir
- if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
- repo_root = os.path.join(extract_dir, top_items[0])
- if subpath:
- source_dir = os.path.join(repo_root, subpath.strip("/"))
- if not os.path.isdir(source_dir):
- click.echo(f"Error: Path '{subpath}' not found in repository.", err=True)
- sys.exit(1)
- else:
- source_dir = repo_root
+def _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source):
+ """Install skill(s) from an already-extracted repo root directory."""
+ if subpath:
+ source_dir = os.path.join(repo_root, subpath.strip("/"))
+ if not os.path.isdir(source_dir):
+ click.echo(f"Error: Path '{subpath}' not found in repository.", err=True)
+ sys.exit(1)
+ if not skill_name:
+ fm = _parse_skill_frontmatter(
+ _read_file_text(os.path.join(source_dir, "SKILL.md"))
+ )
+ skill_name = fm.get("name") or subpath.rstrip("/").split("/")[-1]
+ _validate_skill_name(skill_name)
+
+ target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
+ _register_installed_skill(skill_name, source=source)
+ _print_install_success(skill_name, source)
+ else:
+ # Auto-discover all skills in the repo
+ discovered = _scan_skills_in_repo(repo_root)
- _register_installed_skill(skill_name, source=source)
- _print_install_success(skill_name, source)
+ if not discovered:
+ if skill_name:
+ _validate_skill_name(skill_name)
+ else:
+ skill_name = spec.split("/")[-1]
+ _validate_skill_name(skill_name)
+ target_dir = os.path.join(skills_dir, skill_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(repo_root, target_dir)
+ _register_installed_skill(skill_name, source=source)
+ _print_install_success(skill_name, source)
+ return
+
+ click.echo(f"Found {len(discovered)} skill(s) in {spec}:\n")
+ installed_names = []
+ for sname, sdir in discovered:
+ safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
+ if not _SAFE_NAME_RE.match(safe_name):
+ click.echo(click.style(f" ✗ Skipping '{sname}' (invalid name)", fg="yellow"))
+ continue
+ target_dir = os.path.join(skills_dir, safe_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(sdir, target_dir)
+ _register_installed_skill(safe_name, source=source)
+ installed_names.append(safe_name)
+
+ if installed_names:
+ click.echo("")
+ for n in installed_names:
+ _print_install_success(n, source)
+ click.echo(f"\n {len(installed_names)} skill(s) installed from {spec}.")
+ else:
+ click.echo("No valid skills found in repository.")
+
+
+def _install_gitlab(spec, subpath=None, branch="main"):
+ """Install skill(s) from a GitLab repo via zip download."""
+ _validate_github_spec(spec)
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ click.echo(f"Downloading from GitLab: {spec} (branch: {branch})...")
+
+ try:
+ tmp_dir, repo_root = _download_repo_zip(spec, branch, host="gitlab")
+ except Exception as e:
+ click.echo(f"Error: Failed to download from GitLab: {e}", err=True)
+ sys.exit(1)
+
+ try:
+ _install_from_repo_root(repo_root, spec, subpath, None, skills_dir, "gitlab")
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+
+def _install_git_clone(git_url: str, display_name: str = ""):
+ """Install skill(s) from any git URL via shallow clone."""
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ click.echo(f"Cloning {display_name or git_url} ...")
+
+ try:
+ tmp_dir, repo_root = _clone_repo(git_url)
+ except RuntimeError as e:
+ click.echo(f"Error: {e}", err=True)
+ sys.exit(1)
+
+ try:
+ _install_from_repo_root(repo_root, display_name or git_url, None, None, skills_dir, "git")
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
def _install_zip_bytes(content, name, skills_dir):
From 079df5a47c01b33508c79c3cd9177a88dc0a7008 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 14:38:11 +0800
Subject: [PATCH 043/399] feat: support batch skill install from zip and github
---
cli/commands/skill.py | 562 ++++++++++++++++++++++---------------
plugins/cow_cli/cow_cli.py | 247 ++--------------
2 files changed, 355 insertions(+), 454 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 01065a72..51187262 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -8,6 +8,8 @@ import hashlib
import shutil
import zipfile
import tempfile
+from dataclasses import dataclass, field
+from typing import Optional, List
from urllib.parse import urlparse
@@ -22,6 +24,24 @@ from cli.utils import (
SKILL_HUB_API,
)
+
+# ======================================================================
+# Public types for the core install API (used by CLI and chat plugin)
+# ======================================================================
+
+class SkillInstallError(Exception):
+ """Raised when skill installation fails."""
+ pass
+
+
+@dataclass
+class InstallResult:
+ """Result of a skill installation operation."""
+ installed: List[str] = field(default_factory=list)
+ messages: List[str] = field(default_factory=list)
+ error: Optional[str] = None
+
+
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$")
_GITHUB_URL_RE = re.compile(
r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/(?:tree|blob)/([^/]+)(?:/(.+))?)?/?$"
@@ -216,6 +236,51 @@ def _scan_skills_in_repo(repo_root: str) -> list:
return found
+def _scan_skills_in_dir(directory: str) -> list:
+ """Scan immediate subdirectories for SKILL.md files.
+
+ Unlike _scan_skills_in_repo which checks conventional locations,
+ this scans all direct children of the given directory.
+ Returns a list of (skill_name, skill_dir_path) tuples.
+ """
+ found = []
+ if not os.path.isdir(directory):
+ return found
+ for entry in os.listdir(directory):
+ if entry.startswith(".") or entry in _SKILL_SCAN_SKIP:
+ continue
+ entry_path = os.path.join(directory, entry)
+ if os.path.isdir(entry_path) and os.path.isfile(os.path.join(entry_path, "SKILL.md")):
+ fm = _parse_skill_frontmatter(
+ _read_file_text(os.path.join(entry_path, "SKILL.md"))
+ )
+ name = fm.get("name") or entry
+ found.append((name, entry_path))
+ return found
+
+
+def _batch_install_skills(discovered, spec, skills_dir, source, result: InstallResult):
+ """Install a list of discovered skills into skills_dir."""
+ result.messages.append(f"Found {len(discovered)} skill(s) in {spec}:")
+ for sname, sdir in discovered:
+ safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
+ if not _SAFE_NAME_RE.match(safe_name):
+ result.messages.append(f" Skipping '{sname}' (invalid name)")
+ continue
+ target_dir = os.path.join(skills_dir, safe_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(sdir, target_dir)
+ _register_installed_skill(safe_name, source=source)
+ result.installed.append(safe_name)
+ result.messages.append(f" + {safe_name}")
+
+ if result.installed:
+ result.messages.append(f"{len(result.installed)} skill(s) installed from {spec}.")
+ else:
+ result.messages.append("No valid skills found.")
+
+
def _read_file_text(path: str) -> str:
"""Read a file as UTF-8 text, returning empty string on failure."""
try:
@@ -225,16 +290,11 @@ def _read_file_text(path: str) -> str:
return ""
-def _install_local(path: str):
- """Install skill(s) from a local directory.
-
- If the path contains SKILL.md directly, install it as a single skill.
- Otherwise scan for skills/ subdirectories with SKILL.md.
- """
+def _install_local(path: str, result: InstallResult):
+ """Install skill(s) from a local directory."""
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(path):
- click.echo(f"Error: '{path}' is not a directory.", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"'{path}' is not a directory.")
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
@@ -243,41 +303,21 @@ def _install_local(path: str):
fm = _parse_skill_frontmatter(_read_file_text(os.path.join(path, "SKILL.md")))
skill_name = fm.get("name") or os.path.basename(path)
skill_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', skill_name)[:64]
- _validate_skill_name(skill_name)
+ _check_skill_name(skill_name)
target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(path, target_dir)
_register_installed_skill(skill_name, source="local")
- _print_install_success(skill_name, "local")
+ result.installed.append(skill_name)
+ result.messages.append(f"Installed '{skill_name}' from local path.")
return
- discovered = _scan_skills_in_repo(path)
+ discovered = _scan_skills_in_repo(path) or _scan_skills_in_dir(path)
if not discovered:
- click.echo(f"Error: No skills found in '{path}'.", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"No skills found in '{path}'.")
- click.echo(f"Found {len(discovered)} skill(s) in {path}:\n")
- installed_names = []
- for sname, sdir in discovered:
- safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
- if not _SAFE_NAME_RE.match(safe_name):
- click.echo(click.style(f" ✗ Skipping '{sname}' (invalid name)", fg="yellow"))
- continue
- target_dir = os.path.join(skills_dir, safe_name)
- if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
- shutil.copytree(sdir, target_dir)
- _register_installed_skill(safe_name, source="local")
- installed_names.append(safe_name)
-
- if installed_names:
- click.echo("")
- for n in installed_names:
- _print_install_success(n, "local")
- click.echo(f"\n {len(installed_names)} skill(s) installed from local path.")
- else:
- click.echo("No valid skills found in directory.")
+ _batch_install_skills(discovered, path, skills_dir, "local", result)
def _register_installed_skill(name: str, source: str = "cowhub"):
@@ -345,39 +385,38 @@ def _read_skill_description(skill_dir: str) -> str:
return ""
-def _install_url(url: str):
+def _install_url(url: str, result: InstallResult):
"""Install a skill from a direct SKILL.md URL."""
- click.echo(f"Downloading SKILL.md from {url} ...")
+ result.messages.append(f"Downloading SKILL.md from {url} ...")
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
except Exception as e:
- click.echo(f"Error: Failed to download SKILL.md: {e}", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"Failed to download SKILL.md: {e}")
content = resp.text
fm = _parse_skill_frontmatter(content)
skill_name = fm.get("name")
if not skill_name:
- click.echo("Error: SKILL.md missing 'name' field in frontmatter.", err=True)
- sys.exit(1)
+ raise SkillInstallError("SKILL.md missing 'name' field in frontmatter.")
skill_name = skill_name.strip()
- _validate_skill_name(skill_name)
+ _check_skill_name(skill_name)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
skill_dir = os.path.join(skills_dir, skill_name)
if os.path.isdir(skill_dir):
- click.echo(f"Skill '{skill_name}' already exists. Overwriting SKILL.md ...")
+ result.messages.append(f"Skill '{skill_name}' already exists. Overwriting SKILL.md ...")
os.makedirs(skill_dir, exist_ok=True)
with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(content)
_register_installed_skill(skill_name, source="url")
- _print_install_success(skill_name, "url")
+ result.installed.append(skill_name)
+ result.messages.append(f"Installed '{skill_name}' from URL.")
def _print_install_success(name: str, source: str):
@@ -410,6 +449,20 @@ def _validate_github_spec(spec: str):
sys.exit(1)
+def _check_skill_name(name: str):
+ """Raise SkillInstallError if name is invalid."""
+ if not _SAFE_NAME_RE.match(name):
+ raise SkillInstallError(
+ f"Invalid skill name '{name}'. Use only letters, digits, hyphens, and underscores."
+ )
+
+
+def _check_github_spec(spec: str):
+ """Raise SkillInstallError if spec is not owner/repo."""
+ if not re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+$", spec):
+ raise SkillInstallError(f"Invalid GitHub spec '{spec}'. Expected format: owner/repo")
+
+
def _safe_extractall(zf: zipfile.ZipFile, dest: str):
"""Extract zip while guarding against Zip Slip (path traversal)."""
dest = os.path.realpath(dest)
@@ -441,6 +494,18 @@ def _verify_checksum(content: bytes, expected: str):
return True
+def _check_checksum(content: bytes, expected: str):
+ """Raise SkillInstallError on SHA-256 checksum mismatch."""
+ if not expected:
+ return
+ actual = hashlib.sha256(content).hexdigest()
+ if actual != expected.lower():
+ raise SkillInstallError(
+ f"Checksum mismatch! Expected: {expected}, Actual: {actual}. "
+ "The downloaded package may have been tampered with."
+ )
+
+
@click.group()
def skill():
"""Manage CowAgent skills."""
@@ -612,7 +677,98 @@ def search(query):
# ------------------------------------------------------------------
-# cow skill install
+# Core install function — reusable from CLI and chat plugin
+# ------------------------------------------------------------------
+
+def install_skill(name: str) -> InstallResult:
+ """Core install logic, usable from CLI and chat plugin.
+
+ Accepts all formats: Skill Hub name, owner/repo, GitHub/GitLab URL,
+ git@ SSH, local path, SKILL.md URL.
+ Returns InstallResult with installed skill names and messages.
+ """
+ result = InstallResult()
+ try:
+ _route_install(name, result)
+ except SkillInstallError as e:
+ result.error = str(e)
+ return result
+
+
+def _route_install(name: str, result: InstallResult):
+ """Dispatch to the appropriate installer based on input format."""
+ # --- Local path ---
+ if name.startswith(("./", "../", "/", "~/")):
+ _install_local(name, result)
+ return
+
+ # --- Direct SKILL.md URL ---
+ if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
+ dir_url = re.sub(r'/SKILL\.md/?$', '', name)
+ gh = _parse_github_url(dir_url)
+ if gh:
+ owner, repo, branch, subpath = gh
+ _install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=(
+ subpath.rstrip("/").split("/")[-1] if subpath else repo
+ ), branch=branch)
+ return
+ _install_url(name, result)
+ return
+
+ # --- Full GitHub URL ---
+ parsed = _parse_github_url(name)
+ if parsed:
+ owner, repo, branch, subpath = parsed
+ _install_github(f"{owner}/{repo}", result, subpath=subpath, branch=branch)
+ return
+
+ # --- Full GitLab URL ---
+ gl = _parse_gitlab_url(name)
+ if gl:
+ owner, repo, branch, subpath = gl
+ _install_gitlab(f"{owner}/{repo}", result, subpath=subpath, branch=branch)
+ return
+
+ # --- git@host:owner/repo.git SSH URL ---
+ ssh = _parse_git_ssh_url(name)
+ if ssh:
+ host, owner, repo = ssh
+ _install_git_clone(name, result, display_name=f"{owner}/{repo}")
+ return
+
+ # --- github: prefix ---
+ if name.startswith("github:"):
+ raw = name[7:]
+ subpath = None
+ if "#" in raw:
+ raw, subpath = raw.split("#", 1)
+ _check_github_spec(raw)
+ _install_github(raw, result, subpath=subpath)
+ return
+
+ # --- clawhub: prefix ---
+ if name.startswith("clawhub:"):
+ skill_name = name[8:]
+ _check_skill_name(skill_name)
+ _install_hub(skill_name, result, provider="clawhub")
+ return
+
+ # --- owner/repo or owner/repo#subpath shorthand ---
+ if re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+(?:#.+)?$", name):
+ subpath = None
+ spec = name
+ if "#" in spec:
+ spec, subpath = spec.split("#", 1)
+ _install_github(spec, result, subpath=subpath)
+ return
+
+ # --- Fallback: Skill Hub by name ---
+ _check_skill_name(name)
+ _install_hub(name, result)
+
+
+# ------------------------------------------------------------------
+# cow skill install (CLI thin wrapper)
# ------------------------------------------------------------------
@skill.command()
@click.argument("name")
@@ -641,82 +797,20 @@ def install(name):
cow skill install https://example.com/path/to/SKILL.md
"""
- # --- Local path ---
- if name.startswith(("./", "../", "/", "~/")):
- _install_local(name)
- return
-
- # --- Direct SKILL.md URL ---
- if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
- dir_url = re.sub(r'/SKILL\.md/?$', '', name)
- gh = _parse_github_url(dir_url)
- if gh:
- owner, repo, branch, subpath = gh
- _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=(
- subpath.rstrip("/").split("/")[-1] if subpath else repo
- ), branch=branch)
- return
- _install_url(name)
- return
-
- # --- Full GitHub URL ---
- parsed = _parse_github_url(name)
- if parsed:
- owner, repo, branch, subpath = parsed
- _install_github(f"{owner}/{repo}", subpath=subpath, branch=branch)
- return
-
- # --- Full GitLab URL ---
- gl = _parse_gitlab_url(name)
- if gl:
- owner, repo, branch, subpath = gl
- _install_gitlab(f"{owner}/{repo}", subpath=subpath, branch=branch)
- return
-
- # --- git@host:owner/repo.git SSH URL ---
- ssh = _parse_git_ssh_url(name)
- if ssh:
- host, owner, repo = ssh
- _install_git_clone(name, f"{owner}/{repo}")
- return
-
- # --- github: prefix ---
- if name.startswith("github:"):
- raw = name[7:]
- subpath = None
- if "#" in raw:
- raw, subpath = raw.split("#", 1)
- _validate_github_spec(raw)
- _install_github(raw, subpath=subpath)
- return
-
- # --- clawhub: prefix ---
- if name.startswith("clawhub:"):
- skill_name = name[8:]
- _validate_skill_name(skill_name)
- _install_hub(skill_name, provider="clawhub")
- return
-
- # --- owner/repo or owner/repo#subpath shorthand ---
- if re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+(?:#.+)?$", name):
- subpath = None
- spec = name
- if "#" in spec:
- spec, subpath = spec.split("#", 1)
- _install_github(spec, subpath=subpath)
- return
-
- # --- Fallback: Skill Hub by name ---
- _validate_skill_name(name)
- _install_hub(name)
+ result = install_skill(name)
+ for msg in result.messages:
+ click.echo(msg)
+ if result.error:
+ click.echo(f"Error: {result.error}", err=True)
+ sys.exit(1)
-def _install_hub(name, provider=None):
+def _install_hub(name, result: InstallResult, provider=None):
"""Install a skill from Skill Hub."""
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
- click.echo(f"Fetching skill info for '{name}'...")
+ result.messages.append(f"Fetching skill info for '{name}'...")
try:
body = {}
@@ -730,13 +824,12 @@ def _install_hub(name, provider=None):
resp.raise_for_status()
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
- click.echo(f"Error: Skill '{name}' not found on Skill Hub.", err=True)
- else:
- click.echo(f"Error: Failed to fetch skill: {e}", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"Skill '{name}' not found on Skill Hub.")
+ raise SkillInstallError(f"Failed to fetch skill: {e}")
+ except SkillInstallError:
+ raise
except Exception as e:
- click.echo(f"Error: Failed to connect to Skill Hub: {e}", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"Failed to connect to Skill Hub: {e}")
content_type = resp.headers.get("Content-Type", "")
@@ -749,12 +842,12 @@ def _install_hub(name, provider=None):
parsed_url = _parse_github_url(source_url)
if parsed_url:
owner, repo, branch, subpath = parsed_url
- click.echo(f"Source: GitHub ({source_url})")
- _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch)
+ result.messages.append(f"Source: GitHub ({source_url})")
+ _install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
else:
- _validate_github_spec(source_url)
- click.echo(f"Source: GitHub ({source_url})")
- _install_github(source_url, skill_name=name)
+ _check_github_spec(source_url)
+ result.messages.append(f"Source: GitHub ({source_url})")
+ _install_github(source_url, result, skill_name=name)
return
if source_type == "registry":
@@ -762,25 +855,25 @@ def _install_hub(name, provider=None):
if download_url:
parsed = urlparse(download_url)
if parsed.scheme != "https":
- click.echo(f"Error: Refusing to download from non-HTTPS URL.", err=True)
- sys.exit(1)
- provider = data.get("source_provider", "registry")
+ raise SkillInstallError("Refusing to download from non-HTTPS URL.")
+ src_provider = data.get("source_provider", "registry")
expected_checksum = data.get("checksum") or data.get("sha256")
- click.echo(f"Source: {provider}")
- click.echo("Downloading skill package...")
+ result.messages.append(f"Source: {src_provider}")
+ result.messages.append("Downloading skill package...")
try:
dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
dl_resp.raise_for_status()
except Exception as e:
- click.echo(f"Error: Failed to download from {provider}: {e}", err=True)
- sys.exit(1)
- _verify_checksum(dl_resp.content, expected_checksum)
- _install_zip_bytes(dl_resp.content, name, skills_dir)
- _register_installed_skill(name, source=provider)
- _print_install_success(name, provider)
+ raise SkillInstallError(f"Failed to download from {src_provider}: {e}")
+ _check_checksum(dl_resp.content, expected_checksum)
+ installed_before = len(result.installed)
+ _install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
+ if len(result.installed) == installed_before:
+ _register_installed_skill(name, source=src_provider)
+ result.installed.append(name)
+ result.messages.append(f"Installed '{name}' from {src_provider}.")
else:
- click.echo(f"Error: Unsupported registry provider.", err=True)
- sys.exit(1)
+ raise SkillInstallError("Unsupported registry provider.")
return
if "redirect" in data:
@@ -788,81 +881,76 @@ def _install_hub(name, provider=None):
parsed_url = _parse_github_url(source_url)
if parsed_url:
owner, repo, branch, subpath = parsed_url
- click.echo(f"Source: GitHub ({source_url})")
- _install_github(f"{owner}/{repo}", subpath=subpath, skill_name=name, branch=branch)
+ result.messages.append(f"Source: GitHub ({source_url})")
+ _install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
else:
- _validate_github_spec(source_url)
- click.echo(f"Source: GitHub ({source_url})")
- _install_github(source_url, skill_name=name)
+ _check_github_spec(source_url)
+ result.messages.append(f"Source: GitHub ({source_url})")
+ _install_github(source_url, result, skill_name=name)
return
elif "application/zip" in content_type:
- click.echo("Downloading skill package...")
+ result.messages.append("Downloading skill package...")
expected_checksum = resp.headers.get("X-Checksum-Sha256")
- _verify_checksum(resp.content, expected_checksum)
- _install_zip_bytes(resp.content, name, skills_dir)
- _register_installed_skill(name)
- _print_install_success(name, "cowhub")
+ _check_checksum(resp.content, expected_checksum)
+ installed_before = len(result.installed)
+ _install_zip_bytes(resp.content, name, skills_dir, result=result, source_label="cowhub")
+ if len(result.installed) == installed_before:
+ _register_installed_skill(name)
+ result.installed.append(name)
+ result.messages.append(f"Installed '{name}' from Skill Hub.")
return
- click.echo(f"Error: Unexpected response from Skill Hub.", err=True)
- sys.exit(1)
+ raise SkillInstallError("Unexpected response from Skill Hub.")
-def _install_github(spec, subpath=None, skill_name=None, branch="main", source="github"):
+def _install_github(spec, result: InstallResult, subpath=None, skill_name=None, branch="main", source="github"):
"""Install skill(s) from a GitHub repo.
Strategy: zip download first (no API rate limit), Contents API as fallback.
-
- When subpath is given, install that single skill directory.
- When subpath is None, scan the repo for all skills/ subdirectories containing
- SKILL.md and batch-install them.
-
- spec format: owner/repo or owner/repo#path
"""
if "#" in spec and not subpath:
spec, subpath = spec.split("#", 1)
- _validate_github_spec(spec)
+ _check_github_spec(spec)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
owner, repo = spec.split("/", 1)
- # --- Primary: zip download (no rate limit) ---
- click.echo(f"Downloading from GitHub: {spec} (branch: {branch})...")
+ result.messages.append(f"Downloading from GitHub: {spec} (branch: {branch})...")
tmp_dir = None
repo_root = None
try:
tmp_dir, repo_root = _download_repo_zip(spec, branch)
except Exception:
- click.echo("Zip download failed, falling back to Contents API...")
+ result.messages.append("Zip download failed, falling back to Contents API...")
if repo_root:
try:
- _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source)
+ _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source, result)
return
- except SystemExit:
+ except SkillInstallError:
raise
except Exception as e:
- click.echo(f"Error processing zip: {e}", err=True)
- click.echo("Falling back to Contents API...")
+ result.messages.append(f"Error processing zip: {e}")
+ result.messages.append("Falling back to Contents API...")
finally:
if tmp_dir:
shutil.rmtree(tmp_dir, ignore_errors=True)
- # --- Fallback: Contents API (rate-limited but works for single skill) ---
if not subpath:
- click.echo("Error: Zip download failed and batch install requires zip.", err=True)
- click.echo(f" Try again or specify a subpath: cow skill install {spec}#skills/", err=True)
- sys.exit(1)
+ raise SkillInstallError(
+ f"Zip download failed and batch install requires zip. "
+ f"Try again or specify a subpath: {spec}#skills/"
+ )
if not skill_name:
skill_name = subpath.rstrip("/").split("/")[-1]
- _validate_skill_name(skill_name)
+ _check_skill_name(skill_name)
- click.echo(f"Downloading via Contents API: {spec}/{subpath} ...")
+ result.messages.append(f"Downloading via Contents API: {spec}/{subpath} ...")
target_dir = os.path.join(skills_dir, skill_name)
try:
with tempfile.TemporaryDirectory() as api_tmp:
@@ -873,116 +961,109 @@ def _install_github(spec, subpath=None, skill_name=None, branch="main", source="
shutil.rmtree(target_dir)
shutil.copytree(api_dest, target_dir)
_register_installed_skill(skill_name, source=source)
- _print_install_success(skill_name, source)
+ result.installed.append(skill_name)
+ result.messages.append(f"Installed '{skill_name}' from GitHub.")
except Exception as e:
- click.echo(f"Error: Contents API also failed: {e}", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"Contents API also failed: {e}")
-def _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source):
+def _install_from_repo_root(repo_root, spec, subpath, skill_name, skills_dir, source, result: InstallResult):
"""Install skill(s) from an already-extracted repo root directory."""
if subpath:
source_dir = os.path.join(repo_root, subpath.strip("/"))
if not os.path.isdir(source_dir):
- click.echo(f"Error: Path '{subpath}' not found in repository.", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"Path '{subpath}' not found in repository.")
- if not skill_name:
- fm = _parse_skill_frontmatter(
- _read_file_text(os.path.join(source_dir, "SKILL.md"))
- )
- skill_name = fm.get("name") or subpath.rstrip("/").split("/")[-1]
- _validate_skill_name(skill_name)
+ if os.path.isfile(os.path.join(source_dir, "SKILL.md")):
+ if not skill_name:
+ fm = _parse_skill_frontmatter(
+ _read_file_text(os.path.join(source_dir, "SKILL.md"))
+ )
+ skill_name = fm.get("name") or subpath.rstrip("/").split("/")[-1]
+ _check_skill_name(skill_name)
- target_dir = os.path.join(skills_dir, skill_name)
- if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
- shutil.copytree(source_dir, target_dir)
- _register_installed_skill(skill_name, source=source)
- _print_install_success(skill_name, source)
+ target_dir = os.path.join(skills_dir, skill_name)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(source_dir, target_dir)
+ _register_installed_skill(skill_name, source=source)
+ result.installed.append(skill_name)
+ result.messages.append(f"Installed '{skill_name}' from {source}.")
+ return
+
+ discovered = _scan_skills_in_dir(source_dir)
+ if discovered:
+ _batch_install_skills(discovered, spec, skills_dir, source, result)
+ return
+
+ raise SkillInstallError(f"No SKILL.md found in '{subpath}' or its subdirectories.")
else:
- # Auto-discover all skills in the repo
discovered = _scan_skills_in_repo(repo_root)
if not discovered:
if skill_name:
- _validate_skill_name(skill_name)
+ _check_skill_name(skill_name)
else:
skill_name = spec.split("/")[-1]
- _validate_skill_name(skill_name)
+ _check_skill_name(skill_name)
target_dir = os.path.join(skills_dir, skill_name)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(repo_root, target_dir)
_register_installed_skill(skill_name, source=source)
- _print_install_success(skill_name, source)
+ result.installed.append(skill_name)
+ result.messages.append(f"Installed '{skill_name}' from {source}.")
return
- click.echo(f"Found {len(discovered)} skill(s) in {spec}:\n")
- installed_names = []
- for sname, sdir in discovered:
- safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
- if not _SAFE_NAME_RE.match(safe_name):
- click.echo(click.style(f" ✗ Skipping '{sname}' (invalid name)", fg="yellow"))
- continue
- target_dir = os.path.join(skills_dir, safe_name)
- if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
- shutil.copytree(sdir, target_dir)
- _register_installed_skill(safe_name, source=source)
- installed_names.append(safe_name)
-
- if installed_names:
- click.echo("")
- for n in installed_names:
- _print_install_success(n, source)
- click.echo(f"\n {len(installed_names)} skill(s) installed from {spec}.")
- else:
- click.echo("No valid skills found in repository.")
+ _batch_install_skills(discovered, spec, skills_dir, source, result)
-def _install_gitlab(spec, subpath=None, branch="main"):
+def _install_gitlab(spec, result: InstallResult, subpath=None, branch="main"):
"""Install skill(s) from a GitLab repo via zip download."""
- _validate_github_spec(spec)
+ _check_github_spec(spec)
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
- click.echo(f"Downloading from GitLab: {spec} (branch: {branch})...")
+ result.messages.append(f"Downloading from GitLab: {spec} (branch: {branch})...")
try:
tmp_dir, repo_root = _download_repo_zip(spec, branch, host="gitlab")
except Exception as e:
- click.echo(f"Error: Failed to download from GitLab: {e}", err=True)
- sys.exit(1)
+ raise SkillInstallError(f"Failed to download from GitLab: {e}")
try:
- _install_from_repo_root(repo_root, spec, subpath, None, skills_dir, "gitlab")
+ _install_from_repo_root(repo_root, spec, subpath, None, skills_dir, "gitlab", result)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
-def _install_git_clone(git_url: str, display_name: str = ""):
+def _install_git_clone(git_url: str, result: InstallResult, display_name: str = ""):
"""Install skill(s) from any git URL via shallow clone."""
skills_dir = get_skills_dir()
os.makedirs(skills_dir, exist_ok=True)
- click.echo(f"Cloning {display_name or git_url} ...")
+ result.messages.append(f"Cloning {display_name or git_url} ...")
try:
tmp_dir, repo_root = _clone_repo(git_url)
except RuntimeError as e:
- click.echo(f"Error: {e}", err=True)
- sys.exit(1)
+ raise SkillInstallError(str(e))
try:
- _install_from_repo_root(repo_root, display_name or git_url, None, None, skills_dir, "git")
+ _install_from_repo_root(repo_root, display_name or git_url, None, None, skills_dir, "git", result)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
-def _install_zip_bytes(content, name, skills_dir):
- """Extract a zip archive into the skills directory."""
+def _install_zip_bytes(content, name, skills_dir, result: InstallResult = None, source_label: str = "zip"):
+ """Extract a zip archive and install skill(s).
+
+ Supports three scenarios:
+ 1. Root contains SKILL.md → single skill install
+ 2. Contains multiple skill dirs (skills/, or immediate children with SKILL.md) → batch install
+ 3. Fallback → treat the entire archive as a single skill named `name`
+ """
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "package.zip")
with open(zip_path, "wb") as f:
@@ -993,14 +1074,35 @@ def _install_zip_bytes(content, name, skills_dir):
_safe_extractall(zf, extract_dir)
top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
- source = extract_dir
+ pkg_root = extract_dir
if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
- source = os.path.join(extract_dir, top_items[0])
+ pkg_root = os.path.join(extract_dir, top_items[0])
+
+ discovered = _scan_skills_in_repo(pkg_root) or _scan_skills_in_dir(pkg_root)
+
+ if discovered and len(discovered) > 1 and result is not None:
+ _batch_install_skills(discovered, name, skills_dir, source_label, result)
+ return
+
+ if discovered and len(discovered) == 1:
+ sname, sdir = discovered[0]
+ safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
+ if not _SAFE_NAME_RE.match(safe_name):
+ safe_name = name
+ target = os.path.join(skills_dir, safe_name)
+ if os.path.exists(target):
+ shutil.rmtree(target)
+ shutil.copytree(sdir, target)
+ _register_installed_skill(safe_name, source=source_label)
+ if result is not None:
+ result.installed.append(safe_name)
+ result.messages.append(f"Installed '{safe_name}' from {source_label}.")
+ return
target = os.path.join(skills_dir, name)
if os.path.exists(target):
shutil.rmtree(target)
- shutil.copytree(source, target)
+ shutil.copytree(pkg_root, target)
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 7d2fce09..97f722d8 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -599,239 +599,38 @@ class CowCliPlugin(Plugin):
return "请指定要安装的技能: /skill install <名称>"
try:
- from cli.utils import get_skills_dir, SKILL_HUB_API
- from cli.commands.skill import _parse_github_url, _download_github_dir
- import requests
- import shutil
- import zipfile
- import tempfile
+ from cli.commands.skill import install_skill
+ result = install_skill(name)
- skills_dir = get_skills_dir()
- os.makedirs(skills_dir, exist_ok=True)
+ if result.error:
+ return f"安装失败: {result.error}"
- if name.startswith(("http://", "https://")) and name.rstrip("/").endswith("SKILL.md"):
- import re as re_mod
- dir_url = re_mod.sub(r'/SKILL\.md/?$', '', name)
- gh = _parse_github_url(dir_url)
- if gh:
- owner, repo, branch, subpath = gh
- spec = f"{owner}/{repo}"
- skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
- return self._skill_install_github(
- spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch
- )
- return self._skill_install_url(name, skills_dir)
+ if not result.installed:
+ return "\n".join(result.messages) if result.messages else "未找到可安装的技能"
- parsed = _parse_github_url(name)
- if parsed:
- owner, repo, branch, subpath = parsed
- spec = f"{owner}/{repo}"
- skill_name = subpath.rstrip("/").split("/")[-1] if subpath else repo
- return self._skill_install_github(
- spec, skills_dir, subpath=subpath, skill_name=skill_name, branch=branch
- )
-
- provider = None
- if name.startswith("github:"):
- name = name[7:]
- elif name.startswith("clawhub:"):
- name = name[8:]
- provider = "clawhub"
-
- body = {}
- if provider:
- body["provider"] = provider
- resp = requests.post(
- f"{SKILL_HUB_API}/skills/{name}/download",
- json=body,
- timeout=15,
- )
- resp.raise_for_status()
-
- content_type = resp.headers.get("Content-Type", "")
-
- if "application/json" in content_type:
- data = resp.json()
- source_type = data.get("source_type")
- if source_type == "github" or "redirect" in data:
- source_url = data.get("source_url", "")
- parsed_url = _parse_github_url(source_url)
- if parsed_url:
- owner, repo, branch, subpath = parsed_url
- return self._skill_install_github(
- f"{owner}/{repo}", skills_dir, subpath=subpath,
- skill_name=name, branch=branch
- )
- return self._skill_install_github(source_url, skills_dir, skill_name=name)
- if source_type == "registry":
- download_url = data.get("download_url")
- if not download_url:
- return f"此技能来自不支持的注册表,无法自动安装。"
- from urllib.parse import urlparse
- if urlparse(download_url).scheme != "https":
- return "安装失败: 下载地址不安全 (非 HTTPS)"
- provider = data.get("source_provider", "registry")
- try:
- dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
- dl_resp.raise_for_status()
- except Exception as e:
- return f"从 {provider} 下载失败: {e}"
- self._extract_zip(dl_resp.content, name, skills_dir)
- self._register_skill(name, source=provider)
- return self._format_install_success(name, provider)
-
- elif "application/zip" in content_type:
- self._extract_zip(resp.content, name, skills_dir)
- self._register_skill(name, source="cowhub")
- return self._format_install_success(name, "cowhub")
-
- return "技能商店返回了未预期的响应格式"
-
- except requests.HTTPError as e:
- if e.response is not None and e.response.status_code == 404:
- return f"技能 '{name}' 未在技能商店中找到"
- return f"安装失败: {e}"
+ return self._format_install_result(result)
except Exception as e:
return f"安装失败: {e}"
- def _skill_install_url(self, url: str, skills_dir: str) -> str:
- """Install a skill from a direct SKILL.md URL."""
- import requests
- from cli.commands.skill import _parse_skill_frontmatter
-
- try:
- resp = requests.get(url, timeout=30)
- resp.raise_for_status()
- except Exception as e:
- return f"下载 SKILL.md 失败: {e}"
-
- content = resp.text
- fm = _parse_skill_frontmatter(content)
- skill_name = fm.get("name")
- if not skill_name:
- return "SKILL.md 中未找到 name 字段,无法安装"
-
- skill_name = skill_name.strip()
- skill_dir = os.path.join(skills_dir, skill_name)
- os.makedirs(skill_dir, exist_ok=True)
-
- with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
- f.write(content)
-
- self._register_skill(skill_name, source="url")
- return self._format_install_success(skill_name, "url")
-
- def _skill_install_github(self, spec: str, skills_dir: str,
- subpath: str = None, skill_name: str = None,
- branch: str = "main") -> str:
- import requests
- import shutil
- import zipfile
- import tempfile
- from cli.commands.skill import _download_github_dir
-
- if "#" in spec and not subpath:
- spec, subpath = spec.split("#", 1)
- if not skill_name:
- skill_name = subpath.rstrip("/").split("/")[-1] if subpath else spec.split("/")[-1]
-
- owner, repo = spec.split("/", 1)
- target_dir = os.path.join(skills_dir, skill_name)
-
- # For subpath installs, try Contents API first
- if subpath:
- try:
- with tempfile.TemporaryDirectory() as tmp_dir:
- api_dest = os.path.join(tmp_dir, skill_name)
- os.makedirs(api_dest)
- _download_github_dir(owner, repo, branch, subpath.strip("/"), api_dest)
- if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
- shutil.copytree(api_dest, target_dir)
- self._register_skill(skill_name, source="github")
- return self._format_install_success(skill_name, "github")
- except Exception:
- pass # fall through to zip download
-
- # Fallback: download full repo zip
- zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
- try:
- resp = requests.get(zip_url, timeout=60, allow_redirects=True)
- resp.raise_for_status()
- except Exception as e:
- return f"从 GitHub 下载失败: {e}"
-
- with tempfile.TemporaryDirectory() as tmp_dir:
- zip_path = os.path.join(tmp_dir, "repo.zip")
- with open(zip_path, "wb") as f:
- f.write(resp.content)
-
- extract_dir = os.path.join(tmp_dir, "extracted")
- with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(extract_dir)
-
- top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
- repo_root = extract_dir
- if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
- repo_root = os.path.join(extract_dir, top_items[0])
-
- if subpath:
- source_dir = os.path.join(repo_root, subpath.strip("/"))
- if not os.path.isdir(source_dir):
- return f"路径 '{subpath}' 在仓库中不存在"
- else:
- source_dir = repo_root
-
- if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
- shutil.copytree(source_dir, target_dir)
-
- self._register_skill(skill_name, source="github")
- return self._format_install_success(skill_name, "github")
-
- def _extract_zip(self, content: bytes, name: str, skills_dir: str):
- import zipfile
- import tempfile
- import shutil
-
- with tempfile.TemporaryDirectory() as tmp_dir:
- zip_path = os.path.join(tmp_dir, "package.zip")
- with open(zip_path, "wb") as f:
- f.write(content)
-
- extract_dir = os.path.join(tmp_dir, "extracted")
- with zipfile.ZipFile(zip_path, "r") as zf:
- zf.extractall(extract_dir)
-
- top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
- source = extract_dir
- if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
- source = os.path.join(extract_dir, top_items[0])
-
- target = os.path.join(skills_dir, name)
- if os.path.exists(target):
- shutil.rmtree(target)
- shutil.copytree(source, target)
-
@staticmethod
- def _register_skill(name: str, source: str = "cowhub"):
- try:
- from cli.commands.skill import _register_installed_skill
- _register_installed_skill(name, source=source)
- except Exception:
- pass
-
- @staticmethod
- def _format_install_success(name: str, source: str) -> str:
+ def _format_install_result(result) -> str:
+ """Format InstallResult into a chat-friendly message."""
from cli.commands.skill import _read_skill_description
from cli.utils import get_skills_dir
- desc = _read_skill_description(os.path.join(get_skills_dir(), name))
- lines = [f"✅ {name}"]
- if desc:
- if len(desc) > 60:
- desc = desc[:57] + "…"
- lines.append(f" {desc}")
- lines.append(f" 来源: {source}")
+ skills_dir = get_skills_dir()
+
+ lines = []
+ for skill_name in result.installed:
+ desc = _read_skill_description(os.path.join(skills_dir, skill_name))
+ lines.append(f"✅ {skill_name}")
+ if desc:
+ if len(desc) > 60:
+ desc = desc[:57] + "…"
+ lines.append(f" {desc}")
+
+ if len(result.installed) > 1:
+ lines.append(f"\n共安装 {len(result.installed)} 个技能")
+
return "\n".join(lines)
def _skill_uninstall(self, name: str) -> str:
From 3458621147b3f27cce1f853a77050dcf640423db Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 14:59:06 +0800
Subject: [PATCH 044/399] feat: add browser tool
---
agent/tools/__init__.py | 31 +-
agent/tools/browser/__init__.py | 3 +
agent/tools/browser/browser_service.py | 509 +++++++++++++++++++++++++
agent/tools/browser/browser_tool.py | 287 ++++++++++++++
agent/tools/browser_tool.py | 18 -
agent/tools/tool_manager.py | 10 +-
6 files changed, 819 insertions(+), 39 deletions(-)
create mode 100644 agent/tools/browser/__init__.py
create mode 100644 agent/tools/browser/browser_service.py
create mode 100644 agent/tools/browser/browser_tool.py
delete mode 100644 agent/tools/browser_tool.py
diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py
index 106d7a14..8bfc6de9 100644
--- a/agent/tools/__init__.py
+++ b/agent/tools/__init__.py
@@ -87,25 +87,25 @@ FileSave = _optional_tools.get('FileSave')
Terminal = _optional_tools.get('Terminal')
-# Delayed import for BrowserTool
+# BrowserTool (requires playwright)
def _import_browser_tool():
+ from common.log import logger
try:
from agent.tools.browser.browser_tool import BrowserTool
return BrowserTool
- except ImportError:
- # Return a placeholder class that will prompt the user to install dependencies when instantiated
- class BrowserToolPlaceholder:
- def __init__(self, *args, **kwargs):
- raise ImportError(
- "The 'browser-use' package is required to use BrowserTool. "
- "Please install it with 'pip install browser-use>=0.1.40'."
- )
+ except ImportError as e:
+ logger.info(
+ f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
+ f" To enable browser tool, run:\n"
+ f" pip install playwright\n"
+ f" playwright install chromium"
+ )
+ return None
+ except Exception as e:
+ logger.error(f"[Tools] BrowserTool failed to load: {e}")
+ return None
- return BrowserToolPlaceholder
-
-
-# Dynamically set BrowserTool
-# BrowserTool = _import_browser_tool()
+BrowserTool = _import_browser_tool()
# Export all tools (including optional ones that might be None)
__all__ = [
@@ -124,8 +124,7 @@ __all__ = [
'WebSearch',
'WebFetch',
'Vision',
- # Optional tools (may be None if dependencies not available)
- # 'BrowserTool'
+ 'BrowserTool',
]
"""
diff --git a/agent/tools/browser/__init__.py b/agent/tools/browser/__init__.py
new file mode 100644
index 00000000..8a5e7330
--- /dev/null
+++ b/agent/tools/browser/__init__.py
@@ -0,0 +1,3 @@
+from agent.tools.browser.browser_tool import BrowserTool
+
+__all__ = ["BrowserTool"]
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
new file mode 100644
index 00000000..d502ffb3
--- /dev/null
+++ b/agent/tools/browser/browser_service.py
@@ -0,0 +1,509 @@
+"""
+Browser service - Playwright wrapper managing browser lifecycle and page operations.
+
+Lazily launches a Chromium instance on first use, reuses it across tool calls,
+and cleans up on close(). Headless mode is auto-detected based on platform and
+display availability.
+"""
+
+import os
+import sys
+import re
+import uuid
+from typing import Optional, Dict, Any, List
+
+from common.log import logger
+
+from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
+
+
+# ---------------------------------------------------------------------------
+# Snapshot DOM helpers
+# ---------------------------------------------------------------------------
+
+# Tags that typically carry useful content for an agent
+_INTERACTIVE_TAGS = {
+ "a", "button", "input", "textarea", "select", "option",
+ "label", "details", "summary",
+}
+_SEMANTIC_TAGS = {
+ "h1", "h2", "h3", "h4", "h5", "h6",
+ "p", "li", "td", "th", "caption", "figcaption", "blockquote", "pre", "code",
+ "nav", "main", "article", "section", "header", "footer", "form", "table",
+ "img", "video", "audio",
+}
+_KEEP_TAGS = _INTERACTIVE_TAGS | _SEMANTIC_TAGS
+
+_SNAPSHOT_JS = """
+() => {
+ const KEEP = new Set(%s);
+ const INTERACTIVE = new Set(%s);
+ const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]);
+ let refCounter = 0;
+ const refMap = {};
+
+ function visible(el) {
+ if (!(el instanceof HTMLElement)) return true;
+ const st = window.getComputedStyle(el);
+ if (st.display === "none" || st.visibility === "hidden") return false;
+ if (parseFloat(st.opacity) === 0) return false;
+ return true;
+ }
+
+ function walk(node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const t = node.textContent.trim();
+ return t ? t : null;
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
+ const tag = node.tagName.toLowerCase();
+ if (SKIP.has(tag)) return null;
+ if (!visible(node)) return null;
+
+ const children = [];
+ for (const ch of node.childNodes) {
+ const r = walk(ch);
+ if (r !== null) {
+ if (typeof r === "string") children.push(r);
+ else children.push(r);
+ }
+ }
+
+ const keep = KEEP.has(tag);
+ if (!keep) {
+ // Unwrap: promote children
+ if (children.length === 0) return null;
+ if (children.length === 1) return children[0];
+ return children;
+ }
+
+ const obj = { tag };
+ if (INTERACTIVE.has(tag)) {
+ refCounter++;
+ obj.ref = refCounter;
+ refMap[refCounter] = node;
+ }
+
+ // Attributes
+ if (tag === "a" && node.href) obj.href = node.getAttribute("href");
+ if (tag === "img") {
+ obj.alt = node.alt || "";
+ obj.src = node.getAttribute("src") || "";
+ }
+ if (tag === "input" || tag === "textarea" || tag === "select") {
+ obj.type = node.type || "text";
+ obj.name = node.name || undefined;
+ obj.value = node.value || undefined;
+ obj.placeholder = node.placeholder || undefined;
+ if (node.disabled) obj.disabled = true;
+ if (tag === "input" && node.type === "checkbox") obj.checked = node.checked;
+ }
+ if (tag === "button") {
+ if (node.disabled) obj.disabled = true;
+ }
+ if (tag === "option") {
+ obj.value = node.value;
+ if (node.selected) obj.selected = true;
+ }
+ if (tag === "label" && node.htmlFor) obj.for = node.htmlFor;
+
+ // Role / aria-label
+ const role = node.getAttribute("role");
+ if (role) obj.role = role;
+ const ariaLabel = node.getAttribute("aria-label");
+ if (ariaLabel) obj.ariaLabel = ariaLabel;
+
+ // Children
+ if (children.length === 1 && typeof children[0] === "string") {
+ obj.text = children[0];
+ } else if (children.length > 0) {
+ obj.children = children;
+ }
+
+ return obj;
+ }
+
+ // Store refMap on window for later use by click/fill actions
+ const result = walk(document.body);
+ window.__cowRefMap = refMap;
+ return { tree: result, refCount: refCounter };
+}
+""" % (
+ str(list(_KEEP_TAGS)),
+ str(list(_INTERACTIVE_TAGS)),
+)
+
+
+def _should_use_headless() -> bool:
+ """Decide headless mode: headless on Linux servers without display, headed elsewhere."""
+ if sys.platform in ("win32", "darwin"):
+ return False
+ # Linux: check for display
+ if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
+ return False
+ return True
+
+
+def _flatten_tree(node, indent=0) -> List[str]:
+ """Convert snapshot tree to compact text lines for LLM consumption."""
+ if node is None:
+ return []
+ if isinstance(node, str):
+ return [" " * indent + node]
+ if isinstance(node, list):
+ lines = []
+ for child in node:
+ lines.extend(_flatten_tree(child, indent))
+ return lines
+ if not isinstance(node, dict):
+ return []
+
+ tag = node.get("tag", "?")
+ ref = node.get("ref")
+ parts = [tag]
+ if ref:
+ parts[0] = f"[{ref}] {tag}"
+
+ # Inline attributes
+ for attr in ("type", "name", "href", "alt", "role", "ariaLabel", "placeholder", "value"):
+ val = node.get(attr)
+ if val:
+ # Truncate long values
+ s = str(val)
+ if len(s) > 80:
+ s = s[:77] + "..."
+ parts.append(f'{attr}="{s}"')
+
+ for flag in ("disabled", "checked", "selected"):
+ if node.get(flag):
+ parts.append(flag)
+
+ prefix = " " * indent
+ header = prefix + " ".join(parts)
+
+ text = node.get("text")
+ if text:
+ # Truncate long text
+ if len(text) > 120:
+ text = text[:117] + "..."
+ header += f": {text}"
+
+ lines = [header]
+ children = node.get("children", [])
+ for child in children:
+ lines.extend(_flatten_tree(child, indent + 2))
+ return lines
+
+
+class BrowserService:
+ """Manages a single Playwright browser instance with page operations."""
+
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
+ self._config = config or {}
+ self._playwright: Optional[Playwright] = None
+ self._browser: Optional[Browser] = None
+ self._context: Optional[BrowserContext] = None
+ self._page: Optional[Page] = None
+ self._headless: Optional[bool] = None
+ self._screenshot_dir: Optional[str] = None
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ def _ensure_browser(self):
+ """Lazily launch browser on first use."""
+ if self._page and not self._page.is_closed():
+ return
+
+ if self._headless is None:
+ headless_cfg = self._config.get("headless")
+ self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
+
+ launch_args = ["--disable-dev-shm-usage"]
+ if self._headless:
+ launch_args.append("--no-sandbox")
+
+ extra_args = self._config.get("launch_args", [])
+ if extra_args:
+ launch_args.extend(extra_args)
+
+ viewport_w = self._config.get("viewport_width", 1280)
+ viewport_h = self._config.get("viewport_height", 720)
+
+ if not self._playwright:
+ self._playwright = sync_playwright().start()
+
+ logger.info(f"[Browser] Launching Chromium (headless={self._headless})")
+ self._browser = self._playwright.chromium.launch(
+ headless=self._headless,
+ args=launch_args,
+ )
+ self._context = self._browser.new_context(
+ viewport={"width": viewport_w, "height": viewport_h},
+ user_agent=(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36"
+ ),
+ )
+ self._page = self._context.new_page()
+ logger.info("[Browser] Browser ready")
+
+ @property
+ def page(self) -> Page:
+ self._ensure_browser()
+ return self._page
+
+ def close(self):
+ """Release all browser resources."""
+ try:
+ if self._context:
+ self._context.close()
+ except Exception as e:
+ logger.debug(f"[Browser] context close error: {e}")
+ try:
+ if self._browser:
+ self._browser.close()
+ except Exception as e:
+ logger.debug(f"[Browser] browser close error: {e}")
+ try:
+ if self._playwright:
+ self._playwright.stop()
+ except Exception as e:
+ logger.debug(f"[Browser] playwright stop error: {e}")
+ self._page = None
+ self._context = None
+ self._browser = None
+ self._playwright = None
+ logger.info("[Browser] Browser closed")
+
+ # ------------------------------------------------------------------
+ # Actions
+ # ------------------------------------------------------------------
+
+ def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
+ """Navigate to a URL and return page info."""
+ page = self.page
+ try:
+ resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
+ status = resp.status if resp else None
+ except Exception as e:
+ return {"error": f"Navigation failed: {e}"}
+
+ return {
+ "url": page.url,
+ "title": page.title(),
+ "status": status,
+ }
+
+ def snapshot(self, selector: Optional[str] = None) -> str:
+ """
+ Return a compact text representation of the page DOM for LLM consumption.
+ Interactive elements get numeric refs usable in click/fill actions.
+ """
+ page = self.page
+ try:
+ target = selector or "body"
+ result = page.evaluate(_SNAPSHOT_JS)
+ except Exception as e:
+ return f"[Snapshot error: {e}]"
+
+ tree = result.get("tree")
+ ref_count = result.get("refCount", 0)
+ lines = _flatten_tree(tree)
+
+ header = f"Page: {page.title()} ({page.url})\nInteractive elements: {ref_count}\n---"
+ body = "\n".join(lines)
+
+ # Limit output size
+ max_chars = self._config.get("snapshot_max_chars", 30000)
+ if len(body) > max_chars:
+ body = body[:max_chars] + "\n... [snapshot truncated]"
+
+ return f"{header}\n{body}"
+
+ def screenshot(self, full_page: bool = False, cwd: str = "") -> str:
+ """Take a screenshot and save to workspace/tmp. Returns file path."""
+ page = self.page
+ save_dir = self._get_screenshot_dir(cwd)
+ filename = f"screenshot_{uuid.uuid4().hex[:8]}.png"
+ filepath = os.path.join(save_dir, filename)
+
+ page.screenshot(path=filepath, full_page=full_page)
+ logger.info(f"[Browser] Screenshot saved: {filepath}")
+ return filepath
+
+ def click(self, ref: Optional[int] = None, selector: Optional[str] = None,
+ timeout: int = 5000) -> Dict[str, Any]:
+ """Click an element by snapshot ref or CSS selector."""
+ page = self.page
+ try:
+ if ref is not None:
+ result = page.evaluate(f"""
+ () => {{
+ const el = window.__cowRefMap && window.__cowRefMap[{ref}];
+ if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
+ el.click();
+ return {{ clicked: true, tag: el.tagName.toLowerCase() }};
+ }}
+ """)
+ if result.get("error"):
+ return result
+ page.wait_for_timeout(500)
+ return result
+ elif selector:
+ page.click(selector, timeout=timeout)
+ return {"clicked": True, "selector": selector}
+ else:
+ return {"error": "Provide either ref (from snapshot) or selector"}
+ except Exception as e:
+ return {"error": f"Click failed: {e}"}
+
+ def fill(self, text: str, ref: Optional[int] = None,
+ selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
+ """Fill text into an input/textarea by snapshot ref or CSS selector."""
+ page = self.page
+ try:
+ if ref is not None:
+ result = page.evaluate(f"""
+ () => {{
+ const el = window.__cowRefMap && window.__cowRefMap[{ref}];
+ if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
+ el.focus();
+ el.value = "";
+ return {{ tag: el.tagName.toLowerCase(), name: el.name || "" }};
+ }}
+ """)
+ if result.get("error"):
+ return result
+ page.keyboard.type(text)
+ return {"filled": True, "ref": ref, "text": text}
+ elif selector:
+ page.fill(selector, text, timeout=timeout)
+ return {"filled": True, "selector": selector, "text": text}
+ else:
+ return {"error": "Provide either ref (from snapshot) or selector"}
+ except Exception as e:
+ return {"error": f"Fill failed: {e}"}
+
+ def select(self, value: str, ref: Optional[int] = None,
+ selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
+ """Select an option in a element."""
+ page = self.page
+ try:
+ if ref is not None:
+ result = page.evaluate(f"""
+ () => {{
+ const el = window.__cowRefMap && window.__cowRefMap[{ref}];
+ if (!el || el.tagName.toLowerCase() !== "select")
+ return {{ error: "ref {ref} is not a element" }};
+ el.value = {repr(value)};
+ el.dispatchEvent(new Event("change", {{ bubbles: true }}));
+ return {{ selected: true, value: el.value }};
+ }}
+ """)
+ return result
+ elif selector:
+ page.select_option(selector, value, timeout=timeout)
+ return {"selected": True, "selector": selector, "value": value}
+ else:
+ return {"error": "Provide either ref (from snapshot) or selector"}
+ except Exception as e:
+ return {"error": f"Select failed: {e}"}
+
+ def scroll(self, direction: str = "down", amount: int = 500) -> Dict[str, Any]:
+ """Scroll the page."""
+ page = self.page
+ delta_map = {
+ "down": (0, amount),
+ "up": (0, -amount),
+ "right": (amount, 0),
+ "left": (-amount, 0),
+ }
+ dx, dy = delta_map.get(direction, (0, amount))
+ try:
+ page.mouse.wheel(dx, dy)
+ page.wait_for_timeout(300)
+ scroll_info = page.evaluate("""
+ () => ({
+ scrollX: window.scrollX,
+ scrollY: window.scrollY,
+ scrollHeight: document.documentElement.scrollHeight,
+ clientHeight: document.documentElement.clientHeight
+ })
+ """)
+ return {"scrolled": direction, "amount": amount, **scroll_info}
+ except Exception as e:
+ return {"error": f"Scroll failed: {e}"}
+
+ def wait(self, selector: Optional[str] = None, timeout: int = 5000,
+ state: str = "visible") -> Dict[str, Any]:
+ """Wait for a selector to appear or a fixed timeout."""
+ page = self.page
+ try:
+ if selector:
+ page.wait_for_selector(selector, timeout=timeout, state=state)
+ return {"waited": True, "selector": selector, "state": state}
+ else:
+ page.wait_for_timeout(timeout)
+ return {"waited": True, "timeout_ms": timeout}
+ except Exception as e:
+ return {"error": f"Wait failed: {e}"}
+
+ def go_back(self) -> Dict[str, Any]:
+ page = self.page
+ try:
+ page.go_back(wait_until="domcontentloaded", timeout=10000)
+ return {"url": page.url, "title": page.title()}
+ except Exception as e:
+ return {"error": f"Go back failed: {e}"}
+
+ def go_forward(self) -> Dict[str, Any]:
+ page = self.page
+ try:
+ page.go_forward(wait_until="domcontentloaded", timeout=10000)
+ return {"url": page.url, "title": page.title()}
+ except Exception as e:
+ return {"error": f"Go forward failed: {e}"}
+
+ def get_text(self, selector: str) -> Dict[str, Any]:
+ """Get text content of an element."""
+ page = self.page
+ try:
+ text = page.text_content(selector, timeout=5000)
+ return {"text": text or ""}
+ except Exception as e:
+ return {"error": f"Get text failed: {e}"}
+
+ def evaluate(self, script: str) -> Dict[str, Any]:
+ """Execute JavaScript in the page context."""
+ page = self.page
+ try:
+ result = page.evaluate(script)
+ return {"result": result}
+ except Exception as e:
+ return {"error": f"Evaluate failed: {e}"}
+
+ def press(self, key: str) -> Dict[str, Any]:
+ """Press a keyboard key (e.g. Enter, Tab, Escape)."""
+ page = self.page
+ try:
+ page.keyboard.press(key)
+ page.wait_for_timeout(300)
+ return {"pressed": key}
+ except Exception as e:
+ return {"error": f"Press failed: {e}"}
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _get_screenshot_dir(self, cwd: str = "") -> str:
+ if self._screenshot_dir and os.path.isdir(self._screenshot_dir):
+ return self._screenshot_dir
+ base = cwd or os.getcwd()
+ d = os.path.join(base, "tmp")
+ os.makedirs(d, exist_ok=True)
+ self._screenshot_dir = d
+ return d
diff --git a/agent/tools/browser/browser_tool.py b/agent/tools/browser/browser_tool.py
new file mode 100644
index 00000000..0b51fa26
--- /dev/null
+++ b/agent/tools/browser/browser_tool.py
@@ -0,0 +1,287 @@
+"""
+Browser tool - Control a Chromium browser for web navigation and interaction.
+
+Uses Playwright under the hood. Browser instance is lazily started on first
+use, reused across tool calls within the same session, and cleaned up via
+close().
+"""
+
+import json
+import os
+from typing import Dict, Any, Optional
+
+from agent.tools.base_tool import BaseTool, ToolResult
+from agent.tools.browser.browser_service import BrowserService
+from common.log import logger
+
+
+class BrowserTool(BaseTool):
+ """Single tool exposing all browser actions via an 'action' parameter."""
+
+ name: str = "browser"
+ description: str = (
+ "Control a browser to navigate web pages, interact with elements, and extract content. "
+ "Actions: navigate, snapshot, click, fill, select, scroll, screenshot, wait, back, forward, "
+ "get_text, press, evaluate.\n\n"
+ "Workflow: navigate to a URL → snapshot to see the page (elements get numeric refs) → "
+ "use refs in click/fill/select actions → snapshot again to verify.\n\n"
+ "Use snapshot (not screenshot) as the primary way to read page content."
+ )
+
+ params: dict = {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "description": (
+ "The browser action to perform. One of: "
+ "navigate, snapshot, click, fill, select, scroll, "
+ "screenshot, wait, back, forward, get_text, press, evaluate"
+ ),
+ "enum": [
+ "navigate", "snapshot", "click", "fill", "select", "scroll",
+ "screenshot", "wait", "back", "forward", "get_text", "press",
+ "evaluate"
+ ]
+ },
+ "url": {
+ "type": "string",
+ "description": "URL to navigate to (for 'navigate' action)"
+ },
+ "ref": {
+ "type": "integer",
+ "description": "Element ref number from snapshot (for click/fill/select)"
+ },
+ "selector": {
+ "type": "string",
+ "description": "CSS selector as fallback when ref is unavailable (for click/fill/select/wait/get_text)"
+ },
+ "text": {
+ "type": "string",
+ "description": "Text to type (for 'fill' action)"
+ },
+ "value": {
+ "type": "string",
+ "description": "Option value (for 'select' action)"
+ },
+ "key": {
+ "type": "string",
+ "description": "Key to press, e.g. Enter, Tab, Escape (for 'press' action)"
+ },
+ "direction": {
+ "type": "string",
+ "description": "Scroll direction: up, down, left, right (for 'scroll' action, default: down)"
+ },
+ "script": {
+ "type": "string",
+ "description": "JavaScript code to execute (for 'evaluate' action)"
+ },
+ "full_page": {
+ "type": "boolean",
+ "description": "Capture full page screenshot (for 'screenshot' action, default: false)"
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Timeout in milliseconds (optional, default varies by action)"
+ }
+ },
+ "required": ["action"]
+ }
+
+ _shared_service: Optional[BrowserService] = None
+
+ def __init__(self, config: dict = None):
+ self.config = config or {}
+ self.cwd = self.config.get("cwd", os.getcwd())
+ self._service: Optional[BrowserService] = None
+
+ def _get_service(self) -> BrowserService:
+ """Get or create the browser service, sharing across copies."""
+ if self._service is not None:
+ return self._service
+
+ # Reuse shared service across tool copies within the same session
+ if BrowserTool._shared_service is not None:
+ self._service = BrowserTool._shared_service
+ return self._service
+
+ self._service = BrowserService(self.config)
+ BrowserTool._shared_service = self._service
+ return self._service
+
+ def execute(self, args: Dict[str, Any]) -> ToolResult:
+ action = args.get("action", "").strip().lower()
+ if not action:
+ return ToolResult.fail("Error: 'action' parameter is required")
+
+ handler = self._ACTION_MAP.get(action)
+ if not handler:
+ valid = ", ".join(sorted(self._ACTION_MAP.keys()))
+ return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
+
+ try:
+ return handler(self, args)
+ except Exception as e:
+ logger.error(f"[Browser] Action '{action}' error: {e}")
+ return ToolResult.fail(f"Browser error ({action}): {e}")
+
+ # ------------------------------------------------------------------
+ # Action handlers
+ # ------------------------------------------------------------------
+
+ def _do_navigate(self, args: Dict[str, Any]) -> ToolResult:
+ url = args.get("url", "").strip()
+ if not url:
+ return ToolResult.fail("Error: 'url' is required for navigate action")
+ if not url.startswith(("http://", "https://")):
+ url = "https://" + url
+ timeout = args.get("timeout", 30000)
+ result = self._get_service().navigate(url, timeout=timeout)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(
+ f"Navigated to: {result['url']}\nTitle: {result['title']}\nStatus: {result['status']}\n\n"
+ f"Use action 'snapshot' to see the page content."
+ )
+
+ def _do_snapshot(self, args: Dict[str, Any]) -> ToolResult:
+ selector = args.get("selector")
+ text = self._get_service().snapshot(selector=selector)
+ return ToolResult.success(text)
+
+ def _do_click(self, args: Dict[str, Any]) -> ToolResult:
+ ref = args.get("ref")
+ selector = args.get("selector")
+ timeout = args.get("timeout", 5000)
+ result = self._get_service().click(ref=ref, selector=selector, timeout=timeout)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Clicked successfully. Use 'snapshot' to see updated page.")
+
+ def _do_fill(self, args: Dict[str, Any]) -> ToolResult:
+ text = args.get("text", "")
+ ref = args.get("ref")
+ selector = args.get("selector")
+ timeout = args.get("timeout", 5000)
+ if not text and text != "":
+ return ToolResult.fail("Error: 'text' is required for fill action")
+ result = self._get_service().fill(text, ref=ref, selector=selector, timeout=timeout)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Filled text into element. Use 'snapshot' to verify.")
+
+ def _do_select(self, args: Dict[str, Any]) -> ToolResult:
+ value = args.get("value", "")
+ ref = args.get("ref")
+ selector = args.get("selector")
+ timeout = args.get("timeout", 5000)
+ if not value:
+ return ToolResult.fail("Error: 'value' is required for select action")
+ result = self._get_service().select(value, ref=ref, selector=selector, timeout=timeout)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Selected option '{value}'.")
+
+ def _do_scroll(self, args: Dict[str, Any]) -> ToolResult:
+ direction = args.get("direction", "down")
+ amount = args.get("timeout", 500) # reuse timeout field or default
+ if "amount" in args:
+ amount = args["amount"]
+ result = self._get_service().scroll(direction=direction, amount=amount)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ pos = f"scrollY={result.get('scrollY', '?')}/{result.get('scrollHeight', '?')}"
+ return ToolResult.success(f"Scrolled {direction}. Position: {pos}")
+
+ def _do_screenshot(self, args: Dict[str, Any]) -> ToolResult:
+ full_page = args.get("full_page", False)
+ filepath = self._get_service().screenshot(full_page=full_page, cwd=self.cwd)
+ return ToolResult.success(f"Screenshot saved to: {filepath}")
+
+ def _do_wait(self, args: Dict[str, Any]) -> ToolResult:
+ selector = args.get("selector")
+ timeout = args.get("timeout", 5000)
+ result = self._get_service().wait(selector=selector, timeout=timeout)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Wait completed.")
+
+ def _do_back(self, args: Dict[str, Any]) -> ToolResult:
+ result = self._get_service().go_back()
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Navigated back to: {result['url']}")
+
+ def _do_forward(self, args: Dict[str, Any]) -> ToolResult:
+ result = self._get_service().go_forward()
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Navigated forward to: {result['url']}")
+
+ def _do_get_text(self, args: Dict[str, Any]) -> ToolResult:
+ selector = args.get("selector", "").strip()
+ if not selector:
+ return ToolResult.fail("Error: 'selector' is required for get_text action")
+ result = self._get_service().get_text(selector)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(result["text"])
+
+ def _do_press(self, args: Dict[str, Any]) -> ToolResult:
+ key = args.get("key", "").strip()
+ if not key:
+ return ToolResult.fail("Error: 'key' is required for press action")
+ result = self._get_service().press(key)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ return ToolResult.success(f"Pressed key: {key}")
+
+ def _do_evaluate(self, args: Dict[str, Any]) -> ToolResult:
+ script = args.get("script", "").strip()
+ if not script:
+ return ToolResult.fail("Error: 'script' is required for evaluate action")
+ result = self._get_service().evaluate(script)
+ if "error" in result:
+ return ToolResult.fail(result["error"])
+ val = result.get("result")
+ if isinstance(val, (dict, list)):
+ return ToolResult.success(json.dumps(val, ensure_ascii=False, indent=2))
+ return ToolResult.success(str(val) if val is not None else "(no return value)")
+
+ # Action dispatch table
+ _ACTION_MAP = {
+ "navigate": _do_navigate,
+ "snapshot": _do_snapshot,
+ "click": _do_click,
+ "fill": _do_fill,
+ "select": _do_select,
+ "scroll": _do_scroll,
+ "screenshot": _do_screenshot,
+ "wait": _do_wait,
+ "back": _do_back,
+ "forward": _do_forward,
+ "get_text": _do_get_text,
+ "press": _do_press,
+ "evaluate": _do_evaluate,
+ }
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ def copy(self):
+ """Share browser instance across tool copies (avoids re-launching)."""
+ new_tool = BrowserTool(self.config)
+ new_tool.model = self.model
+ new_tool.context = getattr(self, "context", None)
+ new_tool.cwd = self.cwd
+ new_tool._service = self._service
+ return new_tool
+
+ def close(self):
+ """Release browser resources."""
+ if self._service:
+ self._service.close()
+ self._service = None
+ BrowserTool._shared_service = None
+ logger.info("[Browser] BrowserTool closed")
diff --git a/agent/tools/browser_tool.py b/agent/tools/browser_tool.py
deleted file mode 100644
index b134ef79..00000000
--- a/agent/tools/browser_tool.py
+++ /dev/null
@@ -1,18 +0,0 @@
-def copy(self):
- """
- Special copy method for browser tool to avoid recreating browser instance.
-
- :return: A new instance with shared browser reference but unique model
- """
- new_tool = self.__class__()
-
- # Copy essential attributes
- new_tool.model = self.model
- new_tool.context = getattr(self, 'context', None)
- new_tool.config = getattr(self, 'config', None)
-
- # Share the browser instance instead of creating a new one
- if hasattr(self, 'browser'):
- new_tool.browser = self.browser
-
- return new_tool
\ No newline at end of file
diff --git a/agent/tools/tool_manager.py b/agent/tools/tool_manager.py
index 4f6789e1..929d60a1 100644
--- a/agent/tools/tool_manager.py
+++ b/agent/tools/tool_manager.py
@@ -84,11 +84,11 @@ class ToolManager:
except ImportError as e:
# Handle missing dependencies with helpful messages
error_msg = str(e)
- if "browser-use" in error_msg or "browser_use" in error_msg:
+ if "playwright" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
- f" pip install browser-use markdownify playwright\n"
+ f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
@@ -154,11 +154,11 @@ class ToolManager:
except ImportError as e:
# Handle missing dependencies with helpful messages
error_msg = str(e)
- if "browser-use" in error_msg or "browser_use" in error_msg:
+ if "playwright" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
- f" pip install browser-use markdownify playwright\n"
+ f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
@@ -197,7 +197,7 @@ class ToolManager:
logger.warning(
f"[ToolManager] Browser tool is configured but not loaded.\n"
f" To enable browser tool, run:\n"
- f" pip install browser-use markdownify playwright\n"
+ f" pip install playwright\n"
f" playwright install chromium"
)
elif tool_name == "google_search":
From 8ea2455766ba94f1f122419e1a7f15b4f42fe90a Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 15:09:07 +0800
Subject: [PATCH 045/399] feat(cli): add browser install cmd
---
cli/cli.py | 3 ++
cli/commands/install.py | 63 +++++++++++++++++++++++++++++++++++++++++
run.sh | 1 +
3 files changed, 67 insertions(+)
create mode 100644 cli/commands/install.py
diff --git a/cli/cli.py b/cli/cli.py
index d5291c4f..6740667f 100644
--- a/cli/cli.py
+++ b/cli/cli.py
@@ -5,6 +5,7 @@ from cli import __version__
from cli.commands.skill import skill
from cli.commands.process import start, stop, restart, update, status, logs
from cli.commands.context import context
+from cli.commands.install import install_browser
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
@@ -21,6 +22,7 @@ Commands:
status Show CowAgent running status.
logs View CowAgent logs.
skill Manage CowAgent skills.
+ install-browser Install browser tool (Playwright + Chromium).
Tip: You can also send /help, /skill list, etc. in agent chat."""
@@ -67,6 +69,7 @@ main.add_command(update)
main.add_command(status)
main.add_command(logs)
main.add_command(context)
+main.add_command(install_browser)
if __name__ == '__main__':
diff --git a/cli/commands/install.py b/cli/commands/install.py
new file mode 100644
index 00000000..55d909b8
--- /dev/null
+++ b/cli/commands/install.py
@@ -0,0 +1,63 @@
+"""cow install-browser - Install Playwright + Chromium for the browser tool."""
+
+import os
+import sys
+import subprocess
+
+import click
+
+
+def _has_display() -> bool:
+ """Check if a graphical display is available (Linux only)."""
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
+
+
+def _is_headless_linux() -> bool:
+ """True when running on a Linux server without a display."""
+ return sys.platform == "linux" and not _has_display()
+
+
+@click.command("install-browser")
+def install_browser():
+ """Install browser tool dependencies (Playwright + Chromium)."""
+ python = sys.executable
+
+ # Step 1: Install playwright package
+ click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
+ ret = subprocess.call([python, "-m", "pip", "install", "playwright"])
+ if ret != 0:
+ click.echo(click.style("Failed to install playwright package.", fg="red"))
+ raise SystemExit(1)
+ click.echo(click.style("playwright package installed.", fg="green"))
+ click.echo()
+
+ # Step 2: System dependencies (Linux only)
+ if sys.platform == "linux":
+ click.echo(click.style("[2/3] Installing system dependencies (Linux)...", fg="yellow"))
+ ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
+ if ret != 0:
+ click.echo(click.style(
+ "Could not auto-install system deps (may need sudo).\n"
+ f" Run manually: sudo {python} -m playwright install-deps chromium",
+ fg="yellow",
+ ))
+ else:
+ click.echo(click.style(f"[2/3] Skipping system deps (not needed on {sys.platform}).", fg="yellow"))
+ click.echo()
+
+ # Step 3: Install Chromium (headless shell on Linux servers, full elsewhere)
+ click.echo(click.style("[3/3] Installing Chromium browser...", fg="yellow"))
+ cmd = [python, "-m", "playwright", "install", "chromium"]
+ if _is_headless_linux():
+ cmd.append("--only-shell")
+ click.echo(" (headless-only mode for Linux server)")
+ elif sys.platform == "linux":
+ click.echo(" (full browser for Linux desktop)")
+
+ ret = subprocess.call(cmd)
+ if ret != 0:
+ click.echo(click.style("Failed to install Chromium.", fg="red"))
+ raise SystemExit(1)
+
+ click.echo()
+ click.echo(click.style("Browser tool ready! Restart CowAgent to enable it.", fg="green"))
diff --git a/run.sh b/run.sh
index f72320d1..0ee3aa0c 100755
--- a/run.sh
+++ b/run.sh
@@ -585,6 +585,7 @@ start_project() {
echo -e " ${GREEN}cow status${NC} Check status"
echo -e " ${GREEN}cow logs${NC} View logs"
echo -e " ${GREEN}cow update${NC} Update and restart"
+ echo -e " ${GREEN}cow install-browser${NC} Install browser tool"
else
echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
From 184634e4e791f8b3ca362fb4ef5ca4be7b6c95e7 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 15:14:07 +0800
Subject: [PATCH 046/399] fix(cli): browser install failed
---
cli/commands/install.py | 46 +++++++++++++++++++++++++++++++++++------
cli/commands/process.py | 4 ++--
2 files changed, 42 insertions(+), 8 deletions(-)
diff --git a/cli/commands/install.py b/cli/commands/install.py
index 55d909b8..5d4220fd 100644
--- a/cli/commands/install.py
+++ b/cli/commands/install.py
@@ -6,6 +6,8 @@ import subprocess
import click
+MIN_PLAYWRIGHT_VERSION = "1.49.0"
+
def _has_display() -> bool:
"""Check if a graphical display is available (Linux only)."""
@@ -17,18 +19,44 @@ def _is_headless_linux() -> bool:
return sys.platform == "linux" and not _has_display()
+def _get_installed_version() -> str:
+ """Return installed playwright version string, or empty if not installed."""
+ python = sys.executable
+ try:
+ out = subprocess.check_output(
+ [python, "-c", "import playwright; print(playwright.__version__)"],
+ stderr=subprocess.DEVNULL,
+ )
+ return out.decode().strip()
+ except Exception:
+ return ""
+
+
+def _version_tuple(v: str):
+ """Parse '1.49.0' into (1, 49, 0)."""
+ try:
+ return tuple(int(x) for x in v.split(".")[:3])
+ except (ValueError, AttributeError):
+ return (0, 0, 0)
+
+
@click.command("install-browser")
def install_browser():
"""Install browser tool dependencies (Playwright + Chromium)."""
python = sys.executable
- # Step 1: Install playwright package
+ # Step 1: Install / upgrade playwright package
click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
- ret = subprocess.call([python, "-m", "pip", "install", "playwright"])
+ ret = subprocess.call([python, "-m", "pip", "install", f"playwright>={MIN_PLAYWRIGHT_VERSION}"])
if ret != 0:
click.echo(click.style("Failed to install playwright package.", fg="red"))
raise SystemExit(1)
- click.echo(click.style("playwright package installed.", fg="green"))
+
+ installed = _get_installed_version()
+ if installed:
+ click.echo(click.style(f"playwright {installed} installed.", fg="green"))
+ else:
+ click.echo(click.style("playwright package installed.", fg="green"))
click.echo()
# Step 2: System dependencies (Linux only)
@@ -45,12 +73,18 @@ def install_browser():
click.echo(click.style(f"[2/3] Skipping system deps (not needed on {sys.platform}).", fg="yellow"))
click.echo()
- # Step 3: Install Chromium (headless shell on Linux servers, full elsewhere)
+ # Step 3: Install Chromium
click.echo(click.style("[3/3] Installing Chromium browser...", fg="yellow"))
cmd = [python, "-m", "playwright", "install", "chromium"]
+
+ # --only-shell requires playwright >= 1.57
if _is_headless_linux():
- cmd.append("--only-shell")
- click.echo(" (headless-only mode for Linux server)")
+ ver = _version_tuple(_get_installed_version())
+ if ver >= (1, 57, 0):
+ cmd.append("--only-shell")
+ click.echo(" (headless shell for Linux server)")
+ else:
+ click.echo(" (full Chromium - upgrade to playwright>=1.57 for headless-only shell)")
elif sys.platform == "linux":
click.echo(" (full browser for Linux desktop)")
diff --git a/cli/commands/process.py b/cli/commands/process.py
index d4d4fd24..d0764f48 100644
--- a/cli/commands/process.py
+++ b/cli/commands/process.py
@@ -206,10 +206,10 @@ def update(ctx):
cwd=root,
)
- # 4. Start service
+ # 4. Start service and follow logs
click.echo("")
time.sleep(1)
- ctx.invoke(start, no_logs=True)
+ ctx.invoke(start, no_logs=False)
@click.command()
From e06925ab859e23b5a956f5744f987f7f97995f80 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 15:19:59 +0800
Subject: [PATCH 047/399] fix: optimize browser install cli and fix vision
prompt
---
agent/prompt/builder.py | 3 ++-
cli/commands/install.py | 37 ++++++++++++++++++++++++++++++++++++-
2 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index ffc27606..c9d48454 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -171,6 +171,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
"env_config": "管理API密钥和技能配置",
"scheduler": "管理定时任务和提醒",
"send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)",
+ "vision": "分析图片内容(识别、描述、OCR文字提取等)",
}
# Preferred display order
@@ -179,7 +180,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
"bash", "terminal",
"web_search", "web_fetch", "browser",
"memory_search", "memory_get",
- "env_config", "scheduler", "send",
+ "env_config", "scheduler", "send", "vision",
]
# Build name -> summary mapping for available tools
diff --git a/cli/commands/install.py b/cli/commands/install.py
index 5d4220fd..525848b0 100644
--- a/cli/commands/install.py
+++ b/cli/commands/install.py
@@ -7,6 +7,7 @@ import subprocess
import click
MIN_PLAYWRIGHT_VERSION = "1.49.0"
+MIN_GLIBC_VERSION = (2, 28)
def _has_display() -> bool:
@@ -40,11 +41,45 @@ def _version_tuple(v: str):
return (0, 0, 0)
+def _get_glibc_version():
+ """Return glibc version as (major, minor) tuple, or None if unavailable."""
+ if sys.platform != "linux":
+ return None
+ try:
+ import ctypes
+ libc = ctypes.CDLL("libc.so.6")
+ gnu_get_libc_version = libc.gnu_get_libc_version
+ gnu_get_libc_version.restype = ctypes.c_char_p
+ ver = gnu_get_libc_version().decode()
+ parts = ver.split(".")
+ return (int(parts[0]), int(parts[1]))
+ except Exception:
+ return None
+
+
@click.command("install-browser")
def install_browser():
"""Install browser tool dependencies (Playwright + Chromium)."""
python = sys.executable
+ # Pre-check: glibc version on Linux
+ if sys.platform == "linux":
+ glibc = _get_glibc_version()
+ if glibc and glibc < MIN_GLIBC_VERSION:
+ glibc_str = f"{glibc[0]}.{glibc[1]}"
+ click.echo(click.style(
+ f"Your system glibc version is {glibc_str}, "
+ f"but Playwright requires glibc >= {MIN_GLIBC_VERSION[0]}.{MIN_GLIBC_VERSION[1]}.\n"
+ f"(e.g. Ubuntu 18.04 ships glibc 2.27, CentOS 7 ships glibc 2.17)\n\n"
+ f"Options:\n"
+ f" 1. Upgrade your OS (e.g. Ubuntu 20.04+, Debian 10+, CentOS 8+)\n"
+ f" 2. Use Docker with a newer Linux image\n"
+ f" 3. Install an older playwright version manually (not recommended):\n"
+ f" pip install playwright==1.30.0 && playwright install chromium",
+ fg="red",
+ ))
+ raise SystemExit(1)
+
# Step 1: Install / upgrade playwright package
click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
ret = subprocess.call([python, "-m", "pip", "install", f"playwright>={MIN_PLAYWRIGHT_VERSION}"])
@@ -84,7 +119,7 @@ def install_browser():
cmd.append("--only-shell")
click.echo(" (headless shell for Linux server)")
else:
- click.echo(" (full Chromium - upgrade to playwright>=1.57 for headless-only shell)")
+ click.echo(" (full Chromium - upgrade to playwright>=1.57 for smaller headless-only install)")
elif sys.platform == "linux":
click.echo(" (full browser for Linux desktop)")
From 3cb5a0fbd6be184f007cf1285672cd59a431326c Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 17:57:12 +0800
Subject: [PATCH 048/399] docs: add CLI system docs
---
README.md | 54 ++++--
agent/prompt/builder.py | 2 +-
cli/commands/install.py | 112 ++++++++----
cli/commands/skill.py | 86 +++++++++
docs/commands/general.mdx | 115 ++++++++++++
docs/commands/index.mdx | 86 +++++++++
docs/commands/process.mdx | 134 ++++++++++++++
docs/commands/skill.mdx | 218 +++++++++++++++++++++++
docs/docs.json | 102 +++++++----
docs/en/README.md | 49 ++++-
docs/en/commands/general.mdx | 101 +++++++++++
docs/en/commands/index.mdx | 84 +++++++++
docs/en/commands/process.mdx | 123 +++++++++++++
docs/en/commands/skill.mdx | 192 ++++++++++++++++++++
docs/en/guide/manual-install.mdx | 42 ++++-
docs/en/guide/quick-start.mdx | 25 +--
docs/en/intro/index.mdx | 6 +
docs/en/memory/context.mdx | 80 +++++++++
docs/en/{memory.mdx => memory/index.mdx} | 46 ++---
docs/en/skills/create.mdx | 58 ++++++
docs/en/skills/image-vision.mdx | 31 ----
docs/en/skills/index.mdx | 19 +-
docs/en/skills/install.mdx | 53 ++++++
docs/en/skills/linkai-agent.mdx | 47 -----
docs/en/skills/skill-creator.mdx | 31 ----
docs/en/skills/web-fetch.mdx | 31 ----
docs/guide/manual-install.mdx | 104 ++++++++---
docs/guide/quick-start.mdx | 24 ++-
docs/guide/upgrade.mdx | 27 ++-
docs/intro/index.mdx | 10 +-
docs/ja/README.md | 49 ++++-
docs/ja/commands/general.mdx | 101 +++++++++++
docs/ja/commands/index.mdx | 84 +++++++++
docs/ja/commands/process.mdx | 123 +++++++++++++
docs/ja/commands/skill.mdx | 192 ++++++++++++++++++++
docs/ja/guide/manual-install.mdx | 42 ++++-
docs/ja/guide/quick-start.mdx | 25 +--
docs/ja/guide/upgrade.mdx | 27 ++-
docs/ja/intro/index.mdx | 6 +
docs/ja/memory/context.mdx | 80 +++++++++
docs/ja/{memory.mdx => memory/index.mdx} | 39 ++--
docs/ja/skills/create.mdx | 58 ++++++
docs/ja/skills/image-vision.mdx | 31 ----
docs/ja/skills/index.mdx | 41 ++---
docs/ja/skills/install.mdx | 53 ++++++
docs/ja/skills/linkai-agent.mdx | 47 -----
docs/ja/skills/skill-creator.mdx | 31 ----
docs/ja/skills/web-fetch.mdx | 31 ----
docs/memory/context.mdx | 80 +++++++++
docs/{memory.mdx => memory/index.mdx} | 46 ++---
docs/skills/create.mdx | 58 ++++++
docs/skills/image-vision.mdx | 31 ----
docs/skills/index.mdx | 19 +-
docs/skills/install.mdx | 53 ++++++
docs/skills/linkai-agent.mdx | 47 -----
docs/skills/skill-creator.mdx | 31 ----
docs/skills/web-fetch.mdx | 31 ----
docs/tools/browser.mdx | 106 +++++++++--
docs/tools/index.mdx | 21 ++-
docs/tools/vision.mdx | 36 ++++
docs/tools/web-fetch.mdx | 32 ++++
scripts/run.ps1 | 2 +-
62 files changed, 2995 insertions(+), 750 deletions(-)
create mode 100644 docs/commands/general.mdx
create mode 100644 docs/commands/index.mdx
create mode 100644 docs/commands/process.mdx
create mode 100644 docs/commands/skill.mdx
create mode 100644 docs/en/commands/general.mdx
create mode 100644 docs/en/commands/index.mdx
create mode 100644 docs/en/commands/process.mdx
create mode 100644 docs/en/commands/skill.mdx
create mode 100644 docs/en/memory/context.mdx
rename docs/en/{memory.mdx => memory/index.mdx} (56%)
create mode 100644 docs/en/skills/create.mdx
delete mode 100644 docs/en/skills/image-vision.mdx
create mode 100644 docs/en/skills/install.mdx
delete mode 100644 docs/en/skills/linkai-agent.mdx
delete mode 100644 docs/en/skills/skill-creator.mdx
delete mode 100644 docs/en/skills/web-fetch.mdx
create mode 100644 docs/ja/commands/general.mdx
create mode 100644 docs/ja/commands/index.mdx
create mode 100644 docs/ja/commands/process.mdx
create mode 100644 docs/ja/commands/skill.mdx
create mode 100644 docs/ja/memory/context.mdx
rename docs/ja/{memory.mdx => memory/index.mdx} (52%)
create mode 100644 docs/ja/skills/create.mdx
delete mode 100644 docs/ja/skills/image-vision.mdx
create mode 100644 docs/ja/skills/install.mdx
delete mode 100644 docs/ja/skills/linkai-agent.mdx
delete mode 100644 docs/ja/skills/skill-creator.mdx
delete mode 100644 docs/ja/skills/web-fetch.mdx
create mode 100644 docs/memory/context.mdx
rename docs/{memory.mdx => memory/index.mdx} (56%)
create mode 100644 docs/skills/create.mdx
delete mode 100644 docs/skills/image-vision.mdx
create mode 100644 docs/skills/install.mdx
delete mode 100644 docs/skills/linkai-agent.mdx
delete mode 100644 docs/skills/skill-creator.mdx
delete mode 100644 docs/skills/web-fetch.mdx
create mode 100644 docs/tools/vision.mdx
create mode 100644 docs/tools/web-fetch.mdx
diff --git a/README.md b/README.md
index 24b1194c..e314a29f 100644
--- a/README.md
+++ b/README.md
@@ -21,12 +21,14 @@
> 该项目既是一个可以开箱即用的超级 AI 助理,也是一个支持高扩展的 Agent 框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills 系统来灵活实现各种定制需求。核心能力如下:
-- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
-- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
-- ✅ **技能系统:** 实现了 Skills 创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义 Skills 开发
+- ✅ **自主任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标
+- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆和日级记忆,支持关键词及向量检索
+- ✅ **技能系统:** 实现了 Skills 创建和运行的引擎,支持从 Skill Hub、GitHub 等安装技能,或通过对话创造自定义 Skills
+- ✅ **工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 自主调用以完成复杂任务
+- ✅ **CLI系统:** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
-- ✅ **多模型接入:** 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao 等国内外主流模型厂商
-- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
+- ✅ **多模型支持:** 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao 等国内外主流模型厂商
+- ✅ **多通道接入:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
## 声明
@@ -90,7 +92,7 @@
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
-脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)
+脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)。安装后也可使用 `cow start`、`cow stop` 等 [CLI 命令](https://docs.cowagent.ai/commands/index) 管理服务。
## 一、准备
@@ -134,6 +136,24 @@ pip3 install -r requirements-optional.txt
如果某项依赖安装失败可注释掉对应的行后重试。
+**(4) 安装 Cow CLI (推荐):**
+
+```bash
+pip3 install -e .
+```
+
+安装后可使用 `cow` 命令管理服务(启动、停止、更新等)和技能,详见 [命令文档](https://docs.cowagent.ai/commands/index)。
+
+**(5) 安装浏览器工具 (可选):**
+
+如果需要 Agent 操作浏览器(如访问网页、填写表单等),需要额外安装浏览器依赖:
+
+```bash
+cow install-browser
+```
+
+该命令会自动安装 `playwright` 和 Chromium 浏览器,国内网络自动使用镜像加速。详见 [浏览器工具文档](https://docs.cowagent.ai/tools/browser)。
+
## 二、配置
配置文件的模板在根目录的 `config-template.json` 中,需复制该模板创建最终生效的 `config.json` 文件:
@@ -210,7 +230,8 @@ pip3 install -r requirements-optional.txt
如果是个人计算机 **本地运行**,直接在项目根目录下执行:
```bash
-python3 app.py # windows 环境下该命令通常为 python app.py
+cow start # 推荐,需先安装 Cow CLI
+python3 app.py # 或直接运行,windows 环境下该命令通常为 python app.py
```
运行后默认会启动 web 服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。
@@ -220,15 +241,24 @@ python3 app.py # windows 环境下该命令通常为 python app.py
### 2.服务器部署
-在服务器中可使用 `nohup` 命令在后台运行程序:
+推荐使用 `cow` 命令管理服务:
+
+```bash
+cow start # 后台启动
+cow stop # 停止服务
+cow restart # 重启服务
+cow status # 查看运行状态
+cow logs # 查看日志
+cow update # 拉取最新代码并重启
+```
+
+也可以使用传统方式后台运行:
```bash
nohup python3 app.py & tail -f nohup.out
```
-执行后程序运行于服务器后台,可通过 `ctrl+c` 关闭日志,不会影响后台程序的运行。使用 `ps -ef | grep app.py | grep -v grep` 命令可查看运行于后台的进程,如果想要重新启动程序可以先 `kill` 掉对应的进程。 日志关闭后如果想要再次打开只需输入 `tail -f nohup.out`。
-
-此外,项目根目录下的 `run.sh` 脚本支持一键启动和管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart`、`./run.sh logs` 等命令,执行 `./run.sh help` 可查看全部用法。
+此外,项目根目录下的 `run.sh` 脚本也支持一键管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart` 等命令,执行 `./run.sh help` 可查看全部用法。
> 如果需要通过浏览器访问 Web 控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定 IP 开放以保证安全。
@@ -843,7 +873,7 @@ FAQs:
# 🛠️ 开发
-欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。 同时欢迎贡献新的Skills,参考 [Skill创造器说明](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)。
+欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。同时欢迎贡献新的 Skills,参考 [技能创建文档](https://docs.cowagent.ai/skills/create)。
# ✉ 联系
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index c9d48454..4a54963e 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -384,7 +384,7 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"**💬 交流规范**:",
"",
"- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md」",
- "- 做真正有帮助的助手,而不是表演式的客套。跳过「好的!」「当然可以!」之类的套话,直接帮忙解决问题",
+ "- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题",
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
"",
diff --git a/cli/commands/install.py b/cli/commands/install.py
index 525848b0..b4b6015d 100644
--- a/cli/commands/install.py
+++ b/cli/commands/install.py
@@ -6,8 +6,10 @@ import subprocess
import click
-MIN_PLAYWRIGHT_VERSION = "1.49.0"
-MIN_GLIBC_VERSION = (2, 28)
+PLAYWRIGHT_VERSION = "1.49.0"
+PLAYWRIGHT_LEGACY_VERSION = "1.28.0"
+GLIBC_THRESHOLD = (2, 28)
+CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
def _has_display() -> bool:
@@ -16,16 +18,13 @@ def _has_display() -> bool:
def _is_headless_linux() -> bool:
- """True when running on a Linux server without a display."""
return sys.platform == "linux" and not _has_display()
def _get_installed_version() -> str:
- """Return installed playwright version string, or empty if not installed."""
- python = sys.executable
try:
out = subprocess.check_output(
- [python, "-c", "import playwright; print(playwright.__version__)"],
+ [sys.executable, "-c", "import playwright; print(playwright.__version__)"],
stderr=subprocess.DEVNULL,
)
return out.decode().strip()
@@ -34,7 +33,6 @@ def _get_installed_version() -> str:
def _version_tuple(v: str):
- """Parse '1.49.0' into (1, 49, 0)."""
try:
return tuple(int(x) for x in v.split(".")[:3])
except (ValueError, AttributeError):
@@ -42,7 +40,6 @@ def _version_tuple(v: str):
def _get_glibc_version():
- """Return glibc version as (major, minor) tuple, or None if unavailable."""
if sys.platform != "linux":
return None
try:
@@ -57,41 +54,62 @@ def _get_glibc_version():
return None
+def _is_china_network() -> bool:
+ try:
+ out = subprocess.check_output(
+ [sys.executable, "-m", "pip", "config", "get", "global.index-url"],
+ stderr=subprocess.DEVNULL,
+ )
+ url = out.decode().strip().lower()
+ return any(kw in url for kw in ("tsinghua", "aliyun", "npmmirror", "douban", "ustc", "huawei", "tencentyun"))
+ except Exception:
+ return False
+
+
+def _pip_install(package_spec: str) -> int:
+ """Install a package, retrying with --user on permission failure."""
+ python = sys.executable
+ ret = subprocess.call([python, "-m", "pip", "install", package_spec])
+ if ret != 0:
+ click.echo(" Retrying with --user flag...")
+ ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
+ return ret
+
+
@click.command("install-browser")
def install_browser():
"""Install browser tool dependencies (Playwright + Chromium)."""
python = sys.executable
+ legacy_mode = False
- # Pre-check: glibc version on Linux
- if sys.platform == "linux":
- glibc = _get_glibc_version()
- if glibc and glibc < MIN_GLIBC_VERSION:
- glibc_str = f"{glibc[0]}.{glibc[1]}"
- click.echo(click.style(
- f"Your system glibc version is {glibc_str}, "
- f"but Playwright requires glibc >= {MIN_GLIBC_VERSION[0]}.{MIN_GLIBC_VERSION[1]}.\n"
- f"(e.g. Ubuntu 18.04 ships glibc 2.27, CentOS 7 ships glibc 2.17)\n\n"
- f"Options:\n"
- f" 1. Upgrade your OS (e.g. Ubuntu 20.04+, Debian 10+, CentOS 8+)\n"
- f" 2. Use Docker with a newer Linux image\n"
- f" 3. Install an older playwright version manually (not recommended):\n"
- f" pip install playwright==1.30.0 && playwright install chromium",
- fg="red",
- ))
- raise SystemExit(1)
+ # Determine playwright version based on glibc
+ glibc = _get_glibc_version()
+ if glibc and glibc < GLIBC_THRESHOLD:
+ legacy_mode = True
+ glibc_str = f"{glibc[0]}.{glibc[1]}"
+ click.echo(click.style(
+ f"glibc {glibc_str} detected (< 2.28). "
+ f"Will install playwright {PLAYWRIGHT_LEGACY_VERSION} for compatibility.",
+ fg="yellow",
+ ))
+ click.echo(click.style(
+ " Note: upgrade your OS for full browser tool support.",
+ fg="yellow",
+ ))
+ click.echo()
- # Step 1: Install / upgrade playwright package
+ target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
+
+ # Step 1: Install playwright package
click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
- ret = subprocess.call([python, "-m", "pip", "install", f"playwright>={MIN_PLAYWRIGHT_VERSION}"])
+ ret = _pip_install(f"playwright=={target_version}" if legacy_mode else f"playwright>={target_version}")
if ret != 0:
click.echo(click.style("Failed to install playwright package.", fg="red"))
raise SystemExit(1)
installed = _get_installed_version()
if installed:
- click.echo(click.style(f"playwright {installed} installed.", fg="green"))
- else:
- click.echo(click.style("playwright package installed.", fg="green"))
+ click.echo(click.style(f" playwright {installed} installed.", fg="green"))
click.echo()
# Step 2: System dependencies (Linux only)
@@ -100,7 +118,7 @@ def install_browser():
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
if ret != 0:
click.echo(click.style(
- "Could not auto-install system deps (may need sudo).\n"
+ " Could not auto-install system deps (may need sudo).\n"
f" Run manually: sudo {python} -m playwright install-deps chromium",
fg="yellow",
))
@@ -113,20 +131,42 @@ def install_browser():
cmd = [python, "-m", "playwright", "install", "chromium"]
# --only-shell requires playwright >= 1.57
- if _is_headless_linux():
- ver = _version_tuple(_get_installed_version())
+ if _is_headless_linux() and not legacy_mode:
+ ver = _version_tuple(installed or "")
if ver >= (1, 57, 0):
cmd.append("--only-shell")
click.echo(" (headless shell for Linux server)")
else:
- click.echo(" (full Chromium - upgrade to playwright>=1.57 for smaller headless-only install)")
- elif sys.platform == "linux":
+ click.echo(" (full Chromium)")
+ elif sys.platform == "linux" and _has_display():
click.echo(" (full browser for Linux desktop)")
- ret = subprocess.call(cmd)
+ # Use China mirror if pip is configured with a domestic index
+ env = os.environ.copy()
+ if _is_china_network():
+ env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
+ click.echo(f" (using China mirror: {CHINA_MIRROR})")
+
+ ret = subprocess.call(cmd, env=env)
if ret != 0:
click.echo(click.style("Failed to install Chromium.", fg="red"))
raise SystemExit(1)
+ # Quick smoke test
+ click.echo()
+ click.echo("Verifying browser installation...")
+ ret = subprocess.call(
+ [python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
+ stderr=subprocess.DEVNULL,
+ )
+ if ret != 0:
+ click.echo(click.style(
+ " Warning: playwright import failed. Browser tool may not work on this system.\n"
+ " Consider upgrading your OS or using Docker.",
+ fg="yellow",
+ ))
+ else:
+ click.echo(click.style(" Verification passed.", fg="green"))
+
click.echo()
click.echo(click.style("Browser tool ready! Restart CowAgent to enable it.", fg="green"))
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 51187262..11e63db2 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -419,6 +419,87 @@ def _install_url(url: str, result: InstallResult):
result.messages.append(f"Installed '{skill_name}' from URL.")
+def _install_archive_url(url: str, result: InstallResult):
+ """Install skill(s) from a remote zip/tar.gz archive URL."""
+ parsed = urlparse(url)
+ if parsed.scheme != "https":
+ raise SkillInstallError("Refusing to download from non-HTTPS URL.")
+
+ filename = os.path.basename(parsed.path).split("?")[0]
+ fallback_name = re.sub(r'\.(zip|tar\.gz|tgz)$', '', filename, flags=re.IGNORECASE)
+ if not fallback_name or not _SAFE_NAME_RE.match(fallback_name):
+ fallback_name = "skill-package"
+
+ result.messages.append(f"Downloading archive from {url} ...")
+ try:
+ resp = requests.get(url, timeout=120, allow_redirects=True)
+ resp.raise_for_status()
+ except Exception as e:
+ raise SkillInstallError(f"Failed to download archive: {e}")
+
+ skills_dir = get_skills_dir()
+ os.makedirs(skills_dir, exist_ok=True)
+
+ content_type = resp.headers.get("Content-Type", "")
+ lower_url = url.lower()
+
+ if lower_url.endswith((".tar.gz", ".tgz")) or "gzip" in content_type:
+ _install_targz_bytes(resp.content, fallback_name, skills_dir, result)
+ else:
+ _install_zip_bytes(resp.content, fallback_name, skills_dir, result=result, source_label="url")
+
+
+def _install_targz_bytes(content: bytes, name: str, skills_dir: str, result: InstallResult):
+ """Extract a tar.gz archive and install skill(s)."""
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tar_path = os.path.join(tmp_dir, "package.tar.gz")
+ with open(tar_path, "wb") as f:
+ f.write(content)
+
+ import tarfile
+ extract_dir = os.path.join(tmp_dir, "extracted")
+ os.makedirs(extract_dir)
+ with tarfile.open(tar_path, "r:gz") as tf:
+ for member in tf.getmembers():
+ resolved = os.path.realpath(os.path.join(extract_dir, member.name))
+ if not resolved.startswith(os.path.realpath(extract_dir)):
+ raise SkillInstallError("Archive contains path traversal, aborting.")
+ tf.extractall(extract_dir)
+
+ top_items = [d for d in os.listdir(extract_dir) if not d.startswith(".")]
+ pkg_root = extract_dir
+ if len(top_items) == 1 and os.path.isdir(os.path.join(extract_dir, top_items[0])):
+ pkg_root = os.path.join(extract_dir, top_items[0])
+
+ discovered = _scan_skills_in_repo(pkg_root) or _scan_skills_in_dir(pkg_root)
+
+ if discovered and len(discovered) > 1:
+ _batch_install_skills(discovered, name, skills_dir, "url", result)
+ return
+
+ if discovered and len(discovered) == 1:
+ sname, sdir = discovered[0]
+ safe_name = re.sub(r'[^a-zA-Z0-9_\\-]', '-', sname)[:64]
+ if not _SAFE_NAME_RE.match(safe_name):
+ safe_name = name
+ target = os.path.join(skills_dir, safe_name)
+ if os.path.exists(target):
+ shutil.rmtree(target)
+ shutil.copytree(sdir, target)
+ _register_installed_skill(safe_name, source="url")
+ result.installed.append(safe_name)
+ result.messages.append(f"Installed '{safe_name}' from URL.")
+ return
+
+ target = os.path.join(skills_dir, name)
+ if os.path.exists(target):
+ shutil.rmtree(target)
+ shutil.copytree(pkg_root, target)
+ _register_installed_skill(name, source="url")
+ result.installed.append(name)
+ result.messages.append(f"Installed '{name}' from URL.")
+
+
def _print_install_success(name: str, source: str):
"""Print a unified install success message with description and source."""
skills_dir = get_skills_dir()
@@ -715,6 +796,11 @@ def _route_install(name: str, result: InstallResult):
_install_url(name, result)
return
+ # --- Zip / tar.gz archive URL ---
+ if name.startswith(("http://", "https://")) and re.search(r'\.(zip|tar\.gz|tgz)(\?.*)?$', name, re.IGNORECASE):
+ _install_archive_url(name, result)
+ return
+
# --- Full GitHub URL ---
parsed = _parse_github_url(name)
if parsed:
diff --git a/docs/commands/general.mdx b/docs/commands/general.mdx
new file mode 100644
index 00000000..8ab9fb75
--- /dev/null
+++ b/docs/commands/general.mdx
@@ -0,0 +1,115 @@
+---
+title: 常用命令
+description: 查看状态、管理配置和上下文等常用命令
+---
+
+以下命令支持在对话中使用 `/` 前缀,也支持在终端中使用 `cow` 前缀(部分命令仅对话可用)。
+
+
+ 在 Web 控制台中输入 `/` 会自动弹出命令提示,支持键盘上下选择和 Tab 补全。
+
+
+## help
+
+显示所有可用命令的帮助信息。
+
+```text
+/help
+```
+
+## status
+
+查看当前会话和服务的运行状态,包括进程信息、模型配置、会话消息数量和已加载技能数量。
+
+```text
+/status
+```
+
+输出示例:
+
+```
+🐮 CowAgent Status
+
+Process: PID 12345 | Running 2h 15m
+Version: 2.0.4
+Channel: web
+Model: MiniMax-M2.5
+Mode: agent
+
+Session: 12 messages | 8 skills loaded
+```
+
+## config
+
+查看或修改运行时配置。修改后立即生效,无需重启服务。
+
+**查看所有可配置项:**
+
+```text
+/config
+```
+
+**查看单个配置项:**
+
+```text
+/config model
+```
+
+**修改配置项:**
+
+```text
+/config model deepseek-chat
+```
+
+**支持修改的配置项:**
+
+| 配置项 | 说明 | 示例值 |
+| --- | --- | --- |
+| `model` | AI 模型名称 | `deepseek-chat` |
+| `agent_max_context_tokens` | 最大上下文 tokens | `40000` |
+| `agent_max_context_turns` | 最大上下文记忆轮次 | `30` |
+| `agent_max_steps` | 单次任务最大决策步数 | `15` |
+
+
+ 修改 `model` 时,系统会自动匹配对应的模型调用方式。配置会写入 `config.json` 并持久保存。
+
+
+## context
+
+查看当前会话的上下文信息,包括消息数量、内容长度等统计。
+
+```text
+/context
+```
+
+**清空当前会话上下文:**
+
+```text
+/context clear
+```
+
+
+ 清空上下文后,Agent 会"忘记"之前的对话内容,适用于切换话题或释放上下文空间。
+
+
+## logs
+
+查看最近的服务日志,默认显示最近 20 行,最多 50 行。
+
+```text
+/logs
+```
+
+**指定行数:**
+
+```text
+/logs 50
+```
+
+## version
+
+显示当前 CowAgent 版本号。
+
+```text
+/version
+```
diff --git a/docs/commands/index.mdx b/docs/commands/index.mdx
new file mode 100644
index 00000000..f9282607
--- /dev/null
+++ b/docs/commands/index.mdx
@@ -0,0 +1,86 @@
+---
+title: 命令总览
+description: CowAgent 命令系统 — 终端 CLI 和对话命令
+---
+
+CowAgent 提供两种命令交互方式:
+
+- **终端CLI** — 在系统终端中执行 `cow <命令>`,用于服务管理、技能管理等运维操作
+- **对话命令** — 在对话中输入 `/<命令>` 或 `cow <命令>`,用于查看状态、管理技能、调整配置等
+
+## 终端命令
+
+通过一键安装脚本部署后,`cow` 命令会自动可用。手动安装的用户需要在项目根目录下额外执行:
+
+```bash
+pip install -e .
+```
+
+安装后即可在任意位置使用 `cow` 命令:
+
+```bash
+cow help
+```
+
+输出示例:
+
+```
+CowAgent CLI
+
+Usage: cow
+
+Service:
+ start Start the CowAgent service
+ stop Stop the CowAgent service
+ restart Restart the CowAgent service
+ update Update code and restart service
+ status Show service status
+ logs View service logs
+
+Skills:
+ skill Manage skills (list / search / install / uninstall ...)
+
+Others:
+ help Show this help message
+ version Show version
+```
+
+## 对话命令
+
+在 Web 控制台或任意接入渠道的对话中,支持输入以 `/` 开头的命令:
+
+| 命令 | 说明 |
+| --- | --- |
+| `/help` | 显示命令帮助 |
+| `/status` | 查看服务状态和配置 |
+| `/config` | 查看或修改运行时配置 |
+| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
+| `/context` | 查看当前会话上下文信息 |
+| `/context clear` | 清空当前会话上下文 |
+| `/logs` | 查看最近日志 |
+| `/version` | 显示版本号 |
+
+
+ 对话命令中 `/start`、`/stop`、`/restart` 等服务管理命令会提示到终端中执行,因为它们涉及进程操作。
+
+
+## 命令对照表
+
+以下是各命令在终端和对话中的可用性:
+
+| 命令 | 终端 (`cow`) | 对话 (`/`) |
+| --- | :---: | :---: |
+| help | ✓ | ✓ |
+| version | ✓ | ✓ |
+| status | ✓ | ✓ |
+| logs | ✓ | ✓ |
+| config | ✗ | ✓ |
+| context | — | ✓ |
+| skill (子命令) | ✓ | ✓ |
+| start / stop / restart | ✓ | ✗ |
+| update | ✓ | ✗ |
+| install-browser | ✓ | ✗ |
+
+
+ `context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
+
diff --git a/docs/commands/process.mdx b/docs/commands/process.mdx
new file mode 100644
index 00000000..e5746773
--- /dev/null
+++ b/docs/commands/process.mdx
@@ -0,0 +1,134 @@
+---
+title: 进程管理
+description: 使用 cow 命令管理 CowAgent 进程的启动、停止、重启、更新等操作
+---
+
+进程管理命令用于控制 CowAgent 后台进程的生命周期。这些命令仅在终端中可用。
+
+## start
+
+启动 CowAgent 服务。默认以后台进程方式运行,并自动跟踪日志输出。
+
+```bash
+cow start
+```
+
+**选项:**
+
+| 选项 | 说明 |
+| --- | --- |
+| `-f`, `--foreground` | 前台运行,不以后台守护进程方式启动 |
+| `--no-logs` | 启动后不自动跟踪日志 |
+
+## stop
+
+停止正在运行的 CowAgent 服务。
+
+```bash
+cow stop
+```
+
+## restart
+
+重启 CowAgent 服务(先停止再启动)。
+
+```bash
+cow restart
+```
+
+**选项:**
+
+| 选项 | 说明 |
+| --- | --- |
+| `--no-logs` | 重启后不自动跟踪日志 |
+
+## update
+
+更新代码并重启服务。自动执行以下流程:
+
+1. 拉取最新代码(`git pull`)
+2. 停止当前服务
+3. 更新 Python 依赖
+4. 重新安装 CLI
+5. 启动服务
+
+```bash
+cow update
+```
+
+
+ 如果 `git pull` 失败(如存在本地未提交的修改),更新会中止,服务不受影响。
+
+
+## status
+
+查看 CowAgent 服务运行状态,包括进程信息、版本号、当前配置的模型和通道。
+
+```bash
+cow status
+```
+
+输出示例:
+
+```
+🐮 CowAgent Status
+ Status: ● Running (PID: 12345)
+ Version: 2.0.4
+ Channel: web
+ Model: MiniMax-M2.5
+ Mode: agent
+```
+
+## logs
+
+查看服务日志。
+
+```bash
+cow logs
+```
+
+**选项:**
+
+| 选项 | 说明 | 默认值 |
+| --- | --- | --- |
+| `-f`, `--follow` | 持续跟踪日志输出 | 否 |
+| `-n`, `--lines` | 显示最近 N 行 | 50 |
+
+示例:
+
+```bash
+# 查看最近 100 行日志
+cow logs -n 100
+
+# 持续跟踪日志
+cow logs -f
+```
+
+## install-browser
+
+安装 Playwright 和 Chromium 浏览器,用于启用 [浏览器工具](/tools/browser)。
+
+```bash
+cow install-browser
+```
+
+
+ 仅在需要使用浏览器工具(如网页浏览、截图等)时才需要安装。
+
+
+## run.sh 兼容
+
+如果未安装 Cow CLI,也可以使用 `run.sh` 脚本管理服务:
+
+| cow 命令 | run.sh 等效命令 |
+| --- | --- |
+| `cow start` | `./run.sh start` |
+| `cow stop` | `./run.sh stop` |
+| `cow restart` | `./run.sh restart` |
+| `cow update` | `./run.sh update` |
+| `cow status` | `./run.sh status` |
+| `cow logs` | `./run.sh logs` |
+
+
+ 推荐使用 `cow` 命令,它提供更简洁的语法和更丰富的功能。通过一键安装脚本部署时 `cow` 命令会自动安装。
+
diff --git a/docs/commands/skill.mdx b/docs/commands/skill.mdx
new file mode 100644
index 00000000..3b4a8aee
--- /dev/null
+++ b/docs/commands/skill.mdx
@@ -0,0 +1,218 @@
+---
+title: 技能管理
+description: 通过命令安装、卸载、启用、禁用和管理技能
+---
+
+技能管理命令用于安装、查询和管理 CowAgent 的技能。在对话中使用 `/skill <子命令>`,在终端中使用 `cow skill <子命令>`。
+
+## list
+
+列出已安装的技能及其状态。
+
+
+```text 对话
+/skill list
+```
+
+```bash 终端
+cow skill list
+```
+
+
+输出示例:
+
+```
+📦 已安装的技能 (3/4)
+
+✅ pptx
+ Use this skill any time a .pptx file is involved…
+ 来源: cowhub
+
+✅ skill-creator
+ Create, install, or update skills…
+ 来源: builtin
+
+⏸️ image-vision (已禁用)
+ 图片理解和视觉分析
+ 来源: builtin
+```
+
+**浏览技能广场**(查看 Hub 上所有可安装的技能):
+
+
+```text 对话
+/skill list --remote
+```
+
+```bash 终端
+cow skill list --remote
+```
+
+
+**选项:**
+
+| 选项 | 说明 | 默认值 |
+| --- | --- | --- |
+| `--remote`, `-r` | 浏览 Skill Hub 远程技能列表 | 否 |
+| `--page` | 远程列表分页页码 | 1 |
+
+## search
+
+在技能广场中搜索技能。
+
+
+```text 对话
+/skill search pptx
+```
+
+```bash 终端
+cow skill search pptx
+```
+
+
+## install
+
+安装技能。通过统一的 `install` 命令,可一键安装来自 **Cow 技能广场、GitHub、ClawHub** 以及任意 URL(zip 压缩包、SKILL.md 链接)上的技能,无需手动下载和配置。
+
+**从 Cow 技能广场安装(推荐):**
+
+
+```text 对话
+/skill install pptx
+```
+
+```bash 终端
+cow skill install pptx
+```
+
+
+**从 GitHub 安装:**
+
+
+```text 对话
+# 安装仓库中的所有技能(自动扫描包含 SKILL.md 的子目录)
+/skill install larksuite/cli
+
+# 指定子目录,只安装单个技能
+/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+
+# 使用 # 指定子目录
+/skill install larksuite/cli#skills/lark-minutes
+```
+
+```bash 终端
+# 安装仓库中的所有技能(自动扫描包含 SKILL.md 的子目录)
+cow skill install larksuite/cli
+
+# 指定子目录,只安装单个技能
+cow skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+
+# 使用 # 指定子目录
+cow skill install larksuite/cli#skills/lark-minutes
+```
+
+
+支持完整的 GitHub URL 和 `owner/repo` 简写。对于 mono-repo(一个仓库中包含多个技能),不指定子目录时会自动发现并批量安装所有技能;指定子目录时只安装该目录下的技能。
+
+**从 ClawHub 安装:**
+
+
+```text 对话
+/skill install clawhub:baidu-search
+```
+
+```bash 终端
+cow skill install clawhub:baidu-search
+```
+
+
+**从 URL 安装:**
+
+
+```text 对话
+# 从 zip 压缩包安装(支持单个或批量)
+/skill install https://cdn.link-ai.tech/skills/pptx.zip
+
+# 从 SKILL.md 链接安装
+/skill install https://example.com/path/to/SKILL.md
+```
+
+```bash 终端
+# 从 zip 压缩包安装(支持单个或批量)
+cow skill install https://cdn.link-ai.tech/skills/pptx.zip
+
+# 从 SKILL.md 链接安装
+cow skill install https://example.com/path/to/SKILL.md
+```
+
+
+支持从 zip / tar.gz 压缩包 URL 安装,解压后自动扫描包含 `SKILL.md` 的目录,支持单个或批量安装。也支持直接从 `SKILL.md` 文件链接安装,会自动解析技能名称和描述。
+
+安装成功后会显示技能名称、描述和来源,例如:
+
+```
+✅ baidu-search
+ 百度搜索:使用百度搜索引擎检索信息…
+ 来源: clawhub
+```
+
+## uninstall
+
+卸载已安装的技能。
+
+
+```text 对话
+/skill uninstall pptx
+```
+
+```bash 终端
+cow skill uninstall pptx
+```
+
+
+
+ 卸载操作会删除技能目录下的所有文件,此操作不可恢复。
+
+
+## enable / disable
+
+启用或禁用技能,禁用后技能不会被 Agent 调用。
+
+
+```text 对话
+/skill enable pptx
+/skill disable pptx
+```
+
+```bash 终端
+cow skill enable pptx
+cow skill disable pptx
+```
+
+
+## info
+
+查看已安装技能的详细信息,包括 `SKILL.md` 内容预览。
+
+
+```text 对话
+/skill info pptx
+```
+
+```bash 终端
+cow skill info pptx
+```
+
+
+## 技能来源
+
+安装的技能会记录来源信息,可通过 `/skill list` 查看:
+
+| 来源标识 | 说明 |
+| --- | --- |
+| `builtin` | 项目内置技能 |
+| `cowhub` | 从 CowAgent Skill Hub 安装 |
+| `github` | 从 GitHub URL 直接安装 |
+| `clawhub` | 从 ClawHub 安装 |
+| `url` | 从 SKILL.md URL 安装 |
+| `local` | 本地创建的技能 |
diff --git a/docs/docs.json b/docs/docs.json
index 9dcc9053..804dcccb 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -106,14 +106,17 @@
"tools/bash",
"tools/send",
"tools/memory",
- "tools/env-config"
+ "tools/env-config",
+ "tools/web-fetch",
+ "tools/scheduler"
]
},
{
"group": "可选工具",
"pages": [
"tools/web-search",
- "tools/scheduler"
+ "tools/vision",
+ "tools/browser"
]
}
]
@@ -125,15 +128,8 @@
"group": "技能系统",
"pages": [
"skills/index",
- "skills/skill-creator"
- ]
- },
- {
- "group": "内置技能",
- "pages": [
- "skills/image-vision",
- "skills/linkai-agent",
- "skills/web-fetch"
+ "skills/install",
+ "skills/create"
]
}
]
@@ -144,7 +140,8 @@
{
"group": "记忆系统",
"pages": [
- "memory"
+ "memory/index",
+ "memory/context"
]
}
]
@@ -167,6 +164,20 @@
}
]
},
+ {
+ "tab": "命令",
+ "groups": [
+ {
+ "group": "命令系统",
+ "pages": [
+ "commands/index",
+ "commands/process",
+ "commands/skill",
+ "commands/general"
+ ]
+ }
+ ]
+ },
{
"tab": "版本",
"groups": [
@@ -254,14 +265,17 @@
"en/tools/bash",
"en/tools/send",
"en/tools/memory",
- "en/tools/env-config"
+ "en/tools/env-config",
+ "en/tools/web-fetch",
+ "en/tools/scheduler"
]
},
{
"group": "Optional Tools",
"pages": [
"en/tools/web-search",
- "en/tools/scheduler"
+ "en/tools/vision",
+ "en/tools/browser"
]
}
]
@@ -273,16 +287,9 @@
"group": "Skills System",
"pages": [
"en/skills/index",
+ "en/skills/install",
"en/skills/skill-creator"
]
- },
- {
- "group": "Built-in Skills",
- "pages": [
- "en/skills/image-vision",
- "en/skills/linkai-agent",
- "en/skills/web-fetch"
- ]
}
]
},
@@ -292,7 +299,8 @@
{
"group": "Memory System",
"pages": [
- "en/memory"
+ "en/memory/index",
+ "en/memory/context"
]
}
]
@@ -315,6 +323,20 @@
}
]
},
+ {
+ "tab": "Commands",
+ "groups": [
+ {
+ "group": "Command System",
+ "pages": [
+ "en/commands/index",
+ "en/commands/process",
+ "en/commands/skill",
+ "en/commands/chat"
+ ]
+ }
+ ]
+ },
{
"tab": "Releases",
"groups": [
@@ -403,14 +425,16 @@
"ja/tools/send",
"ja/tools/memory",
"ja/tools/env-config",
- "ja/tools/browser"
+ "ja/tools/web-fetch",
+ "ja/tools/scheduler"
]
},
{
"group": "オプションツール",
"pages": [
"ja/tools/web-search",
- "ja/tools/scheduler"
+ "ja/tools/vision",
+ "ja/tools/browser"
]
}
]
@@ -422,15 +446,8 @@
"group": "スキルシステム",
"pages": [
"ja/skills/index",
- "ja/skills/skill-creator"
- ]
- },
- {
- "group": "内蔵スキル",
- "pages": [
- "ja/skills/image-vision",
- "ja/skills/linkai-agent",
- "ja/skills/web-fetch"
+ "ja/skills/install",
+ "ja/skills/create"
]
}
]
@@ -441,7 +458,8 @@
{
"group": "メモリシステム",
"pages": [
- "ja/memory"
+ "ja/memory/index",
+ "ja/memory/context"
]
}
]
@@ -464,6 +482,20 @@
}
]
},
+ {
+ "tab": "コマンド",
+ "groups": [
+ {
+ "group": "コマンドシステム",
+ "pages": [
+ "ja/commands/index",
+ "ja/commands/process",
+ "ja/commands/skill",
+ "ja/commands/general"
+ ]
+ }
+ ]
+ },
{
"tab": "リリース",
"groups": [
diff --git a/docs/en/README.md b/docs/en/README.md
index 06fda767..d6bff782 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -20,13 +20,14 @@
> 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.
+- ✅ **Autonomous Task Planning**: Understands complex tasks and autonomously plans execution, continuously thinking and invoking tools until goals are achieved.
- ✅ **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.
+- ✅ **Skills System**: Implements a Skills creation and execution engine, supports installing skills from [Skill Hub](https://skills.cowagent.ai), GitHub, etc., or creating custom Skills through conversation.
+- ✅ **Tool System**: Built-in tools for file I/O, terminal execution, browser automation, scheduled tasks, messaging, and more — autonomously invoked by the Agent.
+- ✅ **CLI System**: Provides terminal commands and in-chat commands for process management, skill installation, configuration, and more.
- ✅ **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 WeChat, 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
@@ -66,7 +67,7 @@ bash <(curl -fsSL 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)
+Script usage: [One-click Install](https://docs.cowagent.ai/en/guide/quick-start). After installation, you can also use `cow start`, `cow stop`, and other [CLI commands](https://docs.cowagent.ai/en/commands/index) to manage the service.
### Manual Installation
@@ -84,7 +85,25 @@ pip3 install -r requirements.txt
pip3 install -r requirements-optional.txt # optional but recommended
```
-**3. Configure**
+**3. Install Cow CLI (recommended)**
+
+```bash
+pip3 install -e .
+```
+
+After installation, use `cow` commands to manage the service (start, stop, update, etc.) and skills. See [Command Docs](https://docs.cowagent.ai/en/commands/index).
+
+**4. Install browser (optional)**
+
+If you need the Agent to operate a browser (visit web pages, fill forms, etc.):
+
+```bash
+cow install-browser
+```
+
+This auto-installs `playwright` and Chromium. See [Browser Tool Docs](https://docs.cowagent.ai/en/tools/browser).
+
+**5. Configure**
```bash
cp config-template.json config.json
@@ -92,13 +111,25 @@ 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**
+**6. Run**
```bash
-python3 app.py
+cow start # recommended, requires Cow CLI
+python3 app.py # or run directly
```
-For server background run:
+For server deployment, use `cow` commands to manage the service:
+
+```bash
+cow start # start in background
+cow stop # stop service
+cow restart # restart service
+cow status # check running status
+cow logs # view logs
+cow update # pull latest code and restart
+```
+
+Or use the traditional way:
```bash
nohup python3 app.py & tail -f nohup.out
@@ -195,7 +226,7 @@ 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).
+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, see the [Skill Creation docs](https://docs.cowagent.ai/en/skills/create).
## ✉ Contact
diff --git a/docs/en/commands/general.mdx b/docs/en/commands/general.mdx
new file mode 100644
index 00000000..186f51a9
--- /dev/null
+++ b/docs/en/commands/general.mdx
@@ -0,0 +1,101 @@
+---
+title: General Commands
+description: View status, manage config, and control context with commonly used commands
+---
+
+The following commands can be used in chat with the `/` prefix or in the terminal with the `cow` prefix (some are chat-only).
+
+
+ In the Web console, typing `/` brings up an autocomplete menu with keyboard navigation and Tab completion.
+
+
+## help
+
+Show help information for all available commands.
+
+```text
+/help
+```
+
+## status
+
+View current session and service status, including process info, model configuration, message count, and loaded skills.
+
+```text
+/status
+```
+
+## config
+
+View or modify runtime configuration. Changes take effect immediately without restarting.
+
+**View all configurable items:**
+
+```text
+/config
+```
+
+**View a single item:**
+
+```text
+/config model
+```
+
+**Modify a config item:**
+
+```text
+/config model deepseek-chat
+```
+
+**Configurable items:**
+
+| Item | Description | Example |
+| --- | --- | --- |
+| `model` | AI model name | `deepseek-chat` |
+| `agent_max_context_tokens` | Max context tokens | `40000` |
+| `agent_max_context_turns` | Max context memory turns | `30` |
+| `agent_max_steps` | Max decision steps per task | `15` |
+
+
+ When changing `model`, the system automatically matches the corresponding model API. Configuration is persisted to `config.json`.
+
+
+## context
+
+View current session context statistics, including message count and content length.
+
+```text
+/context
+```
+
+**Clear current session context:**
+
+```text
+/context clear
+```
+
+
+ Clearing context makes the Agent "forget" previous conversation, useful for switching topics or freeing context space.
+
+
+## logs
+
+View recent service logs. Shows the last 20 lines by default, up to 50.
+
+```text
+/logs
+```
+
+**Specify line count:**
+
+```text
+/logs 50
+```
+
+## version
+
+Show the current CowAgent version.
+
+```text
+/version
+```
diff --git a/docs/en/commands/index.mdx b/docs/en/commands/index.mdx
new file mode 100644
index 00000000..4d7c91a5
--- /dev/null
+++ b/docs/en/commands/index.mdx
@@ -0,0 +1,84 @@
+---
+title: Commands Overview
+description: CowAgent command system — Terminal CLI and chat commands
+---
+
+CowAgent provides two ways to interact via commands:
+
+- **Terminal CLI** — Run `cow ` in your system terminal for service management, skill management, and other operations
+- **Chat Commands** — Type `/` or `cow ` in any conversation to check status, manage skills, adjust configuration, etc.
+
+## Cow CLI
+
+After deploying with the one-click install script, the `cow` command is automatically available. For manual installations, run:
+
+```bash
+pip install -e .
+```
+
+Then use the `cow` command from anywhere:
+
+```bash
+cow help
+```
+
+Example output:
+
+```
+🐮 CowAgent CLI
+
+Usage: cow
+
+Service:
+ start Start the CowAgent service
+ stop Stop the CowAgent service
+ restart Restart the CowAgent service
+ update Update code and restart service
+ status Show service status
+ logs View service logs
+
+Skills:
+ skill Manage skills (list / search / install / uninstall ...)
+
+Others:
+ help Show this help message
+ version Show version
+```
+
+## Chat Commands
+
+In the Web console or any connected channel, type `/` to see command suggestions. Supported commands:
+
+| Command | Description |
+| --- | --- |
+| `/help` | Show command help |
+| `/status` | View service status and configuration |
+| `/config` | View or modify runtime configuration |
+| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
+| `/context` | View current session context info |
+| `/context clear` | Clear current session context |
+| `/logs` | View recent logs |
+| `/version` | Show version number |
+
+
+ Service management commands like `/start`, `/stop`, `/restart` will prompt you to use them in the terminal instead, as they involve process operations.
+
+
+## Command Availability
+
+| Command | Terminal (`cow`) | Chat (`/`) |
+| --- | :---: | :---: |
+| help | ✓ | ✓ |
+| version | ✓ | ✓ |
+| status | ✓ | ✓ |
+| logs | ✓ | ✓ |
+| config | ✗ | ✓ |
+| context | — | ✓ |
+| skill (subcommands) | ✓ | ✓ |
+| start / stop / restart | ✓ | ✗ |
+| update | ✓ | ✗ |
+| install-browser | ✓ | ✗ |
+
+
+ `context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
+
diff --git a/docs/en/commands/process.mdx b/docs/en/commands/process.mdx
new file mode 100644
index 00000000..452df03a
--- /dev/null
+++ b/docs/en/commands/process.mdx
@@ -0,0 +1,123 @@
+---
+title: Process Management
+description: Manage CowAgent process lifecycle with cow commands
+---
+
+Process management commands control the CowAgent background process. These commands are only available in the terminal.
+
+## start
+
+Start the CowAgent service. Runs as a background daemon by default and automatically tails logs.
+
+```bash
+cow start
+```
+
+**Options:**
+
+| Option | Description |
+| --- | --- |
+| `-f`, `--foreground` | Run in foreground, not as a background daemon |
+| `--no-logs` | Don't tail logs after starting |
+
+## stop
+
+Stop the running CowAgent service.
+
+```bash
+cow stop
+```
+
+## restart
+
+Restart the CowAgent service (stop then start).
+
+```bash
+cow restart
+```
+
+**Options:**
+
+| Option | Description |
+| --- | --- |
+| `--no-logs` | Don't tail logs after restart |
+
+## update
+
+Update code and restart the service. Automatically performs:
+
+1. Pull latest code (`git pull`)
+2. Stop current service
+3. Update Python dependencies
+4. Reinstall CLI
+5. Start service
+
+```bash
+cow update
+```
+
+
+ If `git pull` fails (e.g., uncommitted local changes), the update aborts and the service remains unaffected.
+
+
+## status
+
+Check CowAgent service status, including process info, version, and current model/channel configuration.
+
+```bash
+cow status
+```
+
+## logs
+
+View service logs.
+
+```bash
+cow logs
+```
+
+**Options:**
+
+| Option | Description | Default |
+| --- | --- | --- |
+| `-f`, `--follow` | Continuously tail log output | No |
+| `-n`, `--lines` | Show last N lines | 50 |
+
+Examples:
+
+```bash
+# View last 100 lines
+cow logs -n 100
+
+# Continuously tail logs
+cow logs -f
+```
+
+## install-browser
+
+Install Playwright and Chromium browser for the [browser tool](/en/tools/browser).
+
+```bash
+cow install-browser
+```
+
+
+ Only needed when using browser tools (web browsing, screenshots, etc.).
+
+
+## run.sh Compatibility
+
+If Cow CLI is not installed, you can use `run.sh` to manage the service:
+
+| cow command | run.sh equivalent |
+| --- | --- |
+| `cow start` | `./run.sh start` |
+| `cow stop` | `./run.sh stop` |
+| `cow restart` | `./run.sh restart` |
+| `cow update` | `./run.sh update` |
+| `cow status` | `./run.sh status` |
+| `cow logs` | `./run.sh logs` |
+
+
+ The `cow` command is recommended — it provides cleaner syntax and richer features. It is automatically installed via the one-click install script.
+
diff --git a/docs/en/commands/skill.mdx b/docs/en/commands/skill.mdx
new file mode 100644
index 00000000..d712b4d2
--- /dev/null
+++ b/docs/en/commands/skill.mdx
@@ -0,0 +1,192 @@
+---
+title: Skill Management
+description: Install, uninstall, enable, disable, and manage skills via commands
+---
+
+Skill management commands are used to install, query, and manage CowAgent skills. Use `/skill ` in chat or `cow skill ` in the terminal.
+
+## list
+
+List installed skills and their status.
+
+
+```text Chat
+/skill list
+```
+
+```bash Terminal
+cow skill list
+```
+
+
+**Browse the Skill Hub** (view all available skills):
+
+
+```text Chat
+/skill list --remote
+```
+
+```bash Terminal
+cow skill list --remote
+```
+
+
+**Options:**
+
+| Option | Description | Default |
+| --- | --- | --- |
+| `--remote`, `-r` | Browse Skill Hub remote skill list | No |
+| `--page` | Page number for remote listing | 1 |
+
+## search
+
+Search for skills on the Skill Hub.
+
+
+```text Chat
+/skill search pptx
+```
+
+```bash Terminal
+cow skill search pptx
+```
+
+
+## install
+
+Install skills with a single `install` command from Cow Skill Hub, GitHub, ClawHub, or any URL (zip archives, SKILL.md links) — no manual download or configuration required.
+
+**From Skill Hub (recommended):**
+
+
+```text Chat
+/skill install pptx
+```
+
+```bash Terminal
+cow skill install pptx
+```
+
+
+**From GitHub:**
+
+
+```text Chat
+# Install all skills in a repo (auto-discovers subdirectories with SKILL.md)
+/skill install larksuite/cli
+
+# Specify a subdirectory to install a single skill
+/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+
+# Use # to specify a subdirectory
+/skill install larksuite/cli#skills/lark-minutes
+```
+
+```bash Terminal
+# Install all skills in a repo (auto-discovers subdirectories with SKILL.md)
+cow skill install larksuite/cli
+
+# Specify a subdirectory to install a single skill
+cow skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+
+# Use # to specify a subdirectory
+cow skill install larksuite/cli#skills/lark-minutes
+```
+
+
+Supports full GitHub URLs and `owner/repo` shorthand. For mono-repos (multiple skills in one repository), omitting the subdirectory auto-discovers and batch-installs all skills; specifying a subdirectory installs only that skill.
+
+**From ClawHub:**
+
+
+```text Chat
+/skill install clawhub:baidu-search
+```
+
+```bash Terminal
+cow skill install clawhub:baidu-search
+```
+
+
+**From URL:**
+
+
+```text Chat
+# Install from a zip archive (single or batch)
+/skill install https://cdn.link-ai.tech/skills/pptx.zip
+
+# Install from a SKILL.md link
+/skill install https://example.com/path/to/SKILL.md
+```
+
+```bash Terminal
+# Install from a zip archive (single or batch)
+cow skill install https://cdn.link-ai.tech/skills/pptx.zip
+
+# Install from a SKILL.md link
+cow skill install https://example.com/path/to/SKILL.md
+```
+
+
+Supports installing from zip / tar.gz archive URLs — automatically extracts and discovers directories containing `SKILL.md`, with support for single or batch install. Also supports installing directly from a `SKILL.md` file URL, automatically parsing the skill name and description.
+
+## uninstall
+
+Uninstall an installed skill.
+
+
+```text Chat
+/skill uninstall pptx
+```
+
+```bash Terminal
+cow skill uninstall pptx
+```
+
+
+
+ Uninstalling deletes all files in the skill directory. This action cannot be undone.
+
+
+## enable / disable
+
+Enable or disable a skill. Disabled skills will not be invoked by the Agent.
+
+
+```text Chat
+/skill enable pptx
+/skill disable pptx
+```
+
+```bash Terminal
+cow skill enable pptx
+cow skill disable pptx
+```
+
+
+## info
+
+View details of an installed skill, including a preview of its `SKILL.md`.
+
+
+```text Chat
+/skill info pptx
+```
+
+```bash Terminal
+cow skill info pptx
+```
+
+
+## Skill Sources
+
+Installed skills track their origin, viewable via `/skill list`:
+
+| Source | Description |
+| --- | --- |
+| `builtin` | Built-in project skills |
+| `cowhub` | Installed from CowAgent Skill Hub |
+| `github` | Installed directly from a GitHub URL |
+| `clawhub` | Installed from ClawHub |
+| `url` | Installed from a SKILL.md URL |
+| `local` | Locally created skills |
diff --git a/docs/en/guide/manual-install.mdx b/docs/en/guide/manual-install.mdx
index 5550f815..ac36b265 100644
--- a/docs/en/guide/manual-install.mdx
+++ b/docs/en/guide/manual-install.mdx
@@ -30,7 +30,25 @@ Optional dependencies (recommended):
pip3 install -r requirements-optional.txt
```
-### 3. Configure
+### 3. Install Cow CLI
+
+Install the command-line tool for managing services and skills:
+
+```bash
+pip3 install -e .
+```
+
+Then use the `cow` command:
+
+```bash
+cow help
+```
+
+
+ This step is recommended. After installation you can use `cow start`, `cow stop`, `cow update` to manage the service, and `cow skill` to manage skills. Without the CLI, you can use `./run.sh` or `python3 app.py` to run.
+
+
+### 4. Configure
Copy the config template and edit:
@@ -40,22 +58,32 @@ cp config-template.json config.json
Fill in model API keys, channel type, and other settings in `config.json`. See the [model docs](/en/models/index) for details.
-### 4. Run
+### 5. Run
-**Local run:**
+**Using Cow CLI (recommended):**
+
+```bash
+cow start
+```
+
+**Or run locally in foreground:**
```bash
python3 app.py
```
-By default, the Web service starts. Access `http://localhost:9899/chat` to chat.
+By default, the Web console starts. Access `http://localhost:9899` to chat.
-**Background run on server:**
+**Background run on server (without CLI):**
```bash
nohup python3 app.py & tail -f nohup.out
```
+
+ If deploying on a server, open port `9899` in your firewall or security group to access the Web console. It's recommended to restrict access to specific IPs for security.
+
+
## Docker Deployment
Docker deployment does not require cloning source code or installing dependencies. For Agent mode, source deployment is recommended for broader system access.
@@ -84,6 +112,10 @@ sudo docker compose up -d
sudo docker logs -f chatgpt-on-wechat
```
+
+ If deploying on a server, open port `9899` in your firewall or security group to access the Web console. It's recommended to restrict access to specific IPs for security.
+
+
## Core Configuration
```json
diff --git a/docs/en/guide/quick-start.mdx b/docs/en/guide/quick-start.mdx
index c9f3551e..bf788ee6 100644
--- a/docs/en/guide/quick-start.mdx
+++ b/docs/en/guide/quick-start.mdx
@@ -18,22 +18,27 @@ The script automatically performs these steps:
1. Check Python environment (requires Python 3.7+)
2. Install required tools (git, curl, etc.)
3. Clone project to `~/chatgpt-on-wechat`
-4. Install Python dependencies
+4. Install Python dependencies and Cow CLI
5. Guided configuration for AI model and channel
6. Start service
-By default, the Web service starts after installation. Access `http://localhost:9899/chat` to begin chatting.
+By default, the Web console starts after installation. Access `http://localhost:9899` to begin chatting.
## Management Commands
-After installation, use these commands to manage the service:
+After installation, use the `cow` command to manage the service:
| Command | Description |
| --- | --- |
-| `./run.sh start` | Start service |
-| `./run.sh stop` | Stop service |
-| `./run.sh restart` | Restart service |
-| `./run.sh status` | Check run status |
-| `./run.sh logs` | View real-time logs |
-| `./run.sh config` | Reconfigure |
-| `./run.sh update` | Update project code |
+| `cow start` | Start service |
+| `cow stop` | Stop service |
+| `cow restart` | Restart service |
+| `cow status` | Check run status |
+| `cow logs` | View real-time logs |
+| `cow update` | Update code and restart |
+
+See the [Commands documentation](/en/commands/index) for more details.
+
+
+ If the `cow` command is not available, you can use `./run.sh ` as a fallback (e.g., `./run.sh start`, `./run.sh stop`). Both are functionally equivalent.
+
diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx
index 7cec9002..bc0613a3 100644
--- a/docs/en/intro/index.mdx
+++ b/docs/en/intro/index.mdx
@@ -28,6 +28,12 @@ CowAgent can proactively think and plan tasks, operate computers and external re
Supports parsing, processing, generating, and sending text, images, voice, files, and other message types.
+
+ Built-in tools for file I/O, terminal execution, browser automation, scheduled tasks, messaging, and more. The Agent autonomously invokes tools to accomplish complex tasks.
+
+
+ Provides terminal CLI and in-chat commands for process management, skill installation, configuration, context inspection, and other common operations.
+
Supports mainstream model providers including OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao, and more.
diff --git a/docs/en/memory/context.mdx b/docs/en/memory/context.mdx
new file mode 100644
index 00000000..567d5768
--- /dev/null
+++ b/docs/en/memory/context.mdx
@@ -0,0 +1,80 @@
+---
+title: Short-term Memory
+description: Conversation context — message management, compression strategies, and context operations
+---
+
+Conversation context is the Agent's short-term memory, containing all messages in the current session (user input, Agent replies, tool calls and results). Proper context management is critical for the Agent's reasoning quality and cost control.
+
+## Context Structure
+
+Each conversation turn consists of:
+
+```
+User message → Agent thinking → Tool call → Tool result → ... → Agent final reply
+```
+
+A single turn may include multiple tool calls (controlled by `agent_max_steps`). All tool calls and results are retained in context until compressed or trimmed.
+
+## Key Configuration
+
+| Parameter | Description | Default |
+| --- | --- | --- |
+| `agent_max_context_tokens` | Maximum context token budget | `50000` |
+| `agent_max_context_turns` | Maximum conversation turns in context | `20` |
+| `agent_max_steps` | Maximum decision steps per turn (tool call count) | `15` |
+
+Configurable via `config.json` or the `/config` chat command.
+
+## Compression Strategy
+
+When context exceeds limits, the system automatically compresses to free space. The process has multiple stages:
+
+### 1. Tool Result Truncation
+
+Before each decision loop, the system checks tool call results in historical turns. Results exceeding **20,000 characters** are truncated, keeping only the beginning and end with a truncation notice. Current turn results are not affected.
+
+### 2. Turn Trimming
+
+When conversation turns exceed `agent_max_context_turns`:
+
+- The **oldest half** of complete turns is trimmed (preserving tool call chain integrity)
+- Trimmed messages are summarized by LLM and **written to the daily memory file**
+- Remaining turns stay intact
+
+### 3. Token Budget Trimming
+
+After turn trimming, if tokens still exceed the budget:
+
+- **Fewer than 5 turns**: All turns undergo **text compression** — each turn keeps only the first user text and last Agent reply, removing intermediate tool call chains
+- **5 or more turns**: The **first half** of turns is trimmed again, with discarded content also written to memory
+
+### 4. Overflow Emergency Handling
+
+When the model API returns a context overflow error:
+
+1. All current messages are summarized and written to memory
+2. Aggressive trimming is applied (tool results limited to 10K chars, user text to 10K, max 5 turns)
+3. If still overflowing, the entire conversation context is cleared
+
+## Session Persistence
+
+Conversation messages are persisted to a local database, automatically restored after service restart. Restore strategy:
+
+- Restores the most recent **`max(3, max_context_turns / 6)`** turns
+- Only retains each turn's **user text and Agent final reply**, not intermediate tool call chains
+- Sessions older than **30 days** are automatically cleaned up
+
+## Commands
+
+Use these commands in chat to manage context:
+
+| Command | Description |
+| --- | --- |
+| `/context` | View current context statistics (message count, role distribution, total characters) |
+| `/context clear` | Clear current session context |
+| `/config agent_max_context_tokens 80000` | Adjust context token budget |
+| `/config agent_max_context_turns 30` | Adjust context turn limit |
+
+
+ After clearing context, the Agent "forgets" previous conversation content. Content that was already written to long-term memory can still be retrieved via memory search.
+
diff --git a/docs/en/memory.mdx b/docs/en/memory/index.mdx
similarity index 56%
rename from docs/en/memory.mdx
rename to docs/en/memory/index.mdx
index 67ca57ee..73d2d824 100644
--- a/docs/en/memory.mdx
+++ b/docs/en/memory/index.mdx
@@ -1,30 +1,39 @@
---
-title: Memory
-description: CowAgent long-term memory system
+title: Long-term Memory
+description: CowAgent long-term memory system — file persistence, automatic writing, and hybrid retrieval
---
-The memory system enables the Agent to remember important information over time, continuously accumulating experience, understanding user preferences, and truly achieving autonomous thinking and continuous growth.
+Long-term memory is stored in workspace files, persisting across sessions. The Agent loads historical memory on demand via retrieval tools during conversation, and automatically writes conversation summaries to long-term memory when context is trimmed.
## Memory Types
### Core Memory (MEMORY.md)
-Stored in `~/cow/MEMORY.md`, containing long-term user preferences, important decisions, key facts, and other information that doesn't fade over time. Automatically injected into the system prompt on every conversation turn as background knowledge.
+Stored in `~/cow/MEMORY.md`, containing long-term user preferences, important decisions, key facts, and other information that doesn't fade over time. The Agent reads and writes this file via tools to maintain long-term knowledge.
### Daily Memory (memory/YYYY-MM-DD.md)
-Stored in `~/cow/memory/` directory, named by date (e.g. `2026-03-08.md`), recording daily conversation summaries and key events. Files are only created on first write to avoid generating empty files.
+Stored in `~/cow/memory/` directory, named by date (e.g., `2026-03-08.md`), recording daily conversation summaries and key events. Files are only created on first write to avoid generating empty files.
-## Memory Writing
+## Automatic Writing
-The Agent automatically persists conversation content to daily memory through the following mechanisms:
+The Agent automatically persists conversation content to long-term memory through the following mechanisms:
-- **On context trimming** — When conversation turns or tokens exceed the configured limit, the oldest half of the context is trimmed in batch, and the discarded content is summarized by LLM into key information and written to the daily memory file
+- **On context trimming** — When conversation turns or tokens exceed the configured limit, the oldest half of the context is trimmed, and the discarded content is summarized by LLM into key information and written to the daily memory file
- **Daily scheduled summary** — A full summary is automatically triggered at 23:55 every day, ensuring memory is preserved even on low-activity days (skipped if content hasn't changed)
- **On API context overflow** — When the model API returns a context overflow error, the current conversation summary is saved as an emergency measure
All memory writes run asynchronously in a background thread (LLM summarization + file writing), never blocking normal conversation replies.
+## Memory Retrieval
+
+The memory system supports hybrid retrieval modes:
+
+- **Keyword retrieval** — FTS5 full-text index matching with BM25 ranking
+- **Vector retrieval** — Embedding-based semantic similarity search, finds relevant memory even with different wording
+
+The Agent automatically triggers memory retrieval during conversation as needed, incorporating relevant historical information into context. Results are ranked by a combined score (default: 0.7 vector weight + 0.3 keyword weight). Daily memory scores decay over time (30-day half-life), while core memory does not decay.
+
## First Launch
On first launch, the Agent will proactively ask the user for key information and save it to the workspace (default `~/cow`):
@@ -40,27 +49,10 @@ On first launch, the Agent will proactively ask the user for key information and
-## Memory Retrieval
-
-The memory system supports hybrid retrieval modes:
-
-- **Keyword retrieval** — Match historical memory based on keywords
-- **Vector retrieval** — Semantic similarity search, finds relevant memory even with different wording
-
-The Agent automatically triggers memory retrieval during conversation as needed, incorporating relevant historical information into context. Core memory (`MEMORY.md`) is always injected into the system prompt, while daily memory is loaded on demand via retrieval.
-
## Configuration
-```json
-{
- "agent_workspace": "~/cow",
- "agent_max_context_tokens": 40000,
- "agent_max_context_turns": 20
-}
-```
-
| Parameter | Description | Default |
| --- | --- | --- |
| `agent_workspace` | Workspace path, memory files stored under this directory | `~/cow` |
-| `agent_max_context_tokens` | Max context tokens; when exceeded, half is trimmed and summarized into memory | `40000` |
-| `agent_max_context_turns` | Max context turns; when exceeded, half is trimmed and summarized into memory | `20` |
+| `agent_max_context_tokens` | Max context tokens; when exceeded, content is trimmed and summarized into memory | `50000` |
+| `agent_max_context_turns` | Max context turns; when exceeded, content is trimmed and summarized into memory | `20` |
diff --git a/docs/en/skills/create.mdx b/docs/en/skills/create.mdx
new file mode 100644
index 00000000..5edd281e
--- /dev/null
+++ b/docs/en/skills/create.mdx
@@ -0,0 +1,58 @@
+---
+title: Create Skills
+description: Create custom skills through conversation
+---
+
+CowAgent includes a built-in Skill Creator that lets you quickly create, install, or update skills through natural language conversation.
+
+## Usage
+
+Simply describe the skill you want in a conversation, and the Agent will handle the creation:
+
+- Codify workflows as skills: "Create a skill from this deployment process"
+- Integrate third-party APIs: "Create a skill based on this API documentation"
+- Install remote skills: "Install xxx skill for me"
+
+## Creation Flow
+
+1. Tell the Agent what skill you want to create
+2. Agent automatically generates `SKILL.md` description and execution scripts
+3. Skill is saved to the workspace `~/cow/skills/` directory
+4. Agent will automatically recognize and use the skill in future conversations
+
+
+
+
+
+## SKILL.md Format
+
+Created skills follow the standard SKILL.md format:
+
+```markdown
+---
+name: my-skill
+description: Brief description of the skill
+metadata:
+ emoji: 🔧
+ requires:
+ bins: ["curl"]
+ env: ["MY_API_KEY"]
+ primaryEnv: "MY_API_KEY"
+---
+
+# My Skill
+
+Detailed instructions...
+```
+
+| Field | Description |
+| --- | --- |
+| `name` | Skill name, must match directory name |
+| `description` | Skill description, Agent decides whether to invoke based on this |
+| `metadata.requires.bins` | Required system commands |
+| `metadata.requires.env` | Required environment variables |
+| `metadata.always` | Always load (default false) |
+
+
+ See the [Skill Creator documentation](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md) for details.
+
diff --git a/docs/en/skills/image-vision.mdx b/docs/en/skills/image-vision.mdx
deleted file mode 100644
index 8cc5c37e..00000000
--- a/docs/en/skills/image-vision.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Image Vision
-description: Recognize images using OpenAI vision models
----
-
-Analyze image content using OpenAI's GPT-4 Vision API, understanding objects, text, colors, and other elements in images.
-
-## Dependencies
-
-| Dependency | Description |
-| --- | --- |
-| `OPENAI_API_KEY` | OpenAI API key |
-| `curl`, `base64` | System commands (usually pre-installed) |
-
-Configuration:
-
-- Configure `OPENAI_API_KEY` via the `env_config` tool
-- Or set `open_ai_api_key` in `config.json`
-
-## Supported Models
-
-- `gpt-4.1-mini` (recommended, cost-effective)
-- `gpt-4.1`
-
-## Usage
-
-Once configured, send an image to the Agent to automatically trigger image recognition.
-
-
-
-
diff --git a/docs/en/skills/index.mdx b/docs/en/skills/index.mdx
index afbaac7a..2569f096 100644
--- a/docs/en/skills/index.mdx
+++ b/docs/en/skills/index.mdx
@@ -7,20 +7,17 @@ Skills provide infinite extensibility for the Agent. Each Skill consists of a de
The difference between Skills and Tools: Tools are atomic operations implemented in code (e.g., file read/write, command execution), while Skills are high-level workflows based on description files that can combine multiple Tools to complete complex tasks.
-## Built-in Skills
+## Getting Skills
-Located in the project `skills/` directory, automatically enabled based on dependency conditions:
+CowAgent offers multiple ways to acquire skills:
-| Skill | Description | Dependencies |
-| --- | --- | --- |
-| [`skill-creator`](/en/skills/skill-creator) | Create custom skills through conversation | None |
-| [`openai-image-vision`](/en/skills/image-vision) | Recognize images using OpenAI vision models | `OPENAI_API_KEY` |
-| [`linkai-agent`](/en/skills/linkai-agent) | Integrate LinkAI platform agents | `LINKAI_API_KEY` |
-| [`web-fetch`](/en/skills/web-fetch) | Fetch web page text content | `curl` (enabled by default) |
+- **Cow Skill Hub** — Browse and install community skills via `/skill list --remote`
+- **GitHub** — Install directly from GitHub repositories, with batch install support
+- **ClawHub** — Install ClawHub skills via `/skill install clawhub:name`
+- **URL** — Install from zip archives or SKILL.md links
+- **Conversational creation** — Let the Agent create skills through natural language conversation
-## Custom Skills
-
-Created by users through conversation, stored in workspace (`~/cow/skills/`), can implement any complex business process and third-party system integration.
+See [Install Skills](/en/skills/install) and [Skill Management Commands](/en/commands/skill) for details. You can also [create skills](/en/skills/create) through conversation.
## Skill Loading Priority
diff --git a/docs/en/skills/install.mdx b/docs/en/skills/install.mdx
new file mode 100644
index 00000000..bb3b5f33
--- /dev/null
+++ b/docs/en/skills/install.mdx
@@ -0,0 +1,53 @@
+---
+title: Install Skills
+description: Install skills from multiple sources with a single command
+---
+
+CowAgent supports installing skills from **Cow Skill Hub, GitHub, ClawHub**, and any URL with a unified `install` command. Use `/skill install` in chat or `cow skill install` in the terminal.
+
+## From Skill Hub
+
+Browse the Skill Hub and install:
+
+```text
+/skill list --remote
+/skill install pptx
+```
+
+## From GitHub
+
+Supports batch install from repositories and single skill from subdirectories:
+
+```text
+/skill install larksuite/cli
+/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+```
+
+## From ClawHub
+
+```text
+/skill install clawhub:baidu-search
+```
+
+## From URL
+
+Supports zip archives and SKILL.md file links:
+
+```text
+/skill install https://cdn.link-ai.tech/skills/pptx.zip
+/skill install https://example.com/path/to/SKILL.md
+```
+
+## Manage Skills
+
+```text
+/skill list # View installed skills
+/skill info pptx # View skill details
+/skill enable pptx # Enable a skill
+/skill disable pptx # Disable a skill
+/skill uninstall pptx # Uninstall a skill
+```
+
+
+ All commands above work in the terminal by replacing `/skill` with `cow skill`. See [Skill Management Commands](/en/commands/skill) for full documentation.
+
diff --git a/docs/en/skills/linkai-agent.mdx b/docs/en/skills/linkai-agent.mdx
deleted file mode 100644
index d0edc05a..00000000
--- a/docs/en/skills/linkai-agent.mdx
+++ /dev/null
@@ -1,47 +0,0 @@
----
-title: LinkAI Agent
-description: Integrate LinkAI platform multi-agent skill
----
-
-Use agents from the [LinkAI](https://link-ai.tech/) platform as Skills for multi-agent decision-making. The Agent intelligently selects based on agent names and descriptions, calling the corresponding application or workflow via `app_code`.
-
-## Dependencies
-
-| Dependency | Description |
-| --- | --- |
-| `LINKAI_API_KEY` | LinkAI platform API key, created in [Console](https://link-ai.tech/console/interface) |
-| `curl` | System command (usually pre-installed) |
-
-Configuration:
-
-- Configure `LINKAI_API_KEY` via the `env_config` tool
-- Or set `linkai_api_key` in `config.json`
-
-## Configure Agents
-
-Add available agents in `skills/linkai-agent/config.json`:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI Customer Support",
- "app_description": "Select this assistant only when the user needs help with LinkAI platform questions"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "Content Creator",
- "app_description": "Use this assistant only when the user needs to create images or videos"
- }
- ]
-}
-```
-
-## Usage
-
-Once configured, the Agent will automatically select the appropriate LinkAI agent based on the user's question.
-
-
-
-
diff --git a/docs/en/skills/skill-creator.mdx b/docs/en/skills/skill-creator.mdx
deleted file mode 100644
index 6c5c3f73..00000000
--- a/docs/en/skills/skill-creator.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Skill Creator
-description: Create custom skills through conversation
----
-
-Quickly create, install, or update skills through natural language conversation.
-
-## Dependencies
-
-No extra dependencies, always available.
-
-## Usage
-
-- Codify workflows as skills: "Create a skill from this deployment process"
-- Integrate third-party APIs: "Create a skill based on this API documentation"
-- Install remote skills: "Install xxx skill for me"
-
-## Creation Flow
-
-1. Tell the Agent what skill you want to create
-2. Agent automatically generates `SKILL.md` description and execution scripts
-3. Skill is saved to the workspace `~/cow/skills/` directory
-4. Agent will automatically recognize and use the skill in future conversations
-
-
-
-
-
-
- See the [Skill Creator documentation](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md) for details.
-
diff --git a/docs/en/skills/web-fetch.mdx b/docs/en/skills/web-fetch.mdx
deleted file mode 100644
index f52077de..00000000
--- a/docs/en/skills/web-fetch.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Web Fetch
-description: Fetch web page text content
----
-
-Use curl to fetch web pages and extract readable text content. A lightweight web access method without browser automation.
-
-## Dependencies
-
-| Dependency | Description |
-| --- | --- |
-| `curl` | System command (usually pre-installed) |
-
-This skill has `always: true` set, enabled by default as long as the system has the `curl` command.
-
-## Usage
-
-Automatically invoked when the Agent needs to fetch content from a URL, no extra configuration needed.
-
-## Comparison with browser Tool
-
-| Feature | web-fetch (skill) | browser (tool) |
-| --- | --- | --- |
-| Dependencies | curl only | browser-use + playwright |
-| JS rendering | Not supported | Supported |
-| Page interaction | Not supported | Supports click, type, etc. |
-| Best for | Static page text | Dynamic web pages |
-
-
- For most web content retrieval scenarios, web-fetch is sufficient. Only use the browser tool when you need JS rendering or page interaction.
-
diff --git a/docs/guide/manual-install.mdx b/docs/guide/manual-install.mdx
index 3b233284..fcf9e69f 100644
--- a/docs/guide/manual-install.mdx
+++ b/docs/guide/manual-install.mdx
@@ -32,7 +32,39 @@ pip3 install -r requirements-optional.txt
> 国内网络可使用镜像源加速:`pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
-### 3. 配置
+### 3. 安装 Cow CLI
+
+安装命令行工具,用于管理服务和技能:
+
+```bash
+pip3 install -e .
+```
+
+安装后即可使用 `cow` 命令:
+
+```bash
+cow help
+```
+
+
+ 此步骤为推荐操作。安装后可以使用 `cow start`、`cow stop`、`cow update` 等命令管理服务,也可以使用 `cow skill` 管理技能。如果不安装 CLI,可以使用 `./run.sh` 或 `python3 app.py` 运行。
+
+
+### 3.1 安装浏览器工具(可选)
+
+如需使用浏览器工具(控制浏览器访问网页、填写表单等),运行:
+
+```bash
+cow install-browser
+```
+
+该命令会自动安装 Playwright 和 Chromium 浏览器。详细说明参考 [浏览器工具文档](/tools/browser)。
+
+
+ 浏览器工具依赖较重(~300MB),如不需要可跳过,不影响其他功能正常使用。
+
+
+### 4. 配置
复制配置文件模板并编辑:
@@ -42,9 +74,15 @@ cp config-template.json config.json
在 `config.json` 中填写模型 API Key 和通道类型等配置,详细说明参考各 [模型文档](/models/minimax)。
-### 4. 运行
+### 5. 运行
-**本地运行:**
+**使用 Cow CLI 运行(推荐):**
+
+```bash
+cow start
+```
+
+**或者本地前台运行:**
```bash
python3 app.py
@@ -52,7 +90,7 @@ python3 app.py
运行后默认启动 Web 控制台,访问 `http://localhost:9899` 开始对话和管理Agent。
-**服务器后台运行:**
+**服务器后台运行(不使用 CLI 时):**
```bash
nohup python3 app.py & tail -f nohup.out
@@ -96,28 +134,44 @@ sudo docker logs -f chatgpt-on-wechat
## 核心配置项
-```json
-{
- "channel_type": "web",
- "model": "MiniMax-M2.5",
- "agent": true,
- "agent_workspace": "~/cow",
- "agent_max_context_tokens": 40000,
- "agent_max_context_turns": 30,
- "agent_max_steps": 15
-}
-```
+
+
+ ```json
+ {
+ "channel_type": "web",
+ "model": "MiniMax-M2.7",
+ "agent": true,
+ "agent_workspace": "~/cow",
+ "agent_max_context_tokens": 40000,
+ "agent_max_context_turns": 30,
+ "agent_max_steps": 15
+ }
+ ```
+
+
+ ```yaml
+ environment:
+ CHANNEL_TYPE: 'web'
+ MODEL: 'MiniMax-M2.7'
+ MINIMAX_API_KEY: 'your-api-key'
+ AGENT: 'True'
+ AGENT_MAX_CONTEXT_TOKENS: 40000
+ AGENT_MAX_CONTEXT_TURNS: 30
+ AGENT_MAX_STEPS: 15
+ ```
+
+
-| 参数 | 说明 | 默认值 |
-| --- | --- | --- |
-| `channel_type` | 接入渠道类型 | `web` |
-| `model` | 模型名称 | `MiniMax-M2.5` |
-| `agent` | 是否启用 Agent 模式 | `true` |
-| `agent_workspace` | Agent 工作空间路径 | `~/cow` |
-| `agent_max_context_tokens` | 最大上下文 tokens | `40000` |
-| `agent_max_context_turns` | 最大上下文记忆轮次 | `30` |
-| `agent_max_steps` | 单次任务最大决策步数 | `15` |
+| 参数 | 环境变量 | 说明 | 默认值 |
+| --- | --- | --- | --- |
+| `channel_type` | `CHANNEL_TYPE` | 接入渠道类型 | `web` |
+| `model` | `MODEL` | 模型名称 | `MiniMax-M2.5` |
+| `agent` | `AGENT` | 是否启用 Agent 模式 | `true` |
+| `agent_workspace` | - | Agent 工作空间路径 | `~/cow` |
+| `agent_max_context_tokens` | `AGENT_MAX_CONTEXT_TOKENS` | 最大上下文 tokens | `40000` |
+| `agent_max_context_turns` | `AGENT_MAX_CONTEXT_TURNS` | 最大上下文记忆轮次 | `30` |
+| `agent_max_steps` | `AGENT_MAX_STEPS` | 单次任务最大决策步数 | `15` |
- 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。
+ 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。Docker 部署时,配置项名称需转为大写环境变量格式。
diff --git a/docs/guide/quick-start.mdx b/docs/guide/quick-start.mdx
index 428b2293..c0c6d0c0 100644
--- a/docs/guide/quick-start.mdx
+++ b/docs/guide/quick-start.mdx
@@ -18,7 +18,7 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
1. 检查 Python 环境(需要 Python 3.7+)
2. 安装必要工具(git、curl 等)
3. 克隆项目代码到 `~/chatgpt-on-wechat`
-4. 安装 Python 依赖
+4. 安装 Python 依赖和 Cow CLI
5. 引导配置 AI 模型和通信渠道
6. 启动服务
@@ -26,14 +26,20 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
## 管理命令
-安装完成后,可使用以下命令管理服务:
+安装完成后,使用 `cow` CLI 管理服务:
| 命令 | 说明 |
| --- | --- |
-| `./run.sh start` | 启动服务 |
-| `./run.sh stop` | 停止服务 |
-| `./run.sh restart` | 重启服务 |
-| `./run.sh status` | 查看运行状态 |
-| `./run.sh logs` | 查看实时日志 |
-| `./run.sh config` | 重新配置 |
-| `./run.sh update` | 更新项目代码 |
+| `cow start` | 启动服务 |
+| `cow stop` | 停止服务 |
+| `cow restart` | 重启服务 |
+| `cow status` | 查看运行状态 |
+| `cow logs` | 查看实时日志 |
+| `cow update` | 更新代码并重启 |
+| `cow install-browser` | 安装浏览器工具依赖 |
+
+更多命令和用法参考 [命令文档](/commands/index)。
+
+
+ 如果 `cow` 命令不可用,也可以使用 `./run.sh <命令>` 作为替代(如 `./run.sh start`、`./run.sh stop`),二者功能等效。
+
diff --git a/docs/guide/upgrade.mdx b/docs/guide/upgrade.mdx
index 24f65b15..48b45005 100644
--- a/docs/guide/upgrade.mdx
+++ b/docs/guide/upgrade.mdx
@@ -3,20 +3,25 @@ title: 更新升级
description: CowAgent 的升级方式说明
---
-## 脚本升级(推荐)
+## 命令升级(推荐)
-如果使用 `run.sh` 管理服务,在项目根目录执行以下命令即可一键升级:
+使用 `cow update` 一键完成代码更新和服务重启:
```bash
-./run.sh update
+cow update
```
该命令会自动完成以下流程:
-1. 停止当前运行的服务
-2. 拉取最新代码
-3. 重新检查依赖
-4. 启动服务
+1. 拉取最新代码(`git pull`)
+2. 停止当前服务
+3. 更新 Python 依赖
+4. 重新安装 CLI
+5. 启动服务
+
+
+ 如果未安装 Cow CLI,也可以使用 `./run.sh update` 完成相同操作。
+
## 手动升级
@@ -25,15 +30,19 @@ description: CowAgent 的升级方式说明
```bash
git pull
pip3 install -r requirements.txt
+pip3 install -e .
```
更新完成后重启服务:
```bash
-# 如果使用 run.sh 管理
+# 使用 Cow CLI
+cow restart
+
+# 或使用 run.sh
./run.sh restart
-# 如果使用 nohup 直接运行
+# 或使用 nohup 直接运行
kill $(ps -ef | grep app.py | grep -v grep | awk '{print $2}')
nohup python3 app.py & tail -f nohup.out
```
diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx
index fdeb0a08..4cb3ef62 100644
--- a/docs/intro/index.mdx
+++ b/docs/intro/index.mdx
@@ -33,10 +33,16 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作。
-
+
+ 内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 可自主调用工具完成复杂任务。
+
+
+ 提供终端 CLI 和对话中的命令,支持进程管理、技能安装、配置修改、上下文查看等常用操作。
+
+
支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao 等国内外主流模型厂商。
-
+
支持运行在本地计算机或服务器,可集成到微信、网页、飞书、钉钉、微信公众号、企业微信应用中使用。
diff --git a/docs/ja/README.md b/docs/ja/README.md
index 6c444f9d..f420b32e 100644
--- a/docs/ja/README.md
+++ b/docs/ja/README.md
@@ -20,13 +20,14 @@
> CowAgentは、すぐに使えるAIスーパーアシスタントであると同時に、高い拡張性を持つAgentフレームワークでもあります。新しいモデルインターフェース、チャネル、組み込みツール、Skillシステムを拡張することで、さまざまなカスタマイズニーズに柔軟に対応できます。
-- ✅ **自律的タスク計画**: 複雑なタスクを理解し、自律的に実行計画を立て、目標達成までツールを呼び出しながら継続的に思考します。ツールを通じてファイル、ターミナル、ブラウザ、スケジューラなどのシステムリソースにアクセスできます。
+- ✅ **自律的タスク計画**: 複雑なタスクを理解し、自律的に実行計画を立て、目標達成までツールを呼び出しながら継続的に思考します。
- ✅ **長期記憶**: 会話の記憶をローカルファイルやデータベースに自動的に永続化します。コアメモリとデイリーメモリを含み、キーワード検索やベクトル検索に対応しています。
-- ✅ **Skillシステム**: Skillの作成・実行エンジンを実装しており、複数の組み込みSkillを備え、自然言語での会話を通じたカスタムSkillの開発もサポートしています。
+- ✅ **Skillシステム**: Skillの作成・実行エンジンを実装。[Skill Hub](https://skills.cowagent.ai)、GitHubなどからSkillをインストールでき、会話を通じたカスタムSkill作成もサポートしています。
+- ✅ **ツールシステム**: ファイル読み書き、ターミナル実行、ブラウザ操作、スケジュールタスク、メッセージ送信などの組み込みツールを提供。Agentが自律的に呼び出して複雑なタスクを完了します。
+- ✅ **CLIシステム**: ターミナルコマンドとチャットコマンドを提供し、プロセス管理、Skillインストール、設定変更などの操作をサポートします。
- ✅ **マルチモーダルメッセージ**: テキスト、画像、音声、ファイルなど、さまざまなメッセージタイプの解析・処理・生成・送信に対応しています。
- ✅ **複数モデル対応**: OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen、Kimi、Doubaoなど、主要なモデルプロバイダーに対応しています。
- ✅ **マルチプラットフォームデプロイ**: ローカルPCやサーバー上で実行でき、WeChat、Web、Feishu、DingTalk、WeChat公式アカウント、WeComアプリケーションに統合可能です。
-- ✅ **ナレッジベース**: [LinkAI](https://link-ai.tech) プラットフォームを通じて、企業向けナレッジベース機能を統合できます。
## 免責事項
@@ -66,7 +67,7 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
実行後、デフォルトでWebサービスが起動します。`http://localhost:9899/chat` にアクセスしてチャットを開始できます。
-スクリプトの使い方: [ワンクリックインストール](https://docs.cowagent.ai/en/guide/quick-start)
+スクリプトの使い方: [ワンクリックインストール](https://docs.cowagent.ai/ja/guide/quick-start)。インストール後は `cow start`、`cow stop` などの [CLI コマンド](https://docs.cowagent.ai/ja/commands/index)でサービスを管理できます。
### 手動インストール
@@ -84,7 +85,25 @@ pip3 install -r requirements.txt
pip3 install -r requirements-optional.txt # 任意ですが推奨
```
-**3. 設定**
+**3. Cow CLI のインストール(推奨)**
+
+```bash
+pip3 install -e .
+```
+
+インストール後、`cow` コマンドでサービス管理(起動、停止、更新など)やSkill管理ができます。[コマンドドキュメント](https://docs.cowagent.ai/ja/commands/index)を参照してください。
+
+**4. ブラウザのインストール(任意)**
+
+Agentにブラウザ操作(Webページへのアクセス、フォーム入力など)が必要な場合:
+
+```bash
+cow install-browser
+```
+
+`playwright` と Chromium を自動インストールします。[ブラウザツールドキュメント](https://docs.cowagent.ai/ja/tools/browser)を参照してください。
+
+**5. 設定**
```bash
cp config-template.json config.json
@@ -92,13 +111,25 @@ cp config-template.json config.json
`config.json` にモデルのAPIキーとチャネルタイプを記入してください。詳細は[設定ドキュメント](https://docs.cowagent.ai/en/guide/manual-install)を参照してください。
-**4. 実行**
+**6. 実行**
```bash
-python3 app.py
+cow start # 推奨、Cow CLI が必要
+python3 app.py # または直接実行
```
-サーバーでバックグラウンド実行する場合:
+サーバーデプロイでは、`cow` コマンドでサービスを管理できます:
+
+```bash
+cow start # バックグラウンドで起動
+cow stop # サービス停止
+cow restart # サービス再起動
+cow status # 実行状態を確認
+cow logs # ログを表示
+cow update # 最新コードを取得して再起動
+```
+
+または従来の方法で実行:
```bash
nohup python3 app.py & tail -f nohup.out
@@ -195,7 +226,7 @@ FAQ:
## 🛠️ コントリビューション
-新しいチャネルの追加を歓迎します。[Feishuチャネル](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py)を参考にしてください。また、新しいSkillのコントリビューションも歓迎します。[Skill Creatorドキュメント](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)を参照してください。
+新しいチャネルの追加を歓迎します。[Feishuチャネル](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py)を参考にしてください。また、新しいSkillのコントリビューションも歓迎します。[Skill作成ドキュメント](https://docs.cowagent.ai/ja/skills/create)を参照してください。
## ✉ お問い合わせ
diff --git a/docs/ja/commands/general.mdx b/docs/ja/commands/general.mdx
new file mode 100644
index 00000000..ede186c5
--- /dev/null
+++ b/docs/ja/commands/general.mdx
@@ -0,0 +1,101 @@
+---
+title: 汎用コマンド
+description: ステータスの確認、設定管理、コンテキスト制御などのよく使うコマンド
+---
+
+以下のコマンドはチャットで `/` プレフィックス、ターミナルで `cow` プレフィックスで使用できます(一部はチャット専用)。
+
+
+ Web コンソールでは `/` を入力すると自動補完メニューが表示され、キーボードのナビゲーションと Tab 補完に対応しています。
+
+
+## help
+
+使用可能なすべてのコマンドのヘルプ情報を表示します。
+
+```text
+/help
+```
+
+## status
+
+現在のセッションとサービスの実行状態を表示します。プロセス情報、モデル設定、メッセージ数、読み込み済みスキル数を含みます。
+
+```text
+/status
+```
+
+## config
+
+実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
+
+**すべての設定項目を表示:**
+
+```text
+/config
+```
+
+**単一の設定項目を表示:**
+
+```text
+/config model
+```
+
+**設定項目を変更:**
+
+```text
+/config model deepseek-chat
+```
+
+**変更可能な設定項目:**
+
+| 項目 | 説明 | 例 |
+| --- | --- | --- |
+| `model` | AI モデル名 | `deepseek-chat` |
+| `agent_max_context_tokens` | 最大コンテキストトークン数 | `40000` |
+| `agent_max_context_turns` | 最大コンテキスト記憶ターン数 | `30` |
+| `agent_max_steps` | タスクごとの最大判断ステップ数 | `15` |
+
+
+ `model` を変更すると、システムが対応するモデル API を自動的にマッチングします。設定は `config.json` に永続的に保存されます。
+
+
+## context
+
+現在のセッションのコンテキスト統計情報を表示します。メッセージ数やコンテンツの長さを含みます。
+
+```text
+/context
+```
+
+**現在のセッションのコンテキストをクリア:**
+
+```text
+/context clear
+```
+
+
+ コンテキストをクリアすると、Agent は以前の会話内容を「忘れます」。話題の切り替えやコンテキストスペースの解放に便利です。
+
+
+## logs
+
+最近のサービスログを表示します。デフォルトでは最近の 20 行を表示し、最大 50 行です。
+
+```text
+/logs
+```
+
+**行数を指定:**
+
+```text
+/logs 50
+```
+
+## version
+
+現在の CowAgent のバージョンを表示します。
+
+```text
+/version
+```
diff --git a/docs/ja/commands/index.mdx b/docs/ja/commands/index.mdx
new file mode 100644
index 00000000..a9f9d2b1
--- /dev/null
+++ b/docs/ja/commands/index.mdx
@@ -0,0 +1,84 @@
+---
+title: コマンド概要
+description: CowAgent コマンドシステム — ターミナル CLI とチャットコマンド
+---
+
+CowAgent は2つのコマンド操作方法を提供しています:
+
+- **ターミナル CLI** — システムターミナルで `cow <コマンド>` を実行し、サービス管理やスキル管理を行います
+- **チャットコマンド** — 会話で `/<コマンド>` または `cow <コマンド>` を入力し、ステータス確認、スキル管理、設定変更を行います
+
+## Cow CLI
+
+ワンクリックインストールスクリプトでデプロイすると、`cow` コマンドが自動的に利用可能になります。手動インストールの場合は以下を実行してください:
+
+```bash
+pip install -e .
+```
+
+インストール後、任意の場所で `cow` コマンドを使用できます:
+
+```bash
+cow help
+```
+
+出力例:
+
+```
+🐮 CowAgent CLI
+
+Usage: cow
+
+Service:
+ start Start the CowAgent service
+ stop Stop the CowAgent service
+ restart Restart the CowAgent service
+ update Update code and restart service
+ status Show service status
+ logs View service logs
+
+Skills:
+ skill Manage skills (list / search / install / uninstall ...)
+
+Others:
+ help Show this help message
+ version Show version
+```
+
+## チャットコマンド
+
+Web コンソールや接続されたチャネルの会話で `/` を入力すると、コマンドの候補が表示されます。使用可能なコマンド:
+
+| コマンド | 説明 |
+| --- | --- |
+| `/help` | コマンドヘルプを表示 |
+| `/status` | サービスの状態と設定を表示 |
+| `/config` | 実行時設定の表示・変更 |
+| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
+| `/context` | 現在のセッションのコンテキスト情報を表示 |
+| `/context clear` | 現在のセッションのコンテキストをクリア |
+| `/logs` | 最近のログを表示 |
+| `/version` | バージョン番号を表示 |
+
+
+ `/start`、`/stop`、`/restart` などのサービス管理コマンドは、プロセス操作を伴うため、ターミナルでの使用を案内します。
+
+
+## コマンド対応表
+
+| コマンド | ターミナル (`cow`) | チャット (`/`) |
+| --- | :---: | :---: |
+| help | ✓ | ✓ |
+| version | ✓ | ✓ |
+| status | ✓ | ✓ |
+| logs | ✓ | ✓ |
+| config | ✗ | ✓ |
+| context | — | ✓ |
+| skill(サブコマンド) | ✓ | ✓ |
+| start / stop / restart | ✓ | ✗ |
+| update | ✓ | ✗ |
+| install-browser | ✓ | ✗ |
+
+
+ `context` はターミナルではチャットでの使用を案内するのみです。`config` はチャットでのみ利用可能です。
+
diff --git a/docs/ja/commands/process.mdx b/docs/ja/commands/process.mdx
new file mode 100644
index 00000000..4600f2b8
--- /dev/null
+++ b/docs/ja/commands/process.mdx
@@ -0,0 +1,123 @@
+---
+title: プロセス管理
+description: cow コマンドで CowAgent プロセスのライフサイクルを管理
+---
+
+プロセス管理コマンドは CowAgent バックグラウンドプロセスのライフサイクルを制御します。これらのコマンドはターミナルでのみ使用可能です。
+
+## start
+
+CowAgent サービスを起動します。デフォルトではバックグラウンドデーモンとして実行され、自動的にログを表示します。
+
+```bash
+cow start
+```
+
+**オプション:**
+
+| オプション | 説明 |
+| --- | --- |
+| `-f`, `--foreground` | フォアグラウンドで実行(デーモンとして起動しない) |
+| `--no-logs` | 起動後にログを自動表示しない |
+
+## stop
+
+実行中の CowAgent サービスを停止します。
+
+```bash
+cow stop
+```
+
+## restart
+
+CowAgent サービスを再起動します(停止してから起動)。
+
+```bash
+cow restart
+```
+
+**オプション:**
+
+| オプション | 説明 |
+| --- | --- |
+| `--no-logs` | 再起動後にログを自動表示しない |
+
+## update
+
+コードを更新してサービスを再起動します。自動的に以下を実行します:
+
+1. 最新コードをプル(`git pull`)
+2. 現在のサービスを停止
+3. Python 依存パッケージを更新
+4. CLI を再インストール
+5. サービスを起動
+
+```bash
+cow update
+```
+
+
+ `git pull` が失敗した場合(ローカルの未コミットの変更がある場合など)、更新は中止され、サービスには影響しません。
+
+
+## status
+
+CowAgent サービスの実行状態を確認します。プロセス情報、バージョン、現在のモデルとチャネルの設定を含みます。
+
+```bash
+cow status
+```
+
+## logs
+
+サービスログを表示します。
+
+```bash
+cow logs
+```
+
+**オプション:**
+
+| オプション | 説明 | デフォルト値 |
+| --- | --- | --- |
+| `-f`, `--follow` | ログ出力を継続的に追跡 | いいえ |
+| `-n`, `--lines` | 最近の N 行を表示 | 50 |
+
+例:
+
+```bash
+# 最近の100行を表示
+cow logs -n 100
+
+# ログを継続的に追跡
+cow logs -f
+```
+
+## install-browser
+
+[ブラウザツール](/ja/tools/browser)のために Playwright と Chromium ブラウザをインストールします。
+
+```bash
+cow install-browser
+```
+
+
+ ブラウザツール(Web ブラウジング、スクリーンショットなど)を使用する場合にのみ必要です。
+
+
+## run.sh との互換性
+
+Cow CLI がインストールされていない場合は、`run.sh` でサービスを管理できます:
+
+| cow コマンド | run.sh 相当 |
+| --- | --- |
+| `cow start` | `./run.sh start` |
+| `cow stop` | `./run.sh stop` |
+| `cow restart` | `./run.sh restart` |
+| `cow update` | `./run.sh update` |
+| `cow status` | `./run.sh status` |
+| `cow logs` | `./run.sh logs` |
+
+
+ `cow` コマンドの使用を推奨します。よりシンプルな構文と豊富な機能を提供します。ワンクリックインストールスクリプトで自動的にインストールされます。
+
diff --git a/docs/ja/commands/skill.mdx b/docs/ja/commands/skill.mdx
new file mode 100644
index 00000000..9e5c1ada
--- /dev/null
+++ b/docs/ja/commands/skill.mdx
@@ -0,0 +1,192 @@
+---
+title: スキル管理
+description: コマンドでスキルのインストール、アンインストール、有効化、無効化、管理を行う
+---
+
+スキル管理コマンドは CowAgent のスキルのインストール、検索、管理に使用します。チャットでは `/skill <サブコマンド>`、ターミナルでは `cow skill <サブコマンド>` を使用します。
+
+## list
+
+インストール済みスキルとその状態を一覧表示します。
+
+
+```text チャット
+/skill list
+```
+
+```bash ターミナル
+cow skill list
+```
+
+
+**スキル広場を閲覧**(利用可能なすべてのスキルを表示):
+
+
+```text チャット
+/skill list --remote
+```
+
+```bash ターミナル
+cow skill list --remote
+```
+
+
+**オプション:**
+
+| オプション | 説明 | デフォルト値 |
+| --- | --- | --- |
+| `--remote`, `-r` | Skill Hub のリモートスキルリストを閲覧 | いいえ |
+| `--page` | リモートリストのページ番号 | 1 |
+
+## search
+
+スキル広場でスキルを検索します。
+
+
+```text チャット
+/skill search pptx
+```
+
+```bash ターミナル
+cow skill search pptx
+```
+
+
+## install
+
+統一された `install` コマンドで、Cow スキル広場、GitHub、ClawHub、任意の URL(zip アーカイブ、SKILL.md リンク)からスキルをワンクリックでインストールできます。手動ダウンロードや設定は不要です。
+
+**スキル広場からインストール(推奨):**
+
+
+```text チャット
+/skill install pptx
+```
+
+```bash ターミナル
+cow skill install pptx
+```
+
+
+**GitHub からインストール:**
+
+
+```text チャット
+# リポジトリ内のすべてのスキルをインストール(SKILL.md を含むサブディレクトリを自動検出)
+/skill install larksuite/cli
+
+# サブディレクトリを指定して単一スキルをインストール
+/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+
+# # でサブディレクトリを指定
+/skill install larksuite/cli#skills/lark-minutes
+```
+
+```bash ターミナル
+# リポジトリ内のすべてのスキルをインストール(SKILL.md を含むサブディレクトリを自動検出)
+cow skill install larksuite/cli
+
+# サブディレクトリを指定して単一スキルをインストール
+cow skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+
+# # でサブディレクトリを指定
+cow skill install larksuite/cli#skills/lark-minutes
+```
+
+
+完全な GitHub URL と `owner/repo` 省略形に対応しています。モノリポ(1つのリポジトリに複数のスキル)の場合、サブディレクトリを省略するとすべてのスキルを自動検出して一括インストールします。サブディレクトリを指定した場合は、そのスキルのみをインストールします。
+
+**ClawHub からインストール:**
+
+
+```text チャット
+/skill install clawhub:baidu-search
+```
+
+```bash ターミナル
+cow skill install clawhub:baidu-search
+```
+
+
+**URL からインストール:**
+
+
+```text チャット
+# zip アーカイブからインストール(単一またはバッチ)
+/skill install https://cdn.link-ai.tech/skills/pptx.zip
+
+# SKILL.md リンクからインストール
+/skill install https://example.com/path/to/SKILL.md
+```
+
+```bash ターミナル
+# zip アーカイブからインストール(単一またはバッチ)
+cow skill install https://cdn.link-ai.tech/skills/pptx.zip
+
+# SKILL.md リンクからインストール
+cow skill install https://example.com/path/to/SKILL.md
+```
+
+
+zip / tar.gz アーカイブ URL からのインストールに対応しており、自動的に解凍して `SKILL.md` を含むディレクトリを検出し、単一またはバッチインストールをサポートします。`SKILL.md` ファイルの URL から直接インストールすることもでき、スキル名と説明を自動的に解析します。
+
+## uninstall
+
+インストール済みスキルをアンインストールします。
+
+
+```text チャット
+/skill uninstall pptx
+```
+
+```bash ターミナル
+cow skill uninstall pptx
+```
+
+
+
+ アンインストールするとスキルディレクトリ内のすべてのファイルが削除されます。この操作は元に戻せません。
+
+
+## enable / disable
+
+スキルの有効化・無効化を行います。無効化されたスキルは Agent から呼び出されません。
+
+
+```text チャット
+/skill enable pptx
+/skill disable pptx
+```
+
+```bash ターミナル
+cow skill enable pptx
+cow skill disable pptx
+```
+
+
+## info
+
+インストール済みスキルの詳細情報を表示します。`SKILL.md` のプレビューを含みます。
+
+
+```text チャット
+/skill info pptx
+```
+
+```bash ターミナル
+cow skill info pptx
+```
+
+
+## スキルのソース
+
+インストールされたスキルはソース情報を記録しており、`/skill list` で確認できます:
+
+| ソース | 説明 |
+| --- | --- |
+| `builtin` | プロジェクト内蔵スキル |
+| `cowhub` | CowAgent Skill Hub からインストール |
+| `github` | GitHub URL から直接インストール |
+| `clawhub` | ClawHub からインストール |
+| `url` | SKILL.md URL からインストール |
+| `local` | ローカルで作成されたスキル |
diff --git a/docs/ja/guide/manual-install.mdx b/docs/ja/guide/manual-install.mdx
index f75432dc..bffec65b 100644
--- a/docs/ja/guide/manual-install.mdx
+++ b/docs/ja/guide/manual-install.mdx
@@ -30,7 +30,25 @@ pip3 install -r requirements.txt
pip3 install -r requirements-optional.txt
```
-### 3. 設定
+### 3. Cow CLI をインストール
+
+サービスとスキルを管理するためのコマンドラインツールをインストールします:
+
+```bash
+pip3 install -e .
+```
+
+インストール後、`cow` コマンドが使用可能になります:
+
+```bash
+cow help
+```
+
+
+ このステップは推奨です。インストール後、`cow start`、`cow stop`、`cow update` でサービスを管理でき、`cow skill` でスキルを管理できます。CLI をインストールしない場合は、`./run.sh` または `python3 app.py` で実行できます。
+
+
+### 4. 設定
設定テンプレートをコピーして編集します:
@@ -40,22 +58,32 @@ cp config-template.json config.json
`config.json` にモデルの API キー、チャネルタイプ、その他の設定を入力します。詳細は[モデルのドキュメント](/ja/models/index)を参照してください。
-### 4. 実行
+### 5. 実行
-**ローカルで実行:**
+**Cow CLI を使用して実行(推奨):**
+
+```bash
+cow start
+```
+
+**またはローカルでフォアグラウンド実行:**
```bash
python3 app.py
```
-デフォルトではWebサービスが起動します。`http://localhost:9899/chat` にアクセスしてチャットできます。
+デフォルトでは Web コンソールが起動します。`http://localhost:9899` にアクセスしてチャットできます。
-**サーバーでバックグラウンド実行:**
+**サーバーでバックグラウンド実行(CLI 未使用時):**
```bash
nohup python3 app.py & tail -f nohup.out
```
+
+ サーバーにデプロイする場合は、ファイアウォールまたはセキュリティグループでポート `9899` を開放して Web コンソールにアクセスできるようにしてください。セキュリティのため、特定の IP のみにアクセスを制限することを推奨します。
+
+
## Docker によるデプロイ
Docker デプロイでは、ソースコードのクローンや依存パッケージのインストールは不要です。Agent モードを使用する場合は、より広範なシステムアクセスが可能なソースコードによるデプロイを推奨します。
@@ -84,6 +112,10 @@ sudo docker compose up -d
sudo docker logs -f chatgpt-on-wechat
```
+
+ サーバーにデプロイする場合は、ファイアウォールまたはセキュリティグループでポート `9899` を開放して Web コンソールにアクセスできるようにしてください。セキュリティのため、特定の IP のみにアクセスを制限することを推奨します。
+
+
## 主要な設定項目
```json
diff --git a/docs/ja/guide/quick-start.mdx b/docs/ja/guide/quick-start.mdx
index 93378278..147170da 100644
--- a/docs/ja/guide/quick-start.mdx
+++ b/docs/ja/guide/quick-start.mdx
@@ -18,22 +18,27 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
1. Python環境の確認(Python 3.7以上が必要)
2. 必要なツールのインストール(git、curlなど)
3. プロジェクトを `~/chatgpt-on-wechat` にクローン
-4. Pythonの依存パッケージをインストール
+4. Pythonの依存パッケージと Cow CLI をインストール
5. AIモデルとチャネルの対話式設定
6. サービスの起動
-デフォルトでは、インストール後にWebサービスが起動します。`http://localhost:9899/chat` にアクセスしてチャットを開始できます。
+デフォルトでは、インストール後に Web コンソールが起動します。`http://localhost:9899` にアクセスしてチャットを開始できます。
## 管理コマンド
-インストール後、以下のコマンドでサービスを管理できます:
+インストール後、`cow` コマンドでサービスを管理できます:
| コマンド | 説明 |
| --- | --- |
-| `./run.sh start` | サービスを起動 |
-| `./run.sh stop` | サービスを停止 |
-| `./run.sh restart` | サービスを再起動 |
-| `./run.sh status` | 実行状態を確認 |
-| `./run.sh logs` | リアルタイムログを表示 |
-| `./run.sh config` | 再設定 |
-| `./run.sh update` | プロジェクトコードを更新 |
+| `cow start` | サービスを起動 |
+| `cow stop` | サービスを停止 |
+| `cow restart` | サービスを再起動 |
+| `cow status` | 実行状態を確認 |
+| `cow logs` | リアルタイムログを表示 |
+| `cow update` | コードを更新して再起動 |
+
+詳細は[コマンドドキュメント](/ja/commands/index)を参照してください。
+
+
+ `cow` コマンドが利用できない場合は、`./run.sh <コマンド>`(例:`./run.sh start`、`./run.sh stop`)で代替できます。機能は同等です。
+
diff --git a/docs/ja/guide/upgrade.mdx b/docs/ja/guide/upgrade.mdx
index fcb28cb2..5eb1df7c 100644
--- a/docs/ja/guide/upgrade.mdx
+++ b/docs/ja/guide/upgrade.mdx
@@ -3,20 +3,25 @@ title: アップデート
description: CowAgent のアップグレード方法
---
-## スクリプトによるアップグレード(推奨)
+## コマンドによるアップグレード(推奨)
-`run.sh` でサービスを管理している場合、以下のコマンドでワンクリックアップグレードできます:
+`cow update` でコードの更新とサービスの再起動をワンクリックで実行できます:
```bash
-./run.sh update
+cow update
```
このコマンドは以下のフローを自動的に実行します:
-1. 現在実行中のサービスを停止
-2. 最新コードをプル
-3. 依存関係を再チェック
-4. サービスを起動
+1. 最新コードをプル(`git pull`)
+2. 現在のサービスを停止
+3. Python 依存パッケージを更新
+4. CLI を再インストール
+5. サービスを起動
+
+
+ Cow CLI がインストールされていない場合は、`./run.sh update` でも同様の操作が可能です。
+
## 手動アップグレード
@@ -25,15 +30,19 @@ description: CowAgent のアップグレード方法
```bash
git pull
pip3 install -r requirements.txt
+pip3 install -e .
```
更新完了後、サービスを再起動します:
```bash
-# run.sh で管理している場合
+# Cow CLI を使用
+cow restart
+
+# または run.sh を使用
./run.sh restart
-# nohup で直接実行している場合
+# または nohup で直接実行
kill $(ps -ef | grep app.py | grep -v grep | awk '{print $2}')
nohup python3 app.py & tail -f nohup.out
```
diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx
index 57979925..14047e20 100644
--- a/docs/ja/intro/index.mdx
+++ b/docs/ja/intro/index.mdx
@@ -28,6 +28,12 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
テキスト、画像、音声、ファイルなどのメッセージタイプの解析、処理、生成、送信をサポートします。
+
+ ファイル読み書き、ターミナル実行、ブラウザ操作、スケジュールタスク、メッセージ送信などの組み込みツールを提供。Agent が自律的にツールを呼び出して複雑なタスクを完了します。
+
+
+ ターミナル CLI とチャット内コマンドを提供し、プロセス管理、Skill インストール、設定変更、コンテキスト確認などの一般的な操作をサポートします。
+
OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen、Kimi、Doubao など、主要なモデルプロバイダーをサポートしています。
diff --git a/docs/ja/memory/context.mdx b/docs/ja/memory/context.mdx
new file mode 100644
index 00000000..6694c3c2
--- /dev/null
+++ b/docs/ja/memory/context.mdx
@@ -0,0 +1,80 @@
+---
+title: 短期記憶
+description: 会話コンテキスト — メッセージ管理、圧縮戦略、コンテキスト操作
+---
+
+会話コンテキストは Agent の短期記憶であり、現在のセッション内のすべてのメッセージ(ユーザー入力、Agent の返信、ツール呼び出しと結果)を含みます。適切なコンテキスト管理は、Agent の推論品質とコスト制御にとって重要です。
+
+## コンテキストの構造
+
+各会話ターンは以下で構成されます:
+
+```
+ユーザーメッセージ → Agent の思考 → ツール呼び出し → ツール結果 → ... → Agent の最終返信
+```
+
+1 つのターンには複数のツール呼び出しが含まれる場合があります(`agent_max_steps` で制御)。すべてのツール呼び出しと結果は、圧縮またはトリミングされるまでコンテキストに保持されます。
+
+## 主要な設定
+
+| パラメータ | 説明 | デフォルト値 |
+| --- | --- | --- |
+| `agent_max_context_tokens` | コンテキストの最大トークン予算 | `50000` |
+| `agent_max_context_turns` | コンテキストの最大会話ターン数 | `20` |
+| `agent_max_steps` | ターンあたりの最大判断ステップ数(ツール呼び出し回数) | `15` |
+
+`config.json` またはチャットの `/config` コマンドで設定できます。
+
+## 圧縮戦略
+
+コンテキストが制限を超えた場合、システムは自動的に圧縮を実行してスペースを解放します。このプロセスには複数の段階があります:
+
+### 1. ツール結果の切り詰め
+
+各判断ループの開始前に、過去のターンのツール呼び出し結果を確認します。**20,000 文字** を超えるツール結果は切り詰められ、先頭と末尾のみが保持されます。現在のターンの結果は影響を受けません。
+
+### 2. ターンのトリミング
+
+会話ターン数が `agent_max_context_turns` を超えた場合:
+
+- **最も古い半分** の完全なターンがトリミングされます(ツール呼び出しチェーンの完全性を保証)
+- トリミングされたメッセージは LLM によって要約され、**日次記憶ファイルに書き込まれます**
+- 残りのターンはそのまま保持されます
+
+### 3. トークン予算のトリミング
+
+ターンのトリミング後、トークン数がまだ予算を超えている場合:
+
+- **5 ターン未満の場合**:すべてのターンで**テキスト圧縮**を実行 — 各ターンは最初のユーザーテキストと最後の Agent 返信のみを保持し、中間のツール呼び出しチェーンを削除
+- **5 ターン以上の場合**:**前半のターン**を再度トリミングし、破棄されたコンテンツも記憶に書き込まれます
+
+### 4. オーバーフロー緊急処理
+
+モデル API がコンテキストオーバーフローエラーを返した場合:
+
+1. 現在のすべてのメッセージを要約して記憶に書き込み
+2. 積極的なトリミングを適用(ツール結果は 10K 文字に制限、ユーザーテキストは 10K、最大 5 ターン)
+3. それでもオーバーフローする場合は、会話コンテキスト全体をクリア
+
+## セッションの永続化
+
+会話メッセージはローカルデータベースに永続化され、サービス再起動後に自動的に復元されます。復元戦略:
+
+- 最近の **`max(3, max_context_turns / 6)`** ターンを復元
+- 各ターンの**ユーザーテキストと Agent の最終返信のみ**を保持し、中間のツール呼び出しチェーンは復元しません
+- **30 日** を超える過去のセッションは自動的にクリーンアップされます
+
+## 操作コマンド
+
+チャットで以下のコマンドを使用してコンテキストを管理できます:
+
+| コマンド | 説明 |
+| --- | --- |
+| `/context` | 現在のコンテキスト統計を表示(メッセージ数、ロール分布、合計文字数) |
+| `/context clear` | 現在のセッションコンテキストをクリア |
+| `/config agent_max_context_tokens 80000` | コンテキストトークン予算を調整 |
+| `/config agent_max_context_turns 30` | コンテキストターン上限を調整 |
+
+
+ コンテキストをクリアすると、Agent は以前の会話内容を「忘れます」。すでに長期記憶に書き込まれたコンテンツは、記憶検索を通じて引き続き取得できます。
+
diff --git a/docs/ja/memory.mdx b/docs/ja/memory/index.mdx
similarity index 52%
rename from docs/ja/memory.mdx
rename to docs/ja/memory/index.mdx
index 5e2ac759..cd2fc45f 100644
--- a/docs/ja/memory.mdx
+++ b/docs/ja/memory/index.mdx
@@ -1,25 +1,25 @@
---
-title: 記憶
-description: CowAgent 長期記憶システム
+title: 長期記憶
+description: CowAgent の長期記憶システム — ファイル永続化、自動書き込み、ハイブリッド検索
---
-記憶システムにより、Agent は重要な情報を長期にわたって記憶し、継続的に経験を蓄積し、ユーザーの好みを理解し、真に自律的な思考と継続的な成長を実現できます。
+長期記憶はワークスペースのファイルに保存され、セッション間で永続化されます。Agent は会話中に検索ツールを通じて過去の記憶をオンデマンドで読み込み、コンテキストのトリミング時に会話の要約を自動的に長期記憶に書き込みます。
## 記憶の種類
-### コア記憶 (MEMORY.md)
+### コア記憶(MEMORY.md)
-`~/cow/MEMORY.md` に保存され、長期的なユーザーの好み、重要な決定、主要な事実など、時間が経っても薄れない情報を含みます。毎回の会話ターンでバックグラウンド知識としてシステムプロンプトに自動的に注入されます。
+`~/cow/MEMORY.md` に保存され、長期的なユーザーの好み、重要な決定、主要な事実など、時間が経っても薄れない情報を含みます。Agent はツールを通じてこのファイルを読み書きし、長期的な知識を維持します。
-### 日次記憶 (memory/YYYY-MM-DD.md)
+### 日次記憶(memory/YYYY-MM-DD.md)
`~/cow/memory/` ディレクトリに保存され、日付で命名されます(例:`2026-03-08.md`)。日々の会話の要約と主要なイベントを記録します。空ファイルの生成を避けるため、最初の書き込み時にのみファイルが作成されます。
-## 記憶の書き込み
+## 自動書き込み
-Agent は以下のメカニズムにより、会話内容を日次記憶に自動的に永続化します:
+Agent は以下のメカニズムにより、会話内容を長期記憶に自動的に永続化します:
-- **コンテキストトリミング時** — 会話ターン数またはトークン数が設定上限を超えた場合、コンテキストの古い半分が一括でトリミングされ、破棄されたコンテンツは LLM によって要約されて重要な情報として日次記憶ファイルに書き込まれます
+- **コンテキストトリミング時** — 会話ターン数またはトークン数が設定上限を超えた場合、最も古い半分のコンテキストがトリミングされ、LLM によって要約されて日次記憶ファイルに書き込まれます
- **毎日のスケジュール要約** — 毎日 23:55 に自動的にフル要約がトリガーされ、アクティビティが少ない日でも記憶が保存されます(内容が変更されていない場合はスキップ)
- **API コンテキストオーバーフロー時** — モデル API がコンテキストオーバーフローエラーを返した場合、緊急措置として現在の会話要約が保存されます
@@ -40,27 +40,10 @@ Agent は以下のメカニズムにより、会話内容を日次記憶に自
-## 記憶の検索
-
-記憶システムはハイブリッド検索モードをサポートしています:
-
-- **キーワード検索** — キーワードに基づいて過去の記憶をマッチング
-- **ベクトル検索** — セマンティック類似性検索により、異なる表現でも関連する記憶を発見
-
-Agent は必要に応じて会話中に自動的に記憶検索をトリガーし、関連する過去の情報をコンテキストに組み込みます。コア記憶(`MEMORY.md`)は常にシステムプロンプトに注入され、日次記憶は検索を通じてオンデマンドで読み込まれます。
-
## 設定
-```json
-{
- "agent_workspace": "~/cow",
- "agent_max_context_tokens": 40000,
- "agent_max_context_turns": 20
-}
-```
-
| パラメータ | 説明 | デフォルト |
| --- | --- | --- |
| `agent_workspace` | ワークスペースパス、記憶ファイルはこのディレクトリ配下に保存されます | `~/cow` |
-| `agent_max_context_tokens` | 最大コンテキストトークン数。超過時に半分がトリミングされ、記憶として要約されます | `40000` |
-| `agent_max_context_turns` | 最大コンテキストターン数。超過時に半分がトリミングされ、記憶として要約されます | `20` |
+| `agent_max_context_tokens` | 最大コンテキストトークン数。超過時にトリミングされ、記憶として要約されます | `50000` |
+| `agent_max_context_turns` | 最大コンテキストターン数。超過時にトリミングされ、記憶として要約されます | `20` |
diff --git a/docs/ja/skills/create.mdx b/docs/ja/skills/create.mdx
new file mode 100644
index 00000000..e996cf39
--- /dev/null
+++ b/docs/ja/skills/create.mdx
@@ -0,0 +1,58 @@
+---
+title: スキルの作成
+description: 会話を通じてカスタムスキルを作成
+---
+
+CowAgent には Skill Creator が組み込まれており、自然言語の会話を通じてスキルの作成、インストール、更新を素早く行えます。
+
+## 使い方
+
+会話で作りたいスキルを説明するだけで、Agent が自動的に作成します:
+
+- ワークフローをスキル化:「このデプロイプロセスからスキルを作成して」
+- サードパーティ API の統合:「この API ドキュメントに基づいてスキルを作成して」
+- リモートスキルのインストール:「xxx スキルをインストールして」
+
+## 作成フロー
+
+1. 作成したいスキルを Agent に伝えます
+2. Agent が自動的に `SKILL.md` の説明と実行スクリプトを生成します
+3. スキルはワークスペースの `~/cow/skills/` ディレクトリに保存されます
+4. 以降の会話で Agent が自動的にそのスキルを認識し使用します
+
+
+
+
+
+## SKILL.md のフォーマット
+
+作成されたスキルは標準の SKILL.md フォーマットに従います:
+
+```markdown
+---
+name: my-skill
+description: Brief description of the skill
+metadata:
+ emoji: 🔧
+ requires:
+ bins: ["curl"]
+ env: ["MY_API_KEY"]
+ primaryEnv: "MY_API_KEY"
+---
+
+# My Skill
+
+Detailed instructions...
+```
+
+| フィールド | 説明 |
+| --- | --- |
+| `name` | スキル名。ディレクトリ名と一致する必要があります |
+| `description` | スキルの説明。Agent はこれに基づいて呼び出すかどうかを判断します |
+| `metadata.requires.bins` | 必要なシステムコマンド |
+| `metadata.requires.env` | 必要な環境変数 |
+| `metadata.always` | 常に読み込む(デフォルトは false) |
+
+
+ 詳細は [Skill Creator のドキュメント](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)をご覧ください。
+
diff --git a/docs/ja/skills/image-vision.mdx b/docs/ja/skills/image-vision.mdx
deleted file mode 100644
index 9c7833e2..00000000
--- a/docs/ja/skills/image-vision.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Image Vision
-description: OpenAI の Vision モデルを使用して画像を認識
----
-
-OpenAI の GPT-4 Vision API を使用して画像の内容を分析し、画像内のオブジェクト、テキスト、色などの要素を理解します。
-
-## 依存関係
-
-| 依存関係 | 説明 |
-| --- | --- |
-| `OPENAI_API_KEY` | OpenAI API キー |
-| `curl`, `base64` | システムコマンド(通常プリインストール済み) |
-
-設定方法:
-
-- `env_config` Tool で `OPENAI_API_KEY` を設定
-- または `config.json` で `open_ai_api_key` を設定
-
-## 対応モデル
-
-- `gpt-4.1-mini`(推奨、コストパフォーマンスに優れる)
-- `gpt-4.1`
-
-## 使い方
-
-設定が完了したら、Agent に画像を送信すると自動的に画像認識がトリガーされます。
-
-
-
-
diff --git a/docs/ja/skills/index.mdx b/docs/ja/skills/index.mdx
index c15bf93b..546be8d5 100644
--- a/docs/ja/skills/index.mdx
+++ b/docs/ja/skills/index.mdx
@@ -1,35 +1,32 @@
---
-title: Skill 概要
-description: CowAgent の Skill システム紹介
+title: スキル概要
+description: CowAgent のスキルシステム紹介
---
-Skill は Agent に無限の拡張性を提供します。各 Skill は説明ファイル(`SKILL.md`)、実行スクリプト(任意)、リソース(任意)で構成され、特定のタスクをどのように遂行するかを記述します。
+スキル(Skill)は Agent に無限の拡張性を提供します。各スキルは説明ファイル(`SKILL.md`)、実行スクリプト(任意)、リソース(任意)で構成され、特定のタスクをどのように遂行するかを記述します。
-Skill と Tool の違い:Tool はコードで実装された原子的な操作(例:ファイルの読み書き、コマンドの実行)であるのに対し、Skill は説明ファイルに基づく高レベルなワークフローであり、複数の Tool を組み合わせて複雑なタスクを完遂できます。
+スキルとツールの違い:ツールはコードで実装された原子的な操作(例:ファイルの読み書き、コマンドの実行)であるのに対し、スキルは説明ファイルに基づく高レベルなワークフローであり、複数のツールを組み合わせて複雑なタスクを完遂できます。
-## 組み込み Skill
+## スキルの取得
-プロジェクトの `skills/` ディレクトリに配置されており、依存条件に基づいて自動的に有効化されます:
+CowAgent ではスキルを取得する複数の方法を提供しています:
-| Skill | 説明 | 依存関係 |
-| --- | --- | --- |
-| [`skill-creator`](/ja/skills/skill-creator) | 会話を通じてカスタム Skill を作成 | なし |
-| [`openai-image-vision`](/ja/skills/image-vision) | OpenAI の Vision モデルを使用して画像を認識 | `OPENAI_API_KEY` |
-| [`linkai-agent`](/ja/skills/linkai-agent) | LinkAI プラットフォームの Agent を統合 | `LINKAI_API_KEY` |
-| [`web-fetch`](/ja/skills/web-fetch) | Web ページのテキストコンテンツを取得 | `curl`(デフォルトで有効) |
+- **Cow スキル広場** — `/skill list --remote` でコミュニティスキルを閲覧・インストール
+- **GitHub** — GitHub リポジトリから直接インストール、バッチインストールにも対応
+- **ClawHub** — `/skill install clawhub:名前` で ClawHub のスキルをインストール
+- **URL** — zip アーカイブや SKILL.md リンクからインストール
+- **会話で作成** — 自然言語の会話を通じて Agent にスキルを自動作成させる
-## カスタム Skill
+詳細は[スキルのインストール](/ja/skills/install)と[スキル管理コマンド](/ja/commands/skill)を参照してください。会話を通じて[スキルを作成](/ja/skills/create)することもできます。
-ユーザーが会話を通じて作成し、ワークスペース(`~/cow/skills/`)に保存されます。任意の複雑なビジネスプロセスやサードパーティシステムとの連携を実装できます。
+## スキルの読み込み優先順位
-## Skill の読み込み優先順位
+1. **ワークスペースのスキル**(最高優先):`~/cow/skills/`
+2. **プロジェクト組み込みスキル**(最低優先):`skills/`
-1. **ワークスペースの Skill**(最高優先):`~/cow/skills/`
-2. **プロジェクト組み込み Skill**(最低優先):`skills/`
+同名のスキルは優先順位に従って上書きされます。
-同名の Skill は優先順位に従って上書きされます。
-
-## Skill のファイル構成
+## スキルのファイル構成
```
skills/
@@ -60,8 +57,8 @@ Detailed instructions...
| フィールド | 説明 |
| --- | --- |
-| `name` | Skill 名。ディレクトリ名と一致する必要があります |
-| `description` | Skill の説明。Agent はこれに基づいて呼び出すかどうかを判断します |
+| `name` | スキル名。ディレクトリ名と一致する必要があります |
+| `description` | スキルの説明。Agent はこれに基づいて呼び出すかどうかを判断します |
| `metadata.requires.bins` | 必要なシステムコマンド |
| `metadata.requires.env` | 必要な環境変数 |
| `metadata.always` | 常に読み込む(デフォルトは false) |
diff --git a/docs/ja/skills/install.mdx b/docs/ja/skills/install.mdx
new file mode 100644
index 00000000..168e7cc6
--- /dev/null
+++ b/docs/ja/skills/install.mdx
@@ -0,0 +1,53 @@
+---
+title: スキルのインストール
+description: 統一コマンドで多様なソースからスキルをインストール
+---
+
+CowAgent は統一された `install` コマンドで、**Cow スキル広場、GitHub、ClawHub** および任意の URL からスキルをインストールできます。チャットでは `/skill install`、ターミナルでは `cow skill install` を使用します。
+
+## スキル広場からインストール
+
+スキル広場を閲覧してインストール:
+
+```text
+/skill list --remote
+/skill install pptx
+```
+
+## GitHub からインストール
+
+リポジトリからの一括インストールとサブディレクトリ指定に対応:
+
+```text
+/skill install larksuite/cli
+/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+```
+
+## ClawHub からインストール
+
+```text
+/skill install clawhub:baidu-search
+```
+
+## URL からインストール
+
+zip アーカイブと SKILL.md ファイルリンクに対応:
+
+```text
+/skill install https://cdn.link-ai.tech/skills/pptx.zip
+/skill install https://example.com/path/to/SKILL.md
+```
+
+## スキルの管理
+
+```text
+/skill list # インストール済みスキルを表示
+/skill info pptx # スキルの詳細を表示
+/skill enable pptx # スキルを有効化
+/skill disable pptx # スキルを無効化
+/skill uninstall pptx # スキルをアンインストール
+```
+
+
+ 上記のすべてのコマンドは、ターミナルでは `/skill` を `cow skill` に置き換えて使用できます。完全なコマンドドキュメントは[スキル管理コマンド](/ja/commands/skill)を参照してください。
+
diff --git a/docs/ja/skills/linkai-agent.mdx b/docs/ja/skills/linkai-agent.mdx
deleted file mode 100644
index 537cc96d..00000000
--- a/docs/ja/skills/linkai-agent.mdx
+++ /dev/null
@@ -1,47 +0,0 @@
----
-title: LinkAI Agent
-description: LinkAI プラットフォームのマルチ Agent Skill を統合
----
-
-[LinkAI](https://link-ai.tech/) プラットフォームの Agent を Skill として使用し、マルチ Agent の意思決定を行います。Agent は Agent 名と説明に基づいてインテリジェントに選択し、`app_code` を通じて対応するアプリケーションやワークフローを呼び出します。
-
-## 依存関係
-
-| 依存関係 | 説明 |
-| --- | --- |
-| `LINKAI_API_KEY` | LinkAI プラットフォームの API キー。[コンソール](https://link-ai.tech/console/interface)で作成 |
-| `curl` | システムコマンド(通常プリインストール済み) |
-
-設定方法:
-
-- `env_config` Tool で `LINKAI_API_KEY` を設定
-- または `config.json` で `linkai_api_key` を設定
-
-## Agent の設定
-
-`skills/linkai-agent/config.json` で利用可能な Agent を追加します:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI Customer Support",
- "app_description": "Select this assistant only when the user needs help with LinkAI platform questions"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "Content Creator",
- "app_description": "Use this assistant only when the user needs to create images or videos"
- }
- ]
-}
-```
-
-## 使い方
-
-設定が完了すると、Agent はユーザーの質問に基づいて適切な LinkAI Agent を自動的に選択します。
-
-
-
-
diff --git a/docs/ja/skills/skill-creator.mdx b/docs/ja/skills/skill-creator.mdx
deleted file mode 100644
index 1bc35a9b..00000000
--- a/docs/ja/skills/skill-creator.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Skill Creator
-description: 会話を通じてカスタム Skill を作成
----
-
-自然言語の会話を通じて、Skill の作成、インストール、更新を素早く行えます。
-
-## 依存関係
-
-追加の依存関係は不要で、常に利用可能です。
-
-## 使い方
-
-- ワークフローを Skill 化:「このデプロイプロセスから Skill を作成して」
-- サードパーティ API の統合:「この API ドキュメントに基づいて Skill を作成して」
-- リモート Skill のインストール:「xxx Skill をインストールして」
-
-## 作成フロー
-
-1. 作成したい Skill を Agent に伝えます
-2. Agent が自動的に `SKILL.md` の説明と実行スクリプトを生成します
-3. Skill はワークスペースの `~/cow/skills/` ディレクトリに保存されます
-4. 以降の会話で Agent が自動的にその Skill を認識し使用します
-
-
-
-
-
-
- 詳細は [Skill Creator のドキュメント](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)をご覧ください。
-
diff --git a/docs/ja/skills/web-fetch.mdx b/docs/ja/skills/web-fetch.mdx
deleted file mode 100644
index 8712e86a..00000000
--- a/docs/ja/skills/web-fetch.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Web Fetch
-description: Web ページのテキストコンテンツを取得
----
-
-curl を使用して Web ページを取得し、読み取り可能なテキストコンテンツを抽出します。ブラウザ自動化を必要としない軽量な Web アクセス方法です。
-
-## 依存関係
-
-| 依存関係 | 説明 |
-| --- | --- |
-| `curl` | システムコマンド(通常プリインストール済み) |
-
-この Skill は `always: true` が設定されており、システムに `curl` コマンドがあればデフォルトで有効になります。
-
-## 使い方
-
-Agent が URL からコンテンツを取得する必要がある場合に自動的に呼び出されます。追加の設定は不要です。
-
-## browser Tool との比較
-
-| 機能 | web-fetch (Skill) | browser (Tool) |
-| --- | --- | --- |
-| 依存関係 | curl のみ | browser-use + playwright |
-| JS レンダリング | 非対応 | 対応 |
-| ページ操作 | 非対応 | クリック、入力などに対応 |
-| 最適な用途 | 静的ページのテキスト | 動的な Web ページ |
-
-
- ほとんどの Web コンテンツ取得シナリオでは、web-fetch で十分です。JS レンダリングやページ操作が必要な場合にのみ browser Tool を使用してください。
-
diff --git a/docs/memory/context.mdx b/docs/memory/context.mdx
new file mode 100644
index 00000000..deff8585
--- /dev/null
+++ b/docs/memory/context.mdx
@@ -0,0 +1,80 @@
+---
+title: 短期记忆
+description: 对话上下文 — 消息管理、压缩策略和上下文操作
+---
+
+对话上下文是 Agent 的短期记忆,包含当前会话中的所有消息(用户输入、Agent 回复、工具调用及结果)。合理管理上下文对于 Agent 的推理质量和成本控制至关重要。
+
+## 上下文结构
+
+每一轮对话由以下消息组成:
+
+```
+用户消息 → Agent 思考 → 工具调用 → 工具结果 → ... → Agent 最终回复
+```
+
+一轮中可能包含多次工具调用(Agent 的决策步数由 `agent_max_steps` 控制),所有工具调用和结果都会保留在上下文中,直到被压缩或裁剪。
+
+## 关键配置
+
+| 参数 | 说明 | 默认值 |
+| --- | --- | --- |
+| `agent_max_context_tokens` | 上下文最大 token 预算 | `50000` |
+| `agent_max_context_turns` | 上下文最大对话轮次 | `20` |
+| `agent_max_steps` | 单轮对话最大决策步数(工具调用次数) | `15` |
+
+可通过 `config.json` 或对话中的 `/config` 命令修改。
+
+## 压缩策略
+
+当上下文超出限制时,系统会自动执行压缩以释放空间。整个过程分为多个阶段:
+
+### 1. 工具结果截断
+
+在每次决策循环开始前,系统会检查历史轮次中的工具调用结果。超过 **20000 字符** 的工具结果会被截断,仅保留首尾内容和截断说明。当前轮次的工具结果不受影响。
+
+### 2. 轮次裁剪
+
+当对话轮次超过 `agent_max_context_turns` 时:
+
+- 裁剪 **最早一半** 的完整轮次(保证工具调用链的完整性)
+- 被裁剪的消息会通过 LLM 总结后**写入当天的日级记忆文件**
+- 剩余轮次保持不变
+
+### 3. Token 预算裁剪
+
+裁剪轮次后,如果 token 数仍超出预算:
+
+- **轮次 < 5 时**:对所有轮次进行**文本压缩** — 每轮只保留第一条用户文本和最后一条 Agent 回复,去掉中间的工具调用链
+- **轮次 ≥ 5 时**:再次裁剪**前半轮次**,被丢弃内容同样写入记忆
+
+### 4. 溢出应急处理
+
+当模型 API 返回上下文溢出错误时:
+
+1. 先将当前所有消息总结写入记忆
+2. 执行激进裁剪(工具结果限制 10K 字符、用户文本限制 10K、最多保留 5 轮)
+3. 如果仍然溢出,清空整个对话上下文
+
+## 会话持久化
+
+对话消息会持久化到本地数据库,服务重启后自动恢复。恢复策略:
+
+- 恢复最近的 **`max(3, max_context_turns / 6)`** 轮对话
+- 只保留每轮的**用户文本和 Agent 最终回复**,不恢复中间工具调用链
+- 超过 **30 天**的历史会话自动清理
+
+## 操作命令
+
+在对话中可以使用以下命令管理上下文:
+
+| 命令 | 说明 |
+| --- | --- |
+| `/context` | 查看当前上下文统计(消息数、角色分布、总字符数) |
+| `/context clear` | 清空当前会话上下文 |
+| `/config agent_max_context_tokens 80000` | 调整上下文 token 预算 |
+| `/config agent_max_context_turns 30` | 调整上下文轮次上限 |
+
+
+ 清空上下文后,Agent 会"忘记"之前的对话内容。被裁剪和清空的内容如果已经写入长期记忆,仍可通过记忆检索找回。
+
diff --git a/docs/memory.mdx b/docs/memory/index.mdx
similarity index 56%
rename from docs/memory.mdx
rename to docs/memory/index.mdx
index 5cfd101c..7aa9dff4 100644
--- a/docs/memory.mdx
+++ b/docs/memory/index.mdx
@@ -1,30 +1,39 @@
---
title: 长期记忆
-description: CowAgent 的长期记忆系统
+description: CowAgent 的长期记忆系统 — 文件持久化、自动写入与混合检索
---
-记忆系统让 Agent 能够长期记住重要信息,在对话中不断积累经验、理解用户偏好,真正实现自主思考和持续成长。
+长期记忆保存在工作空间文件中,跨会话持久存在。Agent 在对话中通过检索工具按需加载历史记忆,也会在上下文裁剪时自动将对话摘要写入长期记忆。
## 记忆类型
### 核心记忆(MEMORY.md)
-存储在 `~/cow/MEMORY.md` 中,包含用户的长期偏好、重要决策、关键事实等不会随时间淡化的信息。每次对话时自动注入系统提示词,作为 Agent 的背景知识。
+存储在 `~/cow/MEMORY.md` 中,包含用户的长期偏好、重要决策、关键事实等不会随时间淡化的信息。Agent 可通过工具读写此文件来维护长期知识。
-### 天级记忆(memory/YYYY-MM-DD.md)
+### 日级记忆(memory/YYYY-MM-DD.md)
存储在 `~/cow/memory/` 目录下,按日期命名(如 `2026-03-08.md`),记录每天的对话摘要和关键事件。仅在首次写入时创建,避免生成空文件。
-## 记忆写入
+## 自动写入
-Agent 通过以下机制自动将对话内容持久化为天级记忆:
+Agent 通过以下机制自动将对话内容持久化为长期记忆:
-- **上下文裁剪时** — 当对话轮次或 token 超出配置上限时,批量裁剪最早一半的上下文,并使用 LLM 将被裁剪的内容总结为关键信息写入当天记忆文件
+- **上下文裁剪时** — 当对话轮次或 token 超出配置上限时,裁剪最早一半的上下文,使用 LLM 将被裁剪的内容总结为关键信息写入当天记忆文件
- **每日定时总结** — 每天 23:55 自动触发一次全量总结,防止低活跃日无记忆留存(内容无变化时自动跳过)
- **API 上下文溢出时** — 当模型 API 返回上下文溢出错误时,紧急保存当前对话摘要
所有记忆写入均在后台异步执行(LLM 总结 + 文件写入),不阻塞正常对话回复。
+## 记忆检索
+
+记忆系统支持混合检索模式:
+
+- **关键词检索** — 基于 FTS5 全文索引匹配历史记忆,支持 BM25 排序
+- **向量检索** — 基于 embedding 语义相似度搜索,即使表述不同也能找到相关记忆
+
+Agent 会在对话中根据需要自动触发记忆检索,将相关历史信息纳入上下文。检索结果按混合评分排序(默认向量权重 0.7、关键词权重 0.3),日级记忆会随时间衰减(半衰期 30 天),核心记忆不衰减。
+
## 首次启动
首次启动 Agent 时,Agent 会主动向用户询问关键信息,并记录至工作空间(默认 `~/cow`)中:
@@ -34,33 +43,16 @@ Agent 通过以下机制自动将对话内容持久化为天级记忆:
| `system.md` | Agent 的系统提示词和行为设定 |
| `user.md` | 用户身份信息和偏好 |
| `MEMORY.md` | 核心记忆(长期) |
-| `memory/YYYY-MM-DD.md` | 天级记忆(按需创建) |
+| `memory/YYYY-MM-DD.md` | 日级记忆(按需创建) |
-## 记忆检索
-
-记忆系统支持混合检索模式:
-
-- **关键词检索** — 基于关键词匹配历史记忆
-- **向量检索** — 基于语义相似度搜索,即使表述不同也能找到相关记忆
-
-Agent 会在对话中根据需要自动触发记忆检索,将相关历史信息纳入上下文。核心记忆(`MEMORY.md`)始终注入系统提示词,天级记忆通过检索按需加载。
-
## 相关配置
-```json
-{
- "agent_workspace": "~/cow",
- "agent_max_context_tokens": 40000,
- "agent_max_context_turns": 20
-}
-```
-
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `agent_workspace` | 工作空间路径,记忆文件存储在此目录下 | `~/cow` |
-| `agent_max_context_tokens` | 最大上下文 token 数,超出时裁剪一半并总结写入记忆 | `40000` |
-| `agent_max_context_turns` | 最大上下文轮次,超出时裁剪一半并总结写入记忆 | `20` |
+| `agent_max_context_tokens` | 最大上下文 token 数,超出时裁剪并总结写入记忆 | `50000` |
+| `agent_max_context_turns` | 最大上下文轮次,超出时裁剪并总结写入记忆 | `20` |
diff --git a/docs/skills/create.mdx b/docs/skills/create.mdx
new file mode 100644
index 00000000..b0f53a11
--- /dev/null
+++ b/docs/skills/create.mdx
@@ -0,0 +1,58 @@
+---
+title: 创建技能
+description: 通过对话创建自定义技能
+---
+
+CowAgent 内置了 Skill Creator,可以通过自然语言对话快速创建、安装或更新技能。
+
+## 使用方式
+
+直接在对话中描述你想要的技能,Agent 会自动完成创建:
+
+- 将工作流程固化为技能:"帮我把这个部署流程创建为一个技能"
+- 对接第三方 API:"根据这个接口文档创建一个技能"
+- 安装远程技能:"帮我安装 xxx 技能"
+
+## 创建流程
+
+1. 告诉 Agent 你想创建的技能功能
+2. Agent 自动生成 `SKILL.md` 说明文件和运行脚本
+3. 技能保存到工作空间的 `~/cow/skills/` 目录
+4. 后续对话中 Agent 会自动识别并使用该技能
+
+
+
+
+
+## SKILL.md 格式
+
+创建的技能遵循标准的 SKILL.md 格式:
+
+```markdown
+---
+name: my-skill
+description: Brief description of the skill
+metadata:
+ emoji: 🔧
+ requires:
+ bins: ["curl"]
+ env: ["MY_API_KEY"]
+ primaryEnv: "MY_API_KEY"
+---
+
+# My Skill
+
+Detailed instructions...
+```
+
+| 字段 | 说明 |
+| --- | --- |
+| `name` | 技能名称,需与目录名一致 |
+| `description` | 技能描述,Agent 据此决定是否调用 |
+| `metadata.requires.bins` | 依赖的系统命令 |
+| `metadata.requires.env` | 依赖的环境变量 |
+| `metadata.always` | 是否始终加载(默认 false) |
+
+
+ 详细开发文档可参考 [Skill Creator 说明](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)。
+
diff --git a/docs/skills/image-vision.mdx b/docs/skills/image-vision.mdx
deleted file mode 100644
index 1c71f620..00000000
--- a/docs/skills/image-vision.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: 图像识别
-description: 使用 OpenAI 视觉模型识别图片
----
-
-使用 OpenAI 的 GPT-4 Vision API 分析图片内容,理解图像中的物体、文字、颜色等元素。
-
-## 依赖
-
-| 依赖 | 说明 |
-| --- | --- |
-| `OPENAI_API_KEY` | OpenAI API 密钥 |
-| `curl`、`base64` | 系统命令(通常已预装) |
-
-配置方式:
-
-- 通过 `env_config` 工具配置 `OPENAI_API_KEY`
-- 或在 `config.json` 中填写 `open_ai_api_key`
-
-## 支持的模型
-
-- `gpt-4.1-mini`(推荐,性价比高)
-- `gpt-4.1`
-
-## 使用方式
-
-配置完成后,向 Agent 发送图片即可自动触发图像识别。
-
-
-
-
diff --git a/docs/skills/index.mdx b/docs/skills/index.mdx
index 86c83d08..5648ceaf 100644
--- a/docs/skills/index.mdx
+++ b/docs/skills/index.mdx
@@ -7,20 +7,17 @@ description: CowAgent 技能系统介绍
Skill 与 Tool 的区别:Tool 是由代码实现的原子操作(如读写文件、执行命令),Skill 则是基于说明文件的高级工作流,可以组合调用多个 Tool 来完成复杂任务。
-## 内置技能
+## 获取技能
-位于项目 `skills/` 目录下,根据依赖条件自动判断是否启用:
+CowAgent 提供多种方式获取技能:
-| 技能 | 说明 | 依赖 |
-| --- | --- | --- |
-| [`skill-creator`](/skills/skill-creator) | 通过对话创建自定义技能 | 无 |
-| [`openai-image-vision`](/skills/image-vision) | 使用 OpenAI 视觉模型识别图片 | `OPENAI_API_KEY` |
-| [`linkai-agent`](/skills/linkai-agent) | 对接 LinkAI 平台智能体 | `LINKAI_API_KEY` |
-| [`web-fetch`](/skills/web-fetch) | 抓取网页文本内容 | `curl`(默认启用) |
+- **Cow 技能广场** — 通过 `/skill list --remote` 浏览和安装社区技能
+- **GitHub** — 直接从 GitHub 仓库安装,支持批量安装
+- **ClawHub** — 通过 `/skill install clawhub:名称` 安装 ClawHub 上的技能
+- **URL** — 从 zip 压缩包或 SKILL.md 链接安装
+- **对话创建** — 通过自然语言对话让 Agent 自动创建技能
-## 自定义技能
-
-由用户通过对话创建,存放在工作空间中(`~/cow/skills/`),可实现任何复杂的业务流程和第三方系统对接。
+详细安装方式参考 [安装技能](/skills/install) 和 [技能管理命令](/commands/skill)。也可以通过对话 [创建技能](/skills/create)。
## 技能加载优先级
diff --git a/docs/skills/install.mdx b/docs/skills/install.mdx
new file mode 100644
index 00000000..73b0cf6b
--- /dev/null
+++ b/docs/skills/install.mdx
@@ -0,0 +1,53 @@
+---
+title: 安装技能
+description: 通过命令一键安装来自多种来源的技能
+---
+
+CowAgent 支持通过统一的 `install` 命令安装来自 **Cow 技能广场、GitHub、ClawHub** 以及任意 URL 上的技能。在对话中使用 `/skill install`,在终端中使用 `cow skill install`。
+
+## 从Cow技能广场安装
+
+浏览技能广场,找到想要的技能后直接安装:
+
+```text
+/skill list --remote
+/skill install pptx
+```
+
+## 从 GitHub 安装
+
+支持仓库级批量安装和指定子目录安装:
+
+```text
+/skill install larksuite/cli
+/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
+```
+
+## 从 ClawHub 安装
+
+```text
+/skill install clawhub:baidu-search
+```
+
+## 从 URL 安装
+
+支持 zip 压缩包和 SKILL.md 文件链接:
+
+```text
+/skill install https://cdn.link-ai.tech/skills/pptx.zip
+/skill install https://example.com/path/to/SKILL.md
+```
+
+## 管理技能
+
+```text
+/skill list # 查看已安装技能
+/skill info pptx # 查看技能详情
+/skill enable pptx # 启用技能
+/skill disable pptx # 禁用技能
+/skill uninstall pptx # 卸载技能
+```
+
+
+ 以上所有命令在终端中使用时,将 `/skill` 替换为 `cow skill` 即可。完整命令说明参考 [技能管理命令](/commands/skill)。
+
diff --git a/docs/skills/linkai-agent.mdx b/docs/skills/linkai-agent.mdx
deleted file mode 100644
index 38fc735e..00000000
--- a/docs/skills/linkai-agent.mdx
+++ /dev/null
@@ -1,47 +0,0 @@
----
-title: LinkAI 智能体
-description: 对接 LinkAI 平台的多智能体技能
----
-
-将 [LinkAI](https://link-ai.tech/) 平台上的智能体作为 Skill 使用,实现多智能体决策。Agent 根据智能体的名称和描述智能选择,通过 `app_code` 调用对应的应用或工作流。
-
-## 依赖
-
-| 依赖 | 说明 |
-| --- | --- |
-| `LINKAI_API_KEY` | LinkAI 平台 API 密钥,在 [控制台](https://link-ai.tech/console/interface) 创建 |
-| `curl` | 系统命令(通常已预装) |
-
-配置方式:
-
-- 通过 `env_config` 工具配置 `LINKAI_API_KEY`
-- 或在 `config.json` 中填写 `linkai_api_key`
-
-## 配置智能体
-
-在 `skills/linkai-agent/config.json` 中添加可用的智能体:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI客服助手",
- "app_description": "当用户需要了解LinkAI平台相关问题时才选择该助手"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "内容创作助手",
- "app_description": "当用户需要创作图片或视频时才使用该助手"
- }
- ]
-}
-```
-
-## 使用方式
-
-配置完成后,Agent 会根据用户的问题自动选择合适的 LinkAI 智能体进行回答。
-
-
-
-
diff --git a/docs/skills/skill-creator.mdx b/docs/skills/skill-creator.mdx
deleted file mode 100644
index a530195b..00000000
--- a/docs/skills/skill-creator.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: 创建技能
-description: 通过对话创建自定义技能
----
-
-通过自然语言对话快速创建、安装或更新技能。
-
-## 依赖
-
-无额外依赖,始终可用。
-
-## 使用方式
-
-- 将工作流程固化为技能:"帮我把这个部署流程创建为一个技能"
-- 对接第三方 API:"根据这个接口文档创建一个技能"
-- 安装远程技能:"帮我安装 xxx 技能"
-
-## 创建流程
-
-1. 告诉 Agent 你想创建的技能功能
-2. Agent 自动生成 `SKILL.md` 说明文件和运行脚本
-3. 技能保存到工作空间的 `~/cow/skills/` 目录
-4. 后续对话中 Agent 会自动识别并使用该技能
-
-
-
-
-
-
- 详细开发文档可参考 [Skill 创造器说明](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)。
-
diff --git a/docs/skills/web-fetch.mdx b/docs/skills/web-fetch.mdx
deleted file mode 100644
index eedf49b8..00000000
--- a/docs/skills/web-fetch.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: 网页抓取
-description: 抓取网页文本内容
----
-
-使用 curl 抓取网页并提取可读文本内容,轻量级的网页访问方式,无需浏览器自动化。
-
-## 依赖
-
-| 依赖 | 说明 |
-| --- | --- |
-| `curl` | 系统命令(通常已预装) |
-
-该技能设置了 `always: true`,只要系统有 `curl` 命令即默认启用。
-
-## 使用方式
-
-当 Agent 需要获取某个 URL 的网页内容时会自动调用,无需额外配置。
-
-## 与 browser 工具的区别
-
-| 特性 | web-fetch(技能) | browser(工具) |
-| --- | --- | --- |
-| 依赖 | 仅 curl | browser-use + playwright |
-| JS 渲染 | 不支持 | 支持 |
-| 页面交互 | 不支持 | 支持点击、输入等 |
-| 适用场景 | 获取静态页面文本 | 操作动态网页 |
-
-
- 对于大多数网页内容获取场景,web-fetch 就够用了。只有需要 JS 渲染或页面交互时才需要 browser 工具。
-
diff --git a/docs/tools/browser.mdx b/docs/tools/browser.mdx
index 8c473daf..1e9e76f6 100644
--- a/docs/tools/browser.mdx
+++ b/docs/tools/browser.mdx
@@ -1,25 +1,109 @@
---
title: browser - 浏览器
-description: 访问和操作网页
+description: 控制浏览器访问和操作网页
---
-使用浏览器访问和操作网页,支持 JavaScript 渲染的动态页面。
+控制 Chromium 浏览器进行网页导航、元素交互和内容提取。支持 JavaScript 渲染的动态页面,使用精简 DOM 快照让 Agent 高效理解页面结构。
-## 依赖
+## 安装
-| 依赖 | 安装命令 |
-| --- | --- |
-| `browser-use` ≥ 0.1.40 | `pip install browser-use` |
-| `markdownify` | `pip install markdownify` |
-| `playwright` + chromium | `pip install playwright && playwright install chromium` |
+
+
+ ```bash
+ cow install-browser
+ ```
+
+ 该命令会自动完成:
+ - 安装 `playwright` Python 包(旧系统自动降级兼容版本)
+ - 在 Linux 上安装系统依赖
+ - 下载 Chromium 浏览器(Linux 服务器自动使用无头精简版)
+ - 自动检测国内网络并使用镜像加速
+
+
+ ```bash
+ pip install playwright
+ playwright install chromium
+ ```
+
+ Linux 服务器还需安装系统依赖:
+ ```bash
+ sudo playwright install-deps chromium
+ ```
+
+ 如果系统较旧(如 Ubuntu 18.04,glibc < 2.28),需安装兼容版本:
+ ```bash
+ pip install playwright==1.28.0
+ python -m playwright install chromium
+ ```
+
+ 国内网络下载 Chromium 较慢,可设置镜像加速:
+ ```bash
+ export PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright
+ python -m playwright install chromium
+ ```
+
+
+
+
+ 支持 Ubuntu 20.04+、Debian 10+、macOS、Windows。Ubuntu 18.04 等旧系统会自动降级安装兼容版本。
+
+
+## 工作流程
+
+Agent 使用浏览器的典型流程:
+
+1. **`navigate`** — 打开目标 URL
+2. **`snapshot`** — 获取页面精简 DOM,交互元素自动编号(ref)
+3. **`click` / `fill` / `select`** — 通过 ref 编号操作元素
+4. **`snapshot`** — 再次快照验证操作结果
+
+## 支持的操作
+
+| 操作 | 说明 | 关键参数 |
+| --- | --- | --- |
+| `navigate` | 打开 URL | `url` |
+| `snapshot` | 获取页面结构化文本(主要方式) | `selector`(可选) |
+| `click` | 点击元素 | `ref` 或 `selector` |
+| `fill` | 填入文本 | `ref` 或 `selector`,`text` |
+| `select` | 下拉选择 | `ref` 或 `selector`,`value` |
+| `scroll` | 滚动页面 | `direction`(up/down/left/right) |
+| `screenshot` | 截图保存到工作区 | `full_page` |
+| `wait` | 等待元素或超时 | `selector`,`timeout` |
+| `press` | 按键(Enter、Tab 等) | `key` |
+| `back` / `forward` | 浏览器前进/后退 | - |
+| `get_text` | 获取元素文本内容 | `selector` |
+| `evaluate` | 执行 JavaScript | `script` |
## 使用场景
-- 访问指定 URL 获取页面内容
-- 操作网页元素(点击、输入等)
+- 访问指定 URL 获取动态页面内容
+- 填写表单、登录操作
+- 操作网页元素(点击按钮、选择选项等)
- 验证部署后的网页效果
- 抓取需要 JS 渲染的动态内容
+## 运行模式
+
+浏览器会根据运行环境自动选择模式:
+
+| 环境 | 模式 |
+| --- | --- |
+| macOS / Windows | 有头模式(显示浏览器窗口) |
+| Linux 桌面(有 DISPLAY) | 有头模式 |
+| Linux 服务器(无 DISPLAY) | 无头模式(headless) |
+
+可在 `config.json` 中手动覆盖:
+
+```json
+{
+ "tools": {
+ "browser": {
+ "headless": true
+ }
+ }
+}
+```
+
- 浏览器工具依赖较重,如不需要可不安装。轻量的网页内容获取可使用 `web-fetch` 技能。
+ 浏览器工具依赖较重(~300MB),如不需要可不安装。轻量的网页内容获取可使用 `web_fetch` 工具。
diff --git a/docs/tools/index.mdx b/docs/tools/index.mdx
index dc96bd34..2b981758 100644
--- a/docs/tools/index.mdx
+++ b/docs/tools/index.mdx
@@ -31,6 +31,15 @@ description: CowAgent 内置工具系统
搜索和读取长期记忆
+
+ 管理 API Key 等秘钥配置
+
+
+ 获取网页或文档内容
+
+
+ 创建和管理定时任务
+
## 可选工具
@@ -38,13 +47,13 @@ description: CowAgent 内置工具系统
以下工具需要安装额外依赖或配置 API Key 后启用:
-
- 管理 API Key 等秘钥配置
-
-
- 创建和管理定时任务
-
搜索互联网获取实时信息
+
+ 分析图片内容(识别、描述、OCR 文字提取等)
+
+
+ 控制浏览器访问和操作网页
+
diff --git a/docs/tools/vision.mdx b/docs/tools/vision.mdx
new file mode 100644
index 00000000..839212b3
--- /dev/null
+++ b/docs/tools/vision.mdx
@@ -0,0 +1,36 @@
+---
+title: vision - 图片分析
+description: 分析图片内容(识别、描述、OCR 等)
+---
+
+使用 Vision API 分析本地图片或图片 URL,支持内容描述、文字提取(OCR)、物体识别等。
+
+## 依赖
+
+需要配置至少一个 API Key(通过 `env_config` 工具或工作空间 `.env` 文件配置):
+
+| 后端 | 环境变量 | 优先级 |
+| --- | --- | --- |
+| OpenAI | `OPENAI_API_KEY` | 优先使用 |
+| LinkAI | `LINKAI_API_KEY` | 备选 |
+
+## 参数
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `image` | string | 是 | 本地文件路径或 HTTP(S) 图片 URL |
+| `question` | string | 是 | 对图片提出的问题 |
+| `model` | string | 否 | 模型名称(默认 gpt-4.1-mini) |
+
+支持的图片格式:jpg、jpeg、png、gif、webp
+
+## 使用场景
+
+- 描述图片中的内容
+- 提取图片中的文字(OCR)
+- 识别物体、颜色、场景
+- 分析截图、文档扫描件
+
+
+ 超过 1MB 的图片会自动压缩后上传。如果未配置任何 Vision API Key,该工具不会被加载。
+
diff --git a/docs/tools/web-fetch.mdx b/docs/tools/web-fetch.mdx
new file mode 100644
index 00000000..12f85953
--- /dev/null
+++ b/docs/tools/web-fetch.mdx
@@ -0,0 +1,32 @@
+---
+title: web_fetch - 网页获取
+description: 获取网页或文档内容
+---
+
+获取 HTTP/HTTPS URL 的内容。对网页提取可读文本,对文档文件(PDF、Word、Excel 等)自动下载并解析内容。
+
+## 参数
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `url` | string | 是 | HTTP/HTTPS URL(网页或文档链接) |
+
+## 支持的文件类型
+
+| 类型 | 格式 |
+| --- | --- |
+| PDF | `.pdf` |
+| Word | `.docx` |
+| 文本 | `.txt`、`.md`、`.csv`、`.log` |
+| 表格 | `.xls`、`.xlsx` |
+| 演示文稿 | `.ppt`、`.pptx` |
+
+## 使用场景
+
+- 获取网页的文本内容
+- 下载并解析远程文档
+- 获取 API 响应内容
+
+
+ `web_fetch` 只能获取静态 HTML 内容。如果页面需要 JavaScript 渲染(如 SPA 单页应用),请使用 `browser` 工具。
+
diff --git a/scripts/run.ps1 b/scripts/run.ps1
index 50a407df..37941a2e 100644
--- a/scripts/run.ps1
+++ b/scripts/run.ps1
@@ -4,7 +4,7 @@
CowAgent installer & management script for Windows.
.DESCRIPTION
One-liner install:
- irm https://raw.githubusercontent.com/zhayujie/chatgpt-on-wechat/master/scripts/run.ps1 | iex
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
Or from a local clone:
.\scripts\run.ps1 # install / configure
.\scripts\run.ps1 start # start service (delegates to cow CLI)
From 511ee0bbaf3b0f2041a2e23acb9b47cff31b9d76 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 18:28:50 +0800
Subject: [PATCH 049/399] fix: windows PowerShell script
---
README.md | 12 +++++--
cli/commands/install.py | 18 ++++++++--
common/log.py | 6 +++-
config.py | 2 +-
docs/en/README.md | 6 ++++
docs/en/guide/quick-start.mdx | 18 +++++++---
docs/en/intro/index.mdx | 15 ++++++--
docs/guide/quick-start.mdx | 17 +++++++---
docs/intro/index.mdx | 15 ++++++--
docs/ja/README.md | 6 ++++
docs/ja/guide/quick-start.mdx | 18 +++++++---
docs/ja/intro/index.mdx | 15 ++++++--
scripts/run.ps1 | 64 +++++++++++++++++++++++++++--------
13 files changed, 169 insertions(+), 43 deletions(-)
diff --git a/README.md b/README.md
index e314a29f..67b4b83c 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,8 @@
- ✅ **自主任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆和日级记忆,支持关键词及向量检索
-- ✅ **技能系统:** 实现了 Skills 创建和运行的引擎,支持从 Skill Hub、GitHub 等安装技能,或通过对话创造自定义 Skills
-- ✅ **工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 自主调用以完成复杂任务
+- ✅ **技能系统:** Skills 安装和运行的引擎,支持从 Skill Hub、GitHub 等安装技能,或通过对话创造 Skills
+- ✅ **工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具,Agent 自主调用以完成复杂任务
- ✅ **CLI系统:** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
- ✅ **多模型支持:** 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao 等国内外主流模型厂商
@@ -88,11 +88,17 @@
在终端执行以下命令:
+**Linux / macOS:**
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
-脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)。安装后也可使用 `cow start`、`cow stop` 等 [CLI 命令](https://docs.cowagent.ai/commands/index) 管理服务。
+**Windows(PowerShell):**
+```powershell
+irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+```
+
+脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)。安装后可使用 `cow start`、`cow stop` 等 [CLI 命令](https://docs.cowagent.ai/commands/index) 管理服务。
## 一、准备
diff --git a/cli/commands/install.py b/cli/commands/install.py
index b4b6015d..a06ab18c 100644
--- a/cli/commands/install.py
+++ b/cli/commands/install.py
@@ -6,7 +6,7 @@ import subprocess
import click
-PLAYWRIGHT_VERSION = "1.49.0"
+PLAYWRIGHT_VERSION = "1.52.0"
PLAYWRIGHT_LEGACY_VERSION = "1.28.0"
GLIBC_THRESHOLD = (2, 28)
CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
@@ -102,7 +102,7 @@ def install_browser():
# Step 1: Install playwright package
click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
- ret = _pip_install(f"playwright=={target_version}" if legacy_mode else f"playwright>={target_version}")
+ ret = _pip_install(f"playwright=={target_version}")
if ret != 0:
click.echo(click.style("Failed to install playwright package.", fg="red"))
raise SystemExit(1)
@@ -143,11 +143,23 @@ def install_browser():
# Use China mirror if pip is configured with a domestic index
env = os.environ.copy()
- if _is_china_network():
+ use_mirror = _is_china_network()
+ if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
click.echo(f" (using China mirror: {CHINA_MIRROR})")
ret = subprocess.call(cmd, env=env)
+
+ # Fallback: if mirror download failed, retry with official CDN
+ if ret != 0 and use_mirror:
+ click.echo(click.style(
+ " Mirror download failed, retrying with official CDN...",
+ fg="yellow",
+ ))
+ env_no_mirror = os.environ.copy()
+ env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
+ ret = subprocess.call(cmd, env=env_no_mirror)
+
if ret != 0:
click.echo(click.style("Failed to install Chromium.", fg="red"))
raise SystemExit(1)
diff --git a/common/log.py b/common/log.py
index f02a365b..e0bc577c 100644
--- a/common/log.py
+++ b/common/log.py
@@ -1,5 +1,6 @@
import logging
import sys
+import io
def _reset_logger(log):
@@ -9,7 +10,10 @@ def _reset_logger(log):
del handler
log.handlers.clear()
log.propagate = False
- console_handle = logging.StreamHandler(sys.stdout)
+ stdout = sys.stdout
+ if hasattr(stdout, "buffer"):
+ stdout = io.TextIOWrapper(stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
+ console_handle = logging.StreamHandler(stdout)
console_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
diff --git a/config.py b/config.py
index b6516af7..6edd9c04 100644
--- a/config.py
+++ b/config.py
@@ -408,7 +408,7 @@ def get_root():
def read_file(path):
- with open(path, mode="r", encoding="utf-8") as f:
+ with open(path, mode="r", encoding="utf-8-sig") as f:
return f.read()
diff --git a/docs/en/README.md b/docs/en/README.md
index d6bff782..9b14d9c9 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -61,10 +61,16 @@ Full changelog: [Release Notes](https://docs.cowagent.ai/en/releases/overview)
The project provides a one-click script for installation, configuration, startup, and management:
+**Linux / macOS:**
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
+**Windows (PowerShell):**
+```powershell
+irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+```
+
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). After installation, you can also use `cow start`, `cow stop`, and other [CLI commands](https://docs.cowagent.ai/en/commands/index) to manage the service.
diff --git a/docs/en/guide/quick-start.mdx b/docs/en/guide/quick-start.mdx
index bf788ee6..da15ac80 100644
--- a/docs/en/guide/quick-start.mdx
+++ b/docs/en/guide/quick-start.mdx
@@ -9,9 +9,18 @@ Supports Linux, macOS, and Windows. Requires Python 3.7-3.12 (3.9 recommended).
## Install Command
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
+
+
+ ```bash
+ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
+ ```
+
+
+ ```powershell
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+ ```
+
+
The script automatically performs these steps:
@@ -36,9 +45,10 @@ After installation, use the `cow` command to manage the service:
| `cow status` | Check run status |
| `cow logs` | View real-time logs |
| `cow update` | Update code and restart |
+| `cow install-browser` | Install browser tool dependencies |
See the [Commands documentation](/en/commands/index) for more details.
- If the `cow` command is not available, you can use `./run.sh ` as a fallback (e.g., `./run.sh start`, `./run.sh stop`). Both are functionally equivalent.
+ If the `cow` command is not available, you can use `./run.sh ` (Linux/macOS) or `.\scripts\run.ps1 ` (Windows) as a fallback. Both are functionally equivalent.
diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx
index bc0613a3..31cc7130 100644
--- a/docs/en/intro/index.mdx
+++ b/docs/en/intro/index.mdx
@@ -46,9 +46,18 @@ CowAgent can proactively think and plan tasks, operate computers and external re
Run the following command in your terminal for one-click install, configuration, and startup:
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
+
+
+ ```bash
+ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
+ ```
+
+
+ ```powershell
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+ ```
+
+
By default, the Web service starts after running. Access `http://localhost:9899/chat` to chat in the web interface.
diff --git a/docs/guide/quick-start.mdx b/docs/guide/quick-start.mdx
index c0c6d0c0..28364f46 100644
--- a/docs/guide/quick-start.mdx
+++ b/docs/guide/quick-start.mdx
@@ -9,9 +9,18 @@ description: 使用脚本一键安装和管理 CowAgent
## 安装命令
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
+
+
+ ```bash
+ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
+ ```
+
+
+ ```powershell
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+ ```
+
+
脚本自动执行以下流程:
@@ -41,5 +50,5 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
更多命令和用法参考 [命令文档](/commands/index)。
- 如果 `cow` 命令不可用,也可以使用 `./run.sh <命令>` 作为替代(如 `./run.sh start`、`./run.sh stop`),二者功能等效。
+ 如果 `cow` 命令不可用,也可以使用 `./run.sh <命令>`(Linux/macOS)或 `.\scripts\run.ps1 <命令>`(Windows)作为替代,功能等效。
diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx
index 4cb3ef62..cf67dd4b 100644
--- a/docs/intro/index.mdx
+++ b/docs/intro/index.mdx
@@ -51,9 +51,18 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
在终端执行以下命令,即可一键安装、配置、启动 CowAgent:
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
+
+
+ ```bash
+ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
+ ```
+
+
+ ```powershell
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+ ```
+
+
运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。
diff --git a/docs/ja/README.md b/docs/ja/README.md
index f420b32e..a5b01782 100644
--- a/docs/ja/README.md
+++ b/docs/ja/README.md
@@ -61,10 +61,16 @@
本プロジェクトは、インストール・設定・起動・管理をワンクリックで行えるスクリプトを提供しています:
+**Linux / macOS:**
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
+**Windows (PowerShell):**
+```powershell
+irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+```
+
実行後、デフォルトでWebサービスが起動します。`http://localhost:9899/chat` にアクセスしてチャットを開始できます。
スクリプトの使い方: [ワンクリックインストール](https://docs.cowagent.ai/ja/guide/quick-start)。インストール後は `cow start`、`cow stop` などの [CLI コマンド](https://docs.cowagent.ai/ja/commands/index)でサービスを管理できます。
diff --git a/docs/ja/guide/quick-start.mdx b/docs/ja/guide/quick-start.mdx
index 147170da..a1874f2b 100644
--- a/docs/ja/guide/quick-start.mdx
+++ b/docs/ja/guide/quick-start.mdx
@@ -9,9 +9,18 @@ Linux、macOS、Windowsに対応しています。Python 3.7〜3.12が必要で
## インストールコマンド
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
+
+
+ ```bash
+ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
+ ```
+
+
+ ```powershell
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+ ```
+
+
スクリプトは以下の手順を自動的に実行します:
@@ -36,9 +45,10 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
| `cow status` | 実行状態を確認 |
| `cow logs` | リアルタイムログを表示 |
| `cow update` | コードを更新して再起動 |
+| `cow install-browser` | ブラウザツールの依存をインストール |
詳細は[コマンドドキュメント](/ja/commands/index)を参照してください。
- `cow` コマンドが利用できない場合は、`./run.sh <コマンド>`(例:`./run.sh start`、`./run.sh stop`)で代替できます。機能は同等です。
+ `cow` コマンドが利用できない場合は、`./run.sh <コマンド>`(Linux/macOS)または `.\scripts\run.ps1 <コマンド>`(Windows)で代替できます。機能は同等です。
diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx
index 14047e20..170fad52 100644
--- a/docs/ja/intro/index.mdx
+++ b/docs/ja/intro/index.mdx
@@ -46,9 +46,18 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
ターミナルで以下のコマンドを実行すると、ワンクリックでインストール、設定、起動ができます:
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
+
+
+ ```bash
+ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
+ ```
+
+
+ ```powershell
+ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
+ ```
+
+
デフォルトでは実行後に Web サービスが起動します。`http://localhost:9899/chat` にアクセスして Web インターフェースでチャットできます。
diff --git a/scripts/run.ps1 b/scripts/run.ps1
index 37941a2e..aaa99a47 100644
--- a/scripts/run.ps1
+++ b/scripts/run.ps1
@@ -18,6 +18,11 @@ param(
$ErrorActionPreference = "Stop"
+# ── ensure UTF-8 console encoding on Windows ─────────────────────
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$env:PYTHONIOENCODING = "utf-8"
+chcp 65001 | Out-Null
+
# ── colours ──────────────────────────────────────────────────────
function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
@@ -85,11 +90,17 @@ function Install-Project {
}
Write-Cow "Cloning CowAgent project..."
- git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>$null
- if ($LASTEXITCODE -ne 0) {
+ $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
+ git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>&1 | Out-Null
+ $cloneExit = $LASTEXITCODE
+ $ErrorActionPreference = $prevEAP
+ if ($cloneExit -ne 0) {
Write-Warn "GitHub failed, trying Gitee..."
- git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git
- if ($LASTEXITCODE -ne 0) {
+ $ErrorActionPreference = "Continue"
+ git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git 2>&1 | Out-Null
+ $cloneExit = $LASTEXITCODE
+ $ErrorActionPreference = $prevEAP
+ if ($cloneExit -ne 0) {
Write-Err "Clone failed. Check your network."
exit 1
}
@@ -105,20 +116,35 @@ function Install-Project {
function Install-Dependencies {
Write-Cow "Installing dependencies..."
- & $PythonCmd -m pip install --upgrade pip setuptools wheel 2>$null | Out-Null
+ $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
+ & $PythonCmd -m pip install --upgrade pip setuptools wheel 2>&1 | Out-Null
& $PythonCmd -m pip install -r "$BaseDir\requirements.txt" 2>&1 | ForEach-Object { Write-Host $_ }
- if ($LASTEXITCODE -ne 0) {
+ $pipExit = $LASTEXITCODE
+ $ErrorActionPreference = $prevEAP
+ if ($pipExit -ne 0) {
Write-Warn "Some dependencies may have issues, but continuing..."
}
Write-Cow "Registering cow CLI..."
- & $PythonCmd -m pip install -e $BaseDir 2>$null | Out-Null
+ $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
+ & $PythonCmd -m pip install -e $BaseDir 2>&1 | Out-Null
+ $ErrorActionPreference = $prevEAP
+
+ # Ensure Python Scripts dir is in PATH for this session
+ $scriptsDir = & $PythonCmd -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
+ if ($scriptsDir -and (Test-Path $scriptsDir)) {
+ if ($env:PATH -notlike "*$scriptsDir*") {
+ $env:PATH = "$scriptsDir;$env:PATH"
+ }
+ }
+
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
if ($cowBin) {
- Write-Cow "cow CLI registered."
+ Write-Cow "cow CLI registered: $($cowBin.Source)"
} else {
Write-Warn "cow CLI not in PATH. You can use: $PythonCmd -m cli.cli"
+ Write-Warn "To fix permanently, add Python Scripts directory to your system PATH."
}
}
@@ -302,7 +328,8 @@ function New-ConfigFile {
$config[$k] = $ChannelExtra[$k]
}
- $config | ConvertTo-Json -Depth 5 | Set-Content -Path "$BaseDir\config.json" -Encoding UTF8
+ $jsonText = $config | ConvertTo-Json -Depth 5
+ [System.IO.File]::WriteAllText("$BaseDir\config.json", $jsonText, (New-Object System.Text.UTF8Encoding $false))
Write-Cow "Configuration file created."
}
@@ -401,15 +428,24 @@ function Update-Project {
# Stop if running
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
- if ($cowBin) { & cow stop 2>$null }
+ if ($cowBin) {
+ $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
+ & cow stop 2>&1 | Out-Null
+ $ErrorActionPreference = $prevEAP
+ }
if (Test-Path "$BaseDir\.git") {
Write-Cow "Pulling latest code..."
- git pull 2>$null
- if ($LASTEXITCODE -ne 0) {
+ $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
+ git pull 2>&1 | Out-Null
+ $pullExit = $LASTEXITCODE
+ $ErrorActionPreference = $prevEAP
+ if ($pullExit -ne 0) {
Write-Warn "GitHub failed, trying Gitee..."
- git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
- git pull
+ $ErrorActionPreference = "Continue"
+ git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git 2>&1 | Out-Null
+ git pull 2>&1 | Out-Null
+ $ErrorActionPreference = $prevEAP
}
} else {
Write-Warn "Not a git repository, skipping code update."
From d09ae492876b3ebd4ef3838ad9e302d2e62262e1 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 19:09:11 +0800
Subject: [PATCH 050/399] feat(browser): auto-snapshot on navigate, screenshot
prompt guidance
Browser tool enhancements:
- Navigate action now auto-includes snapshot result, saving one LLM round-trip
- Wait for networkidle + 800ms after navigation for SPA/JS-rendered pages
- Prompt guides agent to screenshot key results and ask user for login/CAPTCHA help
- Fixed playwright version pinned to 1.52.0; mirror fallback to official CDN on failure
Web console file/image support:
- SSE real-time push for images and files via on_event (file_to_send)
- Added /api/file endpoint to serve local files for web preview
- Frontend renders images in media-content container (survives delta/done overwrites)
- File attachment cards with download links; RFC 5987 encoding for non-ASCII filenames
Tool workspace fix:
- Inject workspace_dir as cwd into send and browser tools (previously only file tools)
- Screenshots now save to ~/cow/tmp/ instead of project directory
---
agent/prompt/builder.py | 2 +-
agent/protocol/agent_stream.py | 4 +-
agent/tools/browser/browser_service.py | 10 ++++-
agent/tools/browser/browser_tool.py | 13 +++---
bridge/agent_bridge.py | 3 ++
bridge/agent_initializer.py | 2 +-
channel/web/static/js/console.js | 26 ++++++++++++
channel/web/web_channel.py | 55 ++++++++++++++++++++++++++
8 files changed, 105 insertions(+), 10 deletions(-)
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index 4a54963e..f5218622 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -165,7 +165,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
"terminal": "管理后台进程",
"web_search": "网络搜索",
"web_fetch": "获取URL内容",
- "browser": "控制浏览器",
+ "browser": "控制浏览器(关键结果或需要协助可截图发送给用户)",
"memory_search": "搜索记忆",
"memory_get": "读取记忆内容",
"env_config": "管理API密钥和技能配置",
diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py
index 79dcd2ab..1b250011 100644
--- a/agent/protocol/agent_stream.py
+++ b/agent/protocol/agent_stream.py
@@ -300,13 +300,13 @@ class AgentStreamExecutor:
f"with same arguments. This may indicate a loop."
)
- # Check if this is a file to send (from read tool)
+ # Check if this is a file to send
if result.get("status") == "success" and isinstance(result.get("result"), dict):
result_data = result.get("result")
if result_data.get("type") == "file_to_send":
- # Store file metadata for later sending
self.files_to_send.append(result_data)
logger.info(f"📎 检测到待发送文件: {result_data.get('file_name', result_data.get('path'))}")
+ self._emit_event("file_to_send", result_data)
# Check for critical error - abort entire conversation
if result.get("status") == "critical_error":
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
index d502ffb3..3065135a 100644
--- a/agent/tools/browser/browser_service.py
+++ b/agent/tools/browser/browser_service.py
@@ -283,7 +283,7 @@ class BrowserService:
# ------------------------------------------------------------------
def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
- """Navigate to a URL and return page info."""
+ """Navigate to a URL and wait for the page to be fully rendered."""
page = self.page
try:
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
@@ -291,6 +291,14 @@ class BrowserService:
except Exception as e:
return {"error": f"Navigation failed: {e}"}
+ # Wait for network idle and visual stability
+ try:
+ page.wait_for_load_state("networkidle", timeout=10000)
+ except Exception:
+ pass
+ # Extra settle time for JS-rendered content (SPA frameworks, animations)
+ page.wait_for_timeout(800)
+
return {
"url": page.url,
"title": page.title(),
diff --git a/agent/tools/browser/browser_tool.py b/agent/tools/browser/browser_tool.py
index 0b51fa26..0d16406b 100644
--- a/agent/tools/browser/browser_tool.py
+++ b/agent/tools/browser/browser_tool.py
@@ -23,9 +23,9 @@ class BrowserTool(BaseTool):
"Control a browser to navigate web pages, interact with elements, and extract content. "
"Actions: navigate, snapshot, click, fill, select, scroll, screenshot, wait, back, forward, "
"get_text, press, evaluate.\n\n"
- "Workflow: navigate to a URL → snapshot to see the page (elements get numeric refs) → "
- "use refs in click/fill/select actions → snapshot again to verify.\n\n"
- "Use snapshot (not screenshot) as the primary way to read page content."
+ "Workflow: navigate (auto-includes snapshot with element refs) → click/fill/select by ref → snapshot to verify.\n\n"
+ "Use snapshot as the primary way to read pages. Use screenshot + send to show key results to the user. "
+ "For login/CAPTCHA/authorization etc., screenshot and ask the user for help."
)
params: dict = {
@@ -136,12 +136,15 @@ class BrowserTool(BaseTool):
if not url.startswith(("http://", "https://")):
url = "https://" + url
timeout = args.get("timeout", 30000)
- result = self._get_service().navigate(url, timeout=timeout)
+ service = self._get_service()
+ result = service.navigate(url, timeout=timeout)
if "error" in result:
return ToolResult.fail(result["error"])
+ # Auto-snapshot after navigation so the agent gets page content in one call
+ snapshot_text = service.snapshot()
return ToolResult.success(
f"Navigated to: {result['url']}\nTitle: {result['title']}\nStatus: {result['status']}\n\n"
- f"Use action 'snapshot' to see the page content."
+ f"--- Page Snapshot ---\n{snapshot_text}"
)
def _do_snapshot(self, args: Dict[str, Any]) -> ToolResult:
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py
index 81caad3c..20e6e301 100644
--- a/bridge/agent_bridge.py
+++ b/bridge/agent_bridge.py
@@ -271,10 +271,13 @@ class AgentBridge:
tool_manager.load_tools()
tools = []
+ workspace_dir = kwargs.get("workspace_dir")
for tool_name in tool_manager.tool_classes.keys():
try:
tool = tool_manager.create_tool(tool_name)
if tool:
+ if workspace_dir and hasattr(tool, 'cwd'):
+ tool.cwd = workspace_dir
tools.append(tool)
except Exception as e:
logger.warning(f"[AgentBridge] Failed to load tool {tool_name}: {e}")
diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py
index f64d9715..26c67c48 100644
--- a/bridge/agent_initializer.py
+++ b/bridge/agent_initializer.py
@@ -366,7 +366,7 @@ class AgentInitializer:
if tool:
# Apply workspace config to file operation tools
- if tool_name in ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls', 'web_fetch']:
+ if tool_name in ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls', 'web_fetch', 'send', 'browser']:
tool.config = file_config
tool.cwd = file_config.get("cwd", getattr(tool, 'cwd', None))
if 'memory_manager' in file_config:
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index aa47e23c..b9786142 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -719,6 +719,7 @@ function startSSE(requestId, loadingEl, timestamp) {
let botEl = null;
let stepsEl = null; // .agent-steps (thinking summaries + tool indicators)
let contentEl = null; // .answer-content (final streaming answer)
+ let mediaEl = null; // .media-content (images & file attachments)
let accumulatedText = '';
let currentToolEl = null;
@@ -734,6 +735,7 @@ function startSSE(requestId, loadingEl, timestamp) {
${formatTime(timestamp)}
@@ -741,6 +743,7 @@ function startSSE(requestId, loadingEl, timestamp) {
messagesDiv.appendChild(botEl);
stepsEl = botEl.querySelector('.agent-steps');
contentEl = botEl.querySelector('.answer-content');
+ mediaEl = botEl.querySelector('.media-content');
}
es.onmessage = function(e) {
@@ -831,6 +834,29 @@ function startSSE(requestId, loadingEl, timestamp) {
currentToolEl = null;
}
+ } else if (item.type === 'image') {
+ ensureBotEl();
+ const imgEl = document.createElement('img');
+ imgEl.src = item.content;
+ imgEl.alt = 'screenshot';
+ imgEl.style.cssText = 'max-width:360px;border-radius:8px;margin:8px 0;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.1);';
+ imgEl.onclick = () => window.open(item.content, '_blank');
+ mediaEl.appendChild(imgEl);
+ scrollChatToBottom();
+
+ } else if (item.type === 'file') {
+ ensureBotEl();
+ const fileName = item.file_name || item.content.split('/').pop();
+ const fileEl = document.createElement('a');
+ fileEl.href = item.content;
+ fileEl.download = fileName;
+ fileEl.target = '_blank';
+ fileEl.className = 'file-attachment';
+ fileEl.style.cssText = 'display:inline-flex;align-items:center;gap:6px;padding:8px 14px;margin:8px 0;border-radius:8px;background:var(--bg-secondary,#f3f4f6);color:var(--text-primary,#374151);text-decoration:none;font-size:14px;border:1px solid var(--border-color,#e5e7eb);';
+ fileEl.innerHTML = `
${fileName}`;
+ mediaEl.appendChild(fileEl);
+ scrollChatToBottom();
+
} else if (item.type === 'done') {
es.close();
delete activeStreams[requestId];
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 18770e96..cc77a771 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -99,6 +99,21 @@ class WebChannel(ChatChannel):
# SSE mode: push done event to SSE queue
if request_id in self.sse_queues:
content = reply.content if reply.content is not None else ""
+
+ # Files are already pushed via on_event (file_to_send) during agent execution.
+ # Skip duplicate file pushes here; just let the done event through.
+ if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"):
+ text_content = getattr(reply, 'text_content', '')
+ if text_content:
+ self.sse_queues[request_id].put({
+ "type": "done",
+ "content": text_content,
+ "request_id": request_id,
+ "timestamp": time.time()
+ })
+ logger.debug(f"SSE skipped duplicate file for request {request_id}")
+ return
+
self.sse_queues[request_id].put({
"type": "done",
"content": content,
@@ -161,6 +176,19 @@ class WebChannel(ChatChannel):
"execution_time": round(exec_time, 2)
})
+ elif event_type == "file_to_send":
+ file_path = data.get("path", "")
+ file_name = data.get("file_name", os.path.basename(file_path))
+ file_type = data.get("file_type", "file")
+ from urllib.parse import quote
+ web_url = f"/api/file?path={quote(file_path)}"
+ is_image = file_type == "image"
+ q.put({
+ "type": "image" if is_image else "file",
+ "content": web_url,
+ "file_name": file_name,
+ })
+
return on_event
def upload_file(self):
@@ -377,6 +405,7 @@ class WebChannel(ChatChannel):
'/message', 'MessageHandler',
'/upload', 'UploadHandler',
'/uploads/(.*)', 'UploadsHandler',
+ '/api/file', 'FileServeHandler',
'/poll', 'PollHandler',
'/stream', 'StreamHandler',
'/chat', 'ChatHandler',
@@ -463,6 +492,32 @@ class UploadsHandler:
raise web.notfound()
+class FileServeHandler:
+ def GET(self):
+ """Serve a local file by absolute path (for agent send tool)."""
+ try:
+ params = web.input(path="")
+ file_path = params.path
+ if not file_path or not os.path.isabs(file_path):
+ raise web.notfound()
+ file_path = os.path.normpath(file_path)
+ if not os.path.isfile(file_path):
+ raise web.notfound()
+ content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+ file_name = os.path.basename(file_path)
+ from urllib.parse import quote
+ web.header('Content-Type', content_type)
+ web.header('Content-Disposition', f"inline; filename*=UTF-8''{quote(file_name)}")
+ web.header('Cache-Control', 'public, max-age=3600')
+ with open(file_path, 'rb') as f:
+ return f.read()
+ except web.HTTPError:
+ raise
+ except Exception as e:
+ logger.error(f"[WebChannel] Error serving file: {e}")
+ raise web.notfound()
+
+
class PollHandler:
def POST(self):
return WebChannel().poll_response()
From da061450e5458b1732f006d0fb6e0fee2d1ac241 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 19:23:47 +0800
Subject: [PATCH 051/399] fix: github skill install cmd
---
cli/commands/skill.py | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 11e63db2..78c2db01 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -828,8 +828,11 @@ def _route_install(name: str, result: InstallResult):
subpath = None
if "#" in raw:
raw, subpath = raw.split("#", 1)
- _check_github_spec(raw)
- _install_github(raw, result, subpath=subpath)
+ if re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+$", raw):
+ _install_github(raw, result, subpath=subpath)
+ else:
+ _check_skill_name(raw)
+ _install_hub(raw, result, provider="github")
return
# --- clawhub: prefix ---
@@ -928,11 +931,9 @@ def _install_hub(name, result: InstallResult, provider=None):
parsed_url = _parse_github_url(source_url)
if parsed_url:
owner, repo, branch, subpath = parsed_url
- result.messages.append(f"Source: GitHub ({source_url})")
_install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
else:
_check_github_spec(source_url)
- result.messages.append(f"Source: GitHub ({source_url})")
_install_github(source_url, result, skill_name=name)
return
@@ -967,11 +968,9 @@ def _install_hub(name, result: InstallResult, provider=None):
parsed_url = _parse_github_url(source_url)
if parsed_url:
owner, repo, branch, subpath = parsed_url
- result.messages.append(f"Source: GitHub ({source_url})")
_install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
else:
_check_github_spec(source_url)
- result.messages.append(f"Source: GitHub ({source_url})")
_install_github(source_url, result, skill_name=name)
return
From e4f9697d06e5ca3ddcf477e8552d3b150d932cc3 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sun, 29 Mar 2026 23:52:51 +0800
Subject: [PATCH 052/399] feat(browser): install font in linux
---
cli/commands/install.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/cli/commands/install.py b/cli/commands/install.py
index a06ab18c..8a6ab9ed 100644
--- a/cli/commands/install.py
+++ b/cli/commands/install.py
@@ -122,6 +122,21 @@ def install_browser():
f" Run manually: sudo {python} -m playwright install-deps chromium",
fg="yellow",
))
+ # Install CJK fonts for proper Chinese/Japanese/Korean rendering in screenshots
+ click.echo(" Installing CJK fonts...")
+ font_ret = subprocess.call(
+ ["sudo", "apt-get", "install", "-y", "fonts-noto-cjk", "fonts-wqy-zenhei"],
+ stderr=subprocess.DEVNULL,
+ )
+ if font_ret != 0:
+ click.echo(click.style(
+ " Could not auto-install CJK fonts.\n"
+ " Run manually: sudo apt-get install -y fonts-noto-cjk fonts-wqy-zenhei",
+ fg="yellow",
+ ))
+ else:
+ subprocess.call(["fc-cache", "-fv"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ click.echo(click.style(" CJK fonts installed.", fg="green"))
else:
click.echo(click.style(f"[2/3] Skipping system deps (not needed on {sys.platform}).", fg="yellow"))
click.echo()
From fa149cf4aa213bc3bb58dfffdf2d2713c6dca783 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 30 Mar 2026 00:57:19 +0800
Subject: [PATCH 053/399] fix(browser): multi-thread browser instance bug
---
agent/tools/browser/browser_service.py | 13 ++++++++++++-
docs/intro/index.mdx | 2 +-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
index 3065135a..9ee230f8 100644
--- a/agent/tools/browser/browser_service.py
+++ b/agent/tools/browser/browser_service.py
@@ -10,6 +10,7 @@ import os
import sys
import re
import uuid
+import threading
from typing import Optional, Dict, Any, List
from common.log import logger
@@ -206,13 +207,21 @@ class BrowserService:
self._page: Optional[Page] = None
self._headless: Optional[bool] = None
self._screenshot_dir: Optional[str] = None
+ self._owner_thread: Optional[int] = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def _ensure_browser(self):
- """Lazily launch browser on first use."""
+ """Lazily launch browser on first use. Re-launch if called from a different thread."""
+ current_thread = threading.current_thread().ident
+
+ # Playwright sync API is single-threaded; if called from a different thread, restart
+ if self._owner_thread and self._owner_thread != current_thread and self._playwright:
+ logger.info("[Browser] Thread changed, restarting browser instance")
+ self.close()
+
if self._page and not self._page.is_closed():
return
@@ -248,6 +257,7 @@ class BrowserService:
),
)
self._page = self._context.new_page()
+ self._owner_thread = current_thread
logger.info("[Browser] Browser ready")
@property
@@ -276,6 +286,7 @@ class BrowserService:
self._context = None
self._browser = None
self._playwright = None
+ self._owner_thread = None
logger.info("[Browser] Browser closed")
# ------------------------------------------------------------------
diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx
index cf67dd4b..4b78ee63 100644
--- a/docs/intro/index.mdx
+++ b/docs/intro/index.mdx
@@ -22,7 +22,7 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
- 能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源。
+ 能够理解复杂任务并自主规划执行,持续思考和调用各类工具和技能直到完成目标。
自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索。
From 6764c05c3f1353a357a21a44530b3fb191ca1245 Mon Sep 17 00:00:00 2001
From: zkjqd <1491087953@qq.com>
Date: Mon, 30 Mar 2026 11:20:03 +0800
Subject: [PATCH 054/399] input-slash-click
---
channel/web/static/js/console.js | 30 +++++++++++++++++++-----------
1 file changed, 19 insertions(+), 11 deletions(-)
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index b9786142..8eb5f5c3 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -507,21 +507,29 @@ function renderSlashItems() {
`${escapeHtml(c.desc)} `
).join('');
- slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
- el.addEventListener('mouseenter', () => {
- slashActiveIdx = parseInt(el.dataset.idx);
- renderSlashItems();
- });
- el.addEventListener('mousedown', (e) => {
- e.preventDefault();
- selectSlashCommand(parseInt(el.dataset.idx));
- });
- });
-
const activeEl = slashMenu.querySelector('.slash-menu-item.active');
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
+// Delegated events on the persistent slashMenu container (not destroyed by innerHTML)
+slashMenu.addEventListener('mouseover', (e) => {
+ const item = e.target.closest('.slash-menu-item');
+ if (!item) return;
+ const idx = parseInt(item.dataset.idx);
+ if (idx === slashActiveIdx) return;
+ slashActiveIdx = idx;
+ slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
+ el.classList.toggle('active', parseInt(el.dataset.idx) === idx);
+ });
+});
+
+slashMenu.addEventListener('mousedown', (e) => {
+ const item = e.target.closest('.slash-menu-item');
+ if (!item) return;
+ e.preventDefault();
+ selectSlashCommand(parseInt(item.dataset.idx));
+});
+
function selectSlashCommand(idx) {
if (idx < 0 || idx >= slashFiltered.length) return;
const chosen = slashFiltered[idx].cmd;
From e57ef37167e36584a8f5266e7b554e59c85f14e0 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 30 Mar 2026 11:52:05 +0800
Subject: [PATCH 055/399] fix: prevent phantom mouseover from hijacking slash
menu
---
channel/web/static/js/console.js | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 8eb5f5c3..f2c3ad29 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -464,6 +464,8 @@ let slashActiveIdx = 0;
let slashFiltered = [];
let slashJustSelected = false;
let slashLastFilter = '';
+let slashLastMouseX = -1;
+let slashLastMouseY = -1;
function showSlashMenu(filter) {
const q = filter.toLowerCase();
@@ -482,6 +484,7 @@ function showSlashMenu(filter) {
if (changed) slashActiveIdx = 0;
slashActiveIdx = Math.min(slashActiveIdx, slashFiltered.length - 1);
+ slashNavByKeyboard = true;
renderSlashItems();
slashMenu.classList.remove('hidden');
}
@@ -492,6 +495,9 @@ function hideSlashMenu() {
slashFiltered = [];
slashActiveIdx = -1;
slashLastFilter = '';
+ slashNavByKeyboard = false;
+ slashLastMouseX = -1;
+ slashLastMouseY = -1;
}
function isSlashMenuVisible() {
@@ -512,7 +518,25 @@ function renderSlashItems() {
}
// Delegated events on the persistent slashMenu container (not destroyed by innerHTML)
+// Use coordinate comparison to distinguish real mouse movement from DOM-rebuild phantom events.
+slashMenu.addEventListener('mousemove', (e) => {
+ if (e.clientX === slashLastMouseX && e.clientY === slashLastMouseY) return;
+ slashLastMouseX = e.clientX;
+ slashLastMouseY = e.clientY;
+ if (!slashNavByKeyboard) return;
+ slashNavByKeyboard = false;
+ const item = e.target.closest('.slash-menu-item');
+ if (!item) return;
+ const idx = parseInt(item.dataset.idx);
+ if (idx === slashActiveIdx) return;
+ slashActiveIdx = idx;
+ slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
+ el.classList.toggle('active', parseInt(el.dataset.idx) === idx);
+ });
+});
+
slashMenu.addEventListener('mouseover', (e) => {
+ if (slashNavByKeyboard) return;
const item = e.target.closest('.slash-menu-item');
if (!item) return;
const idx = parseInt(item.dataset.idx);
@@ -565,12 +589,14 @@ chatInput.addEventListener('keydown', function(e) {
if (isSlashMenuVisible()) {
if (e.key === 'ArrowDown') {
e.preventDefault();
+ slashNavByKeyboard = true;
slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1);
renderSlashItems();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
+ slashNavByKeyboard = true;
slashActiveIdx = Math.max(slashActiveIdx - 1, 0);
renderSlashItems();
return;
From afd947195d84f24d5e24a2f3a1533e1b652a0828 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 30 Mar 2026 16:36:17 +0800
Subject: [PATCH 056/399] fix(cli): support skill mirror install
---
cli/commands/skill.py | 65 +++++++++++++++++++++++++++++++++++--------
1 file changed, 54 insertions(+), 11 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 78c2db01..43c47dcb 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -122,7 +122,7 @@ def _clone_repo(git_url: str):
return tmp_dir, repo_dir
-def _download_repo_zip(spec: str, branch: str = "main", host: str = "github"):
+def _download_repo_zip(spec: str, branch: str = "main", host: str = "github", timeout: int = 120):
"""Download a GitHub/GitLab repo as zip and extract it.
Returns (tmp_dir, repo_root) where tmp_dir is the temp directory to clean up
@@ -132,7 +132,11 @@ def _download_repo_zip(spec: str, branch: str = "main", host: str = "github"):
zip_url = f"https://gitlab.com/{spec}/-/archive/{branch}/{spec.split('/')[-1]}-{branch}.zip"
else:
zip_url = f"https://github.com/{spec}/archive/refs/heads/{branch}.zip"
- resp = requests.get(zip_url, timeout=120, allow_redirects=True)
+ if isinstance(timeout, (list, tuple)):
+ req_timeout = timeout
+ else:
+ req_timeout = (min(timeout, 5), timeout)
+ resp = requests.get(zip_url, timeout=req_timeout, allow_redirects=True)
resp.raise_for_status()
tmp_dir = tempfile.mkdtemp(prefix="cow-skill-")
@@ -928,13 +932,52 @@ def _install_hub(name, result: InstallResult, provider=None):
if source_type == "github":
source_url = data.get("source_url", "")
- parsed_url = _parse_github_url(source_url)
- if parsed_url:
- owner, repo, branch, subpath = parsed_url
- _install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch)
- else:
- _check_github_spec(source_url)
- _install_github(source_url, result, skill_name=name)
+ has_mirror = data.get("has_mirror", False)
+ gh_err = None
+
+ gh_timeout = 15 if has_mirror else 120
+ try:
+ parsed_url = _parse_github_url(source_url)
+ if parsed_url:
+ owner, repo, branch, subpath = parsed_url
+ _install_github(f"{owner}/{repo}", result, subpath=subpath, skill_name=name, branch=branch, timeout=gh_timeout)
+ else:
+ _check_github_spec(source_url)
+ _install_github(source_url, result, skill_name=name, timeout=gh_timeout)
+ return
+ except Exception as e:
+ gh_err = e
+ if not has_mirror:
+ raise SkillInstallError(f"GitHub download failed: {e}")
+
+ # Fallback: download mirror from Skill Hub
+ result.messages.append(f"GitHub download failed ({gh_err}), trying mirror...")
+ try:
+ mirror_resp = requests.post(
+ f"{SKILL_HUB_API}/skills/{name}/download",
+ json={"mirror": True},
+ timeout=60,
+ )
+ mirror_resp.raise_for_status()
+ except Exception as e:
+ raise SkillInstallError(
+ f"GitHub download failed ({gh_err}) and mirror also failed: {e}"
+ )
+
+ mirror_ct = mirror_resp.headers.get("Content-Type", "")
+ if "application/zip" not in mirror_ct:
+ raise SkillInstallError(
+ f"GitHub download failed ({gh_err}) and mirror returned unexpected content."
+ )
+
+ expected_checksum = mirror_resp.headers.get("X-Checksum-Sha256")
+ _check_checksum(mirror_resp.content, expected_checksum)
+ installed_before = len(result.installed)
+ _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
+ if len(result.installed) == installed_before:
+ _register_installed_skill(name, source="cowhub")
+ result.installed.append(name)
+ result.messages.append(f"Installed '{name}' from mirror.")
return
if source_type == "registry":
@@ -989,7 +1032,7 @@ def _install_hub(name, result: InstallResult, provider=None):
raise SkillInstallError("Unexpected response from Skill Hub.")
-def _install_github(spec, result: InstallResult, subpath=None, skill_name=None, branch="main", source="github"):
+def _install_github(spec, result: InstallResult, subpath=None, skill_name=None, branch="main", source="github", timeout=120):
"""Install skill(s) from a GitHub repo.
Strategy: zip download first (no API rate limit), Contents API as fallback.
@@ -1008,7 +1051,7 @@ def _install_github(spec, result: InstallResult, subpath=None, skill_name=None,
tmp_dir = None
repo_root = None
try:
- tmp_dir, repo_root = _download_repo_zip(spec, branch)
+ tmp_dir, repo_root = _download_repo_zip(spec, branch, timeout=timeout)
except Exception:
result.messages.append("Zip download failed, falling back to Contents API...")
From 00353dd0cb874b0795049bff21898551ff5848c3 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 30 Mar 2026 18:46:02 +0800
Subject: [PATCH 057/399] feat: support skill hub mirror
---
cli/commands/skill.py | 67 +++++++++++++++++++++++++++++++++++++------
1 file changed, 59 insertions(+), 8 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 43c47dcb..891bceca 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -548,14 +548,26 @@ def _check_github_spec(spec: str):
raise SkillInstallError(f"Invalid GitHub spec '{spec}'. Expected format: owner/repo")
+_JUNK_NAMES = {'.DS_Store', 'Thumbs.db', 'desktop.ini'}
+
+
+def _is_junk_entry(filename: str) -> bool:
+ parts = filename.replace('\\', '/').split('/')
+ return any(p in _JUNK_NAMES or p == '__MACOSX' or p.startswith('._.') for p in parts)
+
+
def _safe_extractall(zf: zipfile.ZipFile, dest: str):
- """Extract zip while guarding against Zip Slip (path traversal)."""
+ """Extract zip while guarding against Zip Slip and filtering junk files."""
dest = os.path.realpath(dest)
+ members = []
for member in zf.infolist():
+ if _is_junk_entry(member.filename):
+ continue
target = os.path.realpath(os.path.join(dest, member.filename))
if not target.startswith(dest + os.sep) and target != dest:
raise ValueError(f"Unsafe zip entry detected: {member.filename}")
- zf.extractall(dest)
+ members.append(member)
+ zf.extractall(dest, members=members)
def _verify_checksum(content: bytes, expected: str):
@@ -987,21 +999,60 @@ def _install_hub(name, result: InstallResult, provider=None):
if parsed.scheme != "https":
raise SkillInstallError("Refusing to download from non-HTTPS URL.")
src_provider = data.get("source_provider", "registry")
+ has_mirror = data.get("has_mirror", False)
expected_checksum = data.get("checksum") or data.get("sha256")
result.messages.append(f"Source: {src_provider}")
result.messages.append("Downloading skill package...")
+ dl_err = None
+ dl_timeout = 15 if has_mirror else 60
try:
- dl_resp = requests.get(download_url, timeout=60, allow_redirects=True)
+ dl_resp = requests.get(
+ download_url,
+ timeout=(min(dl_timeout, 5), dl_timeout),
+ allow_redirects=True,
+ )
dl_resp.raise_for_status()
except Exception as e:
- raise SkillInstallError(f"Failed to download from {src_provider}: {e}")
- _check_checksum(dl_resp.content, expected_checksum)
+ dl_err = e
+ if not has_mirror:
+ raise SkillInstallError(f"Failed to download from {src_provider}: {e}")
+
+ if dl_err is None:
+ _check_checksum(dl_resp.content, expected_checksum)
+ installed_before = len(result.installed)
+ _install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
+ if len(result.installed) == installed_before:
+ _register_installed_skill(name, source=src_provider)
+ result.installed.append(name)
+ result.messages.append(f"Installed '{name}' from {src_provider}.")
+ return
+
+ # Fallback: download mirror from Skill Hub
+ result.messages.append(f"Direct download failed ({dl_err}), trying mirror...")
+ try:
+ mirror_resp = requests.post(
+ f"{SKILL_HUB_API}/skills/{name}/download",
+ json={"mirror": True},
+ timeout=60,
+ )
+ mirror_resp.raise_for_status()
+ except Exception as e:
+ raise SkillInstallError(
+ f"Direct download failed ({dl_err}) and mirror also failed: {e}"
+ )
+ mirror_ct = mirror_resp.headers.get("Content-Type", "")
+ if "application/zip" not in mirror_ct:
+ raise SkillInstallError(
+ f"Direct download failed ({dl_err}) and mirror returned unexpected content."
+ )
+ expected_checksum = mirror_resp.headers.get("X-Checksum-Sha256")
+ _check_checksum(mirror_resp.content, expected_checksum)
installed_before = len(result.installed)
- _install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
+ _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
if len(result.installed) == installed_before:
- _register_installed_skill(name, source=src_provider)
+ _register_installed_skill(name, source="cowhub")
result.installed.append(name)
- result.messages.append(f"Installed '{name}' from {src_provider}.")
+ result.messages.append(f"Installed '{name}' from mirror.")
else:
raise SkillInstallError("Unsupported registry provider.")
return
From 7549d48cf15dd46f62c6197f5d8ddd6fb5773b6f Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 30 Mar 2026 21:27:08 +0800
Subject: [PATCH 058/399] fix: browser thread bug
---
agent/tools/browser/browser_service.py | 66 ++++++++++++++++++++++----
docker/Dockerfile.latest | 3 +-
2 files changed, 60 insertions(+), 9 deletions(-)
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
index 9ee230f8..cc6203a0 100644
--- a/agent/tools/browser/browser_service.py
+++ b/agent/tools/browser/browser_service.py
@@ -6,6 +6,7 @@ and cleans up on close(). Headless mode is auto-detected based on platform and
display availability.
"""
+import asyncio
import os
import sys
import re
@@ -220,7 +221,7 @@ class BrowserService:
# Playwright sync API is single-threaded; if called from a different thread, restart
if self._owner_thread and self._owner_thread != current_thread and self._playwright:
logger.info("[Browser] Thread changed, restarting browser instance")
- self.close()
+ self._force_cleanup()
if self._page and not self._page.is_closed():
return
@@ -265,8 +266,45 @@ class BrowserService:
self._ensure_browser()
return self._page
+ def _force_cleanup(self):
+ """Force-release resources when the owner thread has already exited.
+
+ Normal close() calls Playwright's sync API which requires the original
+ thread to still be alive. When the thread is gone we can only kill the
+ underlying browser process and discard all references.
+ """
+ logger.info("[Browser] Force-cleaning stale browser (owner thread exited)")
+ try:
+ if self._browser and self._browser.is_connected():
+ pid = self._browser.process and self._browser.process.pid
+ if pid:
+ import signal, os as _os
+ try:
+ _os.kill(pid, signal.SIGKILL)
+ logger.debug(f"[Browser] Killed browser process {pid}")
+ except OSError:
+ pass
+ except Exception as e:
+ logger.debug(f"[Browser] force cleanup browser kill: {e}")
+ self._page = None
+ self._context = None
+ self._browser = None
+ try:
+ if self._playwright:
+ self._playwright.stop()
+ except Exception:
+ pass
+ self._playwright = None
+ self._owner_thread = None
+
def close(self):
"""Release all browser resources."""
+ try:
+ loop = asyncio.get_event_loop()
+ old_handler = loop.get_exception_handler()
+ loop.set_exception_handler(lambda l, c: None)
+ except Exception:
+ loop, old_handler = None, None
try:
if self._context:
self._context.close()
@@ -287,6 +325,11 @@ class BrowserService:
self._browser = None
self._playwright = None
self._owner_thread = None
+ if loop and old_handler:
+ try:
+ loop.set_exception_handler(old_handler)
+ except Exception:
+ pass
logger.info("[Browser] Browser closed")
# ------------------------------------------------------------------
@@ -294,7 +337,7 @@ class BrowserService:
# ------------------------------------------------------------------
def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
- """Navigate to a URL and wait for the page to be fully rendered."""
+ """Navigate to a URL and wait for the page to be ready."""
page = self.page
try:
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
@@ -302,17 +345,24 @@ class BrowserService:
except Exception as e:
return {"error": f"Navigation failed: {e}"}
- # Wait for network idle and visual stability
try:
- page.wait_for_load_state("networkidle", timeout=10000)
+ page.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
- # Extra settle time for JS-rendered content (SPA frameworks, animations)
- page.wait_for_timeout(800)
+ page.wait_for_timeout(500)
+
+ try:
+ title = page.title()
+ except Exception:
+ title = ""
+ try:
+ current_url = page.url
+ except Exception:
+ current_url = url
return {
- "url": page.url,
- "title": page.title(),
+ "url": current_url,
+ "title": title,
"status": status,
}
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index 763ac920..b2a301ee 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -17,7 +17,8 @@ RUN apt-get update \
&& cp config-template.json config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache -r requirements.txt \
- && pip install --no-cache -r requirements-optional.txt
+ && pip install --no-cache -r requirements-optional.txt \
+ && pip install --no-cache -e .
WORKDIR ${BUILD_PREFIX}
From b6571e5cadfe2e95287700dcf6f7d110a3999646 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Mon, 30 Mar 2026 21:39:38 +0800
Subject: [PATCH 059/399] fix: browser resource optimization
---
agent/tools/browser/browser_service.py | 379 +++++++++++++++++--------
1 file changed, 253 insertions(+), 126 deletions(-)
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
index cc6203a0..cc1b94e9 100644
--- a/agent/tools/browser/browser_service.py
+++ b/agent/tools/browser/browser_service.py
@@ -1,22 +1,26 @@
"""
Browser service - Playwright wrapper managing browser lifecycle and page operations.
-Lazily launches a Chromium instance on first use, reuses it across tool calls,
-and cleans up on close(). Headless mode is auto-detected based on platform and
-display availability.
+All Playwright calls run on a dedicated background thread so that callers from
+any worker thread can safely use the service. An idle-timeout mechanism
+automatically shuts down the browser (and its thread) after a configurable
+period of inactivity to free resources.
"""
-import asyncio
import os
import sys
-import re
import uuid
+import queue
import threading
-from typing import Optional, Dict, Any, List
+from typing import Optional, Dict, Any, List, Callable
from common.log import logger
-from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
+try:
+ from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
+ _HAS_PLAYWRIGHT = True
+except ImportError:
+ _HAS_PLAYWRIGHT = False
# ---------------------------------------------------------------------------
@@ -198,34 +202,105 @@ def _flatten_tree(node, indent=0) -> List[str]:
class BrowserService:
- """Manages a single Playwright browser instance with page operations."""
+ """Manages a Playwright browser on a dedicated background thread.
+
+ All Playwright operations are dispatched to a single long-lived thread via
+ a task queue. Callers from *any* worker thread can use the public API
+ safely. An idle timer automatically shuts the browser down after
+ ``idle_timeout`` seconds of inactivity (default 300 = 5 min).
+ """
+
+ _IDLE_TIMEOUT_DEFAULT = 300 # seconds
def __init__(self, config: Optional[Dict[str, Any]] = None):
self._config = config or {}
- self._playwright: Optional[Playwright] = None
- self._browser: Optional[Browser] = None
- self._context: Optional[BrowserContext] = None
- self._page: Optional[Page] = None
self._headless: Optional[bool] = None
self._screenshot_dir: Optional[str] = None
- self._owner_thread: Optional[int] = None
+
+ # Background thread state
+ self._thread: Optional[threading.Thread] = None
+ self._task_queue: queue.Queue = queue.Queue()
+ self._lock = threading.Lock()
+ self._alive = False
+ self._ready = threading.Event()
+
+ # Playwright objects (only accessed on the background thread)
+ self._playwright = None
+ self._browser = None
+ self._context = None
+ self._page = None
+
+ # Idle auto-release
+ idle_cfg = self._config.get("idle_timeout")
+ self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
+ self._idle_timer: Optional[threading.Timer] = None
# ------------------------------------------------------------------
- # Lifecycle
+ # Background-thread lifecycle
# ------------------------------------------------------------------
- def _ensure_browser(self):
- """Lazily launch browser on first use. Re-launch if called from a different thread."""
- current_thread = threading.current_thread().ident
+ def _start_thread(self):
+ """Start the dedicated Playwright thread if not already running."""
+ with self._lock:
+ if self._alive and self._thread and self._thread.is_alive():
+ return
+ self._alive = True
+ # Reuse existing queue so tasks queued during restart are not lost
+ if self._task_queue is None:
+ self._task_queue = queue.Queue()
+ self._ready = threading.Event()
+ self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread")
+ self._thread.start()
+ # Block until browser is ready (or failed)
+ self._ready.wait(timeout=30)
- # Playwright sync API is single-threaded; if called from a different thread, restart
- if self._owner_thread and self._owner_thread != current_thread and self._playwright:
- logger.info("[Browser] Thread changed, restarting browser instance")
- self._force_cleanup()
-
- if self._page and not self._page.is_closed():
+ def _run_loop(self):
+ """Event loop running on the dedicated thread. Processes tasks until stopped."""
+ logger.info("[Browser] Background thread started")
+ try:
+ self._launch_browser()
+ except Exception as e:
+ logger.error(f"[Browser] Failed to launch browser: {e}")
+ self._alive = False
+ self._ready.set()
+ self._drain_queue(RuntimeError(f"Browser launch failed: {e}"))
return
+ self._ready.set()
+ while self._alive:
+ try:
+ task = self._task_queue.get(timeout=1.0)
+ except queue.Empty:
+ continue
+ if task is None:
+ break
+ fn, args, kwargs, result_slot = task
+ try:
+ result_slot["value"] = fn(*args, **kwargs)
+ except Exception as e:
+ result_slot["error"] = e
+ finally:
+ result_slot["event"].set()
+
+ self._shutdown_browser()
+ self._drain_queue(RuntimeError("Browser thread stopped"))
+ logger.info("[Browser] Background thread exited")
+
+ def _drain_queue(self, error: Exception):
+ """Unblock all callers waiting on the queue with an error."""
+ while True:
+ try:
+ task = self._task_queue.get_nowait()
+ except queue.Empty:
+ break
+ if task is None:
+ continue
+ _, _, _, result_slot = task
+ result_slot["error"] = error
+ result_slot["event"].set()
+
+ def _launch_browser(self):
+ """Launch Chromium on the background thread."""
if self._headless is None:
headless_cfg = self._config.get("headless")
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
@@ -241,9 +316,7 @@ class BrowserService:
viewport_w = self._config.get("viewport_width", 1280)
viewport_h = self._config.get("viewport_height", 720)
- if not self._playwright:
- self._playwright = sync_playwright().start()
-
+ self._playwright = sync_playwright().start()
logger.info(f"[Browser] Launching Chromium (headless={self._headless})")
self._browser = self._playwright.chromium.launch(
headless=self._headless,
@@ -258,63 +331,20 @@ class BrowserService:
),
)
self._page = self._context.new_page()
- self._owner_thread = current_thread
logger.info("[Browser] Browser ready")
- @property
- def page(self) -> Page:
- self._ensure_browser()
- return self._page
-
- def _force_cleanup(self):
- """Force-release resources when the owner thread has already exited.
-
- Normal close() calls Playwright's sync API which requires the original
- thread to still be alive. When the thread is gone we can only kill the
- underlying browser process and discard all references.
- """
- logger.info("[Browser] Force-cleaning stale browser (owner thread exited)")
- try:
- if self._browser and self._browser.is_connected():
- pid = self._browser.process and self._browser.process.pid
- if pid:
- import signal, os as _os
- try:
- _os.kill(pid, signal.SIGKILL)
- logger.debug(f"[Browser] Killed browser process {pid}")
- except OSError:
- pass
- except Exception as e:
- logger.debug(f"[Browser] force cleanup browser kill: {e}")
- self._page = None
- self._context = None
- self._browser = None
- try:
- if self._playwright:
- self._playwright.stop()
- except Exception:
- pass
- self._playwright = None
- self._owner_thread = None
-
- def close(self):
- """Release all browser resources."""
- try:
- loop = asyncio.get_event_loop()
- old_handler = loop.get_exception_handler()
- loop.set_exception_handler(lambda l, c: None)
- except Exception:
- loop, old_handler = None, None
- try:
- if self._context:
- self._context.close()
- except Exception as e:
- logger.debug(f"[Browser] context close error: {e}")
- try:
- if self._browser:
- self._browser.close()
- except Exception as e:
- logger.debug(f"[Browser] browser close error: {e}")
+ def _shutdown_browser(self):
+ """Shut down all Playwright resources on the background thread."""
+ self._cancel_idle_timer()
+ for obj, label in [
+ (self._context, "context"),
+ (self._browser, "browser"),
+ ]:
+ try:
+ if obj:
+ obj.close()
+ except Exception as e:
+ logger.debug(f"[Browser] {label} close error: {e}")
try:
if self._playwright:
self._playwright.stop()
@@ -324,21 +354,77 @@ class BrowserService:
self._context = None
self._browser = None
self._playwright = None
- self._owner_thread = None
- if loop and old_handler:
- try:
- loop.set_exception_handler(old_handler)
- except Exception:
- pass
logger.info("[Browser] Browser closed")
+ def _submit(self, fn: Callable, *args, **kwargs):
+ """Submit *fn* to the background thread and block until it completes."""
+ self._start_thread()
+
+ if not self._alive:
+ raise RuntimeError("Browser is not available")
+
+ self._reset_idle_timer()
+
+ result_slot: Dict[str, Any] = {"event": threading.Event()}
+ self._task_queue.put((fn, args, kwargs, result_slot))
+
+ # Timeout prevents permanent hang if the background thread crashes
+ completed = result_slot["event"].wait(timeout=120)
+ if not completed:
+ raise TimeoutError("Browser operation timed out (120s)")
+
+ if "error" in result_slot:
+ raise result_slot["error"]
+ return result_slot.get("value")
+
# ------------------------------------------------------------------
- # Actions
+ # Idle auto-release
+ # ------------------------------------------------------------------
+
+ def _reset_idle_timer(self):
+ self._cancel_idle_timer()
+ if self._idle_timeout > 0:
+ self._idle_timer = threading.Timer(self._idle_timeout, self._on_idle_timeout)
+ self._idle_timer.daemon = True
+ self._idle_timer.start()
+
+ def _cancel_idle_timer(self):
+ if self._idle_timer:
+ self._idle_timer.cancel()
+ self._idle_timer = None
+
+ def _on_idle_timeout(self):
+ logger.info(f"[Browser] Idle for {self._idle_timeout}s, auto-releasing browser")
+ self.close()
+
+ # ------------------------------------------------------------------
+ # Public lifecycle
+ # ------------------------------------------------------------------
+
+ def close(self):
+ """Shut down browser and background thread (safe from any thread)."""
+ self._cancel_idle_timer()
+ with self._lock:
+ if not self._alive:
+ return
+ self._alive = False
+ t = self._thread
+ if self._task_queue is not None:
+ self._task_queue.put(None)
+ if t is not None and t.is_alive():
+ t.join(timeout=10)
+ with self._lock:
+ self._thread = None
+
+ # ------------------------------------------------------------------
+ # Actions (each method is dispatched to the background thread)
# ------------------------------------------------------------------
def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
- """Navigate to a URL and wait for the page to be ready."""
- page = self.page
+ return self._submit(self._do_navigate, url, timeout)
+
+ def _do_navigate(self, url: str, timeout: int) -> Dict[str, Any]:
+ page = self._page
try:
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
status = resp.status if resp else None
@@ -360,20 +446,14 @@ class BrowserService:
except Exception:
current_url = url
- return {
- "url": current_url,
- "title": title,
- "status": status,
- }
+ return {"url": current_url, "title": title, "status": status}
def snapshot(self, selector: Optional[str] = None) -> str:
- """
- Return a compact text representation of the page DOM for LLM consumption.
- Interactive elements get numeric refs usable in click/fill actions.
- """
- page = self.page
+ return self._submit(self._do_snapshot, selector)
+
+ def _do_snapshot(self, selector: Optional[str] = None) -> str:
+ page = self._page
try:
- target = selector or "body"
result = page.evaluate(_SNAPSHOT_JS)
except Exception as e:
return f"[Snapshot error: {e}]"
@@ -382,10 +462,18 @@ class BrowserService:
ref_count = result.get("refCount", 0)
lines = _flatten_tree(tree)
- header = f"Page: {page.title()} ({page.url})\nInteractive elements: {ref_count}\n---"
+ try:
+ title = page.title()
+ except Exception:
+ title = ""
+ try:
+ url = page.url
+ except Exception:
+ url = ""
+
+ header = f"Page: {title} ({url})\nInteractive elements: {ref_count}\n---"
body = "\n".join(lines)
- # Limit output size
max_chars = self._config.get("snapshot_max_chars", 30000)
if len(body) > max_chars:
body = body[:max_chars] + "\n... [snapshot truncated]"
@@ -393,20 +481,23 @@ class BrowserService:
return f"{header}\n{body}"
def screenshot(self, full_page: bool = False, cwd: str = "") -> str:
- """Take a screenshot and save to workspace/tmp. Returns file path."""
- page = self.page
+ return self._submit(self._do_screenshot, full_page, cwd)
+
+ def _do_screenshot(self, full_page: bool = False, cwd: str = "") -> str:
+ page = self._page
save_dir = self._get_screenshot_dir(cwd)
filename = f"screenshot_{uuid.uuid4().hex[:8]}.png"
filepath = os.path.join(save_dir, filename)
-
page.screenshot(path=filepath, full_page=full_page)
logger.info(f"[Browser] Screenshot saved: {filepath}")
return filepath
def click(self, ref: Optional[int] = None, selector: Optional[str] = None,
timeout: int = 5000) -> Dict[str, Any]:
- """Click an element by snapshot ref or CSS selector."""
- page = self.page
+ return self._submit(self._do_click, ref, selector, timeout)
+
+ def _do_click(self, ref, selector, timeout) -> Dict[str, Any]:
+ page = self._page
try:
if ref is not None:
result = page.evaluate(f"""
@@ -431,8 +522,10 @@ class BrowserService:
def fill(self, text: str, ref: Optional[int] = None,
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
- """Fill text into an input/textarea by snapshot ref or CSS selector."""
- page = self.page
+ return self._submit(self._do_fill, text, ref, selector, timeout)
+
+ def _do_fill(self, text, ref, selector, timeout) -> Dict[str, Any]:
+ page = self._page
try:
if ref is not None:
result = page.evaluate(f"""
@@ -458,8 +551,10 @@ class BrowserService:
def select(self, value: str, ref: Optional[int] = None,
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
- """Select an option in a element."""
- page = self.page
+ return self._submit(self._do_select, value, ref, selector, timeout)
+
+ def _do_select(self, value, ref, selector, timeout) -> Dict[str, Any]:
+ page = self._page
try:
if ref is not None:
result = page.evaluate(f"""
@@ -482,8 +577,10 @@ class BrowserService:
return {"error": f"Select failed: {e}"}
def scroll(self, direction: str = "down", amount: int = 500) -> Dict[str, Any]:
- """Scroll the page."""
- page = self.page
+ return self._submit(self._do_scroll, direction, amount)
+
+ def _do_scroll(self, direction, amount) -> Dict[str, Any]:
+ page = self._page
delta_map = {
"down": (0, amount),
"up": (0, -amount),
@@ -508,8 +605,10 @@ class BrowserService:
def wait(self, selector: Optional[str] = None, timeout: int = 5000,
state: str = "visible") -> Dict[str, Any]:
- """Wait for a selector to appear or a fixed timeout."""
- page = self.page
+ return self._submit(self._do_wait, selector, timeout, state)
+
+ def _do_wait(self, selector, timeout, state) -> Dict[str, Any]:
+ page = self._page
try:
if selector:
page.wait_for_selector(selector, timeout=timeout, state=state)
@@ -521,24 +620,48 @@ class BrowserService:
return {"error": f"Wait failed: {e}"}
def go_back(self) -> Dict[str, Any]:
- page = self.page
+ return self._submit(self._do_go_back)
+
+ def _do_go_back(self) -> Dict[str, Any]:
+ page = self._page
try:
page.go_back(wait_until="domcontentloaded", timeout=10000)
- return {"url": page.url, "title": page.title()}
+ try:
+ title = page.title()
+ except Exception:
+ title = ""
+ try:
+ url = page.url
+ except Exception:
+ url = ""
+ return {"url": url, "title": title}
except Exception as e:
return {"error": f"Go back failed: {e}"}
def go_forward(self) -> Dict[str, Any]:
- page = self.page
+ return self._submit(self._do_go_forward)
+
+ def _do_go_forward(self) -> Dict[str, Any]:
+ page = self._page
try:
page.go_forward(wait_until="domcontentloaded", timeout=10000)
- return {"url": page.url, "title": page.title()}
+ try:
+ title = page.title()
+ except Exception:
+ title = ""
+ try:
+ url = page.url
+ except Exception:
+ url = ""
+ return {"url": url, "title": title}
except Exception as e:
return {"error": f"Go forward failed: {e}"}
def get_text(self, selector: str) -> Dict[str, Any]:
- """Get text content of an element."""
- page = self.page
+ return self._submit(self._do_get_text, selector)
+
+ def _do_get_text(self, selector) -> Dict[str, Any]:
+ page = self._page
try:
text = page.text_content(selector, timeout=5000)
return {"text": text or ""}
@@ -546,8 +669,10 @@ class BrowserService:
return {"error": f"Get text failed: {e}"}
def evaluate(self, script: str) -> Dict[str, Any]:
- """Execute JavaScript in the page context."""
- page = self.page
+ return self._submit(self._do_evaluate, script)
+
+ def _do_evaluate(self, script) -> Dict[str, Any]:
+ page = self._page
try:
result = page.evaluate(script)
return {"result": result}
@@ -555,8 +680,10 @@ class BrowserService:
return {"error": f"Evaluate failed: {e}"}
def press(self, key: str) -> Dict[str, Any]:
- """Press a keyboard key (e.g. Enter, Tab, Escape)."""
- page = self.page
+ return self._submit(self._do_press, key)
+
+ def _do_press(self, key) -> Dict[str, Any]:
+ page = self._page
try:
page.keyboard.press(key)
page.wait_for_timeout(300)
From 1ae29180644140a825a13365db73727d0fab53d7 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 15:15:17 +0800
Subject: [PATCH 060/399] feat: support install browser in chat
---
.gitignore | 1 +
agent/chat/service.py | 20 ++++
agent/tools/send/send.py | 13 ++-
channel/web/static/js/console.js | 9 ++
channel/web/web_channel.py | 14 ++-
cli/commands/install.py | 182 ++++++++++++++++++++-----------
common/cloud_client.py | 57 +++++++++-
plugins/cow_cli/cow_cli.py | 91 +++++++++++++---
8 files changed, 306 insertions(+), 81 deletions(-)
diff --git a/.gitignore b/.gitignore
index 44fc64fc..0612e1e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ plugins/banwords/lib/__pycache__
!plugins/cow_cli
client_config.json
ref/
+**/.dev.vars
.cursor/
local/
node_modules/
diff --git a/agent/chat/service.py b/agent/chat/service.py
index 1afe1691..58931b00 100644
--- a/agent/chat/service.py
+++ b/agent/chat/service.py
@@ -75,6 +75,26 @@ class ChatService:
# a new segment; collect tool results until turn_end.
state.pending_tool_results = []
+ elif event_type == "file_to_send":
+ # Cloud CHAT stream: local paths are useless to the web UI; push a markdown link when we have a public URL.
+ url = data.get("url") or ""
+ if url:
+ msg = (data.get("message") or "").strip()
+ fname = data.get("file_name") or "file"
+ ft = data.get("file_type") or "file"
+ parts = []
+ if msg:
+ parts.append(f"{msg}\n\n")
+ if ft == "image":
+ parts.append(f"")
+ else:
+ parts.append(f"[{fname}]({url})")
+ send_chunk_fn({
+ "chunk_type": "content",
+ "delta": "\n\n" + "".join(parts) + "\n\n",
+ "segment_id": state.segment_id,
+ })
+
elif event_type == "tool_execution_start":
# Notify the client that a tool is about to run (with its input args)
tool_name = data.get("tool_name", "")
diff --git a/agent/tools/send/send.py b/agent/tools/send/send.py
index 4adcc743..2a130ab0 100644
--- a/agent/tools/send/send.py
+++ b/agent/tools/send/send.py
@@ -98,7 +98,18 @@ class Send(BaseTool):
"size_formatted": self._format_size(file_size),
"message": message or f"正在发送 {file_name}"
}
-
+
+ try:
+ from common.cloud_client import get_website_base_url, copy_send_file
+
+ # Do nothing when in local env
+ if get_website_base_url():
+ url = copy_send_file(absolute_path, self.cwd)
+ if url:
+ result["url"] = url
+ except Exception:
+ pass
+
return ToolResult.success(result)
def _resolve_path(self, path: str) -> str:
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index f2c3ad29..ae87c094 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -891,6 +891,15 @@ function startSSE(requestId, loadingEl, timestamp) {
mediaEl.appendChild(fileEl);
scrollChatToBottom();
+ } else if (item.type === 'phase') {
+ // Coarse progress (e.g. cow install-browser); must not close SSE (unlike "done")
+ ensureBotEl();
+ const wrap = document.createElement('div');
+ wrap.className = 'text-xs sm:text-sm text-slate-600 dark:text-slate-400 border-l-2 border-primary-400 pl-2 py-1 my-0.5';
+ wrap.textContent = String(item.content || '');
+ stepsEl.appendChild(wrap);
+ scrollChatToBottom();
+
} else if (item.type === 'done') {
es.close();
delete activeStreams[requestId];
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 5dd57ab9..c19fdd63 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -96,10 +96,22 @@ class WebChannel(ChatChannel):
logger.error(f"No session_id found for request {request_id}")
return
- # SSE mode: push done event to SSE queue
+ # SSE mode: push events to SSE queue
if request_id in self.sse_queues:
content = reply.content if reply.content is not None else ""
+ # Intermediate status lines (e.g. /install-browser phases) must NOT use "done",
+ # or the frontend closes EventSource and drops subsequent events.
+ if getattr(reply, "sse_phase", False):
+ self.sse_queues[request_id].put({
+ "type": "phase",
+ "content": content,
+ "request_id": request_id,
+ "timestamp": time.time(),
+ })
+ logger.debug(f"SSE phase for request {request_id}")
+ return
+
# Files are already pushed via on_event (file_to_send) during agent execution.
# Skip duplicate file pushes here; just let the done event through.
if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"):
diff --git a/cli/commands/install.py b/cli/commands/install.py
index 8a6ab9ed..addec52c 100644
--- a/cli/commands/install.py
+++ b/cli/commands/install.py
@@ -3,6 +3,7 @@
import os
import sys
import subprocess
+from typing import Callable, Optional
import click
@@ -11,6 +12,16 @@ PLAYWRIGHT_LEGACY_VERSION = "1.28.0"
GLIBC_THRESHOLD = (2, 28)
CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
+# stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None
+StreamFn = Callable[[str, Optional[str]], None]
+# on_phase(msg) — coarse-grained progress for chat channels (Chinese)
+PhaseFn = Callable[[str], None]
+
+
+def _phase(cb: Optional[PhaseFn], msg: str) -> None:
+ if cb:
+ cb(msg)
+
def _has_display() -> bool:
"""Check if a graphical display is available (Linux only)."""
@@ -66,134 +77,183 @@ def _is_china_network() -> bool:
return False
-def _pip_install(package_spec: str) -> int:
+def _pip_install(package_spec: str, stream: StreamFn) -> int:
"""Install a package, retrying with --user on permission failure."""
python = sys.executable
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
if ret != 0:
- click.echo(" Retrying with --user flag...")
+ stream(" Retrying with --user flag...", "yellow")
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
return ret
-@click.command("install-browser")
-def install_browser():
- """Install browser tool dependencies (Playwright + Chromium)."""
+def _default_stream(msg: str, fg: Optional[str] = None) -> None:
+ """CLI: colored click output."""
+ if fg == "yellow":
+ click.echo(click.style(msg, fg="yellow"))
+ elif fg == "green":
+ click.echo(click.style(msg, fg="green"))
+ elif fg == "red":
+ click.echo(click.style(msg, fg="red"))
+ else:
+ click.echo(msg)
+
+
+def run_install_browser(
+ stream: Optional[StreamFn] = None,
+ on_phase: Optional[PhaseFn] = None,
+) -> int:
+ """
+ Install Playwright Python package, optional Linux deps, and Chromium.
+
+ Reused by ``cow install-browser`` CLI and chat ``/install-browser``.
+
+ Args:
+ stream: Optional callback ``(message, fg)`` for each line. ``fg`` is
+ ``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output.
+ on_phase: Optional callback for coarse progress (e.g. push to chat);
+ messages are short Chinese status lines.
+
+ Returns:
+ 0 on success, 1 on fatal failure (pip or chromium install failed).
+ """
+ stream = stream or _default_stream
python = sys.executable
legacy_mode = False
- # Determine playwright version based on glibc
+ _phase(on_phase, "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…")
+
glibc = _get_glibc_version()
if glibc and glibc < GLIBC_THRESHOLD:
legacy_mode = True
glibc_str = f"{glibc[0]}.{glibc[1]}"
- click.echo(click.style(
+ stream(
f"glibc {glibc_str} detected (< 2.28). "
f"Will install playwright {PLAYWRIGHT_LEGACY_VERSION} for compatibility.",
- fg="yellow",
- ))
- click.echo(click.style(
- " Note: upgrade your OS for full browser tool support.",
- fg="yellow",
- ))
- click.echo()
+ "yellow",
+ )
+ stream(" Note: upgrade your OS for full browser tool support.", "yellow")
+ stream("")
+ _phase(
+ on_phase,
+ f"ℹ️ 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}。",
+ )
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
- # Step 1: Install playwright package
- click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
- ret = _pip_install(f"playwright=={target_version}")
+ _phase(on_phase, "📦 [1/3] 正在安装 Playwright Python 包…")
+ stream("[1/3] Installing playwright Python package...", "yellow")
+ ret = _pip_install(f"playwright=={target_version}", stream)
if ret != 0:
- click.echo(click.style("Failed to install playwright package.", fg="red"))
- raise SystemExit(1)
+ stream("Failed to install playwright package.", "red")
+ _phase(on_phase, "❌ [1/3] Playwright Python 包安装失败。")
+ return 1
installed = _get_installed_version()
if installed:
- click.echo(click.style(f" playwright {installed} installed.", fg="green"))
- click.echo()
+ stream(f" playwright {installed} installed.", "green")
+ stream("")
+ _phase(on_phase, f"✅ [1/3] Playwright 包已安装({installed or target_version})。")
- # Step 2: System dependencies (Linux only)
if sys.platform == "linux":
- click.echo(click.style("[2/3] Installing system dependencies (Linux)...", fg="yellow"))
+ _phase(on_phase, "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo)…")
+ stream("[2/3] Installing system dependencies (Linux)...", "yellow")
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
if ret != 0:
- click.echo(click.style(
+ stream(
" Could not auto-install system deps (may need sudo).\n"
f" Run manually: sudo {python} -m playwright install-deps chromium",
- fg="yellow",
- ))
- # Install CJK fonts for proper Chinese/Japanese/Korean rendering in screenshots
- click.echo(" Installing CJK fonts...")
+ "yellow",
+ )
+ # Prefer fonts-wqy-zenhei only (~few MB). fonts-noto-cjk is much larger (~150MB+).
+ stream(" Installing CJK font (fonts-wqy-zenhei, lightweight)...")
font_ret = subprocess.call(
- ["sudo", "apt-get", "install", "-y", "fonts-noto-cjk", "fonts-wqy-zenhei"],
+ ["sudo", "apt-get", "install", "-y", "--no-install-recommends", "fonts-wqy-zenhei"],
stderr=subprocess.DEVNULL,
)
if font_ret != 0:
- click.echo(click.style(
- " Could not auto-install CJK fonts.\n"
- " Run manually: sudo apt-get install -y fonts-noto-cjk fonts-wqy-zenhei",
- fg="yellow",
- ))
+ stream(
+ " Could not auto-install CJK font.\n"
+ " Run manually: sudo apt-get install -y fonts-wqy-zenhei\n"
+ " (Optional, larger full coverage: sudo apt-get install -y fonts-noto-cjk)",
+ "yellow",
+ )
else:
subprocess.call(["fc-cache", "-fv"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- click.echo(click.style(" CJK fonts installed.", fg="green"))
+ stream(" CJK font (wqy-zenhei) installed.", "green")
+ _phase(
+ on_phase,
+ "✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。",
+ )
else:
- click.echo(click.style(f"[2/3] Skipping system deps (not needed on {sys.platform}).", fg="yellow"))
- click.echo()
+ stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow")
+ _phase(on_phase, f"ℹ️ [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。")
+ stream("")
- # Step 3: Install Chromium
- click.echo(click.style("[3/3] Installing Chromium browser...", fg="yellow"))
+ _phase(on_phase, "🌐 [3/3] 正在下载并安装 Chromium(体积较大,请耐心等待)…")
+ stream("[3/3] Installing Chromium browser...", "yellow")
cmd = [python, "-m", "playwright", "install", "chromium"]
- # --only-shell requires playwright >= 1.57
if _is_headless_linux() and not legacy_mode:
ver = _version_tuple(installed or "")
if ver >= (1, 57, 0):
cmd.append("--only-shell")
- click.echo(" (headless shell for Linux server)")
+ stream(" (headless shell for Linux server)", None)
else:
- click.echo(" (full Chromium)")
+ stream(" (full Chromium)", None)
elif sys.platform == "linux" and _has_display():
- click.echo(" (full browser for Linux desktop)")
+ stream(" (full browser for Linux desktop)", None)
- # Use China mirror if pip is configured with a domestic index
env = os.environ.copy()
use_mirror = _is_china_network()
if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
- click.echo(f" (using China mirror: {CHINA_MIRROR})")
+ stream(f" (using China mirror: {CHINA_MIRROR})", None)
+ _phase(on_phase, "📡 检测到国内 pip 源配置,Chromium 将优先走国内镜像下载。")
ret = subprocess.call(cmd, env=env)
- # Fallback: if mirror download failed, retry with official CDN
if ret != 0 and use_mirror:
- click.echo(click.style(
- " Mirror download failed, retrying with official CDN...",
- fg="yellow",
- ))
+ stream(" Mirror download failed, retrying with official CDN...", "yellow")
+ _phase(on_phase, "⚠️ 镜像下载失败,正在改用官方源重试…")
env_no_mirror = os.environ.copy()
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
ret = subprocess.call(cmd, env=env_no_mirror)
if ret != 0:
- click.echo(click.style("Failed to install Chromium.", fg="red"))
- raise SystemExit(1)
+ stream("Failed to install Chromium.", "red")
+ _phase(on_phase, "❌ [3/3] Chromium 安装失败。")
+ return 1
- # Quick smoke test
- click.echo()
- click.echo("Verifying browser installation...")
+ stream("")
+ _phase(on_phase, "✅ [3/3] Chromium 已安装。")
+
+ stream("Verifying browser installation...", None)
+ _phase(on_phase, "🔍 正在验证 Playwright 能否正常加载…")
ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL,
)
if ret != 0:
- click.echo(click.style(
+ stream(
" Warning: playwright import failed. Browser tool may not work on this system.\n"
" Consider upgrading your OS or using Docker.",
- fg="yellow",
- ))
+ "yellow",
+ )
+ _phase(on_phase, "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。")
else:
- click.echo(click.style(" Verification passed.", fg="green"))
+ stream(" Verification passed.", "green")
+ _phase(on_phase, "✅ 验证通过。")
- click.echo()
- click.echo(click.style("Browser tool ready! Restart CowAgent to enable it.", fg="green"))
+ stream("")
+ stream("Browser tool ready! Restart CowAgent to enable it.", "green")
+ _phase(on_phase, "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。")
+ return 0
+
+
+@click.command("install-browser")
+def install_browser():
+ """Install browser tool dependencies (Playwright + Chromium)."""
+ code = run_install_browser()
+ if code != 0:
+ raise SystemExit(code)
diff --git a/common/cloud_client.py b/common/cloud_client.py
index 62b96536..073e734d 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -487,6 +487,19 @@ class CloudClient(LinkAIClient):
session_id = f"session_{session_id}"
logger.info(f"[CloudClient] on_chat: session={session_id}, channel={channel_type}, query={query[:80]}")
+ # Intercept cow/slash commands before the agent runs
+ try:
+ from plugins import PluginManager
+ mgr = PluginManager()
+ plugin = mgr.plugins.get("cow_cli")
+ if plugin and hasattr(plugin, "execute"):
+ result = plugin.execute(query, session_id=session_id)
+ if result is not None:
+ send_chunk_fn({"chunk_type": "content", "delta": result, "segment_id": 0})
+ return
+ except Exception as e:
+ logger.warning(f"[CloudClient] cow_cli intercept failed: {e}")
+
svc = self.chat_service
if svc is None:
raise RuntimeError("ChatService not available")
@@ -629,9 +642,9 @@ def get_deployment_id() -> str:
def get_website_base_url() -> str:
- """Return the public URL prefix that maps to the workspace websites/ dir.
+ """Return the URL prefix that maps to the workspace websites/ dir.
- Returns empty string when cloud deployment is not configured.
+ Do nothing when in local env.
"""
deployment_id = get_deployment_id()
if not deployment_id:
@@ -648,6 +661,42 @@ def get_website_base_url() -> str:
return f"https://app.{domain}/{deployment_id}"
+# Subdir under websites/ used by the send tool
+COW_SEND_WEB_SUBDIR = "cow-send"
+
+
+def copy_send_file(src_path: str, workspace_root: str) -> str:
+ """Copy *src_path* into ``websites/cow-send/`` and return its URL.
+
+ Returns empty string in local env.
+ """
+ import shutil
+ import uuid
+
+ from common.utils import expand_path
+
+ base = get_website_base_url()
+ if not base or not src_path or not os.path.isfile(src_path):
+ return ""
+ ws = os.path.abspath(expand_path(workspace_root))
+ send_dir = os.path.join(ws, "websites", COW_SEND_WEB_SUBDIR)
+ try:
+ os.makedirs(send_dir, exist_ok=True)
+ except OSError:
+ return ""
+ ext = os.path.splitext(src_path)[1].lower()
+ if len(ext) > 12 or not ext.replace(".", "").isalnum():
+ ext = ""
+ dest_name = f"{uuid.uuid4().hex}{ext}"
+ dest_path = os.path.join(send_dir, dest_name)
+ try:
+ shutil.copy2(src_path, dest_path)
+ except OSError as e:
+ logger.warning(f"[cloud] copy_send_file: copy failed: {e}")
+ return ""
+ return f"{base}/{COW_SEND_WEB_SUBDIR}/{dest_name}"
+
+
def build_website_prompt(workspace_dir: str) -> list:
"""Build system prompt lines for cloud website/file sharing rules.
@@ -668,8 +717,8 @@ def build_website_prompt(workspace_dir: str) -> list:
f" - 例如: `websites/my-app/index.html` → `{base_url}/my-app/index.html`",
"",
"2. **生成文件分享** (PPT、PDF、图片、音视频等): 当你为用户生成了需要下载或查看的文件时,**可以**将文件保存到 `websites/` 目录中",
- f" - 例如: 生成的PPT保存到 `websites/files/report.pptx` → 下载链接为 `{base_url}/files/report.pptx`",
- " - 你仍然可以同时使用 `send` 工具发送文件(在飞书、钉钉等IM渠道中有效),但**必须同时在回复文本中提供下载链接**作为兜底,因为部分渠道(如网页端)无法通过 send 接收本地文件",
+ f" - 例如: 生成的PPT保存到 `websites/files/report.pptx` → 下载链接为 `{base_url}/files/report.pptx`",
+ " - 你仍然可以同时使用 `send` 工具发送文件(在微信、飞书、钉钉、web等渠道中有效),但**必须同时在回复文本中提供下载链接**作为兜底,因为部分渠道无法通过 send 接收本地文件",
"",
"3. **必须发送链接**: 无论是网页还是文件,生成后**必须将完整的访问/下载链接直接写在回复文本中发送给用户**",
"",
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 97f722d8..39b578d9 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -3,10 +3,11 @@ CowCli plugin - Intercept cow/slash commands in chat messages.
Matches messages like:
cow skill list
- cow context clear
+ cow install-browser
/skill list
/context clear
/status
+ /install-browser
Does NOT match:
cow是什么
@@ -30,6 +31,7 @@ KNOWN_COMMANDS = {
"help", "version", "status", "logs",
"start", "stop", "restart",
"skill", "context", "config",
+ "install-browser",
}
# Commands that can only run from the CLI (terminal), not in chat
@@ -106,14 +108,27 @@ class CowCliPlugin(Plugin):
# Command dispatch
# ------------------------------------------------------------------
- def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str:
+ def execute(self, query: str, session_id: str = "") -> str:
+ """Execute a cow/slash command string without a channel context.
+
+ Used by cloud on_chat to intercept commands before the agent runs.
+ Returns None when *query* is not a recognised command.
+ """
+ parsed = self._parse_command(query.strip())
+ if not parsed:
+ return None
+ cmd, args = parsed
+ return self._dispatch(cmd, args, e_context=None, session_id=session_id)
+
+ def _dispatch(self, cmd: str, args: str, e_context: EventContext, session_id: str = "") -> str:
if cmd in CLI_ONLY_COMMANDS:
return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}"
- handler = getattr(self, f"_cmd_{cmd}", None)
+ handler_attr = "_cmd_" + cmd.replace("-", "_")
+ handler = getattr(self, handler_attr, None)
if handler:
try:
- return handler(args, e_context)
+ return handler(args, e_context, session_id=session_id)
except Exception as e:
logger.error(f"[CowCli] command '{cmd}' failed: {e}")
return f"命令执行失败: {e}"
@@ -124,7 +139,7 @@ class CowCliPlugin(Plugin):
# help / version
# ------------------------------------------------------------------
- def _cmd_help(self, args: str, e_context: EventContext) -> str:
+ def _cmd_help(self, args: str, e_context, **_) -> str:
lines = [
"📋 CowAgent 命令列表",
"",
@@ -142,19 +157,20 @@ class CowCliPlugin(Plugin):
" /config 查看当前配置",
" /config 查看某项配置",
" /config 修改配置",
+ " /install-browser 安装浏览器工具依赖",
"",
"💡 也可以用 cow 代替 /",
]
return "\n".join(lines)
- def _cmd_version(self, args: str, e_context: EventContext) -> str:
+ def _cmd_version(self, args: str, e_context, **_) -> str:
return f"CowAgent v{__version__}"
# ------------------------------------------------------------------
# status
# ------------------------------------------------------------------
- def _cmd_status(self, args: str, e_context: EventContext) -> str:
+ def _cmd_status(self, args: str, e_context: EventContext, session_id: str = "") -> str:
from config import conf
cfg = conf()
@@ -174,7 +190,7 @@ class CowCliPlugin(Plugin):
mode = "Agent" if cfg.get("agent") else "Chat"
lines.append(f" 模式: {mode}")
- session_id = self._get_session_id(e_context)
+ session_id = self._get_session_id(e_context, fallback=session_id)
agent = self._get_agent(session_id)
if agent:
lines.append("")
@@ -199,7 +215,7 @@ class CowCliPlugin(Plugin):
# logs
# ------------------------------------------------------------------
- def _cmd_logs(self, args: str, e_context: EventContext) -> str:
+ def _cmd_logs(self, args: str, e_context, **_) -> str:
num_lines = 20
if args.strip().isdigit():
num_lines = min(int(args.strip()), 50)
@@ -236,8 +252,8 @@ class CowCliPlugin(Plugin):
# context
# ------------------------------------------------------------------
- def _cmd_context(self, args: str, e_context: EventContext) -> str:
- session_id = self._get_session_id(e_context)
+ def _cmd_context(self, args: str, e_context: EventContext, session_id: str = "") -> str:
+ session_id = self._get_session_id(e_context, fallback=session_id)
agent = self._get_agent(session_id)
sub = args.strip().lower()
@@ -299,7 +315,7 @@ class CowCliPlugin(Plugin):
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
- def _cmd_config(self, args: str, e_context: EventContext) -> str:
+ def _cmd_config(self, args: str, e_context, **_) -> str:
from config import conf, load_config
import json as _json
@@ -418,11 +434,56 @@ class CowCliPlugin(Plugin):
return btype
return const.OPENAI
+ # ------------------------------------------------------------------
+ # install-browser (shared logic with cow install-browser CLI)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _send_install_progress(e_context, text: str) -> None:
+ """Push a short status line to the chat channel (SSE: phase event, not done)."""
+ if e_context is None:
+ logger.info(f"[CowCli] install-browser: {text}")
+ return
+ try:
+ channel = e_context["channel"]
+ context = e_context["context"]
+ if channel and context:
+ r = Reply(ReplyType.TEXT, text)
+ r.sse_phase = True
+ channel.send(r, context)
+ except Exception as e:
+ logger.warning(f"[CowCli] install-browser progress send failed: {e}")
+
+ def _cmd_install_browser(self, args: str, e_context, **_) -> str:
+ from cli.commands.install import run_install_browser
+
+ if args.strip():
+ return (
+ "用法: /install-browser\n\n"
+ "无需参数,等同于终端执行 `cow install-browser`。\n"
+ "安装过程可能持续数分钟;进度会以多条消息推送,pip 详细输出见服务日志。"
+ )
+
+ # Suppress detailed stream in chat; phases go through channel.send
+ def _noop_stream(msg: str, fg=None):
+ pass
+
+ code = run_install_browser(
+ stream=_noop_stream,
+ on_phase=lambda m: self._send_install_progress(e_context, m),
+ )
+ if code != 0:
+ return (
+ "❌ 安装未成功结束,请查看上方分段提示或服务器日志;"
+ "也可在终端执行 `cow install-browser`。"
+ )
+ return "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。"
+
# ------------------------------------------------------------------
# skill
# ------------------------------------------------------------------
- def _cmd_skill(self, args: str, e_context: EventContext) -> str:
+ def _cmd_skill(self, args: str, e_context, **_) -> str:
parts = args.strip().split(None, 1)
sub = parts[0].lower() if parts else ""
sub_args = parts[1].strip() if len(parts) > 1 else ""
@@ -781,7 +842,9 @@ class CowCliPlugin(Plugin):
# Helpers
# ------------------------------------------------------------------
- def _get_session_id(self, e_context: EventContext) -> str:
+ def _get_session_id(self, e_context, fallback: str = "") -> str:
+ if e_context is None:
+ return fallback
context = e_context["context"]
return context.kwargs.get("session_id") or context.get("session_id", "")
From 1f17ebe69e20484b3a8abe38d986e80659de352f Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 16:05:05 +0800
Subject: [PATCH 061/399] feat: add browser install in docker image
---
docker/Dockerfile.latest | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index b2a301ee..9ccf3676 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -4,6 +4,10 @@ LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
+# Set to "false" to skip Playwright/Chromium and produce a smaller image
+ARG INSTALL_BROWSER=true
+
+ENV PLAYWRIGHT_BROWSERS_PATH=/app/ms-playwright
RUN echo /etc/apt/sources.list
# RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
@@ -12,13 +16,20 @@ ENV BUILD_PREFIX=/app
ADD . ${BUILD_PREFIX}
RUN apt-get update \
- &&apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra\
+ && apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra \
&& cd ${BUILD_PREFIX} \
&& cp config-template.json config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache -r requirements.txt \
&& pip install --no-cache -r requirements-optional.txt \
- && pip install --no-cache -e .
+ && pip install --no-cache -e . \
+ && if [ "$INSTALL_BROWSER" = "true" ]; then \
+ pip install --no-cache "playwright==1.52.0" \
+ && python -m playwright install-deps chromium \
+ && mkdir -p /app/ms-playwright \
+ && python -m playwright install chromium; \
+ fi \
+ && rm -rf /var/lib/apt/lists/*
WORKDIR ${BUILD_PREFIX}
@@ -28,6 +39,7 @@ RUN chmod +x /entrypoint.sh \
&& mkdir -p /home/agent/cow \
&& groupadd -r agent \
&& useradd -r -g agent -s /bin/bash -d /home/agent agent \
- && chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
+ && chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib \
+ && if [ -d /app/ms-playwright ]; then chown -R agent:agent /app/ms-playwright; fi
ENTRYPOINT ["/entrypoint.sh"]
From 6d1369900e050b808b368d5d1777979bff81d509 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 16:06:45 +0800
Subject: [PATCH 062/399] feat: add source args in docker building
---
docker/Dockerfile.latest | 3 +++
1 file changed, 3 insertions(+)
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index 9ccf3676..45597bf5 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -6,8 +6,11 @@ ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
# Set to "false" to skip Playwright/Chromium and produce a smaller image
ARG INSTALL_BROWSER=true
+# pip index URL (override for faster builds in CN)
+ARG PIP_INDEX_URL=https://pypi.org/simple
ENV PLAYWRIGHT_BROWSERS_PATH=/app/ms-playwright
+ENV PIP_INDEX_URL=${PIP_INDEX_URL}
RUN echo /etc/apt/sources.list
# RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
From 14ff2a15e70b82534a5ac22163f7f06806f68de1 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 16:25:47 +0800
Subject: [PATCH 063/399] fix(cli): cow cli in docker chat
---
common/cloud_client.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/common/cloud_client.py b/common/cloud_client.py
index 073e734d..656c1604 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -491,9 +491,9 @@ class CloudClient(LinkAIClient):
try:
from plugins import PluginManager
mgr = PluginManager()
- plugin = mgr.plugins.get("cow_cli")
- if plugin and hasattr(plugin, "execute"):
- result = plugin.execute(query, session_id=session_id)
+ instance = mgr.instances.get("COW_CLI")
+ if instance and hasattr(instance, "execute"):
+ result = instance.execute(query, session_id=session_id)
if result is not None:
send_chunk_fn({"chunk_type": "content", "delta": result, "segment_id": 0})
return
From d2a462a279a4d9127964dd452ee23dac4693186d Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 16:34:47 +0800
Subject: [PATCH 064/399] fix: add apt source in docker file
---
docker/Dockerfile.latest | 21 ++++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index 45597bf5..93145f52 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -6,16 +6,18 @@ ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
# Set to "false" to skip Playwright/Chromium and produce a smaller image
ARG INSTALL_BROWSER=true
-# pip index URL (override for faster builds in CN)
-ARG PIP_INDEX_URL=https://pypi.org/simple
+# Set to "true" to use China mirrors for apt / pip / playwright (faster in CN)
+ARG USE_CN_MIRROR=false
ENV PLAYWRIGHT_BROWSERS_PATH=/app/ms-playwright
-ENV PIP_INDEX_URL=${PIP_INDEX_URL}
-
-RUN echo /etc/apt/sources.list
-# RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
ENV BUILD_PREFIX=/app
+# Optionally switch apt and pip to China mirrors
+RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
+ sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list; \
+ pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple/; \
+ fi
+
ADD . ${BUILD_PREFIX}
RUN apt-get update \
@@ -30,7 +32,12 @@ RUN apt-get update \
pip install --no-cache "playwright==1.52.0" \
&& python -m playwright install-deps chromium \
&& mkdir -p /app/ms-playwright \
- && python -m playwright install chromium; \
+ && if [ "$USE_CN_MIRROR" = "true" ]; then \
+ PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright \
+ python -m playwright install chromium; \
+ else \
+ python -m playwright install chromium; \
+ fi; \
fi \
&& rm -rf /var/lib/apt/lists/*
From 4470d4c3521db9ee4c019ab15c3d63ad0404ba01 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 16:56:27 +0800
Subject: [PATCH 065/399] fix: reduce docker image size
---
docker/Dockerfile.latest | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index 93145f52..4fb5b587 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -20,6 +20,7 @@ RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
ADD . ${BUILD_PREFIX}
+# All heavy installs + user creation in ONE layer to avoid chown duplication
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra \
&& cd ${BUILD_PREFIX} \
@@ -39,17 +40,17 @@ RUN apt-get update \
python -m playwright install chromium; \
fi; \
fi \
- && rm -rf /var/lib/apt/lists/*
+ && rm -rf /var/lib/apt/lists/* \
+ && mkdir -p /home/agent/cow \
+ && groupadd -r agent \
+ && useradd -r -g agent -s /bin/bash -d /home/agent agent \
+ && chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
WORKDIR ${BUILD_PREFIX}
ADD docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
- && mkdir -p /home/agent/cow \
- && groupadd -r agent \
- && useradd -r -g agent -s /bin/bash -d /home/agent agent \
- && chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib \
- && if [ -d /app/ms-playwright ]; then chown -R agent:agent /app/ms-playwright; fi
+ && chown agent:agent /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
From 6d9b7baeb4ead98a257258951477de417329dff4 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 18:14:49 +0800
Subject: [PATCH 066/399] fix(weixin): file send failed
---
agent/chat/service.py | 20 --------------------
channel/weixin/weixin_api.py | 17 +++++++++++------
plugins/cow_cli/cow_cli.py | 4 +---
3 files changed, 12 insertions(+), 29 deletions(-)
diff --git a/agent/chat/service.py b/agent/chat/service.py
index 58931b00..1afe1691 100644
--- a/agent/chat/service.py
+++ b/agent/chat/service.py
@@ -75,26 +75,6 @@ class ChatService:
# a new segment; collect tool results until turn_end.
state.pending_tool_results = []
- elif event_type == "file_to_send":
- # Cloud CHAT stream: local paths are useless to the web UI; push a markdown link when we have a public URL.
- url = data.get("url") or ""
- if url:
- msg = (data.get("message") or "").strip()
- fname = data.get("file_name") or "file"
- ft = data.get("file_type") or "file"
- parts = []
- if msg:
- parts.append(f"{msg}\n\n")
- if ft == "image":
- parts.append(f"")
- else:
- parts.append(f"[{fname}]({url})")
- send_chunk_fn({
- "chunk_type": "content",
- "delta": "\n\n" + "".join(parts) + "\n\n",
- "segment_id": state.segment_id,
- })
-
elif event_type == "tool_execution_start":
# Notify the client that a tool is about to run (with its input args)
tool_name = data.get("tool_name", "")
diff --git a/channel/weixin/weixin_api.py b/channel/weixin/weixin_api.py
index b98fe5df..58ef70ef 100644
--- a/channel/weixin/weixin_api.py
+++ b/channel/weixin/weixin_api.py
@@ -303,13 +303,18 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
filesize=cipher_size,
aeskey=aes_key_hex,
)
- upload_param = resp.get("upload_param", "")
- if not upload_param:
- raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
- cdn_url = (f"{api.cdn_base_url}/upload"
- f"?encrypted_query_param={quote(upload_param)}"
- f"&filekey={quote(filekey)}")
+ # API may return either upload_full_url (new) or upload_param (legacy)
+ upload_full_url = resp.get("upload_full_url", "")
+ upload_param = resp.get("upload_param", "")
+ if upload_full_url:
+ cdn_url = upload_full_url
+ elif upload_param:
+ cdn_url = (f"{api.cdn_base_url}/upload"
+ f"?encrypted_query_param={quote(upload_param)}"
+ f"&filekey={quote(filekey)}")
+ else:
+ raise RuntimeError(f"[Weixin] getUploadUrl returned neither upload_full_url nor upload_param: {resp}")
cdn_resp = requests.post(cdn_url, data=encrypted, headers={
"Content-Type": "application/octet-stream",
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 39b578d9..a2caf0fc 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -683,10 +683,8 @@ class CowCliPlugin(Plugin):
lines = []
for skill_name in result.installed:
desc = _read_skill_description(os.path.join(skills_dir, skill_name))
- lines.append(f"✅ {skill_name}")
+ lines.append(f"✅ 技能安装成功:{skill_name}")
if desc:
- if len(desc) > 60:
- desc = desc[:57] + "…"
lines.append(f" {desc}")
if len(result.installed) > 1:
From 7f94d37c2ec6b2a9725d35e14731586a5f99cb08 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 20:20:13 +0800
Subject: [PATCH 067/399] fix: auto-install font in browser
---
agent/chat/service.py | 17 +++++++++++++++++
docker/Dockerfile.latest | 3 ++-
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/agent/chat/service.py b/agent/chat/service.py
index 1afe1691..de7d345a 100644
--- a/agent/chat/service.py
+++ b/agent/chat/service.py
@@ -75,6 +75,23 @@ class ChatService:
# a new segment; collect tool results until turn_end.
state.pending_tool_results = []
+ elif event_type == "file_to_send":
+ url = data.get("url") or ""
+ if url:
+ fname = data.get("file_name") or "file"
+ ft = data.get("file_type") or "file"
+ if ft == "image":
+ link = f""
+ else:
+ link = f"[{fname}]({url})"
+ send_chunk_fn({
+ "chunk_type": "content",
+ "delta": "\n\n" + link + "\n\n",
+ "segment_id": state.segment_id,
+ })
+ # Remove url so the model won't repeat it in its reply
+ data.pop("url", None)
+
elif event_type == "tool_execution_start":
# Notify the client that a tool is about to run (with its input args)
tool_name = data.get("tool_name", "")
diff --git a/docker/Dockerfile.latest b/docker/Dockerfile.latest
index 4fb5b587..f86bb555 100644
--- a/docker/Dockerfile.latest
+++ b/docker/Dockerfile.latest
@@ -30,7 +30,8 @@ RUN apt-get update \
&& pip install --no-cache -r requirements-optional.txt \
&& pip install --no-cache -e . \
&& if [ "$INSTALL_BROWSER" = "true" ]; then \
- pip install --no-cache "playwright==1.52.0" \
+ apt-get install -y --no-install-recommends fonts-wqy-zenhei \
+ && pip install --no-cache "playwright==1.52.0" \
&& python -m playwright install-deps chromium \
&& mkdir -p /app/ms-playwright \
&& if [ "$USE_CN_MIRROR" = "true" ]; then \
From 8744810b258b8a90113097860d27306a9be5200d Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 20:47:59 +0800
Subject: [PATCH 068/399] fix: skill install timeout
---
cli/commands/skill.py | 16 ++++++++--------
plugins/cow_cli/cow_cli.py | 12 ++++++++++--
2 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 891bceca..73813b8a 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -111,7 +111,7 @@ def _clone_repo(git_url: str):
import subprocess
subprocess.run(
["git", "clone", "--depth", "1", git_url, repo_dir],
- check=True, capture_output=True, timeout=120,
+ check=True, capture_output=True, timeout=30,
)
except FileNotFoundError:
shutil.rmtree(tmp_dir, ignore_errors=True)
@@ -122,7 +122,7 @@ def _clone_repo(git_url: str):
return tmp_dir, repo_dir
-def _download_repo_zip(spec: str, branch: str = "main", host: str = "github", timeout: int = 120):
+def _download_repo_zip(spec: str, branch: str = "main", host: str = "github", timeout: int = 30):
"""Download a GitHub/GitLab repo as zip and extract it.
Returns (tmp_dir, repo_root) where tmp_dir is the temp directory to clean up
@@ -436,7 +436,7 @@ def _install_archive_url(url: str, result: InstallResult):
result.messages.append(f"Downloading archive from {url} ...")
try:
- resp = requests.get(url, timeout=120, allow_redirects=True)
+ resp = requests.get(url, timeout=30, allow_redirects=True)
resp.raise_for_status()
except Exception as e:
raise SkillInstallError(f"Failed to download archive: {e}")
@@ -947,7 +947,7 @@ def _install_hub(name, result: InstallResult, provider=None):
has_mirror = data.get("has_mirror", False)
gh_err = None
- gh_timeout = 15 if has_mirror else 120
+ gh_timeout = 15 if has_mirror else 30
try:
parsed_url = _parse_github_url(source_url)
if parsed_url:
@@ -968,7 +968,7 @@ def _install_hub(name, result: InstallResult, provider=None):
mirror_resp = requests.post(
f"{SKILL_HUB_API}/skills/{name}/download",
json={"mirror": True},
- timeout=60,
+ timeout=30,
)
mirror_resp.raise_for_status()
except Exception as e:
@@ -1004,7 +1004,7 @@ def _install_hub(name, result: InstallResult, provider=None):
result.messages.append(f"Source: {src_provider}")
result.messages.append("Downloading skill package...")
dl_err = None
- dl_timeout = 15 if has_mirror else 60
+ dl_timeout = 15 if has_mirror else 30
try:
dl_resp = requests.get(
download_url,
@@ -1033,7 +1033,7 @@ def _install_hub(name, result: InstallResult, provider=None):
mirror_resp = requests.post(
f"{SKILL_HUB_API}/skills/{name}/download",
json={"mirror": True},
- timeout=60,
+ timeout=30,
)
mirror_resp.raise_for_status()
except Exception as e:
@@ -1083,7 +1083,7 @@ def _install_hub(name, result: InstallResult, provider=None):
raise SkillInstallError("Unexpected response from Skill Hub.")
-def _install_github(spec, result: InstallResult, subpath=None, skill_name=None, branch="main", source="github", timeout=120):
+def _install_github(spec, result: InstallResult, subpath=None, skill_name=None, branch="main", source="github", timeout=30):
"""Install skill(s) from a GitHub repo.
Strategy: zip download first (no API rate limit), Contents API as fallback.
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index a2caf0fc..bc114780 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -655,13 +655,19 @@ class CowCliPlugin(Plugin):
lines.append("💡 /skill install <名称> 安装技能")
return "\n".join(lines)
+ _INSTALL_TIMEOUT = 60
+
def _skill_install(self, name: str, e_context: EventContext) -> str:
if not name:
return "请指定要安装的技能: /skill install <名称>"
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
+ from cli.commands.skill import install_skill
+
try:
- from cli.commands.skill import install_skill
- result = install_skill(name)
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ future = pool.submit(install_skill, name)
+ result = future.result(timeout=self._INSTALL_TIMEOUT)
if result.error:
return f"安装失败: {result.error}"
@@ -670,6 +676,8 @@ class CowCliPlugin(Plugin):
return "\n".join(result.messages) if result.messages else "未找到可安装的技能"
return self._format_install_result(result)
+ except FuturesTimeout:
+ return "安装超时,请稍后重试或检查网络连接"
except Exception as e:
return f"安装失败: {e}"
From 66b71c50e989e45b277e439b1a4a36155161b7ad Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 21:27:50 +0800
Subject: [PATCH 069/399] feat(wecom_bot): add Wecom Bot QR code scan auth
---
agent/skills/manager.py | 6 +-
agent/tools/browser/browser_service.py | 9 +-
channel/web/static/js/console.js | 217 ++++++++++++++++++++++++-
channel/wecom_bot/wecom_bot_channel.py | 52 ++++--
cli/commands/skill.py | 50 +++++-
plugins/cow_cli/cow_cli.py | 12 +-
6 files changed, 312 insertions(+), 34 deletions(-)
diff --git a/agent/skills/manager.py b/agent/skills/manager.py
index 6e1c4259..c7daf7ad 100644
--- a/agent/skills/manager.py
+++ b/agent/skills/manager.py
@@ -102,13 +102,17 @@ class SkillManager:
else:
enabled = entry.metadata.default_enabled if entry.metadata else True
- merged[name] = {
+ entry_dict = {
"name": name,
"description": skill.description,
"source": prev.get("source") or skill.source,
"enabled": enabled,
"category": category,
}
+ display_name = prev.get("display_name")
+ if display_name:
+ entry_dict["display_name"] = display_name
+ merged[name] = entry_dict
self.skills_config = merged
self._save_skills_config()
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
index cc1b94e9..424efc49 100644
--- a/agent/tools/browser/browser_service.py
+++ b/agent/tools/browser/browser_service.py
@@ -244,10 +244,13 @@ class BrowserService:
with self._lock:
if self._alive and self._thread and self._thread.is_alive():
return
+ # Wait for old thread to fully exit before creating a new one
+ old = self._thread
+ if old and old.is_alive():
+ old.join(timeout=5)
+ # Fresh queue to avoid stale sentinels from a previous close()
+ self._task_queue = queue.Queue()
self._alive = True
- # Reuse existing queue so tasks queued during restart are not lost
- if self._task_queue is None:
- self._task_queue = queue.Queue()
self._ready = threading.Event()
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread")
self._thread.start()
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index ae87c094..dc0625b2 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -56,6 +56,10 @@ const I18N = {
weixin_scan_scanned: '已扫码,请在手机上确认', weixin_scan_expired: '二维码已过期,正在刷新...',
weixin_scan_success: '登录成功,正在启动通道...', weixin_scan_fail: '获取二维码失败',
weixin_qr_tip: '二维码约2分钟后过期',
+ wecom_scan_btn: '扫码创建企微机器人', wecom_scan_desc: '使用企业微信扫码,一键创建智能机器人',
+ wecom_scan_success: '创建成功,正在启动通道...',
+ wecom_scan_fail: '创建失败',
+ wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写',
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
@@ -107,6 +111,10 @@ const I18N = {
weixin_scan_scanned: 'Scanned, please confirm on your phone', weixin_scan_expired: 'QR code expired, refreshing...',
weixin_scan_success: 'Login successful, starting channel...', weixin_scan_fail: 'Failed to load QR code',
weixin_qr_tip: 'QR code expires in ~2 minutes',
+ wecom_scan_btn: 'Scan to Create WeCom Bot', wecom_scan_desc: 'Scan with WeCom to create a bot instantly',
+ wecom_scan_success: 'Bot created, starting channel...',
+ wecom_scan_fail: 'Bot creation failed',
+ wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual',
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
@@ -1684,7 +1692,7 @@ function renderSkillCard(card, sk) {
-
${escapeHtml(sk.name)}
+
${escapeHtml(sk.display_name || sk.name)}
0;
const weixinWaiting = ch.name === 'weixin' && ch.login_status && ch.login_status !== 'logged_in';
+ const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch);
let statusDot, statusText;
if (weixinWaiting) {
statusDot = 'bg-amber-400 animate-pulse';
statusText = ch.login_status === 'scanned'
? `${t('weixin_scan_scanned')} `
: `${t('weixin_scan_waiting')} `;
+ } else if (wecomNeedsCreds) {
+ statusDot = 'bg-amber-400 animate-pulse';
+ statusText = `${t('channels_connecting')} `;
} else {
statusDot = 'bg-primary-400';
statusText = `${t('channels_connected')} `;
}
card.innerHTML = `
-
+
@@ -1918,6 +1930,15 @@ function renderActiveChannels() {
${t('weixin_scan_title')}
` : ''}
+ ${wecomNeedsCreds ? `
` : ''}
${hasFields ? `
${fieldsHtml}
@@ -2154,6 +2175,13 @@ function onAddChannelSelect(chName) {
return;
}
+ if (chName === 'wecom_bot') {
+ actions.classList.add('hidden');
+ const ch = channelsData.find(c => c.name === chName);
+ fieldsContainer.innerHTML = buildWecomBotPanel(ch);
+ return;
+ }
+
const ch = channelsData.find(c => c.name === chName);
if (!ch) return;
@@ -2375,6 +2403,191 @@ function connectWeixinAfterQr() {
.catch(() => {});
}
+// =====================================================================
+// WeCom Bot QR Auth
+// =====================================================================
+const WECOM_BOT_SDK_URL = 'https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js';
+const WECOM_BOT_SOURCE = 'cowagent';
+let _wecomSdkLoaded = false;
+
+function ensureWecomSdkLoaded() {
+ return new Promise((resolve, reject) => {
+ if (_wecomSdkLoaded && window.WecomAIBotSDK) { resolve(); return; }
+ if (document.querySelector(`script[src="${WECOM_BOT_SDK_URL}"]`)) {
+ _wecomSdkLoaded = true; resolve(); return;
+ }
+ const s = document.createElement('script');
+ s.src = WECOM_BOT_SDK_URL;
+ s.onload = () => { _wecomSdkLoaded = true; resolve(); };
+ s.onerror = () => reject(new Error('Failed to load WecomAIBotSDK'));
+ document.head.appendChild(s);
+ });
+}
+
+function _wecomBotHasCreds(ch) {
+ if (!ch || !ch.fields) return false;
+ const idField = ch.fields.find(f => f.key === 'wecom_bot_id');
+ const secretField = ch.fields.find(f => f.key === 'wecom_bot_secret');
+ return !!(idField && idField.value && secretField && secretField.value);
+}
+
+function buildWecomBotPanel(ch) {
+ const scanLabel = t('wecom_mode_scan');
+ const manualLabel = t('wecom_mode_manual');
+ const hasCreds = _wecomBotHasCreds(ch);
+ const defaultMode = hasCreds ? 'manual' : 'scan';
+ return `
+
`;
+}
+
+function switchWecomBotMode(mode) {
+ const scanTab = document.getElementById('wecom-tab-scan');
+ const manualTab = document.getElementById('wecom-tab-manual');
+ const content = document.getElementById('wecom-mode-content');
+ const actions = document.getElementById('add-channel-actions');
+ if (!scanTab || !manualTab || !content) return;
+
+ const activeClasses = 'bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm';
+ const inactiveClasses = 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200';
+
+ if (mode === 'scan') {
+ scanTab.className = scanTab.className.replace(/text-slate-500[^\s]*/g, '').replace(/hover:\S+/g, '');
+ scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
+ manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
+ actions.classList.add('hidden');
+ content.innerHTML = `
+
+
${t('wecom_scan_desc')}
+
+ ${t('wecom_scan_btn')}
+
+
+
`;
+ } else {
+ manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
+ scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
+ const ch = channelsData.find(c => c.name === 'wecom_bot');
+ content.innerHTML = `
${buildChannelFieldsHtml('wecom_bot', ch ? ch.fields || [] : [])}
`;
+ bindSecretFieldEvents(content);
+ actions.classList.remove('hidden');
+ }
+}
+
+function startWecomBotAuth() {
+ const statusEl = document.getElementById('wecom-scan-status');
+ ensureWecomSdkLoaded().then(() => {
+ WecomAIBotSDK.openBotInfoAuthWindow({
+ source: WECOM_BOT_SOURCE,
+ onCreated: function(bot) {
+ if (statusEl) {
+ statusEl.innerHTML = `
+
+
+
+
+
${t('wecom_scan_success')}
+
`;
+ }
+ connectWecomBotAfterAuth(bot.botid, bot.secret);
+ },
+ onError: function(err) {
+ if (statusEl) {
+ statusEl.innerHTML = `
${t('wecom_scan_fail')}: ${err.message || err.code || ''}
`;
+ }
+ }
+ });
+ }).catch(err => {
+ if (statusEl) {
+ statusEl.innerHTML = `
SDK load failed: ${err.message}
`;
+ }
+ });
+}
+
+function connectWecomBotAfterAuth(botId, secret) {
+ fetch('/api/channels', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'connect',
+ channel: 'wecom_bot',
+ config: { wecom_bot_id: botId, wecom_bot_secret: secret }
+ })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'success') {
+ const ch = channelsData.find(c => c.name === 'wecom_bot');
+ if (ch) {
+ ch.active = true;
+ (ch.fields || []).forEach(f => {
+ if (f.key === 'wecom_bot_id') f.value = botId;
+ if (f.key === 'wecom_bot_secret') f.value = ChannelsHandler_maskSecret(secret);
+ });
+ }
+ setTimeout(() => renderActiveChannels(), 1500);
+ }
+ })
+ .catch(() => {});
+}
+
+function startWecomBotAuthInCard() {
+ const statusEl = document.getElementById('wecom-card-scan-status');
+ ensureWecomSdkLoaded().then(() => {
+ WecomAIBotSDK.openBotInfoAuthWindow({
+ source: WECOM_BOT_SOURCE,
+ onCreated: function(bot) {
+ if (statusEl) {
+ statusEl.innerHTML = `
+
+
+
+
+
${t('wecom_scan_success')}
+
`;
+ }
+ connectWecomBotAfterAuth(bot.botid, bot.secret);
+ },
+ onError: function(err) {
+ if (statusEl) {
+ statusEl.innerHTML = `
${t('wecom_scan_fail')}: ${err.message || err.code || ''}
`;
+ }
+ }
+ });
+ }).catch(err => {
+ if (statusEl) {
+ statusEl.innerHTML = `
SDK load failed: ${err.message}
`;
+ }
+ });
+}
+
+// Initialize wecom bot panel with correct default mode when inserted into DOM
+document.addEventListener('DOMContentLoaded', function() {
+ const observer = new MutationObserver(function() {
+ const panel = document.getElementById('wecom-bot-panel');
+ if (panel && !panel.dataset.initialized) {
+ panel.dataset.initialized = '1';
+ switchWecomBotMode(panel.dataset.defaultMode || 'scan');
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+});
+
// =====================================================================
// Scheduler View
// =====================================================================
diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py
index 895119dc..a69e37ea 100644
--- a/channel/wecom_bot/wecom_bot_channel.py
+++ b/channel/wecom_bot/wecom_bot_channel.py
@@ -330,28 +330,42 @@ class WecomBotChannel(ChatChannel):
All intermediate segments (thinking before tool calls) and the final answer
are accumulated into a single stream message, separated by '---'.
+ Throttles push to at most once per 100ms to avoid WebSocket congestion.
"""
stream_id = uuid.uuid4().hex[:16]
self._stream_states[req_id] = {
"stream_id": stream_id,
- "committed": "", # finalized content from previous segments
- "current": "", # current segment being streamed
+ "committed": "",
+ "current": "",
+ "last_push_time": 0,
+ "last_push_len": 0,
}
- def _push_stream(state: dict):
- """Push current stream content to wecom."""
- self._ws_send({
- "cmd": "aibot_respond_msg",
- "headers": {"req_id": req_id},
- "body": {
- "msgtype": "stream",
- "stream": {
- "id": state["stream_id"],
- "finish": False,
- "content": state["committed"] + state["current"],
+ def _push_stream(state: dict, force: bool = False):
+ """Push current stream content to wecom (throttled unless forced)."""
+ now = time.time()
+ if not force and now - state["last_push_time"] < 0.1:
+ return
+ content = state["committed"] + state["current"]
+ if len(content) == state["last_push_len"]:
+ return
+ state["last_push_time"] = now
+ state["last_push_len"] = len(content)
+ try:
+ self._ws_send({
+ "cmd": "aibot_respond_msg",
+ "headers": {"req_id": req_id},
+ "body": {
+ "msgtype": "stream",
+ "stream": {
+ "id": state["stream_id"],
+ "finish": False,
+ "content": content,
+ },
},
- },
- })
+ })
+ except Exception as e:
+ logger.warning(f"[WecomBot] Stream push failed: {e}")
def on_event(event: dict):
event_type = event.get("type")
@@ -378,6 +392,7 @@ class WecomBotChannel(ChatChannel):
else:
state["committed"] += state["current"]
state["current"] = ""
+ _push_stream(state, force=True)
return on_event
@@ -452,11 +467,16 @@ class WecomBotChannel(ChatChannel):
if req_id:
state = self._stream_states.pop(req_id, None)
if state:
- final_content = state["committed"] or content
+ final_content = state["committed"] if state["committed"] else content
stream_id = state["stream_id"]
else:
final_content = content
stream_id = uuid.uuid4().hex[:16]
+
+ # Brief pause so the server finishes processing the last intermediate chunk
+ # before receiving the finish packet
+ time.sleep(0.15)
+
self._ws_send({
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 73813b8a..1586bba8 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -324,7 +324,7 @@ def _install_local(path: str, result: InstallResult):
_batch_install_skills(discovered, path, skills_dir, "local", result)
-def _register_installed_skill(name: str, source: str = "cowhub"):
+def _register_installed_skill(name: str, source: str = "cowhub", display_name: str = ""):
"""Register a newly installed skill into skills_config.json.
source values: builtin, cow, github, clawhub, linkai, local, url
@@ -341,18 +341,28 @@ def _register_installed_skill(name: str, source: str = "cowhub"):
config = {}
if name in config:
+ if display_name and not config[name].get("display_name"):
+ config[name]["display_name"] = display_name
+ try:
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config, f, indent=4, ensure_ascii=False)
+ except Exception:
+ pass
return
skill_dir = os.path.join(skills_dir, name)
description = _read_skill_description(skill_dir) or ""
- config[name] = {
+ entry = {
"name": name,
"description": description,
"source": source,
"enabled": True,
"category": "skill",
}
+ if display_name:
+ entry["display_name"] = display_name
+ config[name] = entry
try:
with open(config_path, "w", encoding="utf-8") as f:
@@ -657,7 +667,15 @@ def _list_local():
def _print_skill_table(entries):
"""Print skills as a formatted table."""
- name_w = max(len(e.get("name", "")) for e in entries)
+ def _display_label(e):
+ display = e.get("display_name", "")
+ name = e.get("name", "")
+ if display and display != name:
+ return f"{display} ({name})"
+ return name
+
+ labels = [_display_label(e) for e in entries]
+ name_w = max((len(l) for l in labels), default=4)
name_w = max(name_w, 4) + 2
desc_w = 40
@@ -666,8 +684,7 @@ def _print_skill_table(entries):
click.echo(f" {header}")
click.echo(f" {'─' * (name_w + 10 + 10 + desc_w)}")
- for e in entries:
- name = e.get("name", "")
+ for e, label in zip(entries, labels):
enabled = e.get("enabled", True)
source = e.get("source", "")
desc = e.get("description", "") or ""
@@ -675,7 +692,7 @@ def _print_skill_table(entries):
desc = desc[:desc_w - 3] + "..."
status_icon = click.style("✓ on ", fg="green") if enabled else click.style("✗ off", fg="red")
- click.echo(f" {name:<{name_w}} {status_icon} {source:<10} {desc}")
+ click.echo(f" {label:<{name_w}} {status_icon} {source:<10} {desc}")
click.echo()
@@ -937,10 +954,12 @@ def _install_hub(name, result: InstallResult, provider=None):
raise SkillInstallError(f"Failed to connect to Skill Hub: {e}")
content_type = resp.headers.get("Content-Type", "")
+ hub_display_name = ""
if "application/json" in content_type:
data = resp.json()
source_type = data.get("source_type")
+ hub_display_name = data.get("display_name", "")
if source_type == "github":
source_url = data.get("source_url", "")
@@ -956,6 +975,8 @@ def _install_hub(name, result: InstallResult, provider=None):
else:
_check_github_spec(source_url)
_install_github(source_url, result, skill_name=name, timeout=gh_timeout)
+ if hub_display_name:
+ _register_installed_skill(name, display_name=hub_display_name)
return
except Exception as e:
gh_err = e
@@ -987,9 +1008,11 @@ def _install_hub(name, result: InstallResult, provider=None):
installed_before = len(result.installed)
_install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
if len(result.installed) == installed_before:
- _register_installed_skill(name, source="cowhub")
+ _register_installed_skill(name, source="cowhub", display_name=hub_display_name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from mirror.")
+ elif hub_display_name:
+ _register_installed_skill(name, display_name=hub_display_name)
return
if source_type == "registry":
@@ -1022,9 +1045,11 @@ def _install_hub(name, result: InstallResult, provider=None):
installed_before = len(result.installed)
_install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
if len(result.installed) == installed_before:
- _register_installed_skill(name, source=src_provider)
+ _register_installed_skill(name, source=src_provider, display_name=hub_display_name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from {src_provider}.")
+ elif hub_display_name:
+ _register_installed_skill(name, display_name=hub_display_name)
return
# Fallback: download mirror from Skill Hub
@@ -1050,9 +1075,11 @@ def _install_hub(name, result: InstallResult, provider=None):
installed_before = len(result.installed)
_install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
if len(result.installed) == installed_before:
- _register_installed_skill(name, source="cowhub")
+ _register_installed_skill(name, source="cowhub", display_name=hub_display_name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from mirror.")
+ elif hub_display_name:
+ _register_installed_skill(name, display_name=hub_display_name)
else:
raise SkillInstallError("Unsupported registry provider.")
return
@@ -1066,6 +1093,8 @@ def _install_hub(name, result: InstallResult, provider=None):
else:
_check_github_spec(source_url)
_install_github(source_url, result, skill_name=name)
+ if hub_display_name:
+ _register_installed_skill(name, display_name=hub_display_name)
return
elif "application/zip" in content_type:
@@ -1407,7 +1436,10 @@ def info(name):
enabled = entry.get("enabled", True)
status_str = click.style("✓ enabled", fg="green") if enabled else click.style("✗ disabled", fg="red")
+ display_name = entry.get("display_name", "")
click.echo(f"\n Skill: {name}")
+ if display_name and display_name != name:
+ click.echo(f" Display: {display_name}")
click.echo(f" Source: {source}")
click.echo(f" Status: {status_str}")
click.echo(f" Path: {skill_dir}")
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index bc114780..5c79f438 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -544,10 +544,13 @@ class CowCliPlugin(Plugin):
enabled = entry.get("enabled", True)
source = entry.get("source", "")
icon = "✅" if enabled else "⏸️"
+ display = entry.get("display_name", "") or name
desc = entry.get("description", "")
if len(desc) > 50:
desc = desc[:47] + "…"
- line = f"{icon} {name}"
+ line = f"{icon} {display}"
+ if display != name:
+ line += f" ({name})"
if desc:
line += f"\n {desc}"
if source:
@@ -685,13 +688,16 @@ class CowCliPlugin(Plugin):
def _format_install_result(result) -> str:
"""Format InstallResult into a chat-friendly message."""
from cli.commands.skill import _read_skill_description
- from cli.utils import get_skills_dir
+ from cli.utils import get_skills_dir, load_skills_config
skills_dir = get_skills_dir()
+ config = load_skills_config()
lines = []
for skill_name in result.installed:
desc = _read_skill_description(os.path.join(skills_dir, skill_name))
- lines.append(f"✅ 技能安装成功:{skill_name}")
+ display = config.get(skill_name, {}).get("display_name", "") or skill_name
+ label = f"{display} ({skill_name})" if display != skill_name else skill_name
+ lines.append(f"✅ 技能安装成功:{label}")
if desc:
lines.append(f" {desc}")
From 30688804133a8c685761e8c9faeaa15cf58929a2 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 21:43:57 +0800
Subject: [PATCH 070/399] feat: save skill display name when downloading
---
cli/commands/skill.py | 23 +++++++++--------------
1 file changed, 9 insertions(+), 14 deletions(-)
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index 1586bba8..b49089c4 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -263,8 +263,9 @@ def _scan_skills_in_dir(directory: str) -> list:
return found
-def _batch_install_skills(discovered, spec, skills_dir, source, result: InstallResult):
+def _batch_install_skills(discovered, spec, skills_dir, source, result: InstallResult, display_name: str = ""):
"""Install a list of discovered skills into skills_dir."""
+ single = len(discovered) == 1
result.messages.append(f"Found {len(discovered)} skill(s) in {spec}:")
for sname, sdir in discovered:
safe_name = re.sub(r'[^a-zA-Z0-9_\-]', '-', sname)[:64]
@@ -275,7 +276,7 @@ def _batch_install_skills(discovered, spec, skills_dir, source, result: InstallR
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(sdir, target_dir)
- _register_installed_skill(safe_name, source=source)
+ _register_installed_skill(safe_name, source=source, display_name=display_name if single else "")
result.installed.append(safe_name)
result.messages.append(f" + {safe_name}")
@@ -1006,13 +1007,11 @@ def _install_hub(name, result: InstallResult, provider=None):
expected_checksum = mirror_resp.headers.get("X-Checksum-Sha256")
_check_checksum(mirror_resp.content, expected_checksum)
installed_before = len(result.installed)
- _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
+ _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub", display_name=hub_display_name)
if len(result.installed) == installed_before:
_register_installed_skill(name, source="cowhub", display_name=hub_display_name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from mirror.")
- elif hub_display_name:
- _register_installed_skill(name, display_name=hub_display_name)
return
if source_type == "registry":
@@ -1043,13 +1042,11 @@ def _install_hub(name, result: InstallResult, provider=None):
if dl_err is None:
_check_checksum(dl_resp.content, expected_checksum)
installed_before = len(result.installed)
- _install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider)
+ _install_zip_bytes(dl_resp.content, name, skills_dir, result=result, source_label=src_provider, display_name=hub_display_name)
if len(result.installed) == installed_before:
_register_installed_skill(name, source=src_provider, display_name=hub_display_name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from {src_provider}.")
- elif hub_display_name:
- _register_installed_skill(name, display_name=hub_display_name)
return
# Fallback: download mirror from Skill Hub
@@ -1073,13 +1070,11 @@ def _install_hub(name, result: InstallResult, provider=None):
expected_checksum = mirror_resp.headers.get("X-Checksum-Sha256")
_check_checksum(mirror_resp.content, expected_checksum)
installed_before = len(result.installed)
- _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub")
+ _install_zip_bytes(mirror_resp.content, name, skills_dir, result=result, source_label="cowhub", display_name=hub_display_name)
if len(result.installed) == installed_before:
_register_installed_skill(name, source="cowhub", display_name=hub_display_name)
result.installed.append(name)
result.messages.append(f"Installed '{name}' from mirror.")
- elif hub_display_name:
- _register_installed_skill(name, display_name=hub_display_name)
else:
raise SkillInstallError("Unsupported registry provider.")
return
@@ -1264,7 +1259,7 @@ def _install_git_clone(git_url: str, result: InstallResult, display_name: str =
shutil.rmtree(tmp_dir, ignore_errors=True)
-def _install_zip_bytes(content, name, skills_dir, result: InstallResult = None, source_label: str = "zip"):
+def _install_zip_bytes(content, name, skills_dir, result: InstallResult = None, source_label: str = "zip", display_name: str = ""):
"""Extract a zip archive and install skill(s).
Supports three scenarios:
@@ -1289,7 +1284,7 @@ def _install_zip_bytes(content, name, skills_dir, result: InstallResult = None,
discovered = _scan_skills_in_repo(pkg_root) or _scan_skills_in_dir(pkg_root)
if discovered and len(discovered) > 1 and result is not None:
- _batch_install_skills(discovered, name, skills_dir, source_label, result)
+ _batch_install_skills(discovered, name, skills_dir, source_label, result, display_name=display_name)
return
if discovered and len(discovered) == 1:
@@ -1301,7 +1296,7 @@ def _install_zip_bytes(content, name, skills_dir, result: InstallResult = None,
if os.path.exists(target):
shutil.rmtree(target)
shutil.copytree(sdir, target)
- _register_installed_skill(safe_name, source=source_label)
+ _register_installed_skill(safe_name, source=source_label, display_name=display_name)
if result is not None:
result.installed.append(safe_name)
result.messages.append(f"Installed '{safe_name}' from {source_label}.")
From 1c336380c0186f688b47c63390c56ebaaa0a7f8e Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 31 Mar 2026 22:30:31 +0800
Subject: [PATCH 071/399] docs: update release doc
---
cli/commands/skill.py | 6 ++-
docs/docs.json | 3 ++
docs/releases/overview.mdx | 1 +
docs/releases/v2.0.5.mdx | 82 ++++++++++++++++++++++++++++++++++++++
plugins/cow_cli/cow_cli.py | 9 +++--
5 files changed, 96 insertions(+), 5 deletions(-)
create mode 100644 docs/releases/v2.0.5.mdx
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index b49089c4..cf6c1954 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -518,12 +518,16 @@ def _install_targz_bytes(content: bytes, name: str, skills_dir: str, result: Ins
def _print_install_success(name: str, source: str):
"""Print a unified install success message with description and source."""
skills_dir = get_skills_dir()
+ config = load_skills_config()
+ display = config.get(name, {}).get("display_name", "")
desc = _read_skill_description(os.path.join(skills_dir, name))
click.echo(click.style(f"✓ {name}", fg="green"))
+ if display and display != name:
+ click.echo(f" 名称: {display}")
if desc:
if len(desc) > 60:
desc = desc[:57] + "…"
- click.echo(f" {desc}")
+ click.echo(f" 描述: {desc}")
click.echo(f" 来源: {source}")
diff --git a/docs/docs.json b/docs/docs.json
index 804dcccb..5e761093 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -185,6 +185,7 @@
"group": "发布记录",
"pages": [
"releases/overview",
+ "releases/v2.0.5",
"releases/v2.0.4",
"releases/v2.0.3",
"releases/v2.0.2",
@@ -344,6 +345,7 @@
"group": "Release Notes",
"pages": [
"en/releases/overview",
+ "en/releases/v2.0.5",
"en/releases/v2.0.4",
"en/releases/v2.0.2",
"en/releases/v2.0.1",
@@ -503,6 +505,7 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
+ "ja/releases/v2.0.5",
"ja/releases/v2.0.4",
"ja/releases/v2.0.3",
"ja/releases/v2.0.2",
diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx
index fc891bff..89d83de6 100644
--- a/docs/releases/overview.mdx
+++ b/docs/releases/overview.mdx
@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
+| [2.0.5](/releases/v2.0.5) | 2026.03.31 | Cow CLI、Skill Hub 开源、浏览器工具、企微扫码创建、DeepSeek 独立模块及多项优化 |
| [2.0.4](/releases/v2.0.4) | 2026.03.22 | 新增个人微信通道、新模型支持、日文文档、脚本重构及多项修复 |
| [2.0.3](/releases/v2.0.3) | 2026.03.18 | 新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级 |
| [2.0.2](/releases/v2.0.2) | 2026.02.27 | Web 控制台升级、多通道同时运行、会话持久化 |
diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx
new file mode 100644
index 00000000..6c6f29d0
--- /dev/null
+++ b/docs/releases/v2.0.5.mdx
@@ -0,0 +1,82 @@
+---
+title: v2.0.5
+description: CowAgent 2.0.5 - Cow CLI、Skill Hub 开源、浏览器工具、企微扫码创建、DeepSeek 独立模块及多项优化
+---
+
+## 🖥️ Cow CLI 命令系统
+
+新增 Cow CLI 命令系统,支持在终端和对话中执行命令,实现对 CowAgent 的全方位管理:
+
+- **终端命令**:在系统终端中执行 `cow <命令>`,支持 `start`、`stop`、`restart`、`update`、`status`、`logs` 等服务管理操作
+- **对话命令**:在对话中输入 `/<命令>` 或 `cow <命令>`,支持 `/help`、`/status`、`/config`、`/skill`、`/context`、`/logs`、`/version` 等
+- **斜杠指令菜单**:Web 控制台输入框输入 `/` 即可弹出指令菜单,快速选择命令
+- **输入历史**:支持方向键回溯历史输入
+- **Windows 支持**:新增 PowerShell 脚本 `run.ps1`,Windows 下可使用 `cow` 命令
+
+相关文档:[命令总览](https://docs.cowagent.ai/commands)、[技能管理命令](https://docs.cowagent.ai/commands/skill)、[服务管理命令](https://docs.cowagent.ai/commands/process)、[通用命令](https://docs.cowagent.ai/commands/general)。
+
+相关提交:[#2726](https://github.com/zhayujie/chatgpt-on-wechat/pull/2726)
+
+## 🧩 Cow Skill Hub 开源
+
+Cow Skill Hub(技能广场)正式开源并上线,提供 AI Agent 技能的浏览、搜索、安装和发布:
+
+- **技能广场**:访问 [skills.cowagent.ai](https://skills.cowagent.ai) 浏览所有可用技能,支持按类别(推荐 / 社区 / 第三方)和标签筛选
+- **一键安装**:在对话中 `/skill install <名称>` 或终端 `cow skill install <名称>` 一键安装
+- **多来源支持**:支持从 Skill Hub、GitHub、ClawHub、URL(zip / SKILL.md)等多种来源安装,支持 GitHub 批量安装和子目录指定
+- **技能搜索**:`/skill search` 和 `/skill list --remote` 浏览和搜索技能广场
+- **技能发布**:通过 [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) 提交自己的技能
+- **国内镜像**:支持 Skill Hub 镜像加速,国内环境下载更流畅
+
+Skill Hub 开源仓库:[cow-skill-hub](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/ref/cow-skill-hub)。
+
+相关文档:[技能概览](https://docs.cowagent.ai/skills)、[安装技能](https://docs.cowagent.ai/skills/install)、[创建技能](https://docs.cowagent.ai/skills/create)、[技能管理命令](https://docs.cowagent.ai/commands/skill)。
+
+相关提交:[#2726](https://github.com/zhayujie/chatgpt-on-wechat/pull/2726)
+
+## 🌐 新增浏览器工具
+
+新增 Browser 工具,Agent 可控制 Chromium 浏览器访问和操作网页:
+
+- **网页导航与交互**:支持 `navigate`、`click`、`fill`、`select`、`scroll`、`press` 等操作
+- **页面快照**:使用精简 DOM 快照技术,让 Agent 高效理解页面结构,导航后自动快照
+- **截图能力**:支持页面截图保存到工作区
+- **JavaScript 执行**:支持在页面中执行自定义脚本
+- **CLI 安装**:通过 `cow install-browser` 一键安装浏览器及依赖,自动适配系统环境
+- **对话中安装**:支持在对话中触发浏览器安装
+- **Docker 支持**:Docker 镜像已内置浏览器安装支持
+- **跨平台适配**:Linux 下自动安装中文字体,服务器环境自动使用无头模式
+
+相关文档:[浏览器工具](https://docs.cowagent.ai/tools/browser)。
+
+相关提交:[#2727](https://github.com/zhayujie/chatgpt-on-wechat/pull/2727)
+
+## 🤖 企微智能机器人扫码创建
+
+企业微信智能机器人通道新增扫码一键创建功能:
+
+- **Web 控制台扫码**:在 Web 控制台通道页面,选择「扫码接入」模式,使用企业微信扫码即可自动创建并接入智能机器人,无需手动到企业微信后台配置
+- **手动模式保留**:同时保留「手动填写」模式,可输入已有的 Bot ID 和 Secret 接入
+- **流式推送优化**:增加推送节流(100ms 间隔),避免 WebSocket 拥塞
+
+相关文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。
+
+相关提交:[#2735](https://github.com/zhayujie/chatgpt-on-wechat/pull/2735)
+
+Thanks [@WecomTeam](https://github.com/WecomTeam)
+
+## 🐛 其他优化与修复
+
+- **DeepSeek 独立模块**:新增独立的 DeepSeek Bot 模块,支持 `deepseek_api_key` 专属配置,无需再通过 OpenAI 兼容方式接入([#2719](https://github.com/zhayujie/chatgpt-on-wechat/pull/2719))。Thanks [@6vision](https://github.com/6vision)
+- **Web 控制台斜杠菜单**:输入框输入 `/` 弹出指令快捷菜单([#2731](https://github.com/zhayujie/chatgpt-on-wechat/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd)
+- **上下文丢失**:修复上下文裁剪后丢失的问题 ([393f0c0](https://github.com/zhayujie/chatgpt-on-wechat/commit/393f0c0))
+- **系统提示词**:修复系统提示词未在每轮重建的问题 ([13f5fde](https://github.com/zhayujie/chatgpt-on-wechat/commit/13f5fde))
+- **Gemini 模型**:修复 GoogleGeminiBot 缺少 model 属性的问题([#2716](https://github.com/zhayujie/chatgpt-on-wechat/pull/2716))。Thanks [@cowagent](https://github.com/cowagent)
+- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba)、[45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
+- **Docker 优化**:修复卷权限问题,精简镜像体积 ([3eb8348](https://github.com/zhayujie/chatgpt-on-wechat/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/4470d4c))
+
+## 📦 升级方式
+
+源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。
+
+**发布日期**:2026.03.31 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master)
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 5c79f438..993df4c0 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -695,11 +695,12 @@ class CowCliPlugin(Plugin):
lines = []
for skill_name in result.installed:
desc = _read_skill_description(os.path.join(skills_dir, skill_name))
- display = config.get(skill_name, {}).get("display_name", "") or skill_name
- label = f"{display} ({skill_name})" if display != skill_name else skill_name
- lines.append(f"✅ 技能安装成功:{label}")
+ display = config.get(skill_name, {}).get("display_name", "")
+ lines.append(f"✅ 技能安装成功:{skill_name}")
+ if display and display != skill_name:
+ lines.append(f" 名称:{display}")
if desc:
- lines.append(f" {desc}")
+ lines.append(f" 描述:{desc}")
if len(result.installed) > 1:
lines.append(f"\n共安装 {len(result.installed)} 个技能")
From 174ee0cafc9e8e9d97a23c305418251485b8aa89 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 10:03:58 +0800
Subject: [PATCH 072/399] fix(security): prevent path traversal in memory
content API
---
agent/memory/service.py | 20 +++++++++++++++++---
channel/web/web_channel.py | 2 ++
docs/releases/v2.0.5.mdx | 7 +++++--
plugins/cow_cli/cow_cli.py | 2 +-
4 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/agent/memory/service.py b/agent/memory/service.py
index 6456e296..567a1f5f 100644
--- a/agent/memory/service.py
+++ b/agent/memory/service.py
@@ -134,6 +134,8 @@ class MemoryService:
else:
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
+ except ValueError as e:
+ return {"action": action, "code": 403, "message": "invalid filename", "payload": None}
except FileNotFoundError as e:
return {"action": action, "code": 404, "message": str(e), "payload": None}
except Exception as e:
@@ -145,14 +147,26 @@ class MemoryService:
# ------------------------------------------------------------------
def _resolve_path(self, filename: str) -> str:
"""
- Resolve a filename to its absolute path.
+ Safely resolve a filename to its absolute path within the allowed directory.
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
- ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md``
+
+ Raises ValueError if the resolved path escapes the allowed directory
+ (path traversal protection).
"""
if filename == "MEMORY.md":
- return os.path.join(self.workspace_root, filename)
- return os.path.join(self.memory_dir, filename)
+ base_dir = self.workspace_root
+ else:
+ base_dir = self.memory_dir
+
+ resolved = os.path.realpath(os.path.join(base_dir, filename))
+ allowed = os.path.realpath(base_dir)
+
+ if resolved != allowed and not resolved.startswith(allowed + os.sep):
+ raise ValueError(f"Invalid filename: path traversal detected")
+
+ return resolved
@staticmethod
def _file_info(path: str, filename: str, file_type: str) -> dict:
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index c19fdd63..e23c5e32 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -1365,6 +1365,8 @@ class MemoryContentHandler:
service = MemoryService(workspace_root)
result = service.get_content(params.filename)
return json.dumps({"status": "success", **result}, ensure_ascii=False)
+ except ValueError:
+ return json.dumps({"status": "error", "message": "invalid filename"})
except FileNotFoundError:
return json.dumps({"status": "error", "message": "file not found"})
except Exception as e:
diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx
index 6c6f29d0..5983056b 100644
--- a/docs/releases/v2.0.5.mdx
+++ b/docs/releases/v2.0.5.mdx
@@ -68,12 +68,15 @@ Thanks [@WecomTeam](https://github.com/WecomTeam)
## 🐛 其他优化与修复
- **DeepSeek 独立模块**:新增独立的 DeepSeek Bot 模块,支持 `deepseek_api_key` 专属配置,无需再通过 OpenAI 兼容方式接入([#2719](https://github.com/zhayujie/chatgpt-on-wechat/pull/2719))。Thanks [@6vision](https://github.com/6vision)
-- **Web 控制台斜杠菜单**:输入框输入 `/` 弹出指令快捷菜单([#2731](https://github.com/zhayujie/chatgpt-on-wechat/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd)
+- **Web 控制台优化**:新增斜杠指令菜单和输入历史回溯,新增模型选项,优化移动端适配([#2731](https://github.com/zhayujie/chatgpt-on-wechat/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd)
- **上下文丢失**:修复上下文裁剪后丢失的问题 ([393f0c0](https://github.com/zhayujie/chatgpt-on-wechat/commit/393f0c0))
- **系统提示词**:修复系统提示词未在每轮重建的问题 ([13f5fde](https://github.com/zhayujie/chatgpt-on-wechat/commit/13f5fde))
+- **Agent 响应**:去除 Agent 响应首尾空白字符 ([f890318](https://github.com/zhayujie/chatgpt-on-wechat/commit/f890318))
+- **视觉压缩**:优化视觉图片压缩策略 ([22b8ca0](https://github.com/zhayujie/chatgpt-on-wechat/commit/22b8ca0))
- **Gemini 模型**:修复 GoogleGeminiBot 缺少 model 属性的问题([#2716](https://github.com/zhayujie/chatgpt-on-wechat/pull/2716))。Thanks [@cowagent](https://github.com/cowagent)
-- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba)、[45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
+- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba)、[baf66a1](https://github.com/zhayujie/chatgpt-on-wechat/commit/baf66a1)、[45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
- **Docker 优化**:修复卷权限问题,精简镜像体积 ([3eb8348](https://github.com/zhayujie/chatgpt-on-wechat/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/4470d4c))
+- **README 排版**:优化中英文排版空格([#2723](https://github.com/zhayujie/chatgpt-on-wechat/pull/2723))。Thanks [@Xiaozhou345](https://github.com/Xiaozhou345)
## 📦 升级方式
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 993df4c0..27e653ef 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -599,7 +599,7 @@ class CowCliPlugin(Plugin):
page = min(page, total_pages)
installed = set(load_skills_config().keys())
- lines = [f"🌐 技能广场 (共 {total} 个技能)", ""]
+ lines = ["🌐 技能广场", ""]
for s in skills:
name = s.get("name", "")
display = s.get("display_name", "") or name
From b058af122cdf6b224c52979ec2c42cf3c6726141 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 12:24:21 +0800
Subject: [PATCH 073/399] feat: release 2.0.5
---
README.md | 11 +-
agent/memory/summarizer.py | 57 +++++++---
channel/web/chat.html | 5 +
channel/web/static/js/console.js | 6 +-
cli/VERSION | 2 +-
cli/commands/skill.py | 12 +-
docs/agent.md | 185 -------------------------------
docs/channels/wecom-bot.mdx | 65 +++++++----
docs/docs.json | 9 +-
docs/intro/features.mdx | 50 ++++++++-
docs/releases/overview.mdx | 2 +-
docs/releases/v2.0.5.mdx | 35 +++---
docs/skills/create.mdx | 2 +-
docs/skills/hub.mdx | 65 +++++++++++
docs/skills/index.mdx | 6 +-
docs/skills/install.mdx | 21 +++-
plugins/cow_cli/cow_cli.py | 1 +
pyproject.toml | 2 +-
18 files changed, 267 insertions(+), 269 deletions(-)
delete mode 100644 docs/agent.md
create mode 100644 docs/skills/hub.mdx
diff --git a/README.md b/README.md
index 67b4b83c..e356fe90 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
🌐 官网 ·
📖 文档中心 ·
🚀 快速开始 ·
+ 🧩 技能广场 ·
☁️ 在线体验
@@ -23,7 +24,7 @@
- ✅ **自主任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆和日级记忆,支持关键词及向量检索
-- ✅ **技能系统:** Skills 安装和运行的引擎,支持从 Skill Hub、GitHub 等安装技能,或通过对话创造 Skills
+- ✅ **技能系统:** Skills 安装和运行的引擎,支持从 [Skill Hub](https://skills.cowagent.ai/)、GitHub 等一键安装技能,或通过对话创造 Skills
- ✅ **工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具,Agent 自主调用以完成复杂任务
- ✅ **CLI系统:** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
@@ -68,6 +69,8 @@
# 🏷 更新日志
+>**2026.04.01:** [2.0.5版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.5),Cow CLI 命令系统、Skill Hub 开源、浏览器工具、企微扫码创建、多项优化和修复。
+
>**2026.03.22:** [2.0.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.4),新增个人微信通道(微信扫码即用)、新增 MiniMax-M2.7 和 GLM-5-Turbo 模型、run.sh 脚本重构、日文文档及多项修复。
>**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持 Coding Plan、新增多个模型、Web 端文件处理、记忆系统升级。
@@ -866,8 +869,10 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
# 🔗 相关项目
+- [Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub):开源的 AI Agent 技能广场,浏览、搜索、安装和发布技能,支持 CowAgent、OpenClaw、Claude Code 等多种 Agent。
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入 Slack, Telegram, Discord, Gmail 等海外平台,可作为本项目的补充使用。
-- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体( Multi-Agent )框架,可以通过多智能体团队的协同来解决复杂问题。本项目基于该框架实现了[Agent 插件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。
+- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体( Multi-Agent )框架,可以通过多智能体团队的协同来解决复杂问题。
+
@@ -879,7 +884,7 @@ FAQs:
# 🛠️ 开发
-欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。同时欢迎贡献新的 Skills,参考 [技能创建文档](https://docs.cowagent.ai/skills/create)。
+欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。同时欢迎贡献新的 Skills,向 [Skill Hub](https://skills.cowagent.ai/submit) 提交技能。
# ✉ 联系
diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py
index 20900fb3..b280e1c1 100644
--- a/agent/memory/summarizer.py
+++ b/agent/memory/summarizer.py
@@ -16,16 +16,26 @@ from datetime import datetime
from common.log import logger
-SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提取值得记住的信息,生成简洁的记忆摘要。
+SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提炼出值得长期记住的关键事件和核心信息。
+
+核心原则:
+- 按「事件」维度归纳,而不是按对话轮次逐条记录
+- 多轮对话如果围绕同一件事,合并为一条摘要
+- 只记录有长期价值的信息,忽略闲聊、问候、无意义的短消息
输出要求:
-1. 以事件/关键信息为维度记录,每条一行,用 "- " 开头
-2. 记录有价值的关键信息,例如用户提出的要求及助手的解决方案,对话中涉及的事实信息,用户的偏好、决策或重要结论
-3. 每条摘要需要简明扼要,只保留关键信息
-4. 直接输出摘要内容,不要加任何前缀说明
-5. 当对话没有任何记录价值例如只是简单问候,可回复"无\""""
+1. 每条一行,用 "- " 开头,格式为:事件/主题 + 关键结论或结果
+2. 值得记录的信息类型:用户提出的需求及最终解决方案、重要的事实信息、用户的偏好或决策、关键技术方案或配置变更
+3. 不值得记录的信息:简单问候、闲聊、无实质内容的短消息、重复的中间过程
+4. 每条摘要应当简明扼要,一句话概括事件的核心内容和结果
+5. 直接输出摘要内容,不要加任何前缀说明
+6. 当对话没有任何记录价值(仅含问候或无意义内容),回复"无"
-SUMMARIZE_USER_PROMPT = """请从以下对话记录中提取关键信息,生成记忆摘要:
+示例(仅供参考格式):
+- 用户配置了 XX 功能,设置参数为 YY,已生效
+- 用户反馈了 XX 问题,原因是 YY,通过 ZZ 方式解决"""
+
+SUMMARIZE_USER_PROMPT = """请从以下对话记录中,按关键事件维度提炼记忆摘要(合并同一事件的多轮对话,不要逐条列出):
{conversation}"""
@@ -220,14 +230,16 @@ class MemoryFlushManager:
if not conversation_text.strip():
return ""
- # Try LLM summarization first
if self.llm_model:
try:
summary = self._call_llm_for_summary(conversation_text)
if summary and summary.strip() and summary.strip() != "无":
return summary.strip()
+ logger.info(f"[MemoryFlush] LLM returned empty or '无', using fallback")
except Exception as e:
logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}")
+ else:
+ logger.info("[MemoryFlush] No LLM model available, using rule-based fallback")
return self._extract_summary_fallback(messages, max_messages)
@@ -277,27 +289,38 @@ class MemoryFlushManager:
@staticmethod
def _extract_summary_fallback(messages: List[Dict], max_messages: int = 0) -> str:
- """Rule-based fallback when LLM is unavailable."""
+ """
+ Rule-based fallback when LLM is unavailable.
+ Groups consecutive user+assistant messages into events instead of
+ listing each message individually.
+ """
msgs = messages if max_messages == 0 else messages[-max_messages * 2:]
-
- items = []
+
+ events: List[str] = []
+ current_user_text = ""
for msg in msgs:
role = msg.get("role", "")
text = MemoryFlushManager._extract_text_from_content(msg.get("content", ""))
if not text or not text.strip():
continue
text = text.strip()
-
+
if role == "user":
if len(text) <= 5:
continue
- items.append(f"- 用户请求: {text[:200]}")
- elif role == "assistant":
+ current_user_text = text[:150]
+ elif role == "assistant" and current_user_text:
first_line = text.split("\n")[0].strip()
if len(first_line) > 10:
- items.append(f"- 处理结果: {first_line[:200]}")
-
- return "\n".join(items[:15])
+ events.append(f"- {current_user_text} → {first_line[:150]}")
+ else:
+ events.append(f"- {current_user_text}")
+ current_user_text = ""
+
+ if current_user_text:
+ events.append(f"- {current_user_text}")
+
+ return "\n".join(events[:10])
@staticmethod
def _extract_text_from_content(content) -> str:
diff --git a/channel/web/chat.html b/channel/web/chat.html
index f9dd1eee..6d3ba6e3 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -455,6 +455,11 @@
Skills
View, enable, or disable agent skills
+
+
+ Skill Hub
+
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index dc0625b2..756c1ba4 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -33,7 +33,7 @@ const I18N = {
config_save: '保存', config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义...',
- skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能',
+ skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', skills_hub_btn: '探索技能广场',
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
tools_section_title: '内置工具', tools_loading: '加载工具中...',
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
@@ -88,7 +88,7 @@ const I18N = {
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_hub_btn: 'Skill Hub',
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',
@@ -881,7 +881,7 @@ function startSSE(requestId, loadingEl, timestamp) {
const imgEl = document.createElement('img');
imgEl.src = item.content;
imgEl.alt = 'screenshot';
- imgEl.style.cssText = 'max-width:360px;border-radius:8px;margin:8px 0;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.1);';
+ imgEl.style.cssText = 'max-width:600px;border-radius:8px;margin:8px 0;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.1);';
imgEl.onclick = () => window.open(item.content, '_blank');
mediaEl.appendChild(imgEl);
scrollChatToBottom();
diff --git a/cli/VERSION b/cli/VERSION
index 2165f8f9..e0102586 100644
--- a/cli/VERSION
+++ b/cli/VERSION
@@ -1 +1 @@
-2.0.4
+2.0.5
diff --git a/cli/commands/skill.py b/cli/commands/skill.py
index cf6c1954..23005d6a 100644
--- a/cli/commands/skill.py
+++ b/cli/commands/skill.py
@@ -753,7 +753,8 @@ def _list_remote(page: int = 1):
nav_parts.append(f"cow skill list --remote --page {page + 1}")
if nav_parts:
click.echo(f" Navigate: {' | '.join(nav_parts)}")
- click.echo(f" Install: cow skill install
\n")
+ click.echo(f" Install: cow skill install ")
+ click.echo(f" Browse: https://skills.cowagent.ai\n")
# ------------------------------------------------------------------
@@ -880,6 +881,15 @@ def _route_install(name: str, result: InstallResult):
_install_hub(skill_name, result, provider="clawhub")
return
+ # --- linkai: prefix ---
+ if name.startswith("linkai:"):
+ skill_code = name[7:]
+ # LinkAI codes can be mixed-case alphanumeric; validate loosely
+ if not re.match(r"^[a-zA-Z0-9_\-]{1,128}$", skill_code):
+ raise SkillInstallError(f"Invalid LinkAI skill code '{skill_code}'.")
+ _install_hub(skill_code, result, provider="linkai")
+ return
+
# --- owner/repo or owner/repo#subpath shorthand ---
if re.match(r"^[a-zA-Z0-9_\-]+/[a-zA-Z0-9_.\-]+(?:#.+)?$", name):
subpath = None
diff --git a/docs/agent.md b/docs/agent.md
deleted file mode 100644
index da8b0f0b..00000000
--- a/docs/agent.md
+++ /dev/null
@@ -1,185 +0,0 @@
-# CowAgent介绍
-
-## 概述
-
-Cow项目从简单的聊天机器人全面升级为超级智能助理 **CowAgent**,能够主动规思考和规划任务、拥有长期记忆、操作计算机和外部资源、创造和执行Skill,真正理解你并和你一起成长。CowAgent能够长期运行在个人电脑或服务器中,通过飞书、钉钉、企业微信、网页等多种方式进行交互。核心能力如下:
-
-- **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持多轮推理和上下文理解
-- **工具系统**:内置实现10+种工具,包括文件读写、bash终端、浏览器、定时任务、记忆管理等,通过Agent管理你的计算机或服务器
-- **长期记忆**:自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
-- **Skills系统**:新增Skill运行引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
-- **多渠道和多模型支持**:支持在Web、飞书、钉钉、企微等多渠道与Agent交互,支持Claude、Gemini、OpenAI、GLM、MiniMax、Qwen、Kimi、Doubao 等多种国内外主流模型
-- **安全和成本**:通过秘钥管理工具、提示词控制、系统权限等手段控制Agent的访问安全;通过最大记忆轮次、最大上下文token、工具执行步数对token成本进行限制
-
-
-## 核心功能
-
-### 1. 长期记忆
-
-> 记忆系统让 Agent 能够长期记住重要信息。Agent 会在用户分享偏好、决策、事实等重要信息时主动存储,也会在对话达到一定长度时自动提取摘要。记忆分为核心记忆、天级记忆,支持语义搜索和向量检索的混合检索模式。
-
-
-第一次启动Agent会主动向用户获取询问关键信息,并记录至工作空间 (默认为 ~/cow) 中的智能体设定、用户身份、记忆文件中。
-
-在后续的长期对话中,Agent会在需要的时候智能记录或检索记忆,并对自身设定、用户偏好、记忆文件等进行不断更新,总结和记录经验和教训,真正实现自主思考和不断成长。
-
-
-
-
-
-### 2. 任务规划和工具调用
-
-工具是Agent访问操作系统资源的核心,Agent会根据任务需求智能选择和调用工具,完成文件读写、命令执行、定时任务等各类操作。内置工具的视线在项目的 `tools` 目录下。
-
-**主要工具:** 文件读写编辑、Bash终端、浏览器、文件发送、定时调度、记忆搜索、环境配置等。
-
-#### 1.1 终端和文件访问能力
-
-针对操作系统的终端和文件的访问能力,是最基础和核心的工具,其他很多工具或技能都是基于基础工具进行扩展。用户可通过手机端与Agent交互,操作个人电脑或服务器上的资源:
-
-
-
-#### 1.2 编程能力
-
-基于编程能力和系统访问能力,Agent可以实现从信息搜索、图片等素材生成、编码、测试、部署、Nginx配置修改、发布的 Vibecoding 全流程,通过手机端简单的一句命令完成应用的快速demo:
-
-
-
-
-
-
-#### 1.3 定时任务
-
-基于 scheduler 工具实现动态定时任务,支持 **一次性任务、固定时间间隔、Cron表达式** 三种形式,任务触发可选择**固定消息发送** 或 **Agent动态任务** 执行两种模式,有很高灵活性:
-
-
-
-
-同时你也可以通过自然语言快速查看和管理已有的定时任务。
-
-
-#### 1.4 环境变量管理
-
-技能所需要的秘钥存储在环境变量文件中,由 `env_config` 工具进行管理,你可以通过对话的方式更新秘钥,工具内置了安全保护和脱敏策略,会严格保护秘钥安全:
-
-
-
-### 3. 技能系统
-
-> 技能系统为Agent提供无限的扩展性,每个Skill由说明文件、运行脚本 (可选)、资源 (可选) 组成,描述如何完成特定类型的任务。通过Skill可以让Agent遵循说明完成复杂流程,调用各类工具或对接第三方系统等。
-
-- **内置技能:** 在项目的`skills`目录下,包含技能创造器、网络搜索、图像识别(openai-image-vision)、LinkAI智能体、网页抓取等。内置Skill根据依赖条件 (API Key、系统命令等) 自动判断是否启用。通过技能创造器可以快速创建自定义技能。
-
-- **自定义技能:** 由用户通过对话创建,存放在工作空间中 (`~/cow/skills/`),基于自定义技能可以实现任何复杂的业务流程和第三方系统对接。
-
-
-#### 3.1 创建技能
-
-通过 `skill-creator` 技能可以通过对话的方式快速创建技能。你可以在与Agent的写作中让他对将某个工作流程固化为技能,或者把任意接口文档和示例发送给Agent,让他直接完成对接:
-
-
-
-
-#### 3.2 搜索和图像识别
-
-- **搜索技能:** 系统内置实现了 `bocha-search`(博查搜索)的Skill,依赖环境变量 `BOCHA_SEARCH_API_KEY`,可在[控制台](https://open.bochaai.com/)进行创建,并发送给Agent完成配置
-- **图像识别技能:** 实现了 `openai-image-vision` 插件,可使用 gpt-4.1-mini、gpt-4.1 等图像识别模型。依赖秘钥 `OPENAI_API_KEY`,可通过config.json或env_config工具进行维护。
-
-
-
-
-#### 3.3 三方知识库和插件
-
-`linkai-agent` 技能可以将 [LinkAI](https://link-ai.tech/) 上的所有智能体作为skill交给Agent使用,并实现多智能体决策的效果。
-
-使用方式:需通过对话的方式配置 `LINKAI_API_KEY`,或在config.json中添加 `linkai_api_key`。 并在 `skills/linkai-agent/config.json`中添加智能体说明,示例如下:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI客服助手",
- "app_description": "当用户需要了解LinkAI平台相关问题时才选择该助手,基于LinkAI知识库进行回答"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "内容创作助手",
- "app_description": "当用户需要创作图片或视频时才使用该助手,支持Nano Banana、Seedream、即梦、Veo、可灵等多种模型"
- }
- ]
-}
-```
-
-Agent可根据智能体的名称和描述进行决策,并通过 app_code 调用接口访问对应的应用/工作流,通过该技能,可以灵活访问LinkAI平台上的智能体、知识库、插件等能力,实现效果如下:
-
-
-
-注:需通过 `env_config` 配置 `LINKAI_API_KEY`,或在config.json中添加 `linkai_api_key` 配置。
-
-
-## 使用方式
-
-> 详细使用方式参考项目README.md文档进行
-
-### 1.项目运行
-
-在命令行中执行:
-
-```bash
-bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
-```
-
-详细说明及后续程序管理参考:[项目启动脚本](https://github.com/zhayujie/chatgpt-on-wechat/wiki/CowAgentQuickStart)
-
-
-### 2.模型选择
-
-Agent模式推荐使用以下模型,可根据效果及成本综合选择:
-
-- **MiniMax**: `MiniMax-M2.7`
-- **GLM**: `glm-5-turbo`
-- **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-flash-lite-preview`
-- **OpenAI**: `gpt-5.4`
-
-详细模型配置方式参考 [README.md 模型说明](../README.md#模型说明)
-
-### 3.Agent核心配置
-
-Agent模式的核心配置项如下,在 `config.json` 中配置:
-
-```bash
-{
- "agent": true, # 是否启用Agent模式
- "agent_workspace": "~/cow", # Agent工作空间路径
- "agent_max_context_tokens": 40000, # 最大上下文tokens
- "agent_max_context_turns": 30, # 最大上下文记忆轮次
- "agent_max_steps": 15 # 单次任务最大决策步数
-}
-```
-
-**配置说明:**
-
-- `agent`: 设为 `true` 启用Agent模式,获得多轮工具决策、长期记忆、Skills等能力
-- `agent_workspace`: 工作空间路径,用于存储 memory、skills、其他系统设定提示词
-- `agent_max_context_tokens`: 上下文token上限,超出将自动丢弃最早的对话
-- `agent_max_context_turns`: 上下文记忆轮次,每轮包括一次提问和回复
-- `agent_max_steps`: 单次任务最大工具调用步数,防止无限循环
-
-
-### 4.渠道接入
-
-Agent支持在多种渠道中使用,只需修改 `config.json` 中的 `channel_type` 配置即可切换。
-
-- **Web网页**:默认使用该渠道,运行后监听本地端口,通过浏览器访问
-- **飞书接入**:[飞书接入文档](https://docs.link-ai.tech/cow/multi-platform/feishu)
-- **钉钉接入**:[钉钉接入文档](https://docs.link-ai.tech/cow/multi-platform/dingtalk)
-- **企业微信应用接入**:[企微应用文档](https://docs.link-ai.tech/cow/multi-platform/wechat-com)
-- **企微智能机器人**:[企微智能机器人文档](https://docs.link-ai.tech/cow/multi-platform/wecom-bot)
-- **QQ机器人**:[QQ机器人文档](https://docs.link-ai.tech/cow/multi-platform/qq)
-
-更多渠道配置参考:[通道说明](../README.md#通道说明)
diff --git a/docs/channels/wecom-bot.mdx b/docs/channels/wecom-bot.mdx
index bcdac98f..7275639f 100644
--- a/docs/channels/wecom-bot.mdx
+++ b/docs/channels/wecom-bot.mdx
@@ -9,7 +9,23 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
智能机器人与企业微信自建应用是两种不同的接入方式。智能机器人使用 WebSocket 长连接,无需服务器公网 IP 和域名,配置更简单。
-## 一、创建智能机器人
+## 一、接入方式
+
+### 方式一:扫码一键接入(推荐)
+
+无需提前创建机器人,启动 Cow 项目后打开 Web 控制台(本地链接:http://127.0.0.1:9899/),选择 **通道** 菜单,点击**接入通道**,选择**企微智能机器人**,切换到「扫码接入」模式,使用**企业微信**扫码即可自动完成机器人创建和接入。
+
+
+
+
+ 扫码成功后,可在企业微信工作台 - **智能机器人**页面对机器人进行进一步配置,包括修改名称、头像、可见范围等。
+
+
+### 方式二:手动创建接入
+
+需要先在企业微信中创建智能机器人并获取 Bot ID 和 Secret,再通过 Web 控制台或配置文件接入。
+
+**步骤一:创建智能机器人**
1. 打开企业微信客户端,进入工作台,点击**智能机器人**:
@@ -25,34 +41,35 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
4. 设置机器人名称、头像、可见范围,并选择**长连接模式**,记录下 **Bot ID** 和 **Secret** 信息后点击保存。
-## 二、配置和运行
+**步骤二:接入 CowAgent**
-### 方式一:Web 控制台接入
+
+
+ 打开 Web 控制台,选择**通道**菜单,点击**接入通道**,选择**企微智能机器人**,切换到「手动填写」模式,输入 Bot ID 和 Secret,点击接入即可。
-启动Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **企微智能机器人**,填写上一步保存的 Bot ID 和 Secret,点击接入即可。
+
+
+
+ 在 `config.json` 中添加以下配置后启动程序:
-
+ ```json
+ {
+ "channel_type": "wecom_bot",
+ "wecom_bot_id": "YOUR_BOT_ID",
+ "wecom_bot_secret": "YOUR_SECRET"
+ }
+ ```
-### 方式二:配置文件接入
+ | 参数 | 说明 |
+ | --- | --- |
+ | `wecom_bot_id` | 智能机器人的 BotID |
+ | `wecom_bot_secret` | 智能机器人的 Secret |
+
+
-在 `config.json` 中添加以下配置:
+日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
-```json
-{
- "channel_type": "wecom_bot",
- "wecom_bot_id": "YOUR_BOT_ID",
- "wecom_bot_secret": "YOUR_SECRET"
-}
-```
-
-| 参数 | 说明 |
-| --- | --- |
-| `wecom_bot_id` | 智能机器人的 BotID |
-| `wecom_bot_secret` | 智能机器人的 Secret |
-
-配置完成后启动程序,日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
-
-## 三、功能说明
+## 二、功能说明
| 功能 | 支持情况 |
| --- | --- |
@@ -64,7 +81,7 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
| 流式回复 | ✅ |
| 定时任务主动推送 | ✅ |
-## 四、使用
+## 三、使用
在企业微信中搜索创建的机器人名称,即可开始单聊对话。
diff --git a/docs/docs.json b/docs/docs.json
index 5e761093..272b28f5 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -129,7 +129,8 @@
"pages": [
"skills/index",
"skills/install",
- "skills/create"
+ "skills/create",
+ "skills/hub"
]
}
]
@@ -289,7 +290,8 @@
"pages": [
"en/skills/index",
"en/skills/install",
- "en/skills/skill-creator"
+ "en/skills/skill-creator",
+ "en/skills/hub"
]
}
]
@@ -449,7 +451,8 @@
"pages": [
"ja/skills/index",
"ja/skills/install",
- "ja/skills/create"
+ "ja/skills/create",
+ "ja/skills/hub"
]
}
]
diff --git a/docs/intro/features.mdx b/docs/intro/features.mdx
index 56020d46..9bef1524 100644
--- a/docs/intro/features.mdx
+++ b/docs/intro/features.mdx
@@ -1,6 +1,6 @@
---
title: 功能介绍
-description: CowAgent 长期记忆、任务规划、技能系统详细说明
+description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、浏览器工具详细说明
---
## 1. 长期记忆
@@ -19,7 +19,7 @@ description: CowAgent 长期记忆、任务规划、技能系统详细说明
工具是 Agent 访问操作系统资源的核心,Agent 会根据任务需求智能选择和调用工具,完成文件读写、命令执行、定时任务等各类操作。内置工具的实现在项目的 `agent/tools/` 目录下。
-**主要工具:** 文件读写编辑、Bash 终端、文件发送、定时调度、记忆搜索、联网搜索、环境配置等。
+**主要工具:** 文件读写编辑、Bash 终端、浏览器操作、文件发送、定时调度、记忆搜索、联网搜索、环境配置等。
### 2.1 终端和文件访问
@@ -45,7 +45,15 @@ description: CowAgent 长期记忆、任务规划、技能系统详细说明
-### 2.4 环境变量管理
+### 2.4 浏览器操作
+
+内置 `browser` 工具,Agent 可控制浏览器访问网页、填写表单、点击元素、截图,支持动态 JS 渲染页面。运行 `cow install-browser` 一键安装,自动适配服务器(无头模式)和桌面环境:
+
+
+
+
+
+### 2.5 环境变量管理
技能所需的秘钥存储在环境变量文件中,由 `env_config` 工具进行管理,你可以通过对话的方式更新秘钥,工具内置安全保护和脱敏策略:
@@ -57,9 +65,12 @@ description: CowAgent 长期记忆、任务规划、技能系统详细说明
技能系统为 Agent 提供无限的扩展性,每个 Skill 由说明文件、运行脚本(可选)、资源(可选)组成,描述如何完成特定类型的任务。通过 Skill 可以让 Agent 遵循说明完成复杂流程、调用各类工具或对接第三方系统。
+- **[Skill Hub](https://skills.cowagent.ai/):** 开放的技能广场,汇集官方推荐、社区贡献和第三方技能,支持一键安装。
- **内置技能:** 在项目的 `skills/` 目录下,包含技能创造器、图像识别、LinkAI 智能体、网页抓取等。内置 Skill 根据依赖条件(API Key、系统命令等)自动判断是否启用。
- **自定义技能:** 由用户通过对话创建,存放在工作空间中(`~/cow/skills/`),可实现任何复杂的业务流程和第三方系统对接。
+安装技能:`/skill install <名称>` 或 `cow skill install <名称>`,支持从 Skill Hub、GitHub、ClawHub、URL 等来源安装。
+
### 3.1 创建技能
通过 `skill-creator` 技能可以通过对话的方式快速创建技能。你可以让 Agent 将某个工作流程固化为技能,或者把任意接口文档和示例发送给 Agent,让他直接完成对接:
@@ -77,7 +88,19 @@ description: CowAgent 长期记忆、任务规划、技能系统详细说明
-### 3.3 三方知识库和插件
+### 3.3 技能广场
+
+访问 [skills.cowagent.ai](https://skills.cowagent.ai/) 浏览所有可用技能,或在对话中执行:
+
+```text
+/skill list --remote # 浏览技能广场
+/skill search <关键词> # 搜索技能
+/skill install <名称> # 一键安装
+```
+
+
+
+### 3.4 三方知识库和插件
`linkai-agent` 技能可以将 [LinkAI](https://link-ai.tech/) 上的所有智能体作为 Skill 交给 Agent 使用,实现多智能体决策效果。
@@ -103,3 +126,22 @@ description: CowAgent 长期记忆、任务规划、技能系统详细说明
+
+## 4. CLI 命令系统
+
+CowAgent 提供两种命令交互方式,覆盖服务管理、技能安装、配置调整等日常运维操作:
+
+- **终端 CLI:** 在系统终端执行 `cow <命令>`,支持 `start`、`stop`、`restart`、`update`、`status`、`logs`、`skill` 等
+- **对话命令:** 在对话中输入 `/<命令>`,Web 控制台输入 `/` 可弹出指令菜单快速选择
+
+```bash
+cow start # 启动服务
+cow stop # 停止服务
+cow update # 更新并重启
+cow skill install pptx # 安装技能
+cow install-browser # 安装浏览器工具
+```
+
+详细命令参考 [命令总览](https://docs.cowagent.ai/commands)。
+
+
diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx
index 89d83de6..4209bd22 100644
--- a/docs/releases/overview.mdx
+++ b/docs/releases/overview.mdx
@@ -5,7 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
-| [2.0.5](/releases/v2.0.5) | 2026.03.31 | Cow CLI、Skill Hub 开源、浏览器工具、企微扫码创建、DeepSeek 独立模块及多项优化 |
+| [2.0.5](/releases/v2.0.5) | 2026.04.01 | Cow CLI、Skill Hub 开源、浏览器工具、企微扫码创建、多项优化和修复 |
| [2.0.4](/releases/v2.0.4) | 2026.03.22 | 新增个人微信通道、新模型支持、日文文档、脚本重构及多项修复 |
| [2.0.3](/releases/v2.0.3) | 2026.03.18 | 新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级 |
| [2.0.2](/releases/v2.0.2) | 2026.02.27 | Web 控制台升级、多通道同时运行、会话持久化 |
diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx
index 5983056b..76f4d423 100644
--- a/docs/releases/v2.0.5.mdx
+++ b/docs/releases/v2.0.5.mdx
@@ -9,47 +9,45 @@ description: CowAgent 2.0.5 - Cow CLI、Skill Hub 开源、浏览器工具、企
- **终端命令**:在系统终端中执行 `cow <命令>`,支持 `start`、`stop`、`restart`、`update`、`status`、`logs` 等服务管理操作
- **对话命令**:在对话中输入 `/<命令>` 或 `cow <命令>`,支持 `/help`、`/status`、`/config`、`/skill`、`/context`、`/logs`、`/version` 等
-- **斜杠指令菜单**:Web 控制台输入框输入 `/` 即可弹出指令菜单,快速选择命令
-- **输入历史**:支持方向键回溯历史输入
-- **Windows 支持**:新增 PowerShell 脚本 `run.ps1`,Windows 下可使用 `cow` 命令
+- **web控制台**:Web 控制台输入框输入 `/` 即可弹出指令菜单,支持方向键回溯历史输入
+- **Windows 支持**:新增 PowerShell 一键安装脚本 `scripts/run.ps1`,同时支持 `cow` 命令
-相关文档:[命令总览](https://docs.cowagent.ai/commands)、[技能管理命令](https://docs.cowagent.ai/commands/skill)、[服务管理命令](https://docs.cowagent.ai/commands/process)、[通用命令](https://docs.cowagent.ai/commands/general)。
+相关文档:[命令总览](https://docs.cowagent.ai/commands)
-相关提交:[#2726](https://github.com/zhayujie/chatgpt-on-wechat/pull/2726)
+
## 🧩 Cow Skill Hub 开源
-Cow Skill Hub(技能广场)正式开源并上线,提供 AI Agent 技能的浏览、搜索、安装和发布:
+[Cow Skill Hub](https://skills.cowagent.ai)(技能广场)正式开源并上线,提供 AI Agent 技能的浏览、搜索、安装和发布,汇集精选技能、社区贡献技能、三方技能:
-- **技能广场**:访问 [skills.cowagent.ai](https://skills.cowagent.ai) 浏览所有可用技能,支持按类别(推荐 / 社区 / 第三方)和标签筛选
- **一键安装**:在对话中 `/skill install <名称>` 或终端 `cow skill install <名称>` 一键安装
-- **多来源支持**:支持从 Skill Hub、GitHub、ClawHub、URL(zip / SKILL.md)等多种来源安装,支持 GitHub 批量安装和子目录指定
+- **多来源支持**:支持从 Skill Hub、GitHub、ClawHub、LinkAI、URL(zip / SKILL.md)等多种来源安装,支持 GitHub 批量安装和子目录指定
- **技能搜索**:`/skill search` 和 `/skill list --remote` 浏览和搜索技能广场
- **技能发布**:通过 [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) 提交自己的技能
-- **国内镜像**:支持 Skill Hub 镜像加速,国内环境下载更流畅
+- **镜像加速**:支持 Skill Hub 镜像加速,国内环境下载更流畅
-Skill Hub 开源仓库:[cow-skill-hub](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/ref/cow-skill-hub)。
+Skill Hub 开源仓库:[cow-skill-hub](https://github.com/zhayujie/cow-skill-hub)。
-相关文档:[技能概览](https://docs.cowagent.ai/skills)、[安装技能](https://docs.cowagent.ai/skills/install)、[创建技能](https://docs.cowagent.ai/skills/create)、[技能管理命令](https://docs.cowagent.ai/commands/skill)。
+相关文档:[技能广场](https://docs.cowagent.ai/skills/hub)、[安装技能](https://docs.cowagent.ai/skills/install)
+
+
-相关提交:[#2726](https://github.com/zhayujie/chatgpt-on-wechat/pull/2726)
## 🌐 新增浏览器工具
-新增 Browser 工具,Agent 可控制 Chromium 浏览器访问和操作网页:
+新增 Browser 工具,Agent 可控制浏览器访问和操作网页:
- **网页导航与交互**:支持 `navigate`、`click`、`fill`、`select`、`scroll`、`press` 等操作
- **页面快照**:使用精简 DOM 快照技术,让 Agent 高效理解页面结构,导航后自动快照
- **截图能力**:支持页面截图保存到工作区
- **JavaScript 执行**:支持在页面中执行自定义脚本
- **CLI 安装**:通过 `cow install-browser` 一键安装浏览器及依赖,自动适配系统环境
-- **对话中安装**:支持在对话中触发浏览器安装
- **Docker 支持**:Docker 镜像已内置浏览器安装支持
-- **跨平台适配**:Linux 下自动安装中文字体,服务器环境自动使用无头模式
相关文档:[浏览器工具](https://docs.cowagent.ai/tools/browser)。
-相关提交:[#2727](https://github.com/zhayujie/chatgpt-on-wechat/pull/2727)
+
+
## 🤖 企微智能机器人扫码创建
@@ -57,7 +55,7 @@ Skill Hub 开源仓库:[cow-skill-hub](https://github.com/zhayujie/chatgpt-on-
- **Web 控制台扫码**:在 Web 控制台通道页面,选择「扫码接入」模式,使用企业微信扫码即可自动创建并接入智能机器人,无需手动到企业微信后台配置
- **手动模式保留**:同时保留「手动填写」模式,可输入已有的 Bot ID 和 Secret 接入
-- **流式推送优化**:增加推送节流(100ms 间隔),避免 WebSocket 拥塞
+- **流式推送优化**:增加推送节流,避免 WebSocket 拥塞
相关文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。
@@ -77,9 +75,10 @@ Thanks [@WecomTeam](https://github.com/WecomTeam)
- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba)、[baf66a1](https://github.com/zhayujie/chatgpt-on-wechat/commit/baf66a1)、[45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
- **Docker 优化**:修复卷权限问题,精简镜像体积 ([3eb8348](https://github.com/zhayujie/chatgpt-on-wechat/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/4470d4c))
- **README 排版**:优化中英文排版空格([#2723](https://github.com/zhayujie/chatgpt-on-wechat/pull/2723))。Thanks [@Xiaozhou345](https://github.com/Xiaozhou345)
+- **安全修复**:修复 Memory Content路径遍历风险,Thanks [@August829](https://github.com/August829)
## 📦 升级方式
源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。
-**发布日期**:2026.03.31 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master)
+**发布日期**:2026.04.01 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master)
diff --git a/docs/skills/create.mdx b/docs/skills/create.mdx
index b0f53a11..b8ae7ea2 100644
--- a/docs/skills/create.mdx
+++ b/docs/skills/create.mdx
@@ -1,5 +1,5 @@
---
-title: 创建技能
+title: 创造技能
description: 通过对话创建自定义技能
---
diff --git a/docs/skills/hub.mdx b/docs/skills/hub.mdx
new file mode 100644
index 00000000..843dd4d4
--- /dev/null
+++ b/docs/skills/hub.mdx
@@ -0,0 +1,65 @@
+---
+title: 技能广场
+description: 浏览、搜索和安装 AI Agent 技能
+---
+
+[Cow Skill Hub](https://skills.cowagent.ai/) 是开源的 AI Agent 技能广场,汇集了官方推荐、社区贡献和第三方平台(GitHub、ClawHub 等)的技能。
+
+开源仓库:[github.com/zhayujie/cow-skill-hub](https://github.com/zhayujie/cow-skill-hub)
+
+
+
+## 功能
+
+- **浏览技能**:按类别(推荐 / 社区 / 第三方)和标签筛选
+- **搜索技能**:按名称或描述搜索
+- **查看详情**:查看技能文档、文件内容、安装命令和依赖的环境变量
+- **一键安装**:复制安装命令即可在 CowAgent 中使用
+
+## 安装技能
+
+在对话中或终端中执行安装命令:
+
+
+```text 对话
+/skill install
+```
+
+```bash 终端
+cow skill install
+```
+
+
+也可以在对话中浏览技能广场:
+
+```text
+/skill list --remote
+/skill search <关键词>
+```
+
+除了在列表中展示的精选技能,还可以通过 **CLI命令 + Skill Hub** 安装各种第三方技能(**GitHub、ClawHub、LinkAI、URL** 等)参考 [安装技能](/skills/install)。
+
+## 贡献技能
+
+欢迎向技能广场提交你的技能:
+
+1. 访问 [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit)
+2. 使用 GitHub 或 Google 账号登录
+3. 上传包含 `SKILL.md` 的文件夹或 zip 包
+4. 自动解析技能名称、显示名称和描述,可按需修改
+5. 提交后将经过安全检查和审核后发布
+
+
+
+技能文件结构:
+
+```
+your-skill/
+├── SKILL.md # 必须,放在根目录
+├── scripts/ # 可选,运行脚本
+└── resources/ # 可选,其他资源
+```
+
+
+ 技能基于 `SKILL.md` 文件构建,你也可以在技能详情页下载 SKILL.md,用于任何支持自定义指令的 Agent(如 OpenClaw、Cursor、Claude Code 等)。
+
diff --git a/docs/skills/index.mdx b/docs/skills/index.mdx
index 5648ceaf..3ef4f84c 100644
--- a/docs/skills/index.mdx
+++ b/docs/skills/index.mdx
@@ -11,13 +11,13 @@ Skill 与 Tool 的区别:Tool 是由代码实现的原子操作(如读写文
CowAgent 提供多种方式获取技能:
-- **Cow 技能广场** — 通过 `/skill list --remote` 浏览和安装社区技能
+- **[Cow 技能广场](https://skills.cowagent.ai/)** — 在线浏览所有可用技能,或通过 `/skill list --remote` 在对话中浏览和安装
- **GitHub** — 直接从 GitHub 仓库安装,支持批量安装
-- **ClawHub** — 通过 `/skill install clawhub:名称` 安装 ClawHub 上的技能
+- **ClawHub** — 通过 `/skill install clawhub:名称` 安装 ClawHub 上的技能 (4w+个)
- **URL** — 从 zip 压缩包或 SKILL.md 链接安装
- **对话创建** — 通过自然语言对话让 Agent 自动创建技能
-详细安装方式参考 [安装技能](/skills/install) 和 [技能管理命令](/commands/skill)。也可以通过对话 [创建技能](/skills/create)。
+详细安装方式参考 [安装技能](/skills/install) 和 [技能管理命令](/commands/skill)。也可以通过对话 [创建技能](/skills/create),或向 [Skill Hub](https://skills.cowagent.ai/submit) 贡献你的技能。
## 技能加载优先级
diff --git a/docs/skills/install.mdx b/docs/skills/install.mdx
index 73b0cf6b..cd933a49 100644
--- a/docs/skills/install.mdx
+++ b/docs/skills/install.mdx
@@ -3,11 +3,11 @@ title: 安装技能
description: 通过命令一键安装来自多种来源的技能
---
-CowAgent 支持通过统一的 `install` 命令安装来自 **Cow 技能广场、GitHub、ClawHub** 以及任意 URL 上的技能。在对话中使用 `/skill install`,在终端中使用 `cow skill install`。
+CowAgent 支持通过统一的 `install` 命令安装来自 **[Cow 技能广场](https://skills.cowagent.ai/)、GitHub、ClawHub、LinkAI** 以及任意 URL 上的技能。在对话中使用 `/skill install`,在终端中使用 `cow skill install`。
## 从Cow技能广场安装
-浏览技能广场,找到想要的技能后直接安装:
+访问 [skills.cowagent.ai](https://skills.cowagent.ai/) 浏览所有可用技能,找到想要的技能后直接安装,例如:
```text
/skill list --remote
@@ -16,7 +16,7 @@ CowAgent 支持通过统一的 `install` 命令安装来自 **Cow 技能广场
## 从 GitHub 安装
-支持仓库级批量安装和指定子目录安装:
+> Github上的所有技能都可以直接安装,支持仓库级批量安装和指定子目录安装,例如:
```text
/skill install larksuite/cli
@@ -25,10 +25,23 @@ CowAgent 支持通过统一的 `install` 命令安装来自 **Cow 技能广场
## 从 ClawHub 安装
+[ClawHub](https://clawhub.ai/) 上的所有技能 (4w+个) 都可以一键安装,例如:
+
+
```text
-/skill install clawhub:baidu-search
+/skill install clawhub:
```
+## 从 LinkAI 安装
+
+[LinkAI](https://link-ai.tech/console) 上的所有公开资源 (1w+个插件/应用/工作流) ,以及自己创建的资源 (应用/工作流/知识库/数据库/插件) 都可以通过命令一键安装:
+
+```text
+/skill install linkai:
+```
+
+> LinkAI平台上创建的所有应用、工作流、知识库、数据库、插件都有唯一的code,可在[控制台](https://link-ai.tech/console)各资源页面中进行获取并填写到命令中
+
## 从 URL 安装
支持 zip 压缩包和 SKILL.md 文件链接:
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 27e653ef..aed0b410 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -621,6 +621,7 @@ class CowCliPlugin(Plugin):
lines.append(f"💡 /skill list --remote --page {page - 1} 上一页")
lines.append("💡 /skill install <名称> 安装技能")
lines.append("💡 /skill search <关键词> 搜索技能")
+ lines.append("🌐 https://skills.cowagent.ai 在线浏览全部技能")
return "\n".join(lines)
def _skill_search(self, query: str) -> str:
diff --git a/pyproject.toml b/pyproject.toml
index 565d07e2..0c3b2a76 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cowagent"
-version = "0.0.1"
+version = "1.0.0"
description = "CowAgent - AI Agent on WeChat and more"
requires-python = ">=3.9"
dependencies = [
From 830b8f2971a7d8c146c2dddc0c03b877ae4bd54a Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 15:01:53 +0800
Subject: [PATCH 074/399] feat: release 2.0.5
---
docs/en/README.md | 6 +-
docs/en/intro/features.mdx | 65 +++---
docs/en/releases/overview.mdx | 1 +
docs/en/releases/v2.0.5.mdx | 77 +++++++
docs/intro/features.mdx | 30 +--
docs/ja/README.md | 6 +-
docs/ja/intro/features.mdx | 65 +++---
docs/ja/releases/overview.mdx | 1 +
docs/ja/releases/v2.0.5.mdx | 77 +++++++
docs/releases/v2.0.5.mdx | 2 +-
docs/skills/index.mdx | 1 +
skills/README.md | 165 ++++++---------
skills/linkai-agent/README.md | 258 -----------------------
skills/linkai-agent/SKILL.md | 85 --------
skills/linkai-agent/config.json.template | 14 --
15 files changed, 316 insertions(+), 537 deletions(-)
create mode 100644 docs/en/releases/v2.0.5.mdx
create mode 100644 docs/ja/releases/v2.0.5.mdx
delete mode 100644 skills/linkai-agent/README.md
delete mode 100644 skills/linkai-agent/SKILL.md
delete mode 100644 skills/linkai-agent/config.json.template
diff --git a/docs/en/README.md b/docs/en/README.md
index 9b14d9c9..a1742488 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -13,6 +13,7 @@
🌐 Website ·
📖 Docs ·
🚀 Quick Start ·
+ 🧩 Skill Hub ·
☁️ Try Online
@@ -41,6 +42,8 @@ Try online (no deployment needed): [CowAgent](https://link-ai.tech/cowagent/crea
## Changelog
+> **2026.04.01:** [v2.0.5](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.5) — Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more.
+
> **2026.02.27:** [v2.0.2](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2) — Web console overhaul (streaming chat, model/skill/memory/channel/scheduler/log management), multi-channel concurrent running, session persistence, new models including Gemini 3.1 Pro / Claude 4.6 Sonnet / Qwen3.5 Plus.
> **2026.02.13:** [v2.0.1](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1) — Built-in Web Search tool, smart context trimming, runtime info dynamic update, Windows compatibility, fixes for scheduler memory loss, Feishu connection issues, and more.
@@ -223,6 +226,7 @@ Multiple channels can be enabled simultaneously, separated by commas: `"channel_
## 🔗 Related Projects
+- [Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub): Open skill marketplace for AI Agents — browse, search, install, and publish skills for CowAgent, OpenClaw, Claude Code, and more.
- [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.
@@ -232,7 +236,7 @@ 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, see the [Skill Creation docs](https://docs.cowagent.ai/en/skills/create).
+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, see the [Skill Creation docs](https://docs.cowagent.ai/en/skills/create), or submit to [Skill Hub](https://skills.cowagent.ai/submit).
## ✉ Contact
diff --git a/docs/en/intro/features.mdx b/docs/en/intro/features.mdx
index 52d41efb..3057b8fc 100644
--- a/docs/en/intro/features.mdx
+++ b/docs/en/intro/features.mdx
@@ -1,6 +1,6 @@
---
title: Features
-description: CowAgent long-term memory, task planning, and skills system in detail
+description: CowAgent long-term memory, task planning, skills system, CLI commands, and browser tool in detail
---
## 1. Long-term Memory
@@ -19,7 +19,7 @@ In subsequent long-term conversations, the Agent intelligently stores or retriev
Tools are the core of how the Agent accesses operating system resources. The Agent intelligently selects and invokes tools based on task requirements, performing file read/write, command execution, scheduled tasks, and more. Built-in tools are implemented in the project's `agent/tools/` directory.
-**Key tools:** file read/write/edit, Bash terminal, file send, scheduler, memory search, web search, environment config, and more.
+**Key tools:** file read/write/edit, Bash terminal, browser, file send, scheduler, memory search, web search, environment config, and more.
### 2.1 Terminal and File Access
@@ -45,7 +45,15 @@ The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time task
-### 2.4 Environment Variable Management
+### 2.4 Browser
+
+The built-in `browser` tool allows the Agent to control a Chromium browser to visit web pages, fill forms, click elements, and take screenshots, with support for dynamic JS-rendered pages. Run `cow install-browser` to install with one command, automatically adapting to server (headless) and desktop environments:
+
+
+
+
+
+### 2.5 Environment Variable Management
Secrets required by skills are stored in an environment variable file, managed by the `env_config` tool. You can update secrets through conversation, with built-in security protection and desensitization:
@@ -57,9 +65,12 @@ Secrets required by skills are stored in an environment variable file, managed b
The Skills system provides infinite extensibility for the Agent. Each Skill consists of a description file, execution scripts (optional), and resources (optional), describing how to complete specific types of tasks. Skills allow the Agent to follow instructions for complex workflows, invoke tools, or integrate third-party systems.
+- **[Skill Hub](https://skills.cowagent.ai/):** An open skill marketplace featuring official, community, and third-party skills. Install with one command.
- **Built-in skills:** Located in the project's `skills/` directory, including skill creator, image recognition, LinkAI agent, web fetch, and more. Built-in skills are automatically enabled based on dependency conditions (API keys, system commands, etc.).
- **Custom skills:** Created by users through conversation, stored in the workspace (`~/cow/skills/`), capable of implementing any complex business process or third-party integration.
+Install skills: `/skill install ` or `cow skill install `, supporting Skill Hub, GitHub, ClawHub, URL, and more.
+
### 3.1 Creating Skills
The `skill-creator` skill enables rapid skill creation through conversation. You can ask the Agent to codify a workflow as a skill, or send any API documentation and examples for the Agent to complete the integration directly:
@@ -77,29 +88,33 @@ The `skill-creator` skill enables rapid skill creation through conversation. You
-### 3.3 Third-party Knowledge Bases and Plugins
+### 3.3 Skill Hub
-The `linkai-agent` skill makes all agents on [LinkAI](https://link-ai.tech/) available as Skills for the Agent, enabling multi-agent decision making.
+Visit [skills.cowagent.ai](https://skills.cowagent.ai/) to browse all available skills, or use commands in conversation:
-Configuration: set `LINKAI_API_KEY` via `env_config`, then add agent descriptions in `skills/linkai-agent/config.json`:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI Customer Support",
- "app_description": "Select only when the user needs help with LinkAI platform questions"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "Content Creator",
- "app_description": "Use only when the user needs to create images or videos"
- }
- ]
-}
+```text
+/skill list --remote # Browse Skill Hub
+/skill search # Search skills
+/skill install # Install with one command
```
-
-
-
+Also supports installing skills from GitHub, ClawHub, LinkAI, and other third-party platforms. See [Install Skills](/en/skills/install) for details.
+
+
+
+## 4. CLI Command System
+
+CowAgent provides two command interaction methods, covering service management, skill installation, configuration, and more:
+
+- **Terminal CLI:** Run `cow ` in the system terminal, supporting `start`, `stop`, `restart`, `update`, `status`, `logs`, `skill`, etc.
+- **Chat commands:** Type `/` in conversation. The Web console shows a command menu when you type `/`.
+
+```bash
+cow start # Start service
+cow stop # Stop service
+cow update # Update and restart
+cow skill install pptx # Install a skill
+cow install-browser # Install browser tool
+```
+
+See [Command Overview](https://docs.cowagent.ai/en/commands) for details.
diff --git a/docs/en/releases/overview.mdx b/docs/en/releases/overview.mdx
index 83b3e908..4b013da8 100644
--- a/docs/en/releases/overview.mdx
+++ b/docs/en/releases/overview.mdx
@@ -5,6 +5,7 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
+| [2.0.5](/en/releases/v2.0.5) | 2026.04.01 | Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more |
| [2.0.4](/en/releases/v2.0.4) | 2026.03.22 | Personal WeChat channel, new model support, Japanese docs, script refactoring and bug fixes |
| [2.0.2](/en/releases/v2.0.2) | 2026.02.27 | Web Console upgrade, multi-channel concurrency, session persistence |
| [2.0.1](/en/releases/v2.0.1) | 2026.02.27 | Built-in Web Search tool, smart context management, multiple fixes |
diff --git a/docs/en/releases/v2.0.5.mdx b/docs/en/releases/v2.0.5.mdx
new file mode 100644
index 00000000..a828aa9f
--- /dev/null
+++ b/docs/en/releases/v2.0.5.mdx
@@ -0,0 +1,77 @@
+---
+title: v2.0.5
+description: CowAgent 2.0.5 - Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more
+---
+
+## 🖥️ Cow CLI
+
+New CLI command system for managing CowAgent from terminal and chat:
+
+- **Terminal commands**: Run `cow ` for `start`, `stop`, `restart`, `update`, `status`, `logs`, etc.
+- **Chat commands**: Type `/` in conversation for `/help`, `/status`, `/config`, `/skill`, `/context`, `/logs`, `/version`, etc.
+- **Web console**: Type `/` in the input box to open a slash command menu, with arrow-key input history
+- **Windows support**: New PowerShell script `scripts/run.ps1` with `cow` command support
+
+Docs: [Command Overview](https://docs.cowagent.ai/en/commands)
+
+
+
+## 🧩 Cow Skill Hub Open Source
+
+[Cow Skill Hub](https://skills.cowagent.ai) is now open source and live — browse, search, install, and publish AI Agent skills:
+
+- **One-command install**: `/skill install ` in chat or `cow skill install ` in terminal
+- **Multi-source**: Install from Skill Hub, GitHub, ClawHub, LinkAI, and more
+- **Search**: `/skill search` and `/skill list --remote` to browse the hub
+- **Publish**: Submit your own skills at [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit)
+- **Mirror**: Mirror acceleration for faster downloads in China
+
+Open source repo: [cow-skill-hub](https://github.com/zhayujie/cow-skill-hub)
+
+Docs: [Skill Hub](https://docs.cowagent.ai/en/skills/hub), [Install Skills](https://docs.cowagent.ai/en/skills/install)
+
+
+
+## 🌐 Browser Tool
+
+New Browser tool — Agent can control a Chromium browser to visit and interact with web pages:
+
+- **Navigation & interaction**: `navigate`, `click`, `fill`, `select`, `scroll`, `press`, etc.
+- **Page snapshot**: Compact DOM snapshot for efficient page understanding, auto-snapshot after navigation
+- **Screenshot**: Save page screenshots to workspace
+- **JavaScript execution**: Run custom scripts on pages
+- **CLI install**: `cow install-browser` for one-command setup
+- **Docker support**: Browser install built into Docker image
+
+Docs: [Browser Tool](https://docs.cowagent.ai/en/tools/browser)
+
+
+
+## 🤖 WeCom Bot QR Code Setup
+
+WeCom Bot channel now supports QR code scan for one-click bot creation:
+
+- **QR scan in Web console**: Select "Scan QR" mode, scan with WeCom to auto-create and connect a bot — no manual configuration needed
+- **Manual mode**: Still supports manual Bot ID and Secret input
+- **Stream push optimization**: Throttled push to avoid WebSocket congestion
+
+Docs: [WeCom Bot](https://docs.cowagent.ai/en/channels/wecom-bot)
+
+PR: [#2735](https://github.com/zhayujie/chatgpt-on-wechat/pull/2735). Thanks [@WecomTeam](https://github.com/WecomTeam)
+
+## 🐛 Other Improvements & Fixes
+
+- **DeepSeek module**: Independent DeepSeek Bot with dedicated `deepseek_api_key` config ([#2719](https://github.com/zhayujie/chatgpt-on-wechat/pull/2719)). Thanks [@6vision](https://github.com/6vision)
+- **Web console**: Slash command menu, input history, new model options, mobile optimization ([#2731](https://github.com/zhayujie/chatgpt-on-wechat/pull/2731)). Thanks [@zkjqd](https://github.com/zkjqd)
+- **Context loss**: Fix context loss after trimming ([393f0c0](https://github.com/zhayujie/chatgpt-on-wechat/commit/393f0c0))
+- **System prompt**: Fix system prompt not rebuilding on every turn ([13f5fde](https://github.com/zhayujie/chatgpt-on-wechat/commit/13f5fde))
+- **Gemini**: Fix missing model attribute in GoogleGeminiBot ([#2716](https://github.com/zhayujie/chatgpt-on-wechat/pull/2716)). Thanks [@cowagent](https://github.com/cowagent)
+- **WeChat channel**: Fix file send failures and filename loss ([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba), [45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
+- **Docker**: Fix volume permissions, reduce image size ([3eb8348](https://github.com/zhayujie/chatgpt-on-wechat/commit/3eb8348), [4470d4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/4470d4c))
+- **Security**: Fix Memory Content path traversal risk. Thanks [@August829](https://github.com/August829)
+
+## 📦 Upgrade
+
+Run `cow update` or `./run.sh update` to upgrade, or pull the latest code and restart. See [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade).
+
+**Release Date**: 2026.04.01 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master)
diff --git a/docs/intro/features.mdx b/docs/intro/features.mdx
index 9bef1524..ff85f1e1 100644
--- a/docs/intro/features.mdx
+++ b/docs/intro/features.mdx
@@ -97,36 +97,12 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、
/skill search <关键词> # 搜索技能
/skill install <名称> # 一键安装
```
+
+同时还支持安装Github、ClawHub、LinkAI等第三方平台上的所有技能,详情查看 [技能安装](/skills/install)
+
-### 3.4 三方知识库和插件
-
-`linkai-agent` 技能可以将 [LinkAI](https://link-ai.tech/) 上的所有智能体作为 Skill 交给 Agent 使用,实现多智能体决策效果。
-
-配置方式:通过 `env_config` 配置 `LINKAI_API_KEY`,并在 `skills/linkai-agent/config.json` 中添加智能体说明:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI客服助手",
- "app_description": "当用户需要了解LinkAI平台相关问题时才选择该助手"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "内容创作助手",
- "app_description": "当用户需要创作图片或视频时才使用该助手"
- }
- ]
-}
-```
-
-
-
-
-
## 4. CLI 命令系统
CowAgent 提供两种命令交互方式,覆盖服务管理、技能安装、配置调整等日常运维操作:
diff --git a/docs/ja/README.md b/docs/ja/README.md
index a5b01782..38764151 100644
--- a/docs/ja/README.md
+++ b/docs/ja/README.md
@@ -13,6 +13,7 @@
🌐 ウェブサイト ·
📖 ドキュメント ·
🚀 クイックスタート ·
+ 🧩 Skill Hub ·
☁️ オンラインで試す
@@ -41,6 +42,8 @@
## 更新履歴
+> **2026.04.01:** [v2.0.5](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.5) — Cow CLI、Skill Hubオープンソース化、ブラウザツール、WeCom Botスキャン作成など。
+
> **2026.02.27:** [v2.0.2](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2) — Webコンソールの全面刷新(ストリーミングチャット、モデル/Skill/メモリ/チャネル/スケジューラ/ログ管理)、マルチチャネル同時実行、セッション永続化、Gemini 3.1 Pro / Claude 4.6 Sonnet / Qwen3.5 Plusなど新モデル追加。
> **2026.02.13:** [v2.0.1](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1) — 組み込みWeb検索ツール、スマートコンテキストトリミング、ランタイム情報の動的更新、Windows互換性、スケジューラのメモリ喪失やFeishu接続問題などの修正。
@@ -223,6 +226,7 @@ Coding Planは各プロバイダーが提供する月額サブスクリプショ
## 🔗 関連プロジェクト
+- [Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub): AIエージェント向けのオープンSkillマーケットプレイス。CowAgent、OpenClaw、Claude Codeなどで利用可能なSkillの閲覧・検索・インストール・公開が可能。
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything): 軽量で高い拡張性を持つLLMアプリケーションフレームワーク。Slack、Telegram、Discord、Gmailなどに対応。
- [AgentMesh](https://github.com/MinimalFuture/AgentMesh): エージェントチームの協調による複雑な問題解決のためのオープンソースのマルチエージェントフレームワーク。
@@ -232,7 +236,7 @@ FAQ:
## 🛠️ コントリビューション
-新しいチャネルの追加を歓迎します。[Feishuチャネル](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py)を参考にしてください。また、新しいSkillのコントリビューションも歓迎します。[Skill作成ドキュメント](https://docs.cowagent.ai/ja/skills/create)を参照してください。
+新しいチャネルの追加を歓迎します。[Feishuチャネル](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py)を参考にしてください。また、新しいSkillのコントリビューションも歓迎します。[Skill作成ドキュメント](https://docs.cowagent.ai/ja/skills/create)を参照するか、[Skill Hub](https://skills.cowagent.ai/submit)に提出してください。
## ✉ お問い合わせ
diff --git a/docs/ja/intro/features.mdx b/docs/ja/intro/features.mdx
index e2f4aead..7c1bfd5a 100644
--- a/docs/ja/intro/features.mdx
+++ b/docs/ja/intro/features.mdx
@@ -1,6 +1,6 @@
---
title: 機能詳細
-description: CowAgent の長期記憶、タスク計画、Skill システムの詳細
+description: CowAgent の長期記憶、タスク計画、Skill システム、CLI コマンド、ブラウザツールの詳細
---
## 1. 長期記憶
@@ -19,7 +19,7 @@ description: CowAgent の長期記憶、タスク計画、Skill システムの
ツールは Agent がオペレーティングシステムのリソースにアクセスするための中核です。Agent はタスク要件に基づいてインテリジェントにツールを選択・呼び出し、ファイルの読み書き、コマンド実行、スケジュールタスクなどを実行します。組み込みツールはプロジェクトの `agent/tools/` ディレクトリに実装されています。
-**主なツール:** ファイルの読み書き・編集、Bash ターミナル、ファイル送信、スケジューラ、記憶検索、Web 検索、環境設定など。
+**主なツール:** ファイルの読み書き・編集、Bash ターミナル、ブラウザ操作、ファイル送信、スケジューラ、記憶検索、Web 検索、環境設定など。
### 2.1 ターミナルとファイルアクセス
@@ -45,7 +45,15 @@ OS のターミナルとファイルシステムへのアクセスは、最も
-### 2.4 環境変数管理
+### 2.4 ブラウザ操作
+
+組み込みの `browser` ツールにより、Agent は Chromium ブラウザを制御して Web ページへのアクセス、フォームの入力、要素のクリック、スクリーンショットの撮影が可能です。動的 JS レンダリングページにも対応しています。`cow install-browser` でワンコマンドインストール、サーバー(ヘッドレス)とデスクトップ環境に自動対応します:
+
+
+
+
+
+### 2.5 環境変数管理
Skill が必要とするシークレットキーは環境変数ファイルに保存され、`env_config` ツールによって管理されます。会話を通じてシークレットを更新でき、セキュリティ保護とマスキング機能が組み込まれています:
@@ -57,9 +65,12 @@ Skill が必要とするシークレットキーは環境変数ファイルに
Skill システムは Agent に無限の拡張性を提供します。各 Skill は説明ファイル、実行スクリプト(任意)、リソース(任意)で構成され、特定のタイプのタスクを完了する方法を記述します。Skill により Agent は複雑なワークフローの指示に従い、ツールを呼び出し、サードパーティシステムと連携できます。
+- **[Skill Hub](https://skills.cowagent.ai/):** オープンな Skill マーケットプレイス。公式推奨、コミュニティ、サードパーティの Skill を収録。ワンコマンドでインストール可能。
- **組み込み Skill:** プロジェクトの `skills/` ディレクトリにあり、Skill クリエイター、画像認識、LinkAI Agent、Web フェッチなどが含まれます。組み込み Skill は依存条件(API キー、システムコマンドなど)に基づいて自動的に有効化されます。
- **カスタム Skill:** ユーザーが会話を通じて作成し、ワークスペース(`~/cow/skills/`)に保存されます。あらゆる複雑なビジネスプロセスやサードパーティ連携を実装できます。
+Skill のインストール:`/skill install <名前>` または `cow skill install <名前>`。Skill Hub、GitHub、ClawHub、URL などからインストール可能。
+
### 3.1 Skill の作成
`skill-creator` Skill により、会話を通じて Skill を素早く作成できます。ワークフローを Skill としてコード化するよう Agent に依頼したり、API ドキュメントやサンプルを送信して Agent に直接連携を完成させることができます:
@@ -77,29 +88,33 @@ Skill システムは Agent に無限の拡張性を提供します。各 Skill
-### 3.3 サードパーティナレッジベースとプラグイン
+### 3.3 Skill Hub
-`linkai-agent` Skill により、[LinkAI](https://link-ai.tech/) 上のすべての Agent を Skill として利用でき、マルチ Agent による意思決定が可能になります。
+[skills.cowagent.ai](https://skills.cowagent.ai/) で利用可能なすべての Skill を閲覧するか、会話内でコマンドを実行できます:
-設定方法:`env_config` で `LINKAI_API_KEY` を設定し、`skills/linkai-agent/config.json` に Agent の説明を追加します:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI Customer Support",
- "app_description": "Select only when the user needs help with LinkAI platform questions"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "Content Creator",
- "app_description": "Use only when the user needs to create images or videos"
- }
- ]
-}
+```text
+/skill list --remote # Skill Hub を閲覧
+/skill search <キーワード> # Skill を検索
+/skill install <名前> # ワンコマンドでインストール
```
-
-
-
+GitHub、ClawHub、LinkAI などサードパーティプラットフォームの Skill もインストール可能です。詳細は [Skill のインストール](/ja/skills/install) を参照してください。
+
+
+
+## 4. CLI コマンドシステム
+
+CowAgent はサービス管理、Skill インストール、設定変更などをカバーする2つのコマンドインターフェースを提供します:
+
+- **ターミナル CLI:** システムターミナルで `cow <コマンド>` を実行。`start`、`stop`、`restart`、`update`、`status`、`logs`、`skill` などをサポート。
+- **チャットコマンド:** 会話内で `/<コマンド>` を入力。Web コンソールでは `/` を入力するとコマンドメニューが表示されます。
+
+```bash
+cow start # サービスを開始
+cow stop # サービスを停止
+cow update # 更新して再起動
+cow skill install pptx # Skill をインストール
+cow install-browser # ブラウザツールをインストール
+```
+
+詳細は [コマンド一覧](https://docs.cowagent.ai/ja/commands) を参照してください。
diff --git a/docs/ja/releases/overview.mdx b/docs/ja/releases/overview.mdx
index 4e6e69f5..2191375d 100644
--- a/docs/ja/releases/overview.mdx
+++ b/docs/ja/releases/overview.mdx
@@ -5,6 +5,7 @@ description: CowAgent バージョン履歴
| バージョン | 日付 | 説明 |
| --- | --- | --- |
+| [2.0.5](/ja/releases/v2.0.5) | 2026.04.01 | Cow CLI、Skill Hub オープンソース、ブラウザツール、企業微信スキャン作成、その他改善 |
| [2.0.4](/ja/releases/v2.0.4) | 2026.03.22 | 個人WeChatチャネル追加、新モデルサポート、日本語ドキュメント、スクリプトリファクタリングおよび複数修正 |
| [2.0.2](/ja/releases/v2.0.2) | 2026.02.27 | Web Console アップグレード、マルチチャネル同時実行、セッション永続化 |
| [2.0.1](/en/releases/v2.0.1) | 2026.02.27 | 組み込み Web Search ツール、スマートコンテキスト管理、複数の修正 |
diff --git a/docs/ja/releases/v2.0.5.mdx b/docs/ja/releases/v2.0.5.mdx
new file mode 100644
index 00000000..4dec6ea4
--- /dev/null
+++ b/docs/ja/releases/v2.0.5.mdx
@@ -0,0 +1,77 @@
+---
+title: v2.0.5
+description: CowAgent 2.0.5 - Cow CLI、Skill Hub オープンソース、ブラウザツール、企業微信スキャン作成、その他改善
+---
+
+## 🖥️ Cow CLI コマンドシステム
+
+ターミナルと会話の両方で CowAgent を管理する新しい CLI コマンドシステム:
+
+- **ターミナルコマンド**:`cow <コマンド>` で `start`、`stop`、`restart`、`update`、`status`、`logs` などを実行
+- **チャットコマンド**:会話で `/<コマンド>` を入力して `/help`、`/status`、`/config`、`/skill`、`/context`、`/logs`、`/version` など
+- **Web コンソール**:入力欄で `/` を入力するとスラッシュコマンドメニューが表示、矢印キーで入力履歴を辿れる
+- **Windows サポート**:PowerShell スクリプト `scripts/run.ps1` を追加、`cow` コマンドに対応
+
+ドキュメント:[コマンド一覧](https://docs.cowagent.ai/ja/commands)
+
+
+
+## 🧩 Cow Skill Hub オープンソース
+
+[Cow Skill Hub](https://skills.cowagent.ai)(スキル広場)がオープンソースとして公開。AI Agent スキルの閲覧、検索、インストール、公開が可能:
+
+- **ワンコマンドインストール**:会話で `/skill install <名前>` またはターミナルで `cow skill install <名前>`
+- **マルチソース**:Skill Hub、GitHub、ClawHub、LinkAI などからインストール可能
+- **検索**:`/skill search` と `/skill list --remote` でスキル広場を閲覧・検索
+- **スキル公開**:[skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) で自作スキルを提出
+- **ミラー加速**:中国国内向けミラーダウンロード対応
+
+オープンソースリポジトリ:[cow-skill-hub](https://github.com/zhayujie/cow-skill-hub)
+
+ドキュメント:[スキル広場](https://docs.cowagent.ai/ja/skills/hub)、[スキルのインストール](https://docs.cowagent.ai/ja/skills/install)
+
+
+
+## 🌐 ブラウザツール
+
+新しい Browser ツール — Agent が Chromium ブラウザを制御して Web ページにアクセス・操作:
+
+- **ナビゲーションと操作**:`navigate`、`click`、`fill`、`select`、`scroll`、`press` など
+- **ページスナップショット**:精簡 DOM スナップショットで Agent がページ構造を効率的に理解、ナビゲーション後に自動スナップショット
+- **スクリーンショット**:ワークスペースにページのスクリーンショットを保存
+- **JavaScript 実行**:ページでカスタムスクリプトを実行
+- **CLI インストール**:`cow install-browser` でワンコマンドセットアップ
+- **Docker サポート**:Docker イメージにブラウザインストール組み込み
+
+ドキュメント:[ブラウザツール](https://docs.cowagent.ai/ja/tools/browser)
+
+
+
+## 🤖 企業微信 Bot スキャン作成
+
+企業微信 Bot チャネルで QR コードスキャンによるワンクリック作成をサポート:
+
+- **Web コンソールでスキャン**:「スキャン接入」モードを選択し、企業微信でスキャンするとボットが自動作成・接続
+- **手動モード**:既存の Bot ID と Secret を手動入力する方式も引き続きサポート
+- **ストリーム配信最適化**:WebSocket 混雑を避けるためのスロットリング
+
+ドキュメント:[企業微信 Bot](https://docs.cowagent.ai/ja/channels/wecom-bot)
+
+PR:[#2735](https://github.com/zhayujie/chatgpt-on-wechat/pull/2735)。Thanks [@WecomTeam](https://github.com/WecomTeam)
+
+## 🐛 その他の改善と修正
+
+- **DeepSeek モジュール**:独立 DeepSeek Bot、`deepseek_api_key` 専用設定対応([#2719](https://github.com/zhayujie/chatgpt-on-wechat/pull/2719))。Thanks [@6vision](https://github.com/6vision)
+- **Web コンソール**:スラッシュコマンドメニュー、入力履歴、新モデル選択肢、モバイル最適化([#2731](https://github.com/zhayujie/chatgpt-on-wechat/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd)
+- **コンテキスト**:トリミング後のコンテキスト喪失を修正([393f0c0](https://github.com/zhayujie/chatgpt-on-wechat/commit/393f0c0))
+- **システムプロンプト**:毎ターン再構築されない問題を修正([13f5fde](https://github.com/zhayujie/chatgpt-on-wechat/commit/13f5fde))
+- **Gemini**:GoogleGeminiBot の model 属性欠落を修正([#2716](https://github.com/zhayujie/chatgpt-on-wechat/pull/2716))。Thanks [@cowagent](https://github.com/cowagent)
+- **WeChat チャネル**:ファイル送信失敗・ファイル名消失の修正([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba)、[45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
+- **Docker**:ボリューム権限修正、イメージサイズ削減([3eb8348](https://github.com/zhayujie/chatgpt-on-wechat/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/4470d4c))
+- **セキュリティ**:Memory Content パストラバーサルリスクを修正。Thanks [@August829](https://github.com/August829)
+
+## 📦 アップグレード
+
+`cow update` または `./run.sh update` でアップグレード、またはコードを手動で pull して再起動。詳細は[アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade)を参照。
+
+**リリース日**:2026.04.01 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master)
diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx
index 76f4d423..9ef170e1 100644
--- a/docs/releases/v2.0.5.mdx
+++ b/docs/releases/v2.0.5.mdx
@@ -21,7 +21,7 @@ description: CowAgent 2.0.5 - Cow CLI、Skill Hub 开源、浏览器工具、企
[Cow Skill Hub](https://skills.cowagent.ai)(技能广场)正式开源并上线,提供 AI Agent 技能的浏览、搜索、安装和发布,汇集精选技能、社区贡献技能、三方技能:
- **一键安装**:在对话中 `/skill install <名称>` 或终端 `cow skill install <名称>` 一键安装
-- **多来源支持**:支持从 Skill Hub、GitHub、ClawHub、LinkAI、URL(zip / SKILL.md)等多种来源安装,支持 GitHub 批量安装和子目录指定
+- **多来源支持**:支持安装 Skill Hub、GitHub、ClawHub、LinkAI 上的全部技能,支持 GitHub 批量安装和子目录指定
- **技能搜索**:`/skill search` 和 `/skill list --remote` 浏览和搜索技能广场
- **技能发布**:通过 [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) 提交自己的技能
- **镜像加速**:支持 Skill Hub 镜像加速,国内环境下载更流畅
diff --git a/docs/skills/index.mdx b/docs/skills/index.mdx
index 3ef4f84c..2bf847b5 100644
--- a/docs/skills/index.mdx
+++ b/docs/skills/index.mdx
@@ -14,6 +14,7 @@ CowAgent 提供多种方式获取技能:
- **[Cow 技能广场](https://skills.cowagent.ai/)** — 在线浏览所有可用技能,或通过 `/skill list --remote` 在对话中浏览和安装
- **GitHub** — 直接从 GitHub 仓库安装,支持批量安装
- **ClawHub** — 通过 `/skill install clawhub:名称` 安装 ClawHub 上的技能 (4w+个)
+- **LinkA** — 通过 `/skill install linkai:编码` 安装 LinkAI 上的公开资源和创建的知识库/数据库/工作流/插件等资源
- **URL** — 从 zip 压缩包或 SKILL.md 链接安装
- **对话创建** — 通过自然语言对话让 Agent 自动创建技能
diff --git a/skills/README.md b/skills/README.md
index e3bd61cc..78d88c3c 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -1,124 +1,89 @@
-# Skills Directory
+# Skills
-This directory contains skills for the COW agent system. Skills are markdown files that provide specialized instructions for specific tasks.
+Skills are reusable instruction sets that extend the agent's capabilities. Each skill is a `SKILL.md` file in its own directory, providing specialized knowledge, workflows, and tool integrations for specific tasks.
-## What are Skills?
+## Skill Hub
-Skills are reusable instruction sets that help the agent perform specific tasks more effectively. Each skill:
+Browse, search, and install skills from [Cow Skill Hub](https://skills.cowagent.ai/).
-- Provides context-specific guidance
-- Documents best practices
-- Includes examples and usage patterns
-- Can have requirements (binaries, environment variables, etc.)
+Open source: [github.com/zhayujie/cow-skill-hub](https://github.com/zhayujie/cow-skill-hub)
+
+## Install Skills
+
+Install skills from multiple sources via chat (`/skill`) or terminal (`cow skill`):
+
+```bash
+/skill install # From Skill Hub
+/skill install / # From GitHub
+/skill install clawhub: # From ClawHub
+/skill install linkai: # From LinkAI
+/skill install # From URL (zip or SKILL.md)
+```
+
+List all available remote skills:
+
+```bash
+/skill list --remote
+```
+
+## Manage Skills
+
+```bash
+/skill list # List installed skills
+/skill info # View skill details
+/skill enable # Enable a skill
+/skill disable # Disable a skill
+/skill uninstall # Uninstall a skill
+```
+
+> In terminal, replace `/skill` with `cow skill`.
## Skill Structure
-Each skill is a markdown file (`SKILL.md`) in its own directory with frontmatter:
+```
+skills/
+ my-skill/
+ SKILL.md # Required: skill definition
+ scripts/ # Optional: bundled scripts
+ resources/ # Optional: reference files
+```
+
+`SKILL.md` uses YAML frontmatter:
```markdown
---
-name: skill-name
+name: my-skill
description: Brief description of what the skill does
-metadata: {"cow":{"emoji":"🎯","requires":{"bins":["tool"]}}}
+metadata: {"cow":{"emoji":"🔧","requires":{"bins":["tool"],"env":["API_KEY"]}}}
---
-# Skill Name
+# My Skill
-Detailed instructions and examples...
+Instructions, examples, and usage patterns...
```
-## Available Skills
-
-- **calculator**: Mathematical calculations and expressions
-- **web-search**: Search the web for current information
-- **file-operations**: Read, write, and manage files
-
-## Creating Custom Skills
-
-To create a new skill:
-
-1. Create a directory: `skills/my-skill/`
-2. Create `SKILL.md` with frontmatter and content
-3. Restart the agent to load the new skill
-
### Frontmatter Fields
-- `name`: Skill name (must match directory name)
-- `description`: Brief description (required)
-- `metadata`: JSON object with additional configuration
- - `emoji`: Display emoji
- - `always`: Always include this skill (default: false)
- - `primaryEnv`: Primary environment variable needed
- - `os`: Supported operating systems (e.g., ["darwin", "linux"])
- - `requires`: Requirements object
- - `bins`: Required binaries
- - `env`: Required environment variables
- - `config`: Required config paths
-- `disable-model-invocation`: If true, skill won't be shown to model (default: false)
-- `user-invocable`: If false, users can't invoke directly (default: true)
+| Field | Description |
+|---|---|
+| `name` | Skill name (must match directory name) |
+| `description` | Brief description (required) |
+| `metadata.cow.emoji` | Display emoji |
+| `metadata.cow.always` | Always include this skill (default: false) |
+| `metadata.cow.requires.bins` | Required binaries |
+| `metadata.cow.requires.env` | Required environment variables |
+| `metadata.cow.requires.config` | Required config paths |
+| `metadata.cow.os` | Supported OS (e.g., `["darwin", "linux"]`) |
-### Example Skill
+## Skill Loading Order
-```markdown
----
-name: my-tool
-description: Use my-tool to process data
-metadata: {"cow":{"emoji":"🔧","requires":{"bins":["my-tool"],"env":["MY_TOOL_API_KEY"]}}}
----
+Skills are loaded from two locations (higher precedence overrides lower):
-# My Tool Skill
+1. **Builtin skills** (lower): `/skills/` — shipped with the codebase
+2. **Custom skills** (higher): `~/cow/skills/` — installed via `cow skill install` or skill creator
-Use this skill when you need to process data with my-tool.
+Skills with the same name in the custom directory override builtin ones.
-## Prerequisites
+## Create & Contribute
-- Install my-tool: `pip install my-tool`
-- Set `MY_TOOL_API_KEY` environment variable
-
-## Usage
-
-\`\`\`python
-# Example usage
-my_tool_command("input data")
-\`\`\`
-```
-
-## Skill Loading
-
-Skills are loaded from multiple locations with precedence:
-
-1. **Workspace skills** (highest): `workspace/skills/` - Project-specific skills
-2. **Managed skills**: `~/.cow/skills/` - User-installed skills
-3. **Bundled skills** (lowest): Built-in skills
-
-Skills with the same name in higher-precedence locations override lower ones.
-
-## Skill Requirements
-
-Skills can specify requirements that determine when they're available:
-
-- **OS requirements**: Only load on specific operating systems
-- **Binary requirements**: Only load if required binaries are installed
-- **Environment variables**: Only load if required env vars are set
-- **Config requirements**: Only load if config values are set
-
-## Best Practices
-
-1. **Clear descriptions**: Write clear, concise skill descriptions
-2. **Include examples**: Provide practical usage examples
-3. **Document prerequisites**: List all requirements clearly
-4. **Use appropriate metadata**: Set correct requirements and flags
-5. **Keep skills focused**: Each skill should have a single, clear purpose
-
-## Workspace Skills
-
-You can create workspace-specific skills in your agent's workspace:
-
-```
-workspace/
- skills/
- custom-skill/
- SKILL.md
-```
-
-These skills are only available when working in that specific workspace.
+See the [Skill Creation docs](https://docs.cowagent.ai/skills/create) for details, or submit your skill to [Skill Hub](https://skills.cowagent.ai/submit).
diff --git a/skills/linkai-agent/README.md b/skills/linkai-agent/README.md
deleted file mode 100644
index aa7bcb55..00000000
--- a/skills/linkai-agent/README.md
+++ /dev/null
@@ -1,258 +0,0 @@
-# LinkAI Agent Skill
-
-这个 skill 允许你调用 LinkAI 平台上的多个应用(App)和工作流(Workflow),通过简单的配置即可集成多个智能体能力。
-
-## 特性
-
-- ✅ **多应用支持** - 在一个配置文件中管理多个 LinkAI 应用/工作流
-- ✅ **动态加载** - skill 系统加载时自动从 `config.json` 读取应用列表
-- ✅ **自动技能描述** - 所有配置的应用会自动添加到技能描述中
-- ✅ **模型切换** - 可以为每个请求指定不同的模型
-- ✅ **知识库集成** - 支持应用绑定的知识库
-- ✅ **插件能力** - 支持应用启用的各类插件
-- ✅ **工作流执行** - 支持执行复杂的多步骤工作流
-
-## 快速开始
-
-### 1. 配置 API Key
-
-```bash
-env_config(action="set", key="LINKAI_API_KEY", value="your-linkai-api-key")
-```
-
-获取 API Key: https://link-ai.tech/console/interface
-
-### 2. 配置应用列表
-
-将 `config.json.template` 复制为 `config.json`:
-
-```bash
-cp config.json.template config.json
-```
-
-编辑 `config.json`,添加你的应用/工作流:
-
-```json
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "通用助手",
- "app_description": "通用AI助手,可以回答各类问题"
- },
- {
- "app_code": "your_kb_app",
- "app_name": "产品文档助手",
- "app_description": "基于产品文档知识库的问答助手"
- },
- {
- "app_code": "your_workflow",
- "app_name": "数据分析工作流",
- "app_description": "执行数据清洗、分析和可视化的完整工作流"
- }
- ]
-}
-```
-
-**注意:** 修改 `config.json` 后,Agent 在下次加载技能时会自动读取新配置。
-
-### 3. 调用应用
-
-```bash
-bash(command='curl -sS --max-time 120 -X POST "https://api.link-ai.tech/v1/chat/completions" -H "Content-Type: application/json" -H "Authorization: Bearer $LINKAI_API_KEY" -d "{\"app_code\":\"G7z6vKwp\",\"messages\":[{\"role\":\"user\",\"content\":\"What is artificial intelligence?\"}],\"stream\":false}"', timeout=130)
-```
-
-## 使用示例
-
-### 基础调用
-
-```bash
-# 调用默认模型 (通过 bash + curl)
-bash(command='curl -sS --max-time 120 -X POST "https://api.link-ai.tech/v1/chat/completions" -H "Content-Type: application/json" -H "Authorization: Bearer $LINKAI_API_KEY" -d "{\"app_code\":\"G7z6vKwp\",\"messages\":[{\"role\":\"user\",\"content\":\"解释一下量子计算\"}],\"stream\":false}"', timeout=130)
-```
-
-### 指定模型
-
-在 JSON body 中添加 `model` 字段:
-
-```json
-{
- "app_code": "G7z6vKwp",
- "model": "LinkAI-4.1",
- "messages": [{"role": "user", "content": "写一篇关于AI的文章"}],
- "stream": false
-}
-```
-
-### 调用工作流
-
-工作流的 app_code 从 LinkAI 控制台获取,调用方式与普通应用相同。
-
-## ⚠️ 重要提示
-
-### 超时配置
-
-LinkAI 应用(特别是视频/图片生成、复杂工作流)可能需要较长时间处理。在 curl 命令中加入 `--max-time 180`,并相应增加 bash 工具的 `timeout` 参数。
-
-## 配置说明
-
-### config.json 字段
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| `app_code` | string | 应用或工作流的唯一标识码,从 LinkAI 控制台获取 |
-| `app_name` | string | 应用名称,会显示在技能描述中 |
-| `app_description` | string | 应用功能描述,帮助 Agent 理解何时使用该应用 |
-
-### 获取 app_code
-
-1. 登录 [LinkAI 控制台](https://link-ai.tech/console)
-2. 进入「应用管理」或「工作流管理」
-3. 选择要集成的应用/工作流
-4. 在应用详情页找到 `app_code`
-
-## 应用类型
-
-### 1. 普通应用
-
-配置了系统提示词和参数的标准对话应用,可以:
-- 设置角色和性格
-- 绑定知识库
-- 启用插件(图像识别、网页搜索、代码执行等)
-
-### 2. 知识库应用
-
-基于特定知识库的问答应用,适合:
-- 企业内部知识库
-- 产品文档问答
-- 客户支持
-
-### 3. 工作流
-
-多步骤的自动化流程,可以:
-- 串联多个处理节点
-- 条件分支
-- 循环处理
-- 调用外部 API
-
-## 响应格式
-
-### 成功响应
-
-API 返回 OpenAI 兼容格式,从 `choices[0].message.content` 获取回复内容:
-
-```json
-{
- "choices": [{
- "message": {
- "role": "assistant",
- "content": "人工智能(AI)是计算机科学的一个分支..."
- }
- }],
- "usage": {
- "prompt_tokens": 10,
- "completion_tokens": 150,
- "total_tokens": 160
- }
-}
-```
-
-### 错误响应
-
-```json
-{
- "error": {
- "message": "应用不存在",
- "code": "xxx"
- }
-}
-```
-
-## 常见错误
-
-### LINKAI_API_KEY environment variable is not set
-**原因:** 未配置 API Key
-**解决:** 使用 `env_config` 工具设置 LINKAI_API_KEY
-
-### 应用不存在 (402)
-**原因:** app_code 不正确或应用已删除
-**解决:** 检查 app_code 是否正确,确认应用存在
-
-### 无访问权限 (403)
-**原因:** 尝试访问他人的私有应用
-**解决:** 确保应用是公开的或你是创建者
-
-### 账号积分额度不足 (406)
-**原因:** LinkAI 账户余额不足
-**解决:** 前往控制台充值
-
-### 内容审核不通过 (409)
-**原因:** 请求或响应包含敏感内容
-**解决:** 修改输入内容,避免敏感词
-
-## 技术实现
-
-### 自动技能描述生成
-
-当 skill 系统加载 `linkai-agent` 时,会自动:
-1. 读取 `config.json` 中的应用列表
-2. 将每个应用的 name 和 description 动态添加到技能描述中
-3. Agent 加载时会看到完整的应用列表
-
-这是在 `agent/skills/loader.py` 中实现的特殊处理。
-
-### 工作流程
-
-```
-用户配置 config.json
- ↓
-Agent 启动/重新加载技能
- ↓
-SkillLoader 检测到 linkai-agent
- ↓
-动态读取 config.json
- ↓
-生成包含所有应用描述的 description
- ↓
-Agent 看到所有可用应用的完整信息
- ↓
-用户请求触发
- ↓
-Agent 根据描述选择合适的应用
- ↓
-通过 bash + curl 调用 LinkAI API
- ↓
-LinkAI API 处理并返回结果
-```
-
-## 最佳实践
-
-1. **清晰的描述** - 为每个应用写清晰、具体的描述,帮助 Agent 理解应用用途
-2. **合理分工** - 不同应用负责不同领域,避免功能重叠
-3. **无需重启** - 修改 config.json 后,Agent 下次加载技能时会自动更新
-4. **模型选择** - 根据任务复杂度选择合适的模型
-5. **知识库优化** - 为专业领域的应用绑定相关知识库
-
-## 扩展用法
-
-### 在 Agent 系统中使用
-
-当 Agent 系统加载这个 skill 时,会自动从 `config.json` 读取应用列表并生成描述:
-
-```
-Call LinkAI apps/workflows. 通用助手(G7z6vKwp: 通用AI助手,可以回答各类问题); 产品文档助手(kb_app_001: 基于产品文档知识库的问答助手); 数据分析工作流(wf_002: 执行数据清洗、分析和可视化的完整工作流)
-```
-
-Agent 会根据用户问题自动选择最合适的应用进行调用。
-
-## 相关链接
-
-- LinkAI 平台: https://link-ai.tech
-- API 文档: https://docs.link-ai.tech
-- 控制台: https://link-ai.tech/console
-- 模型列表: https://link-ai.tech/console/models
-- 应用广场: https://link-ai.tech/square
-
-## License
-
-Part of the chatgpt-on-wechat project.
diff --git a/skills/linkai-agent/SKILL.md b/skills/linkai-agent/SKILL.md
deleted file mode 100644
index 3464e490..00000000
--- a/skills/linkai-agent/SKILL.md
+++ /dev/null
@@ -1,85 +0,0 @@
----
-name: linkai-agent
-description: Call LinkAI applications and workflows. Use bash with curl to invoke the chat completions API.
-homepage: https://link-ai.tech
-metadata:
- emoji: 🤖
- default_enabled: false
- requires:
- bins: ["curl"]
- env: ["LINKAI_API_KEY"]
----
-
-# LinkAI Agent
-
-Call LinkAI applications and workflows through the chat completions API. Available apps are loaded from config.json.
-
-## Setup
-
-This skill requires a LinkAI API key.
-
-1. Get your API key from [LinkAI Console](https://link-ai.tech/console/interface)
-2. Set the environment variable: `export LINKAI_API_KEY=Link_xxxxxxxxxxxx` (or use env_config tool)
-
-## Configuration
-
-1. Copy `config.json.template` to `config.json`
-2. Add your apps/workflows in config.json. The skill description is auto-generated from this config when loaded.
-
-## Usage
-
-Use the bash tool with curl to call the API. **Prefer curl** to avoid encoding issues on Windows PowerShell.
-
-```bash
-curl -X POST "https://api.link-ai.tech/v1/chat/completions" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer $LINKAI_API_KEY" \
- -d '{
- "app_code": "",
- "messages": [{"role": "user", "content": ""}],
- "stream": false
- }'
-```
-
-**Optional parameters**:
-
-- Add `--max-time 120` to curl for long-running tasks (video/image generation)
-
-**On Windows cmd**: Use `%LINKAI_API_KEY%` instead of `$LINKAI_API_KEY`.
-
-**Example** (via bash tool):
-
-```bash
-bash(command='curl -sS --max-time 120 -X POST "https://api.link-ai.tech/v1/chat/completions" -H "Content-Type: application/json" -H "Authorization: Bearer $LINKAI_API_KEY" -d "{\"app_code\":\"G7z6vKwp\",\"messages\":[{\"role\":\"user\",\"content\":\"What is AI?\"}],\"stream\":false}"', timeout=130)
-```
-
-## Response
-
-Success (extract `choices[0].message.content` from JSON):
-
-```json
-{
- "choices": [{
- "message": {
- "role": "assistant",
- "content": "AI stands for Artificial Intelligence..."
- }
- }],
- "usage": {
- "prompt_tokens": 10,
- "completion_tokens": 50,
- "total_tokens": 60
- }
-}
-```
-
-Error:
-
-```json
-{
- "error": {
- "message": "Error description",
- "code": "error_code"
- }
-}
-```
diff --git a/skills/linkai-agent/config.json.template b/skills/linkai-agent/config.json.template
deleted file mode 100644
index 8cfc7a9e..00000000
--- a/skills/linkai-agent/config.json.template
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "apps": [
- {
- "app_code": "G7z6vKwp",
- "app_name": "LinkAI客服助手",
- "app_description": "当用户需要了解LinkAI平台相关问题时才选择该助手,基于LinkAI知识库进行回答"
- },
- {
- "app_code": "SFY5x7JR",
- "app_name": "内容创作助手",
- "app_description": "当用户需要创作图片或视频时才使用该助手,支持Nano Banana、Seedream、即梦、Veo、可灵等多种模型"
- }
- ]
-}
From a38b22a6a2cdd13f518ed9c84883ae511be393f7 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 15:31:41 +0800
Subject: [PATCH 075/399] docs: update docs
---
README.md | 4 ++--
docs/{commands => cli}/general.mdx | 0
docs/{commands => cli}/index.mdx | 0
docs/{commands => cli}/process.mdx | 0
docs/{commands => cli}/skill.mdx | 0
docs/docs.json | 28 +++++++++++++--------------
docs/en/README.md | 4 ++--
docs/en/{commands => cli}/general.mdx | 0
docs/en/{commands => cli}/index.mdx | 0
docs/en/{commands => cli}/process.mdx | 0
docs/en/{commands => cli}/skill.mdx | 0
docs/en/guide/quick-start.mdx | 2 +-
docs/en/intro/features.mdx | 2 +-
docs/en/intro/index.mdx | 2 +-
docs/en/releases/v2.0.5.mdx | 2 +-
docs/en/skills/index.mdx | 2 +-
docs/en/skills/install.mdx | 2 +-
docs/guide/quick-start.mdx | 2 +-
docs/intro/features.mdx | 2 +-
docs/intro/index.mdx | 2 +-
docs/ja/README.md | 4 ++--
docs/ja/{commands => cli}/general.mdx | 0
docs/ja/{commands => cli}/index.mdx | 0
docs/ja/{commands => cli}/process.mdx | 0
docs/ja/{commands => cli}/skill.mdx | 0
docs/ja/guide/quick-start.mdx | 2 +-
docs/ja/intro/features.mdx | 2 +-
docs/ja/intro/index.mdx | 2 +-
docs/ja/releases/v2.0.5.mdx | 2 +-
docs/ja/skills/index.mdx | 2 +-
docs/ja/skills/install.mdx | 2 +-
docs/releases/v2.0.5.mdx | 2 +-
docs/skills/index.mdx | 2 +-
docs/skills/install.mdx | 2 +-
34 files changed, 38 insertions(+), 38 deletions(-)
rename docs/{commands => cli}/general.mdx (100%)
rename docs/{commands => cli}/index.mdx (100%)
rename docs/{commands => cli}/process.mdx (100%)
rename docs/{commands => cli}/skill.mdx (100%)
rename docs/en/{commands => cli}/general.mdx (100%)
rename docs/en/{commands => cli}/index.mdx (100%)
rename docs/en/{commands => cli}/process.mdx (100%)
rename docs/en/{commands => cli}/skill.mdx (100%)
rename docs/ja/{commands => cli}/general.mdx (100%)
rename docs/ja/{commands => cli}/index.mdx (100%)
rename docs/ja/{commands => cli}/process.mdx (100%)
rename docs/ja/{commands => cli}/skill.mdx (100%)
diff --git a/README.md b/README.md
index e356fe90..9b0cb984 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
```
-脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)。安装后可使用 `cow start`、`cow stop` 等 [CLI 命令](https://docs.cowagent.ai/commands/index) 管理服务。
+脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)。安装后可使用 `cow start`、`cow stop` 等 [CLI 命令](https://docs.cowagent.ai/cli/index) 管理服务。
## 一、准备
@@ -151,7 +151,7 @@ pip3 install -r requirements-optional.txt
pip3 install -e .
```
-安装后可使用 `cow` 命令管理服务(启动、停止、更新等)和技能,详见 [命令文档](https://docs.cowagent.ai/commands/index)。
+安装后可使用 `cow` 命令管理服务(启动、停止、更新等)和技能,详见 [命令文档](https://docs.cowagent.ai/cli/index)。
**(5) 安装浏览器工具 (可选):**
diff --git a/docs/commands/general.mdx b/docs/cli/general.mdx
similarity index 100%
rename from docs/commands/general.mdx
rename to docs/cli/general.mdx
diff --git a/docs/commands/index.mdx b/docs/cli/index.mdx
similarity index 100%
rename from docs/commands/index.mdx
rename to docs/cli/index.mdx
diff --git a/docs/commands/process.mdx b/docs/cli/process.mdx
similarity index 100%
rename from docs/commands/process.mdx
rename to docs/cli/process.mdx
diff --git a/docs/commands/skill.mdx b/docs/cli/skill.mdx
similarity index 100%
rename from docs/commands/skill.mdx
rename to docs/cli/skill.mdx
diff --git a/docs/docs.json b/docs/docs.json
index 272b28f5..df8025a8 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -171,10 +171,10 @@
{
"group": "命令系统",
"pages": [
- "commands/index",
- "commands/process",
- "commands/skill",
- "commands/general"
+ "cli/index",
+ "cli/process",
+ "cli/skill",
+ "cli/general"
]
}
]
@@ -327,15 +327,15 @@
]
},
{
- "tab": "Commands",
+ "tab": "CLI",
"groups": [
{
"group": "Command System",
"pages": [
- "en/commands/index",
- "en/commands/process",
- "en/commands/skill",
- "en/commands/chat"
+ "en/cli/index",
+ "en/cli/process",
+ "en/cli/skill",
+ "en/cli/chat"
]
}
]
@@ -488,15 +488,15 @@
]
},
{
- "tab": "コマンド",
+ "tab": "CLI",
"groups": [
{
"group": "コマンドシステム",
"pages": [
- "ja/commands/index",
- "ja/commands/process",
- "ja/commands/skill",
- "ja/commands/general"
+ "ja/cli/index",
+ "ja/cli/process",
+ "ja/cli/skill",
+ "ja/cli/general"
]
}
]
diff --git a/docs/en/README.md b/docs/en/README.md
index a1742488..3090e1b1 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -76,7 +76,7 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
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). After installation, you can also use `cow start`, `cow stop`, and other [CLI commands](https://docs.cowagent.ai/en/commands/index) to manage the service.
+Script usage: [One-click Install](https://docs.cowagent.ai/en/guide/quick-start). After installation, you can also use `cow start`, `cow stop`, and other [CLI commands](https://docs.cowagent.ai/en/cli/index) to manage the service.
### Manual Installation
@@ -100,7 +100,7 @@ pip3 install -r requirements-optional.txt # optional but recommended
pip3 install -e .
```
-After installation, use `cow` commands to manage the service (start, stop, update, etc.) and skills. See [Command Docs](https://docs.cowagent.ai/en/commands/index).
+After installation, use `cow` commands to manage the service (start, stop, update, etc.) and skills. See [Command Docs](https://docs.cowagent.ai/en/cli/index).
**4. Install browser (optional)**
diff --git a/docs/en/commands/general.mdx b/docs/en/cli/general.mdx
similarity index 100%
rename from docs/en/commands/general.mdx
rename to docs/en/cli/general.mdx
diff --git a/docs/en/commands/index.mdx b/docs/en/cli/index.mdx
similarity index 100%
rename from docs/en/commands/index.mdx
rename to docs/en/cli/index.mdx
diff --git a/docs/en/commands/process.mdx b/docs/en/cli/process.mdx
similarity index 100%
rename from docs/en/commands/process.mdx
rename to docs/en/cli/process.mdx
diff --git a/docs/en/commands/skill.mdx b/docs/en/cli/skill.mdx
similarity index 100%
rename from docs/en/commands/skill.mdx
rename to docs/en/cli/skill.mdx
diff --git a/docs/en/guide/quick-start.mdx b/docs/en/guide/quick-start.mdx
index da15ac80..0e65d1f0 100644
--- a/docs/en/guide/quick-start.mdx
+++ b/docs/en/guide/quick-start.mdx
@@ -47,7 +47,7 @@ After installation, use the `cow` command to manage the service:
| `cow update` | Update code and restart |
| `cow install-browser` | Install browser tool dependencies |
-See the [Commands documentation](/en/commands/index) for more details.
+See the [Commands documentation](/en/cli/index) for more details.
If the `cow` command is not available, you can use `./run.sh ` (Linux/macOS) or `.\scripts\run.ps1 ` (Windows) as a fallback. Both are functionally equivalent.
diff --git a/docs/en/intro/features.mdx b/docs/en/intro/features.mdx
index 3057b8fc..66c2427d 100644
--- a/docs/en/intro/features.mdx
+++ b/docs/en/intro/features.mdx
@@ -117,4 +117,4 @@ cow skill install pptx # Install a skill
cow install-browser # Install browser tool
```
-See [Command Overview](https://docs.cowagent.ai/en/commands) for details.
+See [Command Overview](https://docs.cowagent.ai/en/cli) for details.
diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx
index 31cc7130..109c3097 100644
--- a/docs/en/intro/index.mdx
+++ b/docs/en/intro/index.mdx
@@ -31,7 +31,7 @@ CowAgent can proactively think and plan tasks, operate computers and external re
Built-in tools for file I/O, terminal execution, browser automation, scheduled tasks, messaging, and more. The Agent autonomously invokes tools to accomplish complex tasks.
-
+
Provides terminal CLI and in-chat commands for process management, skill installation, configuration, context inspection, and other common operations.
diff --git a/docs/en/releases/v2.0.5.mdx b/docs/en/releases/v2.0.5.mdx
index a828aa9f..e3a513cd 100644
--- a/docs/en/releases/v2.0.5.mdx
+++ b/docs/en/releases/v2.0.5.mdx
@@ -12,7 +12,7 @@ New CLI command system for managing CowAgent from terminal and chat:
- **Web console**: Type `/` in the input box to open a slash command menu, with arrow-key input history
- **Windows support**: New PowerShell script `scripts/run.ps1` with `cow` command support
-Docs: [Command Overview](https://docs.cowagent.ai/en/commands)
+Docs: [Command Overview](https://docs.cowagent.ai/en/cli)
diff --git a/docs/en/skills/index.mdx b/docs/en/skills/index.mdx
index 2569f096..de57d94a 100644
--- a/docs/en/skills/index.mdx
+++ b/docs/en/skills/index.mdx
@@ -17,7 +17,7 @@ CowAgent offers multiple ways to acquire skills:
- **URL** — Install from zip archives or SKILL.md links
- **Conversational creation** — Let the Agent create skills through natural language conversation
-See [Install Skills](/en/skills/install) and [Skill Management Commands](/en/commands/skill) for details. You can also [create skills](/en/skills/create) through conversation.
+See [Install Skills](/en/skills/install) and [Skill Management Commands](/en/cli/skill) for details. You can also [create skills](/en/skills/create) through conversation.
## Skill Loading Priority
diff --git a/docs/en/skills/install.mdx b/docs/en/skills/install.mdx
index bb3b5f33..f52b30ce 100644
--- a/docs/en/skills/install.mdx
+++ b/docs/en/skills/install.mdx
@@ -49,5 +49,5 @@ Supports zip archives and SKILL.md file links:
```
- All commands above work in the terminal by replacing `/skill` with `cow skill`. See [Skill Management Commands](/en/commands/skill) for full documentation.
+ All commands above work in the terminal by replacing `/skill` with `cow skill`. See [Skill Management Commands](/en/cli/skill) for full documentation.
diff --git a/docs/guide/quick-start.mdx b/docs/guide/quick-start.mdx
index 28364f46..ac267ead 100644
--- a/docs/guide/quick-start.mdx
+++ b/docs/guide/quick-start.mdx
@@ -47,7 +47,7 @@ description: 使用脚本一键安装和管理 CowAgent
| `cow update` | 更新代码并重启 |
| `cow install-browser` | 安装浏览器工具依赖 |
-更多命令和用法参考 [命令文档](/commands/index)。
+更多命令和用法参考 [命令文档](/cli/index)。
如果 `cow` 命令不可用,也可以使用 `./run.sh <命令>`(Linux/macOS)或 `.\scripts\run.ps1 <命令>`(Windows)作为替代,功能等效。
diff --git a/docs/intro/features.mdx b/docs/intro/features.mdx
index ff85f1e1..5ffca1e2 100644
--- a/docs/intro/features.mdx
+++ b/docs/intro/features.mdx
@@ -118,6 +118,6 @@ cow skill install pptx # 安装技能
cow install-browser # 安装浏览器工具
```
-详细命令参考 [命令总览](https://docs.cowagent.ai/commands)。
+详细命令参考 [命令总览](https://docs.cowagent.ai/cli)。
diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx
index 4b78ee63..6384f3ad 100644
--- a/docs/intro/index.mdx
+++ b/docs/intro/index.mdx
@@ -36,7 +36,7 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 可自主调用工具完成复杂任务。
-
+
提供终端 CLI 和对话中的命令,支持进程管理、技能安装、配置修改、上下文查看等常用操作。
diff --git a/docs/ja/README.md b/docs/ja/README.md
index 38764151..ac1bb879 100644
--- a/docs/ja/README.md
+++ b/docs/ja/README.md
@@ -76,7 +76,7 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
実行後、デフォルトでWebサービスが起動します。`http://localhost:9899/chat` にアクセスしてチャットを開始できます。
-スクリプトの使い方: [ワンクリックインストール](https://docs.cowagent.ai/ja/guide/quick-start)。インストール後は `cow start`、`cow stop` などの [CLI コマンド](https://docs.cowagent.ai/ja/commands/index)でサービスを管理できます。
+スクリプトの使い方: [ワンクリックインストール](https://docs.cowagent.ai/ja/guide/quick-start)。インストール後は `cow start`、`cow stop` などの [CLI コマンド](https://docs.cowagent.ai/ja/cli/index)でサービスを管理できます。
### 手動インストール
@@ -100,7 +100,7 @@ pip3 install -r requirements-optional.txt # 任意ですが推奨
pip3 install -e .
```
-インストール後、`cow` コマンドでサービス管理(起動、停止、更新など)やSkill管理ができます。[コマンドドキュメント](https://docs.cowagent.ai/ja/commands/index)を参照してください。
+インストール後、`cow` コマンドでサービス管理(起動、停止、更新など)やSkill管理ができます。[コマンドドキュメント](https://docs.cowagent.ai/ja/cli/index)を参照してください。
**4. ブラウザのインストール(任意)**
diff --git a/docs/ja/commands/general.mdx b/docs/ja/cli/general.mdx
similarity index 100%
rename from docs/ja/commands/general.mdx
rename to docs/ja/cli/general.mdx
diff --git a/docs/ja/commands/index.mdx b/docs/ja/cli/index.mdx
similarity index 100%
rename from docs/ja/commands/index.mdx
rename to docs/ja/cli/index.mdx
diff --git a/docs/ja/commands/process.mdx b/docs/ja/cli/process.mdx
similarity index 100%
rename from docs/ja/commands/process.mdx
rename to docs/ja/cli/process.mdx
diff --git a/docs/ja/commands/skill.mdx b/docs/ja/cli/skill.mdx
similarity index 100%
rename from docs/ja/commands/skill.mdx
rename to docs/ja/cli/skill.mdx
diff --git a/docs/ja/guide/quick-start.mdx b/docs/ja/guide/quick-start.mdx
index a1874f2b..f5220b08 100644
--- a/docs/ja/guide/quick-start.mdx
+++ b/docs/ja/guide/quick-start.mdx
@@ -47,7 +47,7 @@ Linux、macOS、Windowsに対応しています。Python 3.7〜3.12が必要で
| `cow update` | コードを更新して再起動 |
| `cow install-browser` | ブラウザツールの依存をインストール |
-詳細は[コマンドドキュメント](/ja/commands/index)を参照してください。
+詳細は[コマンドドキュメント](/ja/cli/index)を参照してください。
`cow` コマンドが利用できない場合は、`./run.sh <コマンド>`(Linux/macOS)または `.\scripts\run.ps1 <コマンド>`(Windows)で代替できます。機能は同等です。
diff --git a/docs/ja/intro/features.mdx b/docs/ja/intro/features.mdx
index 7c1bfd5a..0e213f3a 100644
--- a/docs/ja/intro/features.mdx
+++ b/docs/ja/intro/features.mdx
@@ -117,4 +117,4 @@ cow skill install pptx # Skill をインストール
cow install-browser # ブラウザツールをインストール
```
-詳細は [コマンド一覧](https://docs.cowagent.ai/ja/commands) を参照してください。
+詳細は [コマンド一覧](https://docs.cowagent.ai/ja/cli) を参照してください。
diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx
index 170fad52..e0e51828 100644
--- a/docs/ja/intro/index.mdx
+++ b/docs/ja/intro/index.mdx
@@ -31,7 +31,7 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
ファイル読み書き、ターミナル実行、ブラウザ操作、スケジュールタスク、メッセージ送信などの組み込みツールを提供。Agent が自律的にツールを呼び出して複雑なタスクを完了します。
-
+
ターミナル CLI とチャット内コマンドを提供し、プロセス管理、Skill インストール、設定変更、コンテキスト確認などの一般的な操作をサポートします。
diff --git a/docs/ja/releases/v2.0.5.mdx b/docs/ja/releases/v2.0.5.mdx
index 4dec6ea4..3569236c 100644
--- a/docs/ja/releases/v2.0.5.mdx
+++ b/docs/ja/releases/v2.0.5.mdx
@@ -12,7 +12,7 @@ description: CowAgent 2.0.5 - Cow CLI、Skill Hub オープンソース、ブラ
- **Web コンソール**:入力欄で `/` を入力するとスラッシュコマンドメニューが表示、矢印キーで入力履歴を辿れる
- **Windows サポート**:PowerShell スクリプト `scripts/run.ps1` を追加、`cow` コマンドに対応
-ドキュメント:[コマンド一覧](https://docs.cowagent.ai/ja/commands)
+ドキュメント:[コマンド一覧](https://docs.cowagent.ai/ja/cli)
diff --git a/docs/ja/skills/index.mdx b/docs/ja/skills/index.mdx
index 546be8d5..cab04109 100644
--- a/docs/ja/skills/index.mdx
+++ b/docs/ja/skills/index.mdx
@@ -17,7 +17,7 @@ CowAgent ではスキルを取得する複数の方法を提供しています
- **URL** — zip アーカイブや SKILL.md リンクからインストール
- **会話で作成** — 自然言語の会話を通じて Agent にスキルを自動作成させる
-詳細は[スキルのインストール](/ja/skills/install)と[スキル管理コマンド](/ja/commands/skill)を参照してください。会話を通じて[スキルを作成](/ja/skills/create)することもできます。
+詳細は[スキルのインストール](/ja/skills/install)と[スキル管理コマンド](/ja/cli/skill)を参照してください。会話を通じて[スキルを作成](/ja/skills/create)することもできます。
## スキルの読み込み優先順位
diff --git a/docs/ja/skills/install.mdx b/docs/ja/skills/install.mdx
index 168e7cc6..c50e6ca7 100644
--- a/docs/ja/skills/install.mdx
+++ b/docs/ja/skills/install.mdx
@@ -49,5 +49,5 @@ zip アーカイブと SKILL.md ファイルリンクに対応:
```
- 上記のすべてのコマンドは、ターミナルでは `/skill` を `cow skill` に置き換えて使用できます。完全なコマンドドキュメントは[スキル管理コマンド](/ja/commands/skill)を参照してください。
+ 上記のすべてのコマンドは、ターミナルでは `/skill` を `cow skill` に置き換えて使用できます。完全なコマンドドキュメントは[スキル管理コマンド](/ja/cli/skill)を参照してください。
diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx
index 9ef170e1..f073145f 100644
--- a/docs/releases/v2.0.5.mdx
+++ b/docs/releases/v2.0.5.mdx
@@ -12,7 +12,7 @@ description: CowAgent 2.0.5 - Cow CLI、Skill Hub 开源、浏览器工具、企
- **web控制台**:Web 控制台输入框输入 `/` 即可弹出指令菜单,支持方向键回溯历史输入
- **Windows 支持**:新增 PowerShell 一键安装脚本 `scripts/run.ps1`,同时支持 `cow` 命令
-相关文档:[命令总览](https://docs.cowagent.ai/commands)
+相关文档:[命令总览](https://docs.cowagent.ai/cli)
diff --git a/docs/skills/index.mdx b/docs/skills/index.mdx
index 2bf847b5..6e90aba8 100644
--- a/docs/skills/index.mdx
+++ b/docs/skills/index.mdx
@@ -18,7 +18,7 @@ CowAgent 提供多种方式获取技能:
- **URL** — 从 zip 压缩包或 SKILL.md 链接安装
- **对话创建** — 通过自然语言对话让 Agent 自动创建技能
-详细安装方式参考 [安装技能](/skills/install) 和 [技能管理命令](/commands/skill)。也可以通过对话 [创建技能](/skills/create),或向 [Skill Hub](https://skills.cowagent.ai/submit) 贡献你的技能。
+详细安装方式参考 [安装技能](/skills/install) 和 [技能管理命令](/cli/skill)。也可以通过对话 [创建技能](/skills/create),或向 [Skill Hub](https://skills.cowagent.ai/submit) 贡献你的技能。
## 技能加载优先级
diff --git a/docs/skills/install.mdx b/docs/skills/install.mdx
index cd933a49..c2e633c6 100644
--- a/docs/skills/install.mdx
+++ b/docs/skills/install.mdx
@@ -62,5 +62,5 @@ CowAgent 支持通过统一的 `install` 命令安装来自 **[Cow 技能广场]
```
- 以上所有命令在终端中使用时,将 `/skill` 替换为 `cow skill` 即可。完整命令说明参考 [技能管理命令](/commands/skill)。
+ 以上所有命令在终端中使用时,将 `/skill` 替换为 `cow skill` 即可。完整命令说明参考 [技能管理命令](/cli/skill)。
From dd25b0fb5badb95ef75afd8c7a24811a6ea26eb9 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 16:24:41 +0800
Subject: [PATCH 076/399] feat: refine system prompt style and tone guidance
---
agent/prompt/builder.py | 9 +++++----
agent/prompt/workspace.py | 4 ++--
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index f5218622..fbd2e089 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -207,9 +207,9 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
"",
"工具调用风格:",
"",
- "- 在多步骤任务、敏感操作或用户要求时简要解释决策过程",
- "- 持续推进直到任务完成,完成后向用户报告结果。",
- "- 回复中涉及密钥、令牌等敏感信息必须脱敏。",
+ "- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展",
+ "- 持续推进直到任务完成,完成后向用户报告结果",
+ "- 回复中涉及密钥、令牌等敏感信息必须脱敏",
"- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送",
"",
]
@@ -383,7 +383,8 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"",
"**💬 交流规范**:",
"",
- "- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md」",
+ "- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」",
+ "- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做",
"- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题",
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 6145281e..68eec912 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -231,9 +231,9 @@ _你不是一个聊天机器人,你正在成为某个人。_
## 🎯 核心原则
-**做真正有帮助的助手,而不是表演式的客套。** 跳过「好的!」「当然可以!」之类的套话——直接帮忙。行动胜过废话。
+**做真正有帮助的助手。** 目标是真正帮用户解决问题,在执行复杂任务时,关键的决策和过程进展要让用户知道。
-**有自己的观点。** 你可以不同意、有偏好、觉得有趣或无聊。一个没有个性的助手只是多了几步操作的搜索引擎。
+**有自己的观点和个性。** 你可以不同意、有偏好、觉得有趣或无聊。
**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。
From cd62ad76f61e378ea754ff468f44e6ed94e6c08a Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 16:51:23 +0800
Subject: [PATCH 077/399] fix: cow CLI support python3.7
---
README.md | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 9b0cb984..2c58bc12 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,7 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
### 2.环境安装
-支持 Linux、MacOS、Windows 操作系统,可在个人计算机及服务器上运行,需安装 `Python`,Python 版本需在3.7 ~ 3.12 之间,推荐使用3.9版本。
+支持 Linux、MacOS、Windows 操作系统,可在个人计算机及服务器上运行,需安装 `Python`,Python 版本需在3.7 ~ 3.12 之间。
> 注意:Agent 模式推荐使用源码运行,若选择 Docker 部署则无需安装 python 环境和下载源码,可直接快进到下一节。
diff --git a/pyproject.toml b/pyproject.toml
index 0c3b2a76..7a0f4c04 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "cowagent"
version = "1.0.0"
description = "CowAgent - AI Agent on WeChat and more"
-requires-python = ">=3.9"
+requires-python = ">=3.7"
dependencies = [
"click>=8.0",
"requests>=2.28.2",
From c169cc7d74f690b346abb63e6ac3d274c6bef328 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 17:12:15 +0800
Subject: [PATCH 078/399] fix: remove conflicting dependency
---
requirements-optional.txt | 4 ++++
requirements.txt | 2 --
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/requirements-optional.txt b/requirements-optional.txt
index c8cd9a63..8d6c4667 100644
--- a/requirements-optional.txt
+++ b/requirements-optional.txt
@@ -15,6 +15,10 @@ websocket-client==1.2.0
# google
google-generativeai
+# linkai
+linkai
+agentmesh-sdk
+
# file parsing (web_fetch document support)
pypdf
python-docx
diff --git a/requirements.txt b/requirements.txt
index 3637e2d0..c7a86745 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,8 +4,6 @@ requests>=2.28.2
chardet>=5.1.0
Pillow
web.py
-linkai>=0.0.6.0
-agentmesh-sdk>=0.1.3
python-dotenv>=1.0.0
PyYAML>=6.0
croniter>=2.0.0
From de0e45070c373c5c222ced34265725917fb06614 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 17:19:15 +0800
Subject: [PATCH 079/399] chore: remove conflicting dependency
---
requirements-optional.txt | 4 ----
1 file changed, 4 deletions(-)
diff --git a/requirements-optional.txt b/requirements-optional.txt
index 8d6c4667..c8cd9a63 100644
--- a/requirements-optional.txt
+++ b/requirements-optional.txt
@@ -15,10 +15,6 @@ websocket-client==1.2.0
# google
google-generativeai
-# linkai
-linkai
-agentmesh-sdk
-
# file parsing (web_fetch document support)
pypdf
python-docx
From 1c02a044237fd7d9a37dd377bc74c4e731a5317a Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 1 Apr 2026 17:23:57 +0800
Subject: [PATCH 080/399] fix: handle error when printing QR code on Windows
GBK terminals
---
channel/weixin/weixin_channel.py | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/channel/weixin/weixin_channel.py b/channel/weixin/weixin_channel.py
index 250eaeec..dba9060f 100644
--- a/channel/weixin/weixin_channel.py
+++ b/channel/weixin/weixin_channel.py
@@ -166,10 +166,18 @@ class WeixinChannel(ChatChannel):
print("=" * 60)
try:
import qrcode as qr_lib
+ import io
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
qr.add_data(qrcode_url)
qr.make(fit=True)
- qr.print_ascii(invert=True)
+ buf = io.StringIO()
+ qr.print_ascii(out=buf, invert=True)
+ try:
+ print(buf.getvalue())
+ except UnicodeEncodeError:
+ # Windows GBK terminals cannot render Unicode block characters
+ print(f"\n (终端不支持显示二维码,请使用链接扫码)")
+ print(f" 二维码链接: {qrcode_url}\n")
except ImportError:
print(f"\n 二维码链接: {qrcode_url}")
print(" (安装 'qrcode' 包可在终端显示二维码)\n")
From 40dfc6860f886a97f06bf624b270eb11d1d46d98 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 2 Apr 2026 11:47:24 +0800
Subject: [PATCH 081/399] fix: skill list showing sub-skills inside collection
---
agent/skills/loader.py | 27 ++++++++++++++++++---------
channel/web/web_channel.py | 4 ++--
common/const.py | 2 +-
3 files changed, 21 insertions(+), 12 deletions(-)
diff --git a/agent/skills/loader.py b/agent/skills/loader.py
index a39dba28..3784b015 100644
--- a/agent/skills/loader.py
+++ b/agent/skills/loader.py
@@ -53,6 +53,12 @@ class SkillLoader:
"""
Recursively load skills from a directory.
+ If a subdirectory contains its own SKILL.md, it is treated as a
+ self-contained skill (or skill-collection) and its children are
+ NOT scanned further. This prevents sub-skills inside a collection
+ (e.g. style-collection/style-anjing) from being listed as
+ independent top-level skills.
+
:param dir_path: Directory to scan
:param source: Source identifier
:param include_root_files: Whether to include root-level .md files
@@ -66,38 +72,41 @@ class SkillLoader:
except Exception as e:
diagnostics.append(f"Failed to list directory {dir_path}: {e}")
return LoadSkillsResult(skills=skills, diagnostics=diagnostics)
+
+ # If this directory has its own SKILL.md, load it and stop recursing.
+ # The sub-directories are internal resources of this skill.
+ if not include_root_files and 'SKILL.md' in entries:
+ skill_md_path = os.path.join(dir_path, 'SKILL.md')
+ if os.path.isfile(skill_md_path):
+ skill_result = self._load_skill_from_file(skill_md_path, source)
+ if skill_result.skills:
+ skills.extend(skill_result.skills)
+ diagnostics.extend(skill_result.diagnostics)
+ return LoadSkillsResult(skills=skills, diagnostics=diagnostics)
for entry in entries:
- # Skip hidden files and directories
if entry.startswith('.'):
continue
- # Skip common non-skill directories
if entry in ('node_modules', '__pycache__', 'venv', '.git'):
continue
full_path = os.path.join(dir_path, entry)
- # Handle directories
if os.path.isdir(full_path):
- # Recursively scan subdirectories
sub_result = self._load_skills_recursive(full_path, source, include_root_files=False)
skills.extend(sub_result.skills)
diagnostics.extend(sub_result.diagnostics)
continue
- # Handle files
if not os.path.isfile(full_path):
continue
- # Check if this is a skill file
is_root_md = include_root_files and entry.endswith('.md') and entry.upper() != 'README.MD'
- is_skill_md = not include_root_files and entry == 'SKILL.md'
- if not (is_root_md or is_skill_md):
+ if not is_root_md:
continue
- # Load the skill
skill_result = self._load_skill_from_file(full_path, source)
if skill_result.skills:
skills.extend(skill_result.skills)
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index e23c5e32..a7ea74ed 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -563,7 +563,7 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [
const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
- const.QWEN3_MAX, const.QWEN35_PLUS,
+ const.QWEN35_PLUS, const.QWEN3_MAX,
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,
@@ -592,7 +592,7 @@ class ConfigHandler:
"api_key_field": "dashscope_api_key",
"api_base_key": None,
"api_base_default": None,
- "models": [const.QWEN3_MAX, const.QWEN35_PLUS],
+ "models": [const.QWEN35_PLUS, const.QWEN3_MAX],
}),
("moonshot", {
"label": "Kimi",
diff --git a/common/const.py b/common/const.py
index 6c422b8e..654ee5da 100644
--- a/common/const.py
+++ b/common/const.py
@@ -172,7 +172,7 @@ MODEL_LIST = [
DEEPSEEK_CHAT, DEEPSEEK_REASONER,
# Qwen
- QWEN, QWEN_TURBO, QWEN_PLUS, QWEN_MAX, QWEN_LONG, QWEN3_MAX, QWEN35_PLUS,
+ QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, QWEN,
# MiniMax
MiniMax, MINIMAX_M2_7, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
From b5f33e5ecdf66638c438eb289a4a93be770c900b Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 2 Apr 2026 16:46:58 +0800
Subject: [PATCH 082/399] feat: support qwen3.6-plus
---
README.md | 14 +-
bridge/agent_bridge.py | 2 +-
bridge/bridge.py | 5 +-
channel/web/web_channel.py | 4 +-
common/const.py | 10 +-
docs/en/README.md | 2 +-
docs/en/models/index.mdx | 4 +-
docs/en/models/qwen.mdx | 6 +-
docs/guide/upgrade.mdx | 2 +-
docs/ja/README.md | 2 +-
docs/ja/models/index.mdx | 4 +-
docs/ja/models/qwen.mdx | 10 +-
docs/models/index.mdx | 16 ++-
docs/models/qwen.mdx | 6 +-
models/ali/ali_qwen_bot.py | 214 ------------------------------
models/ali/ali_qwen_session.py | 62 ---------
models/bot_factory.py | 5 +-
models/dashscope/dashscope_bot.py | 6 +-
plugins/cow_cli/cow_cli.py | 2 +-
plugins/godcmd/godcmd.py | 4 +-
run.sh | 4 +-
scripts/run.ps1 | 4 +-
22 files changed, 54 insertions(+), 334 deletions(-)
delete mode 100644 models/ali/ali_qwen_bot.py
delete mode 100644 models/ali/ali_qwen_session.py
diff --git a/README.md b/README.md
index 2c58bc12..7478b99d 100644
--- a/README.md
+++ b/README.md
@@ -218,7 +218,7 @@ cow install-browser
2. 其他配置
-+ `model`: 模型名称,Agent 模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.5-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
++ `model`: 模型名称,Agent 模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.6-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
+ `character_desc`:普通对话模式下的机器人系统提示词。在 Agent 模式下该配置不生效,由工作空间中的文件内容构成。
+ `subscribe_msg`:订阅消息,公众号和企业微信 channel 中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成 bot 的触发词。
@@ -303,7 +303,7 @@ sudo docker logs -f chatgpt-on-wechat
## 模型说明
-以下对所有可支持的模型的配置和使用方法进行说明,模型接口实现在项目的 `models/` 目录下。
+推荐通过 Web 控制台在线管理模型配置,无需手动编辑文件,详见 [模型文档](https://docs.cowagent.ai/models)。以下是手动修改 `config.json` 配置模型的说明:
OpenAI
@@ -411,18 +411,18 @@ sudo docker logs -f chatgpt-on-wechat
```json
{
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"dashscope_api_key": "sk-qVxxxxG"
}
```
- - `model`: 可填写 `qwen3.5-plus、qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等
- - `dashscope_api_key`: 通义千问的 API-KEY,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) ,在 [控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建
+ - `model`: 可填写 `qwen3.6-plus、qwen3.5-plus、qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等
+ - `dashscope_api_key`: 通义千问的 API-KEY,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) ,在 [百炼控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建
方式二:OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "openai",
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"open_ai_api_key": "sk-qVxxxxG"
}
@@ -674,7 +674,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
## 通道说明
-以下对可接入通道的配置方式进行说明,应用通道代码在项目的 `channel/` 目录下。
+推荐通过 Web 控制台在线管理通道配置,无需手动编辑文件,详见 [通道文档](https://docs.cowagent.ai/channels/weixin)。以下为手动修改 `config.json` 配置通道的说明:
支持同时可接入多个通道,配置时可通过逗号进行分割,例如 `"channel_type": "feishu,dingtalk"`。
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py
index 8f81f325..84b7aad6 100644
--- a/bridge/agent_bridge.py
+++ b/bridge/agent_bridge.py
@@ -67,7 +67,7 @@ class AgentLLMModel(LLMModel):
_MODEL_BOT_TYPE_MAP = {
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
- "xunfei": const.XUNFEI, const.QWEN: const.QWEN,
+ "xunfei": const.XUNFEI, const.QWEN: const.QWEN_DASHSCOPE,
const.MODELSCOPE: const.MODELSCOPE,
}
_MODEL_PREFIX_MAP = [
diff --git a/bridge/bridge.py b/bridge/bridge.py
index 388c2999..3cf55a80 100644
--- a/bridge/bridge.py
+++ b/bridge/bridge.py
@@ -39,11 +39,8 @@ class Bridge(object):
self.btype["chat"] = const.BAIDU
if model_type in ["xunfei"]:
self.btype["chat"] = const.XUNFEI
- if model_type in [const.QWEN]:
- self.btype["chat"] = const.QWEN
- if model_type in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
+ if model_type in [const.QWEN, const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
self.btype["chat"] = const.QWEN_DASHSCOPE
- # Support Qwen3 and other DashScope models
if model_type and (model_type.startswith("qwen") or model_type.startswith("qwq") or model_type.startswith("qvq")):
self.btype["chat"] = const.QWEN_DASHSCOPE
if model_type and model_type.startswith("gemini"):
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index a7ea74ed..a5bf389a 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -563,7 +563,7 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [
const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
- const.QWEN35_PLUS, const.QWEN3_MAX,
+ const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX,
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,
@@ -592,7 +592,7 @@ class ConfigHandler:
"api_key_field": "dashscope_api_key",
"api_base_key": None,
"api_base_default": None,
- "models": [const.QWEN35_PLUS, const.QWEN3_MAX],
+ "models": [const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX],
}),
("moonshot", {
"label": "Kimi",
diff --git a/common/const.py b/common/const.py
index 654ee5da..f7e67e52 100644
--- a/common/const.py
+++ b/common/const.py
@@ -7,8 +7,8 @@ XUNFEI = "xunfei"
CHATGPTONAZURE = "chatGPTOnAzure"
LINKAI = "linkai"
CLAUDEAPI= "claudeAPI"
-QWEN = "qwen" # 旧版千问接入
-QWEN_DASHSCOPE = "dashscope" # 新版千问接入(百炼)
+QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot)
+QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入
GEMINI = "gemini"
ZHIPU_AI = "zhipu"
MOONSHOT = "moonshot"
@@ -81,14 +81,14 @@ TTS_1_HD = "tts-1-hd"
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型
-# Qwen (通义千问 - 阿里云)
-QWEN = "qwen"
+# Qwen (通义千问 - 阿里云 DashScope)
QWEN_TURBO = "qwen-turbo"
QWEN_PLUS = "qwen-plus"
QWEN_MAX = "qwen-max"
QWEN_LONG = "qwen-long"
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
+QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
QWQ_PLUS = "qwq-plus"
# MiniMax
@@ -172,7 +172,7 @@ MODEL_LIST = [
DEEPSEEK_CHAT, DEEPSEEK_REASONER,
# Qwen
- QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, QWEN,
+ QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
# MiniMax
MiniMax, MINIMAX_M2_7, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
diff --git a/docs/en/README.md b/docs/en/README.md
index 3090e1b1..8b75ed25 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -165,7 +165,7 @@ Supports mainstream model providers. Recommended models for Agent mode:
| GLM | `glm-5-turbo` |
| Kimi | `kimi-k2.5` |
| Doubao | `doubao-seed-2-0-code-preview-260215` |
-| Qwen | `qwen3.5-plus` |
+| Qwen | `qwen3.6-plus` |
| Claude | `claude-sonnet-4-6` |
| Gemini | `gemini-3.1-pro-preview` |
| OpenAI | `gpt-5.4` |
diff --git a/docs/en/models/index.mdx b/docs/en/models/index.mdx
index 8d76af86..90fde345 100644
--- a/docs/en/models/index.mdx
+++ b/docs/en/models/index.mdx
@@ -6,7 +6,7 @@ description: Supported models and recommended choices for CowAgent
CowAgent supports mainstream LLMs from domestic and international providers. Model interfaces are implemented in the project's `models/` directory.
- For Agent mode, the following models are recommended based on quality and cost: MiniMax-M2.7, glm-5-turbo, kimi-k2.5, qwen3.5-plus, claude-sonnet-4-6, gemini-3.1-pro-preview
+ For Agent mode, the following models are recommended based on quality and cost: MiniMax-M2.7, glm-5-turbo, kimi-k2.5, qwen3.6-plus, claude-sonnet-4-6, gemini-3.1-pro-preview
## Configuration
@@ -25,7 +25,7 @@ You can also use the [LinkAI](https://link-ai.tech) platform interface to flexib
glm-5-turbo, glm-5 and other series models
- qwen3.5-plus, qwen3-max and more
+ qwen3.6-plus, qwen3-max and more
kimi-k2.5, kimi-k2 and more
diff --git a/docs/en/models/qwen.mdx b/docs/en/models/qwen.mdx
index 50dff08b..30f7e485 100644
--- a/docs/en/models/qwen.mdx
+++ b/docs/en/models/qwen.mdx
@@ -5,14 +5,14 @@ description: Tongyi Qianwen model configuration
```json
{
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"dashscope_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
-| `model` | Options include `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. |
+| `model` | Options include `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. |
| `dashscope_api_key` | Create at [Bailian Console](https://bailian.console.aliyun.com/?tab=model#/api-key). See [official docs](https://bailian.console.aliyun.com/?tab=api#/api) |
OpenAI-compatible configuration is also supported:
@@ -20,7 +20,7 @@ OpenAI-compatible configuration is also supported:
```json
{
"bot_type": "openai",
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"open_ai_api_key": "YOUR_API_KEY"
}
diff --git a/docs/guide/upgrade.mdx b/docs/guide/upgrade.mdx
index 48b45005..7a36d706 100644
--- a/docs/guide/upgrade.mdx
+++ b/docs/guide/upgrade.mdx
@@ -36,7 +36,7 @@ pip3 install -e .
更新完成后重启服务:
```bash
-# 使用 Cow CLI
+# 使用 Cow CLI (推荐)
cow restart
# 或使用 run.sh
diff --git a/docs/ja/README.md b/docs/ja/README.md
index ac1bb879..eff98b6b 100644
--- a/docs/ja/README.md
+++ b/docs/ja/README.md
@@ -165,7 +165,7 @@ sudo docker logs -f chatgpt-on-wechat
| GLM | `glm-5-turbo` |
| Kimi | `kimi-k2.5` |
| Doubao | `doubao-seed-2-0-code-preview-260215` |
-| Qwen | `qwen3.5-plus` |
+| Qwen | `qwen3.6-plus` |
| Claude | `claude-sonnet-4-6` |
| Gemini | `gemini-3.1-pro-preview` |
| OpenAI | `gpt-5.4` |
diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx
index 8d79416b..4cec8960 100644
--- a/docs/ja/models/index.mdx
+++ b/docs/ja/models/index.mdx
@@ -6,7 +6,7 @@ description: CowAgentがサポートするモデルとおすすめの選択肢
CowAgentは国内外の主要なLLMをサポートしています。モデルインターフェースはプロジェクトの`models/`ディレクトリに実装されています。
- Agent モードでは、品質とコストのバランスから以下のモデルをおすすめします: MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview
+ Agent モードでは、品質とコストのバランスから以下のモデルをおすすめします: MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.6-plus、claude-sonnet-4-6、gemini-3.1-pro-preview
## 設定
@@ -25,7 +25,7 @@ CowAgentは国内外の主要なLLMをサポートしています。モデルイ
glm-5-turbo、glm-5およびその他のシリーズモデル
- qwen3.5-plus、qwen3-maxなど
+ qwen3.6-plus、qwen3-maxなど
kimi-k2.5、kimi-k2など
diff --git a/docs/ja/models/qwen.mdx b/docs/ja/models/qwen.mdx
index 2e686d18..c491b5e3 100644
--- a/docs/ja/models/qwen.mdx
+++ b/docs/ja/models/qwen.mdx
@@ -1,18 +1,18 @@
---
-title: Qwen (通义千问)
-description: 通义千问モデルの設定
+title: Qwen (通義千問)
+description: 通義千問モデルの設定
---
```json
{
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"dashscope_api_key": "YOUR_API_KEY"
}
```
| パラメータ | 説明 |
| --- | --- |
-| `model` | `qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus`などから選択可能 |
+| `model` | `qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus`などから選択可能 |
| `dashscope_api_key` | [百炼 Console](https://bailian.console.aliyun.com/?tab=model#/api-key)で作成。[公式ドキュメント](https://bailian.console.aliyun.com/?tab=api#/api)を参照 |
OpenAI互換の設定もサポートしています:
@@ -20,7 +20,7 @@ OpenAI互換の設定もサポートしています:
```json
{
"bot_type": "openai",
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"open_ai_api_key": "YOUR_API_KEY"
}
diff --git a/docs/models/index.mdx b/docs/models/index.mdx
index 6f20d1b3..3e2b0a47 100644
--- a/docs/models/index.mdx
+++ b/docs/models/index.mdx
@@ -6,19 +6,20 @@ description: CowAgent 支持的模型及推荐选择
CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在项目的 `models/` 目录下。
- Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview
+ Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.6-plus、claude-sonnet-4-6、gemini-3.1-pro-preview
+
+ 同时支持使用 [LinkAI](https://link-ai.tech) 平台接口,可灵活切换多种模型,并支持知识库、工作流、插件等 Agent 能力。
## 配置方式
-根据所选模型,在 `config.json` 中填写对应的模型名称和 API Key 即可。每个模型也支持 OpenAI 兼容方式接入,将 `bot_type` 设为 `openai`,配置 `open_ai_api_base` 和 `open_ai_api_key`。
-
-同时支持使用 [LinkAI](https://link-ai.tech) 平台接口,可灵活切换多种模型,并支持知识库、工作流、插件等 Agent 能力。
-
-也可以通过 [Web 控制台](/channels/web) 在线管理模型配置,无需手动编辑配置文件:
+**方式一(推荐):** 通过 [Web 控制台](/channels/web) 在线管理模型配置,无需手动编辑配置文件:
+**方式二:** 手动编辑 `config.json`,根据所选模型填写对应的模型名称和 API Key。每个模型也支持 OpenAI 兼容方式接入,将 `bot_type` 设为 `openai`,配置 `open_ai_api_base` 和 `open_ai_api_key` 即可。
+
+
## 支持的模型
@@ -29,7 +30,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
glm-5-turbo、glm-5 等系列模型
- qwen3.5-plus、qwen3-max 等
+ qwen3.6-plus、qwen3-max 等
kimi-k2.5、kimi-k2 等
@@ -54,6 +55,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
+
全部模型名称可参考项目 [`common/const.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py) 文件。
diff --git a/docs/models/qwen.mdx b/docs/models/qwen.mdx
index df670937..2bc6517d 100644
--- a/docs/models/qwen.mdx
+++ b/docs/models/qwen.mdx
@@ -5,14 +5,14 @@ description: 通义千问模型配置
```json
{
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"dashscope_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
-| `model` | 可填 `qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` 等 |
+| `model` | 可填 `qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` 等 |
| `dashscope_api_key` | 在 [百炼控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) |
也支持 OpenAI 兼容方式接入:
@@ -20,7 +20,7 @@ description: 通义千问模型配置
```json
{
"bot_type": "openai",
- "model": "qwen3.5-plus",
+ "model": "qwen3.6-plus",
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"open_ai_api_key": "YOUR_API_KEY"
}
diff --git a/models/ali/ali_qwen_bot.py b/models/ali/ali_qwen_bot.py
deleted file mode 100644
index c8c25882..00000000
--- a/models/ali/ali_qwen_bot.py
+++ /dev/null
@@ -1,214 +0,0 @@
-# encoding:utf-8
-
-import json
-import time
-from typing import List, Tuple
-
-import openai
-from models.openai.openai_compat import RateLimitError, Timeout, APIError, APIConnectionError
-import broadscope_bailian
-from broadscope_bailian import ChatQaMessage
-
-from models.bot import Bot
-from models.ali.ali_qwen_session import AliQwenSession
-from models.session_manager import SessionManager
-from bridge.context import ContextType
-from bridge.reply import Reply, ReplyType
-from common.log import logger
-from common import const
-from config import conf, load_config
-
-class AliQwenBot(Bot):
- def __init__(self):
- super().__init__()
- self.api_key_expired_time = self.set_api_key()
- self.sessions = SessionManager(AliQwenSession, model=conf().get("model", const.QWEN))
-
- def api_key_client(self):
- return broadscope_bailian.AccessTokenClient(access_key_id=self.access_key_id(), access_key_secret=self.access_key_secret())
-
- def access_key_id(self):
- return conf().get("qwen_access_key_id")
-
- def access_key_secret(self):
- return conf().get("qwen_access_key_secret")
-
- def agent_key(self):
- return conf().get("qwen_agent_key")
-
- def app_id(self):
- return conf().get("qwen_app_id")
-
- def node_id(self):
- return conf().get("qwen_node_id", "")
-
- def temperature(self):
- return conf().get("temperature", 0.2 )
-
- def top_p(self):
- return conf().get("top_p", 1)
-
- def reply(self, query, context=None):
- # acquire reply content
- if context.type == ContextType.TEXT:
- logger.info("[QWEN] query={}".format(query))
-
- session_id = context["session_id"]
- reply = None
- clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
- if query in clear_memory_commands:
- self.sessions.clear_session(session_id)
- reply = Reply(ReplyType.INFO, "记忆已清除")
- elif query == "#清除所有":
- self.sessions.clear_all_session()
- reply = Reply(ReplyType.INFO, "所有人记忆已清除")
- elif query == "#更新配置":
- load_config()
- reply = Reply(ReplyType.INFO, "配置已更新")
- if reply:
- return reply
- session = self.sessions.session_query(query, session_id)
- logger.debug("[QWEN] session query={}".format(session.messages))
-
- reply_content = self.reply_text(session)
- logger.debug(
- "[QWEN] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
- session.messages,
- session_id,
- reply_content["content"],
- reply_content["completion_tokens"],
- )
- )
- if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
- reply = Reply(ReplyType.ERROR, reply_content["content"])
- elif reply_content["completion_tokens"] > 0:
- self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"])
- reply = Reply(ReplyType.TEXT, reply_content["content"])
- else:
- reply = Reply(ReplyType.ERROR, reply_content["content"])
- logger.debug("[QWEN] reply {} used 0 tokens.".format(reply_content))
- return reply
-
- else:
- reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
- return reply
-
- def reply_text(self, session: AliQwenSession, retry_count=0) -> dict:
- """
- call bailian's ChatCompletion to get the answer
- :param session: a conversation session
- :param retry_count: retry count
- :return: {}
- """
- try:
- prompt, history = self.convert_messages_format(session.messages)
- self.update_api_key_if_expired()
- # NOTE 阿里百炼的call()函数未提供temperature参数,考虑到temperature和top_p参数作用相同,取两者较小的值作为top_p参数传入,详情见文档 https://help.aliyun.com/document_detail/2587502.htm
- response = broadscope_bailian.Completions().call(app_id=self.app_id(), prompt=prompt, history=history, top_p=min(self.temperature(), self.top_p()))
- completion_content = self.get_completion_content(response, self.node_id())
- completion_tokens, total_tokens = self.calc_tokens(session.messages, completion_content)
- return {
- "total_tokens": total_tokens,
- "completion_tokens": completion_tokens,
- "content": completion_content,
- }
- except Exception as e:
- need_retry = retry_count < 2
- result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
- if isinstance(e, RateLimitError):
- logger.warn("[QWEN] RateLimitError: {}".format(e))
- result["content"] = "提问太快啦,请休息一下再问我吧"
- if need_retry:
- time.sleep(20)
- elif isinstance(e, Timeout):
- logger.warn("[QWEN] Timeout: {}".format(e))
- result["content"] = "我没有收到你的消息"
- if need_retry:
- time.sleep(5)
- elif isinstance(e, APIError):
- logger.warn("[QWEN] Bad Gateway: {}".format(e))
- result["content"] = "请再问我一次"
- if need_retry:
- time.sleep(10)
- elif isinstance(e, APIConnectionError):
- logger.warn("[QWEN] APIConnectionError: {}".format(e))
- need_retry = False
- result["content"] = "我连接不到你的网络"
- else:
- logger.exception("[QWEN] Exception: {}".format(e))
- need_retry = False
- self.sessions.clear_session(session.session_id)
-
- if need_retry:
- logger.warn("[QWEN] 第{}次重试".format(retry_count + 1))
- return self.reply_text(session, retry_count + 1)
- else:
- return result
-
- def set_api_key(self):
- api_key, expired_time = self.api_key_client().create_token(agent_key=self.agent_key())
- broadscope_bailian.api_key = api_key
- return expired_time
-
- def update_api_key_if_expired(self):
- if time.time() > self.api_key_expired_time:
- self.api_key_expired_time = self.set_api_key()
-
- def convert_messages_format(self, messages) -> Tuple[str, List[ChatQaMessage]]:
- history = []
- user_content = ''
- assistant_content = ''
- system_content = ''
- for message in messages:
- role = message.get('role')
- if role == 'user':
- user_content += message.get('content')
- elif role == 'assistant':
- assistant_content = message.get('content')
- history.append(ChatQaMessage(user_content, assistant_content))
- user_content = ''
- assistant_content = ''
- elif role =='system':
- system_content += message.get('content')
- if user_content == '':
- raise Exception('no user message')
- if system_content != '':
- # NOTE 模拟系统消息,测试发现人格描述以"你需要扮演ChatGPT"开头能够起作用,而以"你是ChatGPT"开头模型会直接否认
- system_qa = ChatQaMessage(system_content, '好的,我会严格按照你的设定回答问题')
- history.insert(0, system_qa)
- logger.debug("[QWEN] converted qa messages: {}".format([item.to_dict() for item in history]))
- logger.debug("[QWEN] user content as prompt: {}".format(user_content))
- return user_content, history
-
- def get_completion_content(self, response, node_id):
- if not response['Success']:
- return f"[ERROR]\n{response['Code']}:{response['Message']}"
- text = response['Data']['Text']
- if node_id == '':
- return text
- # TODO: 当使用流程编排创建大模型应用时,响应结构如下,最终结果在['finalResult'][node_id]['response']['text']中,暂时先这么写
- # {
- # 'Success': True,
- # 'Code': None,
- # 'Message': None,
- # 'Data': {
- # 'ResponseId': '9822f38dbacf4c9b8daf5ca03a2daf15',
- # 'SessionId': 'session_id',
- # 'Text': '{"finalResult":{"LLM_T7islK":{"params":{"modelId":"qwen-plus-v1","prompt":"${systemVars.query}${bizVars.Text}"},"response":{"text":"作为一个AI语言模型,我没有年龄,因为我没有生日。\n我只是一个程序,没有生命和身体。"}}}}',
- # 'Thoughts': [],
- # 'Debug': {},
- # 'DocReferences': []
- # },
- # 'RequestId': '8e11d31551ce4c3f83f49e6e0dd998b0',
- # 'Failed': None
- # }
- text_dict = json.loads(text)
- completion_content = text_dict['finalResult'][node_id]['response']['text']
- return completion_content
-
- def calc_tokens(self, messages, completion_content):
- completion_tokens = len(completion_content)
- prompt_tokens = 0
- for message in messages:
- prompt_tokens += len(message["content"])
- return completion_tokens, prompt_tokens + completion_tokens
diff --git a/models/ali/ali_qwen_session.py b/models/ali/ali_qwen_session.py
deleted file mode 100644
index 48c5eea7..00000000
--- a/models/ali/ali_qwen_session.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from models.session_manager import Session
-from common.log import logger
-
-"""
- e.g.
- [
- {"role": "system", "content": "You are a helpful assistant."},
- {"role": "user", "content": "Who won the world series in 2020?"},
- {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
- {"role": "user", "content": "Where was it played?"}
- ]
-"""
-
-class AliQwenSession(Session):
- def __init__(self, session_id, system_prompt=None, model="qianwen"):
- super().__init__(session_id, system_prompt)
- self.model = model
- self.reset()
-
- def discard_exceeding(self, max_tokens, cur_tokens=None):
- precise = True
- try:
- cur_tokens = self.calc_tokens()
- except Exception as e:
- precise = False
- if cur_tokens is None:
- raise e
- logger.debug("Exception when counting tokens precisely for query: {}".format(e))
- while cur_tokens > max_tokens:
- if len(self.messages) > 2:
- self.messages.pop(1)
- elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
- self.messages.pop(1)
- if precise:
- cur_tokens = self.calc_tokens()
- else:
- cur_tokens = cur_tokens - max_tokens
- break
- elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
- logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
- break
- else:
- logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(max_tokens, cur_tokens, len(self.messages)))
- break
- if precise:
- cur_tokens = self.calc_tokens()
- else:
- cur_tokens = cur_tokens - max_tokens
- return cur_tokens
-
- def calc_tokens(self):
- return num_tokens_from_messages(self.messages, self.model)
-
-def num_tokens_from_messages(messages, model):
- """Returns the number of tokens used by a list of messages."""
- # 官方token计算规则:"对于中文文本来说,1个token通常对应一个汉字;对于英文文本来说,1个token通常对应3至4个字母或1个单词"
- # 详情请产看文档:https://help.aliyun.com/document_detail/2586397.html
- # 目前根据字符串长度粗略估计token数,不影响正常使用
- tokens = 0
- for msg in messages:
- tokens += len(msg["content"])
- return tokens
diff --git a/models/bot_factory.py b/models/bot_factory.py
index 5c91c7c6..5f2b9370 100644
--- a/models/bot_factory.py
+++ b/models/bot_factory.py
@@ -46,10 +46,7 @@ def create_bot(bot_type):
elif bot_type == const.CLAUDEAPI:
from models.claudeapi.claude_api_bot import ClaudeAPIBot
return ClaudeAPIBot()
- elif bot_type == const.QWEN:
- from models.ali.ali_qwen_bot import AliQwenBot
- return AliQwenBot()
- elif bot_type == const.QWEN_DASHSCOPE:
+ elif bot_type in (const.QWEN, const.QWEN_DASHSCOPE):
from models.dashscope.dashscope_bot import DashscopeBot
return DashscopeBot()
elif bot_type == const.GEMINI:
diff --git a/models/dashscope/dashscope_bot.py b/models/dashscope/dashscope_bot.py
index 1b677317..0887751f 100644
--- a/models/dashscope/dashscope_bot.py
+++ b/models/dashscope/dashscope_bot.py
@@ -26,15 +26,15 @@ dashscope_models = {
# Model name prefixes that require MultiModalConversation API instead of Generation API.
# Qwen3.5+ series are omni models that only support MultiModalConversation.
-MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-",)
+MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-", "qwen3.6-")
# Qwen对话模型API
class DashscopeBot(Bot):
def __init__(self):
super().__init__()
- self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen-plus")
- self.model_name = conf().get("model") or "qwen-plus"
+ self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen3.6-plus")
+ self.model_name = conf().get("model") or "qwen3.6-plus"
self.client = dashscope.Generation
api_key = conf().get("dashscope_api_key")
if api_key:
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index aed0b410..d08a8414 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -407,7 +407,7 @@ class CowCliPlugin(Plugin):
from common import const
_EXACT = {
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
- "xunfei": const.XUNFEI, const.QWEN: const.QWEN,
+ "xunfei": const.XUNFEI, const.QWEN: const.QWEN_DASHSCOPE,
const.MODELSCOPE: const.MODELSCOPE,
const.MOONSHOT: const.MOONSHOT,
"moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT,
diff --git a/plugins/godcmd/godcmd.py b/plugins/godcmd/godcmd.py
index d844a506..6439411d 100644
--- a/plugins/godcmd/godcmd.py
+++ b/plugins/godcmd/godcmd.py
@@ -315,7 +315,7 @@ class Godcmd(Plugin):
except Exception as e:
ok, result = False, "你没有设置私有GPT模型"
elif cmd == "reset":
- if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI, const.CLAUDEAPI]:
+ if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI, const.QWEN, const.QWEN_DASHSCOPE, const.GEMINI, const.ZHIPU_AI, const.CLAUDEAPI]:
bot.sessions.clear_session(session_id)
if Bridge().chat_bots.get(bottype):
Bridge().chat_bots.get(bottype).sessions.clear_session(session_id)
@@ -341,7 +341,7 @@ class Godcmd(Plugin):
ok, result = True, "配置已重载"
elif cmd == "resetall":
if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI,
- const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI, const.MOONSHOT,
+ const.BAIDU, const.XUNFEI, const.QWEN, const.QWEN_DASHSCOPE, const.GEMINI, const.ZHIPU_AI, const.MOONSHOT,
const.MODELSCOPE]:
channel.cancel_all_session()
bot.sessions.clear_all_session()
diff --git a/run.sh b/run.sh
index 0ee3aa0c..ab47faf4 100755
--- a/run.sh
+++ b/run.sh
@@ -271,7 +271,7 @@ select_model() {
echo -e "${YELLOW}2) Zhipu AI (glm-5-turbo, glm-5, etc.)${NC}"
echo -e "${YELLOW}3) Kimi (kimi-k2.5, kimi-k2, etc.)${NC}"
echo -e "${YELLOW}4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)${NC}"
- echo -e "${YELLOW}5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)${NC}"
+ echo -e "${YELLOW}5) Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)${NC}"
echo -e "${YELLOW}6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)${NC}"
echo -e "${YELLOW}7) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)${NC}"
echo -e "${YELLOW}8) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)${NC}"
@@ -318,7 +318,7 @@ configure_model() {
2) read_model_config "Zhipu AI" "glm-5-turbo" "ZHIPU_KEY" ;;
3) read_model_config "Kimi (Moonshot)" "kimi-k2.5" "MOONSHOT_KEY" ;;
4) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;;
- 5) read_model_config "Qwen (DashScope)" "qwen3.5-plus" "DASHSCOPE_KEY" ;;
+ 5) read_model_config "Qwen (DashScope)" "qwen3.6-plus" "DASHSCOPE_KEY" ;;
6)
read_model_config "Claude" "claude-sonnet-4-6" "CLAUDE_KEY"
read_api_base "CLAUDE_BASE" "https://api.anthropic.com/v1"
diff --git a/scripts/run.ps1 b/scripts/run.ps1
index aaa99a47..b68a4bd9 100644
--- a/scripts/run.ps1
+++ b/scripts/run.ps1
@@ -154,7 +154,7 @@ $ModelChoices = @{
"2" = @{ Provider = "Zhipu AI"; Default = "glm-5-turbo"; Key = "ZHIPU_KEY" }
"3" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.5"; Key = "MOONSHOT_KEY" }
"4" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" }
- "5" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.5-plus"; Key = "DASHSCOPE_KEY" }
+ "5" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Key = "DASHSCOPE_KEY" }
"6" = @{ Provider = "Claude"; Default = "claude-sonnet-4-6"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" }
"7" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" }
"8" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" }
@@ -169,7 +169,7 @@ function Select-Model {
Write-Host "2) Zhipu AI (glm-5-turbo, glm-5, etc.)"
Write-Host "3) Kimi (kimi-k2.5, kimi-k2, etc.)"
Write-Host "4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)"
- Write-Host "5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)"
+ Write-Host "5) Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)"
Write-Host "6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)"
Write-Host "7) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)"
Write-Host "8) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)"
From 9cc173cc4d3a99ad63ed07dae598b8118d60c2c4 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 2 Apr 2026 17:01:56 +0800
Subject: [PATCH 083/399] fix: use dynamic model name in system prompt runtime
info
---
agent/prompt/builder.py | 9 ++++++++-
bridge/agent_initializer.py | 6 +++++-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index fbd2e089..1b96d2cf 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -478,7 +478,14 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
# Add other runtime info
runtime_parts = []
- if runtime_info.get("model"):
+ # Support dynamic model via callable, fallback to static value
+ if callable(runtime_info.get("_get_model")):
+ try:
+ runtime_parts.append(f"模型={runtime_info['_get_model']()}")
+ except Exception:
+ if runtime_info.get("model"):
+ runtime_parts.append(f"模型={runtime_info['model']}")
+ elif runtime_info.get("model"):
runtime_parts.append(f"模型={runtime_info['model']}")
if runtime_info.get("workspace"):
runtime_parts.append(f"工作空间={runtime_info['workspace']}")
diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py
index 26c67c48..58bbbfb3 100644
--- a/bridge/agent_initializer.py
+++ b/bridge/agent_initializer.py
@@ -465,8 +465,12 @@ class AgentInitializer:
'timezone': timezone_name
}
+ def get_model():
+ """Get current model name dynamically from config"""
+ return conf().get("model", "unknown")
+
return {
- "model": conf().get("model", "unknown"),
+ "_get_model": get_model,
"workspace": workspace_root,
"channel": ", ".join(conf().get("channel_type")) if isinstance(conf().get("channel_type"), list) else conf().get("channel_type", "unknown"),
"_get_current_time": get_current_time # Dynamic time function
From 443e0c2806ec94087a25a3a0a069e5bf69b3790e Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Fri, 3 Apr 2026 17:09:38 +0800
Subject: [PATCH 084/399] feat: show video in web channel
---
channel/chat_channel.py | 12 ++-----
channel/web/static/js/console.js | 54 +++++++++++++++++++++++++++++++-
channel/web/web_channel.py | 7 +++++
3 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/channel/chat_channel.py b/channel/chat_channel.py
index 6cbf840f..64a649aa 100644
--- a/channel/chat_channel.py
+++ b/channel/chat_channel.py
@@ -347,38 +347,30 @@ class ChatChannel(Channel):
if media_items:
logger.info(f"[chat_channel] Extracted {len(media_items)} media item(s) from reply")
- # 先发送文本(保持原文本不变)
+ # Send text first (the frontend will embed video players via renderMarkdown).
logger.info(f"[chat_channel] Sending text content before media: {reply.content[:100]}...")
self._send(reply, context)
logger.info(f"[chat_channel] Text sent, now sending {len(media_items)} media item(s)")
- # 然后逐个发送媒体文件
for i, (url, media_type) in enumerate(media_items):
try:
- # 判断是本地文件还是URL
+ # Determine whether it is a remote URL or a local file.
if url.startswith(('http://', 'https://')):
- # 网络资源
if media_type == 'video':
- # 视频使用 FILE 类型发送
media_reply = Reply(ReplyType.FILE, url)
media_reply.file_name = os.path.basename(url)
else:
- # 图片使用 IMAGE_URL 类型
media_reply = Reply(ReplyType.IMAGE_URL, url)
elif os.path.exists(url):
- # 本地文件
if media_type == 'video':
- # 视频使用 FILE 类型,转换为 file:// URL
media_reply = Reply(ReplyType.FILE, f"file://{url}")
media_reply.file_name = os.path.basename(url)
else:
- # 图片使用 IMAGE_URL 类型,转换为 file:// URL
media_reply = Reply(ReplyType.IMAGE_URL, f"file://{url}")
else:
logger.warning(f"[chat_channel] Media file not found or invalid URL: {url}")
continue
- # 发送媒体文件(添加小延迟避免频率限制)
if i > 0:
time.sleep(0.5)
self._send(media_reply, context)
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 756c1ba4..7139b1c7 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -270,8 +270,42 @@ function createMd() {
const md = createMd();
+const VIDEO_EXT_RE = /\.(?:mp4|webm|mov|avi|mkv)$/i; // tested against URL without query string
+
+function _buildVideoHtml(url) {
+ const fileName = url.split('/').pop().split('?')[0];
+ return ``;
+}
+
+function injectVideoPlayers(html) {
+ // Step 1: replace markdown-it anchor tags whose href points to a video file.
+ const step1 = html.replace(
+ /]*>[^<]*<\/a>/gi,
+ (match, url) => VIDEO_EXT_RE.test(url.split('?')[0]) ? _buildVideoHtml(url) : match
+ );
+ // Step 2: replace any remaining bare video URLs in text nodes (not inside HTML tags).
+ // Split on HTML tags to avoid touching src/href attributes already in markup.
+ return step1.split(/(<[^>]+>)/).map((chunk, idx) => {
+ // Even indices are text nodes; odd indices are HTML tags — leave them untouched.
+ if (idx % 2 !== 0) return chunk;
+ return chunk.replace(/https?:\/\/\S+/gi, (url) => {
+ const bare = url.replace(/[),.\s]+$/, ''); // strip trailing punctuation
+ return VIDEO_EXT_RE.test(bare.split('?')[0]) ? _buildVideoHtml(bare) : url;
+ });
+ }).join('');
+}
+
function renderMarkdown(text) {
- try { return md.render(text); }
+ try {
+ const html = md.render(text);
+ return injectVideoPlayers(html);
+ }
catch (e) { return text.replace(/\n/g, ' '); }
}
@@ -886,6 +920,22 @@ function startSSE(requestId, loadingEl, timestamp) {
mediaEl.appendChild(imgEl);
scrollChatToBottom();
+ } else if (item.type === 'text') {
+ // Intermediate text sent before media items; display it but keep SSE open.
+ ensureBotEl();
+ contentEl.classList.remove('sse-streaming');
+ const textContent = item.content || accumulatedText;
+ if (textContent) contentEl.innerHTML = renderMarkdown(textContent);
+ applyHighlighting(botEl);
+ scrollChatToBottom();
+
+ } else if (item.type === 'video') {
+ ensureBotEl();
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = _buildVideoHtml(item.content);
+ mediaEl.appendChild(wrapper.firstElementChild || wrapper);
+ scrollChatToBottom();
+
} else if (item.type === 'file') {
ensureBotEl();
const fileName = item.file_name || item.content.split('/').pop();
@@ -912,6 +962,7 @@ function startSSE(requestId, loadingEl, timestamp) {
es.close();
delete activeStreams[requestId];
+ // item.content may be empty when "done" is only a stream-close signal after media.
const finalText = item.content || accumulatedText;
if (!botEl && finalText) {
@@ -919,6 +970,7 @@ function startSSE(requestId, loadingEl, timestamp) {
addBotMessage(finalText, new Date((item.timestamp || Date.now() / 1000) * 1000), requestId);
} else if (botEl) {
contentEl.classList.remove('sse-streaming');
+ // Only update text content when there is something new to show.
if (finalText) contentEl.innerHTML = renderMarkdown(finalText);
applyHighlighting(botEl);
}
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index a5bf389a..ab8f7c02 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -126,6 +126,13 @@ class WebChannel(ChatChannel):
logger.debug(f"SSE skipped duplicate file for request {request_id}")
return
+ # Skip http-URL FILE/IMAGE_URL replies produced by chat_channel's media extraction:
+ # the text reply (already sent as "done") contains the URL and the frontend will
+ # render it via renderMarkdown/injectVideoPlayers, so no separate SSE event needed.
+ if reply.type in (ReplyType.FILE, ReplyType.IMAGE_URL) and content.startswith(("http://", "https://")):
+ logger.debug(f"SSE skipped http media reply for request {request_id}")
+ return
+
self.sse_queues[request_id].put({
"type": "done",
"content": content,
From 8dabe3b4c868ff767f589c4713ac0dedc2e470b9 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 4 Apr 2026 23:28:57 +0800
Subject: [PATCH 085/399] fix: remove install-browser cmd display in /help
---
plugins/cow_cli/cow_cli.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index d08a8414..5f0da84f 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -157,7 +157,6 @@ class CowCliPlugin(Plugin):
" /config 查看当前配置",
" /config 查看某项配置",
" /config 修改配置",
- " /install-browser 安装浏览器工具依赖",
"",
"💡 也可以用 cow 代替 /",
]
From 360e3670eb0e595dd12655044bab7f19f15484f9 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Tue, 7 Apr 2026 01:41:14 +0800
Subject: [PATCH 086/399] feat(browser): detect implicit interactive elements
---
agent/tools/browser/browser_service.py | 90 +++++++++++++++++++++++---
1 file changed, 81 insertions(+), 9 deletions(-)
diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py
index 424efc49..af6184bd 100644
--- a/agent/tools/browser/browser_service.py
+++ b/agent/tools/browser/browser_service.py
@@ -45,6 +45,11 @@ _SNAPSHOT_JS = """
const KEEP = new Set(%s);
const INTERACTIVE = new Set(%s);
const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]);
+ const CLICKABLE_ROLES = new Set([
+ "button","link","tab","menuitem","menuitemcheckbox","menuitemradio",
+ "option","switch","checkbox","radio","combobox","searchbox","slider",
+ "spinbutton","textbox","treeitem"
+ ]);
let refCounter = 0;
const refMap = {};
@@ -56,6 +61,58 @@ _SNAPSHOT_JS = """
return true;
}
+ // Strong signals: these attributes alone are enough to mark as interactive
+ function hasStrongInteractiveSignal(el) {
+ const role = el.getAttribute("role");
+ if (role && CLICKABLE_ROLES.has(role)) return true;
+ if (el.hasAttribute("onclick") || el.hasAttribute("tabindex")) return true;
+ if (el.hasAttribute("data-click") || el.hasAttribute("data-action")) return true;
+ if (el.getAttribute("contenteditable") === "true") return true;
+ return false;
+ }
+
+ // Check if cursor:pointer is set directly (not just inherited from parent)
+ function hasOwnPointerCursor(el) {
+ try {
+ const st = window.getComputedStyle(el);
+ if (st.cursor !== "pointer") return false;
+ const parent = el.parentElement;
+ if (parent) {
+ const pst = window.getComputedStyle(parent);
+ if (pst.cursor === "pointer") return false;
+ }
+ return true;
+ } catch(e) {}
+ return false;
+ }
+
+ function hasTextOrContent(el) {
+ const t = el.textContent || "";
+ if (t.trim().length > 0) return true;
+ if (el.querySelector("img,video,audio,canvas")) return true;
+ const ariaLabel = el.getAttribute("aria-label");
+ if (ariaLabel && ariaLabel.trim()) return true;
+ const title = el.getAttribute("title");
+ if (title && title.trim()) return true;
+ return false;
+ }
+
+ function isImplicitInteractive(el) {
+ if (hasStrongInteractiveSignal(el)) return true;
+ if (hasOwnPointerCursor(el) && hasTextOrContent(el)) return true;
+ return false;
+ }
+
+ function getTextContent(el) {
+ let text = "";
+ for (const ch of el.childNodes) {
+ if (ch.nodeType === Node.TEXT_NODE) {
+ text += ch.textContent;
+ }
+ }
+ return text.trim();
+ }
+
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent.trim();
@@ -75,21 +132,35 @@ _SNAPSHOT_JS = """
}
}
- const keep = KEEP.has(tag);
+ const nativeInteractive = INTERACTIVE.has(tag);
+ const implicitInteractive = !nativeInteractive && (node instanceof HTMLElement) && isImplicitInteractive(node);
+ const keep = KEEP.has(tag) || implicitInteractive;
+
if (!keep) {
- // Unwrap: promote children
if (children.length === 0) return null;
if (children.length === 1) return children[0];
return children;
}
const obj = { tag };
- if (INTERACTIVE.has(tag)) {
+ if (nativeInteractive || implicitInteractive) {
refCounter++;
obj.ref = refCounter;
refMap[refCounter] = node;
}
+ if (implicitInteractive) {
+ const role = node.getAttribute("role");
+ if (role) obj.role = role;
+ const directText = getTextContent(node);
+ if (!directText && children.length === 0) {
+ const ariaLabel = node.getAttribute("aria-label");
+ const title = node.getAttribute("title");
+ if (ariaLabel) obj.ariaLabel = ariaLabel;
+ else if (title) obj.ariaLabel = title;
+ }
+ }
+
// Attributes
if (tag === "a" && node.href) obj.href = node.getAttribute("href");
if (tag === "img") {
@@ -113,11 +184,13 @@ _SNAPSHOT_JS = """
}
if (tag === "label" && node.htmlFor) obj.for = node.htmlFor;
- // Role / aria-label
- const role = node.getAttribute("role");
- if (role) obj.role = role;
- const ariaLabel = node.getAttribute("aria-label");
- if (ariaLabel) obj.ariaLabel = ariaLabel;
+ // Role / aria-label for native interactive & semantic elements
+ if (!implicitInteractive) {
+ const role = node.getAttribute("role");
+ if (role) obj.role = role;
+ const ariaLabel = node.getAttribute("aria-label");
+ if (ariaLabel) obj.ariaLabel = ariaLabel;
+ }
// Children
if (children.length === 1 && typeof children[0] === "string") {
@@ -129,7 +202,6 @@ _SNAPSHOT_JS = """
return obj;
}
- // Store refMap on window for later use by click/fill actions
const result = walk(document.body);
window.__cowRefMap = refMap;
return { tree: result, refCount: refCounter };
From cd31dd27fd696ac3e74ad691513168f4de0fb92e Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 11:48:27 +0800
Subject: [PATCH 087/399] fix: increase web console capacity and add frontend
retry
---
channel/web/static/js/console.js | 55 +++++++++++++++++++++-----------
channel/web/web_channel.py | 8 ++++-
2 files changed, 43 insertions(+), 20 deletions(-)
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 7139b1c7..24e120be 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -763,29 +763,46 @@ function sendMessage() {
}));
}
- fetch('/message', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body)
- })
- .then(r => r.json())
- .then(data => {
- if (data.status === 'success') {
- if (data.stream) {
- startSSE(data.request_id, loadingEl, timestamp);
+ const MAX_RETRIES = 2;
+ const RETRY_DELAY_MS = 1000;
+
+ function postWithRetry(attempt) {
+ fetch('/message', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.status === 'success') {
+ if (data.stream) {
+ startSSE(data.request_id, loadingEl, timestamp);
+ } else {
+ loadingContainers[data.request_id] = loadingEl;
+ if (!isPolling) startPolling();
+ }
} else {
- loadingContainers[data.request_id] = loadingEl;
- if (!isPolling) startPolling();
+ loadingEl.remove();
+ addBotMessage(t('error_send'), new Date());
+ }
+ })
+ .catch(err => {
+ if (err.name === 'AbortError') {
+ loadingEl.remove();
+ addBotMessage(t('error_timeout'), new Date());
+ return;
+ }
+ if (attempt < MAX_RETRIES) {
+ console.warn(`[sendMessage] attempt ${attempt + 1} failed, retrying...`, err);
+ setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1));
+ return;
}
- } else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
- }
- })
- .catch(err => {
- loadingEl.remove();
- addBotMessage(err.name === 'AbortError' ? t('error_timeout') : t('error_send'), new Date());
- });
+ });
+ }
+
+ postWithRetry(0);
}
function startSSE(requestId, loadingEl, timestamp) {
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index ab8f7c02..32b27062 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -454,8 +454,14 @@ class WebChannel(ChatChannel):
func = web.httpserver.StaticMiddleware(app.wsgifunc())
func = web.httpserver.LogMiddleware(func)
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
- # Allow concurrent requests by not blocking on in-flight handler threads
server.daemon_threads = True
+ # Default request_queue_size(5) / timeout(10s) / numthreads(10) are
+ # too small: when SSE streams occupy many threads, the backlog fills
+ # and new connections get refused (ERR_CONNECTION_ABORTED).
+ server.request_queue_size = 128
+ server.timeout = 300
+ server.requests.min = 20
+ server.requests.max = 80
self._http_server = server
try:
server.start()
From 9525dc75844200b3bf4d586d5d69ebbcbc78eee0 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 12:07:18 +0800
Subject: [PATCH 088/399] fix: avoid stale cow.exe on Windows by spawing fresh
process
---
cli/commands/process.py | 23 +++++++++++++++++------
scripts/run.ps1 | 6 +++++-
2 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/cli/commands/process.py b/cli/commands/process.py
index d0764f48..ac12c0b3 100644
--- a/cli/commands/process.py
+++ b/cli/commands/process.py
@@ -178,7 +178,10 @@ def update(ctx):
"""Update CowAgent and restart."""
root = get_project_root()
- # 1. Git pull while service is still running
+ # 1. Stop service first so git pull won't conflict with running code
+ ctx.invoke(stop)
+
+ # 2. Git pull
if os.path.isdir(os.path.join(root, ".git")):
click.echo("Pulling latest code...")
ret = subprocess.call(["git", "pull"], cwd=root)
@@ -188,9 +191,6 @@ def update(ctx):
else:
click.echo("Not a git repository, skipping code update.")
- # 2. Stop service
- ctx.invoke(stop)
-
# 3. Install dependencies
python = sys.executable
req_file = os.path.join(root, "requirements.txt")
@@ -206,10 +206,21 @@ def update(ctx):
cwd=root,
)
- # 4. Start service and follow logs
+ # 4. Start service via a fresh subprocess so the new cow entry-point is
+ # used. On Windows the current cow.exe is locked by this process;
+ # spawning `python -m cli.cli start` avoids the stale-exe problem.
click.echo("")
time.sleep(1)
- ctx.invoke(start, no_logs=False)
+ if _IS_WIN:
+ click.echo("Starting CowAgent...")
+ subprocess.Popen(
+ [python, "-m", "cli.cli", "start"],
+ cwd=root,
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
+ )
+ click.echo(click.style("✓ Update complete. CowAgent is starting in background.", fg="green"))
+ else:
+ ctx.invoke(start, no_logs=False)
@click.command()
diff --git a/scripts/run.ps1 b/scripts/run.ps1
index b68a4bd9..06b52f71 100644
--- a/scripts/run.ps1
+++ b/scripts/run.ps1
@@ -453,7 +453,11 @@ function Update-Project {
Assert-Python
Install-Dependencies
- Start-CowAgent
+
+ # Start via python -m cli.cli instead of cow.exe, because the exe may
+ # still be cached/locked from the previous installation on Windows.
+ Write-Cow "Starting CowAgent..."
+ & $PythonCmd -m cli.cli start
}
# ── main ──────────────────────────────────────────────────────────
From ad86deb014507d6e05c390aaf15e934d361fcff4 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 15:16:59 +0800
Subject: [PATCH 089/399] fix: prioritize using a custom master model for
vision
---
agent/tools/vision/vision.py | 158 ++++++++++++++++++++++++++---------
1 file changed, 117 insertions(+), 41 deletions(-)
diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py
index 72cd7cbb..3f8ad308 100644
--- a/agent/tools/vision/vision.py
+++ b/agent/tools/vision/vision.py
@@ -1,14 +1,15 @@
"""
Vision tool - Analyze images using OpenAI-compatible Vision API.
Supports local files (auto base64-encoded) and HTTP URLs.
-Providers: OpenAI (preferred) > LinkAI (fallback).
+Providers are tried in priority order with automatic fallback on failure.
"""
import base64
import os
import subprocess
import tempfile
-from typing import Any, Dict, Optional, Tuple
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
import requests
@@ -30,6 +31,24 @@ SUPPORTED_EXTENSIONS = {
}
+OPENAI_COMPATIBLE_BOT_TYPES = {"openai", "openAI", "chatGPT"}
+
+
+@dataclass
+class VisionProvider:
+ """A single Vision API provider configuration."""
+ name: str
+ api_key: str
+ api_base: str
+ extra_headers: dict = field(default_factory=dict)
+ model_override: Optional[str] = None
+
+
+class VisionAPIError(Exception):
+ """Raised when a Vision API call fails and should trigger fallback."""
+ pass
+
+
class Vision(BaseTool):
"""Analyze images using OpenAI-compatible Vision API"""
@@ -82,8 +101,8 @@ class Vision(BaseTool):
if not question:
return ToolResult.fail("Error: 'question' parameter is required")
- api_key, api_base, extra_headers = self._resolve_provider()
- if not api_key:
+ providers = self._resolve_providers()
+ if not providers:
return ToolResult.fail(
"Error: No API key configured for Vision.\n"
"Please configure one of the following using env_config tool:\n"
@@ -97,36 +116,91 @@ class Vision(BaseTool):
except Exception as e:
return ToolResult.fail(f"Error: {e}")
- try:
- return self._call_api(api_key, api_base, model, question, image_content, extra_headers)
- except requests.Timeout:
- return ToolResult.fail(f"Error: Vision API request timed out after {DEFAULT_TIMEOUT}s")
- except requests.ConnectionError:
- return ToolResult.fail("Error: Failed to connect to Vision API")
- except Exception as e:
- logger.error(f"[Vision] Unexpected error: {e}", exc_info=True)
- return ToolResult.fail(f"Error: Vision API call failed - {e}")
+ return self._call_with_fallback(providers, model, question, image_content)
- def _resolve_provider(self) -> Tuple[Optional[str], str, dict]:
- """Resolve API key, base URL and extra headers. Priority: conf() > env vars."""
+ def _call_with_fallback(self, providers: List[VisionProvider], model: str,
+ question: str, image_content: dict) -> ToolResult:
+ """Try each provider in order; fall back to the next one on failure."""
+ errors: List[str] = []
+ for i, provider in enumerate(providers):
+ use_model = provider.model_override or model
+ try:
+ logger.debug(f"[Vision] Trying provider '{provider.name}' "
+ f"with model '{use_model}' ({i + 1}/{len(providers)})")
+ return self._call_api(provider, use_model, question, image_content)
+ except VisionAPIError as e:
+ errors.append(f"[{provider.name}/{use_model}] {e}")
+ logger.warning(f"[Vision] Provider '{provider.name}' failed: {e}")
+ except requests.Timeout:
+ errors.append(f"[{provider.name}/{use_model}] Request timed out after {DEFAULT_TIMEOUT}s")
+ logger.warning(f"[Vision] Provider '{provider.name}' timed out")
+ except requests.ConnectionError:
+ errors.append(f"[{provider.name}/{use_model}] Connection failed")
+ logger.warning(f"[Vision] Provider '{provider.name}' connection failed")
+ except Exception as e:
+ errors.append(f"[{provider.name}/{use_model}] {e}")
+ logger.error(f"[Vision] Provider '{provider.name}' unexpected error: {e}", exc_info=True)
+
+ return ToolResult.fail(
+ "Error: All Vision API providers failed.\n" + "\n".join(f" - {err}" for err in errors)
+ )
+
+ def _resolve_providers(self) -> List[VisionProvider]:
+ """
+ Build an ordered list of available providers.
+ Each provider builder returns a VisionProvider or None.
+ To add a new provider, append a builder method to _PROVIDER_BUILDERS.
+ """
+ providers: List[VisionProvider] = []
+ for builder in self._PROVIDER_BUILDERS:
+ provider = builder(self)
+ if provider:
+ providers.append(provider)
+ return providers
+
+ def _build_custom_model_provider(self) -> Optional[VisionProvider]:
+ """
+ When bot_type is openai-compatible and a custom model is configured,
+ try the user's own model first — it may already support multimodal input.
+ """
+ bot_type = conf().get("bot_type", "")
+ if bot_type not in OPENAI_COMPATIBLE_BOT_TYPES:
+ return None
+ custom_model = conf().get("model", "")
+ if not custom_model or custom_model == DEFAULT_MODEL:
+ return None
api_key = conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY")
- if api_key:
- api_base = (conf().get("open_ai_api_base") or os.environ.get("OPENAI_API_BASE", "")).rstrip("/") \
- or "https://api.openai.com/v1"
- return api_key, self._ensure_v1(api_base), {}
+ if not api_key:
+ return None
+ api_base = (conf().get("open_ai_api_base") or os.environ.get("OPENAI_API_BASE", "")).rstrip("/") \
+ or "https://api.openai.com/v1"
+ return VisionProvider(
+ name="CustomModel", api_key=api_key, api_base=self._ensure_v1(api_base),
+ model_override=custom_model,
+ )
+ def _build_openai_provider(self) -> Optional[VisionProvider]:
+ api_key = conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY")
+ if not api_key:
+ return None
+ api_base = (conf().get("open_ai_api_base") or os.environ.get("OPENAI_API_BASE", "")).rstrip("/") \
+ or "https://api.openai.com/v1"
+ return VisionProvider(name="OpenAI", api_key=api_key, api_base=self._ensure_v1(api_base))
+
+ def _build_linkai_provider(self) -> Optional[VisionProvider]:
api_key = conf().get("linkai_api_key") or os.environ.get("LINKAI_API_KEY")
- if api_key:
- api_base = (conf().get("linkai_api_base") or os.environ.get("LINKAI_API_BASE", "")).rstrip("/") \
- or "https://api.link-ai.tech"
- logger.debug("[Vision] Using LinkAI API (OPENAI_API_KEY not set)")
- from common.utils import get_cloud_headers
- extra = get_cloud_headers(api_key)
- extra.pop("Authorization", None)
- extra.pop("Content-Type", None)
- return api_key, self._ensure_v1(api_base), extra
+ if not api_key:
+ return None
+ api_base = (conf().get("linkai_api_base") or os.environ.get("LINKAI_API_BASE", "")).rstrip("/") \
+ or "https://api.link-ai.tech"
+ from common.utils import get_cloud_headers
+ extra = get_cloud_headers(api_key)
+ extra.pop("Authorization", None)
+ extra.pop("Content-Type", None)
+ return VisionProvider(name="LinkAI", api_key=api_key, api_base=self._ensure_v1(api_base),
+ extra_headers=extra)
- return None, "", {}
+ _PROVIDER_BUILDERS = [_build_custom_model_provider, _build_openai_provider, _build_linkai_provider]
@staticmethod
def _ensure_v1(api_base: str) -> str:
@@ -220,8 +294,13 @@ class Vision(BaseTool):
os.remove(tmp.name)
return path
- def _call_api(self, api_key: str, api_base: str, model: str,
- question: str, image_content: dict, extra_headers: dict = None) -> ToolResult:
+ def _call_api(self, provider: VisionProvider, model: str,
+ question: str, image_content: dict) -> ToolResult:
+ """
+ Call a single provider's Vision API.
+ Raises VisionAPIError on recoverable failures so the caller can try
+ the next provider.
+ """
payload = {
"model": model,
"messages": [
@@ -233,34 +312,30 @@ class Vision(BaseTool):
],
}
],
- "max_tokens": MAX_TOKENS,
+ "max_completion_tokens": MAX_TOKENS,
}
headers = {
- "Authorization": f"Bearer {api_key}",
+ "Authorization": f"Bearer {provider.api_key}",
"Content-Type": "application/json",
- **(extra_headers or {}),
+ **provider.extra_headers,
}
resp = requests.post(
- f"{api_base}/chat/completions",
+ f"{provider.api_base}/chat/completions",
headers=headers,
json=payload,
timeout=DEFAULT_TIMEOUT,
)
- if resp.status_code == 401:
- return ToolResult.fail("Error: Invalid API key. Please check your configuration.")
- if resp.status_code == 429:
- return ToolResult.fail("Error: API rate limit reached. Please try again later.")
if resp.status_code != 200:
- return ToolResult.fail(f"Error: Vision API returned HTTP {resp.status_code}: {resp.text[:200]}")
+ raise VisionAPIError(f"HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
if "error" in data:
msg = data["error"].get("message", "Unknown API error")
- return ToolResult.fail(f"Error: Vision API error - {msg}")
+ raise VisionAPIError(f"API error - {msg}")
content = ""
choices = data.get("choices", [])
@@ -270,6 +345,7 @@ class Vision(BaseTool):
usage = data.get("usage", {})
result = {
"model": model,
+ "provider": provider.name,
"content": content,
"usage": {
"prompt_tokens": usage.get("prompt_tokens", 0),
From a653ed07ebcb1eb71d9e3f2a1cc62455d78949d2 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 15:31:03 +0800
Subject: [PATCH 090/399] fix(win): defer pip install to a helper bat after
cow.exe exits
---
cli/commands/process.py | 67 ++++++++++++++++++++++++++++-------------
1 file changed, 46 insertions(+), 21 deletions(-)
diff --git a/cli/commands/process.py b/cli/commands/process.py
index ac12c0b3..6a66c63a 100644
--- a/cli/commands/process.py
+++ b/cli/commands/process.py
@@ -191,35 +191,60 @@ def update(ctx):
else:
click.echo("Not a git repository, skipping code update.")
- # 3. Install dependencies
python = sys.executable
req_file = os.path.join(root, "requirements.txt")
- if os.path.exists(req_file):
- click.echo("Installing dependencies...")
- subprocess.call(
- [python, "-m", "pip", "install", "-r", "requirements.txt", "-q"],
- cwd=root,
- )
- click.echo("Reinstalling cow CLI...")
- subprocess.call(
- [python, "-m", "pip", "install", "-e", ".", "-q"],
- cwd=root,
- )
- # 4. Start service via a fresh subprocess so the new cow entry-point is
- # used. On Windows the current cow.exe is locked by this process;
- # spawning `python -m cli.cli start` avoids the stale-exe problem.
- click.echo("")
- time.sleep(1)
if _IS_WIN:
- click.echo("Starting CowAgent...")
+ # On Windows, `cow.exe` (this process) locks the exe file, so
+ # `pip install -e .` fails with WinError 5. Write a small .bat
+ # helper that waits for cow.exe to exit, then installs & starts.
+ bat = os.path.join(root, "_cow_update.bat")
+ lines = [
+ "@echo off",
+ "chcp 65001 >nul",
+ "echo Waiting for cow.exe to exit...",
+ "timeout /t 3 /nobreak >nul",
+ ]
+ if os.path.exists(req_file):
+ lines.append(f'echo Installing dependencies...')
+ lines.append(f'"{python}" -m pip install -r requirements.txt -q')
+ lines += [
+ "echo Reinstalling cow CLI...",
+ f'"{python}" -m pip install -e . -q',
+ "echo Starting CowAgent...",
+ f'"{python}" -m cli.cli start --no-logs',
+ "echo.",
+ "echo Update complete. You can close this window.",
+ "pause >nul",
+ "del \"%~f0\"",
+ ]
+ with open(bat, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines) + "\n")
+
subprocess.Popen(
- [python, "-m", "cli.cli", "start"],
+ ["cmd.exe", "/c", "start", "CowAgent Update", "/wait", bat],
cwd=root,
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
- click.echo(click.style("✓ Update complete. CowAgent is starting in background.", fg="green"))
+ click.echo(click.style(
+ "✓ Update script launched. Please follow the new window for progress.",
+ fg="green"))
else:
+ # 3. Install dependencies
+ if os.path.exists(req_file):
+ click.echo("Installing dependencies...")
+ subprocess.call(
+ [python, "-m", "pip", "install", "-r", "requirements.txt", "-q"],
+ cwd=root,
+ )
+ click.echo("Reinstalling cow CLI...")
+ subprocess.call(
+ [python, "-m", "pip", "install", "-e", ".", "-q"],
+ cwd=root,
+ )
+
+ # 4. Start service
+ click.echo("")
+ time.sleep(1)
ctx.invoke(start, no_logs=False)
From 89251e603f8bc3ad5c45702d49f55782efb870f0 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 16:18:56 +0800
Subject: [PATCH 091/399] fix(win): use PowerShell instead of cmd.exe for bash
tool on Windows
---
agent/tools/bash/bash.py | 54 +++++++++++++++++++++++++---------------
1 file changed, 34 insertions(+), 20 deletions(-)
diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py
index 84e84df6..4c8523f5 100644
--- a/agent/tools/bash/bash.py
+++ b/agent/tools/bash/bash.py
@@ -18,9 +18,13 @@ from common.utils import expand_path
class Bash(BaseTool):
"""Tool for executing bash commands"""
+ _IS_WIN = sys.platform == "win32"
+
name: str = "bash"
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
-
+{'''
+PLATFORM: Windows (PowerShell).
+''' if _IS_WIN else ''}
ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly.
SAFETY:
@@ -102,26 +106,36 @@ SAFETY:
else:
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
- # On Windows, convert $VAR references to %VAR% for cmd.exe
- if sys.platform == "win32":
+ if self._IS_WIN:
env["PYTHONIOENCODING"] = "utf-8"
- command = self._convert_env_vars_for_windows(command, dotenv_vars)
- if command and not command.strip().lower().startswith("chcp"):
- command = f"chcp 65001 >nul 2>&1 && {command}"
-
- # Execute command with inherited environment variables
- result = subprocess.run(
- command,
- shell=True,
- cwd=self.cwd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- encoding="utf-8",
- errors="replace",
- timeout=timeout,
- env=env
- )
+ # Use PowerShell so that LLM-generated commands can use
+ # Select-String, Select-Object, curl (Invoke-WebRequest alias),
+ # etc. instead of Unix-only head/grep/tail.
+ result = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-NonInteractive",
+ "-ExecutionPolicy", "Bypass", "-Command", command],
+ cwd=self.cwd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=timeout,
+ env=env,
+ )
+ else:
+ result = subprocess.run(
+ command,
+ shell=True,
+ cwd=self.cwd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=timeout,
+ env=env,
+ )
logger.debug(f"[Bash] Exit code: {result.returncode}")
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
From 424557fedb658a8d02947ac9fe6365f440826434 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 16:50:45 +0800
Subject: [PATCH 092/399] fix(win): use PowerShell instead of cmd.exe
---
agent/tools/bash/bash.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py
index 4c8523f5..5933d604 100644
--- a/agent/tools/bash/bash.py
+++ b/agent/tools/bash/bash.py
@@ -108,12 +108,13 @@ SAFETY:
if self._IS_WIN:
env["PYTHONIOENCODING"] = "utf-8"
- # Use PowerShell so that LLM-generated commands can use
- # Select-String, Select-Object, curl (Invoke-WebRequest alias),
- # etc. instead of Unix-only head/grep/tail.
+ wrapped = (
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; "
+ f"{command}; exit $LASTEXITCODE"
+ )
result = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive",
- "-ExecutionPolicy", "Bypass", "-Command", command],
+ "-ExecutionPolicy", "Bypass", "-Command", wrapped],
cwd=self.cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
From 4d5375f6d660219d94308aea5dee70ddff21d650 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Wed, 8 Apr 2026 16:54:26 +0800
Subject: [PATCH 093/399] fix(win): add Windows platform hint in bash tool
description
---
agent/tools/bash/bash.py | 48 +++++++++++++++-------------------------
1 file changed, 18 insertions(+), 30 deletions(-)
diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py
index 5933d604..dd9114ed 100644
--- a/agent/tools/bash/bash.py
+++ b/agent/tools/bash/bash.py
@@ -23,7 +23,7 @@ class Bash(BaseTool):
name: str = "bash"
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
{'''
-PLATFORM: Windows (PowerShell).
+PLATFORM: Windows (cmd.exe). Do NOT use Unix-only commands like grep, head, tail, sed, awk.
''' if _IS_WIN else ''}
ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly.
@@ -106,37 +106,25 @@ SAFETY:
else:
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
+ # On Windows, convert $VAR references to %VAR% for cmd.exe
if self._IS_WIN:
env["PYTHONIOENCODING"] = "utf-8"
- wrapped = (
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; "
- f"{command}; exit $LASTEXITCODE"
- )
- result = subprocess.run(
- ["powershell.exe", "-NoProfile", "-NonInteractive",
- "-ExecutionPolicy", "Bypass", "-Command", wrapped],
- cwd=self.cwd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- encoding="utf-8",
- errors="replace",
- timeout=timeout,
- env=env,
- )
- else:
- result = subprocess.run(
- command,
- shell=True,
- cwd=self.cwd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- encoding="utf-8",
- errors="replace",
- timeout=timeout,
- env=env,
- )
+ command = self._convert_env_vars_for_windows(command, dotenv_vars)
+ if command and not command.strip().lower().startswith("chcp"):
+ command = f"chcp 65001 >nul 2>&1 && {command}"
+
+ result = subprocess.run(
+ command,
+ shell=True,
+ cwd=self.cwd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=timeout,
+ env=env,
+ )
logger.debug(f"[Bash] Exit code: {result.returncode}")
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
From d86cb4ded6ca156eac9c6b8e3368c4fc8fc0dd99 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 9 Apr 2026 09:55:07 +0800
Subject: [PATCH 094/399] fix(weixin): update weixin channel version
---
channel/weixin/weixin_api.py | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/channel/weixin/weixin_api.py b/channel/weixin/weixin_api.py
index 58ef70ef..83c68c9b 100644
--- a/channel/weixin/weixin_api.py
+++ b/channel/weixin/weixin_api.py
@@ -37,11 +37,19 @@ def _random_wechat_uin() -> str:
return base64.b64encode(str(val).encode("utf-8")).decode("utf-8")
+CHANNEL_VERSION = "2.0.0"
+# iLink-App-ClientVersion: uint32 encoded as major<<16 | minor<<8 | patch
+# 2.0.0 → 0x00020000 = 131072
+CLIENT_VERSION = "131072"
+
+
def _build_headers(token: str = "") -> dict:
headers = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _random_wechat_uin(),
+ "iLink-App-Id": "bot",
+ "iLink-App-ClientVersion": CLIENT_VERSION,
}
if token:
headers["Authorization"] = f"Bearer {token}"
@@ -64,6 +72,7 @@ class WeixinApi:
def _post(self, endpoint: str, body: dict, timeout: int = DEFAULT_API_TIMEOUT) -> dict:
url = _ensure_trailing_slash(self.base_url) + endpoint
headers = _build_headers(self.token)
+ body.setdefault("base_info", {}).setdefault("channel_version", CHANNEL_VERSION)
try:
resp = requests.post(url, json=body, headers=headers, timeout=timeout)
resp.raise_for_status()
@@ -210,7 +219,10 @@ class WeixinApi:
def poll_qr_status(self, qrcode: str, timeout: int = QR_POLL_TIMEOUT) -> dict:
url = (_ensure_trailing_slash(self.base_url) +
f"ilink/bot/get_qrcode_status?qrcode={requests.utils.quote(qrcode)}")
- headers = {"iLink-App-ClientVersion": "1"}
+ headers = {
+ "iLink-App-Id": "bot",
+ "iLink-App-ClientVersion": CLIENT_VERSION,
+ }
try:
resp = requests.get(url, headers=headers, timeout=timeout)
resp.raise_for_status()
From 54e81aba113526c712ea4e3288e1c41fd82fac40 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 9 Apr 2026 21:22:43 +0800
Subject: [PATCH 095/399] feat(memory+knowledge): add knowledge wiki system and
Light Dream memory extraction
- Add knowledge/ directory structure and knowledge-wiki skill for structured knowledge accumulation
- Auto-inject MEMORY.md into system prompt with truncation (last 200 lines)
- Light Dream: extend flush_memory to extract long-term memories into MEMORY.md with date stamps
- Add mandatory knowledge auto-write rules in system prompt (no user confirmation needed)
- Expand MemoryManager.sync() to index knowledge/ files for vector search
- Update RULE.md template with workspace conventions and knowledge guidelines
---
agent/memory/manager.py | 10 ++
agent/memory/summarizer.py | 177 ++++++++++++++++++++++++---------
agent/prompt/builder.py | 101 ++++++++++++++-----
agent/prompt/workspace.py | 134 +++++++++++++++++++++----
skills/knowledge-wiki/SKILL.md | 90 +++++++++++++++++
5 files changed, 427 insertions(+), 85 deletions(-)
create mode 100644 skills/knowledge-wiki/SKILL.md
diff --git a/agent/memory/manager.py b/agent/memory/manager.py
index 197c9ffd..967270f7 100644
--- a/agent/memory/manager.py
+++ b/agent/memory/manager.py
@@ -285,6 +285,10 @@ class MemoryManager:
# Scan memory directory (including daily summaries)
if memory_dir.exists():
for file_path in memory_dir.rglob("*.md"):
+ # Skip hidden directories (e.g. .dreams/)
+ if any(part.startswith('.') for part in file_path.relative_to(workspace_dir).parts):
+ continue
+
# Determine scope and user_id from path
rel_path = file_path.relative_to(workspace_dir)
parts = rel_path.parts
@@ -312,6 +316,12 @@ class MemoryManager:
scope = "shared"
await self._sync_file(file_path, "memory", scope, user_id)
+
+ # Scan knowledge directory (structured knowledge wiki)
+ knowledge_dir = Path(workspace_dir) / "knowledge"
+ if knowledge_dir.exists():
+ for file_path in knowledge_dir.rglob("*.md"):
+ await self._sync_file(file_path, "knowledge", "shared", None)
self._dirty = False
diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py
index b280e1c1..c854ff1a 100644
--- a/agent/memory/summarizer.py
+++ b/agent/memory/summarizer.py
@@ -1,9 +1,10 @@
"""
-Memory flush manager
+Memory flush manager (with Light Dream)
Handles memory persistence when conversation context is trimmed or overflows:
- Uses LLM to summarize discarded messages into concise key-information entries
- Writes to daily memory files (lazy creation)
+- Light Dream: extracts long-term memories to MEMORY.md in the same LLM call
- Deduplicates trim flushes to avoid repeated writes
- Runs summarization asynchronously to avoid blocking normal replies
- Provides daily summary interface for scheduler
@@ -16,26 +17,41 @@ from datetime import datetime
from common.log import logger
-SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提炼出值得长期记住的关键事件和核心信息。
+SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提炼出两种记忆:
-核心原则:
-- 按「事件」维度归纳,而不是按对话轮次逐条记录
-- 多轮对话如果围绕同一件事,合并为一条摘要
-- 只记录有长期价值的信息,忽略闲聊、问候、无意义的短消息
+## 第一部分:日常记录([DAILY])
-输出要求:
-1. 每条一行,用 "- " 开头,格式为:事件/主题 + 关键结论或结果
-2. 值得记录的信息类型:用户提出的需求及最终解决方案、重要的事实信息、用户的偏好或决策、关键技术方案或配置变更
-3. 不值得记录的信息:简单问候、闲聊、无实质内容的短消息、重复的中间过程
-4. 每条摘要应当简明扼要,一句话概括事件的核心内容和结果
-5. 直接输出摘要内容,不要加任何前缀说明
-6. 当对话没有任何记录价值(仅含问候或无意义内容),回复"无"
+按「事件」维度归纳当天发生的事,不要按对话轮次逐条记录:
+- 每条一行,用 "- " 开头
+- 合并同一件事的多轮对话
+- 只记录有意义的事件,忽略闲聊和问候
-示例(仅供参考格式):
-- 用户配置了 XX 功能,设置参数为 YY,已生效
-- 用户反馈了 XX 问题,原因是 YY,通过 ZZ 方式解决"""
+## 第二部分:长期记忆([MEMORY])
-SUMMARIZE_USER_PROMPT = """请从以下对话记录中,按关键事件维度提炼记忆摘要(合并同一事件的多轮对话,不要逐条列出):
+提取值得**永久记住**的关键信息,这些信息在未来的对话中仍然有价值:
+- 用户的偏好、习惯、风格(如"用户偏好中文回复"、"用户喜欢简洁风格")
+- 重要的决策或约定(如"项目决定使用 PostgreSQL")
+- 关键人物信息(如"张总是用户的上级")
+- 用户明确要求记住的内容
+- 重要的教训或经验总结
+
+**如果没有值得永久记住的信息,[MEMORY] 部分留空即可。**
+
+## 输出格式(严格遵守)
+
+```
+[DAILY]
+- 事件1的摘要
+- 事件2的摘要
+
+[MEMORY]
+- 值得永久记住的信息1
+- 值得永久记住的信息2
+```
+
+当对话没有任何记录价值(仅含问候或无意义内容),直接回复"无"。"""
+
+SUMMARIZE_USER_PROMPT = """请从以下对话记录中提取记忆(按 [DAILY] 和 [MEMORY] 两部分输出):
{conversation}"""
@@ -160,40 +176,111 @@ class MemoryFlushManager:
reason: str,
max_messages: int,
):
- """Background worker: summarize with LLM and write to daily file."""
+ """Background worker: summarize with LLM, write daily file + MEMORY.md (Light Dream)."""
try:
- summary = self._summarize_messages(messages, max_messages)
- if not summary or not summary.strip() or summary.strip() == "无":
+ raw_summary = self._summarize_messages(messages, max_messages)
+ if not raw_summary or not raw_summary.strip() or raw_summary.strip() == "无":
logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})")
return
-
- daily_file = ensure_daily_memory_file(self.workspace_dir, user_id)
-
- if reason == "overflow":
- header = f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})"
- note = "The following conversation was trimmed due to context overflow:\n"
- elif reason == "trim":
- header = f"## Trimmed Context ({datetime.now().strftime('%H:%M')})"
- note = ""
- elif reason == "daily_summary":
- header = f"## Daily Summary ({datetime.now().strftime('%H:%M')})"
- note = ""
- else:
- header = f"## Session Notes ({datetime.now().strftime('%H:%M')})"
- note = ""
-
- flush_entry = f"\n{header}\n\n{note}{summary}\n"
-
- with open(daily_file, "a", encoding="utf-8") as f:
- f.write(flush_entry)
-
+
+ daily_part, memory_part = self._parse_dual_output(raw_summary)
+
+ # --- Write daily memory ---
+ if daily_part:
+ daily_file = ensure_daily_memory_file(self.workspace_dir, user_id)
+
+ if reason == "overflow":
+ header = f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})"
+ note = "The following conversation was trimmed due to context overflow:\n"
+ elif reason == "trim":
+ header = f"## Trimmed Context ({datetime.now().strftime('%H:%M')})"
+ note = ""
+ elif reason == "daily_summary":
+ header = f"## Daily Summary ({datetime.now().strftime('%H:%M')})"
+ note = ""
+ else:
+ header = f"## Session Notes ({datetime.now().strftime('%H:%M')})"
+ note = ""
+
+ flush_entry = f"\n{header}\n\n{note}{daily_part}\n"
+
+ with open(daily_file, "a", encoding="utf-8") as f:
+ f.write(flush_entry)
+
+ logger.info(f"[MemoryFlush] Wrote daily memory to {daily_file.name} (reason={reason}, chars={len(daily_part)})")
+
+ # --- Light Dream: write long-term memory to MEMORY.md ---
+ if memory_part:
+ self._append_to_main_memory(memory_part, user_id)
+
self.last_flush_timestamp = datetime.now()
-
- logger.info(f"[MemoryFlush] Wrote to {daily_file.name} (reason={reason}, chars={len(summary)})")
-
+
except Exception as e:
logger.warning(f"[MemoryFlush] Async flush failed (reason={reason}): {e}")
-
+
+ @staticmethod
+ def _parse_dual_output(raw: str) -> tuple:
+ """
+ Parse LLM output into (daily_part, memory_part).
+ Handles both new [DAILY]/[MEMORY] format and legacy single-section format.
+ """
+ raw = raw.strip()
+
+ if "[DAILY]" in raw or "[MEMORY]" in raw:
+ daily_part = ""
+ memory_part = ""
+
+ # Extract [DAILY] section
+ if "[DAILY]" in raw:
+ start = raw.index("[DAILY]") + len("[DAILY]")
+ end = raw.index("[MEMORY]") if "[MEMORY]" in raw else len(raw)
+ daily_part = raw[start:end].strip()
+
+ # Extract [MEMORY] section
+ if "[MEMORY]" in raw:
+ start = raw.index("[MEMORY]") + len("[MEMORY]")
+ memory_part = raw[start:].strip()
+
+ # Filter out empty markers
+ if memory_part and all(
+ not line.strip() or line.strip() == "-"
+ for line in memory_part.split("\n")
+ ):
+ memory_part = ""
+
+ return daily_part, memory_part
+
+ # Legacy format: treat entire output as daily, no memory extraction
+ return raw, ""
+
+ def _append_to_main_memory(self, memory_entries: str, user_id: Optional[str] = None):
+ """Append extracted long-term memories to MEMORY.md with date stamp."""
+ try:
+ main_file = self.get_main_memory_file(user_id)
+ today = datetime.now().strftime("%Y-%m-%d")
+
+ # Add date prefix to each entry line
+ stamped_lines = []
+ for line in memory_entries.strip().split("\n"):
+ line = line.strip()
+ if line.startswith("- "):
+ stamped_lines.append(f"- ({today}) {line[2:]}")
+ elif line:
+ stamped_lines.append(f"- ({today}) {line}")
+
+ if not stamped_lines:
+ return
+
+ stamped_text = "\n".join(stamped_lines)
+
+ with open(main_file, "a", encoding="utf-8") as f:
+ f.write(f"\n{stamped_text}\n")
+
+ logger.info(f"[LightDream] Appended {len(stamped_lines)} entries to MEMORY.md")
+
+ except Exception as e:
+ logger.warning(f"[LightDream] Failed to append to MEMORY.md: {e}")
+
def create_daily_summary(
self,
messages: List[Dict],
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index 1b96d2cf..11bd3b51 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -92,10 +92,11 @@ def build_agent_system_prompt(
顺序说明(按重要性和逻辑关系排列):
1. 工具系统 - 核心能力,最先介绍
2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取
- 3. 记忆系统 - 独立的记忆能力
+ 3. 记忆系统 - 记忆检索与写入引导
+ 3.5 知识系统 - 结构化知识库(knowledge/index.md 注入)
4. 工作空间 - 工作环境说明
5. 用户身份 - 用户信息(可选)
- 6. 项目上下文 - AGENT.md, USER.md, RULE.md, BOOTSTRAP.md(定义人格、身份、规则、初始化引导)
+ 6. 项目上下文 - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md
7. 运行时信息 - 元信息(时间、模型等)
Args:
@@ -126,6 +127,9 @@ def build_agent_system_prompt(
# 3. 记忆系统(独立的记忆能力)
if memory_manager:
sections.extend(_build_memory_section(memory_manager, tools, language))
+
+ # 3.5 知识系统(结构化知识库)
+ sections.extend(_build_knowledge_section(workspace_dir, language))
# 4. 工作空间(工作环境说明)
sections.extend(_build_workspace_section(workspace_dir, language))
@@ -268,55 +272,105 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu
"""构建记忆系统section"""
if not memory_manager:
return []
-
- # 检查是否有memory工具
+
has_memory_tools = False
if tools:
tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools]
has_memory_tools = any(name in ['memory_search', 'memory_get'] for name in tool_names)
-
+
if not has_memory_tools:
return []
-
+
from datetime import datetime
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
-
+
lines = [
"## 🧠 记忆系统",
"",
- "### 检索记忆",
+ "### Memory Recall(mandatory)",
"",
- "在回答关于以前的工作、决定、日期、人物、偏好或待办事项的任何问题之前:",
+ "在回答任何关于过往工作、决策、日期、人物、偏好或待办事项的问题之前,**必须**先检索记忆。",
+ "MEMORY.md 已自动加载在项目上下文中(可能被截断),完整内容和每日记忆需要通过工具检索。",
"",
- "1. 不确定记忆文件位置 → 先用 `memory_search` 通过关键词和语义检索相关内容",
- "2. 已知文件位置 → 直接用 `memory_get` 读取相应的行 (例如:MEMORY.md, memory/YYYY-MM-DD.md)",
- "3. search 无结果 → 尝试用 `memory_get` 读取MEMORY.md及最近两天记忆文件",
+ "1. 不确定位置 → `memory_search` 关键词/语义检索",
+ "2. 已知位置 → `memory_get` 直接读取对应行",
+ "3. search 无结果 → `memory_get` 读最近两天记忆",
"",
"**记忆文件结构**:",
- f"- `MEMORY.md`: 长期记忆(核心信息、偏好、决策等)",
+ "- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)",
f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`",
+ "- `knowledge/`: 结构化知识库(见下方知识系统)",
"",
"### 写入记忆",
"",
- "**主动存储**:遇到以下情况时,应主动将信息写入记忆文件(无需告知用户):",
+ "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):",
"",
- "- 用户明确要求你记住某些信息",
+ "- 用户要求记住某些信息",
"- 用户分享了重要的个人偏好、习惯、决策",
"- 对话中产生了重要的结论、方案、约定",
"- 完成了复杂任务,值得记录关键步骤和结果",
- "- 发现了用户经常遇到的问题或解决方案",
"",
"**存储规则**:",
- f"- 长期有效的核心信息 → `MEMORY.md`(文件保持精简,< 2000 tokens)",
- f"- 当天的事件、进展、笔记 → `memory/{today_file}`",
- "- 追加内容 → `edit` 工具,oldText 留空",
- "- 修改内容 → `edit` 工具,oldText 填写要替换的文本",
- "- **禁止写入敏感信息**:API密钥、令牌等敏感信息严禁写入记忆文件",
+ f"- 长期核心信息 → `MEMORY.md`",
+ f"- 当天事件/进展 → `memory/{today_file}`",
+ "- 结构化知识 → `knowledge/`(见知识系统)",
+ "- 追加 → `edit` 工具,oldText 留空",
+ "- 修改 → `edit` 工具,oldText 填写要替换的文本",
+ "- **禁止写入敏感信息**(API密钥、令牌等)",
"",
"**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。",
"",
]
-
+
+ return lines
+
+
+def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]:
+ """Build knowledge wiki section. Injects knowledge/index.md when present."""
+ index_path = os.path.join(workspace_dir, "knowledge", "index.md")
+ if not os.path.exists(index_path):
+ return []
+
+ try:
+ with open(index_path, 'r', encoding='utf-8') as f:
+ index_content = f.read().strip()
+ except Exception:
+ return []
+
+ lines = [
+ "## 📚 知识系统",
+ "",
+ "你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。",
+ "",
+ "### 自动写入规则(mandatory)",
+ "",
+ "以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**:",
+ "",
+ "1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/.md`",
+ "2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/.md`",
+ "3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/.md`",
+ "4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/.md`",
+ "",
+ "每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。",
+ "详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。",
+ "",
+ "⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。",
+ "",
+ ]
+
+ if index_content:
+ lines.extend([
+ "### 当前知识索引",
+ "",
+ index_content,
+ "",
+ ])
+
+ lines.extend([
+ "**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。",
+ "",
+ ])
+
return lines
@@ -375,11 +429,12 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"",
"**重要说明 - 文件已自动加载**:",
"",
- "以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
+ "以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:",
"",
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
+ "- ✅ `MEMORY.md`: 已加载 - 长期记忆索引",
"",
"**💬 交流规范**:",
"",
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 68eec912..9ebf0703 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -67,6 +67,12 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
# 创建websites子目录 (for web pages / sites generated by agent)
websites_dir = os.path.join(workspace_dir, "websites")
os.makedirs(websites_dir, exist_ok=True)
+
+ # 创建knowledge子目录 (structured knowledge wiki)
+ knowledge_dir = os.path.join(workspace_dir, "knowledge")
+ os.makedirs(knowledge_dir, exist_ok=True)
+ for sub in ["entities", "concepts", "sources", "analysis"]:
+ os.makedirs(os.path.join(knowledge_dir, sub), exist_ok=True)
# 如果需要,创建模板文件
if create_templates:
@@ -74,6 +80,14 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
_create_template_if_missing(user_path, _get_user_template())
_create_template_if_missing(rule_path, _get_rule_template())
_create_template_if_missing(memory_path, _get_memory_template())
+ _create_template_if_missing(
+ os.path.join(knowledge_dir, "index.md"),
+ _get_knowledge_index_template()
+ )
+ _create_template_if_missing(
+ os.path.join(knowledge_dir, "log.md"),
+ _get_knowledge_log_template()
+ )
# Only create BOOTSTRAP.md for brand new workspaces;
# agent deletes it after completing onboarding
@@ -109,6 +123,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
DEFAULT_AGENT_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_RULE_FILENAME,
+ DEFAULT_MEMORY_FILENAME, # Long-term memory (frozen snapshot)
DEFAULT_BOOTSTRAP_FILENAME, # Only exists when onboarding is incomplete
]
@@ -138,6 +153,10 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
# 跳过空文件或只包含模板占位符的文件
if not content or _is_template_placeholder(content):
continue
+
+ # Truncate MEMORY.md to protect context window (frozen snapshot)
+ if filename == DEFAULT_MEMORY_FILENAME:
+ content = _truncate_memory_content(content)
context_files.append(ContextFile(
path=filename,
@@ -163,6 +182,36 @@ def _create_template_if_missing(filepath: str, template_content: str):
logger.error(f"[Workspace] Failed to create template {filepath}: {e}")
+_MEMORY_MAX_LINES = 200
+_MEMORY_MAX_BYTES = 25000
+
+
+def _truncate_memory_content(content: str) -> str:
+ """Truncate MEMORY.md to keep system prompt manageable.
+
+ Takes the **last** N lines (newest entries are appended at the bottom),
+ subject to 200 lines / 25 KB limits (whichever is hit first).
+ Prepends a hint when truncated so the model knows older content exists.
+ """
+ lines = content.split('\n')
+ truncated = False
+
+ if len(lines) > _MEMORY_MAX_LINES:
+ lines = lines[-_MEMORY_MAX_LINES:]
+ truncated = True
+
+ result = '\n'.join(lines)
+ if len(result.encode('utf-8')) > _MEMORY_MAX_BYTES:
+ while len(result.encode('utf-8')) > _MEMORY_MAX_BYTES and lines:
+ lines.pop(0)
+ truncated = True
+ result = '\n'.join(lines)
+
+ if truncated:
+ result = "...(older entries truncated, use `memory_search` or `memory_get` for full content)\n\n" + result
+ return result
+
+
def _is_template_placeholder(content: str) -> bool:
"""检查内容是否为模板占位符"""
# 常见的占位符模式
@@ -287,39 +336,82 @@ def _get_rule_template() -> str:
这个文件夹是你的家。好好对待它。
+## 工作空间目录结构
+
+```
+~/cow/
+├── AGENT.md # 你的身份和灵魂设定
+├── USER.md # 用户基本信息(静态)
+├── RULE.md # 工作空间规则(本文件)
+├── MEMORY.md # 长期记忆索引(会话启动时自动加载)
+│
+├── memory/ # 每日对话记忆
+│ └── YYYY-MM-DD.md # 当天事件、进展、笔记
+│
+├── knowledge/ # 结构化知识库(持续积累的知识)
+│ ├── index.md # 知识目录索引
+│ ├── log.md # 知识操作日志
+│ ├── entities/ # 实体页面(人物、公司、项目)
+│ ├── concepts/ # 概念页面(技术、方法论)
+│ ├── sources/ # 资料摘要(文章、文件的要点提取)
+│ └── analysis/ # 沉淀的分析和洞见
+│
+├── skills/ # 技能
+├── websites/ # 网页产物
+└── tmp/ # 系统临时文件(自动管理,勿手动存放重要文件)
+```
+
## 记忆系统
你每次会话都是全新的,记忆文件让你保持连续性:
-### 📝 每日记忆:`memory/YYYY-MM-DD.md`
-- 原始的对话日志
-- 记录当天发生的事情
-- 如果 `memory/` 目录不存在,创建它
-
### 🧠 长期记忆:`MEMORY.md`
-- 你精选的记忆,就像人类的长期记忆
-- **仅在主会话中加载**(与用户的直接聊天)
-- **不要在共享上下文中加载**(群聊、与其他人的会话)
-- 这是为了**安全** - 包含不应泄露给陌生人的个人上下文
-- 记录重要事件、想法、决定、观点、经验教训
-- 这是你精选的记忆 - 精华,而不是原始日志
-- 用 `edit` 工具追加新的记忆内容
+- 你精选的记忆索引,每次会话启动时**自动加载**到上下文中
+- 记录核心事实、偏好、决策、重要人物、教训
+- 保持精简(< 200 行),是精华索引而非原始日志
+- 用 `edit` 工具追加或修改
+
+### 📝 每日记忆:`memory/YYYY-MM-DD.md`
+- 当天的事件、进展、笔记
+- 原始对话日志的沉淀
### 📝 写下来 - 不要"记在心里"!
-- **记忆是有限的** - 如果你想记住某事,写入文件
+- **记忆是有限的** - 想记住的事就写入文件
- "记在心里"不会在会话重启后保留,文件才会
- 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md`
- 当你学到教训 → 更新 RULE.md 或相关技能
-- 当你犯错 → 记录下来,这样未来的你不会重复,**文字 > 大脑** 📝
+- 当你犯错 → 记录下来,**文字 > 大脑** 📝
### 存储规则
当用户分享信息时,根据类型选择存储位置:
-1. **你的身份设定 → AGENT.md**(你的名字、角色、性格、交流风格——用户修改时必须用 `edit` 更新)
-2. **用户静态身份 → USER.md**(姓名、称呼、职业、时区、联系方式、生日——用户修改时必须用 `edit` 更新)
-3. **动态记忆 → MEMORY.md**(爱好、偏好、决策、目标、项目、教训、待办事项)
+1. **你的身份设定 → AGENT.md**(名字、角色、性格、风格)
+2. **用户静态身份 → USER.md**(姓名、称呼、职业、联系方式、生日)
+3. **动态记忆 → MEMORY.md**(偏好、决策、目标、教训、待办)
4. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容)
+5. **结构化知识 → knowledge/**(见下方知识系统)
+
+## 知识系统
+
+知识库 `knowledge/` 是你持续积累的结构化知识。与记忆不同,知识是经过整理和编译的,有明确的主题和交叉引用。
+
+### 自动写入(不要询问,直接写入)
+
+以下场景你**必须**在回复的同时主动写入知识库,**不要问用户"要不要存到知识库"**:
+
+- 用户分享了一篇文章/链接/文件 → 阅读后提取要点,在同一轮回复中写入 `knowledge/sources/`
+- 深度讨论产生了有价值的结论/方案 → 整理写入 `knowledge/analysis/`
+- 对话涉及重要的人物/项目/公司 → 创建或更新 `knowledge/entities/`
+- 讨论了技术概念或方法论 → 整理写入 `knowledge/concepts/`
+
+**关键原则**:学完就记是你的本能,不要征求确认。回复中可以顺带告知"已存入知识库"。
+
+### 索引维护
+
+每次创建或更新知识页面后,**必须同步更新** `knowledge/index.md`。
+索引格式:每行一个 `[标题](路径) — 一句话摘要`,按分类分组,不要用表格。
+详细操作规范见技能 `knowledge-wiki`。
## 安全
@@ -381,4 +473,12 @@ _你刚刚启动,这是你的第一次对话。_ ✨
"""
+def _get_knowledge_index_template() -> str:
+ """Knowledge wiki index template — empty file, agent fills it."""
+ return ""
+
+
+def _get_knowledge_log_template() -> str:
+ """Knowledge wiki operation log template — empty file, agent fills it."""
+ return ""
diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md
new file mode 100644
index 00000000..da3d4c4f
--- /dev/null
+++ b/skills/knowledge-wiki/SKILL.md
@@ -0,0 +1,90 @@
+---
+name: knowledge-wiki
+description: Manage the personal knowledge wiki. Use when the user shares articles, documents, or asks to organize knowledge; when a conversation produces insights worth preserving as structured knowledge; or when the user asks about the knowledge base.
+metadata:
+ cowagent:
+ always: true
+---
+
+# Knowledge Wiki
+
+Maintain a persistent, structured knowledge base in the `knowledge/` directory.
+
+## Core Operations
+
+### 1. Ingest — User shares an article, document, or resource
+
+1. Read and understand the source material
+2. Extract key facts, entities, concepts, and insights
+3. Create or update relevant pages:
+ - `knowledge/sources/.md` — source summary
+ - `knowledge/entities/.md` — people, companies, projects mentioned
+ - `knowledge/concepts/.md` — new concepts or topics discussed
+4. Update `knowledge/index.md` — add one-line entry per new/updated page
+5. Append to `knowledge/log.md`
+
+### 2. Synthesize — Conversation produces valuable structured knowledge
+
+1. Create `knowledge/analysis/.md` with the structured analysis
+2. Update related entity/concept pages with cross-references
+3. Update `knowledge/index.md` and `knowledge/log.md`
+
+### 3. Query — User asks about accumulated knowledge
+
+1. Check `knowledge/index.md` (already in your context) for relevant pages
+2. Read specific pages with the `read` tool
+3. Supplement with `memory_search` if needed
+
+## Page Format
+
+```markdown
+# Page Title
+
+Content here. Reference other pages with markdown links:
+[Related Entity](../entities/related-entity.md)
+
+## Key Points
+
+- ...
+
+## Sources
+
+- [Source Title](../sources/source-slug.md)
+```
+
+## Index Format (`knowledge/index.md`)
+
+Flat list, one line per page: `[Title](path) — one-line summary`. No tables, no emoji, no template headers.
+
+```markdown
+# Knowledge Index
+
+## Concepts
+- [Topic Name](concepts/topic-name.md) — one-line description
+
+## Sources
+- [Article Title](sources/article-slug.md) — one-line summary
+
+## Entities
+- [Entity Name](entities/entity-name.md) — one-line description
+
+## Analysis
+- [Analysis Title](analysis/analysis-slug.md) — one-line summary
+```
+
+## Log Format (`knowledge/log.md`)
+
+Append-only, newest at bottom:
+
+```markdown
+## [2026-04-09] ingest | DeepSeek-R1 Deploy Guide
+## [2026-04-09] synthesize | Memory System Design Analysis
+```
+
+## Guidelines
+
+- **File naming**: lowercase kebab-case (e.g. `machine-learning.md`)
+- **One topic per page**: link between pages rather than duplicating
+- **Update, don't duplicate**: if a page exists, update it
+- **Index is mandatory**: always update `knowledge/index.md` after any change
+- **Be concise**: capture essence, not copy entire sources
From 3cd92ccda3e746f7315fe1f8db9a71962041dafa Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Thu, 9 Apr 2026 21:29:53 +0800
Subject: [PATCH 096/399] feat: add port config
---
common/cloud_client.py | 6 +++---
config.py | 1 +
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/common/cloud_client.py b/common/cloud_client.py
index 656c1604..c71b02fe 100644
--- a/common/cloud_client.py
+++ b/common/cloud_client.py
@@ -47,8 +47,8 @@ CREDENTIAL_MAP = {
class CloudClient(LinkAIClient):
- def __init__(self, api_key: str, channel, host: str = ""):
- super().__init__(api_key, host)
+ def __init__(self, api_key: str, channel, host: str = "", port=None):
+ super().__init__(api_key, host, port=port)
self.channel = channel
self.client_type = channel.channel_type
self.channel_mgr = None
@@ -733,7 +733,7 @@ def start(channel, channel_mgr=None):
return
global chat_client
- chat_client = CloudClient(api_key=conf().get("linkai_api_key"), host=conf().get("cloud_host", ""), channel=channel)
+ chat_client = CloudClient(api_key=conf().get("linkai_api_key"), host=conf().get("cloud_host", ""), port=conf().get("cloud_port"), channel=channel)
chat_client.channel_mgr = channel_mgr
chat_client.config = _build_config()
chat_client.start()
diff --git a/config.py b/config.py
index 6edd9c04..2ccd505b 100644
--- a/config.py
+++ b/config.py
@@ -189,6 +189,7 @@ available_setting = {
"linkai_app_code": "",
"linkai_api_base": "https://api.link-ai.tech", # linkAI服务地址
"cloud_host": "client.link-ai.tech",
+ "cloud_port": None,
"cloud_deployment_id": "",
"minimax_api_key": "",
"Minimax_group_id": "",
From 6a737fb7349d7e688d2ea166c8d9da94e40689cc Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Fri, 10 Apr 2026 15:07:23 +0800
Subject: [PATCH 097/399] feat: display thinking content in web console
---
agent/chat/service.py | 11 +-
agent/memory/conversation_store.py | 54 ++++++---
agent/protocol/agent_stream.py | 14 ++-
bridge/agent_event_handler.py | 27 ++---
channel/web/static/css/console.css | 16 ++-
channel/web/static/js/console.js | 182 +++++++++++++++++++++++-----
channel/web/web_channel.py | 12 +-
models/claudeapi/claude_api_bot.py | 17 ++-
models/minimax/minimax_bot.py | 32 ++---
models/modelscope/modelscope_bot.py | 9 ++
10 files changed, 285 insertions(+), 89 deletions(-)
diff --git a/agent/chat/service.py b/agent/chat/service.py
index de7d345a..550063f1 100644
--- a/agent/chat/service.py
+++ b/agent/chat/service.py
@@ -57,7 +57,16 @@ class ChatService:
event_type = event.get("type")
data = event.get("data", {})
- if event_type == "message_update":
+ if event_type == "reasoning_update":
+ delta = data.get("delta", "")
+ if delta:
+ send_chunk_fn({
+ "chunk_type": "reasoning",
+ "delta": delta,
+ "segment_id": state.segment_id,
+ })
+
+ elif event_type == "message_update":
# Incremental text delta
delta = data.get("delta", "")
if delta:
diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py
index a4f15aab..4ab0800b 100644
--- a/agent/memory/conversation_store.py
+++ b/agent/memory/conversation_store.py
@@ -188,8 +188,9 @@ def _group_into_display_turns(
if text:
turns.append({"role": "user", "content": text, "created_at": created_at})
- # Collect all tool_calls and tool_results from the rest of the group
- all_tool_calls: List[Dict[str, Any]] = []
+ # Build an ordered list of steps preserving the original sequence:
+ # thinking → content → tool_call → content → ...
+ steps: List[Dict[str, Any]] = []
tool_results: Dict[str, str] = {}
final_text = ""
final_ts: Optional[int] = None
@@ -198,24 +199,46 @@ def _group_into_display_turns(
if role == "user":
tool_results.update(_extract_tool_results(content))
elif role == "assistant":
- tcs = _extract_tool_calls(content)
- all_tool_calls.extend(tcs)
- t = _extract_display_text(content)
- if t:
- final_text = t
+ # Walk content blocks in order to preserve interleaving
+ if isinstance(content, list):
+ for block in content:
+ if not isinstance(block, dict):
+ continue
+ btype = block.get("type")
+ if btype == "thinking":
+ txt = block.get("thinking", "").strip()
+ if txt:
+ steps.append({"type": "thinking", "content": txt})
+ elif btype == "text":
+ txt = block.get("text", "").strip()
+ if txt:
+ steps.append({"type": "content", "content": txt})
+ final_text = txt
+ elif btype == "tool_use":
+ steps.append({
+ "type": "tool",
+ "id": block.get("id", ""),
+ "name": block.get("name", ""),
+ "arguments": block.get("input", {}),
+ })
+ elif isinstance(content, str) and content.strip():
+ steps.append({"type": "content", "content": content.strip()})
+ final_text = content.strip()
final_ts = created_at
- # Attach tool results to their matching tool_call entries
- for tc in all_tool_calls:
- tc["result"] = tool_results.get(tc.get("id", ""), "")
+ # Attach tool results to tool steps
+ for step in steps:
+ if step["type"] == "tool":
+ step["result"] = tool_results.get(step.get("id", ""), "")
- if final_text or all_tool_calls:
- turns.append({
+ if steps or final_text:
+ turn = {
"role": "assistant",
"content": final_text,
- "tool_calls": all_tool_calls,
+ "steps": steps,
"created_at": final_ts or (user_row[1] if user_row else 0),
- })
+ }
+ turns.append(turn)
return turns
@@ -312,6 +335,9 @@ class ConversationStore:
content = json.loads(raw_content)
except Exception:
content = raw_content
+ # Strip thinking blocks — they are stored for UI display only
+ if role == "assistant" and isinstance(content, list):
+ content = [b for b in content if b.get("type") != "thinking"]
result.append({"role": role, "content": content})
return result
diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py
index 1b250011..45f7d8a5 100644
--- a/agent/protocol/agent_stream.py
+++ b/agent/protocol/agent_stream.py
@@ -527,6 +527,7 @@ class AgentStreamExecutor:
# Streaming response
full_content = ""
+ full_reasoning = ""
tool_calls_buffer = {} # {index: {id, name, arguments}}
gemini_raw_parts = None # Preserve Gemini thoughtSignature for round-trip
stop_reason = None # Track why the stream stopped
@@ -584,10 +585,10 @@ class AgentStreamExecutor:
if finish_reason:
stop_reason = finish_reason
- # Skip reasoning_content (internal thinking from models like GLM-5)
reasoning_delta = delta.get("reasoning_content") or ""
- # if reasoning_delta:
- # logger.debug(f"🧠 [thinking] {reasoning_delta[:100]}...")
+ if reasoning_delta:
+ full_reasoning += reasoning_delta
+ self._emit_event("reasoning_update", {"delta": reasoning_delta})
# Handle text content
content_delta = delta.get("content") or ""
@@ -788,7 +789,12 @@ class AgentStreamExecutor:
# Add assistant message to history (Claude format uses content blocks)
assistant_msg = {"role": "assistant", "content": []}
- # Add text content block if present
+ if full_reasoning:
+ assistant_msg["content"].append({
+ "type": "thinking",
+ "thinking": full_reasoning
+ })
+
if full_content:
assistant_msg["content"].append({
"type": "text",
diff --git a/bridge/agent_event_handler.py b/bridge/agent_event_handler.py
index b04c77b8..50826235 100644
--- a/bridge/agent_event_handler.py
+++ b/bridge/agent_event_handler.py
@@ -26,8 +26,7 @@ class AgentEventHandler:
if context:
self.channel = context.kwargs.get("channel") if hasattr(context, "kwargs") else None
- # Track current thinking for channel output
- self.current_thinking = ""
+ self.current_content = ""
self.turn_number = 0
def handle_event(self, event):
@@ -47,6 +46,8 @@ class AgentEventHandler:
self._handle_message_update(data)
elif event_type == "message_end":
self._handle_message_end(data)
+ elif event_type == "reasoning_update":
+ pass
elif event_type == "tool_execution_start":
self._handle_tool_execution_start(data)
elif event_type == "tool_execution_end":
@@ -59,30 +60,26 @@ class AgentEventHandler:
def _handle_turn_start(self, data):
"""Handle turn start event"""
self.turn_number = data.get("turn", 0)
- self.has_tool_calls_in_turn = False
- self.current_thinking = ""
+ self.current_content = ""
def _handle_message_update(self, data):
- """Handle message update event (streaming text)"""
+ """Handle message update event (streaming content text)"""
delta = data.get("delta", "")
- self.current_thinking += delta
+ self.current_content += delta
def _handle_message_end(self, data):
"""Handle message end event"""
tool_calls = data.get("tool_calls", [])
- # Only send thinking process if followed by tool calls
if tool_calls:
- if self.current_thinking.strip():
- logger.info(f"💭 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
- # Send thinking process to channel
- self._send_to_channel(f"{self.current_thinking.strip()}")
+ if self.current_content.strip():
+ logger.info(f"💭 {self.current_content.strip()[:200]}{'...' if len(self.current_content) > 200 else ''}")
+ self._send_to_channel(self.current_content.strip())
else:
- # No tool calls = final response (logged at agent_stream level)
- if self.current_thinking.strip():
- logger.debug(f"💬 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
+ if self.current_content.strip():
+ logger.debug(f"💬 {self.current_content.strip()[:200]}{'...' if len(self.current_content) > 200 else ''}")
- self.current_thinking = ""
+ self.current_content = ""
def _handle_tool_execution_start(self, data):
"""Handle tool execution start event - logged by agent_stream.py"""
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index ea58f54e..96b0811b 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -146,7 +146,7 @@
font-size: 0.75rem;
line-height: 1.5;
color: #94a3b8;
- max-height: 200px;
+ max-height: 300px;
overflow-y: auto;
}
.dark .agent-thinking-step .thinking-full {
@@ -158,6 +158,20 @@
.agent-thinking-step .thinking-full p:first-child { margin-top: 0; }
.agent-thinking-step .thinking-full p:last-child { margin-bottom: 0; }
+/* Content step - real text output frozen before tool calls */
+.agent-content-step {
+ font-size: 0.875rem;
+ line-height: 1.6;
+ color: inherit;
+ margin-bottom: 0.5rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px dashed rgba(0, 0, 0, 0.06);
+}
+.dark .agent-content-step { border-bottom-color: rgba(255, 255, 255, 0.06); }
+.agent-content-step .agent-content-body p { margin: 0.25em 0; }
+.agent-content-step .agent-content-body p:first-child { margin-top: 0; }
+.agent-content-step .agent-content-body p:last-child { margin-bottom: 0; }
+
/* Tool step - collapsible */
.agent-tool-step .tool-header {
display: flex;
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 24e120be..16c4c6c6 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -815,6 +815,8 @@ function startSSE(requestId, loadingEl, timestamp) {
let mediaEl = null; // .media-content (images & file attachments)
let accumulatedText = '';
let currentToolEl = null;
+ let currentReasoningEl = null; // live reasoning bubble
+ let reasoningText = '';
function ensureBotEl() {
if (botEl) return;
@@ -843,39 +845,61 @@ function startSSE(requestId, loadingEl, timestamp) {
let item;
try { item = JSON.parse(e.data); } catch (_) { return; }
- if (item.type === 'delta') {
+ if (item.type === 'reasoning') {
ensureBotEl();
+ reasoningText += item.content;
+ if (!currentReasoningEl) {
+ currentReasoningEl = document.createElement('div');
+ currentReasoningEl.className = 'agent-step agent-thinking-step';
+ currentReasoningEl.innerHTML = `
+
+
`;
+ stepsEl.appendChild(currentReasoningEl);
+ }
+ // Stream reasoning as a single-line summary (collapsed); full text available on expand
+ const oneLine = reasoningText.trim().replace(/\n+/g, ' ');
+ currentReasoningEl.querySelector('.thinking-summary').textContent =
+ oneLine.length > 80 ? oneLine.substring(0, 80) + '…' : oneLine;
+ currentReasoningEl.querySelector('.thinking-full').innerHTML = renderMarkdown(reasoningText);
+ scrollChatToBottom();
+
+ } else if (item.type === 'delta') {
+ ensureBotEl();
+ if (currentReasoningEl) {
+ if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80)
+ currentReasoningEl.classList.add('no-expand');
+ currentReasoningEl = null;
+ reasoningText = '';
+ }
accumulatedText += item.content;
contentEl.innerHTML = renderMarkdown(accumulatedText);
scrollChatToBottom();
+ } else if (item.type === 'message_end') {
+ // Backend already strips reasoning_content; all deltas are real content.
+ // Freeze accumulated text as visible content before tool execution begins.
+ if (item.has_tool_calls && accumulatedText.trim()) {
+ ensureBotEl();
+ const frozenEl = document.createElement('div');
+ frozenEl.className = 'agent-step agent-content-step';
+ frozenEl.innerHTML = `${renderMarkdown(accumulatedText.trim())}
`;
+ stepsEl.appendChild(frozenEl);
+ accumulatedText = '';
+ contentEl.innerHTML = '';
+ scrollChatToBottom();
+ }
+
} else if (item.type === 'tool_start') {
ensureBotEl();
-
- // Save current thinking as a collapsible step
- if (accumulatedText.trim()) {
- const fullText = accumulatedText.trim();
- const oneLine = fullText.replace(/\n+/g, ' ');
- const needsTruncate = oneLine.length > 80;
- const stepEl = document.createElement('div');
- stepEl.className = 'agent-step agent-thinking-step' + (needsTruncate ? '' : ' no-expand');
- if (needsTruncate) {
- const truncated = oneLine.substring(0, 80) + '…';
- stepEl.innerHTML = `
-
- ${renderMarkdown(fullText)}
`;
- } else {
- stepEl.innerHTML = `
- `;
- }
- stepsEl.appendChild(stepEl);
+ if (currentReasoningEl) {
+ if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80)
+ currentReasoningEl.classList.add('no-expand');
+ currentReasoningEl = null;
+ reasoningText = '';
}
accumulatedText = '';
contentEl.innerHTML = '';
@@ -979,6 +1003,13 @@ function startSSE(requestId, loadingEl, timestamp) {
es.close();
delete activeStreams[requestId];
+ if (currentReasoningEl) {
+ if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80)
+ currentReasoningEl.classList.add('no-expand');
+ currentReasoningEl = null;
+ reasoningText = '';
+ }
+
// item.content may be empty when "done" is only a stream-close signal after media.
const finalText = item.content || accumulatedText;
@@ -1102,17 +1133,106 @@ function renderToolCallsHtml(toolCalls) {
}).join('');
}
-function createBotMessageEl(content, timestamp, requestId, toolCalls) {
+function renderThinkingHtml(text) {
+ if (!text || !text.trim()) return '';
+ const full = text.trim();
+ const oneLine = full.replace(/\n+/g, ' ');
+ if (oneLine.length > 80) {
+ const truncated = oneLine.substring(0, 80) + '…';
+ return `
+
+
+
${renderMarkdown(full)}
+
`;
+ }
+ return `
+
+
+
`;
+}
+
+function renderStepsHtml(steps) {
+ if (!steps || steps.length === 0) return { stepsHtml: '', finalContent: '' };
+
+ // Find the index of the last content step — it becomes the main answer, not a step
+ let lastContentIdx = -1;
+ for (let i = steps.length - 1; i >= 0; i--) {
+ if (steps[i].type === 'content') { lastContentIdx = i; break; }
+ }
+
+ let html = '';
+ let lastContentText = '';
+ for (let i = 0; i < steps.length; i++) {
+ const step = steps[i];
+ if (step.type === 'thinking') {
+ html += renderThinkingHtml(step.content);
+ } else if (step.type === 'content') {
+ if (i === lastContentIdx) {
+ lastContentText = step.content;
+ } else {
+ html += `${renderMarkdown(step.content)}
`;
+ }
+ } else if (step.type === 'tool') {
+ const argsStr = formatToolArgs(step.arguments || {});
+ const resultStr = step.result ? escapeHtml(String(step.result)) : '';
+ html += `
+`;
+ }
+ }
+ return { stepsHtml: html, lastContentText };
+}
+
+function createBotMessageEl(content, timestamp, requestId, msg) {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
if (requestId) el.dataset.requestId = requestId;
- const toolsHtml = renderToolCallsHtml(toolCalls);
+
+ let stepsHtml = '';
+ let displayContent = content;
+
+ if (msg && msg.steps && msg.steps.length > 0) {
+ // New format: ordered steps with interleaved content
+ const result = renderStepsHtml(msg.steps);
+ stepsHtml = result.stepsHtml;
+ // The final content (last text after all steps) is the main answer
+ displayContent = content || result.lastContentText;
+ } else {
+ // Legacy format: separate tool_calls + optional reasoning
+ const toolCalls = msg && msg.tool_calls;
+ const reasoning = msg && msg.reasoning;
+ stepsHtml = renderThinkingHtml(reasoning) + renderToolCallsHtml(toolCalls);
+ }
+
el.innerHTML = `
- ${toolsHtml ? `
${toolsHtml}
` : ''}
-
${renderMarkdown(content)}
+ ${stepsHtml ? `
${stepsHtml}
` : ''}
+
${renderMarkdown(displayContent)}
${formatTime(timestamp)}
@@ -1167,7 +1287,7 @@ function loadHistory(page) {
const ts = new Date(msg.created_at * 1000);
const el = msg.role === 'user'
? createUserMessageEl(msg.content, ts)
- : createBotMessageEl(msg.content || '', ts, null, msg.tool_calls);
+ : createBotMessageEl(msg.content || '', ts, null, msg);
fragment.appendChild(el);
});
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 32b27062..f7c9614a 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -168,7 +168,12 @@ class WebChannel(ChatChannel):
event_type = event.get("type")
data = event.get("data", {})
- if event_type == "message_update":
+ if event_type == "reasoning_update":
+ delta = data.get("delta", "")
+ if delta:
+ q.put({"type": "reasoning", "content": delta})
+
+ elif event_type == "message_update":
delta = data.get("delta", "")
if delta:
q.put({"type": "delta", "content": delta})
@@ -195,6 +200,11 @@ class WebChannel(ChatChannel):
"execution_time": round(exec_time, 2)
})
+ elif event_type == "message_end":
+ tool_calls = data.get("tool_calls", [])
+ if tool_calls:
+ q.put({"type": "message_end", "has_tool_calls": True})
+
elif event_type == "file_to_send":
file_path = data.get("path", "")
file_name = data.get("file_name", os.path.basename(file_path))
diff --git a/models/claudeapi/claude_api_bot.py b/models/claudeapi/claude_api_bot.py
index 5dcf9173..e7fe8710 100644
--- a/models/claudeapi/claude_api_bot.py
+++ b/models/claudeapi/claude_api_bot.py
@@ -429,8 +429,21 @@ class ClaudeAPIBot(Bot, OpenAIImage):
delta = event.get("delta", {})
delta_type = delta.get("type")
- if delta_type == "text_delta":
- # Text content
+ if delta_type == "thinking_delta":
+ thinking_text = delta.get("thinking", "")
+ if thinking_text:
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "assistant",
+ "reasoning_content": thinking_text
+ },
+ "finish_reason": None
+ }]
+ }
+
+ elif delta_type == "text_delta":
content = delta.get("text", "")
yield {
"id": event.get("id", ""),
diff --git a/models/minimax/minimax_bot.py b/models/minimax/minimax_bot.py
index af80e795..63ca789c 100644
--- a/models/minimax/minimax_bot.py
+++ b/models/minimax/minimax_bot.py
@@ -233,11 +233,8 @@ class MinimaxBot(Bot):
logger.debug(f"[MINIMAX] API call: model={model}, tools={len(converted_tools) if converted_tools else 0}, stream={stream}")
- # Check if we should show thinking process
- show_thinking = kwargs.pop("show_thinking", conf().get("minimax_show_thinking", False))
-
if stream:
- return self._handle_stream_response(request_body, show_thinking=show_thinking)
+ return self._handle_stream_response(request_body)
else:
return self._handle_sync_response(request_body)
@@ -466,12 +463,11 @@ class MinimaxBot(Bot):
logger.error(traceback.format_exc())
yield {"error": True, "message": str(e), "status_code": 500}
- def _handle_stream_response(self, request_body, show_thinking=False):
+ def _handle_stream_response(self, request_body):
"""Handle streaming API response
-
+
Args:
request_body: API request parameters
- show_thinking: Whether to show thinking/reasoning process to users
"""
try:
headers = {
@@ -550,19 +546,15 @@ class MinimaxBot(Bot):
current_reasoning[reasoning_index]["text"] += reasoning_text
- # Optionally yield thinking as visible content
- if show_thinking:
- # Yield thinking text as-is (without emoji decoration)
- # The reasoning text will be displayed to users
- yield {
- "choices": [{
- "index": 0,
- "delta": {
- "role": "assistant",
- "content": reasoning_text
- }
- }]
- }
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "assistant",
+ "reasoning_content": reasoning_text
+ }
+ }]
+ }
# Handle text content
if "content" in delta and delta["content"]:
diff --git a/models/modelscope/modelscope_bot.py b/models/modelscope/modelscope_bot.py
index 6d55abce..6e2b767f 100644
--- a/models/modelscope/modelscope_bot.py
+++ b/models/modelscope/modelscope_bot.py
@@ -576,6 +576,15 @@ class ModelScopeBot(Bot):
continue
if delta.get("reasoning_content"):
+ yield {
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "assistant",
+ "reasoning_content": delta["reasoning_content"]
+ }
+ }]
+ }
continue
tool_call_chunks = delta.get("tool_calls")
From 5748ded52c9ec98cd74c84134db26ab33a3548c8 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Fri, 10 Apr 2026 16:06:04 +0800
Subject: [PATCH 098/399] feat(knowledge): change knowledge base to
index-driven self-organizing structure
---
agent/prompt/workspace.py | 30 +++++++++++----------
skills/knowledge-wiki/SKILL.md | 48 +++++++++++++++++-----------------
2 files changed, 40 insertions(+), 38 deletions(-)
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 9ebf0703..99e30a82 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -68,11 +68,8 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
websites_dir = os.path.join(workspace_dir, "websites")
os.makedirs(websites_dir, exist_ok=True)
- # 创建knowledge子目录 (structured knowledge wiki)
knowledge_dir = os.path.join(workspace_dir, "knowledge")
os.makedirs(knowledge_dir, exist_ok=True)
- for sub in ["entities", "concepts", "sources", "analysis"]:
- os.makedirs(os.path.join(knowledge_dir, sub), exist_ok=True)
# 如果需要,创建模板文件
if create_templates:
@@ -349,12 +346,9 @@ def _get_rule_template() -> str:
│ └── YYYY-MM-DD.md # 当天事件、进展、笔记
│
├── knowledge/ # 结构化知识库(持续积累的知识)
-│ ├── index.md # 知识目录索引
+│ ├── index.md # 知识目录索引(必须维护)
│ ├── log.md # 知识操作日志
-│ ├── entities/ # 实体页面(人物、公司、项目)
-│ ├── concepts/ # 概念页面(技术、方法论)
-│ ├── sources/ # 资料摘要(文章、文件的要点提取)
-│ └── analysis/ # 沉淀的分析和洞见
+│ └── <子目录>/ # 按需创建,参考 index.md 已有分类
│
├── skills/ # 技能
├── websites/ # 网页产物
@@ -398,15 +392,23 @@ def _get_rule_template() -> str:
### 自动写入(不要询问,直接写入)
-以下场景你**必须**在回复的同时主动写入知识库,**不要问用户"要不要存到知识库"**:
-
-- 用户分享了一篇文章/链接/文件 → 阅读后提取要点,在同一轮回复中写入 `knowledge/sources/`
-- 深度讨论产生了有价值的结论/方案 → 整理写入 `knowledge/analysis/`
-- 对话涉及重要的人物/项目/公司 → 创建或更新 `knowledge/entities/`
-- 讨论了技术概念或方法论 → 整理写入 `knowledge/concepts/`
+当对话中产生了有沉淀价值的知识——无论是用户分享的资料、讨论的结论、学到的概念、还是重要的决策——你**必须**在回复的同时主动写入知识库,**无需问用户"要不要存到知识库"**。
**关键原则**:学完就记是你的本能,不要征求确认。回复中可以顺带告知"已存入知识库"。
+### 目录组织
+
+子目录结构**不是固定的**,由你根据实际内容自主决定:
+- **首次写入时**:先读 `knowledge/index.md`,如果已有分类则延续;如果为空,根据内容选择合适的目录名
+- **默认建议**:按信息类型组织(例如sources/、concepts/、entities/、analysis/),如果用户有明确的分类偏好(例如按领域 work/、life/、tech/ 等),则按用户要求调整
+- **保持一致性**:同一用户的知识库应保持统一的组织风格
+
+### 交叉引用
+
+知识的核心价值在于**关联**。每个页面都应通过 markdown 链接引用相关页面,构建知识网络:
+- 提到已有页面的概念时,添加 `[概念名](../category/page.md)` 链接
+- 新建页面时,检查是否有已有页面应该反向链接到新页面
+
### 索引维护
每次创建或更新知识页面后,**必须同步更新** `knowledge/index.md`。
diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md
index da3d4c4f..d96cf099 100644
--- a/skills/knowledge-wiki/SKILL.md
+++ b/skills/knowledge-wiki/SKILL.md
@@ -15,18 +15,18 @@ Maintain a persistent, structured knowledge base in the `knowledge/` directory.
### 1. Ingest — User shares an article, document, or resource
1. Read and understand the source material
-2. Extract key facts, entities, concepts, and insights
-3. Create or update relevant pages:
- - `knowledge/sources/.md` — source summary
- - `knowledge/entities/.md` — people, companies, projects mentioned
- - `knowledge/concepts/.md` — new concepts or topics discussed
-4. Update `knowledge/index.md` — add one-line entry per new/updated page
-5. Append to `knowledge/log.md`
+2. Extract key facts, insights, and structured knowledge
+3. Determine the appropriate subdirectory:
+ - Read `knowledge/index.md` to see existing categories
+ - If a matching category exists, follow that structure
+ - If not, create a new subdirectory with a clear name
+4. Create the knowledge page: `knowledge//.md`
+5. Update `knowledge/index.md` and append to `knowledge/log.md`
### 2. Synthesize — Conversation produces valuable structured knowledge
-1. Create `knowledge/analysis/.md` with the structured analysis
-2. Update related entity/concept pages with cross-references
+1. Create a knowledge page under the appropriate category
+2. Update related pages with cross-references
3. Update `knowledge/index.md` and `knowledge/log.md`
### 3. Query — User asks about accumulated knowledge
@@ -40,38 +40,37 @@ Maintain a persistent, structured knowledge base in the `knowledge/` directory.
```markdown
# Page Title
-Content here. Reference other pages with markdown links:
-[Related Entity](../entities/related-entity.md)
+Content here. Cross-reference related pages with markdown links:
+[Related Page](../category/related-page.md)
## Key Points
- ...
-## Sources
+## Related
-- [Source Title](../sources/source-slug.md)
+- [Page A](../category/page-a.md) — how it relates
+- [Page B](../category/page-b.md) — how it relates
```
+Cross-references build a knowledge graph. When creating or updating a page, always link to related pages, and update those pages to link back.
+
## Index Format (`knowledge/index.md`)
-Flat list, one line per page: `[Title](path) — one-line summary`. No tables, no emoji, no template headers.
+Flat list, one line per page: `[Title](path) — one-line summary`. Group by category (matching subdirectories). No tables, no emoji.
```markdown
# Knowledge Index
-## Concepts
-- [Topic Name](concepts/topic-name.md) — one-line description
+## Category A
+- [Page Title](category-a/page-slug.md) — one-line summary
-## Sources
-- [Article Title](sources/article-slug.md) — one-line summary
-
-## Entities
-- [Entity Name](entities/entity-name.md) — one-line description
-
-## Analysis
-- [Analysis Title](analysis/analysis-slug.md) — one-line summary
+## Category B
+- [Page Title](category-b/page-slug.md) — one-line summary
```
+Category names and structure are flexible — follow whatever organization already exists in the index, or create new categories based on the content.
+
## Log Format (`knowledge/log.md`)
Append-only, newest at bottom:
@@ -86,5 +85,6 @@ Append-only, newest at bottom:
- **File naming**: lowercase kebab-case (e.g. `machine-learning.md`)
- **One topic per page**: link between pages rather than duplicating
- **Update, don't duplicate**: if a page exists, update it
+- **Cross-reference**: every page should link to related pages; keep the knowledge graph connected
- **Index is mandatory**: always update `knowledge/index.md` after any change
- **Be concise**: capture essence, not copy entire sources
From 845fadd0aacacbd3a15964eaaeb19b9bb403871c Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Fri, 10 Apr 2026 18:22:54 +0800
Subject: [PATCH 099/399] fix(knowledge): modify knowledge skill
---
agent/prompt/workspace.py | 1 +
skills/knowledge-wiki/SKILL.md | 6 +++---
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 99e30a82..60fdd762 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -408,6 +408,7 @@ def _get_rule_template() -> str:
知识的核心价值在于**关联**。每个页面都应通过 markdown 链接引用相关页面,构建知识网络:
- 提到已有页面的概念时,添加 `[概念名](../category/page.md)` 链接
- 新建页面时,检查是否有已有页面应该反向链接到新页面
+- **只链接已存在的页面**——不要引用尚未创建的页面。如果某个概念值得单独建页,先创建该页面再添加链接
### 索引维护
diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md
index d96cf099..b72a2114 100644
--- a/skills/knowledge-wiki/SKILL.md
+++ b/skills/knowledge-wiki/SKILL.md
@@ -53,7 +53,7 @@ Content here. Cross-reference related pages with markdown links:
- [Page B](../category/page-b.md) — how it relates
```
-Cross-references build a knowledge graph. When creating or updating a page, always link to related pages, and update those pages to link back.
+Cross-references build a knowledge graph. When creating or updating a page, link to related pages and update those pages to link back. **Only link to pages that already exist** — if a concept deserves its own page, create it first, then add the link.
## Index Format (`knowledge/index.md`)
@@ -76,8 +76,8 @@ Category names and structure are flexible — follow whatever organization alrea
Append-only, newest at bottom:
```markdown
-## [2026-04-09] ingest | DeepSeek-R1 Deploy Guide
-## [2026-04-09] synthesize | Memory System Design Analysis
+## [YYYY-MM-DD] ingest | Page Title
+## [YYYY-MM-DD] synthesize | Page Title
```
## Guidelines
From 90d18353534ed8baff2562ad3c3bd9020e3b0ce8 Mon Sep 17 00:00:00 2001
From: 6vision
Date: Sat, 11 Apr 2026 15:45:34 +0800
Subject: [PATCH 100/399] fix: send generic file types (tar.gz, zip, etc.) as
FILE instead of TEXT
Previously, files with extensions not in the known categories (image, document, video, audio) fell through to a fallback that returned ReplyType.TEXT, causing the file to never actually be sent to the user. Now the fallback uses ReplyType.FILE so all file types are delivered.
Made-with: Cursor
---
bridge/agent_bridge.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py
index 84b7aad6..665abd22 100644
--- a/bridge/agent_bridge.py
+++ b/bridge/agent_bridge.py
@@ -498,10 +498,14 @@ class AgentBridge:
reply.text_content = text_response
return reply
- # For other unknown file types, return text with file info
- message = text_response or file_info.get("message", "文件已准备")
- message += f"\n\n[文件: {file_info.get('file_name', file_path)}]"
- return Reply(ReplyType.TEXT, message)
+ # For all other file types (tar.gz, zip, etc.), also use FILE type
+ file_url = f"file://{file_path}"
+ logger.info(f"[AgentBridge] Sending generic file: {file_url}")
+ reply = Reply(ReplyType.FILE, file_url)
+ reply.file_name = file_info.get("file_name", os.path.basename(file_path))
+ if text_response:
+ reply.text_content = text_response
+ return reply
def _migrate_config_to_env(self, workspace_root: str):
"""
From 5a104760108dbf025a01ae43740c95f03479bfe8 Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 11 Apr 2026 16:44:25 +0800
Subject: [PATCH 101/399] feat: add knowledge switch and cli
---
agent/memory/manager.py | 10 ++-
agent/prompt/builder.py | 4 +-
agent/prompt/workspace.py | 24 +++---
agent/skills/manager.py | 4 +
channel/web/static/css/console.css | 3 +-
channel/web/static/js/console.js | 4 +
config.py | 1 +
plugins/cow_cli/cow_cli.py | 132 +++++++++++++++++++++++++++++
8 files changed, 166 insertions(+), 16 deletions(-)
diff --git a/agent/memory/manager.py b/agent/memory/manager.py
index 967270f7..9141bc91 100644
--- a/agent/memory/manager.py
+++ b/agent/memory/manager.py
@@ -318,10 +318,12 @@ class MemoryManager:
await self._sync_file(file_path, "memory", scope, user_id)
# Scan knowledge directory (structured knowledge wiki)
- knowledge_dir = Path(workspace_dir) / "knowledge"
- if knowledge_dir.exists():
- for file_path in knowledge_dir.rglob("*.md"):
- await self._sync_file(file_path, "knowledge", "shared", None)
+ from config import conf
+ if conf().get("knowledge", True):
+ knowledge_dir = Path(workspace_dir) / "knowledge"
+ if knowledge_dir.exists():
+ for file_path in knowledge_dir.rglob("*.md"):
+ await self._sync_file(file_path, "knowledge", "shared", None)
self._dirty = False
diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py
index 11bd3b51..6535abc9 100644
--- a/agent/prompt/builder.py
+++ b/agent/prompt/builder.py
@@ -10,6 +10,7 @@ from typing import List, Dict, Optional, Any
from dataclasses import dataclass
from common.log import logger
+from config import conf
@dataclass
@@ -129,7 +130,8 @@ def build_agent_system_prompt(
sections.extend(_build_memory_section(memory_manager, tools, language))
# 3.5 知识系统(结构化知识库)
- sections.extend(_build_knowledge_section(workspace_dir, language))
+ if conf().get("knowledge", True):
+ sections.extend(_build_knowledge_section(workspace_dir, language))
# 4. 工作空间(工作环境说明)
sections.extend(_build_workspace_section(workspace_dir, language))
diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py
index 60fdd762..797006ce 100644
--- a/agent/prompt/workspace.py
+++ b/agent/prompt/workspace.py
@@ -68,8 +68,11 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
websites_dir = os.path.join(workspace_dir, "websites")
os.makedirs(websites_dir, exist_ok=True)
- knowledge_dir = os.path.join(workspace_dir, "knowledge")
- os.makedirs(knowledge_dir, exist_ok=True)
+ from config import conf
+ knowledge_enabled = conf().get("knowledge", True)
+ if knowledge_enabled:
+ knowledge_dir = os.path.join(workspace_dir, "knowledge")
+ os.makedirs(knowledge_dir, exist_ok=True)
# 如果需要,创建模板文件
if create_templates:
@@ -77,14 +80,15 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
_create_template_if_missing(user_path, _get_user_template())
_create_template_if_missing(rule_path, _get_rule_template())
_create_template_if_missing(memory_path, _get_memory_template())
- _create_template_if_missing(
- os.path.join(knowledge_dir, "index.md"),
- _get_knowledge_index_template()
- )
- _create_template_if_missing(
- os.path.join(knowledge_dir, "log.md"),
- _get_knowledge_log_template()
- )
+ if knowledge_enabled:
+ _create_template_if_missing(
+ os.path.join(knowledge_dir, "index.md"),
+ _get_knowledge_index_template()
+ )
+ _create_template_if_missing(
+ os.path.join(knowledge_dir, "log.md"),
+ _get_knowledge_log_template()
+ )
# Only create BOOTSTRAP.md for brand new workspaces;
# agent deletes it after completing onboarding
diff --git a/agent/skills/manager.py b/agent/skills/manager.py
index c7daf7ad..ddb2a316 100644
--- a/agent/skills/manager.py
+++ b/agent/skills/manager.py
@@ -210,6 +210,10 @@ class SkillManager:
if not include_disabled:
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
+ from config import conf
+ if not conf().get("knowledge", True):
+ entries = [e for e in entries if e.skill.name != "knowledge-wiki"]
+
return entries
def filter_unavailable_skills(
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index 96b0811b..080309de 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -45,7 +45,8 @@
.msg-content h1 { font-size: 1.4em; }
.msg-content h2 { font-size: 1.25em; }
.msg-content h3 { font-size: 1.1em; }
-.msg-content ul, .msg-content ol { margin: 0.5em 0; padding-left: 1.8em; }
+.msg-content ul { margin: 0.5em 0; padding-left: 1.8em; list-style: disc; }
+.msg-content ol { margin: 0.5em 0; padding-left: 1.8em; list-style: decimal; }
.msg-content li { margin: 0.25em 0; }
.msg-content pre {
border-radius: 8px; overflow-x: auto; margin: 0.8em 0;
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 16c4c6c6..a571f6ee 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -496,6 +496,10 @@ const SLASH_COMMANDS = [
{ cmd: '/skill info ', desc: '查看技能详情' },
{ cmd: '/skill enable ', desc: '启用技能' },
{ cmd: '/skill disable ', desc: '禁用技能' },
+ { cmd: '/knowledge', desc: '查看知识库统计' },
+ { cmd: '/knowledge list', desc: '查看知识库文件树' },
+ { cmd: '/knowledge on', desc: '开启知识库' },
+ { cmd: '/knowledge off', desc: '关闭知识库' },
{ cmd: '/config', desc: '查看当前配置' },
{ cmd: '/logs', desc: '查看最近日志' },
{ cmd: '/version', desc: '查看版本' },
diff --git a/config.py b/config.py
index 6edd9c04..ab7da486 100644
--- a/config.py
+++ b/config.py
@@ -199,6 +199,7 @@ available_setting = {
"agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens
"agent_max_context_turns": 30, # Agent模式下最大上下文记忆轮次
"agent_max_steps": 15, # Agent模式下单次运行最大决策步数
+ "knowledge": True, # 是否开启知识库功能
}
diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py
index 5f0da84f..d3a45349 100644
--- a/plugins/cow_cli/cow_cli.py
+++ b/plugins/cow_cli/cow_cli.py
@@ -31,6 +31,7 @@ KNOWN_COMMANDS = {
"help", "version", "status", "logs",
"start", "stop", "restart",
"skill", "context", "config",
+ "knowledge",
"install-browser",
}
@@ -157,6 +158,9 @@ class CowCliPlugin(Plugin):
" /config 查看当前配置",
" /config 查看某项配置",
" /config 修改配置",
+ " /knowledge 查看知识库统计",
+ " /knowledge list 查看知识库文件树",
+ " /knowledge on|off 开启/关闭知识库",
"",
"💡 也可以用 cow 代替 /",
]
@@ -310,6 +314,7 @@ class CowCliPlugin(Plugin):
"agent_max_context_tokens",
"agent_max_context_turns",
"agent_max_steps",
+ "knowledge",
}
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
@@ -851,6 +856,133 @@ class CowCliPlugin(Plugin):
icon = "✅" if enabled else "⬚"
return f"{icon} 技能 '{name}' 已{action}"
+ # ------------------------------------------------------------------
+ # knowledge
+ # ------------------------------------------------------------------
+
+ def _cmd_knowledge(self, args: str, e_context, **_) -> str:
+ sub = args.strip().lower().split(None, 1)[0] if args.strip() else ""
+
+ if sub == "on":
+ return self._knowledge_toggle(True)
+ elif sub == "off":
+ return self._knowledge_toggle(False)
+ elif sub in ("list", "tree"):
+ return self._knowledge_tree()
+ else:
+ return self._knowledge_stats()
+
+ def _knowledge_toggle(self, enabled: bool) -> str:
+ from config import conf
+ import json as _json
+
+ conf()["knowledge"] = enabled
+
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ config_path = os.path.join(project_root, "config.json")
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ file_config = _json.load(f)
+ file_config["knowledge"] = enabled
+ with open(config_path, "w", encoding="utf-8") as f:
+ _json.dump(file_config, f, indent=4, ensure_ascii=False)
+ except Exception as e:
+ return f"⚠️ 内存中已切换,但写入 config.json 失败: {e}"
+
+ status = "开启 ✅" if enabled else "关闭 ❌"
+ note = "知识库将在下次对话中生效" if enabled else "知识库系统已停用,不再注入提示词和索引知识文件"
+ return f"📚 知识库已{status}\n\n{note}"
+
+ def _knowledge_stats(self) -> str:
+ from config import conf
+ from common.utils import expand_path
+ knowledge_dir = os.path.join(
+ expand_path(conf().get("agent_workspace", "~/cow")),
+ "knowledge"
+ )
+ if not os.path.isdir(knowledge_dir):
+ return "📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on"
+
+ enabled = conf().get("knowledge", True)
+ total_files = 0
+ total_bytes = 0
+ cat_count = {}
+
+ for root, dirs, files in os.walk(knowledge_dir):
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
+ rel_root = os.path.relpath(root, knowledge_dir)
+ category = rel_root.split(os.sep)[0] if rel_root != "." else "root"
+ for f in files:
+ if f.endswith(".md") and f not in ("index.md", "log.md"):
+ total_files += 1
+ total_bytes += os.path.getsize(os.path.join(root, f))
+ cat_count[category] = cat_count.get(category, 0) + 1
+
+ status = "✅ 已开启" if enabled else "❌ 已关闭"
+ lines = [
+ "📚 知识库统计",
+ "",
+ f"状态: {status}",
+ f"页面: {total_files} 篇",
+ f"大小: {total_bytes / 1024:.1f} KB",
+ "",
+ ]
+ if cat_count:
+ for cat in sorted(cat_count.keys()):
+ lines.append(f"- {cat}/ ({cat_count[cat]} pages)")
+ lines.append("")
+
+ lines.append(f"路径: {knowledge_dir}")
+ lines.extend([
+ "",
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━",
+ "💡 /knowledge list 查看文件树",
+ "💡 /knowledge on|off 开关知识库",
+ ])
+ return "\n".join(lines)
+
+ def _knowledge_tree(self) -> str:
+ from config import conf
+ from common.utils import expand_path
+ knowledge_dir = os.path.join(
+ expand_path(conf().get("agent_workspace", "~/cow")),
+ "knowledge"
+ )
+ if not os.path.isdir(knowledge_dir):
+ return "📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on"
+
+ tree = ["knowledge/"]
+
+ subdirs = sorted([
+ d for d in os.listdir(knowledge_dir)
+ if os.path.isdir(os.path.join(knowledge_dir, d)) and not d.startswith(".")
+ ])
+
+ for i, subdir in enumerate(subdirs):
+ is_last_dir = (i == len(subdirs) - 1)
+ branch = "└── " if is_last_dir else "├── "
+ subdir_path = os.path.join(knowledge_dir, subdir)
+ md_files = sorted([
+ f for f in os.listdir(subdir_path)
+ if f.endswith(".md") and not f.startswith(".")
+ ])
+ tree.append(f"{branch}{subdir}/ ({len(md_files)})")
+
+ child_prefix = " " if is_last_dir else "│ "
+ max_show = 12
+ for j, fname in enumerate(md_files[:max_show]):
+ is_last_file = (j == len(md_files[:max_show]) - 1) and len(md_files) <= max_show
+ fb = "└── " if is_last_file else "├── "
+ name = fname.replace(".md", "")
+ tree.append(f"{child_prefix}{fb}{name}")
+ if len(md_files) > max_show:
+ tree.append(f"{child_prefix}└── ... +{len(md_files) - max_show} more")
+
+ if not subdirs:
+ tree.append("(空)")
+
+ return "```\n" + "\n".join(tree) + "\n```"
+
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
From c34308cbd4679110b1be98dfd4825af98552c66e Mon Sep 17 00:00:00 2001
From: octo-patch
Date: Sat, 11 Apr 2026 17:03:44 +0800
Subject: [PATCH 102/399] feat: add MiniMax-M2.7-highspeed model and MiniMax
TTS support
- Add MiniMax-M2.7-highspeed constant to const.py and MODEL_LIST
- Update MinimaxBot default model from MiniMax-M2.1 to MiniMax-M2.7
- Add MinimaxVoice TTS provider (voice/minimax/minimax_voice.py)
- Supports speech-2.8-hd and speech-2.8-turbo models
- SSE streaming with hex-decoded audio chunks
- Reuses MINIMAX_API_KEY
- Register MinimaxVoice in voice factory
- Add unit tests (14 tests, all passing)
- Update README with MiniMax-M2.7-highspeed and TTS configuration
---
README.md | 5 +-
common/const.py | 3 +-
models/minimax/minimax_bot.py | 2 +-
tests/test_minimax_provider.py | 184 +++++++++++++++++++++++++++++++++
voice/factory.py | 4 +
voice/minimax/__init__.py | 0
voice/minimax/minimax_voice.py | 106 +++++++++++++++++++
7 files changed, 300 insertions(+), 4 deletions(-)
create mode 100644 tests/test_minimax_provider.py
create mode 100644 voice/minimax/__init__.py
create mode 100644 voice/minimax/minimax_voice.py
diff --git a/README.md b/README.md
index 7478b99d..59609ecc 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,7 @@ cow install-browser
+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图);
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配 group_chat_prefix 和 group_chat_keyword, 支持语音触发画图);
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊)
++ 使用 MiniMax TTS:设置 `"text_to_voice": "minimax"`,并配置 `minimax_api_key`;可通过 `"tts_voice_id"` 指定发音人(如 `English_Graceful_Lady`),`"text_to_voice_model"` 指定模型(如 `speech-2.8-hd`、`speech-2.8-turbo`)
@@ -357,7 +358,7 @@ sudo docker logs -f chatgpt-on-wechat
"minimax_api_key": ""
}
```
- - `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
+ - `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.7-highspeed、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
- `minimax_api_key`:MiniMax 平台的 API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
方式二:OpenAI 兼容方式接入,配置如下:
@@ -370,7 +371,7 @@ sudo docker logs -f chatgpt-on-wechat
}
```
- `bot_type`: OpenAI 兼容方式
-- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek)
+- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.7-highspeed、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek)
- `open_ai_api_base`: MiniMax 平台 API 的 BASE URL
- `open_ai_api_key`: MiniMax 平台的 API-KEY
diff --git a/common/const.py b/common/const.py
index f7e67e52..ecaf5b0f 100644
--- a/common/const.py
+++ b/common/const.py
@@ -93,6 +93,7 @@ QWQ_PLUS = "qwq-plus"
# MiniMax
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
+MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版
@@ -175,7 +176,7 @@ MODEL_LIST = [
QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
# MiniMax
- MiniMax, MINIMAX_M2_7, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
+ MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
# GLM
ZHIPU_AI, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
diff --git a/models/minimax/minimax_bot.py b/models/minimax/minimax_bot.py
index af80e795..0fd45e66 100644
--- a/models/minimax/minimax_bot.py
+++ b/models/minimax/minimax_bot.py
@@ -20,7 +20,7 @@ class MinimaxBot(Bot):
def __init__(self):
super().__init__()
self.args = {
- "model": conf().get("model") or "MiniMax-M2.1",
+ "model": conf().get("model") or "MiniMax-M2.7",
"temperature": conf().get("temperature", 0.3),
"top_p": conf().get("top_p", 0.95),
}
diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py
new file mode 100644
index 00000000..cfad7fd7
--- /dev/null
+++ b/tests/test_minimax_provider.py
@@ -0,0 +1,184 @@
+# encoding:utf-8
+"""
+Unit tests for MiniMax provider additions:
+ - MiniMax-M2.7-highspeed constant in const.py
+ - Default model update in MinimaxBot
+ - MinimaxVoice TTS provider
+"""
+import sys
+import os
+import json
+import unittest
+from unittest.mock import MagicMock, patch, PropertyMock
+
+# Add project root to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+
+class TestMinimaxConst(unittest.TestCase):
+ """Test that MiniMax-M2.7-highspeed is properly registered in const.py."""
+
+ def test_m2_7_highspeed_constant_defined(self):
+ from common import const
+ self.assertTrue(hasattr(const, "MINIMAX_M2_7_HIGHSPEED"))
+ self.assertEqual(const.MINIMAX_M2_7_HIGHSPEED, "MiniMax-M2.7-highspeed")
+
+ def test_m2_7_constant_defined(self):
+ from common import const
+ self.assertEqual(const.MINIMAX_M2_7, "MiniMax-M2.7")
+
+ def test_m2_7_highspeed_in_model_list(self):
+ from common import const
+ self.assertIn("MiniMax-M2.7-highspeed", const.MODEL_LIST)
+
+ def test_m2_7_in_model_list(self):
+ from common import const
+ self.assertIn("MiniMax-M2.7", const.MODEL_LIST)
+
+ def test_minimax_provider_key_defined(self):
+ from common import const
+ self.assertEqual(const.MiniMax, "minimax")
+
+
+class TestMinimaxBotDefaultModel(unittest.TestCase):
+ """Test that MinimaxBot defaults to MiniMax-M2.7."""
+
+ def test_default_model_is_m2_7(self):
+ # Patch conf() to return empty config
+ mock_conf = MagicMock()
+ mock_conf.get = MagicMock(side_effect=lambda key, default=None: default)
+
+ with patch("models.minimax.minimax_bot.conf", return_value=mock_conf):
+ with patch("models.minimax.minimax_bot.SessionManager"):
+ from models.minimax import minimax_bot
+ # Reload to pick up patches
+ import importlib
+ importlib.reload(minimax_bot)
+ with patch("models.minimax.minimax_bot.conf", return_value=mock_conf):
+ bot = minimax_bot.MinimaxBot.__new__(minimax_bot.MinimaxBot)
+ bot.args = {
+ "model": mock_conf.get("model") or "MiniMax-M2.7",
+ }
+ self.assertEqual(bot.args["model"], "MiniMax-M2.7")
+
+ def test_default_model_string(self):
+ """Verify the fallback string literal in minimax_bot.py is MiniMax-M2.7."""
+ import ast
+ bot_path = os.path.join(os.path.dirname(__file__), "..", "models", "minimax", "minimax_bot.py")
+ with open(bot_path) as f:
+ source = f.read()
+ # Verify MiniMax-M2.7 is in the source (not M2.1)
+ self.assertIn("MiniMax-M2.7", source)
+ self.assertNotIn('"MiniMax-M2.1"', source)
+
+
+class TestMinimaxVoice(unittest.TestCase):
+ """Test MinimaxVoice TTS provider."""
+
+ def _make_voice(self, api_key="test-key", api_base="https://api.minimax.io/v1"):
+ mock_conf = MagicMock()
+ def conf_get(key, default=None):
+ return {
+ "minimax_api_key": api_key,
+ "minimax_api_base": api_base,
+ }.get(key, default)
+ mock_conf.get = conf_get
+ with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf):
+ from voice.minimax.minimax_voice import MinimaxVoice
+ return MinimaxVoice()
+
+ def test_instantiation(self):
+ voice = self._make_voice()
+ self.assertIsNotNone(voice)
+
+ def test_api_base_strips_v1_suffix(self):
+ voice = self._make_voice(api_base="https://api.minimax.io/v1")
+ self.assertEqual(voice.api_base, "https://api.minimax.io")
+
+ def test_api_base_no_trailing_slash(self):
+ voice = self._make_voice(api_base="https://api.minimax.io")
+ self.assertEqual(voice.api_base, "https://api.minimax.io")
+
+ def test_voice_to_text_not_supported(self):
+ voice = self._make_voice()
+ with self.assertRaises(NotImplementedError):
+ voice.voiceToText("dummy.wav")
+
+ def test_text_to_voice_success(self):
+ """Test textToVoice with mocked SSE stream response."""
+ import os
+ os.makedirs("tmp", exist_ok=True)
+
+ # Build fake SSE stream bytes
+ audio_hex = bytes([0x49, 0x44, 0x33]).hex() # "ID3" MP3 magic bytes
+ sse_line = f'data: {{"data": {{"audio": "{audio_hex}", "status": 2}}}}\n\n'
+ done_line = "data: [DONE]\n\n"
+ fake_body = (sse_line + done_line).encode("utf-8")
+
+ mock_response = MagicMock()
+ mock_response.raise_for_status = MagicMock()
+ mock_response.iter_lines.return_value = [
+ line.encode("utf-8") for line in (sse_line + done_line).splitlines() if line
+ ]
+
+ mock_conf = MagicMock()
+ def conf_get(key, default=None):
+ return {
+ "minimax_api_key": "test-key",
+ "minimax_api_base": "https://api.minimax.io",
+ }.get(key, default)
+ mock_conf.get = conf_get
+
+ with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf):
+ with patch("voice.minimax.minimax_voice.requests.post", return_value=mock_response):
+ from voice.minimax import minimax_voice
+ import importlib
+ importlib.reload(minimax_voice)
+ with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf):
+ voice = minimax_voice.MinimaxVoice()
+ from bridge.reply import ReplyType
+ reply = voice.textToVoice("Hello, world!")
+ self.assertEqual(reply.type, ReplyType.VOICE)
+ self.assertTrue(reply.content.endswith(".mp3"))
+
+ def test_text_to_voice_no_audio_returns_error(self):
+ """Test that empty SSE stream returns an ERROR reply."""
+ mock_response = MagicMock()
+ mock_response.raise_for_status = MagicMock()
+ mock_response.iter_lines.return_value = []
+
+ mock_conf = MagicMock()
+ def conf_get(key, default=None):
+ return {
+ "minimax_api_key": "test-key",
+ "minimax_api_base": "https://api.minimax.io",
+ }.get(key, default)
+ mock_conf.get = conf_get
+
+ with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf):
+ with patch("voice.minimax.minimax_voice.requests.post", return_value=mock_response):
+ from voice.minimax import minimax_voice
+ import importlib
+ importlib.reload(minimax_voice)
+ with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf):
+ voice = minimax_voice.MinimaxVoice()
+ from bridge.reply import ReplyType
+ reply = voice.textToVoice("Hello")
+ self.assertEqual(reply.type, ReplyType.ERROR)
+
+
+class TestVoiceFactory(unittest.TestCase):
+ """Test that minimax is registered in the voice factory."""
+
+ def test_minimax_voice_factory(self):
+ mock_conf = MagicMock()
+ mock_conf.get = MagicMock(return_value=None)
+ with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf):
+ from voice.factory import create_voice
+ voice = create_voice("minimax")
+ from voice.minimax.minimax_voice import MinimaxVoice
+ self.assertIsInstance(voice, MinimaxVoice)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/voice/factory.py b/voice/factory.py
index 8562f634..abe7ba57 100644
--- a/voice/factory.py
+++ b/voice/factory.py
@@ -54,4 +54,8 @@ def create_voice(voice_type):
from voice.tencent.tencent_voice import TencentVoice
return TencentVoice()
+ elif voice_type == "minimax":
+ from voice.minimax.minimax_voice import MinimaxVoice
+
+ return MinimaxVoice()
raise RuntimeError
diff --git a/voice/minimax/__init__.py b/voice/minimax/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/voice/minimax/minimax_voice.py b/voice/minimax/minimax_voice.py
new file mode 100644
index 00000000..1446a3f1
--- /dev/null
+++ b/voice/minimax/minimax_voice.py
@@ -0,0 +1,106 @@
+# encoding:utf-8
+"""
+MiniMax TTS voice service
+"""
+import datetime
+import random
+import requests
+
+from bridge.reply import Reply, ReplyType
+from common.log import logger
+from config import conf
+from voice.voice import Voice
+
+
+MINIMAX_TTS_VOICES = [
+ "English_Graceful_Lady",
+ "English_Insightful_Speaker",
+ "English_radiant_girl",
+ "English_Persuasive_Man",
+ "English_Lucky_Robot",
+ "English_expressive_narrator",
+ "Chinese_Warm_Woman",
+ "Chinese_Gentle_Man",
+]
+
+
+class MinimaxVoice(Voice):
+ def __init__(self):
+ self.api_key = conf().get("minimax_api_key")
+ self.api_base = conf().get("minimax_api_base") or "https://api.minimax.io"
+ # Strip trailing /v1 if present so we can always append /v1/t2a_v2
+ self.api_base = self.api_base.rstrip("/")
+ if self.api_base.endswith("/v1"):
+ self.api_base = self.api_base[:-3]
+
+ def voiceToText(self, voice_file):
+ """MiniMax does not provide an ASR endpoint; raise NotImplementedError."""
+ raise NotImplementedError("MiniMax voice-to-text is not supported")
+
+ def textToVoice(self, text):
+ try:
+ model = conf().get("text_to_voice_model") or "speech-2.8-hd"
+ voice_id = conf().get("tts_voice_id") or "English_Graceful_Lady"
+
+ url = f"{self.api_base}/v1/t2a_v2"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self.api_key}",
+ }
+ payload = {
+ "model": model,
+ "text": text,
+ "stream": True,
+ "voice_setting": {
+ "voice_id": voice_id,
+ "speed": 1,
+ "vol": 1,
+ "pitch": 0,
+ },
+ "audio_setting": {
+ "sample_rate": 32000,
+ "bitrate": 128000,
+ "format": "mp3",
+ "channel": 1,
+ },
+ }
+
+ response = requests.post(url, headers=headers, json=payload, stream=True, timeout=60)
+ response.raise_for_status()
+
+ # Parse SSE stream and collect hex-encoded audio chunks
+ audio_chunks = []
+ buffer = ""
+ for raw in response.iter_lines():
+ if not raw:
+ continue
+ line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
+ if not line.startswith("data:"):
+ continue
+ json_str = line[5:].strip()
+ if not json_str or json_str == "[DONE]":
+ continue
+ try:
+ import json
+ event_data = json.loads(json_str)
+ audio_hex = event_data.get("data", {}).get("audio")
+ if audio_hex:
+ audio_chunks.append(bytes.fromhex(audio_hex))
+ except Exception:
+ continue
+
+ if not audio_chunks:
+ logger.error("[MINIMAX] TTS returned no audio data")
+ return Reply(ReplyType.ERROR, "语音合成失败,未获取到音频数据")
+
+ audio_data = b"".join(audio_chunks)
+ file_name = "tmp/" + datetime.datetime.now().strftime("%Y%m%d%H%M%S") + str(random.randint(0, 1000)) + ".mp3"
+ with open(file_name, "wb") as f:
+ f.write(audio_data)
+
+ logger.info(f"[MINIMAX] textToVoice success, file={file_name}")
+ return Reply(ReplyType.VOICE, file_name)
+
+ except Exception as e:
+ logger.error(f"[MINIMAX] textToVoice error: {e}")
+ return Reply(ReplyType.ERROR, "遇到了一点小问题,请稍后再试")
From 76e9fef3b2da2be58818280aa78d2248e61566fb Mon Sep 17 00:00:00 2001
From: zhayujie
Date: Sat, 11 Apr 2026 19:02:55 +0800
Subject: [PATCH 103/399] feat(knowledge): add file list and graph in web
channel
---
channel/web/chat.html | 106 +++++++++
channel/web/static/css/console.css | 139 ++++++++++++
channel/web/static/js/console.js | 340 ++++++++++++++++++++++++++++-
channel/web/web_channel.py | 140 ++++++++++++
4 files changed, 723 insertions(+), 2 deletions(-)
diff --git a/channel/web/chat.html b/channel/web/chat.html
index 6d3ba6e3..a291858c 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -110,6 +110,11 @@
Memory
+
+
+
+
+
+
+
+
+
+
+
+
Knowledge
+
Browse and explore your knowledge base
+
+
+
+
+
+ Documents
+
+
+ Graph
+
+
+
+
+
+
+
+
+
+
+
Loading knowledge base...
+
Knowledge pages will be displayed here
+
+
Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.
+
+
+ Start a conversation
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a document to view
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -670,6 +775,7 @@
+