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 ? `
+ +
` : ''} + ${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')}

+
+ QR Code +
+

${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 @@ - -
+ +
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 @@
-
+
' + + slashFiltered.map((c, i) => + `
` + + `${escapeHtml(c.cmd)}` + + `${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' }); +} + +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" }}; + 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 +
+
+
+
+ + +
+
+

Select a document to view

+
+ +
+ + + + + + + + + + @@ -670,6 +775,7 @@ + diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 080309de..21aebdca 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -550,3 +550,142 @@ .dark .slash-menu-item .desc { color: #64748b; } + +/* ============================================================ + Knowledge View + ============================================================ */ + +/* Tab toggle */ +.knowledge-tab { + color: #64748b; +} +.knowledge-tab.active { + background: #fff; + color: #334155; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} +.dark .knowledge-tab.active { + background: rgba(255,255,255,0.1); + color: #e2e8f0; +} + +/* File tree */ +.knowledge-tree-group { + margin-bottom: 2px; +} +.knowledge-tree-group-btn { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: #64748b; + cursor: pointer; + border: none; + background: none; + transition: background 0.15s, color 0.15s; + text-transform: capitalize; +} +.knowledge-tree-group-btn:hover { + background: rgba(0,0,0,0.04); + color: #334155; +} +.dark .knowledge-tree-group-btn:hover { + background: rgba(255,255,255,0.06); + color: #e2e8f0; +} +.knowledge-tree-group-btn i.chevron { + font-size: 8px; + transition: transform 0.15s; +} +.knowledge-tree-group.open .chevron { + transform: rotate(90deg); +} +.knowledge-tree-group-items { + display: none; +} +.knowledge-tree-group.open .knowledge-tree-group-items { + display: block; +} + +.knowledge-tree-file { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px 5px 24px; + border-radius: 6px; + font-size: 12px; + color: #64748b; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + transition: background 0.15s, color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.knowledge-tree-file:hover { + background: rgba(0,0,0,0.04); + color: #334155; +} +.knowledge-tree-file.active { + background: #EDFDF3; + color: #228547; +} +.dark .knowledge-tree-file:hover { + background: rgba(255,255,255,0.06); + color: #e2e8f0; +} +.dark .knowledge-tree-file.active { + background: rgba(74, 190, 110, 0.1); + color: #4ABE6E; +} + +/* Graph legend */ +.knowledge-graph-legend { + position: absolute; + top: 12px; + right: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: #64748b; + z-index: 10; +} +.knowledge-graph-legend-item { + display: flex; + align-items: center; + gap: 4px; +} +.knowledge-graph-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* Graph tooltip */ +.knowledge-graph-tooltip { + position: absolute; + padding: 6px 10px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + color: #334155; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + z-index: 20; +} +.dark .knowledge-graph-tooltip { + background: #1A1A1A; + border-color: rgba(255,255,255,0.1); + color: #e2e8f0; +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index a571f6ee..560d4712 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -15,8 +15,14 @@ const I18N = { console: '控制台', nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控', menu_chat: '对话', menu_config: '配置', menu_skills: '技能', - menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时', + menu_memory: '记忆', menu_knowledge: '知识', menu_channels: '通道', menu_tasks: '定时', menu_logs: '日志', + knowledge_title: '知识库', knowledge_desc: '浏览和探索你的知识库', + knowledge_tab_docs: '文档', knowledge_tab_graph: '图谱', + knowledge_loading: '加载知识库中...', knowledge_loading_desc: '知识页面将显示在这里', + knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面', + knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。', + knowledge_go_chat: '开始对话', welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆
不断成长', example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件', example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能', @@ -70,8 +76,14 @@ const I18N = { console: 'Console', nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor', menu_chat: 'Chat', menu_config: 'Config', menu_skills: 'Skills', - menu_memory: 'Memory', menu_channels: 'Channels', menu_tasks: 'Tasks', + menu_memory: 'Memory', menu_knowledge: 'Knowledge', menu_channels: 'Channels', menu_tasks: 'Tasks', menu_logs: 'Logs', + knowledge_title: 'Knowledge', knowledge_desc: 'Browse and explore your knowledge base', + knowledge_tab_docs: 'Documents', knowledge_tab_graph: 'Graph', + knowledge_loading: 'Loading knowledge base...', knowledge_loading_desc: 'Knowledge pages will be displayed here', + knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet', + knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.', + knowledge_go_chat: 'Start a conversation', welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through
long-term memory.', example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace', example_task_title: 'Skills', example_task_text: 'Show current tools and skills', @@ -182,6 +194,7 @@ const VIEW_META = { config: { group: 'nav_manage', page: 'menu_config' }, skills: { group: 'nav_manage', page: 'menu_skills' }, memory: { group: 'nav_manage', page: 'menu_memory' }, + knowledge:{ group: 'nav_manage', page: 'menu_knowledge' }, channels: { group: 'nav_manage', page: 'menu_channels' }, tasks: { group: 'nav_manage', page: 'menu_tasks' }, logs: { group: 'nav_monitor', page: 'menu_logs' }, @@ -2888,11 +2901,334 @@ navigateTo = function(viewId) { document.getElementById('memory-panel-list').classList.remove('hidden'); loadMemoryView(1); } + else if (viewId === 'knowledge') loadKnowledgeView(); else if (viewId === 'channels') loadChannelsView(); else if (viewId === 'tasks') loadTasksView(); else if (viewId === 'logs') startLogStream(); }; +// ===================================================================== +// Knowledge View +// ===================================================================== +let _knowledgeTreeData = []; +let _knowledgeCurrentFile = null; +let _knowledgeGraphLoaded = false; + +function loadKnowledgeView() { + // Reset to docs tab + switchKnowledgeTab('docs'); + _knowledgeGraphLoaded = false; + _knowledgeCurrentFile = null; + + fetch('/api/knowledge/list').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + + const emptyEl = document.getElementById('knowledge-empty'); + const docsPanel = document.getElementById('knowledge-panel-docs'); + const statsEl = document.getElementById('knowledge-stats'); + + const tree = data.tree || []; + _knowledgeTreeData = tree; + const stats = data.stats || {}; + const totalPages = stats.pages || 0; + const sizeStr = stats.size < 1024 ? stats.size + ' B' : (stats.size / 1024).toFixed(1) + ' KB'; + + statsEl.textContent = totalPages + ' pages · ' + sizeStr; + + if (totalPages === 0) { + emptyEl.querySelector('p').textContent = t('knowledge_empty_hint'); + const guideEl = document.getElementById('knowledge-empty-guide'); + if (guideEl) guideEl.classList.remove('hidden'); + emptyEl.classList.remove('hidden'); + docsPanel.classList.add('hidden'); + return; + } + emptyEl.classList.add('hidden'); + docsPanel.classList.remove('hidden'); + + renderKnowledgeTree(tree); + + // Auto-select the first file (desktop only) + if (window.innerWidth >= 768) { + const firstGroup = tree.find(g => g.files && g.files.length > 0); + if (firstGroup) { + const firstFile = firstGroup.files[0]; + openKnowledgeFile(firstGroup.dir + '/' + firstFile.name, firstFile.title); + } + } else { + document.getElementById('knowledge-content-placeholder').classList.add('hidden'); + document.getElementById('knowledge-content-viewer').classList.add('hidden'); + } + }).catch(() => {}); +} + +function renderKnowledgeTree(tree, filter) { + const container = document.getElementById('knowledge-tree'); + container.innerHTML = ''; + const lowerFilter = (filter || '').toLowerCase(); + + tree.forEach(group => { + const files = group.files.filter(f => + !lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter) + ); + if (files.length === 0 && lowerFilter) return; + + const div = document.createElement('div'); + div.className = 'knowledge-tree-group open'; + + const btn = document.createElement('button'); + btn.className = 'knowledge-tree-group-btn'; + btn.innerHTML = `${escapeHtml(group.dir)}${files.length}`; + btn.onclick = () => div.classList.toggle('open'); + div.appendChild(btn); + + const items = document.createElement('div'); + items.className = 'knowledge-tree-group-items'; + files.forEach(f => { + const fbtn = document.createElement('button'); + const fpath = group.dir + '/' + f.name; + fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : ''); + fbtn.dataset.path = fpath; + fbtn.innerHTML = `${escapeHtml(f.title)}`; + fbtn.onclick = () => openKnowledgeFile(fpath, f.title); + items.appendChild(fbtn); + }); + div.appendChild(items); + container.appendChild(div); + }); +} + +function filterKnowledgeTree(query) { + renderKnowledgeTree(_knowledgeTreeData, query); +} + +function openKnowledgeFile(path, title) { + _knowledgeCurrentFile = path; + // Update active state in tree via data-path + document.querySelectorAll('.knowledge-tree-file').forEach(el => { + el.classList.toggle('active', el.dataset.path === path); + }); + + // Immediately hide placeholder + document.getElementById('knowledge-content-placeholder').classList.add('hidden'); + + fetch(`/api/knowledge/read?path=${encodeURIComponent(path)}`).then(r => r.json()).then(data => { + if (data.status !== 'success') return; + const viewer = document.getElementById('knowledge-content-viewer'); + document.getElementById('knowledge-viewer-title').textContent = title; + document.getElementById('knowledge-viewer-path').textContent = path; + document.getElementById('knowledge-viewer-body').innerHTML = renderMarkdown(data.content || ''); + viewer.classList.remove('hidden'); + applyHighlighting(viewer); + + // Mobile: hide sidebar, show content + if (window.innerWidth < 768) { + document.getElementById('knowledge-sidebar').classList.add('hidden'); + } + }).catch(() => {}); +} + +function knowledgeMobileBack() { + document.getElementById('knowledge-sidebar').classList.remove('hidden'); + document.getElementById('knowledge-content-viewer').classList.add('hidden'); +} + +function switchKnowledgeTab(tab) { + document.querySelectorAll('.knowledge-tab').forEach(el => el.classList.remove('active')); + document.getElementById('knowledge-tab-' + tab).classList.add('active'); + + const docsPanel = document.getElementById('knowledge-panel-docs'); + const graphPanel = document.getElementById('knowledge-panel-graph'); + + if (tab === 'docs') { + docsPanel.classList.remove('hidden'); + graphPanel.classList.add('hidden'); + } else { + docsPanel.classList.add('hidden'); + graphPanel.classList.remove('hidden'); + if (!_knowledgeGraphLoaded) { + loadKnowledgeGraph(); + } + } +} + +function loadKnowledgeGraph() { + _knowledgeGraphLoaded = true; + const container = document.getElementById('knowledge-graph-container'); + container.innerHTML = ''; + + fetch('/api/knowledge/graph').then(r => r.json()).then(data => { + const nodes = data.nodes || []; + const links = data.links || []; + if (nodes.length === 0) { + container.innerHTML = `

${t('knowledge_empty_hint')}

`; + return; + } + renderKnowledgeGraph(container, nodes, links); + }).catch(() => { + container.innerHTML = '
Failed to load graph
'; + }); +} + +function renderKnowledgeGraph(container, nodes, links) { + const width = container.clientWidth; + const height = container.clientHeight || 600; + + const categories = [...new Set(nodes.map(n => n.category))]; + const colorScale = d3.scaleOrdinal(d3.schemeTableau10).domain(categories); + + // Connection count for sizing + const connCount = {}; + nodes.forEach(n => connCount[n.id] = 0); + links.forEach(l => { + connCount[l.source] = (connCount[l.source] || 0) + 1; + connCount[l.target] = (connCount[l.target] || 0) + 1; + }); + + const svg = d3.select(container) + .append('svg') + .attr('width', width) + .attr('height', height); + + const g = svg.append('g'); + + // Zoom with adaptive label visibility + let currentZoomScale = 1; + const zoom = d3.zoom() + .scaleExtent([0.2, 5]) + .on('zoom', (event) => { + g.attr('transform', event.transform); + currentZoomScale = event.transform.k; + updateLabelVisibility(); + }); + svg.call(zoom); + + function updateLabelVisibility() { + if (!label) return; + if (currentZoomScale < 0.8) { + label.attr('opacity', 0); + } else { + const baseFontSize = Math.min(12, 10 / Math.max(currentZoomScale * 0.7, 0.5)); + label.attr('opacity', 1).attr('font-size', baseFontSize); + } + } + + const simulation = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id(d => d.id).distance(90)) + .force('charge', d3.forceManyBody().strength(-180)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('x', d3.forceX(width / 2).strength(0.06)) + .force('y', d3.forceY(height / 2).strength(0.06)) + .force('collision', d3.forceCollide().radius(d => getNodeRadius(d) + 30)); + + function getNodeRadius(d) { + return Math.max(5, Math.min(16, 5 + (connCount[d.id] || 0) * 2)); + } + + const link = g.append('g') + .selectAll('line') + .data(links) + .join('line') + .attr('stroke', '#94a3b8') + .attr('stroke-opacity', 0.3) + .attr('stroke-width', 1); + + const node = g.append('g') + .selectAll('circle') + .data(nodes) + .join('circle') + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => colorScale(d.category)) + .attr('stroke', '#fff') + .attr('stroke-width', 1.5) + .style('cursor', 'pointer') + .call(d3.drag() + .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) + .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) + .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }) + ); + + const label = g.append('g') + .selectAll('text') + .data(nodes) + .join('text') + .text(d => d.label.length > 15 ? d.label.slice(0, 14) + '…' : d.label) + .attr('font-size', 9) + .attr('dx', d => getNodeRadius(d) + 4) + .attr('dy', 3) + .attr('fill', '#64748b') + .style('pointer-events', 'none'); + + // Tooltip + const tooltip = document.createElement('div'); + tooltip.className = 'knowledge-graph-tooltip'; + container.style.position = 'relative'; + container.appendChild(tooltip); + + node.on('mouseover', (event, d) => { + tooltip.textContent = d.label + ' (' + d.category + ')'; + tooltip.style.opacity = '1'; + tooltip.style.left = (event.offsetX + 12) + 'px'; + tooltip.style.top = (event.offsetY - 8) + 'px'; + // Highlight connections + link.attr('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 0.8 : 0.1); + node.attr('opacity', n => n.id === d.id || links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.2); + label.attr('opacity', n => n.id === d.id || links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.1); + }).on('mousemove', (event) => { + tooltip.style.left = (event.offsetX + 12) + 'px'; + tooltip.style.top = (event.offsetY - 8) + 'px'; + }).on('mouseout', () => { + tooltip.style.opacity = '0'; + link.attr('stroke-opacity', 0.3); + node.attr('opacity', 1); + label.attr('opacity', 1); + }).on('click', (event, d) => { + // Switch to docs tab and open the file + switchKnowledgeTab('docs'); + openKnowledgeFile(d.id, d.label); + }); + + simulation.on('tick', () => { + link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) + .attr('x2', d => d.target.x).attr('y2', d => d.target.y); + node.attr('cx', d => d.x).attr('cy', d => d.y); + label.attr('x', d => d.x).attr('y', d => d.y); + }); + + // Auto fit-to-view when simulation settles + simulation.on('end', () => { + const pad = 16; + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + nodes.forEach(n => { + if (n.x < x0) x0 = n.x; + if (n.y < y0) y0 = n.y; + if (n.x > x1) x1 = n.x; + if (n.y > y1) y1 = n.y; + }); + const bw = x1 - x0 + pad * 2; + const bh = y1 - y0 + pad * 2; + if (bw > 0 && bh > 0) { + const scale = Math.min(width / bw, height / bh, 4); + const tx = width / 2 - (x0 + x1) / 2 * scale; + const ty = height / 2 - (y0 + y1) / 2 * scale; + svg.transition().duration(500).call( + zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale) + ); + } + }); + + // Legend + const legendDiv = document.createElement('div'); + legendDiv.className = 'knowledge-graph-legend'; + categories.forEach(cat => { + const item = document.createElement('span'); + item.className = 'knowledge-graph-legend-item'; + item.innerHTML = `${escapeHtml(cat)}`; + legendDiv.appendChild(item); + }); + container.appendChild(legendDiv); +} + // ===================================================================== // Initialization // ===================================================================== diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index f7c9614a..f58238c4 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -445,6 +445,9 @@ class WebChannel(ChatChannel): '/api/skills', 'SkillsHandler', '/api/memory', 'MemoryHandler', '/api/memory/content', 'MemoryContentHandler', + '/api/knowledge/list', 'KnowledgeListHandler', + '/api/knowledge/read', 'KnowledgeReadHandler', + '/api/knowledge/graph', 'KnowledgeGraphHandler', '/api/scheduler', 'SchedulerHandler', '/api/history', 'HistoryHandler', '/api/logs', 'LogsHandler', @@ -1531,6 +1534,143 @@ class AssetsHandler: raise web.notfound() +class KnowledgeListHandler: + """Return the knowledge directory tree as JSON.""" + + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + workspace_root = _get_workspace_root() + knowledge_dir = os.path.join(workspace_root, "knowledge") + if not os.path.isdir(knowledge_dir): + return json.dumps({"status": "success", "tree": [], "stats": {"pages": 0, "size": 0}}) + + tree = [] + total_files = 0 + total_bytes = 0 + for name in sorted(os.listdir(knowledge_dir)): + full = os.path.join(knowledge_dir, name) + if not os.path.isdir(full) or name.startswith("."): + continue + files = [] + for fname in sorted(os.listdir(full)): + if fname.endswith(".md") and not fname.startswith("."): + fpath = os.path.join(full, fname) + size = os.path.getsize(fpath) + total_files += 1 + total_bytes += size + title = fname.replace(".md", "") + try: + with open(fpath, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line.startswith("# "): + title = first_line[2:].strip() + except Exception: + pass + files.append({"name": fname, "title": title, "size": size}) + tree.append({"dir": name, "files": files}) + + return json.dumps({ + "status": "success", + "tree": tree, + "stats": {"pages": total_files, "size": total_bytes}, + "enabled": conf().get("knowledge", True), + }, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Knowledge list error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class KnowledgeReadHandler: + """Read a single knowledge markdown file.""" + + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + params = web.input(path='') + rel_path = params.path + if not rel_path or ".." in rel_path: + return json.dumps({"status": "error", "message": "invalid path"}) + + workspace_root = _get_workspace_root() + full_path = os.path.join(workspace_root, "knowledge", rel_path) + full_path = os.path.normpath(full_path) + knowledge_dir = os.path.normpath(os.path.join(workspace_root, "knowledge")) + if not full_path.startswith(knowledge_dir): + return json.dumps({"status": "error", "message": "path outside knowledge dir"}) + + if not os.path.isfile(full_path): + return json.dumps({"status": "error", "message": "file not found"}) + + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + return json.dumps({"status": "success", "content": content, "path": rel_path}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Knowledge read error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class KnowledgeGraphHandler: + """Return nodes and links for the knowledge graph visualization.""" + + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + import re + from pathlib import Path + + workspace_root = _get_workspace_root() + knowledge_dir = Path(workspace_root) / "knowledge" + if not knowledge_dir.is_dir(): + return json.dumps({"nodes": [], "links": []}) + + nodes = {} + links = [] + link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)') + + for md_file in knowledge_dir.rglob("*.md"): + rel = str(md_file.relative_to(knowledge_dir)) + if rel in ("index.md", "log.md"): + continue + parts = rel.split("/") + category = parts[0] if len(parts) > 1 else "root" + title = md_file.stem.replace("-", " ").title() + try: + content = md_file.read_text(encoding="utf-8") + first_line = content.strip().split("\n")[0] + if first_line.startswith("# "): + title = first_line[2:].strip() + except Exception: + pass + nodes[rel] = {"id": rel, "label": title, "category": category} + try: + content = md_file.read_text(encoding="utf-8") + for _, link_target in link_re.findall(content): + resolved = (md_file.parent / link_target).resolve() + try: + target_rel = str(resolved.relative_to(knowledge_dir)) + except ValueError: + continue + if target_rel != rel: + links.append({"source": rel, "target": target_rel}) + except Exception: + pass + + valid_ids = set(nodes.keys()) + links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids] + seen = set() + deduped = [] + for l in links: + key = tuple(sorted([l["source"], l["target"]])) + if key not in seen: + seen.add(key) + deduped.append(l) + + return json.dumps({ + "nodes": list(nodes.values()), + "links": deduped, + }, ensure_ascii=False) + + class VersionHandler: def GET(self): web.header('Content-Type', 'application/json; charset=utf-8') From 26693acc3f8d299983bfbb63694ca3fe4b8ba323 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 11 Apr 2026 19:46:11 +0800 Subject: [PATCH 104/399] feat(vision): prioritize main model for image recognition with multi-provider fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add call_vision method to all bot implementations (DashScope, Claude, Gemini, ZhipuAI, MiniMax, Doubao, Moonshot, OpenAICompatibleBot) using each vendor's native multimodal API format - Remove call_with_tools/call_vision from Bot base class to fix MRO shadowing issue with OpenAICompatibleBot mixin - Refactor vision tool provider resolution: MainModel → other configured models (auto-discovered) → OpenAI → LinkAI, with automatic fallback - Return actual model name used in call_vision responses - Sync config.json API keys to .env bidirectionally on startup - Fix bot instance cache to detect bot_type/use_linkai config changes - Add SSE reconnection support for web console - Preserve image path hints in Gemini text for correct vision tool calls - Update docs/tools/vision.mdx --- agent/tools/vision/vision.py | 258 +++++++++++++++++----- bridge/agent_bridge.py | 84 ++++---- bridge/agent_initializer.py | 58 ++--- channel/web/static/js/console.js | 336 ++++++++++++++++------------- channel/web/web_channel.py | 15 +- docs/en/tools/vision.mdx | 72 +++++++ docs/ja/tools/vision.mdx | 72 +++++++ docs/tools/vision.mdx | 52 ++++- models/bot.py | 17 +- models/claudeapi/claude_api_bot.py | 76 +++++++ models/dashscope/dashscope_bot.py | 52 +++++ models/doubao/doubao_bot.py | 94 ++++++-- models/gemini/google_gemini_bot.py | 60 +++++- models/minimax/minimax_bot.py | 107 ++++++--- models/moonshot/moonshot_bot.py | 94 ++++++-- models/openai_compatible_bot.py | 50 +++++ models/zhipuai/zhipuai_bot.py | 35 +++ 17 files changed, 1173 insertions(+), 359 deletions(-) create mode 100644 docs/en/tools/vision.mdx create mode 100644 docs/ja/tools/vision.mdx diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 3f8ad308..8a2756c2 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -1,7 +1,13 @@ """ -Vision tool - Analyze images using OpenAI-compatible Vision API. +Vision tool - Analyze images using Vision API. Supports local files (auto base64-encoded) and HTTP URLs. -Providers are tried in priority order with automatic fallback on failure. + +Provider priority (default): + 1. Main model via bot.call_vision — zero extra cost + 2. Other models whose API key is configured — auto-discovered + 3. OpenAI / LinkAI raw HTTP — reliable fallback + When use_linkai=true, LinkAI is promoted to #1. + When tool.vision.model is set, that model is used exclusively first. """ import base64 @@ -14,10 +20,11 @@ from typing import Any, Dict, List, Optional import requests from agent.tools.base_tool import BaseTool, ToolResult +from common import const from common.log import logger from config import conf -DEFAULT_MODEL = "gpt-4.1-mini" +DEFAULT_MODEL = const.GPT_41_MINI DEFAULT_TIMEOUT = 60 MAX_TOKENS = 1000 COMPRESS_THRESHOLD = 1_048_576 # 1 MB @@ -30,8 +37,20 @@ SUPPORTED_EXTENSIONS = { "webp": "image/webp", } +_MAIN_MODEL_PROVIDER_NAME = "MainModel" -OPENAI_COMPATIBLE_BOT_TYPES = {"openai", "openAI", "chatGPT"} +# (config_key_for_api_key, bot_type, default_vision_model, provider_display_name) +# Auto-discovered as fallback vision providers when their API key is configured. +# OpenAI and LinkAI are handled separately (raw HTTP providers), so not listed here. +_DISCOVERABLE_MODELS = [ + ("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_5, "Moonshot"), + ("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"), + ("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN36_PLUS, "DashScope"), + ("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"), + ("gemini_api_key", const.GEMINI, const.GEMINI_31_FLASH_LITE_PRE, "Gemini"), + ("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"), + ("minimax_api_key", const.MiniMax, const.MINIMAX_M2_7, "MiniMax"), +] @dataclass @@ -42,6 +61,8 @@ class VisionProvider: api_base: str extra_headers: dict = field(default_factory=dict) model_override: Optional[str] = None + use_bot: bool = False # When True, call via bot.call_vision instead of raw HTTP + fallback_bot: Any = None # Bot instance for non-main-model providers class VisionAPIError(Exception): @@ -50,13 +71,12 @@ class VisionAPIError(Exception): class Vision(BaseTool): - """Analyze images using OpenAI-compatible Vision API""" + """Analyze images using Vision API""" name: str = "vision" description: str = ( "Analyze a local image or image URL (jpg/jpeg/png) using Vision API. " "Can describe content, extract text, identify objects, colors, etc. " - "Requires OPENAI_API_KEY or LINKAI_API_KEY." ) params: dict = { @@ -70,13 +90,6 @@ class Vision(BaseTool): "type": "string", "description": "Question to ask about the image", }, - "model": { - "type": "string", - "description": ( - f"Vision model to use (default: {DEFAULT_MODEL}). " - "Options: gpt-4.1-mini, gpt-4.1, gpt-4o-mini, gpt-4o" - ), - }, }, "required": ["image", "question"], } @@ -86,15 +99,11 @@ class Vision(BaseTool): @staticmethod def is_available() -> bool: - return bool( - conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY") - or conf().get("linkai_api_key") or os.environ.get("LINKAI_API_KEY") - ) + return True def execute(self, args: Dict[str, Any]) -> ToolResult: image = args.get("image", "").strip() question = args.get("question", "").strip() - model = args.get("model", DEFAULT_MODEL).strip() or DEFAULT_MODEL if not image: return ToolResult.fail("Error: 'image' parameter is required") @@ -104,11 +113,12 @@ class Vision(BaseTool): 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" - " 1. OPENAI_API_KEY (preferred): env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n" - " 2. LINKAI_API_KEY (fallback): env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")\n\n" - "Get your key at: https://platform.openai.com/api-keys or https://link-ai.tech" + "Error: No model available for Vision.\n" + "The main model does not support vision and no other API keys are configured.\n" + "Options:\n" + " 1. Switch to a multimodal model (e.g. qwen3.6-plus, claude-sonnet-4-6, gemini-2.0-flash)\n" + " 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n" + " 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")" ) try: @@ -116,7 +126,7 @@ class Vision(BaseTool): except Exception as e: return ToolResult.fail(f"Error: {e}") - return self._call_with_fallback(providers, model, question, image_content) + return self._call_with_fallback(providers, DEFAULT_MODEL, question, image_content) def _call_with_fallback(self, providers: List[VisionProvider], model: str, question: str, image_content: dict) -> ToolResult: @@ -125,9 +135,14 @@ class Vision(BaseTool): 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) + logger.info(f"[Vision] Trying provider '{provider.name}' " + f"with model '{use_model}' ({i + 1}/{len(providers)})") + if provider.use_bot: + result = self._call_via_bot(use_model, question, image_content, provider) + else: + result = self._call_api(provider, use_model, question, image_content) + logger.info(f"[Vision] ✅ Success via {provider.name} (model={use_model})") + return result except VisionAPIError as e: errors.append(f"[{provider.name}/{use_model}] {e}") logger.warning(f"[Vision] Provider '{provider.name}' failed: {e}") @@ -148,35 +163,113 @@ class Vision(BaseTool): 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. + + Priority: + - use_linkai=true → [LinkAI, MainModel, OtherModels…, OpenAI] + - default → [MainModel, OtherModels…, OpenAI, LinkAI] + + "OtherModels" are auto-discovered from configured API keys. + The main model's bot_type is excluded from OtherModels to avoid + duplicating the MainModel provider. """ + use_linkai = conf().get("use_linkai", False) and conf().get("linkai_api_key") providers: List[VisionProvider] = [] - for builder in self._PROVIDER_BUILDERS: - provider = builder(self) - if provider: - providers.append(provider) + + if use_linkai: + self._append_provider(providers, self._build_linkai_provider) + self._append_provider(providers, self._build_main_model_provider) + self._append_other_model_providers(providers) + self._append_provider(providers, self._build_openai_provider) + else: + self._append_provider(providers, self._build_main_model_provider) + self._append_other_model_providers(providers) + self._append_provider(providers, self._build_openai_provider) + self._append_provider(providers, self._build_linkai_provider) + return providers - def _build_custom_model_provider(self) -> Optional[VisionProvider]: + @staticmethod + def _append_provider(providers: List[VisionProvider], builder) -> None: + p = builder() + if p: + providers.append(p) + + def _append_other_model_providers(self, providers: List[VisionProvider]) -> None: """ - 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. + Auto-discover other models whose API key is configured. + Skip the main model's own bot_type (already covered by MainModel provider). + Skip bot_types that already have a provider in the list (e.g. OpenAI). """ - bot_type = conf().get("bot_type", "") - if bot_type not in OPENAI_COMPATIBLE_BOT_TYPES: + # Determine main model's bot_type so we can skip it + main_bot_type = None + if self.model and hasattr(self.model, '_resolve_bot_type'): + main_bot_type = self.model._resolve_bot_type(conf().get("model", "")) + + existing_names = {p.name for p in providers} + + for config_key, bot_type, default_model, display_name in _DISCOVERABLE_MODELS: + if display_name in existing_names: + continue + if bot_type == main_bot_type: + continue + api_key = conf().get(config_key, "") + if not api_key or not api_key.strip(): + continue + + # Create a bot instance and check if it supports call_vision + try: + from models.bot_factory import create_bot + bot = create_bot(bot_type) + if not hasattr(bot, 'call_vision'): + continue + except Exception: + continue + + providers.append(VisionProvider( + name=display_name, + api_key="", + api_base="", + model_override=default_model, + use_bot=True, + fallback_bot=bot, + )) + + def _resolve_vision_model(self) -> Optional[str]: + """ + Determine which model to use for vision. + + 1. User explicit config: tool.vision.model in config.json + 2. Fallback to the main configured model name + """ + tool_conf = conf().get("tool", {}) + user_vision_model = tool_conf.get("vision", {}).get("model") if isinstance(tool_conf, dict) else None + if user_vision_model: + return user_vision_model + model_name = conf().get("model", "") + return model_name or None + + def _build_main_model_provider(self) -> Optional[VisionProvider]: + """ + Use the vendor's own model for vision via bot.call_vision. + Only available when the bot class has call_vision. + """ + if not (self.model and hasattr(self.model, 'bot')): return None - custom_model = conf().get("model", "") - if not custom_model or custom_model == DEFAULT_MODEL: + try: + bot = self.model.bot + if not hasattr(bot, 'call_vision'): + return None + except Exception: return None - 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" + + vision_model = self._resolve_vision_model() + return VisionProvider( - name="CustomModel", api_key=api_key, api_base=self._ensure_v1(api_base), - model_override=custom_model, + name=_MAIN_MODEL_PROVIDER_NAME, + api_key="", + api_base="", + model_override=vision_model, + use_bot=True, ) def _build_openai_provider(self) -> Optional[VisionProvider]: @@ -200,7 +293,54 @@ class Vision(BaseTool): return VisionProvider(name="LinkAI", api_key=api_key, api_base=self._ensure_v1(api_base), extra_headers=extra) - _PROVIDER_BUILDERS = [_build_custom_model_provider, _build_openai_provider, _build_linkai_provider] + def _call_via_bot(self, model: str, question: str, image_content: dict, + provider: Optional[VisionProvider] = None) -> ToolResult: + """ + Call a model's call_vision with vendor-native API format. + Uses the provider's _fallback_bot if set, otherwise the main model bot. + Raises VisionAPIError on failure so fallback can proceed. + """ + try: + bot = (provider and provider.fallback_bot) or self.model.bot + except Exception as e: + raise VisionAPIError(f"Cannot access bot: {e}") + + # Extract the raw image URL from the OpenAI-format image_content block + image_url = image_content.get("image_url", {}).get("url", "") + if not image_url: + raise VisionAPIError("No image URL in content block") + + try: + response = bot.call_vision( + image_url=image_url, + question=question, + model=model, + max_tokens=MAX_TOKENS, + ) + except Exception as e: + raise VisionAPIError(f"call_vision failed: {e}") + + if response is NotImplemented: + raise VisionAPIError("Bot does not support vision") + + if isinstance(response, dict) and response.get("error"): + raise VisionAPIError(f"API error - {response.get('message', 'Unknown')}") + + content = response.get("content", "") if isinstance(response, dict) else "" + if not content: + raise VisionAPIError("Empty response from main model") + + usage_info = response.get("usage", {}) if isinstance(response, dict) else {} + + # Use the actual model name from the bot response if available + actual_model = response.get("model", model) if isinstance(response, dict) else model + provider_name = provider.name if provider else _MAIN_MODEL_PROVIDER_NAME + return ToolResult.success({ + "model": actual_model, + "provider": provider_name, + "content": content, + "usage": usage_info, + }) @staticmethod def _ensure_v1(api_base: str) -> str: @@ -213,9 +353,13 @@ class Vision(BaseTool): return api_base.rstrip("/") + "/v1" def _build_image_content(self, image: str) -> dict: - """Build the image_url content block for the API request.""" + """ + Build the image_url content block. + Both remote URLs and local files are converted to base64 data URLs + so every bot backend can consume them without extra downloads. + """ if image.startswith(("http://", "https://")): - return {"type": "image_url", "image_url": {"url": image}} + return self._download_to_data_url(image) if not os.path.isfile(image): raise FileNotFoundError(f"Image file not found: {image}") @@ -239,6 +383,19 @@ class Vision(BaseTool): data_url = f"data:{mime_type};base64,{b64}" return {"type": "image_url", "image_url": {"url": data_url}} + @staticmethod + def _download_to_data_url(url: str) -> dict: + """Download a remote image and return it as a base64 data URL.""" + resp = requests.get(url, timeout=30) + if resp.status_code != 200: + raise VisionAPIError(f"Failed to download image: HTTP {resp.status_code}") + content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip() + if not content_type.startswith("image/"): + content_type = "image/jpeg" + b64 = base64.b64encode(resp.content).decode("ascii") + data_url = f"data:{content_type};base64,{b64}" + return {"type": "image_url", "image_url": {"url": data_url}} + @staticmethod def _maybe_compress(path: str) -> str: """Compress image to under COMPRESS_THRESHOLD with max long-edge 1536px.""" @@ -312,7 +469,6 @@ class Vision(BaseTool): ], } ], - "max_completion_tokens": MAX_TOKENS, } headers = { diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 84b7aad6..cc54f52e 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -124,14 +124,15 @@ class AgentLLMModel(LLMModel): @property def bot(self): - """Lazy load the bot, re-create when model changes""" + """Lazy load the bot, re-create when model or bot_type changes""" from models.bot_factory import create_bot cur_model = self.model - if self._bot is None or self._bot_model != cur_model: - bot_type = self._resolve_bot_type(cur_model) - self._bot = create_bot(bot_type) + cur_bot_type = self._resolve_bot_type(cur_model) + if self._bot is None or self._bot_model != cur_model or getattr(self, '_bot_type', None) != cur_bot_type: + self._bot = create_bot(cur_bot_type) self._bot = add_openai_compatible_support(self._bot) self._bot_model = cur_model + self._bot_type = cur_bot_type return self._bot def call(self, request: LLMRequest): @@ -505,15 +506,15 @@ class AgentBridge: def _migrate_config_to_env(self, workspace_root: str): """ - Migrate API keys from config.json to .env file if not already set - + Sync API keys from config.json to .env file. + Adds new keys and updates changed values on each startup. + Args: workspace_root: Workspace directory path (not used, kept for compatibility) """ from config import conf import os - # Mapping from config.json keys to environment variable names key_mapping = { "open_ai_api_key": "OPENAI_API_KEY", "open_ai_api_base": "OPENAI_API_BASE", @@ -522,10 +523,9 @@ class AgentBridge: "linkai_api_key": "LINKAI_API_KEY", } - # Use fixed secure location for .env file env_file = expand_path("~/.cow/.env") - # Read existing env vars from .env file + # Read existing env vars (key -> value) existing_env_vars = {} if os.path.exists(env_file): try: @@ -533,48 +533,46 @@ class AgentBridge: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: - key, _ = line.split('=', 1) - existing_env_vars[key.strip()] = True + key, val = line.split('=', 1) + existing_env_vars[key.strip()] = val.strip() except Exception as e: logger.warning(f"[AgentBridge] Failed to read .env file: {e}") - # Check which keys need to be migrated - keys_to_migrate = {} + # Sync config.json values into .env (add/update/remove) + updated = False for config_key, env_key in key_mapping.items(): - # Skip if already in .env file - if env_key in existing_env_vars: - continue - - # Get value from config.json - value = conf().get(config_key, "") - if value and value.strip(): # Only migrate non-empty values - keys_to_migrate[env_key] = value.strip() - - # Log summary if there are keys to skip - if existing_env_vars: - logger.debug(f"[AgentBridge] {len(existing_env_vars)} env vars already in .env") - - # Write new keys to .env file - if keys_to_migrate: + raw = conf().get(config_key, "") + value = raw.strip() if raw else "" + old_value = existing_env_vars.get(env_key) + + if value: + if old_value == value: + continue + existing_env_vars[env_key] = value + os.environ[env_key] = value + updated = True + else: + if old_value is None: + continue + existing_env_vars.pop(env_key, None) + os.environ.pop(env_key, None) + updated = True + updated = True + + if updated: try: - # Ensure ~/.cow directory and .env file exist env_dir = os.path.dirname(env_file) - if not os.path.exists(env_dir): - os.makedirs(env_dir, exist_ok=True) - if not os.path.exists(env_file): - open(env_file, 'a').close() - - # Append new keys - with open(env_file, 'a', encoding='utf-8') as f: - f.write('\n# Auto-migrated from config.json\n') - for key, value in keys_to_migrate.items(): + os.makedirs(env_dir, exist_ok=True) + + with open(env_file, 'w', encoding='utf-8') as f: + f.write('# Environment variables for agent\n') + f.write('# Auto-managed - synced from config.json on startup\n\n') + for key, value in sorted(existing_env_vars.items()): f.write(f'{key}={value}\n') - # Also set in current process - os.environ[key] = value - - logger.info(f"[AgentBridge] Migrated {len(keys_to_migrate)} API keys from config.json to .env: {list(keys_to_migrate.keys())}") + + logger.info(f"[AgentBridge] Synced API keys from config.json to .env") except Exception as e: - logger.warning(f"[AgentBridge] Failed to migrate API keys: {e}") + logger.warning(f"[AgentBridge] Failed to sync API keys: {e}") def _persist_messages( self, session_id: str, new_messages: list, channel_type: str = "" diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py index 58bbbfb3..5e0fe01b 100644 --- a/bridge/agent_initializer.py +++ b/bridge/agent_initializer.py @@ -490,7 +490,7 @@ class AgentInitializer: env_file = expand_path("~/.cow/.env") - # Read existing env vars + # Read existing env vars (key -> value) existing_env_vars = {} if os.path.exists(env_file): try: @@ -498,38 +498,46 @@ class AgentInitializer: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: - key, _ = line.split('=', 1) - existing_env_vars[key.strip()] = True + key, val = line.split('=', 1) + existing_env_vars[key.strip()] = val.strip() except Exception as e: logger.warning(f"[AgentInitializer] Failed to read .env file: {e}") - # Check which keys need migration - keys_to_migrate = {} + # Sync config.json values into .env (add/update/remove) + updated = False for config_key, env_key in key_mapping.items(): - if env_key in existing_env_vars: - continue - value = conf().get(config_key, "") - if value and value.strip(): - keys_to_migrate[env_key] = value.strip() - - # Write new keys - if keys_to_migrate: + raw = conf().get(config_key, "") + value = raw.strip() if raw else "" + old_value = existing_env_vars.get(env_key) + + if value: + if old_value == value: + continue + existing_env_vars[env_key] = value + os.environ[env_key] = value + updated = True + else: + if old_value is None: + continue + existing_env_vars.pop(env_key, None) + os.environ.pop(env_key, None) + updated = True + + if updated: try: env_dir = os.path.dirname(env_file) - if not os.path.exists(env_dir): - os.makedirs(env_dir, exist_ok=True) - if not os.path.exists(env_file): - open(env_file, 'a').close() - - with open(env_file, 'a', encoding='utf-8') as f: - f.write('\n# Auto-migrated from config.json\n') - for key, value in keys_to_migrate.items(): + os.makedirs(env_dir, exist_ok=True) + + # Rewrite the entire .env file to ensure consistency + with open(env_file, 'w', encoding='utf-8') as f: + f.write('# Environment variables for agent\n') + f.write('# Auto-managed - synced from config.json on startup\n\n') + for key, value in sorted(existing_env_vars.items()): f.write(f'{key}={value}\n') - os.environ[key] = value - - logger.info(f"[AgentInitializer] Migrated {len(keys_to_migrate)} API keys to .env: {list(keys_to_migrate.keys())}") + + logger.info(f"[AgentInitializer] Synced API keys from config.json to .env") except Exception as e: - logger.warning(f"[AgentInitializer] Failed to migrate API keys: {e}") + logger.warning(f"[AgentInitializer] Failed to sync API keys: {e}") def _start_daily_flush_timer(self): """Start a background thread that flushes all agents' memory daily at 23:55.""" diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 24e120be..0f6c2a29 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -806,15 +806,17 @@ function sendMessage() { } function startSSE(requestId, loadingEl, timestamp) { - const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`); - activeStreams[requestId] = es; - 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; + let done = false; + + const MAX_RECONNECTS = 10; + const RECONNECT_BASE_MS = 1000; + let reconnectCount = 0; function ensureBotEl() { if (botEl) return; @@ -839,180 +841,204 @@ function startSSE(requestId, loadingEl, timestamp) { mediaEl = botEl.querySelector('.media-content'); } - es.onmessage = function(e) { - let item; - try { item = JSON.parse(e.data); } catch (_) { return; } + function connect() { + const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`); + activeStreams[requestId] = es; - if (item.type === 'delta') { - ensureBotEl(); - accumulatedText += item.content; - contentEl.innerHTML = renderMarkdown(accumulatedText); - scrollChatToBottom(); + es.onmessage = function(e) { + let item; + try { item = JSON.parse(e.data); } catch (_) { return; } - } else if (item.type === 'tool_start') { - ensureBotEl(); + // Successful data received, reset reconnect counter + reconnectCount = 0; - // 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 = ` -
- - ${escapeHtml(truncated)} - -
-
${renderMarkdown(fullText)}
`; - } else { - stepEl.innerHTML = ` -
- - ${escapeHtml(oneLine)} -
`; + if (item.type === 'delta') { + ensureBotEl(); + accumulatedText += item.content; + contentEl.innerHTML = renderMarkdown(accumulatedText); + 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 = ` +
+ + ${escapeHtml(truncated)} + +
+
${renderMarkdown(fullText)}
`; + } else { + stepEl.innerHTML = ` +
+ + ${escapeHtml(oneLine)} +
`; + } + stepsEl.appendChild(stepEl); } - stepsEl.appendChild(stepEl); - } - accumulatedText = ''; - contentEl.innerHTML = ''; + accumulatedText = ''; + contentEl.innerHTML = ''; - // Add tool execution indicator (collapsible) - currentToolEl = document.createElement('div'); - currentToolEl.className = 'agent-step agent-tool-step'; - const argsStr = formatToolArgs(item.arguments || {}); - currentToolEl.innerHTML = ` -
- - ${item.tool} - -
-
-
-
Input
-
${argsStr}
+ // Add tool execution indicator (collapsible) + currentToolEl = document.createElement('div'); + currentToolEl.className = 'agent-step agent-tool-step'; + const argsStr = formatToolArgs(item.arguments || {}); + currentToolEl.innerHTML = ` +
+ + ${item.tool} +
-
-
`; - stepsEl.appendChild(currentToolEl); +
+
+
Input
+
${argsStr}
+
+
+
`; + stepsEl.appendChild(currentToolEl); - scrollChatToBottom(); + scrollChatToBottom(); - } else if (item.type === 'tool_end') { - if (currentToolEl) { - const isError = item.status !== 'success'; - const icon = currentToolEl.querySelector('.tool-icon'); - icon.className = isError - ? 'fas fa-times text-red-400 flex-shrink-0 tool-icon' - : 'fas fa-check text-primary-400 flex-shrink-0 tool-icon'; + } else if (item.type === 'tool_end') { + if (currentToolEl) { + const isError = item.status !== 'success'; + const icon = currentToolEl.querySelector('.tool-icon'); + icon.className = isError + ? 'fas fa-times text-red-400 flex-shrink-0 tool-icon' + : 'fas fa-check text-primary-400 flex-shrink-0 tool-icon'; - // Show execution time - const nameEl = currentToolEl.querySelector('.tool-name'); - if (item.execution_time !== undefined) { - nameEl.innerHTML += ` ${item.execution_time}s`; + // Show execution time + const nameEl = currentToolEl.querySelector('.tool-name'); + if (item.execution_time !== undefined) { + nameEl.innerHTML += ` ${item.execution_time}s`; + } + + // Fill output section + const outputSection = currentToolEl.querySelector('.tool-output-section'); + if (outputSection && item.result) { + outputSection.innerHTML = ` +
${isError ? 'Error' : 'Output'}
+
${escapeHtml(String(item.result))}
`; + } + + if (isError) currentToolEl.classList.add('tool-failed'); + currentToolEl = null; } - // Fill output section - const outputSection = currentToolEl.querySelector('.tool-output-section'); - if (outputSection && item.result) { - outputSection.innerHTML = ` -
${isError ? 'Error' : 'Output'}
-
${escapeHtml(String(item.result))}
`; - } + } else if (item.type === 'image') { + ensureBotEl(); + const imgEl = document.createElement('img'); + imgEl.src = item.content; + imgEl.alt = 'screenshot'; + 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(); - if (isError) currentToolEl.classList.add('tool-failed'); - currentToolEl = null; + } 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(); + 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 === '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') { + done = true; + 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) { + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + 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); + } + scrollChatToBottom(); + + } else if (item.type === 'error') { + done = true; + es.close(); + delete activeStreams[requestId]; + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + addBotMessage(t('error_send'), new Date()); } + }; - } else if (item.type === 'image') { - ensureBotEl(); - const imgEl = document.createElement('img'); - imgEl.src = item.content; - imgEl.alt = 'screenshot'; - 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(); - - } 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(); - 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 === '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.onerror = function() { 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 (done) return; - if (!botEl && finalText) { - if (loadingEl) { loadingEl.remove(); loadingEl = null; } - addBotMessage(finalText, new Date((item.timestamp || Date.now() / 1000) * 1000), requestId); - } else if (botEl) { + if (reconnectCount < MAX_RECONNECTS) { + reconnectCount++; + const delay = Math.min(RECONNECT_BASE_MS * reconnectCount, 5000); + console.warn(`[SSE] connection lost for ${requestId}, reconnecting in ${delay}ms (attempt ${reconnectCount}/${MAX_RECONNECTS})`); + setTimeout(connect, delay); + return; + } + + // Exhausted retries, show whatever we have + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + if (!botEl) { + addBotMessage(t('error_send'), new Date()); + } else if (accumulatedText) { contentEl.classList.remove('sse-streaming'); - // Only update text content when there is something new to show. - if (finalText) contentEl.innerHTML = renderMarkdown(finalText); + contentEl.innerHTML = renderMarkdown(accumulatedText); applyHighlighting(botEl); } - scrollChatToBottom(); + }; + } - } else if (item.type === 'error') { - es.close(); - delete activeStreams[requestId]; - if (loadingEl) { loadingEl.remove(); loadingEl = null; } - addBotMessage(t('error_send'), new Date()); - } - }; - - es.onerror = function() { - es.close(); - delete activeStreams[requestId]; - if (loadingEl) { loadingEl.remove(); loadingEl = null; } - if (!botEl) { - addBotMessage(t('error_send'), new Date()); - } else if (accumulatedText) { - contentEl.classList.remove('sse-streaming'); - contentEl.innerHTML = renderMarkdown(accumulatedText); - applyHighlighting(botEl); - } - }; + connect(); } function startPolling() { diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 32b27062..bd686f9f 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -329,14 +329,18 @@ class WebChannel(ChatChannel): """ SSE generator for a given request_id. Yields UTF-8 encoded bytes to avoid WSGI Latin-1 mangling. + Supports client reconnection: the queue is only removed after a + "done" event is consumed, so a new GET /stream with the same + request_id can resume reading remaining events. """ if request_id not in self.sse_queues: yield b"data: {\"type\": \"error\", \"message\": \"invalid request_id\"}\n\n" return q = self.sse_queues[request_id] - timeout = 300 # 5 minutes max - deadline = time.time() + timeout + idle_timeout = 600 # 10 minutes without any real event + deadline = time.time() + idle_timeout + done = False try: while time.time() < deadline: @@ -346,13 +350,18 @@ class WebChannel(ChatChannel): yield b": keepalive\n\n" continue + # Real event received, reset idle deadline + deadline = time.time() + idle_timeout + payload = json.dumps(item, ensure_ascii=False) yield f"data: {payload}\n\n".encode("utf-8") if item.get("type") == "done": + done = True break finally: - self.sse_queues.pop(request_id, None) + if done: + self.sse_queues.pop(request_id, None) def poll_response(self): """ diff --git a/docs/en/tools/vision.mdx b/docs/en/tools/vision.mdx new file mode 100644 index 00000000..cebecbea --- /dev/null +++ b/docs/en/tools/vision.mdx @@ -0,0 +1,72 @@ +--- +title: vision - Image Analysis +description: Analyze image content (recognition, description, OCR, etc.) +--- + +Analyze local images or image URLs using Vision API. Supports content description, text extraction (OCR), object recognition, and more. + +## Model Selection + +The vision tool uses a multi-level auto-selection strategy with automatic fallback — no manual configuration required: + +1. **Main model** — uses the currently configured main model for image recognition (zero extra cost) +2. **Other configured models** — auto-discovers other models with configured API keys as alternatives +3. **OpenAI** — uses `open_ai_api_key` to call gpt-4.1-mini +4. **LinkAI** — uses `linkai_api_key` to call LinkAI vision service + +When `use_linkai=true`, LinkAI is promoted to the highest priority. + +If the current provider fails, the tool automatically tries the next one until it succeeds or all fail. + +### Supported Models + +| Vendor | Vision Model | Notes | +| --- | --- | --- | +| OpenAI / Compatible | Main model | All OpenAI-compatible multimodal models | +| Qwen (DashScope) | Main model | Via MultiModalConversation API | +| Claude | Main model | Anthropic native image format | +| Gemini | Main model | inlineData format | +| Doubao | Main model | doubao-seed-2-0 series natively supported | +| Kimi (Moonshot) | Main model | kimi-k2.5 natively supported | +| ZhipuAI | glm-5v-turbo | Always uses dedicated vision model | +| MiniMax | MiniMax-Text-01 | Always uses dedicated vision model | + + + ZhipuAI and MiniMax text models do not support image understanding, so their dedicated vision models are always used automatically. + + +## Parameters + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `image` | string | Yes | Local file path or HTTP(S) image URL | +| `question` | string | Yes | Question to ask about the image | + +Supported image formats: jpg, jpeg, png, gif, webp + +## Custom Configuration + +To specify a particular model for the vision tool, add to `config.json`: + +```json +{ + "tool": { + "vision": { + "model": "gpt-4o" + } + } +} +``` + +In most cases no configuration is needed. The tool works automatically as long as the main model supports multimodal input or any vision-capable API key is configured. + +## Use Cases + +- Describe image content +- Extract text from images (OCR) +- Identify objects, colors, scenes +- Analyze screenshots and scanned documents + + + Images larger than 1MB are automatically compressed (max edge 1536px). All images (including remote URLs) are converted to base64 for transmission to ensure compatibility with all model backends. + diff --git a/docs/ja/tools/vision.mdx b/docs/ja/tools/vision.mdx new file mode 100644 index 00000000..f34bf58a --- /dev/null +++ b/docs/ja/tools/vision.mdx @@ -0,0 +1,72 @@ +--- +title: vision - 画像分析 +description: 画像コンテンツの分析(認識、説明、OCR など) +--- + +Vision API を使用してローカル画像や画像 URL を分析します。コンテンツの説明、テキスト抽出(OCR)、オブジェクト認識などに対応しています。 + +## モデル選択 + +Vision ツールは多段階の自動選択+自動フォールバック戦略を採用しており、手動設定なしで利用可能です: + +1. **メインモデル** — 現在設定されているメインモデルで画像認識を実行(追加コストなし) +2. **その他の設定済みモデル** — API キーが設定されている他のマルチモーダルモデルを自動検出 +3. **OpenAI** — `open_ai_api_key` を使用して gpt-4.1-mini を呼び出し +4. **LinkAI** — `linkai_api_key` を使用して LinkAI ビジョンサービスを呼び出し + +`use_linkai=true` の場合、LinkAI が最優先になります。 + +現在のプロバイダーが失敗した場合、成功するかすべて失敗するまで自動的に次のプロバイダーを試行します。 + +### 対応モデル + +| ベンダー | ビジョンモデル | 説明 | +| --- | --- | --- | +| OpenAI / 互換プロトコル | メインモデル | すべての OpenAI 互換マルチモーダルモデルに対応 | +| 通義千問 (DashScope) | メインモデル | MultiModalConversation API 経由 | +| Claude | メインモデル | Anthropic ネイティブ画像形式 | +| Gemini | メインモデル | inlineData 形式 | +| 豆包 (Doubao) | メインモデル | doubao-seed-2-0 シリーズがネイティブ対応 | +| Kimi (Moonshot) | メインモデル | kimi-k2.5 がネイティブ対応 | +| 智谱 AI | glm-5v-turbo | 常にビジョン専用モデルを使用 | +| MiniMax | MiniMax-Text-01 | 常にビジョン専用モデルを使用 | + + + 智谱 AI と MiniMax のテキストモデルは画像理解に対応していないため、対応するビジョン専用モデルが自動的に使用されます。 + + +## パラメータ + +| パラメータ | 型 | 必須 | 説明 | +| --- | --- | --- | --- | +| `image` | string | はい | ローカルファイルパスまたは HTTP(S) 画像 URL | +| `question` | string | はい | 画像に対する質問 | + +対応画像形式:jpg、jpeg、png、gif、webp + +## カスタム設定 + +Vision ツールで使用するモデルを指定するには、`config.json` に以下を追加します: + +```json +{ + "tool": { + "vision": { + "model": "gpt-4o" + } + } +} +``` + +ほとんどの場合、設定は不要です。メインモデルがマルチモーダルに対応しているか、ビジョン対応の API キーが設定されていれば自動的に動作します。 + +## ユースケース + +- 画像コンテンツの説明 +- 画像からのテキスト抽出(OCR) +- オブジェクト、色、シーンの識別 +- スクリーンショットやスキャン文書の分析 + + + 1MB を超える画像は自動的に圧縮されます(最大辺 1536px)。すべての画像(リモート URL を含む)は base64 に変換して送信され、すべてのモデルバックエンドとの互換性を確保します。 + diff --git a/docs/tools/vision.mdx b/docs/tools/vision.mdx index 839212b3..4e1089e0 100644 --- a/docs/tools/vision.mdx +++ b/docs/tools/vision.mdx @@ -5,14 +5,49 @@ description: 分析图片内容(识别、描述、OCR 等) 使用 Vision API 分析本地图片或图片 URL,支持内容描述、文字提取(OCR)、物体识别等。 -## 依赖 +## 模型选择 -需要配置至少一个 API Key(通过 `env_config` 工具或工作空间 `.env` 文件配置): +Vision 工具采用多级自动选择 + 自动兜底策略,无需手动配置即可使用: -| 后端 | 环境变量 | 优先级 | +1. **主模型** — 优先使用当前配置的主模型进行图像识别(需要是多模态模型) +2. **其他已配置模型** — 自动发现已配置 API Key 的其他多模态模型作为备选 + +如果当前 provider 调用失败,会自动尝试下一个,直到成功或全部失败。 + +### 支持的模型 + +| 厂商 | 视觉模型 | 说明 | | --- | --- | --- | -| OpenAI | `OPENAI_API_KEY` | 优先使用 | -| LinkAI | `LINKAI_API_KEY` | 备选 | +| OpenAI / 兼容协议 | 使用主模型 | 支持所有 OpenAI 协议兼容的多模态模型 | +| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.6-plus 等 | +| Claude | 使用主模型 | Anthropic 原生图像格式 | +| Gemini | 使用主模型 | inlineData 格式 | +| 豆包 (Doubao) | 使用主模型 | doubao-seed-2-0 系列原生支持 | +| Kimi (Moonshot) | 使用主模型 | kimi-k2.5 原生支持 | +| 智谱 AI | glm-5v-turbo | 固定使用视觉专用模型 | +| MiniMax | MiniMax-Text-01 | 固定使用视觉专用模型 | + + + 智谱和 MiniMax 的文本模型不支持图像理解,因此始终使用对应的视觉专用模型,无需手动指定。 + + +> 当 `use_linkai=true` 时,默认使用 LinkAI 的多模态模型进行 + +## 自定义配置 + +如果希望指定 Vision 使用的模型,可在 `config.json` 中配置,例如: + +```json +{ + "tool": { + "vision": { + "model": "gpt-4o" + } + } +} +``` + +大多数情况下无需配置,主模型支持多模态或配置任意一个支持视觉的 API Key 即可自动工作。 ## 参数 @@ -20,17 +55,18 @@ description: 分析图片内容(识别、描述、OCR 等) | --- | --- | --- | --- | | `image` | string | 是 | 本地文件路径或 HTTP(S) 图片 URL | | `question` | string | 是 | 对图片提出的问题 | -| `model` | string | 否 | 模型名称(默认 gpt-4.1-mini) | 支持的图片格式:jpg、jpeg、png、gif、webp + + ## 使用场景 - 描述图片中的内容 - 提取图片中的文字(OCR) - 识别物体、颜色、场景 -- 分析截图、文档扫描件 +- 分析截图、文档扫描图片等 - 超过 1MB 的图片会自动压缩后上传。如果未配置任何 Vision API Key,该工具不会被加载。 + 超过 1MB 的图片会自动压缩后上传,所有图片(包括远程 URL)会统一转为 base64 传输,确保兼容所有模型后端。 diff --git a/models/bot.py b/models/bot.py index ca6e1aa1..f5f72e7d 100644 --- a/models/bot.py +++ b/models/bot.py @@ -2,12 +2,27 @@ Auto-replay chat robot abstract class """ - from bridge.context import Context from bridge.reply import Reply class Bot(object): + """ + Base class for all chat-bot implementations. + + Subclasses may also implement: + + call_with_tools(messages, tools=None, stream=False, **kwargs) + -> dict | generator (OpenAI-compatible format) + + call_vision(image_url, question, model=None, max_tokens=1000) + -> dict with keys: model, content, usage (or error/message) + + These are NOT defined here to avoid shadowing concrete implementations + provided by mixin classes (e.g. OpenAICompatibleBot) in the MRO. + Use ``hasattr(bot, 'call_vision')`` to detect support at runtime. + """ + def reply(self, query, context: Context = None) -> Reply: """ bot auto-reply content diff --git a/models/claudeapi/claude_api_bot.py b/models/claudeapi/claude_api_bot.py index 5dcf9173..ffbb74dd 100644 --- a/models/claudeapi/claude_api_bot.py +++ b/models/claudeapi/claude_api_bot.py @@ -1,7 +1,10 @@ # encoding:utf-8 +import base64 import json +import re import time +from typing import Optional import requests @@ -224,6 +227,79 @@ class ClaudeAPIBot(Bot, OpenAIImage): return 64000 return 8192 + @staticmethod + def _parse_data_url(data_url: str): + """Parse a data:;base64, URL into (media_type, base64_data).""" + m = re.match(r"^data:([^;]+);base64,(.+)$", data_url, re.DOTALL) + if m: + return m.group(1), m.group(2) + return None, None + + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using Claude Messages API (native image blocks).""" + try: + actual_model = model or self._model_mapping(conf().get("model")) + + # Build Claude-native image content block + if image_url.startswith("data:"): + media_type, b64_data = self._parse_data_url(image_url) + if not b64_data: + return {"error": True, "message": "Invalid base64 data URL"} + image_block = { + "type": "image", + "source": {"type": "base64", + "media_type": media_type or "image/jpeg", + "data": b64_data}, + } + else: + image_block = { + "type": "image", + "source": {"type": "url", "url": image_url}, + } + + data = { + "model": actual_model, + "max_tokens": max_tokens, + "messages": [{ + "role": "user", + "content": [ + image_block, + {"type": "text", "text": question}, + ], + }], + } + + headers = { + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + proxies = {"http": self.proxy, "https": self.proxy} if self.proxy else None + resp = requests.post(f"{self.api_base}/messages", + headers=headers, json=data, proxies=proxies) + + if resp.status_code != 200: + return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} + + body = resp.json() + text_parts = [b.get("text", "") for b in body.get("content", []) + if b.get("type") == "text"] + usage = body.get("usage", {}) + return { + "model": actual_model, + "content": "".join(text_parts), + "usage": { + "prompt_tokens": usage.get("input_tokens", 0), + "completion_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[CLAUDE] call_vision error: {e}") + return {"error": True, "message": str(e)} + def call_with_tools(self, messages, tools=None, stream=False, **kwargs): """ Call Claude API with tool support for agent integration diff --git a/models/dashscope/dashscope_bot.py b/models/dashscope/dashscope_bot.py index 0887751f..4d4d628f 100644 --- a/models/dashscope/dashscope_bot.py +++ b/models/dashscope/dashscope_bot.py @@ -1,6 +1,8 @@ # encoding:utf-8 import json +from typing import Optional + from models.bot import Bot from models.session_manager import SessionManager from bridge.context import ContextType @@ -153,6 +155,56 @@ class DashscopeBot(Bot): else: return result + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using DashScope MultiModalConversation API.""" + try: + dashscope.api_key = self.api_key + vision_model = model or "qwen-vl-max" + + # DashScope multimodal format: {"image": url} + {"text": question} + messages = [{ + "role": "user", + "content": [ + {"image": image_url}, + {"text": question}, + ], + }] + + response = MultiModalConversation.call( + model=vision_model, + messages=messages, + max_tokens=max_tokens, + ) + + if response.status_code != HTTPStatus.OK: + return { + "error": True, + "message": f"{response.code} - {response.message}", + } + + resp_dict = self._response_to_dict(response) + choice = resp_dict["output"]["choices"][0] + content = choice.get("message", {}).get("content", "") + if isinstance(content, list): + content = "".join( + item.get("text", "") for item in content if isinstance(item, dict) + ) + usage = resp_dict.get("usage", {}) + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": usage.get("input_tokens", 0), + "completion_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[DASHSCOPE] call_vision error: {e}") + return {"error": True, "message": str(e)} + def call_with_tools(self, messages, tools=None, stream=False, **kwargs): """ Call DashScope API with tool support for agent integration diff --git a/models/doubao/doubao_bot.py b/models/doubao/doubao_bot.py index cfe4ba5c..b31516ec 100644 --- a/models/doubao/doubao_bot.py +++ b/models/doubao/doubao_bot.py @@ -2,6 +2,7 @@ import json import time +from typing import Optional import requests from models.bot import Bot @@ -147,6 +148,49 @@ class DoubaoBot(Bot): else: return result + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using Doubao (Volcengine Ark) OpenAI-compatible API.""" + try: + vision_model = model or self.args.get("model", "doubao-seed-2-0-pro-260215") + payload = { + "model": vision_model, + "max_tokens": max_tokens, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }], + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + resp = requests.post(f"{self.base_url}/chat/completions", + headers=headers, json=payload, timeout=60) + if resp.status_code != 200: + return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} + data = resp.json() + if "error" in data: + return {"error": True, "message": data["error"].get("message", str(data["error"]))} + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + usage = data.get("usage", {}) + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[DOUBAO] call_vision error: {e}") + return {"error": True, "message": str(e)} + # ==================== Agent mode support ==================== def call_with_tools(self, messages, tools=None, stream: bool = False, **kwargs): @@ -434,31 +478,37 @@ class DoubaoBot(Bot): continue if role == "user": - text_parts = [] - tool_results = [] + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content + ) + if has_tool_result: + text_parts = [] + tool_results = [] - for block in content: - if not isinstance(block, dict): - continue - if block.get("type") == "text": - text_parts.append(block.get("text", "")) - elif block.get("type") == "tool_result": - tool_call_id = block.get("tool_use_id") or "" - result_content = block.get("content", "") - if not isinstance(result_content, str): - result_content = json.dumps(result_content, ensure_ascii=False) - tool_results.append({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": result_content - }) + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_result": + tool_call_id = block.get("tool_use_id") or "" + result_content = block.get("content", "") + if not isinstance(result_content, str): + result_content = json.dumps(result_content, ensure_ascii=False) + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content + }) - # Tool results first (must come right after assistant with tool_calls) - for tr in tool_results: - converted.append(tr) + for tr in tool_results: + converted.append(tr) - if text_parts: - converted.append({"role": "user", "content": "\n".join(text_parts)}) + if text_parts: + converted.append({"role": "user", "content": "\n".join(text_parts)}) + else: + # Keep as-is for multimodal content (e.g. image_url blocks) + converted.append(msg) elif role == "assistant": openai_msg = {"role": "assistant"} diff --git a/models/gemini/google_gemini_bot.py b/models/gemini/google_gemini_bot.py index e49a8bf3..aa7199ca 100644 --- a/models/gemini/google_gemini_bot.py +++ b/models/gemini/google_gemini_bot.py @@ -12,6 +12,8 @@ import mimetypes import os import re import time +from typing import Optional + import requests from models.bot import Bot from models.session_manager import SessionManager @@ -144,7 +146,12 @@ class GoogleGeminiBot(Bot): return "", [] pattern = r"\[图片:\s*([^\]]+)\]" image_paths = [m.strip().strip("'\"") for m in re.findall(pattern, content) if m.strip()] - cleaned_text = re.sub(pattern, "", content) + # Replace markers with path-only hints so the model still knows the + # original file location (needed when it calls tools like vision). + def _replace_with_hint(m): + path = m.group(1).strip().strip("'\"") + return f"[attached image: {path}]" + cleaned_text = re.sub(pattern, _replace_with_hint, content) cleaned_text = re.sub(r"\n{3,}", "\n\n", cleaned_text).strip() return cleaned_text, image_paths @@ -225,6 +232,57 @@ class GoogleGeminiBot(Bot): logger.warning(f"[Gemini] Unsupported image URL format: {image_url[:120]}") return None + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using Gemini REST API.""" + try: + model_name = model or self.model or "gemini-2.0-flash" + image_part = self._build_inline_part_from_image_url({"url": image_url}) + if not image_part: + return {"error": True, "message": f"Cannot process image URL: {image_url[:120]}"} + + payload = { + "contents": [{ + "role": "user", + "parts": [image_part, {"text": question}], + }], + "generationConfig": {"maxOutputTokens": max_tokens}, + "safetySettings": [ + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, + ], + } + endpoint = f"{self.api_base}/v1beta/models/{model_name}:generateContent" + headers = {"x-goog-api-key": self.api_key, "Content-Type": "application/json"} + resp = requests.post(endpoint, headers=headers, json=payload, timeout=60) + + if resp.status_code != 200: + return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} + + body = resp.json() + candidates = body.get("candidates", []) + text_parts = [] + for part in candidates[0].get("content", {}).get("parts", []) if candidates else []: + if "text" in part: + text_parts.append(part["text"]) + + usage_meta = body.get("usageMetadata", {}) + return { + "model": model_name, + "content": "".join(text_parts), + "usage": { + "prompt_tokens": usage_meta.get("promptTokenCount", 0), + "completion_tokens": usage_meta.get("candidatesTokenCount", 0), + "total_tokens": usage_meta.get("totalTokenCount", 0), + }, + } + except Exception as e: + logger.error(f"[Gemini] call_vision error: {e}") + return {"error": True, "message": str(e)} + def call_with_tools(self, messages, tools=None, stream=False, **kwargs): """ Call Gemini API with tool support using REST API (following official docs) diff --git a/models/minimax/minimax_bot.py b/models/minimax/minimax_bot.py index af80e795..983a4132 100644 --- a/models/minimax/minimax_bot.py +++ b/models/minimax/minimax_bot.py @@ -2,6 +2,8 @@ import time import json +from typing import Optional + import requests from models.bot import Bot @@ -175,6 +177,51 @@ class MinimaxBot(Bot): else: return result + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using MiniMax OpenAI-compatible API. + Always uses MiniMax-Text-01 — other MiniMax models do not support vision. + """ + try: + vision_model = "MiniMax-Text-01" + payload = { + "model": vision_model, + "max_tokens": max_tokens, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }], + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + resp = requests.post(f"{self.api_base}/chat/completions", + headers=headers, json=payload, timeout=60) + if resp.status_code != 200: + return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} + data = resp.json() + if "error" in data: + return {"error": True, "message": data["error"].get("message", str(data["error"]))} + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + usage = data.get("usage", {}) + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[MINIMAX] call_vision error: {e}") + return {"error": True, "message": str(e)} + def call_with_tools(self, messages, tools=None, stream=False, **kwargs): """ Call MiniMax API with tool support for agent integration @@ -273,37 +320,41 @@ class MinimaxBot(Bot): if role == "user": # Handle user message if isinstance(content, list): - # Extract text from content blocks - text_parts = [] - tool_results = [] + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content + ) + if has_tool_result: + text_parts = [] + tool_results = [] - for block in content: - if isinstance(block, dict): - if block.get("type") == "text": - text_parts.append(block.get("text", "")) - elif block.get("type") == "tool_result": - # Tool result should be a separate message with role="tool" - tool_call_id = block.get("tool_use_id") or "" - if not tool_call_id: - logger.warning(f"[MINIMAX] tool_result missing tool_use_id") - result_content = block.get("content", "") - if not isinstance(result_content, str): - result_content = json.dumps(result_content, ensure_ascii=False) - tool_results.append({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": result_content - }) + for block in content: + if isinstance(block, dict): + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_result": + tool_call_id = block.get("tool_use_id") or "" + if not tool_call_id: + logger.warning(f"[MINIMAX] tool_result missing tool_use_id") + result_content = block.get("content", "") + if not isinstance(result_content, str): + result_content = json.dumps(result_content, ensure_ascii=False) + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content + }) - if text_parts: - converted.append({ - "role": "user", - "content": "\n".join(text_parts) - }) + if text_parts: + converted.append({ + "role": "user", + "content": "\n".join(text_parts) + }) - # Add all tool results (not just the last one) - for tool_result in tool_results: - converted.append(tool_result) + for tool_result in tool_results: + converted.append(tool_result) + else: + # Keep as-is for multimodal content (e.g. image_url blocks) + converted.append(msg) else: # Simple text content converted.append({ diff --git a/models/moonshot/moonshot_bot.py b/models/moonshot/moonshot_bot.py index ded011ca..4d35400e 100644 --- a/models/moonshot/moonshot_bot.py +++ b/models/moonshot/moonshot_bot.py @@ -2,6 +2,7 @@ import json import time +from typing import Optional import requests from models.bot import Bot @@ -147,6 +148,49 @@ class MoonshotBot(Bot): else: return result + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using Moonshot (Kimi) OpenAI-compatible API.""" + try: + vision_model = model or self.args.get("model", "kimi-k2.5") + payload = { + "model": vision_model, + "max_tokens": max_tokens, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }], + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + resp = requests.post(f"{self.base_url}/chat/completions", + headers=headers, json=payload, timeout=60) + if resp.status_code != 200: + return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} + data = resp.json() + if "error" in data: + return {"error": True, "message": data["error"].get("message", str(data["error"]))} + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + usage = data.get("usage", {}) + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[MOONSHOT] call_vision error: {e}") + return {"error": True, "message": str(e)} + # ==================== Agent mode support ==================== def call_with_tools(self, messages, tools=None, stream: bool = False, **kwargs): @@ -435,31 +479,37 @@ class MoonshotBot(Bot): continue if role == "user": - text_parts = [] - tool_results = [] + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content + ) + if has_tool_result: + text_parts = [] + tool_results = [] - for block in content: - if not isinstance(block, dict): - continue - if block.get("type") == "text": - text_parts.append(block.get("text", "")) - elif block.get("type") == "tool_result": - tool_call_id = block.get("tool_use_id") or "" - result_content = block.get("content", "") - if not isinstance(result_content, str): - result_content = json.dumps(result_content, ensure_ascii=False) - tool_results.append({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": result_content - }) + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_result": + tool_call_id = block.get("tool_use_id") or "" + result_content = block.get("content", "") + if not isinstance(result_content, str): + result_content = json.dumps(result_content, ensure_ascii=False) + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content + }) - # Tool results first (must come right after assistant with tool_calls) - for tr in tool_results: - converted.append(tr) + for tr in tool_results: + converted.append(tr) - if text_parts: - converted.append({"role": "user", "content": "\n".join(text_parts)}) + if text_parts: + converted.append({"role": "user", "content": "\n".join(text_parts)}) + else: + # Keep as-is for multimodal content (e.g. image_url blocks) + converted.append(msg) elif role == "assistant": openai_msg = {"role": "assistant"} diff --git a/models/openai_compatible_bot.py b/models/openai_compatible_bot.py index baac0681..6d4d314e 100644 --- a/models/openai_compatible_bot.py +++ b/models/openai_compatible_bot.py @@ -9,6 +9,8 @@ This includes: OpenAI, LinkAI, Azure OpenAI, and many third-party providers. import json import openai +import requests +from typing import Optional from common.log import logger from agent.protocol.message_utils import drop_orphaned_tool_results_openai @@ -306,3 +308,51 @@ class OpenAICompatibleBot: openai_messages.append(msg) return drop_orphaned_tool_results_openai(openai_messages) + + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using the OpenAI-compatible /chat/completions endpoint.""" + try: + api_config = self.get_api_config() + vision_model = model or api_config.get("model", "gpt-4o") + api_key = api_config.get("api_key", "") + api_base = (api_config.get("api_base") or "https://api.openai.com/v1").rstrip("/") + + payload = { + "model": vision_model, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }], + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + resp = requests.post( + f"{api_base}/chat/completions", + headers=headers, json=payload, timeout=60, + ) + if resp.status_code != 200: + body = resp.text[:500] + logger.error(f"[{self.__class__.__name__}] call_vision HTTP {resp.status_code}: {body}") + return {"error": True, "message": f"HTTP {resp.status_code}: {body}"} + data = resp.json() + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + usage = data.get("usage", {}) + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[{self.__class__.__name__}] call_vision error: {e}") + return {"error": True, "message": str(e)} diff --git a/models/zhipuai/zhipuai_bot.py b/models/zhipuai/zhipuai_bot.py index 4733cf9b..98ea5db1 100644 --- a/models/zhipuai/zhipuai_bot.py +++ b/models/zhipuai/zhipuai_bot.py @@ -2,6 +2,7 @@ import time import json +from typing import Optional from models.bot import Bot from models.zhipuai.zhipu_ai_session import ZhipuAISession @@ -149,6 +150,40 @@ class ZHIPUAIBot(Bot, ZhipuAIImage): else: return result + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """Analyze an image using ZhipuAI OpenAI-compatible SDK. + Always uses glm-5v-turbo — the text models (glm-5-turbo etc.) do not support vision. + """ + try: + vision_model = "glm-5v-turbo" + response = self.client.chat.completions.create( + model=vision_model, + max_tokens=max_tokens, + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }], + ) + content = response.choices[0].message.content or "" + usage = response.usage + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": getattr(usage, "prompt_tokens", 0), + "completion_tokens": getattr(usage, "completion_tokens", 0), + "total_tokens": getattr(usage, "total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[ZHIPU_AI] call_vision error: {e}") + return {"error": True, "message": str(e)} + def call_with_tools(self, messages, tools=None, stream=False, **kwargs): """ Call ZhipuAI API with tool support for agent integration From ea78e3d0c61daa3bb08c6434644bd4c09e62d8c8 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 11 Apr 2026 20:16:43 +0800 Subject: [PATCH 105/399] feat(knowledge): document link supports jumping to view --- agent/tools/memory/memory_get.py | 17 ++++- agent/tools/memory/memory_search.py | 7 +++ channel/web/static/js/console.js | 98 ++++++++++++++++++++++++++++- skills/knowledge-wiki/SKILL.md | 1 + 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/agent/tools/memory/memory_get.py b/agent/tools/memory/memory_get.py index 64e4d4de..1bd4828e 100644 --- a/agent/tools/memory/memory_get.py +++ b/agent/tools/memory/memory_get.py @@ -44,6 +44,19 @@ class MemoryGetTool(BaseTool): """ super().__init__() self.memory_manager = memory_manager + + from config import conf + if conf().get("knowledge", True): + self.description = ( + "Read specific content from memory or knowledge files. " + "Use this to get full context from a memory file, knowledge page, or specific line range." + ) + self.params = {**self.params} + self.params["properties"] = {**self.params["properties"]} + self.params["properties"]["path"] = { + "type": "string", + "description": "Relative path to the memory or knowledge file (e.g. 'MEMORY.md', 'memory/2026-01-01.md', 'knowledge/concepts/moe.md')" + } def execute(self, args: dict): """ @@ -68,8 +81,8 @@ class MemoryGetTool(BaseTool): workspace_dir = self.memory_manager.config.get_workspace() # Auto-prepend memory/ if not present and not absolute path - # Exception: MEMORY.md is in the root directory - if not path.startswith('memory/') and not path.startswith('/') and path != 'MEMORY.md': + # Exceptions: MEMORY.md in root, knowledge/ files at workspace root + if not path.startswith('memory/') and not path.startswith('knowledge/') and not path.startswith('/') and path != 'MEMORY.md': path = f'memory/{path}' file_path = workspace_dir / path diff --git a/agent/tools/memory/memory_search.py b/agent/tools/memory/memory_search.py index d7b14df3..1387d11c 100644 --- a/agent/tools/memory/memory_search.py +++ b/agent/tools/memory/memory_search.py @@ -48,6 +48,13 @@ class MemorySearchTool(BaseTool): super().__init__() self.memory_manager = memory_manager self.user_id = user_id + + from config import conf + if conf().get("knowledge", True): + self.description = ( + "Search agent's long-term memory and knowledge base using semantic and keyword search. " + "Use this to recall past conversations, preferences, and knowledge pages." + ) def execute(self, args: dict): """ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 560d4712..33a3f484 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -1038,6 +1038,7 @@ function startSSE(requestId, loadingEl, timestamp) { // Only update text content when there is something new to show. if (finalText) contentEl.innerHTML = renderMarkdown(finalText); applyHighlighting(botEl); + bindChatKnowledgeLinks(botEl); } scrollChatToBottom(); @@ -1255,6 +1256,7 @@ function createBotMessageEl(content, timestamp, requestId, msg) {
`; applyHighlighting(el); + bindChatKnowledgeLinks(el); return el; } @@ -3002,6 +3004,92 @@ function filterKnowledgeTree(query) { renderKnowledgeTree(_knowledgeTreeData, query); } +function resolveKnowledgePath(currentFilePath, relativeHref) { + // currentFilePath: e.g. "concepts/mcp-protocol.md" + // relativeHref: e.g. "../entities/openai.md" + const parts = currentFilePath.split('/'); + parts.pop(); // remove filename, keep directory + const segments = [...parts, ...relativeHref.split('/')]; + const resolved = []; + for (const seg of segments) { + if (seg === '..') resolved.pop(); + else if (seg !== '.' && seg !== '') resolved.push(seg); + } + return resolved.join('/'); +} + +function bindKnowledgeLinks(container, currentFilePath) { + container.querySelectorAll('a').forEach(a => { + const href = a.getAttribute('href'); + if (!href || !href.endsWith('.md')) return; + // Skip absolute URLs + if (/^https?:\/\//.test(href)) return; + + a.addEventListener('click', (e) => { + e.preventDefault(); + const resolved = resolveKnowledgePath(currentFilePath, href); + const linkTitle = a.textContent.trim() || resolved.replace(/\.md$/, '').split('/').pop(); + openKnowledgeFile(resolved, linkTitle); + }); + a.style.cursor = 'pointer'; + a.classList.add('text-primary-500', 'hover:underline'); + }); +} + +function bindChatKnowledgeLinks(container) { + if (!container) return; + container.querySelectorAll('a').forEach(a => { + const href = a.getAttribute('href'); + if (!href || !href.endsWith('.md')) return; + if (/^https?:\/\//.test(href)) return; + + // Determine knowledge path + let knowledgePath = null; + if (href.startsWith('knowledge/')) { + // Full path from workspace root: knowledge/concepts/moe.md + knowledgePath = href.replace(/^knowledge\//, ''); + } else if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(href)) { + // Looks like category/file.md pattern without knowledge/ prefix + knowledgePath = href; + } else if (href.includes('/') && !href.startsWith('/')) { + // Relative path like ../entities/deepseek.md — extract filename and search + const filename = href.split('/').pop(); + knowledgePath = '__search__:' + filename; + } + if (!knowledgePath) return; + + a.addEventListener('click', (e) => { + e.preventDefault(); + if (knowledgePath.startsWith('__search__:')) { + const filename = knowledgePath.replace('__search__:', ''); + // Find the file in cached tree data + const found = _findKnowledgeFileByName(filename); + if (found) { + navigateTo('knowledge'); + setTimeout(() => openKnowledgeFile(found.path, found.title), 100); + } + } else { + navigateTo('knowledge'); + const linkTitle = a.textContent.trim() || knowledgePath.replace(/\.md$/, '').split('/').pop(); + setTimeout(() => openKnowledgeFile(knowledgePath, linkTitle), 100); + } + }); + a.style.cursor = 'pointer'; + a.classList.add('text-primary-500', 'hover:underline'); + }); +} + +function _findKnowledgeFileByName(filename) { + for (const group of _knowledgeTreeData) { + for (const f of group.files) { + if (f.name === filename) { + return { path: group.dir + '/' + f.name, title: f.title }; + } + } + } + return null; +} + function openKnowledgeFile(path, title) { _knowledgeCurrentFile = path; // Update active state in tree via data-path @@ -3017,9 +3105,11 @@ function openKnowledgeFile(path, title) { const viewer = document.getElementById('knowledge-content-viewer'); document.getElementById('knowledge-viewer-title').textContent = title; document.getElementById('knowledge-viewer-path').textContent = path; - document.getElementById('knowledge-viewer-body').innerHTML = renderMarkdown(data.content || ''); + const bodyEl = document.getElementById('knowledge-viewer-body'); + bodyEl.innerHTML = renderMarkdown(data.content || ''); viewer.classList.remove('hidden'); applyHighlighting(viewer); + bindKnowledgeLinks(bodyEl, path); // Mobile: hide sidebar, show content if (window.innerWidth < 768) { @@ -3234,6 +3324,12 @@ function renderKnowledgeGraph(container, nodes, links) { // ===================================================================== applyTheme(); applyI18n(); + +// Pre-fetch knowledge tree for chat link resolution +fetch('/api/knowledge/list').then(r => r.json()).then(data => { + if (data.status === 'success') _knowledgeTreeData = data.tree || []; +}).catch(() => {}); + fetch('/api/version').then(r => r.json()).then(data => { APP_VERSION = `v${data.version}`; document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md index b72a2114..508441ac 100644 --- a/skills/knowledge-wiki/SKILL.md +++ b/skills/knowledge-wiki/SKILL.md @@ -88,3 +88,4 @@ Append-only, newest at bottom: - **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 +- **Full paths in replies**: when referencing knowledge files in conversation replies, use the full path from workspace root (e.g. `[Title](knowledge//.md)`), not relative paths. Relative paths are only for cross-references inside knowledge pages themselves. From a1d82f6193c3d50e63c634a19e86f69052925973 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 16:39:06 +0800 Subject: [PATCH 106/399] feat(knowledge): add cli and update docs --- README.md | 3 +- agent/knowledge/__init__.py | 0 agent/knowledge/service.py | 218 +++++++++++++++++++++++++++++++ agent/tools/memory/memory_get.py | 6 +- channel/web/web_channel.py | 128 +++--------------- cli/VERSION | 2 +- cli/cli.py | 3 + cli/commands/knowledge.py | 121 +++++++++++++++++ common/cloud_client.py | 37 ++++++ config-template.json | 3 +- config.py | 4 +- docs/cli/general.mdx | 39 ++++++ docs/cli/index.mdx | 7 + docs/docs.json | 33 +++++ docs/en/cli/general.mdx | 25 ++++ docs/en/cli/index.mdx | 7 + docs/en/intro/architecture.mdx | 24 ++-- docs/en/intro/features.mdx | 35 +++-- docs/en/intro/index.mdx | 3 + docs/en/knowledge/index.mdx | 77 +++++++++++ docs/en/tools/memory.mdx | 19 ++- docs/intro/architecture.mdx | 38 +++--- docs/intro/features.mdx | 37 ++++-- docs/intro/index.mdx | 8 +- docs/ja/cli/general.mdx | 25 ++++ docs/ja/cli/index.mdx | 7 + docs/ja/intro/architecture.mdx | 24 ++-- docs/ja/intro/features.mdx | 35 +++-- docs/ja/intro/index.mdx | 3 + docs/ja/knowledge/index.mdx | 77 +++++++++++ docs/ja/tools/memory.mdx | 19 ++- docs/knowledge/index.mdx | 77 +++++++++++ docs/tools/memory.mdx | 19 ++- skills/knowledge-wiki/SKILL.md | 1 + 34 files changed, 956 insertions(+), 208 deletions(-) create mode 100644 agent/knowledge/__init__.py create mode 100644 agent/knowledge/service.py create mode 100644 cli/commands/knowledge.py create mode 100644 docs/en/knowledge/index.mdx create mode 100644 docs/ja/knowledge/index.mdx create mode 100644 docs/knowledge/index.mdx diff --git a/README.md b/README.md index 7478b99d..89dee8fe 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小时运行于你的个人电脑或服务器中。

🌐 官网  ·  @@ -24,6 +24,7 @@ - ✅ **自主任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标 - ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆和日级记忆,支持关键词及向量检索 +- ✅ **个人知识库:** 自动整理结构化知识,通过交叉引用构建知识图谱,支持通过对话管理和可视化浏览知识库 - ✅ **技能系统:** Skills 安装和运行的引擎,支持从 [Skill Hub](https://skills.cowagent.ai/)、GitHub 等一键安装技能,或通过对话创造 Skills - ✅ **工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具,Agent 自主调用以完成复杂任务 - ✅ **CLI系统:** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作 diff --git a/agent/knowledge/__init__.py b/agent/knowledge/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py new file mode 100644 index 00000000..a4fc3d6b --- /dev/null +++ b/agent/knowledge/service.py @@ -0,0 +1,218 @@ +""" +Knowledge service for handling knowledge base operations. + +Provides a unified interface for listing, reading, and graphing knowledge files, +callable from the web console, API, or CLI. + +Knowledge file layout (under workspace_root): + knowledge/index.md + knowledge/log.md + knowledge//.md +""" + +import os +import re +from pathlib import Path +from typing import Optional + +from common.log import logger +from config import conf + + +class KnowledgeService: + """ + High-level service for knowledge base queries. + Operates directly on the filesystem. + """ + + def __init__(self, workspace_root: str): + self.workspace_root = workspace_root + self.knowledge_dir = os.path.join(workspace_root, "knowledge") + + # ------------------------------------------------------------------ + # list — directory tree with stats + # ------------------------------------------------------------------ + def list_tree(self) -> dict: + """ + Return the knowledge directory tree grouped by category. + + Returns:: + + { + "tree": [ + { + "dir": "concepts", + "files": [ + {"name": "moe.md", "title": "MoE", "size": 1234}, + ... + ] + }, + ... + ], + "stats": {"pages": 15, "size": 32768}, + "enabled": true + } + """ + if not os.path.isdir(self.knowledge_dir): + return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)} + + tree = [] + total_files = 0 + total_bytes = 0 + for name in sorted(os.listdir(self.knowledge_dir)): + full = os.path.join(self.knowledge_dir, name) + if not os.path.isdir(full) or name.startswith("."): + continue + files = [] + for fname in sorted(os.listdir(full)): + if fname.endswith(".md") and not fname.startswith("."): + fpath = os.path.join(full, fname) + size = os.path.getsize(fpath) + total_files += 1 + total_bytes += size + title = fname.replace(".md", "") + try: + with open(fpath, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line.startswith("# "): + title = first_line[2:].strip() + except Exception: + pass + files.append({"name": fname, "title": title, "size": size}) + tree.append({"dir": name, "files": files}) + + return { + "tree": tree, + "stats": {"pages": total_files, "size": total_bytes}, + "enabled": conf().get("knowledge", True), + } + + # ------------------------------------------------------------------ + # read — single file content + # ------------------------------------------------------------------ + def read_file(self, rel_path: str) -> dict: + """ + Read a single knowledge markdown file. + + :param rel_path: Relative path within knowledge/, e.g. ``concepts/moe.md`` + :return: dict with ``content`` and ``path`` + :raises ValueError: if path is invalid or escapes knowledge dir + :raises FileNotFoundError: if file does not exist + """ + if not rel_path or ".." in rel_path: + raise ValueError("invalid path") + + full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path)) + allowed = os.path.normpath(self.knowledge_dir) + if not full_path.startswith(allowed + os.sep) and full_path != allowed: + raise ValueError("path outside knowledge dir") + + if not os.path.isfile(full_path): + raise FileNotFoundError(f"file not found: {rel_path}") + + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + return {"content": content, "path": rel_path} + + # ------------------------------------------------------------------ + # graph — nodes and links for visualization + # ------------------------------------------------------------------ + def build_graph(self) -> dict: + """ + Parse all knowledge pages and extract cross-reference links. + + Returns:: + + { + "nodes": [ + {"id": "concepts/moe.md", "label": "MoE", "category": "concepts"}, + ... + ], + "links": [ + {"source": "concepts/moe.md", "target": "entities/deepseek.md"}, + ... + ] + } + """ + knowledge_path = Path(self.knowledge_dir) + if not knowledge_path.is_dir(): + return {"nodes": [], "links": []} + + nodes = {} + links = [] + link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)') + + for md_file in knowledge_path.rglob("*.md"): + rel = str(md_file.relative_to(knowledge_path)) + if rel in ("index.md", "log.md"): + continue + parts = rel.split("/") + category = parts[0] if len(parts) > 1 else "root" + title = md_file.stem.replace("-", " ").title() + try: + content = md_file.read_text(encoding="utf-8") + first_line = content.strip().split("\n")[0] + if first_line.startswith("# "): + title = first_line[2:].strip() + for _, link_target in link_re.findall(content): + resolved = (md_file.parent / link_target).resolve() + try: + target_rel = str(resolved.relative_to(knowledge_path)) + except ValueError: + continue + if target_rel != rel: + links.append({"source": rel, "target": target_rel}) + except Exception: + pass + nodes[rel] = {"id": rel, "label": title, "category": category} + + valid_ids = set(nodes.keys()) + links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids] + seen = set() + deduped = [] + for l in links: + key = tuple(sorted([l["source"], l["target"]])) + if key not in seen: + seen.add(key) + deduped.append(l) + + return {"nodes": list(nodes.values()), "links": deduped} + + # ------------------------------------------------------------------ + # dispatch — single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a knowledge management action. + + :param action: ``list``, ``read``, or ``graph`` + :param payload: action-specific payload + :return: protocol-compatible response dict + """ + payload = payload or {} + try: + if action == "list": + result = self.list_tree() + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "read": + path = payload.get("path") + if not path: + return {"action": action, "code": 400, "message": "path is required", "payload": None} + result = self.read_file(path) + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "graph": + result = self.build_graph() + return {"action": action, "code": 200, "message": "success", "payload": result} + + else: + return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + + except ValueError as e: + return {"action": action, "code": 403, "message": str(e), "payload": None} + except FileNotFoundError as e: + return {"action": action, "code": 404, "message": str(e), "payload": None} + except Exception as e: + logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} diff --git a/agent/tools/memory/memory_get.py b/agent/tools/memory/memory_get.py index 1bd4828e..ec466849 100644 --- a/agent/tools/memory/memory_get.py +++ b/agent/tools/memory/memory_get.py @@ -85,7 +85,11 @@ class MemoryGetTool(BaseTool): if not path.startswith('memory/') and not path.startswith('knowledge/') and not path.startswith('/') and path != 'MEMORY.md': path = f'memory/{path}' - file_path = workspace_dir / path + file_path = (workspace_dir / path).resolve() + workspace_resolved = workspace_dir.resolve() + + if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved: + return ToolResult.fail(f"Error: Access denied: path outside workspace") if not file_path.exists(): return ToolResult.fail(f"Error: File not found: {path}") diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index f58238c4..8a5fbaf0 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1535,141 +1535,45 @@ class AssetsHandler: class KnowledgeListHandler: - """Return the knowledge directory tree as JSON.""" - def GET(self): web.header('Content-Type', 'application/json; charset=utf-8') try: - workspace_root = _get_workspace_root() - knowledge_dir = os.path.join(workspace_root, "knowledge") - if not os.path.isdir(knowledge_dir): - return json.dumps({"status": "success", "tree": [], "stats": {"pages": 0, "size": 0}}) - - tree = [] - total_files = 0 - total_bytes = 0 - for name in sorted(os.listdir(knowledge_dir)): - full = os.path.join(knowledge_dir, name) - if not os.path.isdir(full) or name.startswith("."): - continue - files = [] - for fname in sorted(os.listdir(full)): - if fname.endswith(".md") and not fname.startswith("."): - fpath = os.path.join(full, fname) - size = os.path.getsize(fpath) - total_files += 1 - total_bytes += size - title = fname.replace(".md", "") - try: - with open(fpath, "r", encoding="utf-8") as f: - first_line = f.readline().strip() - if first_line.startswith("# "): - title = first_line[2:].strip() - except Exception: - pass - files.append({"name": fname, "title": title, "size": size}) - tree.append({"dir": name, "files": files}) - - return json.dumps({ - "status": "success", - "tree": tree, - "stats": {"pages": total_files, "size": total_bytes}, - "enabled": conf().get("knowledge", True), - }, ensure_ascii=False) + from agent.knowledge.service import KnowledgeService + svc = KnowledgeService(_get_workspace_root()) + result = svc.list_tree() + return json.dumps({"status": "success", **result}, ensure_ascii=False) except Exception as e: logger.error(f"[WebChannel] Knowledge list error: {e}") return json.dumps({"status": "error", "message": str(e)}) class KnowledgeReadHandler: - """Read a single knowledge markdown file.""" - def GET(self): web.header('Content-Type', 'application/json; charset=utf-8') try: + from agent.knowledge.service import KnowledgeService params = web.input(path='') - rel_path = params.path - if not rel_path or ".." in rel_path: - return json.dumps({"status": "error", "message": "invalid path"}) - - workspace_root = _get_workspace_root() - full_path = os.path.join(workspace_root, "knowledge", rel_path) - full_path = os.path.normpath(full_path) - knowledge_dir = os.path.normpath(os.path.join(workspace_root, "knowledge")) - if not full_path.startswith(knowledge_dir): - return json.dumps({"status": "error", "message": "path outside knowledge dir"}) - - if not os.path.isfile(full_path): - return json.dumps({"status": "error", "message": "file not found"}) - - with open(full_path, "r", encoding="utf-8") as f: - content = f.read() - return json.dumps({"status": "success", "content": content, "path": rel_path}, ensure_ascii=False) + svc = KnowledgeService(_get_workspace_root()) + result = svc.read_file(params.path) + return json.dumps({"status": "success", **result}, ensure_ascii=False) + except (ValueError, FileNotFoundError) as e: + return json.dumps({"status": "error", "message": str(e)}) except Exception as e: logger.error(f"[WebChannel] Knowledge read error: {e}") return json.dumps({"status": "error", "message": str(e)}) class KnowledgeGraphHandler: - """Return nodes and links for the knowledge graph visualization.""" - def GET(self): web.header('Content-Type', 'application/json; charset=utf-8') - import re - from pathlib import Path - - workspace_root = _get_workspace_root() - knowledge_dir = Path(workspace_root) / "knowledge" - if not knowledge_dir.is_dir(): + try: + from agent.knowledge.service import KnowledgeService + svc = KnowledgeService(_get_workspace_root()) + return json.dumps(svc.build_graph(), ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Knowledge graph error: {e}") return json.dumps({"nodes": [], "links": []}) - nodes = {} - links = [] - link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)') - - for md_file in knowledge_dir.rglob("*.md"): - rel = str(md_file.relative_to(knowledge_dir)) - if rel in ("index.md", "log.md"): - continue - parts = rel.split("/") - category = parts[0] if len(parts) > 1 else "root" - title = md_file.stem.replace("-", " ").title() - try: - content = md_file.read_text(encoding="utf-8") - first_line = content.strip().split("\n")[0] - if first_line.startswith("# "): - title = first_line[2:].strip() - except Exception: - pass - nodes[rel] = {"id": rel, "label": title, "category": category} - try: - content = md_file.read_text(encoding="utf-8") - for _, link_target in link_re.findall(content): - resolved = (md_file.parent / link_target).resolve() - try: - target_rel = str(resolved.relative_to(knowledge_dir)) - except ValueError: - continue - if target_rel != rel: - links.append({"source": rel, "target": target_rel}) - except Exception: - pass - - valid_ids = set(nodes.keys()) - links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids] - seen = set() - deduped = [] - for l in links: - key = tuple(sorted([l["source"], l["target"]])) - if key not in seen: - seen.add(key) - deduped.append(l) - - return json.dumps({ - "nodes": list(nodes.values()), - "links": deduped, - }, ensure_ascii=False) - class VersionHandler: def GET(self): diff --git a/cli/VERSION b/cli/VERSION index e0102586..157e54f3 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.0.5 +2.0.6 diff --git a/cli/cli.py b/cli/cli.py index 6740667f..9bc6f48f 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -6,6 +6,7 @@ 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 +from cli.commands.knowledge import knowledge HELP_TEXT = """Usage: cow COMMAND [ARGS]... @@ -22,6 +23,7 @@ Commands: status Show CowAgent running status. logs View CowAgent logs. skill Manage CowAgent skills. + knowledge Manage knowledge base. install-browser Install browser tool (Playwright + Chromium). Tip: You can also send /help, /skill list, etc. in agent chat.""" @@ -69,6 +71,7 @@ main.add_command(update) main.add_command(status) main.add_command(logs) main.add_command(context) +main.add_command(knowledge) main.add_command(install_browser) diff --git a/cli/commands/knowledge.py b/cli/commands/knowledge.py new file mode 100644 index 00000000..296ca6c9 --- /dev/null +++ b/cli/commands/knowledge.py @@ -0,0 +1,121 @@ +"""cow knowledge - Knowledge base management commands.""" + +import os + +import click + +from cli.utils import get_project_root + + +def _get_knowledge_dir(): + """Resolve the knowledge directory path from config or default.""" + try: + import sys + sys.path.insert(0, get_project_root()) + from config import conf + from common.utils import expand_path + workspace = expand_path(conf().get("agent_workspace", "~/cow")) + except Exception: + workspace = os.path.expanduser("~/cow") + return os.path.join(workspace, "knowledge") + + +def _get_knowledge_enabled(): + try: + import sys + sys.path.insert(0, get_project_root()) + from config import conf + return conf().get("knowledge", True) + except Exception: + return True + + +@click.group(invoke_without_command=True) +@click.pass_context +def knowledge(ctx): + """Manage CowAgent knowledge base.""" + if ctx.invoked_subcommand is None: + click.echo(_stats()) + + +@knowledge.command("list") +def knowledge_list(): + """Display knowledge base file tree.""" + click.echo(_tree()) + + +def _stats() -> str: + knowledge_dir = _get_knowledge_dir() + if not os.path.isdir(knowledge_dir): + return "Knowledge base directory not found." + + enabled = _get_knowledge_enabled() + 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_icon = click.style("enabled", fg="green") if enabled else click.style("disabled", fg="red") + lines = [ + f"\n Knowledge Base [{status_icon}]", + "", + f" Pages: {total_files}", + f" Size: {total_bytes / 1024:.1f} KB", + "", + ] + if cat_count: + lines.append(" Categories:") + for cat in sorted(cat_count.keys()): + lines.append(f" {cat}/ ({cat_count[cat]} pages)") + lines.append("") + + lines.append(f" Path: {knowledge_dir}") + lines.append("") + return "\n".join(lines) + + +def _tree() -> str: + knowledge_dir = _get_knowledge_dir() + if not os.path.isdir(knowledge_dir): + return "Knowledge base directory not found." + + tree_lines = [" 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_lines.append(f" {branch}{subdir}/ ({len(md_files)})") + + child_prefix = " " if is_last_dir else " │ " + max_show = 15 + 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_lines.append(f"{child_prefix}{fb}{name}") + if len(md_files) > max_show: + tree_lines.append(f"{child_prefix}└── ... +{len(md_files) - max_show} more") + + if not subdirs: + tree_lines.append(" (empty)") + + return "\n" + "\n".join(tree_lines) + "\n" diff --git a/common/cloud_client.py b/common/cloud_client.py index 656c1604..870f5dc4 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -54,6 +54,7 @@ class CloudClient(LinkAIClient): self.channel_mgr = None self._skill_service = None self._memory_service = None + self._knowledge_service = None self._chat_service = None @property @@ -88,6 +89,21 @@ class CloudClient(LinkAIClient): logger.error(f"[CloudClient] Failed to init MemoryService: {e}") return self._memory_service + @property + def knowledge_service(self): + """Lazy-init KnowledgeService.""" + if self._knowledge_service is None: + try: + from agent.knowledge.service import KnowledgeService + from config import conf + from common.utils import expand_path + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + self._knowledge_service = KnowledgeService(workspace_root) + logger.debug("[CloudClient] KnowledgeService initialised") + except Exception as e: + logger.error(f"[CloudClient] Failed to init KnowledgeService: {e}") + return self._knowledge_service + @property def chat_service(self): """Lazy-init ChatService (requires AgentBridge via Bridge singleton).""" @@ -468,6 +484,27 @@ class CloudClient(LinkAIClient): return svc.dispatch(action, payload) + # ------------------------------------------------------------------ + # knowledge callback + # ------------------------------------------------------------------ + def on_knowledge(self, data: dict) -> dict: + """ + Handle KNOWLEDGE messages from the cloud console. + Delegates to KnowledgeService.dispatch for the actual operations. + + :param data: message data with 'action', 'clientId', 'payload' + :return: response dict + """ + action = data.get("action", "") + payload = data.get("payload") + logger.info(f"[CloudClient] on_knowledge: action={action}") + + svc = self.knowledge_service + if svc is None: + return {"action": action, "code": 500, "message": "KnowledgeService not available", "payload": None} + + return svc.dispatch(action, payload) + # ------------------------------------------------------------------ # chat callback # ------------------------------------------------------------------ diff --git a/config-template.json b/config-template.json index d5a80440..65ecc651 100644 --- a/config-template.json +++ b/config-template.json @@ -29,5 +29,6 @@ "agent": true, "agent_max_context_tokens": 40000, "agent_max_context_turns": 20, - "agent_max_steps": 15 + "agent_max_steps": 15, + "knowledge": true } diff --git a/config.py b/config.py index ab7da486..6b231ed2 100644 --- a/config.py +++ b/config.py @@ -180,14 +180,14 @@ available_setting = { # 豆包(火山方舟) 平台配置 "ark_api_key": "", "ark_base_url": "https://ark.cn-beijing.volces.com/api/v3", - #魔搭社区 平台配置 + # 魔搭社区 平台配置 "modelscope_api_key": "", "modelscope_base_url": "https://api-inference.modelscope.cn/v1/chat/completions", # LinkAI平台配置 "use_linkai": False, "linkai_api_key": "", "linkai_app_code": "", - "linkai_api_base": "https://api.link-ai.tech", # linkAI服务地址 + "linkai_api_base": "https://api.link-ai.tech", "cloud_host": "client.link-ai.tech", "cloud_deployment_id": "", "minimax_api_key": "", diff --git a/docs/cli/general.mdx b/docs/cli/general.mdx index 8ab9fb75..32fb5a1c 100644 --- a/docs/cli/general.mdx +++ b/docs/cli/general.mdx @@ -106,6 +106,45 @@ Session: 12 messages | 8 skills loaded /logs 50 ``` +## knowledge + +查看和管理个人知识库。默认显示知识库统计信息。 + +```text +/knowledge +``` + +输出示例: + +``` +📚 知识库 + +- 状态:已开启 +- 页面数:12 +- 总大小:45.2 KB +- 分类明细: + - concepts/: 5 篇 + - entities/: 4 篇 + - sources/: 3 篇 +``` + +**查看目录结构:** + +```text +/knowledge list +``` + +**开启 / 关闭知识库:** + +```text +/knowledge on +/knowledge off +``` + + + 终端 CLI 中 `cow knowledge` 和 `cow knowledge list` 可用,但 `on|off` 仅支持在对话中使用(需实时生效)。 + + ## version 显示当前 CowAgent 版本号。 diff --git a/docs/cli/index.mdx b/docs/cli/index.mdx index f9282607..469b9d7c 100644 --- a/docs/cli/index.mdx +++ b/docs/cli/index.mdx @@ -40,6 +40,9 @@ Service: Skills: skill Manage skills (list / search / install / uninstall ...) +Knowledge: + knowledge View knowledge base stats and structure + Others: help Show this help message version Show version @@ -55,6 +58,9 @@ Others: | `/status` | 查看服务状态和配置 | | `/config` | 查看或修改运行时配置 | | `/skill` | 管理技能(安装、卸载、启用、禁用等) | +| `/knowledge` | 查看知识库统计信息 | +| `/knowledge list` | 查看知识库目录结构 | +| `/knowledge on\|off` | 开启或关闭知识库 | | `/context` | 查看当前会话上下文信息 | | `/context clear` | 清空当前会话上下文 | | `/logs` | 查看最近日志 | @@ -76,6 +82,7 @@ Others: | logs | ✓ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | +| knowledge (子命令) | ✓ | ✓ | | skill (子命令) | ✓ | ✓ | | start / stop / restart | ✓ | ✗ | | update | ✓ | ✗ | diff --git a/docs/docs.json b/docs/docs.json index df8025a8..a1ca6a00 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -147,6 +147,17 @@ } ] }, + { + "tab": "知识", + "groups": [ + { + "group": "知识库", + "pages": [ + "knowledge/index" + ] + } + ] + }, { "tab": "通道", "groups": [ @@ -308,6 +319,17 @@ } ] }, + { + "tab": "Knowledge", + "groups": [ + { + "group": "Knowledge Base", + "pages": [ + "en/knowledge/index" + ] + } + ] + }, { "tab": "Channels", "groups": [ @@ -469,6 +491,17 @@ } ] }, + { + "tab": "ナレッジ", + "groups": [ + { + "group": "ナレッジベース", + "pages": [ + "ja/knowledge/index" + ] + } + ] + }, { "tab": "チャネル", "groups": [ diff --git a/docs/en/cli/general.mdx b/docs/en/cli/general.mdx index 186f51a9..67ba435c 100644 --- a/docs/en/cli/general.mdx +++ b/docs/en/cli/general.mdx @@ -92,6 +92,31 @@ View recent service logs. Shows the last 20 lines by default, up to 50. /logs 50 ``` +## knowledge + +View and manage the personal knowledge base. Shows statistics by default. + +```text +/knowledge +``` + +**View directory structure:** + +```text +/knowledge list +``` + +**Enable / disable knowledge base:** + +```text +/knowledge on +/knowledge off +``` + + + In the terminal CLI, `cow knowledge` and `cow knowledge list` are available, but `on|off` is only supported in chat (requires runtime effect). + + ## version Show the current CowAgent version. diff --git a/docs/en/cli/index.mdx b/docs/en/cli/index.mdx index 4d7c91a5..0b1232f1 100644 --- a/docs/en/cli/index.mdx +++ b/docs/en/cli/index.mdx @@ -40,6 +40,9 @@ Service: Skills: skill Manage skills (list / search / install / uninstall ...) +Knowledge: + knowledge View knowledge base stats and structure + Others: help Show this help message version Show version @@ -55,6 +58,9 @@ In the Web console or any connected channel, type `/` to see command suggestions | `/status` | View service status and configuration | | `/config` | View or modify runtime configuration | | `/skill` | Manage skills (install, uninstall, enable, disable, etc.) | +| `/knowledge` | View knowledge base statistics | +| `/knowledge list` | View knowledge base directory structure | +| `/knowledge on\|off` | Enable or disable knowledge base | | `/context` | View current session context info | | `/context clear` | Clear current session context | | `/logs` | View recent logs | @@ -74,6 +80,7 @@ In the Web console or any connected channel, type `/` to see command suggestions | logs | ✓ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | +| knowledge (subcommands) | ✓ | ✓ | | skill (subcommands) | ✓ | ✓ | | start / stop / restart | ✓ | ✗ | | update | ✓ | ✗ | diff --git a/docs/en/intro/architecture.mdx b/docs/en/intro/architecture.mdx index cb1c7d76..3719df04 100644 --- a/docs/en/intro/architecture.mdx +++ b/docs/en/intro/architecture.mdx @@ -11,14 +11,16 @@ CowAgent's architecture consists of the following core modules: CowAgent Architecture -### Core Modules - | Module | Description | | --- | --- | -| **Channels** | Message channel layer for receiving and sending messages. Supports Web, Feishu, DingTalk, WeCom, WeChat Official Account, and more | -| **Agent Core** | Agent engine including task planning, memory system, and skills engine | -| **Tools** | Tool layer for Agent to access OS resources. 10+ built-in tools | -| **Models** | Model layer with unified access to mainstream LLMs | +| **Plan** | Understands user intent, decomposes complex tasks into multi-step plans, and iteratively invokes tools until the goal is achieved | +| **Memory** | Automatically persists important information as core memory and daily memory, with hybrid keyword and vector retrieval for cross-session context continuity | +| **Knowledge** | Organizes structured knowledge by topic. The Agent autonomously distills valuable information into Markdown pages, maintaining indexes and cross-references to build a growing knowledge network | +| **Tools** | Core capability for Agent to access OS resources. 10+ built-in tools including file read/write, terminal, browser, scheduler, memory search, web search, and more | +| **Skills** | Loads and manages Skills. Supports one-click installation from Skill Hub, GitHub, and more, or custom skill creation through conversation | +| **Models** | Model layer with unified access to OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, and other mainstream LLMs | +| **Channels** | Message channel layer for receiving and sending messages. Supports Web console, WeChat, Feishu, DingTalk, WeCom, WeChat Official Account, and more with a unified protocol | +| **CLI** | Command-line system providing terminal commands (`cow`) and chat commands (`/`) for process management, skill installation, configuration, knowledge base management, and more | ## Agent Mode Workflow @@ -28,7 +30,7 @@ When Agent mode is enabled, CowAgent runs as an autonomous agent with the follow 2. **Understand Intent** — Analyze task requirements and context 3. **Plan Task** — Break complex tasks into multiple steps 4. **Invoke Tools** — Select and execute appropriate tools for each step -5. **Update Memory** — Store important information in long-term memory +5. **Update Memory & Knowledge** — Store important information in long-term memory and organize structured knowledge into the knowledge base 6. **Return Result** — Send execution results back to the user ## Workspace Directory Structure @@ -39,9 +41,12 @@ The Agent workspace is located at `~/cow` by default and stores system prompts, ~/cow/ ├── system.md # Agent system prompt ├── user.md # User profile +├── MEMORY.md # Core memory ├── memory/ # Long-term memory storage -│ ├── core.md # Core memory -│ └── daily/ # Daily memory +│ └── YYYY-MM-DD.md # Daily memory +├── knowledge/ # Personal knowledge base +│ ├── index.md # Knowledge index +│ └── / # Topic-based pages └── skills/ # Custom skills ├── skill-1/ └── skill-2/ @@ -75,3 +80,4 @@ Configure Agent mode parameters in `config.json`: | `agent_max_context_tokens` | Max context tokens | `40000` | | `agent_max_context_turns` | Max context turns | `30` | | `agent_max_steps` | Max decision steps per task | `15` | +| `knowledge` | Enable personal knowledge base | `true` | diff --git a/docs/en/intro/features.mdx b/docs/en/intro/features.mdx index 66c2427d..41fa11a4 100644 --- a/docs/en/intro/features.mdx +++ b/docs/en/intro/features.mdx @@ -15,13 +15,26 @@ In subsequent long-term conversations, the Agent intelligently stores or retriev -## 2. Task Planning and Tool Use +## 2. Personal Knowledge Base + +> The knowledge base system enables the Agent to continuously accumulate and organize structured knowledge. Unlike memory which records along a timeline, the knowledge base is organized by topics, transforming articles, conversation insights, and learning materials into interconnected Markdown pages that form a continuously growing knowledge network. + +The Agent automatically organizes valuable information from conversations into knowledge pages, maintaining cross-references and indexes. The Web console provides document browsing and knowledge graph visualization. Knowledge is stored in `~/cow/knowledge/` within the workspace. + +- **Auto-organization**: The Agent autonomously extracts and organizes structured knowledge during conversations, maintaining indexes and cross-references +- **Knowledge graph**: Automatically builds a knowledge graph from cross-references between pages, with interactive graph visualization in the Web console +- **Chat integration**: Knowledge document links referenced in Agent replies can be clicked directly in the Web console for viewing +- **CLI management**: Use `/knowledge` commands to view stats, browse directory, and toggle the feature with `/knowledge on|off` + +See [Personal Knowledge Base](/en/knowledge) for details. + +## 3. Task Planning and Tool Use 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, browser, file send, scheduler, memory search, web search, environment config, and more. -### 2.1 Terminal and File Access +### 3.1 Terminal and File Access Access to the OS terminal and file system is the most fundamental and core capability. Many other tools and skills build on top of this. Users can interact with the Agent from a mobile device to operate resources on their personal computer or server: @@ -29,7 +42,7 @@ Access to the OS terminal and file system is the most fundamental and core capab -### 2.2 Programming Capability +### 3.2 Programming Capability Combining programming and system access, the Agent can execute the complete **Vibecoding workflow** — from information search, asset generation, coding, testing, deployment, Nginx configuration, to publishing — all triggered by a single command from your phone: @@ -37,7 +50,7 @@ Combining programming and system access, the Agent can execute the complete **Vi -### 2.3 Scheduled Tasks +### 3.3 Scheduled Tasks The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time tasks, fixed intervals, and Cron expressions**. Tasks can be triggered as either a **fixed message send** or an **Agent dynamic task** execution: @@ -45,7 +58,7 @@ The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time task -### 2.4 Browser +### 3.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: @@ -53,7 +66,7 @@ The built-in `browser` tool allows the Agent to control a Chromium browser to vi -### 2.5 Environment Variable Management +### 3.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: @@ -61,7 +74,7 @@ Secrets required by skills are stored in an environment variable file, managed b -## 3. Skills System +## 4. Skills System 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. @@ -71,7 +84,7 @@ The Skills system provides infinite extensibility for the Agent. Each Skill cons Install skills: `/skill install ` or `cow skill install `, supporting Skill Hub, GitHub, ClawHub, URL, and more. -### 3.1 Creating Skills +### 4.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: @@ -79,7 +92,7 @@ The `skill-creator` skill enables rapid skill creation through conversation. You -### 3.2 Web Search and Image Recognition +### 4.2 Web Search and Image Recognition - **Web search:** Built-in `web_search` tool, supports multiple search engines. Configure `BOCHA_API_KEY` or `LINKAI_API_KEY` to enable. - **Image recognition:** Built-in `openai-image-vision` skill, supports `gpt-4.1-mini`, `gpt-4.1`, and other models. Requires `OPENAI_API_KEY`. @@ -88,7 +101,7 @@ The `skill-creator` skill enables rapid skill creation through conversation. You -### 3.3 Skill Hub +### 4.3 Skill Hub Visit [skills.cowagent.ai](https://skills.cowagent.ai/) to browse all available skills, or use commands in conversation: @@ -102,7 +115,7 @@ Also supports installing skills from GitHub, ClawHub, LinkAI, and other third-pa -## 4. CLI Command System +## 5. CLI Command System CowAgent provides two command interaction methods, covering service management, skill installation, configuration, and more: diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx index 109c3097..e4b05352 100644 --- a/docs/en/intro/index.mdx +++ b/docs/en/intro/index.mdx @@ -22,6 +22,9 @@ CowAgent can proactively think and plan tasks, operate computers and external re Automatically persists conversation memory to local files and databases, including core memory and daily memory, with keyword and vector retrieval support. + + Automatically organizes structured knowledge with knowledge graph visualization, building a continuously growing knowledge network through cross-references. + Implements a Skills creation and execution engine with built-in skills, and supports custom Skills development through natural language conversation. diff --git a/docs/en/knowledge/index.mdx b/docs/en/knowledge/index.mdx new file mode 100644 index 00000000..be6d4729 --- /dev/null +++ b/docs/en/knowledge/index.mdx @@ -0,0 +1,77 @@ +--- +title: Personal Knowledge Base +description: CowAgent personal knowledge base — structured knowledge accumulation, automatic organization, and knowledge graph +--- + +The personal knowledge base is the Agent's long-term structured knowledge store, saved in the `knowledge/` directory within the workspace. Unlike memory, which is organized by timeline, the knowledge base organizes content by topic — articles, conversation insights, and learning materials are structured into interlinked Markdown pages, forming a continuously growing knowledge network. + +## Core Concepts + +### Knowledge vs Memory + +| Dimension | Knowledge Base (knowledge/) | Long-term Memory (memory/) | +| --- | --- | --- | +| Organization | By topic, interlinked | By timeline, dated files | +| Writing | Agent actively structures content | Auto-summarized on context trimming | +| Content | Refined, structured knowledge | Raw conversation summaries | +| Use cases | Study notes, tech docs, project knowledge | Conversation history, event records | + +### Directory Structure + +``` +~/cow/knowledge/ +├── index.md # Knowledge index, entry point for all pages +├── log.md # Change log, records each write +├── concepts/ # Conceptual knowledge +│ └── machine-learning.md +├── entities/ # Entity knowledge (people, orgs, tools) +│ └── openai.md +└── sources/ # Source knowledge (articles, papers) + └── llm-wiki.md +``` + +The directory structure is flexible — the Agent automatically creates appropriate category directories based on actual content. Users can also customize the organization. + +## Automatic Organization + +Knowledge writing is an autonomous Agent behavior, triggered in these scenarios: + +- **User shares an article or document** — The Agent automatically extracts key information and creates a structured knowledge page +- **Conversation produces valuable conclusions** — The Agent organizes insights into knowledge pages and links them to existing knowledge +- **User explicitly requests organization** — Users can guide the Agent to organize and update knowledge through conversation + +Each knowledge page includes cross-reference links to related pages, gradually building a knowledge graph. + +## Knowledge Retrieval + +The Agent can retrieve knowledge during conversation through: + +- **Index lookup** — Quickly locate relevant pages via `knowledge/index.md` +- **Semantic search** — Search knowledge content via the `memory_search` tool +- **Direct read** — Read specific knowledge files via the `memory_get` tool + +## Web Console + +The web console provides a dedicated "Knowledge" module with: + +- **Document browsing** — Tree-style directory structure, searchable and collapsible, click to view content +- **Knowledge graph** — D3.js force-directed graph visualizing relationships between knowledge pages +- **Chat integration** — Knowledge document links referenced in Agent replies are clickable for direct navigation + +## CLI Commands + +Manage the knowledge base with the `/knowledge` command: + +| Command | Description | +| --- | --- | +| `/knowledge` | Show knowledge base statistics | +| `/knowledge list` | Display file directory as a tree | +| `/knowledge on` | Enable the knowledge base feature | +| `/knowledge off` | Disable the knowledge base feature | + +## Configuration + +| Parameter | Description | Default | +| --- | --- | --- | +| `knowledge` | Whether to enable the personal knowledge base | `true` | +| `agent_workspace` | Workspace path; knowledge is stored under the `knowledge/` subdirectory | `~/cow` | diff --git a/docs/en/tools/memory.mdx b/docs/en/tools/memory.mdx index a7526336..a1874eef 100644 --- a/docs/en/tools/memory.mdx +++ b/docs/en/tools/memory.mdx @@ -1,9 +1,11 @@ --- -title: memory - Memory -description: Search and read long-term memory +title: memory - Memory & Knowledge +description: Search and read long-term memory and knowledge base files --- -The memory tool contains two sub-tools: `memory_search` (search memory) and `memory_get` (read memory files). +The memory tool contains two sub-tools: `memory_search` (search memory) and `memory_get` (read memory or knowledge files). + +When the [knowledge base](/en/knowledge) feature is enabled, both tools also support accessing files under the `knowledge/` directory. ## Dependencies @@ -11,7 +13,7 @@ No extra dependencies, available by default. Managed by the Agent Core memory sy ## memory_search -Search historical memory with hybrid keyword and vector retrieval. +Search historical memory and knowledge base content with hybrid keyword and vector retrieval. | Parameter | Type | Required | Description | | --- | --- | --- | --- | @@ -19,11 +21,11 @@ Search historical memory with hybrid keyword and vector retrieval. ## memory_get -Read the content of a specific memory file. +Read the content of a specific memory or knowledge file. | Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | Yes | Relative path to memory file (e.g. `MEMORY.md`, `memory/2026-01-01.md`) | +| `path` | string | Yes | Relative path to the file (e.g. `MEMORY.md`, `memory/2026-01-01.md`, `knowledge/concepts/rag.md`) | | `start_line` | integer | No | Start line number | | `end_line` | integer | No | End line number | @@ -34,3 +36,8 @@ The Agent automatically invokes memory tools in these scenarios: - When the user shares important information → stores to memory - When historical context is needed → searches relevant memory - When conversation reaches a certain length → extracts summary for storage +- When discussing domain knowledge → retrieves relevant pages from the knowledge base + + + When `knowledge` is set to `false` in config, the tool descriptions and search scope automatically adjust to include only memory files. + diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index deae6456..5ec593c4 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -11,25 +11,27 @@ CowAgent 的整体架构由以下核心模块组成: CowAgent Architecture -### 核心模块说明 - | 模块 | 说明 | | --- | --- | -| **Channels** | 消息通道层,负责接收和发送消息,支持 Web、飞书、钉钉、企微、公众号等 | -| **Agent Core** | 智能体核心引擎,包括任务规划、记忆系统和技能引擎 | -| **Tools** | 工具层,Agent 通过工具访问操作系统资源,内置 10+ 种工具 | -| **Models** | 模型层,支持国内外主流大语言模型的统一接入 | +| **Plan** | 理解用户意图,将复杂任务分解为多步骤计划,循环调用工具直到完成目标 | +| **Memory** | 自动将重要信息持久化为核心记忆和日级记忆,支持关键词和向量混合检索,跨会话保持上下文连续性 | +| **Knowledge** | 以主题维度组织结构化知识,Agent 自主整理有价值信息为 Markdown 页面,维护索引和交叉引用,构建持续增长的知识网络 | +| **Tools** | Agent 访问操作系统资源的核心能力,内置文件读写、终端执行、浏览器操作、定时调度、记忆检索、联网搜索等 10+ 种工具 | +| **Skills** | 加载和管理 Skills,支持从 Skill Hub、GitHub 等一键安装,或通过对话创建自定义技能 | +| **Models** | 模型层,统一接入 OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen 等国内外主流大语言模型 | +| **Channels** | 消息通道层,负责接收和发送消息,支持 Web 控制台、微信、飞书、钉钉、企微、公众号等,统一消息协议 | +| **CLI** | 命令行系统,提供终端命令(`cow`)和对话命令(`/`),支持进程管理、技能安装、配置修改、知识库管理等操作 | ## Agent 模式 启用 Agent 模式后,CowAgent 会以自主智能体的方式运行,核心工作流如下: -1. **接收消息** - 通过通道接收用户输入 -2. **理解意图** - 分析任务需求和上下文 -3. **规划任务** - 将复杂任务分解为多个步骤 -4. **调用工具** - 选择合适的工具执行每个步骤 -5. **记忆更新** - 将重要信息存入长期记忆 -6. **返回结果** - 将执行结果发送回用户 +1. **接收消息** — 通过通道接收用户输入 +2. **理解意图** — 分析任务需求和上下文 +3. **规划任务** — 将复杂任务分解为多个步骤 +4. **调用工具** — 选择合适的工具执行每个步骤 +5. **记忆与知识更新** — 将重要信息存入长期记忆,将结构化知识整理至知识库 +6. **返回结果** — 将执行结果发送回用户 ## 工作空间 @@ -37,11 +39,14 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 ``` ~/cow/ -├── system.md # Agent system prompt -├── user.md # User profile +├── SYSTEM.md # Agent system prompt +├── USER.md # User profile +├── MEMORY.md # Core memory ├── memory/ # Long-term memory storage -│ ├── core.md # Core memory -│ └── daily/ # Daily memory +│ └── YYYY-MM-DD.md # Daily memory +├── knowledge/ # Personal knowledge base +│ ├── index.md # Knowledge index +│ └── / # Topic-based pages └── skills/ # Custom skills ├── skill-1/ └── skill-2/ @@ -75,3 +80,4 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 | `agent_max_context_tokens` | 最大上下文 token 数 | `40000` | | `agent_max_context_turns` | 最大上下文记忆轮次 | `30` | | `agent_max_steps` | 单次任务最大决策步数 | `15` | +| `knowledge` | 是否启用个人知识库 | `true` | diff --git a/docs/intro/features.mdx b/docs/intro/features.mdx index 5ffca1e2..c3d18731 100644 --- a/docs/intro/features.mdx +++ b/docs/intro/features.mdx @@ -1,6 +1,6 @@ --- title: 功能介绍 -description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、浏览器工具详细说明 +description: CowAgent 长期记忆、个人知识库、任务规划、技能系统、CLI 命令、浏览器工具详细说明 --- ## 1. 长期记忆 @@ -15,13 +15,26 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -## 2. 任务规划和工具调用 +## 2. 个人知识库 + +> 知识库系统让 Agent 能够持续积累和组织结构化知识。与按时间线记录的记忆不同,知识库以主题为维度,将文章、对话洞察、学习材料等整理为互相关联的 Markdown 页面,形成持续增长的知识网络。 + +Agent 会在对话中自动将有价值的信息整理为知识页面,维护交叉引用和索引,通过 Web 控制台可浏览文档和查看知识图谱。知识库存储在工作空间的 `~/cow/knowledge/` 目录下。 + +- **自动整理**:Agent 在对话中自主提取和整理结构化知识,维护索引和交叉引用 +- **知识图谱**:基于页面间的交叉引用自动构建知识图谱,Web 控制台提供可视化关系图浏览 +- **对话联动**:Agent 回复中引用的知识文档链接可在 Web 控制台中直接点击跳转查看 +- **CLI 管理**:通过 `/knowledge` 命令查看统计、浏览目录,通过 `/knowledge on|off` 开关功能 + +详细说明请参考 [个人知识库](/knowledge)。 + +## 3. 任务规划和工具调用 工具是 Agent 访问操作系统资源的核心,Agent 会根据任务需求智能选择和调用工具,完成文件读写、命令执行、定时任务等各类操作。内置工具的实现在项目的 `agent/tools/` 目录下。 **主要工具:** 文件读写编辑、Bash 终端、浏览器操作、文件发送、定时调度、记忆搜索、联网搜索、环境配置等。 -### 2.1 终端和文件访问 +### 3.1 终端和文件访问 针对操作系统的终端和文件的访问能力,是最基础和核心的工具,其他很多工具或技能都是基于此进行扩展。用户可通过手机端与 Agent 交互,操作个人电脑或服务器上的资源: @@ -29,7 +42,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -### 2.2 编程能力 +### 3.2 编程能力 基于编程能力和系统访问能力,Agent 可以实现从信息搜索、图片等素材生成、编码、测试、部署、Nginx 配置修改、发布的 **Vibecoding 全流程**,通过手机端简单的一句命令完成应用的快速 demo: @@ -37,7 +50,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -### 2.3 定时任务 +### 3.3 定时任务 基于 `scheduler` 工具实现动态定时任务,支持**一次性任务、固定时间间隔、Cron 表达式**三种形式,任务触发可选择**固定消息发送**或 **Agent 动态任务**执行两种模式: @@ -45,7 +58,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -### 2.4 浏览器操作 +### 3.4 浏览器操作 内置 `browser` 工具,Agent 可控制浏览器访问网页、填写表单、点击元素、截图,支持动态 JS 渲染页面。运行 `cow install-browser` 一键安装,自动适配服务器(无头模式)和桌面环境: @@ -53,7 +66,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -### 2.5 环境变量管理 +### 3.5 环境变量管理 技能所需的秘钥存储在环境变量文件中,由 `env_config` 工具进行管理,你可以通过对话的方式更新秘钥,工具内置安全保护和脱敏策略: @@ -61,7 +74,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -## 3. 技能系统 +## 4. 技能系统 技能系统为 Agent 提供无限的扩展性,每个 Skill 由说明文件、运行脚本(可选)、资源(可选)组成,描述如何完成特定类型的任务。通过 Skill 可以让 Agent 遵循说明完成复杂流程、调用各类工具或对接第三方系统。 @@ -71,7 +84,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 安装技能:`/skill install <名称>` 或 `cow skill install <名称>`,支持从 Skill Hub、GitHub、ClawHub、URL 等来源安装。 -### 3.1 创建技能 +### 4.1 创建技能 通过 `skill-creator` 技能可以通过对话的方式快速创建技能。你可以让 Agent 将某个工作流程固化为技能,或者把任意接口文档和示例发送给 Agent,让他直接完成对接: @@ -79,7 +92,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -### 3.2 搜索和图像识别 +### 4.2 搜索和图像识别 - **联网搜索:** 内置 `web_search` 工具,支持多种搜索引擎,配置 `BOCHA_API_KEY` 或 `LINKAI_API_KEY` 后启用。 - **图像识别:** 内置 `openai-image-vision` 技能,可使用 `gpt-4.1-mini`、`gpt-4.1` 等模型,依赖 `OPENAI_API_KEY`。 @@ -88,7 +101,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -### 3.3 技能广场 +### 4.3 技能广场 访问 [skills.cowagent.ai](https://skills.cowagent.ai/) 浏览所有可用技能,或在对话中执行: @@ -103,7 +116,7 @@ description: CowAgent 长期记忆、任务规划、技能系统、CLI 命令、 -## 4. CLI 命令系统 +## 5. CLI 命令系统 CowAgent 提供两种命令交互方式,覆盖服务管理、技能安装、配置调整等日常运维操作: diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx index 6384f3ad..d7fd6abb 100644 --- a/docs/intro/index.mdx +++ b/docs/intro/index.mdx @@ -5,7 +5,7 @@ description: CowAgent - 基于大模型的超级AI助理 CowAgent -**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。 +**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆和知识库并不断成长。 CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企业微信应用、微信公众号、网页中使用,7×24小时运行于你的个人电脑或服务器中。 @@ -27,12 +27,12 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索。 + + 自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。 + 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发。 - - 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作。 - 内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 可自主调用工具完成复杂任务。 diff --git a/docs/ja/cli/general.mdx b/docs/ja/cli/general.mdx index ede186c5..9a61fee2 100644 --- a/docs/ja/cli/general.mdx +++ b/docs/ja/cli/general.mdx @@ -92,6 +92,31 @@ description: ステータスの確認、設定管理、コンテキスト制御 /logs 50 ``` +## knowledge + +パーソナルナレッジベースの表示と管理を行います。デフォルトでは統計情報を表示します。 + +```text +/knowledge +``` + +**ディレクトリ構造を表示:** + +```text +/knowledge list +``` + +**ナレッジベースの有効化・無効化:** + +```text +/knowledge on +/knowledge off +``` + + + ターミナル CLI では `cow knowledge` と `cow knowledge list` が利用可能ですが、`on|off` はチャットでのみサポートされます(実行時に即座に反映するため)。 + + ## version 現在の CowAgent のバージョンを表示します。 diff --git a/docs/ja/cli/index.mdx b/docs/ja/cli/index.mdx index a9f9d2b1..02eaf063 100644 --- a/docs/ja/cli/index.mdx +++ b/docs/ja/cli/index.mdx @@ -40,6 +40,9 @@ Service: Skills: skill Manage skills (list / search / install / uninstall ...) +Knowledge: + knowledge View knowledge base stats and structure + Others: help Show this help message version Show version @@ -55,6 +58,9 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す | `/status` | サービスの状態と設定を表示 | | `/config` | 実行時設定の表示・変更 | | `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) | +| `/knowledge` | ナレッジベースの統計情報を表示 | +| `/knowledge list` | ナレッジベースのディレクトリ構造を表示 | +| `/knowledge on\|off` | ナレッジベースの有効化・無効化 | | `/context` | 現在のセッションのコンテキスト情報を表示 | | `/context clear` | 現在のセッションのコンテキストをクリア | | `/logs` | 最近のログを表示 | @@ -74,6 +80,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す | logs | ✓ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | +| knowledge(サブコマンド) | ✓ | ✓ | | skill(サブコマンド) | ✓ | ✓ | | start / stop / restart | ✓ | ✗ | | update | ✓ | ✗ | diff --git a/docs/ja/intro/architecture.mdx b/docs/ja/intro/architecture.mdx index 3add3fe9..57ef30af 100644 --- a/docs/ja/intro/architecture.mdx +++ b/docs/ja/intro/architecture.mdx @@ -11,14 +11,16 @@ CowAgent のアーキテクチャは以下のコアモジュールで構成さ CowAgent Architecture -### コアモジュール - | モジュール | 説明 | | --- | --- | -| **Channels** | メッセージの受信と送信を行うメッセージチャネル層。Web、Feishu(飛書)、DingTalk(釘釘)、WeCom(企業微信)、WeChat公式アカウントなどをサポート | -| **Agent Core** | タスク計画、記憶システム、Skill エンジンを含む Agent エンジン | -| **Tools** | Agent が OS リソースにアクセスするためのツール層。10 以上の組み込みツール | -| **Models** | 主要な LLM への統一アクセスを提供するモデル層 | +| **Plan** | ユーザーの意図を理解し、複雑なタスクをマルチステップの計画に分解、目標達成までツールを反復的に呼び出す | +| **Memory** | 重要な情報をコアメモリとデイリーメモリとして自動永続化し、キーワードとベクトルのハイブリッド検索でセッション間の連続性を実現 | +| **Knowledge** | トピック別に構造化された知識を整理。Agent が価値ある情報を Markdown ページとして自律的に整理し、インデックスと相互参照で成長するナレッジネットワークを構築 | +| **Tools** | Agent が OS リソースにアクセスするための中核能力。ファイル読み書き、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10 以上の組み込みツール | +| **Skills** | Skill の読み込み・管理。Skill Hub や GitHub からのワンクリックインストール、または会話を通じたカスタム Skill の作成をサポート | +| **Models** | モデル層。OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen など主要 LLM への統一アクセスを提供 | +| **Channels** | メッセージチャネル層。Web コンソール、WeChat、Feishu、DingTalk、WeCom、公式アカウントなど複数チャネルを統一プロトコルでサポート | +| **CLI** | コマンドラインシステム。ターミナルコマンド(`cow`)とチャットコマンド(`/`)で、プロセス管理、Skill インストール、設定変更、ナレッジベース管理などをサポート | ## Agent モードのワークフロー @@ -28,7 +30,7 @@ Agent モードが有効な場合、CowAgent は以下のワークフローで 2. **意図の理解** — タスク要件とコンテキストを分析 3. **タスク計画** — 複雑なタスクを複数のステップに分解 4. **ツール呼び出し** — 各ステップに適切なツールを選択・実行 -5. **記憶の更新** — 重要な情報を長期記憶に保存 +5. **記憶・ナレッジの更新** — 重要な情報を長期記憶に保存し、構造化された知識をナレッジベースに整理 6. **結果の返却** — 実行結果をユーザーに送信 ## ワークスペースのディレクトリ構成 @@ -39,9 +41,12 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス ~/cow/ ├── system.md # Agent システムプロンプト ├── user.md # ユーザープロフィール +├── MEMORY.md # コアメモリ ├── memory/ # 長期記憶ストレージ -│ ├── core.md # コアメモリ -│ └── daily/ # デイリーメモリ +│ └── YYYY-MM-DD.md # デイリーメモリ +├── knowledge/ # パーソナルナレッジベース +│ ├── index.md # ナレッジインデックス +│ └── / # トピック別ページ └── skills/ # カスタム Skill ├── skill-1/ └── skill-2/ @@ -75,3 +80,4 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス | `agent_max_context_tokens` | 最大コンテキストトークン数 | `40000` | | `agent_max_context_turns` | 最大コンテキストターン数 | `30` | | `agent_max_steps` | タスクあたりの最大判断ステップ数 | `15` | +| `knowledge` | パーソナルナレッジベースの有効化 | `true` | diff --git a/docs/ja/intro/features.mdx b/docs/ja/intro/features.mdx index 0e213f3a..5b82c8c1 100644 --- a/docs/ja/intro/features.mdx +++ b/docs/ja/intro/features.mdx @@ -15,13 +15,26 @@ description: CowAgent の長期記憶、タスク計画、Skill システム、C -## 2. タスク計画とツール活用 +## 2. パーソナルナレッジベース + +> ナレッジベースシステムにより、Agent は構造化された知識を継続的に蓄積・整理できます。時系列で記録されるメモリとは異なり、ナレッジベースはトピック別に整理され、記事、会話からの洞察、学習資料などを相互にリンクされた Markdown ページとして整理し、継続的に成長するナレッジネットワークを形成します。 + +Agent は会話中に価値ある情報を自動的にナレッジページとして整理し、相互参照とインデックスを維持します。Web コンソールではドキュメントの閲覧とナレッジグラフの可視化が可能です。ナレッジはワークスペースの `~/cow/knowledge/` ディレクトリに保存されます。 + +- **自動整理**:Agent が会話中に構造化された知識を自律的に抽出・整理し、インデックスと相互参照を維持 +- **ナレッジグラフ**:ページ間の相互参照から自動的にナレッジグラフを構築し、Web コンソールでインタラクティブな関係図として可視化 +- **チャット連携**:Agent の回答で参照されるナレッジドキュメントのリンクを Web コンソールで直接クリックして閲覧可能 +- **CLI 管理**:`/knowledge` コマンドで統計表示、ディレクトリ閲覧、`/knowledge on|off` で機能の切り替えが可能 + +詳細は [パーソナルナレッジベース](/ja/knowledge) を参照してください。 + +## 3. タスク計画とツール活用 ツールは Agent がオペレーティングシステムのリソースにアクセスするための中核です。Agent はタスク要件に基づいてインテリジェントにツールを選択・呼び出し、ファイルの読み書き、コマンド実行、スケジュールタスクなどを実行します。組み込みツールはプロジェクトの `agent/tools/` ディレクトリに実装されています。 **主なツール:** ファイルの読み書き・編集、Bash ターミナル、ブラウザ操作、ファイル送信、スケジューラ、記憶検索、Web 検索、環境設定など。 -### 2.1 ターミナルとファイルアクセス +### 3.1 ターミナルとファイルアクセス OS のターミナルとファイルシステムへのアクセスは、最も基本的かつ中核的な機能です。多くの他のツールや Skill はこの機能の上に構築されています。ユーザーはモバイルデバイスから Agent とやり取りし、パソコンやサーバーのリソースを操作できます: @@ -29,7 +42,7 @@ OS のターミナルとファイルシステムへのアクセスは、最も -### 2.2 プログラミング能力 +### 3.2 プログラミング能力 プログラミングとシステムアクセスを組み合わせることで、Agent は完全な **Vibecoding ワークフロー** を実行できます。情報検索、アセット生成、コーディング、テスト、デプロイ、Nginx 設定、公開まで、すべてスマートフォンからの一つのコマンドで実行可能です: @@ -37,7 +50,7 @@ OS のターミナルとファイルシステムへのアクセスは、最も -### 2.3 スケジュールタスク +### 3.3 スケジュールタスク `scheduler` ツールにより動的なスケジュールタスクが可能で、**ワンタイムタスク、固定間隔、Cron 式**をサポートしています。タスクは**固定メッセージ送信**または **Agent 動的タスク**実行としてトリガーできます: @@ -45,7 +58,7 @@ OS のターミナルとファイルシステムへのアクセスは、最も -### 2.4 ブラウザ操作 +### 3.4 ブラウザ操作 組み込みの `browser` ツールにより、Agent は Chromium ブラウザを制御して Web ページへのアクセス、フォームの入力、要素のクリック、スクリーンショットの撮影が可能です。動的 JS レンダリングページにも対応しています。`cow install-browser` でワンコマンドインストール、サーバー(ヘッドレス)とデスクトップ環境に自動対応します: @@ -53,7 +66,7 @@ OS のターミナルとファイルシステムへのアクセスは、最も -### 2.5 環境変数管理 +### 3.5 環境変数管理 Skill が必要とするシークレットキーは環境変数ファイルに保存され、`env_config` ツールによって管理されます。会話を通じてシークレットを更新でき、セキュリティ保護とマスキング機能が組み込まれています: @@ -61,7 +74,7 @@ Skill が必要とするシークレットキーは環境変数ファイルに -## 3. Skill システム +## 4. Skill システム Skill システムは Agent に無限の拡張性を提供します。各 Skill は説明ファイル、実行スクリプト(任意)、リソース(任意)で構成され、特定のタイプのタスクを完了する方法を記述します。Skill により Agent は複雑なワークフローの指示に従い、ツールを呼び出し、サードパーティシステムと連携できます。 @@ -71,7 +84,7 @@ Skill システムは Agent に無限の拡張性を提供します。各 Skill Skill のインストール:`/skill install <名前>` または `cow skill install <名前>`。Skill Hub、GitHub、ClawHub、URL などからインストール可能。 -### 3.1 Skill の作成 +### 4.1 Skill の作成 `skill-creator` Skill により、会話を通じて Skill を素早く作成できます。ワークフローを Skill としてコード化するよう Agent に依頼したり、API ドキュメントやサンプルを送信して Agent に直接連携を完成させることができます: @@ -79,7 +92,7 @@ Skill のインストール:`/skill install <名前>` または `cow skill ins -### 3.2 Web 検索と画像認識 +### 4.2 Web 検索と画像認識 - **Web 検索:** 組み込みの `web_search` ツールで、複数の検索エンジンをサポートします。`BOCHA_API_KEY` または `LINKAI_API_KEY` を設定して有効化してください。 - **画像認識:** 組み込みの `openai-image-vision` Skill で、`gpt-4.1-mini`、`gpt-4.1` などのモデルをサポートします。`OPENAI_API_KEY` が必要です。 @@ -88,7 +101,7 @@ Skill のインストール:`/skill install <名前>` または `cow skill ins -### 3.3 Skill Hub +### 4.3 Skill Hub [skills.cowagent.ai](https://skills.cowagent.ai/) で利用可能なすべての Skill を閲覧するか、会話内でコマンドを実行できます: @@ -102,7 +115,7 @@ GitHub、ClawHub、LinkAI などサードパーティプラットフォームの -## 4. CLI コマンドシステム +## 5. CLI コマンドシステム CowAgent はサービス管理、Skill インストール、設定変更などをカバーする2つのコマンドインターフェースを提供します: diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx index e0e51828..760f7d6d 100644 --- a/docs/ja/intro/index.mdx +++ b/docs/ja/intro/index.mdx @@ -22,6 +22,9 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部 会話の記憶をローカルファイルやデータベースに自動的に永続化します。コアメモリとデイリーメモリを含み、キーワード検索とベクトル検索に対応しています。 + + 構造化された知識を自動整理し、ナレッジグラフの可視化をサポート。相互参照により継続的に成長するナレッジネットワークを構築します。 + Skill の作成・実行エンジンを実装し、組み込み Skill を搭載。自然言語の会話を通じてカスタム Skill の開発もサポートしています。 diff --git a/docs/ja/knowledge/index.mdx b/docs/ja/knowledge/index.mdx new file mode 100644 index 00000000..0ded408c --- /dev/null +++ b/docs/ja/knowledge/index.mdx @@ -0,0 +1,77 @@ +--- +title: パーソナルナレッジベース +description: CowAgent のパーソナルナレッジベース — 構造化された知識の蓄積、自動整理、ナレッジグラフ +--- + +パーソナルナレッジベースは、Agent の長期的な構造化知識ストレージで、ワークスペースの `knowledge/` ディレクトリに保存されます。タイムラインで整理されるメモリとは異なり、ナレッジベースはトピック別にコンテンツを整理します。記事、会話のインサイト、学習資料が相互リンクされた Markdown ページとして構造化され、継続的に成長するナレッジネットワークを形成します。 + +## コアコンセプト + +### ナレッジ vs メモリ + +| 次元 | ナレッジベース(knowledge/) | 長期記憶(memory/) | +| --- | --- | --- | +| 整理方法 | トピック別、相互リンク | タイムライン順、日付ファイル | +| 書き込み | Agent が能動的に構造化 | コンテキストトリミング時に自動要約 | +| コンテンツ | 精製された構造化知識 | 生の会話要約 | +| 用途 | 学習ノート、技術ドキュメント、プロジェクト知識 | 会話履歴、イベント記録 | + +### ディレクトリ構造 + +``` +~/cow/knowledge/ +├── index.md # ナレッジインデックス、全ページのエントリポイント +├── log.md # 変更ログ、各書き込みの記録 +├── concepts/ # 概念的な知識 +│ └── machine-learning.md +├── entities/ # エンティティ知識(人物、組織、ツール) +│ └── openai.md +└── sources/ # ソース知識(記事、論文) + └── llm-wiki.md +``` + +ディレクトリ構造は柔軟です — Agent は実際のコンテンツに基づいて適切なカテゴリディレクトリを自動作成します。ユーザーが整理方法をカスタマイズすることも可能です。 + +## 自動整理 + +ナレッジの書き込みは Agent の自律的な動作で、以下のシナリオでトリガーされます: + +- **ユーザーが記事やドキュメントを共有** — Agent が自動的にキー情報を抽出し、構造化されたナレッジページを作成 +- **会話から価値ある結論が生まれた場合** — Agent がインサイトをナレッジページに整理し、既存の知識とリンク +- **ユーザーが明示的に整理を要求** — ユーザーは会話を通じて Agent にナレッジの整理・更新を指示可能 + +各ナレッジページには関連ページへの相互参照リンクが含まれ、ナレッジグラフを段階的に構築します。 + +## ナレッジ検索 + +Agent は会話中に以下の方法でナレッジを検索できます: + +- **インデックス参照** — `knowledge/index.md` で関連ページを素早く特定 +- **セマンティック検索** — `memory_search` ツールでナレッジコンテンツをセマンティック検索 +- **直接読み取り** — `memory_get` ツールで特定のナレッジファイルを読み取り + +## Web コンソール + +Web コンソールには専用の「ナレッジ」モジュールがあり、以下をサポートします: + +- **ドキュメント閲覧** — ツリー形式のディレクトリ構造、検索・折りたたみ可能、クリックでコンテンツ表示 +- **ナレッジグラフ** — D3.js フォースダイレクテッドグラフによるナレッジ間の関係を可視化 +- **チャット連携** — Agent の返信で参照されたナレッジドキュメントリンクはクリックで直接ナビゲーション + +## CLI コマンド + +`/knowledge` コマンドでナレッジベースを管理: + +| コマンド | 説明 | +| --- | --- | +| `/knowledge` | ナレッジベースの統計情報を表示 | +| `/knowledge list` | ファイルディレクトリをツリー形式で表示 | +| `/knowledge on` | ナレッジベース機能を有効化 | +| `/knowledge off` | ナレッジベース機能を無効化 | + +## 設定 + +| パラメータ | 説明 | デフォルト | +| --- | --- | --- | +| `knowledge` | パーソナルナレッジベースの有効/無効 | `true` | +| `agent_workspace` | ワークスペースパス、ナレッジは `knowledge/` サブディレクトリに保存されます | `~/cow` | diff --git a/docs/ja/tools/memory.mdx b/docs/ja/tools/memory.mdx index 06097223..d82791cf 100644 --- a/docs/ja/tools/memory.mdx +++ b/docs/ja/tools/memory.mdx @@ -1,9 +1,11 @@ --- -title: memory - メモリ -description: 長期メモリの検索と読み取り +title: memory - メモリ & ナレッジ +description: 長期メモリとナレッジベースファイルの検索・読み取り --- -メモリToolには `memory_search`(メモリ検索)と `memory_get`(メモリファイル読み取り)の2つのサブToolがあります。 +メモリToolには `memory_search`(メモリ検索)と `memory_get`(メモリまたはナレッジファイル読み取り)の2つのサブToolがあります。 + +[ナレッジベース](/ja/knowledge) 機能が有効な場合、両ツールとも `knowledge/` ディレクトリのファイルへのアクセスもサポートします。 ## 依存関係 @@ -11,7 +13,7 @@ description: 長期メモリの検索と読み取り ## memory_search -キーワードとベクトルのハイブリッド検索で過去のメモリを検索します。 +キーワードとベクトルのハイブリッド検索で過去のメモリとナレッジベースの内容を検索します。 | パラメータ | 型 | 必須 | 説明 | | --- | --- | --- | --- | @@ -19,11 +21,11 @@ description: 長期メモリの検索と読み取り ## memory_get -特定のメモリファイルの内容を読み取ります。 +特定のメモリファイルまたはナレッジファイルの内容を読み取ります。 | パラメータ | 型 | 必須 | 説明 | | --- | --- | --- | --- | -| `path` | string | はい | メモリファイルの相対パス(例:`MEMORY.md`、`memory/2026-01-01.md`) | +| `path` | string | はい | ファイルの相対パス(例:`MEMORY.md`、`memory/2026-01-01.md`、`knowledge/concepts/rag.md`) | | `start_line` | integer | いいえ | 開始行番号 | | `end_line` | integer | いいえ | 終了行番号 | @@ -34,3 +36,8 @@ Agentは以下のシナリオでメモリToolを自動的に呼び出します - ユーザーが重要な情報を共有した場合 → メモリに保存 - 過去のコンテキストが必要な場合 → 関連するメモリを検索 - 会話が一定の長さに達した場合 → 要約を抽出して保存 +- 専門知識について議論する場合 → ナレッジベースから関連ページを検索 + + + 設定で `knowledge` が `false` に設定されている場合、ツールの説明と検索範囲は自動的にメモリファイルのみに調整されます。 + diff --git a/docs/knowledge/index.mdx b/docs/knowledge/index.mdx new file mode 100644 index 00000000..b4cf7f9b --- /dev/null +++ b/docs/knowledge/index.mdx @@ -0,0 +1,77 @@ +--- +title: 个人知识库 +description: CowAgent 的个人知识库系统 — 结构化知识沉淀、自动整理与知识图谱 +--- + +个人知识库是 Agent 的长期结构化知识存储,保存在工作空间的 `knowledge/` 目录下。与按时间线组织的记忆不同,知识库以主题为维度,将用户分享的文章、对话中的洞察、学习材料等整理为互相关联的 Markdown 页面,形成可持续增长的知识网络。 + +## 核心概念 + +### 知识 vs 记忆 + +| 维度 | 知识库(knowledge/) | 长期记忆(memory/) | +| --- | --- | --- | +| 组织方式 | 按主题分类、互相关联 | 按时间线、日期文件 | +| 写入方式 | Agent 主动整理结构化内容 | 上下文裁剪时自动摘要 | +| 内容特点 | 提炼后的结构化知识 | 原始对话摘要 | +| 典型用途 | 学习笔记、技术文档、项目知识 | 对话历史、事件记录 | + +### 目录结构 + +``` +~/cow/knowledge/ +├── index.md # 知识索引,所有页面的入口 +├── log.md # 变更日志,记录每次写入 +├── concepts/ # 概念类知识 +│ └── machine-learning.md +├── entities/ # 实体类知识(人物、组织、工具) +│ └── openai.md +└── sources/ # 来源类知识(文章、论文) + └── llm-wiki.md +``` + +目录结构是灵活的 — Agent 会根据实际内容自动创建合适的分类目录。用户也可以通过对话自定义目录组织方式。 + +## 自动整理 + +知识库的写入是 Agent 的自主行为,在以下场景中触发: + +- **用户分享文章或文档** — Agent 自动提取关键信息,创建结构化知识页面 +- **对话产生有价值的结论** — Agent 将洞察整理为知识页面,并与已有知识建立关联 +- **用户主动要求整理** — 用户可以通过对话指导 Agent 组织和更新知识 + +每个知识页面都包含与其他页面的交叉引用链接,逐步构建起一个知识图谱。 + +## 知识检索 + +Agent 在对话中可以通过以下方式检索知识: + +- **索引查阅** — 通过 `knowledge/index.md` 快速定位相关知识页面 +- **语义搜索** — 通过 `memory_search` 工具对知识库内容进行语义检索 +- **直接读取** — 通过 `memory_get` 工具读取特定知识文件 + +## Web 控制台 + +Web 控制台提供了专用的「知识」模块,支持: + +- **文档浏览** — 树状目录结构,可搜索、可折叠,点击查看文档内容 +- **知识图谱** — 基于 D3.js 的力导向图,可视化展示知识之间的关联关系 +- **对话联动** — Agent 回复中引用的知识文档链接可直接点击跳转查看 + +## CLI 命令 + +通过 `/knowledge` 命令管理知识库: + +| 命令 | 说明 | +| --- | --- | +| `/knowledge` | 显示知识库统计信息 | +| `/knowledge list` | 以树状结构显示文件目录 | +| `/knowledge on` | 开启知识库功能 | +| `/knowledge off` | 关闭知识库功能 | + +## 相关配置 + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `knowledge` | 是否启用个人知识库功能 | `true` | +| `agent_workspace` | 工作空间路径,知识库存储在此目录的 `knowledge/` 子目录下 | `~/cow` | diff --git a/docs/tools/memory.mdx b/docs/tools/memory.mdx index 0b60624a..c3cc6fe4 100644 --- a/docs/tools/memory.mdx +++ b/docs/tools/memory.mdx @@ -1,9 +1,11 @@ --- -title: memory - 记忆 -description: 搜索和读取长期记忆 +title: memory - 记忆与知识 +description: 搜索和读取长期记忆及知识库文件 --- -记忆工具包含两个子工具:`memory_search`(搜索记忆)和 `memory_get`(读取记忆文件)。 +记忆工具包含两个子工具:`memory_search`(搜索记忆)和 `memory_get`(读取记忆或知识文件)。 + +当 [知识库](/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。 ## 依赖 @@ -11,7 +13,7 @@ description: 搜索和读取长期记忆 ## memory_search -搜索历史记忆,支持关键词和向量混合检索。 +搜索历史记忆和知识库内容,支持关键词和向量混合检索。 | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -19,11 +21,11 @@ description: 搜索和读取长期记忆 ## memory_get -读取特定记忆文件的内容。 +读取特定记忆文件或知识库文件的内容。 | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `path` | string | 是 | 记忆文件的相对路径(如 `MEMORY.md`、`memory/2026-01-01.md`) | +| `path` | string | 是 | 文件的相对路径(如 `MEMORY.md`、`memory/2026-01-01.md`、`knowledge/concepts/rag.md`) | | `start_line` | integer | 否 | 起始行号 | | `end_line` | integer | 否 | 结束行号 | @@ -34,3 +36,8 @@ Agent 会在以下场景自动调用记忆工具: - 用户分享重要信息时 → 存储到记忆 - 需要参考历史信息时 → 搜索相关记忆 - 对话达到一定长度时 → 提取摘要存储 +- 讨论到专业知识时 → 检索知识库中的相关页面 + + + 当 `knowledge` 配置为 `false` 时,工具的描述和搜索范围会自动调整为仅包含记忆文件。 + diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md index 508441ac..1b0123da 100644 --- a/skills/knowledge-wiki/SKILL.md +++ b/skills/knowledge-wiki/SKILL.md @@ -89,3 +89,4 @@ Append-only, newest at bottom: - **Index is mandatory**: always update `knowledge/index.md` after any change - **Be concise**: capture essence, not copy entire sources - **Full paths in replies**: when referencing knowledge files in conversation replies, use the full path from workspace root (e.g. `[Title](knowledge//.md)`), not relative paths. Relative paths are only for cross-references inside knowledge pages themselves. +- **Cite sources**: when answering based on knowledge pages, include links to the relevant pages so the user can explore further. From c6824e5f5e12437587094511271433f615065abc Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 16:49:00 +0800 Subject: [PATCH 107/399] fix: add legacy-cgi dependency for Python 3.13+ #2758 Add conditional dependency `legacy-cgi` for Python 3.13+ to resolve `web.py` installation failure caused by the removal of the `cgi` module (PEP 594). Thanks @sha156 for reporting. --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index c7a86745..be4be71b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ requests>=2.28.2 chardet>=5.1.0 Pillow web.py +legacy-cgi; python_version >= "3.13" python-dotenv>=1.0.0 PyYAML>=6.0 croniter>=2.0.0 From d36d5aee3f7854233d96726b38b159747e9f968f Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 17:09:07 +0800 Subject: [PATCH 108/399] feat: rename repository name from chatgpt-on-wechat to CowAgent - Update GitHub URLs in README.md (badges, release links, clone address, wiki, issues, contributors) - Add project rename notice with SEO keywords and git remote update command - Update docs/docs.json GitHub links - Update all docs (zh/en/ja) across guide, intro, models, releases, skills - Update run.sh and scripts/run.ps1 clone URLs and directory names - Docker image name (zhayujie/chatgpt-on-wechat) kept unchanged for compatibility --- README.md | 48 +++++++++++++++++++------------- cli/VERSION | 2 +- docs/docs.json | 4 +-- docs/en/README.md | 34 +++++++++++----------- docs/en/guide/manual-install.mdx | 8 +++--- docs/en/guide/quick-start.mdx | 2 +- docs/en/intro/index.mdx | 6 ++-- docs/en/models/index.mdx | 2 +- docs/en/releases/overview.mdx | 2 +- docs/en/releases/v2.0.0.mdx | 4 +-- docs/en/releases/v2.0.1.mdx | 38 ++++++++++++------------- docs/en/releases/v2.0.2.mdx | 30 ++++++++++---------- docs/en/releases/v2.0.4.mdx | 24 ++++++++-------- docs/en/releases/v2.0.5.mdx | 18 ++++++------ docs/en/skills/create.mdx | 2 +- docs/guide/manual-install.mdx | 8 +++--- docs/guide/quick-start.mdx | 2 +- docs/intro/index.mdx | 2 +- docs/ja/README.md | 34 +++++++++++----------- docs/ja/guide/manual-install.mdx | 8 +++--- docs/ja/guide/quick-start.mdx | 2 +- docs/ja/intro/index.mdx | 6 ++-- docs/ja/models/index.mdx | 2 +- docs/ja/releases/overview.mdx | 2 +- docs/ja/releases/v2.0.0.mdx | 4 +-- docs/ja/releases/v2.0.1.mdx | 38 ++++++++++++------------- docs/ja/releases/v2.0.2.mdx | 30 ++++++++++---------- docs/ja/releases/v2.0.3.mdx | 48 ++++++++++++++++---------------- docs/ja/releases/v2.0.4.mdx | 24 ++++++++-------- docs/ja/releases/v2.0.5.mdx | 18 ++++++------ docs/ja/skills/create.mdx | 2 +- docs/models/index.mdx | 2 +- docs/releases/overview.mdx | 2 +- docs/releases/v2.0.0.mdx | 4 +-- docs/releases/v2.0.1.mdx | 38 ++++++++++++------------- docs/releases/v2.0.2.mdx | 30 ++++++++++---------- docs/releases/v2.0.3.mdx | 48 ++++++++++++++++---------------- docs/releases/v2.0.4.mdx | 22 +++++++-------- docs/releases/v2.0.5.mdx | 24 ++++++++-------- docs/skills/create.mdx | 2 +- run.sh | 34 +++++++++++----------- scripts/run.ps1 | 18 ++++++------ 42 files changed, 344 insertions(+), 334 deletions(-) diff --git a/README.md b/README.md index c48f6a72..1cdbc309 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -

Chatgpt-on-Wechat

+

CowAgent

- Latest release - License: MIT - Stars
+ Latest release + License: MIT + Stars
[中文] | [English] | [日本語]

@@ -70,17 +70,17 @@ # 🏷 更新日志 ->**2026.04.01:** [2.0.5版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.5),Cow CLI 命令系统、Skill Hub 开源、浏览器工具、企微扫码创建、多项优化和修复。 +>**2026.04.01:** [2.0.5版本](https://github.com/zhayujie/CowAgent/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.22:** [2.0.4版本](https://github.com/zhayujie/CowAgent/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/CowAgent/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.27:** [2.0.2版本](https://github.com/zhayujie/CowAgent/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.13:** [2.0.1版本](https://github.com/zhayujie/CowAgent/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/CowAgent/releases/tag/2.0.0),正式升级为超级 Agent 助理,支持多轮任务决策、具备长期记忆、实现多种系统工具、支持 Skills 框架,新增多种模型并优化了接入渠道。 更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases) @@ -124,11 +124,11 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex **(1) 克隆项目代码:** ```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ ``` -若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/chatgpt-on-wechat +若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/CowAgent **(2) 安装核心依赖 (必选):** @@ -220,7 +220,7 @@ cow install-browser
2. 其他配置 -+ `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)文件 ++ `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/CowAgent/blob/master/common/const.py)文件 + `character_desc`:普通对话模式下的机器人系统提示词。在 Agent 模式下该配置不生效,由工作空间中的文件内容构成。 + `subscribe_msg`:订阅消息,公众号和企业微信 channel 中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成 bot 的触发词。
@@ -232,7 +232,7 @@ cow install-browser + `linkai_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建
-注:全部配置项说明可在 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。 +注:全部配置项说明可在 [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) 文件中查看。 ## 三、运行 @@ -880,18 +880,28 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支 # 🔎 常见问题 -FAQs: +FAQs: 或直接在线咨询 [项目小助手](https://link-ai.tech/app/Kv2fXJcH) (知识库持续完善中,回复供参考) # 🛠️ 开发 -欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。同时欢迎贡献新的 Skills,向 [Skill Hub](https://skills.cowagent.ai/submit) 提交技能。 +欢迎接入更多应用通道,参考 [飞书通道](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道,实现接收和发送消息逻辑即可完成接入。同时欢迎贡献新的 Skills,向 [Skill Hub](https://skills.cowagent.ai/submit) 提交技能。 # ✉ 联系 -欢迎提交PR、Issues进行反馈,以及通过 🌟Star 支持并关注项目更新。项目运行遇到问题可以查看 [常见问题列表](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) ,以及前往 [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中搜索。个人开发者可加入开源交流群参与更多讨论,企业用户可联系[产品客服](https://cdn.link-ai.tech/portal/linkai-customer-service.png)咨询。 +欢迎提交PR、Issues进行反馈,以及通过 🌟Star 支持并关注项目更新。项目运行遇到问题可以查看 [常见问题列表](https://github.com/zhayujie/CowAgent/wiki/FAQs) ,以及前往 [Issues](https://github.com/zhayujie/CowAgent/issues) 中搜索。个人开发者可加入开源交流群参与更多讨论,企业用户可联系[产品客服](https://cdn.link-ai.tech/portal/linkai-customer-service.png)咨询。 # 🌟 贡献者 -![cow contributors](https://contrib.rocks/image?repo=zhayujie/chatgpt-on-wechat&max=1000) +![cow contributors](https://contrib.rocks/image?repo=zhayujie/CowAgent&max=1000) + +# 📌 项目更名说明 + +本项目原名 `chatgpt-on-wechat`(GitHub 原地址:https://github.com/zhayujie/chatgpt-on-wechat), +于 2026.04.13 正式更名为 **CowAgent**。GitHub 已自动设置重定向,原有链接仍可正常访问。 + +如需更新本地仓库的远程地址(可选): +```bash +git remote set-url origin https://github.com/zhayujie/CowAgent.git +``` diff --git a/cli/VERSION b/cli/VERSION index 157e54f3..e0102586 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.0.6 +2.0.5 diff --git a/docs/docs.json b/docs/docs.json index a1ca6a00..e4de83b2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -24,13 +24,13 @@ }, { "label": "GitHub", - "href": "https://github.com/zhayujie/chatgpt-on-wechat" + "href": "https://github.com/zhayujie/CowAgent" } ] }, "footer": { "socials": { - "github": "https://github.com/zhayujie/chatgpt-on-wechat" + "github": "https://github.com/zhayujie/CowAgent" } }, "navigation": { diff --git a/docs/en/README.md b/docs/en/README.md index 8b75ed25..d9723be7 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -1,10 +1,10 @@

CowAgent

- Latest release - License: MIT - Stars
- [中文] | [English] | [日本語] + Latest release + License: MIT + Stars
+ [中文] | [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 WeChat, Web, Feishu, DingTalk, WeCom Bot, WeCom App, and WeChat Official Account — running 7×24 hours on your personal computer or server. @@ -42,19 +42,19 @@ 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.04.01:** [v2.0.5](https://github.com/zhayujie/CowAgent/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.27:** [v2.0.2](https://github.com/zhayujie/CowAgent/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. +> **2026.02.13:** [v2.0.1](https://github.com/zhayujie/CowAgent/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. -> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0) — Full upgrade to AI super assistant with multi-step task planning, long-term memory, built-in tools, Skills framework, new models, and optimized channels. +> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — Full upgrade to AI super assistant with multi-step task planning, long-term memory, built-in tools, Skills framework, new models, and optimized channels. -> **2025.05.23:** [v1.7.6](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.6) — Web channel optimization, AgentMesh multi-agent plugin, Baidu TTS, claude-4-sonnet/opus support. +> **2025.05.23:** [v1.7.6](https://github.com/zhayujie/CowAgent/releases/tag/1.7.6) — Web channel optimization, AgentMesh multi-agent plugin, Baidu TTS, claude-4-sonnet/opus support. -> **2025.04.11:** [v1.7.5](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.5) — wechatferry protocol, DeepSeek model, Tencent Cloud voice, ModelScope and Gitee-AI support. +> **2025.04.11:** [v1.7.5](https://github.com/zhayujie/CowAgent/releases/tag/1.7.5) — wechatferry protocol, DeepSeek model, Tencent Cloud voice, ModelScope and Gitee-AI support. -> **2024.12.13:** [v1.7.4](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.4) — Gemini 2.0 model, Web channel, memory leak fix. +> **2024.12.13:** [v1.7.4](https://github.com/zhayujie/CowAgent/releases/tag/1.7.4) — Gemini 2.0 model, Web channel, memory leak fix. Full changelog: [Release Notes](https://docs.cowagent.ai/en/releases/overview) @@ -83,8 +83,8 @@ Script usage: [One-click Install](https://docs.cowagent.ai/en/guide/quick-start) **1. Clone the project** ```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ ``` **2. Install dependencies** @@ -232,16 +232,16 @@ Multiple channels can be enabled simultaneously, separated by commas: `"channel_ ## 🔎 FAQ -FAQs: +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), or submit to [Skill Hub](https://skills.cowagent.ai/submit). +Welcome to add new channels, referring to the [Feishu channel](https://github.com/zhayujie/CowAgent/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 -Welcome to submit PRs and Issues, and support the project with a 🌟 Star. For questions, check the [FAQ list](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) or search [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues). +Welcome to submit PRs and Issues, and support the project with a 🌟 Star. For questions, check the [FAQ list](https://github.com/zhayujie/CowAgent/wiki/FAQs) or search [Issues](https://github.com/zhayujie/CowAgent/issues). ## 🌟 Contributors -![cow contributors](https://contrib.rocks/image?repo=zhayujie/chatgpt-on-wechat&max=1000) +![cow contributors](https://contrib.rocks/image?repo=zhayujie/CowAgent&max=1000) diff --git a/docs/en/guide/manual-install.mdx b/docs/en/guide/manual-install.mdx index ac36b265..94a00704 100644 --- a/docs/en/guide/manual-install.mdx +++ b/docs/en/guide/manual-install.mdx @@ -8,12 +8,12 @@ description: Deploy CowAgent manually (source code / Docker) ### 1. Clone the project ```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ ``` - For network issues, use the mirror: https://gitee.com/zhayujie/chatgpt-on-wechat + For network issues, use the mirror: https://gitee.com/zhayujie/CowAgent ### 2. Install dependencies @@ -141,5 +141,5 @@ sudo docker logs -f chatgpt-on-wechat | `agent_max_steps` | Max decision steps per task | `15` | - Full configuration options are in the project [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py). + Full configuration options are in the project [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py). diff --git a/docs/en/guide/quick-start.mdx b/docs/en/guide/quick-start.mdx index 0e65d1f0..a23691e7 100644 --- a/docs/en/guide/quick-start.mdx +++ b/docs/en/guide/quick-start.mdx @@ -26,7 +26,7 @@ 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` +3. Clone project to `~/CowAgent` 4. Install Python dependencies and Cow CLI 5. Guided configuration for AI model and channel 6. Start service diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx index e4b05352..b36204b8 100644 --- a/docs/en/intro/index.mdx +++ b/docs/en/intro/index.mdx @@ -9,8 +9,8 @@ description: CowAgent - AI Super Assistant powered by LLMs 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 + + github.com/zhayujie/CowAgent ## Core Capabilities @@ -75,7 +75,7 @@ By default, the Web service starts after running. Access `http://localhost:9899/ ## Disclaimer -1. This project follows the [MIT License](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE) and is intended for technical research and learning. Users must comply with local laws, regulations, policies, and corporate bylaws. Any illegal or rights-infringing use is prohibited. +1. This project follows the [MIT License](https://github.com/zhayujie/CowAgent/blob/master/LICENSE) and is intended for technical research and learning. Users must comply with local laws, regulations, policies, and corporate bylaws. Any illegal or rights-infringing use is prohibited. 2. Agent mode consumes more tokens than normal chat mode. Choose models based on effectiveness and cost. Agent has access to the host operating system — deploy with caution. 3. CowAgent focuses on open-source development and does not participate in, authorize, or issue any cryptocurrency. diff --git a/docs/en/models/index.mdx b/docs/en/models/index.mdx index 90fde345..f6e86f32 100644 --- a/docs/en/models/index.mdx +++ b/docs/en/models/index.mdx @@ -51,5 +51,5 @@ You can also use the [LinkAI](https://link-ai.tech) platform interface to flexib - For a full list of model names, refer to the project's [`common/const.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py) file. + For a full list of model names, refer to the project's [`common/const.py`](https://github.com/zhayujie/CowAgent/blob/master/common/const.py) file. diff --git a/docs/en/releases/overview.mdx b/docs/en/releases/overview.mdx index 4b013da8..6e654874 100644 --- a/docs/en/releases/overview.mdx +++ b/docs/en/releases/overview.mdx @@ -22,4 +22,4 @@ description: CowAgent version history | 1.5.0 | 2023.11.10 | gpt-4-turbo, dall-e-3, tts multimodal | | 1.0.0 | 2022.12.12 | Project created, first ChatGPT integration | -See [GitHub Releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) for full history. +See [GitHub Releases](https://github.com/zhayujie/CowAgent/releases) for full history. diff --git a/docs/en/releases/v2.0.0.mdx b/docs/en/releases/v2.0.0.mdx index 955ba440..e6c37533 100644 --- a/docs/en/releases/v2.0.0.mdx +++ b/docs/en/releases/v2.0.0.mdx @@ -5,7 +5,7 @@ description: CowAgent 2.0 - Full upgrade from chatbot to AI super assistant CowAgent 2.0 is a comprehensive upgrade from a chatbot to an **AI super assistant** — capable of autonomous thinking and task planning, long-term memory, operating computers, and creating and executing skills. -**Release Date**: 2026.02.03 | [GitHub Release](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0) +**Release Date**: 2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) ## Key Updates @@ -60,4 +60,4 @@ CowAgent 2.0 is a comprehensive upgrade from a chatbot to an **AI super assistan ## Contributing -Welcome to [submit feedback](https://github.com/zhayujie/chatgpt-on-wechat/issues) and [contribute code](https://github.com/zhayujie/chatgpt-on-wechat/pulls). +Welcome to [submit feedback](https://github.com/zhayujie/CowAgent/issues) and [contribute code](https://github.com/zhayujie/CowAgent/pulls). diff --git a/docs/en/releases/v2.0.1.mdx b/docs/en/releases/v2.0.1.mdx index eee3c13a..7de5746a 100644 --- a/docs/en/releases/v2.0.1.mdx +++ b/docs/en/releases/v2.0.1.mdx @@ -3,34 +3,34 @@ title: v2.0.1 description: CowAgent 2.0.1 - Built-in Web Search, smart context management, multiple fixes --- -**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.0..2.0.1) +**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) ## New Features -- **Built-in Web Search tool**: Integrated web search as a built-in Agent tool, reducing decision cost ([4f0ea5d](https://github.com/zhayujie/chatgpt-on-wechat/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) -- **Claude Opus 4.6 model support**: Added support for Claude Opus 4.6 model ([#2661](https://github.com/zhayujie/chatgpt-on-wechat/pull/2661)) -- **WeCom image recognition**: Support image message recognition in WeCom channel ([#2667](https://github.com/zhayujie/chatgpt-on-wechat/pull/2667)) +- **Built-in Web Search tool**: Integrated web search as a built-in Agent tool, reducing decision cost ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) +- **Claude Opus 4.6 model support**: Added support for Claude Opus 4.6 model ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) +- **WeCom image recognition**: Support image message recognition in WeCom channel ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) ## Improvements -- **Smart context management**: Resolved chat context overflow with intelligent context trimming strategy to prevent token limits ([cea7fb7](https://github.com/zhayujie/chatgpt-on-wechat/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/chatgpt-on-wechat/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/chatgpt-on-wechat/issues/2663) -- **Runtime info dynamic update**: Automatic update of timestamps and other runtime info in system prompts via dynamic functions ([#2655](https://github.com/zhayujie/chatgpt-on-wechat/pull/2655), [#2657](https://github.com/zhayujie/chatgpt-on-wechat/pull/2657)) -- **Skill prompt optimization**: Improved Skill system prompt generation, simplified tool descriptions for better Agent performance ([6c21833](https://github.com/zhayujie/chatgpt-on-wechat/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) -- **GLM custom API Base URL**: Support custom API Base URL for GLM models ([#2660](https://github.com/zhayujie/chatgpt-on-wechat/pull/2660)) -- **Startup script optimization**: Improved `run.sh` script interaction and configuration flow ([#2656](https://github.com/zhayujie/chatgpt-on-wechat/pull/2656)) -- **Decision step logging**: Added Agent decision step logging for debugging ([cb303e6](https://github.com/zhayujie/chatgpt-on-wechat/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) +- **Smart context management**: Resolved chat context overflow with intelligent context trimming strategy to prevent token limits ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) +- **Runtime info dynamic update**: Automatic update of timestamps and other runtime info in system prompts via dynamic functions ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) +- **Skill prompt optimization**: Improved Skill system prompt generation, simplified tool descriptions for better Agent performance ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) +- **GLM custom API Base URL**: Support custom API Base URL for GLM models ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) +- **Startup script optimization**: Improved `run.sh` script interaction and configuration flow ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) +- **Decision step logging**: Added Agent decision step logging for debugging ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) ## Bug Fixes -- **Scheduler memory loss**: Fixed memory loss caused by Scheduler dispatcher ([a77a874](https://github.com/zhayujie/chatgpt-on-wechat/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) -- **Empty tool calls & long results**: Fixed handling of empty tool calls and excessively long tool results ([0542700](https://github.com/zhayujie/chatgpt-on-wechat/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) -- **OpenAI Function Call**: Fixed function call compatibility with OpenAI models ([158c87a](https://github.com/zhayujie/chatgpt-on-wechat/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) -- **Claude tool name field**: Removed extraneous tool name field from Claude model responses ([eec10cb](https://github.com/zhayujie/chatgpt-on-wechat/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) -- **MiniMax reasoning**: Optimized MiniMax model reasoning content handling, hidden thinking process output ([c72cda3](https://github.com/zhayujie/chatgpt-on-wechat/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/chatgpt-on-wechat/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **GLM thinking process**: Hidden GLM model thinking process display ([72b1cac](https://github.com/zhayujie/chatgpt-on-wechat/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **Feishu connection & SSL**: Fixed Feishu channel SSL certificate errors and connection issues ([229b14b](https://github.com/zhayujie/chatgpt-on-wechat/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/chatgpt-on-wechat/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) -- **model_type validation**: Fixed `AttributeError` caused by non-string `model_type` ([#2666](https://github.com/zhayujie/chatgpt-on-wechat/pull/2666)) +- **Scheduler memory loss**: Fixed memory loss caused by Scheduler dispatcher ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) +- **Empty tool calls & long results**: Fixed handling of empty tool calls and excessively long tool results ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) +- **OpenAI Function Call**: Fixed function call compatibility with OpenAI models ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) +- **Claude tool name field**: Removed extraneous tool name field from Claude model responses ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) +- **MiniMax reasoning**: Optimized MiniMax model reasoning content handling, hidden thinking process output ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **GLM thinking process**: Hidden GLM model thinking process display ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **Feishu connection & SSL**: Fixed Feishu channel SSL certificate errors and connection issues ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) +- **model_type validation**: Fixed `AttributeError` caused by non-string `model_type` ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) ## Platform Compatibility -- **Windows compatibility**: Fixed path handling, file encoding, and `os.getuid()` unavailability on Windows across multiple tool modules ([051ffd7](https://github.com/zhayujie/chatgpt-on-wechat/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) +- **Windows compatibility**: Fixed path handling, file encoding, and `os.getuid()` unavailability on Windows across multiple tool modules ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) diff --git a/docs/en/releases/v2.0.2.mdx b/docs/en/releases/v2.0.2.mdx index b833adfa..80e9f6d8 100644 --- a/docs/en/releases/v2.0.2.mdx +++ b/docs/en/releases/v2.0.2.mdx @@ -3,7 +3,7 @@ title: v2.0.2 description: CowAgent 2.0.2 - Web Console upgrade, multi-channel concurrency, session persistence --- -**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.1...master) +**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) ## Highlights @@ -53,7 +53,7 @@ View Agent runtime logs in real-time for monitoring and troubleshooting: -Related commits: [f1a1413](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1a1413), [c0702c8](https://github.com/zhayujie/chatgpt-on-wechat/commit/c0702c8), [394853c](https://github.com/zhayujie/chatgpt-on-wechat/commit/394853c), [1c71c4e](https://github.com/zhayujie/chatgpt-on-wechat/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/chatgpt-on-wechat/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/chatgpt-on-wechat/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5) +Related commits: [f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) ### 🔀 Multi-Channel Concurrency @@ -67,24 +67,24 @@ Configuration: Set multiple channels in `config.json` via `channel_type` separat } ``` -Related commits: [4694594](https://github.com/zhayujie/chatgpt-on-wechat/commit/4694594), [7cce224](https://github.com/zhayujie/chatgpt-on-wechat/commit/7cce224), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5), [c9adddb](https://github.com/zhayujie/chatgpt-on-wechat/commit/c9adddb) +Related commits: [4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) ### 💾 Session Persistence Session history is now persisted to a local SQLite database. Conversation context is automatically restored after service restarts. Historical conversations in the Web Console are also restored. -Related commits: [29bfbec](https://github.com/zhayujie/chatgpt-on-wechat/commit/29bfbec), [9917552](https://github.com/zhayujie/chatgpt-on-wechat/commit/9917552), [925d728](https://github.com/zhayujie/chatgpt-on-wechat/commit/925d728) +Related commits: [29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) ## New Models -- **Gemini 3.1 Pro Preview**: Added `gemini-3.1-pro-preview` model support ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad)) -- **Claude 4.6 Sonnet**: Added `claude-4.6-sonnet` model support ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad)) -- **Qwen3.5 Plus**: Added `qwen3.5-plus` model support ([e59a289](https://github.com/zhayujie/chatgpt-on-wechat/commit/e59a289)) -- **MiniMax M2.5**: Added `Minimax-M2.5` model support ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **GLM-5**: Added `glm-5` model support ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **Kimi K2.5**: Added `kimi-k2.5` model support ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **Doubao 2.0 Code**: Added `doubao-2.0-code` coding-specialized model ([ab28ee5](https://github.com/zhayujie/chatgpt-on-wechat/commit/ab28ee5)) -- **DashScope Models**: Added Alibaba Cloud DashScope model name support ([ce58f23](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce58f23)) +- **Gemini 3.1 Pro Preview**: Added `gemini-3.1-pro-preview` model support ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Claude 4.6 Sonnet**: Added `claude-4.6-sonnet` model support ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Qwen3.5 Plus**: Added `qwen3.5-plus` model support ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) +- **MiniMax M2.5**: Added `Minimax-M2.5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **GLM-5**: Added `glm-5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Kimi K2.5**: Added `kimi-k2.5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Doubao 2.0 Code**: Added `doubao-2.0-code` coding-specialized model ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) +- **DashScope Models**: Added Alibaba Cloud DashScope model name support ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) ## Website & Documentation @@ -93,6 +93,6 @@ Related commits: [29bfbec](https://github.com/zhayujie/chatgpt-on-wechat/commit/ ## Bug Fixes -- **Gemini DingTalk image recognition**: Fixed Gemini unable to process image markers in DingTalk channel ([05a3304](https://github.com/zhayujie/chatgpt-on-wechat/commit/05a3304)) ([#2670](https://github.com/zhayujie/chatgpt-on-wechat/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) -- **Startup script dependencies**: Fixed dependency installation issue in `run.sh` script ([b6fc9fa](https://github.com/zhayujie/chatgpt-on-wechat/commit/b6fc9fa)) -- **Bare except cleanup**: Replaced `bare except` with `except Exception` for better exception handling ([adca89b](https://github.com/zhayujie/chatgpt-on-wechat/commit/adca89b)) ([#2674](https://github.com/zhayujie/chatgpt-on-wechat/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) +- **Gemini DingTalk image recognition**: Fixed Gemini unable to process image markers in DingTalk channel ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) +- **Startup script dependencies**: Fixed dependency installation issue in `run.sh` script ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) +- **Bare except cleanup**: Replaced `bare except` with `except Exception` for better exception handling ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) diff --git a/docs/en/releases/v2.0.4.mdx b/docs/en/releases/v2.0.4.mdx index a5bed23a..fc976fa0 100644 --- a/docs/en/releases/v2.0.4.mdx +++ b/docs/en/releases/v2.0.4.mdx @@ -16,40 +16,40 @@ Added personal WeChat (`weixin`) channel — the most important update in this r 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) +Related commits: [ce89869](https://github.com/zhayujie/CowAgent/commit/ce89869), [a483ec0](https://github.com/zhayujie/CowAgent/commit/a483ec0), [c1421e0](https://github.com/zhayujie/CowAgent/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) +Related commits: [9192f6f](https://github.com/zhayujie/CowAgent/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)) +- **run.sh Refactoring**: Extracted shared logic and eliminated duplication, reducing from 600+ lines to 177 lines ([49d8707](https://github.com/zhayujie/CowAgent/commit/49d8707)) +- **Executable Permission**: Fixed `run.sh` file permission issue ([652156e](https://github.com/zhayujie/CowAgent/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)) +- **Unified Request Headers**: Added identification headers to external requests across Agent services (Chat, Embedding, Vision, WebSearch, etc.) ([b4e711f](https://github.com/zhayujie/CowAgent/commit/b4e711f)) +- **Auto-Repair Messages**: Enhanced message protocol fault tolerance with automatic repair of malformed message sequences ([b8b57e3](https://github.com/zhayujie/CowAgent/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) +Related commits: [5487c0b](https://github.com/zhayujie/CowAgent/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)) +- **WeCom Bot Compatibility**: Fixed compatibility with older `websocket-client` versions, added unified WebSocket compatibility layer ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) +- **run.sh PID**: Fixed process PID retrieval error in `run.sh` ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) +- **Feishu Encoding**: Fixed message and log encoding issue in Feishu channel ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) +- **Feishu Config**: Removed redundant `feishu_bot_name` dependency in `run.sh` ([1b5be1b](https://github.com/zhayujie/CowAgent/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) +**Release Date**: 2026.03.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.3...master) diff --git a/docs/en/releases/v2.0.5.mdx b/docs/en/releases/v2.0.5.mdx index e3a513cd..ad45398e 100644 --- a/docs/en/releases/v2.0.5.mdx +++ b/docs/en/releases/v2.0.5.mdx @@ -57,21 +57,21 @@ WeCom Bot channel now supports QR code scan for one-click bot creation: 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) +PR: [#2735](https://github.com/zhayujie/CowAgent/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)) +- **DeepSeek module**: Independent DeepSeek Bot with dedicated `deepseek_api_key` config ([#2719](https://github.com/zhayujie/CowAgent/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/CowAgent/pull/2731)). Thanks [@zkjqd](https://github.com/zkjqd) +- **Context loss**: Fix context loss after trimming ([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) +- **System prompt**: Fix system prompt not rebuilding on every turn ([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) +- **Gemini**: Fix missing model attribute in GoogleGeminiBot ([#2716](https://github.com/zhayujie/CowAgent/pull/2716)). Thanks [@cowagent](https://github.com/cowagent) +- **WeChat channel**: Fix file send failures and filename loss ([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba), [45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) +- **Docker**: Fix volume permissions, reduce image size ([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348), [4470d4c](https://github.com/zhayujie/CowAgent/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) +**Release Date**: 2026.04.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.4...master) diff --git a/docs/en/skills/create.mdx b/docs/en/skills/create.mdx index 5edd281e..cfdbde02 100644 --- a/docs/en/skills/create.mdx +++ b/docs/en/skills/create.mdx @@ -54,5 +54,5 @@ Detailed instructions... | `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. + See the [Skill Creator documentation](https://github.com/zhayujie/CowAgent/blob/master/skills/skill-creator/SKILL.md) for details. diff --git a/docs/guide/manual-install.mdx b/docs/guide/manual-install.mdx index fcf9e69f..5715a3ac 100644 --- a/docs/guide/manual-install.mdx +++ b/docs/guide/manual-install.mdx @@ -8,12 +8,12 @@ description: 手动部署 CowAgent(源码 / Docker) ### 1. 克隆项目代码 ```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ ``` - 若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/chatgpt-on-wechat + 若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/CowAgent ### 2. 安装依赖 @@ -173,5 +173,5 @@ sudo docker logs -f chatgpt-on-wechat | `agent_max_steps` | `AGENT_MAX_STEPS` | 单次任务最大决策步数 | `15` | - 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。Docker 部署时,配置项名称需转为大写环境变量格式。 + 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) 文件中查看。Docker 部署时,配置项名称需转为大写环境变量格式。 diff --git a/docs/guide/quick-start.mdx b/docs/guide/quick-start.mdx index ac267ead..9a2b55f7 100644 --- a/docs/guide/quick-start.mdx +++ b/docs/guide/quick-start.mdx @@ -26,7 +26,7 @@ description: 使用脚本一键安装和管理 CowAgent 1. 检查 Python 环境(需要 Python 3.7+) 2. 安装必要工具(git、curl 等) -3. 克隆项目代码到 `~/chatgpt-on-wechat` +3. 克隆项目代码到 `~/CowAgent` 4. 安装 Python 依赖和 Cow CLI 5. 引导配置 AI 模型和通信渠道 6. 启动服务 diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx index d7fd6abb..a93e2422 100644 --- a/docs/intro/index.mdx +++ b/docs/intro/index.mdx @@ -10,7 +10,7 @@ description: CowAgent - 基于大模型的超级AI助理 CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企业微信应用、微信公众号、网页中使用,7×24小时运行于你的个人电脑或服务器中。 - + 开源代码仓库,欢迎 Star 和贡献 diff --git a/docs/ja/README.md b/docs/ja/README.md index eff98b6b..ff8883d1 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -1,10 +1,10 @@

CowAgent

- Latest release - License: MIT - Stars
- [中文] | [English] | [日本語] + Latest release + License: MIT + Stars
+ [中文] | [English] | [日本語]

**CowAgent** はLLMを搭載したAIスーパーアシスタントです。自律的なタスク計画、コンピュータや外部リソースの操作、Skillの作成・実行、長期記憶による継続的な成長が可能です。柔軟なモデル切り替えに対応し、テキスト・音声・画像・ファイルを処理でき、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom Bot(企業微信ボット)、WeComアプリ、WeChat公式アカウントに統合可能で、個人のPCやサーバー上で24時間365日稼働できます。 @@ -42,19 +42,19 @@ ## 更新履歴 -> **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.04.01:** [v2.0.5](https://github.com/zhayujie/CowAgent/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.27:** [v2.0.2](https://github.com/zhayujie/CowAgent/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接続問題などの修正。 +> **2026.02.13:** [v2.0.1](https://github.com/zhayujie/CowAgent/releases/tag/2.0.1) — 組み込みWeb検索ツール、スマートコンテキストトリミング、ランタイム情報の動的更新、Windows互換性、スケジューラのメモリ喪失やFeishu接続問題などの修正。 -> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0) — マルチステップタスク計画、長期記憶、組み込みツール、Skillフレームワーク、新モデル、チャネル最適化を備えたAIスーパーアシスタントへの全面アップグレード。 +> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — マルチステップタスク計画、長期記憶、組み込みツール、Skillフレームワーク、新モデル、チャネル最適化を備えたAIスーパーアシスタントへの全面アップグレード。 -> **2025.05.23:** [v1.7.6](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.6) — Webチャネル最適化、AgentMeshマルチエージェントプラグイン、Baidu TTS、claude-4-sonnet/opus対応。 +> **2025.05.23:** [v1.7.6](https://github.com/zhayujie/CowAgent/releases/tag/1.7.6) — Webチャネル最適化、AgentMeshマルチエージェントプラグイン、Baidu TTS、claude-4-sonnet/opus対応。 -> **2025.04.11:** [v1.7.5](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.5) — wechatferryプロトコル、DeepSeekモデル、Tencent Cloud音声、ModelScope・Gitee-AI対応。 +> **2025.04.11:** [v1.7.5](https://github.com/zhayujie/CowAgent/releases/tag/1.7.5) — wechatferryプロトコル、DeepSeekモデル、Tencent Cloud音声、ModelScope・Gitee-AI対応。 -> **2024.12.13:** [v1.7.4](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.7.4) — Gemini 2.0モデル、Webチャネル、メモリリーク修正。 +> **2024.12.13:** [v1.7.4](https://github.com/zhayujie/CowAgent/releases/tag/1.7.4) — Gemini 2.0モデル、Webチャネル、メモリリーク修正。 全更新履歴: [リリースノート](https://docs.cowagent.ai/en/releases/overview) @@ -83,8 +83,8 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex **1. プロジェクトのクローン** ```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ ``` **2. 依存関係のインストール** @@ -232,16 +232,16 @@ Coding Planは各プロバイダーが提供する月額サブスクリプショ ## 🔎 よくある質問 -FAQ: +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)を参照するか、[Skill Hub](https://skills.cowagent.ai/submit)に提出してください。 +新しいチャネルの追加を歓迎します。[Feishuチャネル](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py)を参考にしてください。また、新しいSkillのコントリビューションも歓迎します。[Skill作成ドキュメント](https://docs.cowagent.ai/ja/skills/create)を参照するか、[Skill Hub](https://skills.cowagent.ai/submit)に提出してください。 ## ✉ お問い合わせ -PRやIssueの提出を歓迎します。🌟 Starでプロジェクトをサポートしてください。ご質問がある場合は、[FAQリスト](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)を確認するか、[Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues)を検索してください。 +PRやIssueの提出を歓迎します。🌟 Starでプロジェクトをサポートしてください。ご質問がある場合は、[FAQリスト](https://github.com/zhayujie/CowAgent/wiki/FAQs)を確認するか、[Issues](https://github.com/zhayujie/CowAgent/issues)を検索してください。 ## 🌟 コントリビューター -![cow contributors](https://contrib.rocks/image?repo=zhayujie/chatgpt-on-wechat&max=1000) +![cow contributors](https://contrib.rocks/image?repo=zhayujie/CowAgent&max=1000) diff --git a/docs/ja/guide/manual-install.mdx b/docs/ja/guide/manual-install.mdx index bffec65b..1e03e1d8 100644 --- a/docs/ja/guide/manual-install.mdx +++ b/docs/ja/guide/manual-install.mdx @@ -8,12 +8,12 @@ description: CowAgentの手動デプロイ(ソースコード / Docker) ### 1. プロジェクトをクローン ```bash -git clone https://github.com/zhayujie/chatgpt-on-wechat -cd chatgpt-on-wechat/ +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ ``` - ネットワークに問題がある場合は、ミラーを使用してください: https://gitee.com/zhayujie/chatgpt-on-wechat + ネットワークに問題がある場合は、ミラーを使用してください: https://gitee.com/zhayujie/CowAgent ### 2. 依存パッケージをインストール @@ -141,5 +141,5 @@ sudo docker logs -f chatgpt-on-wechat | `agent_max_steps` | タスクごとの最大判断ステップ数 | `15` | - すべての設定オプションはプロジェクトの [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) に記載されています。 + すべての設定オプションはプロジェクトの [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) に記載されています。 diff --git a/docs/ja/guide/quick-start.mdx b/docs/ja/guide/quick-start.mdx index f5220b08..407f1e21 100644 --- a/docs/ja/guide/quick-start.mdx +++ b/docs/ja/guide/quick-start.mdx @@ -26,7 +26,7 @@ Linux、macOS、Windowsに対応しています。Python 3.7〜3.12が必要で 1. Python環境の確認(Python 3.7以上が必要) 2. 必要なツールのインストール(git、curlなど) -3. プロジェクトを `~/chatgpt-on-wechat` にクローン +3. プロジェクトを `~/CowAgent` にクローン 4. Pythonの依存パッケージと Cow CLI をインストール 5. AIモデルとチャネルの対話式設定 6. サービスの起動 diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx index 760f7d6d..6f865c61 100644 --- a/docs/ja/intro/index.mdx +++ b/docs/ja/intro/index.mdx @@ -9,8 +9,8 @@ description: CowAgent - LLM を活用した AI スーパーアシスタント CowAgent は自ら思考しタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、長期記憶により継続的に成長します。複数モデルの柔軟な切り替えをサポートし、テキスト、音声、画像、ファイルなどのマルチモーダルメッセージを処理でき、WeChat、Web、Feishu(飛書)、DingTalk(釘釘)、WeCom(企業微信)、WeChat公式アカウントに統合できます。お使いのパソコンやサーバー上で24時間365日稼働します。 - - github.com/zhayujie/chatgpt-on-wechat + + github.com/zhayujie/CowAgent ## コア機能 @@ -75,7 +75,7 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部 ## 免責事項 -1. 本プロジェクトは [MIT License](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE) に基づき、技術研究および学習を目的としています。利用者は現地の法律、規制、ポリシー、および企業の社内規程を遵守する必要があります。違法行為や権利侵害につながる利用は禁止されています。 +1. 本プロジェクトは [MIT License](https://github.com/zhayujie/CowAgent/blob/master/LICENSE) に基づき、技術研究および学習を目的としています。利用者は現地の法律、規制、ポリシー、および企業の社内規程を遵守する必要があります。違法行為や権利侵害につながる利用は禁止されています。 2. Agent モードは通常のチャットモードよりも多くのトークンを消費します。効果とコストを考慮してモデルを選択してください。Agent はホスト OS にアクセスできるため、デプロイには十分注意してください。 3. CowAgent はオープンソース開発に注力しており、いかなる暗号通貨の発行、認可、参加も行っておりません。 diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 4cec8960..b24a8966 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -51,5 +51,5 @@ CowAgentは国内外の主要なLLMをサポートしています。モデルイ
- モデル名の完全なリストについては、プロジェクトの[`common/const.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)ファイルを参照してください。 + モデル名の完全なリストについては、プロジェクトの[`common/const.py`](https://github.com/zhayujie/CowAgent/blob/master/common/const.py)ファイルを参照してください。 diff --git a/docs/ja/releases/overview.mdx b/docs/ja/releases/overview.mdx index 2191375d..3503e0c7 100644 --- a/docs/ja/releases/overview.mdx +++ b/docs/ja/releases/overview.mdx @@ -22,4 +22,4 @@ description: CowAgent バージョン履歴 | 1.5.0 | 2023.11.10 | gpt-4-turbo、dall-e-3、tts マルチモーダル | | 1.0.0 | 2022.12.12 | プロジェクト作成、初の ChatGPT 統合 | -完全な履歴は [GitHub Releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) をご覧ください。 +完全な履歴は [GitHub Releases](https://github.com/zhayujie/CowAgent/releases) をご覧ください。 diff --git a/docs/ja/releases/v2.0.0.mdx b/docs/ja/releases/v2.0.0.mdx index 828c5ed5..01be8185 100644 --- a/docs/ja/releases/v2.0.0.mdx +++ b/docs/ja/releases/v2.0.0.mdx @@ -5,7 +5,7 @@ description: CowAgent 2.0 - チャットボットから AI スーパーアシス CowAgent 2.0 は、チャットボットから **AI スーパーアシスタント** への包括的なアップグレードです。自律的な思考とタスク計画、長期記憶、コンピューターの操作、Skill の作成と実行が可能です。 -**リリース日**: 2026.02.03 | [GitHub Release](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0) +**リリース日**: 2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) ## 主な更新内容 @@ -60,4 +60,4 @@ CowAgent 2.0 は、チャットボットから **AI スーパーアシスタン ## コントリビューション -[フィードバックの送信](https://github.com/zhayujie/chatgpt-on-wechat/issues) や [コードのコントリビューション](https://github.com/zhayujie/chatgpt-on-wechat/pulls) を歓迎します。 +[フィードバックの送信](https://github.com/zhayujie/CowAgent/issues) や [コードのコントリビューション](https://github.com/zhayujie/CowAgent/pulls) を歓迎します。 diff --git a/docs/ja/releases/v2.0.1.mdx b/docs/ja/releases/v2.0.1.mdx index 3f338a56..7de993b3 100644 --- a/docs/ja/releases/v2.0.1.mdx +++ b/docs/ja/releases/v2.0.1.mdx @@ -3,34 +3,34 @@ title: v2.0.1 description: CowAgent 2.0.1 - 組み込み Web Search、スマートコンテキスト管理、複数の修正 --- -**リリース日**: 2026.02.27 | [全変更履歴](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.0..2.0.1) +**リリース日**: 2026.02.27 | [全変更履歴](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) ## 新機能 -- **組み込み Web Search ツール**: Web 検索を Agent の組み込みツールとして統合し、判断コストを削減 ([4f0ea5d](https://github.com/zhayujie/chatgpt-on-wechat/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) -- **Claude Opus 4.6 モデル対応**: Claude Opus 4.6 モデルのサポートを追加 ([#2661](https://github.com/zhayujie/chatgpt-on-wechat/pull/2661)) -- **企业微信の画像認識**: 企业微信チャネルでの画像メッセージ認識をサポート ([#2667](https://github.com/zhayujie/chatgpt-on-wechat/pull/2667)) +- **組み込み Web Search ツール**: Web 検索を Agent の組み込みツールとして統合し、判断コストを削減 ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) +- **Claude Opus 4.6 モデル対応**: Claude Opus 4.6 モデルのサポートを追加 ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) +- **企业微信の画像認識**: 企业微信チャネルでの画像メッセージ認識をサポート ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) ## 改善 -- **スマートコンテキスト管理**: インテリジェントなコンテキストトリミング戦略により、チャットコンテキストのオーバーフローを解決し、トークン制限超過を防止 ([cea7fb7](https://github.com/zhayujie/chatgpt-on-wechat/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/chatgpt-on-wechat/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/chatgpt-on-wechat/issues/2663) -- **ランタイム情報の動的更新**: 動的関数によるシステムプロンプト内のタイムスタンプおよびその他のランタイム情報の自動更新 ([#2655](https://github.com/zhayujie/chatgpt-on-wechat/pull/2655), [#2657](https://github.com/zhayujie/chatgpt-on-wechat/pull/2657)) -- **Skill プロンプトの最適化**: Skill システムプロンプト生成を改善し、ツールの説明を簡素化して Agent のパフォーマンスを向上 ([6c21833](https://github.com/zhayujie/chatgpt-on-wechat/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) -- **GLM カスタム API Base URL**: GLM モデルのカスタム API Base URL をサポート ([#2660](https://github.com/zhayujie/chatgpt-on-wechat/pull/2660)) -- **起動スクリプトの最適化**: `run.sh` スクリプトのインタラクションと設定フローを改善 ([#2656](https://github.com/zhayujie/chatgpt-on-wechat/pull/2656)) -- **判断ステップのログ記録**: デバッグ用の Agent 判断ステップログを追加 ([cb303e6](https://github.com/zhayujie/chatgpt-on-wechat/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) +- **スマートコンテキスト管理**: インテリジェントなコンテキストトリミング戦略により、チャットコンテキストのオーバーフローを解決し、トークン制限超過を防止 ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) +- **ランタイム情報の動的更新**: 動的関数によるシステムプロンプト内のタイムスタンプおよびその他のランタイム情報の自動更新 ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) +- **Skill プロンプトの最適化**: Skill システムプロンプト生成を改善し、ツールの説明を簡素化して Agent のパフォーマンスを向上 ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) +- **GLM カスタム API Base URL**: GLM モデルのカスタム API Base URL をサポート ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) +- **起動スクリプトの最適化**: `run.sh` スクリプトのインタラクションと設定フローを改善 ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) +- **判断ステップのログ記録**: デバッグ用の Agent 判断ステップログを追加 ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) ## バグ修正 -- **Scheduler の記憶喪失**: Scheduler ディスパッチャーによる記憶喪失を修正 ([a77a874](https://github.com/zhayujie/chatgpt-on-wechat/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) -- **空のツール呼び出しと長い結果**: 空のツール呼び出しおよび過度に長いツール結果の処理を修正 ([0542700](https://github.com/zhayujie/chatgpt-on-wechat/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) -- **OpenAI Function Call**: OpenAI モデルとの Function Call 互換性を修正 ([158c87a](https://github.com/zhayujie/chatgpt-on-wechat/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) -- **Claude ツール名フィールド**: Claude モデルのレスポンスから余分なツール名フィールドを削除 ([eec10cb](https://github.com/zhayujie/chatgpt-on-wechat/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) -- **MiniMax 推論**: MiniMax モデルの推論コンテンツ処理を最適化し、思考プロセスの出力を非表示化 ([c72cda3](https://github.com/zhayujie/chatgpt-on-wechat/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/chatgpt-on-wechat/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **GLM 思考プロセス**: GLM モデルの思考プロセス表示を非表示化 ([72b1cac](https://github.com/zhayujie/chatgpt-on-wechat/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **飞书の接続と SSL**: 飞书チャネルの SSL 証明書エラーおよび接続問題を修正 ([229b14b](https://github.com/zhayujie/chatgpt-on-wechat/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/chatgpt-on-wechat/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) -- **model_type バリデーション**: 非文字列の `model_type` による `AttributeError` を修正 ([#2666](https://github.com/zhayujie/chatgpt-on-wechat/pull/2666)) +- **Scheduler の記憶喪失**: Scheduler ディスパッチャーによる記憶喪失を修正 ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) +- **空のツール呼び出しと長い結果**: 空のツール呼び出しおよび過度に長いツール結果の処理を修正 ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) +- **OpenAI Function Call**: OpenAI モデルとの Function Call 互換性を修正 ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) +- **Claude ツール名フィールド**: Claude モデルのレスポンスから余分なツール名フィールドを削除 ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) +- **MiniMax 推論**: MiniMax モデルの推論コンテンツ処理を最適化し、思考プロセスの出力を非表示化 ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **GLM 思考プロセス**: GLM モデルの思考プロセス表示を非表示化 ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **飞书の接続と SSL**: 飞书チャネルの SSL 証明書エラーおよび接続問題を修正 ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) +- **model_type バリデーション**: 非文字列の `model_type` による `AttributeError` を修正 ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) ## プラットフォーム互換性 -- **Windows 互換性**: 複数のツールモジュールにおける Windows でのパス処理、ファイルエンコーディング、および `os.getuid()` の利用不可問題を修正 ([051ffd7](https://github.com/zhayujie/chatgpt-on-wechat/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) +- **Windows 互換性**: 複数のツールモジュールにおける Windows でのパス処理、ファイルエンコーディング、および `os.getuid()` の利用不可問題を修正 ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) diff --git a/docs/ja/releases/v2.0.2.mdx b/docs/ja/releases/v2.0.2.mdx index 7adc8c7f..ef9b3ee2 100644 --- a/docs/ja/releases/v2.0.2.mdx +++ b/docs/ja/releases/v2.0.2.mdx @@ -3,7 +3,7 @@ title: v2.0.2 description: CowAgent 2.0.2 - Web Console アップグレード、マルチチャネル同時実行、セッション永続化 --- -**リリース日**: 2026.02.27 | [全変更履歴](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.1...master) +**リリース日**: 2026.02.27 | [全変更履歴](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) ## ハイライト @@ -53,7 +53,7 @@ Agent のランタイムログをリアルタイムで表示し、監視とト -関連コミット: [f1a1413](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1a1413), [c0702c8](https://github.com/zhayujie/chatgpt-on-wechat/commit/c0702c8), [394853c](https://github.com/zhayujie/chatgpt-on-wechat/commit/394853c), [1c71c4e](https://github.com/zhayujie/chatgpt-on-wechat/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/chatgpt-on-wechat/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/chatgpt-on-wechat/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5) +関連コミット: [f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) ### 🔀 マルチチャネル同時実行 @@ -67,24 +67,24 @@ Agent のランタイムログをリアルタイムで表示し、監視とト } ``` -関連コミット: [4694594](https://github.com/zhayujie/chatgpt-on-wechat/commit/4694594), [7cce224](https://github.com/zhayujie/chatgpt-on-wechat/commit/7cce224), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5), [c9adddb](https://github.com/zhayujie/chatgpt-on-wechat/commit/c9adddb) +関連コミット: [4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) ### 💾 セッション永続化 セッション履歴がローカルの SQLite データベースに永続化されるようになりました。サービス再起動後も会話コンテキストが自動的に復元されます。Web Console の過去の会話も復元されます。 -関連コミット: [29bfbec](https://github.com/zhayujie/chatgpt-on-wechat/commit/29bfbec), [9917552](https://github.com/zhayujie/chatgpt-on-wechat/commit/9917552), [925d728](https://github.com/zhayujie/chatgpt-on-wechat/commit/925d728) +関連コミット: [29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) ## 新モデル -- **Gemini 3.1 Pro Preview**: `gemini-3.1-pro-preview` モデルのサポートを追加 ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad)) -- **Claude 4.6 Sonnet**: `claude-4.6-sonnet` モデルのサポートを追加 ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad)) -- **Qwen3.5 Plus**: `qwen3.5-plus` モデルのサポートを追加 ([e59a289](https://github.com/zhayujie/chatgpt-on-wechat/commit/e59a289)) -- **MiniMax M2.5**: `Minimax-M2.5` モデルのサポートを追加 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **GLM-5**: `glm-5` モデルのサポートを追加 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **Kimi K2.5**: `kimi-k2.5` モデルのサポートを追加 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **Doubao 2.0 Code**: コーディング特化型 `doubao-2.0-code` モデルを追加 ([ab28ee5](https://github.com/zhayujie/chatgpt-on-wechat/commit/ab28ee5)) -- **DashScope モデル**: 阿里云 DashScope モデル名のサポートを追加 ([ce58f23](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce58f23)) +- **Gemini 3.1 Pro Preview**: `gemini-3.1-pro-preview` モデルのサポートを追加 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Claude 4.6 Sonnet**: `claude-4.6-sonnet` モデルのサポートを追加 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Qwen3.5 Plus**: `qwen3.5-plus` モデルのサポートを追加 ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) +- **MiniMax M2.5**: `Minimax-M2.5` モデルのサポートを追加 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **GLM-5**: `glm-5` モデルのサポートを追加 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Kimi K2.5**: `kimi-k2.5` モデルのサポートを追加 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Doubao 2.0 Code**: コーディング特化型 `doubao-2.0-code` モデルを追加 ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) +- **DashScope モデル**: 阿里云 DashScope モデル名のサポートを追加 ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) ## ウェブサイトとドキュメント @@ -93,6 +93,6 @@ Agent のランタイムログをリアルタイムで表示し、監視とト ## バグ修正 -- **Gemini 钉钉画像認識**: 钉钉チャネルで Gemini が画像マーカーを処理できない問題を修正 ([05a3304](https://github.com/zhayujie/chatgpt-on-wechat/commit/05a3304)) ([#2670](https://github.com/zhayujie/chatgpt-on-wechat/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) -- **起動スクリプトの依存関係**: `run.sh` スクリプトの依存関係インストール問題を修正 ([b6fc9fa](https://github.com/zhayujie/chatgpt-on-wechat/commit/b6fc9fa)) -- **bare except の整理**: より適切な例外処理のため `bare except` を `except Exception` に置換 ([adca89b](https://github.com/zhayujie/chatgpt-on-wechat/commit/adca89b)) ([#2674](https://github.com/zhayujie/chatgpt-on-wechat/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) +- **Gemini 钉钉画像認識**: 钉钉チャネルで Gemini が画像マーカーを処理できない問題を修正 ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) +- **起動スクリプトの依存関係**: `run.sh` スクリプトの依存関係インストール問題を修正 ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) +- **bare except の整理**: より適切な例外処理のため `bare except` を `except Exception` に置換 ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) diff --git a/docs/ja/releases/v2.0.3.mdx b/docs/ja/releases/v2.0.3.mdx index f039d6a8..0d039851 100644 --- a/docs/ja/releases/v2.0.3.mdx +++ b/docs/ja/releases/v2.0.3.mdx @@ -11,7 +11,7 @@ description: CowAgent 2.0.3 - 企業微信スマートボットとQQチャネル 接続ドキュメント:[企業微信スマートボット接続](https://docs.cowagent.ai/channels/wecom-bot)。 -関連コミット:[d4480b6](https://github.com/zhayujie/chatgpt-on-wechat/commit/d4480b6), [a42f31f](https://github.com/zhayujie/chatgpt-on-wechat/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/chatgpt-on-wechat/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/chatgpt-on-wechat/commit/8b45d6c) +関連コミット:[d4480b6](https://github.com/zhayujie/CowAgent/commit/d4480b6), [a42f31f](https://github.com/zhayujie/CowAgent/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/CowAgent/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/CowAgent/commit/8b45d6c) ### QQ チャネル @@ -19,18 +19,18 @@ QQ 公式ボット(`qq`)チャネルを追加しました。テキストと 接続ドキュメント:[QQボット接続](https://docs.cowagent.ai/channels/qq)。 -関連コミット:[005a0e1](https://github.com/zhayujie/chatgpt-on-wechat/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/chatgpt-on-wechat/commit/a4d54f5) +関連コミット:[005a0e1](https://github.com/zhayujie/CowAgent/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/CowAgent/commit/a4d54f5) ## 🖥️ Web コンソールのファイル入力・処理対応 Web コンソールのチャット画面でファイルや画像のアップロードが可能になり、Agent に直接ファイルを送信して処理できます。また、Read ツールに Office ドキュメント(Word、Excel、PPT)の解析機能を追加しました。 -関連コミット:[30c6d9b](https://github.com/zhayujie/chatgpt-on-wechat/commit/30c6d9b) +関連コミット:[30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) ## 🤖 新規モデル -- **GPT-5.4 シリーズ**:`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` モデルのサポートを追加 ([1623deb](https://github.com/zhayujie/chatgpt-on-wechat/commit/1623deb)) -- **Gemini 3.1 Flash Lite Preview**:`gemini-3.1-flash-lite-preview` モデルのサポートを追加 ([ba915f2](https://github.com/zhayujie/chatgpt-on-wechat/commit/ba915f2)) +- **GPT-5.4 シリーズ**:`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` モデルのサポートを追加 ([1623deb](https://github.com/zhayujie/CowAgent/commit/1623deb)) +- **Gemini 3.1 Flash Lite Preview**:`gemini-3.1-flash-lite-preview` モデルのサポートを追加 ([ba915f2](https://github.com/zhayujie/CowAgent/commit/ba915f2)) ## 💰 Coding Plan サポート @@ -48,12 +48,12 @@ Web コンソールのチャット画面でファイルや画像のアップロ - 日次定期フラッシュのフォールバック機能を追加し、低アクティビティシナリオでのメモリ損失を防止 - コンテキストメモリの損失問題を修正 -関連コミット:[022c13f](https://github.com/zhayujie/chatgpt-on-wechat/commit/022c13f), [c116235](https://github.com/zhayujie/chatgpt-on-wechat/commit/c116235) +関連コミット:[022c13f](https://github.com/zhayujie/CowAgent/commit/022c13f), [c116235](https://github.com/zhayujie/CowAgent/commit/c116235) ## 🔧 ツールリファクタリング -- **画像認識**:画像認識(Image Vision)を Skill から内蔵 Tool にリファクタリングし、独立した画像ビジョンプロバイダー(Vision Provider)設定を追加。安定性と保守性を向上 ([a50fafa](https://github.com/zhayujie/chatgpt-on-wechat/commit/a50fafa), [3b8b562](https://github.com/zhayujie/chatgpt-on-wechat/commit/3b8b562)) -- **Webスクレイピング**:Webスクレイピング(Web Fetch)を Skill から内蔵 Tool にリファクタリング。リモートドキュメントファイル(PDF、Word、Excel、PPT)のダウンロードと解析をサポート ([ccb9030](https://github.com/zhayujie/chatgpt-on-wechat/commit/ccb9030), [fa61744](https://github.com/zhayujie/chatgpt-on-wechat/commit/fa61744)) +- **画像認識**:画像認識(Image Vision)を Skill から内蔵 Tool にリファクタリングし、独立した画像ビジョンプロバイダー(Vision Provider)設定を追加。安定性と保守性を向上 ([a50fafa](https://github.com/zhayujie/CowAgent/commit/a50fafa), [3b8b562](https://github.com/zhayujie/CowAgent/commit/3b8b562)) +- **Webスクレイピング**:Webスクレイピング(Web Fetch)を Skill から内蔵 Tool にリファクタリング。リモートドキュメントファイル(PDF、Word、Excel、PPT)のダウンロードと解析をサポート ([ccb9030](https://github.com/zhayujie/CowAgent/commit/ccb9030), [fa61744](https://github.com/zhayujie/CowAgent/commit/fa61744)) ## 🐳 Docker デプロイメントの最適化 @@ -64,28 +64,28 @@ Web コンソールのチャット画面でファイルや画像のアップロ ## ⚡ パフォーマンス最適化 -- **起動高速化**:飛書チャネルで依存関係の遅延読み込みを採用し、4-10秒の起動遅延を回避 ([924dc79](https://github.com/zhayujie/chatgpt-on-wechat/commit/924dc79)) -- **チャネルの安定性**:チャネル接続の安定性を最適化し、環境変数によるチャネル設定をサポート ([f1c04bc](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/chatgpt-on-wechat/commit/46d97fd)) +- **起動高速化**:飛書チャネルで依存関係の遅延読み込みを採用し、4-10秒の起動遅延を回避 ([924dc79](https://github.com/zhayujie/CowAgent/commit/924dc79)) +- **チャネルの安定性**:チャネル接続の安定性を最適化し、環境変数によるチャネル設定をサポート ([f1c04bc](https://github.com/zhayujie/CowAgent/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/CowAgent/commit/46d97fd)) ## 🐛 バグ修正 -- **bot_type 設定**:Agent モードでの `bot_type` 設定の受け渡し問題を修正 ([#2691](https://github.com/zhayujie/chatgpt-on-wechat/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) -- **bot_type 優先順位**:Agent モードでの `bot_type` の解析優先順位を調整 ([#2692](https://github.com/zhayujie/chatgpt-on-wechat/pull/2692)) Thanks [@6vision](https://github.com/6vision) -- **智譜モデル設定**:智譜の `bot_type` 命名、Web コンソールの永続化、正規表現エスケープの問題を修正 ([#2693](https://github.com/zhayujie/chatgpt-on-wechat/pull/2693)) Thanks [@6vision](https://github.com/6vision) -- **OpenAI 互換レイヤー**:`openai_compat` レイヤーによる統一エラー処理 ([#2688](https://github.com/zhayujie/chatgpt-on-wechat/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) -- **OpenAI 互換移行**:全モデル Bot の `openai_compat` 移行を完了 ([#2689](https://github.com/zhayujie/chatgpt-on-wechat/pull/2689)) -- **Gemini ツール呼び出し**:Gemini モデルのツール呼び出しマッチング問題を修正 ([eda82ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/eda82ba)) -- **セッション並行処理**:セッション並行シナリオでの競合条件の問題を修正 ([9879878](https://github.com/zhayujie/chatgpt-on-wechat/commit/9879878)) -- **履歴メッセージの復元**:履歴セッションメッセージの不完全な問題を修正。user/assistant のテキストメッセージのみを復元し、ツール呼び出しを除外 ([b788a3d](https://github.com/zhayujie/chatgpt-on-wechat/commit/b788a3d), [a33ce97](https://github.com/zhayujie/chatgpt-on-wechat/commit/a33ce97)) -- **飛書グループチャット**:飛書グループチャットシナリオでの `bot_name` 依存を削除 ([b641bff](https://github.com/zhayujie/chatgpt-on-wechat/commit/b641bff)) -- **Safari 互換性**:Safari ブラウザでの IME Enter キーによるメッセージ誤送信の問題を修正 ([0687916](https://github.com/zhayujie/chatgpt-on-wechat/commit/0687916)) -- **Windows 互換性**:Windows での bash スタイル `$VAR` 環境変数を `%VAR%` に変換する問題を修正 ([7c67513](https://github.com/zhayujie/chatgpt-on-wechat/commit/7c67513)) -- **MiniMax パラメータ**:MiniMax モデルの `max_tokens` 制限を追加 ([1767413](https://github.com/zhayujie/chatgpt-on-wechat/commit/1767413)) -- **.gitignore 更新**:Python ディレクトリの無視ルールを追加 ([#2683](https://github.com/zhayujie/chatgpt-on-wechat/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) +- **bot_type 設定**:Agent モードでの `bot_type` 設定の受け渡し問題を修正 ([#2691](https://github.com/zhayujie/CowAgent/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) +- **bot_type 優先順位**:Agent モードでの `bot_type` の解析優先順位を調整 ([#2692](https://github.com/zhayujie/CowAgent/pull/2692)) Thanks [@6vision](https://github.com/6vision) +- **智譜モデル設定**:智譜の `bot_type` 命名、Web コンソールの永続化、正規表現エスケープの問題を修正 ([#2693](https://github.com/zhayujie/CowAgent/pull/2693)) Thanks [@6vision](https://github.com/6vision) +- **OpenAI 互換レイヤー**:`openai_compat` レイヤーによる統一エラー処理 ([#2688](https://github.com/zhayujie/CowAgent/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) +- **OpenAI 互換移行**:全モデル Bot の `openai_compat` 移行を完了 ([#2689](https://github.com/zhayujie/CowAgent/pull/2689)) +- **Gemini ツール呼び出し**:Gemini モデルのツール呼び出しマッチング問題を修正 ([eda82ba](https://github.com/zhayujie/CowAgent/commit/eda82ba)) +- **セッション並行処理**:セッション並行シナリオでの競合条件の問題を修正 ([9879878](https://github.com/zhayujie/CowAgent/commit/9879878)) +- **履歴メッセージの復元**:履歴セッションメッセージの不完全な問題を修正。user/assistant のテキストメッセージのみを復元し、ツール呼び出しを除外 ([b788a3d](https://github.com/zhayujie/CowAgent/commit/b788a3d), [a33ce97](https://github.com/zhayujie/CowAgent/commit/a33ce97)) +- **飛書グループチャット**:飛書グループチャットシナリオでの `bot_name` 依存を削除 ([b641bff](https://github.com/zhayujie/CowAgent/commit/b641bff)) +- **Safari 互換性**:Safari ブラウザでの IME Enter キーによるメッセージ誤送信の問題を修正 ([0687916](https://github.com/zhayujie/CowAgent/commit/0687916)) +- **Windows 互換性**:Windows での bash スタイル `$VAR` 環境変数を `%VAR%` に変換する問題を修正 ([7c67513](https://github.com/zhayujie/CowAgent/commit/7c67513)) +- **MiniMax パラメータ**:MiniMax モデルの `max_tokens` 制限を追加 ([1767413](https://github.com/zhayujie/CowAgent/commit/1767413)) +- **.gitignore 更新**:Python ディレクトリの無視ルールを追加 ([#2683](https://github.com/zhayujie/CowAgent/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) - **AGENT.md の能動的進化**:システムプロンプトでの AGENT.md 更新ガイダンスを最適化。受動的な「ユーザーが変更した時に更新」から、会話中の性格やスタイルの変化を能動的に検出して自動更新するように改善 ## 📦 アップグレード方法 ソースコードデプロイの場合は `./run.sh update` でワンクリックアップグレードできます。または手動でコードをプルして再起動してください。詳細は [アップデートドキュメント](https://docs.cowagent.ai/guide/upgrade) を参照。 -**リリース日**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.2...master) +**リリース日**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.2...master) diff --git a/docs/ja/releases/v2.0.4.mdx b/docs/ja/releases/v2.0.4.mdx index 97d10d9b..4965caed 100644 --- a/docs/ja/releases/v2.0.4.mdx +++ b/docs/ja/releases/v2.0.4.mdx @@ -16,40 +16,40 @@ description: CowAgent 2.0.4 - 個人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) +関連コミット:[ce89869](https://github.com/zhayujie/CowAgent/commit/ce89869), [a483ec0](https://github.com/zhayujie/CowAgent/commit/a483ec0), [c1421e0](https://github.com/zhayujie/CowAgent/commit/c1421e0) ## 🤖 新規モデル - **MiniMax-M2.7**:MiniMax-M2.7 モデルのサポートを追加 - **GLM-5-Turbo**:智譜 glm-5-turbo モデルのサポートを追加 -関連コミット:[9192f6f](https://github.com/zhayujie/chatgpt-on-wechat/commit/9192f6f) +関連コミット:[9192f6f](https://github.com/zhayujie/CowAgent/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)) +- **run.sh リファクタリング**:共通ロジックを抽出し、大量の重複コードを削除。スクリプトの行数を 600+ 行から 177 行に圧縮 ([49d8707](https://github.com/zhayujie/CowAgent/commit/49d8707)) +- **実行権限**:`run.sh` ファイルの権限問題を修正 ([652156e](https://github.com/zhayujie/CowAgent/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)) +- **リクエストヘッダー統一**:Agent の各サービス(Chat、Embedding、Vision、WebSearch 等)の外部リクエストに統一的な識別ヘッダーを追加 ([b4e711f](https://github.com/zhayujie/CowAgent/commit/b4e711f)) +- **メッセージ自動修復**:メッセージプロトコルのフォールトトレランスを強化し、フォーマット異常なメッセージシーケンスを自動修復 ([b8b57e3](https://github.com/zhayujie/CowAgent/commit/b8b57e3)) ## 🌍 日本語ドキュメント 完全な日本語ドキュメントを追加しました。入門ガイド、チャネル接続、モデル設定などの主要セクションをカバーしています。Thanks [@Ikko Ashimine](https://github.com/ikoamu) -関連コミット:[5487c0b](https://github.com/zhayujie/chatgpt-on-wechat/commit/5487c0b) +関連コミット:[5487c0b](https://github.com/zhayujie/CowAgent/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)) +- **企業微信ボット互換性**:旧バージョンの `websocket-client` との互換性問題を修正し、統一的な WebSocket 互換レイヤーを追加 ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) +- **run.sh PID 取得**:`run.sh` でのプロセス PID 取得エラーを修正 ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) +- **飛書エンコーディング**:飛書チャネルのメッセージとログのエンコーディング問題を修正 ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) +- **飛書設定**:`run.sh` での `feishu_bot_name` への冗長な依存を削除 ([1b5be1b](https://github.com/zhayujie/CowAgent/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) +**リリース日**:2026.03.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.3...master) diff --git a/docs/ja/releases/v2.0.5.mdx b/docs/ja/releases/v2.0.5.mdx index 3569236c..a55b3acc 100644 --- a/docs/ja/releases/v2.0.5.mdx +++ b/docs/ja/releases/v2.0.5.mdx @@ -57,21 +57,21 @@ description: CowAgent 2.0.5 - Cow CLI、Skill Hub オープンソース、ブラ ドキュメント:[企業微信 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) +PR:[#2735](https://github.com/zhayujie/CowAgent/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)) +- **DeepSeek モジュール**:独立 DeepSeek Bot、`deepseek_api_key` 専用設定対応([#2719](https://github.com/zhayujie/CowAgent/pull/2719))。Thanks [@6vision](https://github.com/6vision) +- **Web コンソール**:スラッシュコマンドメニュー、入力履歴、新モデル選択肢、モバイル最適化([#2731](https://github.com/zhayujie/CowAgent/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd) +- **コンテキスト**:トリミング後のコンテキスト喪失を修正([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) +- **システムプロンプト**:毎ターン再構築されない問題を修正([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) +- **Gemini**:GoogleGeminiBot の model 属性欠落を修正([#2716](https://github.com/zhayujie/CowAgent/pull/2716))。Thanks [@cowagent](https://github.com/cowagent) +- **WeChat チャネル**:ファイル送信失敗・ファイル名消失の修正([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba)、[45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) +- **Docker**:ボリューム権限修正、イメージサイズ削減([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/CowAgent/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) +**リリース日**:2026.04.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.4...master) diff --git a/docs/ja/skills/create.mdx b/docs/ja/skills/create.mdx index e996cf39..78640a37 100644 --- a/docs/ja/skills/create.mdx +++ b/docs/ja/skills/create.mdx @@ -54,5 +54,5 @@ Detailed instructions... | `metadata.always` | 常に読み込む(デフォルトは false) | - 詳細は [Skill Creator のドキュメント](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)をご覧ください。 + 詳細は [Skill Creator のドキュメント](https://github.com/zhayujie/CowAgent/blob/master/skills/skill-creator/SKILL.md)をご覧ください。 diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 3e2b0a47..4d6ab34a 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -57,5 +57,5 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 - 全部模型名称可参考项目 [`common/const.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py) 文件。 + 全部模型名称可参考项目 [`common/const.py`](https://github.com/zhayujie/CowAgent/blob/master/common/const.py) 文件。 diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx index 4209bd22..e34f852c 100644 --- a/docs/releases/overview.mdx +++ b/docs/releases/overview.mdx @@ -25,4 +25,4 @@ description: CowAgent 版本更新历史 | 1.5.0 | 2023.11.10 | gpt-4-turbo、dall-e-3、tts 多模态 | | 1.0.0 | 2022.12.12 | 项目创建,首次接入 ChatGPT 模型 | -更多历史版本请查看 [GitHub Releases](https://github.com/zhayujie/chatgpt-on-wechat/releases)。 +更多历史版本请查看 [GitHub Releases](https://github.com/zhayujie/CowAgent/releases)。 diff --git a/docs/releases/v2.0.0.mdx b/docs/releases/v2.0.0.mdx index b3453c68..3436bc19 100644 --- a/docs/releases/v2.0.0.mdx +++ b/docs/releases/v2.0.0.mdx @@ -5,7 +5,7 @@ description: CowAgent 2.0 - 从聊天机器人到超级智能助理的全面升 CowAgent 2.0 实现了从聊天机器人到**超级智能助理**的全面升级!现在它能够主动思考和规划任务、拥有长期记忆、操作计算机和外部资源、创造和执行技能,真正理解你并和你一起成长。 -**发布日期**:2026.02.03 | [GitHub Release](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0) +**发布日期**:2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) ## 重点更新 @@ -102,4 +102,4 @@ Agent 根据任务需求智能选择和调用工具,完成各类复杂操作 ## 参与共建 -2.0 版本后,项目将持续升级 Agent 能力、拓展接入渠道、内置工具、技能系统,降低模型成本和提升安全性。欢迎 [提出反馈](https://github.com/zhayujie/chatgpt-on-wechat/issues) 和 [贡献代码](https://github.com/zhayujie/chatgpt-on-wechat/pulls)。 +2.0 版本后,项目将持续升级 Agent 能力、拓展接入渠道、内置工具、技能系统,降低模型成本和提升安全性。欢迎 [提出反馈](https://github.com/zhayujie/CowAgent/issues) 和 [贡献代码](https://github.com/zhayujie/CowAgent/pulls)。 diff --git a/docs/releases/v2.0.1.mdx b/docs/releases/v2.0.1.mdx index 067ad226..da7b6745 100644 --- a/docs/releases/v2.0.1.mdx +++ b/docs/releases/v2.0.1.mdx @@ -3,34 +3,34 @@ title: v2.0.1 description: CowAgent 2.0.1 - 内置 Web Search、智能上下文管理、多项修复 --- -**发布日期**:2026.02 | [GitHub Release](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1) | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.0..2.0.1) +**发布日期**:2026.02 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.1) | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) ## 新特性 -- **内置 Web Search 工具**:将网络搜索作为 Agent 内置工具集成,降低决策成本 ([4f0ea5d](https://github.com/zhayujie/chatgpt-on-wechat/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) -- **Claude Opus 4.6 模型支持**:新增对 Claude Opus 4.6 模型的支持 ([#2661](https://github.com/zhayujie/chatgpt-on-wechat/pull/2661)) -- **企业微信图片消息识别**:支持企业微信渠道的图片消息识别功能 ([#2667](https://github.com/zhayujie/chatgpt-on-wechat/pull/2667)) +- **内置 Web Search 工具**:将网络搜索作为 Agent 内置工具集成,降低决策成本 ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) +- **Claude Opus 4.6 模型支持**:新增对 Claude Opus 4.6 模型的支持 ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) +- **企业微信图片消息识别**:支持企业微信渠道的图片消息识别功能 ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) ## 优化 -- **智能上下文管理**:解决聊天上下文溢出问题,新增智能上下文裁剪策略,防止 token 超限 ([cea7fb7](https://github.com/zhayujie/chatgpt-on-wechat/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/chatgpt-on-wechat/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/chatgpt-on-wechat/issues/2663) -- **运行时信息动态更新**:通过动态函数方案实现系统提示词中时间戳等运行时信息的自动更新 ([#2655](https://github.com/zhayujie/chatgpt-on-wechat/pull/2655), [#2657](https://github.com/zhayujie/chatgpt-on-wechat/pull/2657)) -- **Skill 提示词优化**:改进 Skill 系统提示词生成逻辑,简化工具描述,提升 Agent 表现 ([6c21833](https://github.com/zhayujie/chatgpt-on-wechat/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) -- **智谱 AI 自定义 API Base URL**:支持智谱 AI 配置自定义 API Base URL ([#2660](https://github.com/zhayujie/chatgpt-on-wechat/pull/2660)) -- **启动脚本优化**:改进 `run.sh` 脚本的交互体验和配置流程 ([#2656](https://github.com/zhayujie/chatgpt-on-wechat/pull/2656)) -- **决策轮次日志**:新增 Agent 决策轮次的日志记录,便于调试 ([cb303e6](https://github.com/zhayujie/chatgpt-on-wechat/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) +- **智能上下文管理**:解决聊天上下文溢出问题,新增智能上下文裁剪策略,防止 token 超限 ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) +- **运行时信息动态更新**:通过动态函数方案实现系统提示词中时间戳等运行时信息的自动更新 ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) +- **Skill 提示词优化**:改进 Skill 系统提示词生成逻辑,简化工具描述,提升 Agent 表现 ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) +- **智谱 AI 自定义 API Base URL**:支持智谱 AI 配置自定义 API Base URL ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) +- **启动脚本优化**:改进 `run.sh` 脚本的交互体验和配置流程 ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) +- **决策轮次日志**:新增 Agent 决策轮次的日志记录,便于调试 ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) ## 问题修复 -- **定时任务记忆丢失**:修复 Scheduler 调度器导致的记忆丢失问题 ([a77a874](https://github.com/zhayujie/chatgpt-on-wechat/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) -- **空工具调用与超长结果**:修复空 tool calls 及过长工具返回结果的异常处理 ([0542700](https://github.com/zhayujie/chatgpt-on-wechat/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) -- **OpenAI Function Call**:修复 OpenAI 模型的 function call 调用兼容性问题 ([158c87a](https://github.com/zhayujie/chatgpt-on-wechat/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) -- **Claude 工具名字段**:移除 Claude 模型响应中多余的 tool name 字段 ([eec10cb](https://github.com/zhayujie/chatgpt-on-wechat/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) -- **MiniMax 推理优化**:优化 MiniMax 模型 reasoning content 处理,隐藏思考过程输出 ([c72cda3](https://github.com/zhayujie/chatgpt-on-wechat/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/chatgpt-on-wechat/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **智谱 AI 思考过程**:隐藏智谱 AI 模型的思考过程展示 ([72b1cac](https://github.com/zhayujie/chatgpt-on-wechat/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **飞书连接与证书**:修复飞书渠道的 SSL 证书错误和连接异常问题 ([229b14b](https://github.com/zhayujie/chatgpt-on-wechat/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/chatgpt-on-wechat/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) -- **model_type 类型校验**:修复非字符串 `model_type` 导致的 `AttributeError` ([#2666](https://github.com/zhayujie/chatgpt-on-wechat/pull/2666)) +- **定时任务记忆丢失**:修复 Scheduler 调度器导致的记忆丢失问题 ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) +- **空工具调用与超长结果**:修复空 tool calls 及过长工具返回结果的异常处理 ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) +- **OpenAI Function Call**:修复 OpenAI 模型的 function call 调用兼容性问题 ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) +- **Claude 工具名字段**:移除 Claude 模型响应中多余的 tool name 字段 ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) +- **MiniMax 推理优化**:优化 MiniMax 模型 reasoning content 处理,隐藏思考过程输出 ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **智谱 AI 思考过程**:隐藏智谱 AI 模型的思考过程展示 ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **飞书连接与证书**:修复飞书渠道的 SSL 证书错误和连接异常问题 ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) +- **model_type 类型校验**:修复非字符串 `model_type` 导致的 `AttributeError` ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) ## 平台兼容 -- **Windows 兼容性适配**:修复 Windows 平台下路径处理、文件编码及 `os.getuid()` 不可用等问题,涉及多个工具模块 ([051ffd7](https://github.com/zhayujie/chatgpt-on-wechat/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) +- **Windows 兼容性适配**:修复 Windows 平台下路径处理、文件编码及 `os.getuid()` 不可用等问题,涉及多个工具模块 ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) diff --git a/docs/releases/v2.0.2.mdx b/docs/releases/v2.0.2.mdx index cc3626f0..ad17bcba 100644 --- a/docs/releases/v2.0.2.mdx +++ b/docs/releases/v2.0.2.mdx @@ -51,7 +51,7 @@ description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会 -相关提交:[f1a1413](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1a1413), [c0702c8](https://github.com/zhayujie/chatgpt-on-wechat/commit/c0702c8), [394853c](https://github.com/zhayujie/chatgpt-on-wechat/commit/394853c), [1c71c4e](https://github.com/zhayujie/chatgpt-on-wechat/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/chatgpt-on-wechat/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/chatgpt-on-wechat/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5) +相关提交:[f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) ### 🔀 多通道同时运行 @@ -65,24 +65,24 @@ description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会 } ``` -相关提交:[4694594](https://github.com/zhayujie/chatgpt-on-wechat/commit/4694594), [7cce224](https://github.com/zhayujie/chatgpt-on-wechat/commit/7cce224), [7d258b5](https://github.com/zhayujie/chatgpt-on-wechat/commit/7d258b5), [c9adddb](https://github.com/zhayujie/chatgpt-on-wechat/commit/c9adddb) +相关提交:[4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) ### 💾 会话持久化 会话历史支持持久化存储至本地 SQLite 数据库,服务重启后会话上下文自动恢复,不再丢失。Web 控制台中的历史对话记录也会同步恢复展示。 -相关提交:[29bfbec](https://github.com/zhayujie/chatgpt-on-wechat/commit/29bfbec), [9917552](https://github.com/zhayujie/chatgpt-on-wechat/commit/9917552), [925d728](https://github.com/zhayujie/chatgpt-on-wechat/commit/925d728) +相关提交:[29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) ### 🤖 新增模型 -- **Gemini 3.1 Pro Preview**:新增 `gemini-3.1-pro-preview` 模型支持 ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad)) -- **Claude 4.6 Sonnet**:新增 `claude-4.6-sonnet` 模型支持 ([52d7cad](https://github.com/zhayujie/chatgpt-on-wechat/commit/52d7cad)) -- **Qwen3.5 Plus**:新增 `qwen3.5-plus` 模型支持 ([e59a289](https://github.com/zhayujie/chatgpt-on-wechat/commit/e59a289)) -- **MiniMax M2.5**:新增 `Minimax-M2.5` 模型支持 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **GLM-5**:新增 `glm-5` 模型支持 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **Kimi K2.5**:新增 `kimi-k2.5` 模型支持 ([48db538](https://github.com/zhayujie/chatgpt-on-wechat/commit/48db538)) -- **Doubao 2.0 Code**:新增 `doubao-2.0-code` 编程专用模型 ([ab28ee5](https://github.com/zhayujie/chatgpt-on-wechat/commit/ab28ee5)) -- **DashScope 模型**:新增阿里云 DashScope 模型名称支持 ([ce58f23](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce58f23)) +- **Gemini 3.1 Pro Preview**:新增 `gemini-3.1-pro-preview` 模型支持 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Claude 4.6 Sonnet**:新增 `claude-4.6-sonnet` 模型支持 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Qwen3.5 Plus**:新增 `qwen3.5-plus` 模型支持 ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) +- **MiniMax M2.5**:新增 `Minimax-M2.5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **GLM-5**:新增 `glm-5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Kimi K2.5**:新增 `kimi-k2.5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Doubao 2.0 Code**:新增 `doubao-2.0-code` 编程专用模型 ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) +- **DashScope 模型**:新增阿里云 DashScope 模型名称支持 ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) ### 🌐 新增官网和文档中心 @@ -91,8 +91,8 @@ description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会 ### 🐛 问题修复 -- **Gemini 钉钉图片识别**:修复 Gemini 在钉钉通道中无法处理图片标记的问题 ([05a3304](https://github.com/zhayujie/chatgpt-on-wechat/commit/05a3304)) ([#2670](https://github.com/zhayujie/chatgpt-on-wechat/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) -- **启动脚本依赖**:修复 `run.sh` 脚本的依赖安装问题 ([b6fc9fa](https://github.com/zhayujie/chatgpt-on-wechat/commit/b6fc9fa)) -- **裸异常捕获**:将代码中的 `bare except` 替换为 `except Exception`,提升异常处理规范性 ([adca89b](https://github.com/zhayujie/chatgpt-on-wechat/commit/adca89b)) ([#2674](https://github.com/zhayujie/chatgpt-on-wechat/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) +- **Gemini 钉钉图片识别**:修复 Gemini 在钉钉通道中无法处理图片标记的问题 ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) +- **启动脚本依赖**:修复 `run.sh` 脚本的依赖安装问题 ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) +- **裸异常捕获**:将代码中的 `bare except` 替换为 `except Exception`,提升异常处理规范性 ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) -**发布日期**:2026.02.27 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.1...master) +**发布日期**:2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) diff --git a/docs/releases/v2.0.3.mdx b/docs/releases/v2.0.3.mdx index 069e2369..22d2d1f6 100644 --- a/docs/releases/v2.0.3.mdx +++ b/docs/releases/v2.0.3.mdx @@ -11,7 +11,7 @@ description: CowAgent 2.0.3 - 新增企微智能机器人和 QQ 通道、Web 控 接入文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。 -相关提交:[d4480b6](https://github.com/zhayujie/chatgpt-on-wechat/commit/d4480b6), [a42f31f](https://github.com/zhayujie/chatgpt-on-wechat/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/chatgpt-on-wechat/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/chatgpt-on-wechat/commit/8b45d6c) +相关提交:[d4480b6](https://github.com/zhayujie/CowAgent/commit/d4480b6), [a42f31f](https://github.com/zhayujie/CowAgent/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/CowAgent/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/CowAgent/commit/8b45d6c) ### QQ 通道 @@ -19,18 +19,18 @@ description: CowAgent 2.0.3 - 新增企微智能机器人和 QQ 通道、Web 控 接入文档参考:[QQ机器人接入](https://docs.cowagent.ai/channels/qq)。 -相关提交:[005a0e1](https://github.com/zhayujie/chatgpt-on-wechat/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/chatgpt-on-wechat/commit/a4d54f5) +相关提交:[005a0e1](https://github.com/zhayujie/CowAgent/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/CowAgent/commit/a4d54f5) ## 🖥️ Web 控制台支持文件输入和处理 Web 控制台对话界面支持文件和图片上传,可直接发送文件给 Agent 进行处理。同时 Read 工具新增对 Office 文档(Word、Excel、PPT)的解析能力。 -相关提交:[30c6d9b](https://github.com/zhayujie/chatgpt-on-wechat/commit/30c6d9b) +相关提交:[30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) ## 🤖 新增模型 -- **GPT-5.4 系列**:新增 `gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` 模型支持 ([1623deb](https://github.com/zhayujie/chatgpt-on-wechat/commit/1623deb)) -- **Gemini 3.1 Flash Lite Preview**:新增 `gemini-3.1-flash-lite-preview` 模型支持 ([ba915f2](https://github.com/zhayujie/chatgpt-on-wechat/commit/ba915f2)) +- **GPT-5.4 系列**:新增 `gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` 模型支持 ([1623deb](https://github.com/zhayujie/CowAgent/commit/1623deb)) +- **Gemini 3.1 Flash Lite Preview**:新增 `gemini-3.1-flash-lite-preview` 模型支持 ([ba915f2](https://github.com/zhayujie/CowAgent/commit/ba915f2)) ## 💰 Coding Plan 支持 @@ -48,12 +48,12 @@ Web 控制台对话界面支持文件和图片上传,可直接发送文件给 - 新增每日定时冲刷兜底机制,避免低活跃场景下记忆丢失 - 修复上下文记忆丢失问题 -相关提交:[022c13f](https://github.com/zhayujie/chatgpt-on-wechat/commit/022c13f), [c116235](https://github.com/zhayujie/chatgpt-on-wechat/commit/c116235) +相关提交:[022c13f](https://github.com/zhayujie/CowAgent/commit/022c13f), [c116235](https://github.com/zhayujie/CowAgent/commit/c116235) ## 🔧 工具重构 -- **图片识别**:将图片识别(Image Vision)从 Skill 重构为内置 Tool,新增独立的图片视觉提供方(Vision Provider)配置,提升稳定性和可维护性 ([a50fafa](https://github.com/zhayujie/chatgpt-on-wechat/commit/a50fafa), [3b8b562](https://github.com/zhayujie/chatgpt-on-wechat/commit/3b8b562)) -- **网页抓取**:将网页抓取(Web Fetch)从 Skill 重构为内置 Tool,支持远程文档文件(PDF、Word、Excel、PPT)的下载和解析 ([ccb9030](https://github.com/zhayujie/chatgpt-on-wechat/commit/ccb9030), [fa61744](https://github.com/zhayujie/chatgpt-on-wechat/commit/fa61744)) +- **图片识别**:将图片识别(Image Vision)从 Skill 重构为内置 Tool,新增独立的图片视觉提供方(Vision Provider)配置,提升稳定性和可维护性 ([a50fafa](https://github.com/zhayujie/CowAgent/commit/a50fafa), [3b8b562](https://github.com/zhayujie/CowAgent/commit/3b8b562)) +- **网页抓取**:将网页抓取(Web Fetch)从 Skill 重构为内置 Tool,支持远程文档文件(PDF、Word、Excel、PPT)的下载和解析 ([ccb9030](https://github.com/zhayujie/CowAgent/commit/ccb9030), [fa61744](https://github.com/zhayujie/CowAgent/commit/fa61744)) ## 🐳 Docker 部署优化 @@ -64,28 +64,28 @@ Web 控制台对话界面支持文件和图片上传,可直接发送文件给 ## ⚡ 性能优化 -- **启动加速**:飞书通道采用懒加载方式导入依赖,避免 4-10 秒的启动延迟 ([924dc79](https://github.com/zhayujie/chatgpt-on-wechat/commit/924dc79)) -- **通道稳定性**:优化通道连接稳定性,支持通道配置通过环境变量设置 ([f1c04bc](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/chatgpt-on-wechat/commit/46d97fd)) +- **启动加速**:飞书通道采用懒加载方式导入依赖,避免 4-10 秒的启动延迟 ([924dc79](https://github.com/zhayujie/CowAgent/commit/924dc79)) +- **通道稳定性**:优化通道连接稳定性,支持通道配置通过环境变量设置 ([f1c04bc](https://github.com/zhayujie/CowAgent/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/CowAgent/commit/46d97fd)) ## 🐛 问题修复 -- **bot_type 配置**:修复 Agent 模式下 `bot_type` 配置传递问题 ([#2691](https://github.com/zhayujie/chatgpt-on-wechat/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) -- **bot_type 优先级**:调整 Agent 模式下 `bot_type` 的解析优先级 ([#2692](https://github.com/zhayujie/chatgpt-on-wechat/pull/2692)) Thanks [@6vision](https://github.com/6vision) -- **智谱模型配置**:修复智谱 `bot_type` 命名、Web 控制台持久化及正则转义问题 ([#2693](https://github.com/zhayujie/chatgpt-on-wechat/pull/2693)) Thanks [@6vision](https://github.com/6vision) -- **OpenAI 兼容层**:使用 `openai_compat` 层统一错误处理 ([#2688](https://github.com/zhayujie/chatgpt-on-wechat/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) -- **OpenAI 兼容迁移**:完成所有模型 Bot 的 `openai_compat` 迁移 ([#2689](https://github.com/zhayujie/chatgpt-on-wechat/pull/2689)) -- **Gemini 工具调用**:修复 Gemini 模型的工具调用匹配问题 ([eda82ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/eda82ba)) -- **会话并发**:修复会话并发场景下的竞态条件问题 ([9879878](https://github.com/zhayujie/chatgpt-on-wechat/commit/9879878)) -- **历史消息恢复**:修复历史会话消息不完整问题,仅恢复 user/assistant 文本消息,剥离工具调用 ([b788a3d](https://github.com/zhayujie/chatgpt-on-wechat/commit/b788a3d), [a33ce97](https://github.com/zhayujie/chatgpt-on-wechat/commit/a33ce97)) -- **飞书群聊**:移除飞书群聊场景下对 `bot_name` 的依赖 ([b641bff](https://github.com/zhayujie/chatgpt-on-wechat/commit/b641bff)) -- **Safari 兼容**:修复 Safari 浏览器 IME 回车键误触发消息发送问题 ([0687916](https://github.com/zhayujie/chatgpt-on-wechat/commit/0687916)) -- **Windows 兼容**:修复 Windows 下 bash 风格 `$VAR` 环境变量转换为 `%VAR%` 的问题 ([7c67513](https://github.com/zhayujie/chatgpt-on-wechat/commit/7c67513)) -- **MiniMax 参数**:增加 MiniMax 模型的 `max_tokens` 限制 ([1767413](https://github.com/zhayujie/chatgpt-on-wechat/commit/1767413)) -- **.gitignore 更新**:添加 Python 目录忽略规则 ([#2683](https://github.com/zhayujie/chatgpt-on-wechat/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) +- **bot_type 配置**:修复 Agent 模式下 `bot_type` 配置传递问题 ([#2691](https://github.com/zhayujie/CowAgent/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) +- **bot_type 优先级**:调整 Agent 模式下 `bot_type` 的解析优先级 ([#2692](https://github.com/zhayujie/CowAgent/pull/2692)) Thanks [@6vision](https://github.com/6vision) +- **智谱模型配置**:修复智谱 `bot_type` 命名、Web 控制台持久化及正则转义问题 ([#2693](https://github.com/zhayujie/CowAgent/pull/2693)) Thanks [@6vision](https://github.com/6vision) +- **OpenAI 兼容层**:使用 `openai_compat` 层统一错误处理 ([#2688](https://github.com/zhayujie/CowAgent/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) +- **OpenAI 兼容迁移**:完成所有模型 Bot 的 `openai_compat` 迁移 ([#2689](https://github.com/zhayujie/CowAgent/pull/2689)) +- **Gemini 工具调用**:修复 Gemini 模型的工具调用匹配问题 ([eda82ba](https://github.com/zhayujie/CowAgent/commit/eda82ba)) +- **会话并发**:修复会话并发场景下的竞态条件问题 ([9879878](https://github.com/zhayujie/CowAgent/commit/9879878)) +- **历史消息恢复**:修复历史会话消息不完整问题,仅恢复 user/assistant 文本消息,剥离工具调用 ([b788a3d](https://github.com/zhayujie/CowAgent/commit/b788a3d), [a33ce97](https://github.com/zhayujie/CowAgent/commit/a33ce97)) +- **飞书群聊**:移除飞书群聊场景下对 `bot_name` 的依赖 ([b641bff](https://github.com/zhayujie/CowAgent/commit/b641bff)) +- **Safari 兼容**:修复 Safari 浏览器 IME 回车键误触发消息发送问题 ([0687916](https://github.com/zhayujie/CowAgent/commit/0687916)) +- **Windows 兼容**:修复 Windows 下 bash 风格 `$VAR` 环境变量转换为 `%VAR%` 的问题 ([7c67513](https://github.com/zhayujie/CowAgent/commit/7c67513)) +- **MiniMax 参数**:增加 MiniMax 模型的 `max_tokens` 限制 ([1767413](https://github.com/zhayujie/CowAgent/commit/1767413)) +- **.gitignore 更新**:添加 Python 目录忽略规则 ([#2683](https://github.com/zhayujie/CowAgent/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) - **AGENT.md 主动演进**:优化系统提示词中对 AGENT.md 的更新引导,从被动的"用户修改时更新"改为主动识别对话中的性格、风格变化并自动更新 ## 📦 升级方式 源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 -**发布日期**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.2...master) +**发布日期**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.2...master) diff --git a/docs/releases/v2.0.4.mdx b/docs/releases/v2.0.4.mdx index 447f7992..55264a4b 100644 --- a/docs/releases/v2.0.4.mdx +++ b/docs/releases/v2.0.4.mdx @@ -16,36 +16,36 @@ description: CowAgent 2.0.4 - 新增个人微信通道、新模型支持、日 接入文档:[微信接入](https://docs.cowagent.ai/channels/weixin)。 -相关提交:[ce89869](https://github.com/zhayujie/chatgpt-on-wechat/commit/ce89869) +相关提交:[ce89869](https://github.com/zhayujie/CowAgent/commit/ce89869) ## 🤖 新增模型 - **MiniMax-M2.7**:新增 MiniMax-M2.7 模型支持 - **GLM-5-Turbo**:新增智谱 glm-5-turbo 模型支持 -相关提交:[9192f6f](https://github.com/zhayujie/chatgpt-on-wechat/commit/9192f6f) +相关提交:[9192f6f](https://github.com/zhayujie/CowAgent/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)) +- **run.sh 重构**:提取公共逻辑,精简脚本代码([49d8707](https://github.com/zhayujie/CowAgent/commit/49d8707)) +- **可执行权限**:修复 `run.sh` 文件权限问题 ([652156e](https://github.com/zhayujie/CowAgent/commit/652156e)) +- **PID 获取**:修复 `run.sh` 中进程 PID 获取错误的问题 ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) ## 🌍 文档更新 新增完整的日文文档,覆盖入门指南、通道接入、模型配置等主要章节。Thanks [@Ikko Ashimine](https://github.com/ikoamu) -相关提交:[5487c0b](https://github.com/zhayujie/chatgpt-on-wechat/commit/5487c0b) +相关提交:[5487c0b](https://github.com/zhayujie/CowAgent/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)) +- **企微机器人兼容**:修复旧版 `websocket-client` 的兼容性问题,新增统一的 WebSocket 兼容层 ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) +- **消息自动修复**:增强消息协议的容错能力,自动修复格式异常的消息序列 ([b8b57e3](https://github.com/zhayujie/CowAgent/commit/b8b57e3)) +- **飞书编码**:修复飞书通道消息和日志的编码问题 ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) +- **飞书配置**:移除 `run.sh` 中对 `feishu_bot_name` 的冗余依赖 ([1b5be1b](https://github.com/zhayujie/CowAgent/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) +**发布日期**:2026.03.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.3...master) diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx index f073145f..bf0c0e53 100644 --- a/docs/releases/v2.0.5.mdx +++ b/docs/releases/v2.0.5.mdx @@ -59,26 +59,26 @@ Skill Hub 开源仓库:[cow-skill-hub](https://github.com/zhayujie/cow-skill-h 相关文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。 -相关提交:[#2735](https://github.com/zhayujie/chatgpt-on-wechat/pull/2735) +相关提交:[#2735](https://github.com/zhayujie/CowAgent/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)) -- **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)、[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) +- **DeepSeek 独立模块**:新增独立的 DeepSeek Bot 模块,支持 `deepseek_api_key` 专属配置,无需再通过 OpenAI 兼容方式接入([#2719](https://github.com/zhayujie/CowAgent/pull/2719))。Thanks [@6vision](https://github.com/6vision) +- **Web 控制台优化**:新增斜杠指令菜单和输入历史回溯,新增模型选项,优化移动端适配([#2731](https://github.com/zhayujie/CowAgent/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd) +- **上下文丢失**:修复上下文裁剪后丢失的问题 ([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) +- **系统提示词**:修复系统提示词未在每轮重建的问题 ([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) +- **Agent 响应**:去除 Agent 响应首尾空白字符 ([f890318](https://github.com/zhayujie/CowAgent/commit/f890318)) +- **视觉压缩**:优化视觉图片压缩策略 ([22b8ca0](https://github.com/zhayujie/CowAgent/commit/22b8ca0)) +- **Gemini 模型**:修复 GoogleGeminiBot 缺少 model 属性的问题([#2716](https://github.com/zhayujie/CowAgent/pull/2716))。Thanks [@cowagent](https://github.com/cowagent) +- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba)、[baf66a1](https://github.com/zhayujie/CowAgent/commit/baf66a1)、[45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) +- **Docker 优化**:修复卷权限问题,精简镜像体积 ([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/CowAgent/commit/4470d4c)) +- **README 排版**:优化中英文排版空格([#2723](https://github.com/zhayujie/CowAgent/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.04.01 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master) +**发布日期**:2026.04.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.4...master) diff --git a/docs/skills/create.mdx b/docs/skills/create.mdx index b8ae7ea2..45cef149 100644 --- a/docs/skills/create.mdx +++ b/docs/skills/create.mdx @@ -54,5 +54,5 @@ Detailed instructions... | `metadata.always` | 是否始终加载(默认 false) | - 详细开发文档可参考 [Skill Creator 说明](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md)。 + 详细开发文档可参考 [Skill Creator 说明](https://github.com/zhayujie/CowAgent/blob/master/skills/skill-creator/SKILL.md)。 diff --git a/run.sh b/run.sh index ab47faf4..c364deab 100755 --- a/run.sh +++ b/run.sh @@ -127,21 +127,21 @@ check_python_version() { # Clone project clone_project() { - echo -e "${GREEN}🔍 Cloning ChatGPT-on-WeChat project...${NC}" + echo -e "${GREEN}🔍 Cloning CowAgent project...${NC}" - if [ -d "chatgpt-on-wechat" ]; then - echo -e "${YELLOW}⚠️ Directory 'chatgpt-on-wechat' already exists.${NC}" + if [ -d "CowAgent" ]; then + echo -e "${YELLOW}⚠️ Directory 'CowAgent' already exists.${NC}" read -p "Choose action: overwrite(o), backup(b), or quit(q)? [press Enter for default: b]: " choice choice=${choice:-b} case "$choice" in o|O) - echo -e "${YELLOW}🗑️ Overwriting 'chatgpt-on-wechat' directory...${NC}" - rm -rf chatgpt-on-wechat + echo -e "${YELLOW}🗑️ Overwriting 'CowAgent' directory...${NC}" + rm -rf CowAgent ;; b|B) - backup_dir="chatgpt-on-wechat_backup_$(date +%s)" + backup_dir="CowAgent_backup_$(date +%s)" echo -e "${YELLOW}🔀 Backing up to '$backup_dir'...${NC}" - mv chatgpt-on-wechat "$backup_dir" + mv CowAgent "$backup_dir" ;; q|Q) echo -e "${RED}❌ Installation cancelled.${NC}" @@ -158,23 +158,23 @@ clone_project() { if ! command -v git &> /dev/null; then echo -e "${YELLOW}⚠️ Git not available. Trying wget/curl...${NC}" - local zip_url="https://gitee.com/zhayujie/chatgpt-on-wechat/repository/archive/master.zip" + local zip_url="https://gitee.com/zhayujie/CowAgent/repository/archive/master.zip" if command -v wget &> /dev/null; then - wget "$zip_url" -O chatgpt-on-wechat.zip + wget "$zip_url" -O CowAgent.zip elif command -v curl &> /dev/null; then - curl -L "$zip_url" -o chatgpt-on-wechat.zip + curl -L "$zip_url" -o CowAgent.zip else echo -e "${RED}❌ Cannot download project. Please install Git, wget, or curl.${NC}" exit 1 fi - unzip chatgpt-on-wechat.zip - mv chatgpt-on-wechat-master chatgpt-on-wechat - rm chatgpt-on-wechat.zip + unzip CowAgent.zip + mv CowAgent-master CowAgent + rm CowAgent.zip else 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 || { + git clone --depth 10 --progress https://github.com/zhayujie/CowAgent.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 + git clone --depth 10 --progress https://gitee.com/zhayujie/CowAgent.git } if [[ $? -ne 0 ]]; then echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}" @@ -182,7 +182,7 @@ clone_project() { fi fi - cd chatgpt-on-wechat || { echo -e "${RED}❌ Failed to enter project directory.${NC}"; exit 1; } + cd CowAgent || { echo -e "${RED}❌ Failed to enter project directory.${NC}"; exit 1; } export BASE_DIR=$(pwd) echo -e "${GREEN}✅ Project cloned successfully: $BASE_DIR${NC}" @@ -802,7 +802,7 @@ cmd_update() { 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 remote set-url origin https://gitee.com/zhayujie/CowAgent.git if git pull; then pull_ok=true else diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 06b52f71..66ae2539 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -67,15 +67,15 @@ function Assert-Python { # ── clone project ──────────────────────────────────────────────── function Install-Project { - if (Test-Path "chatgpt-on-wechat") { - Write-Warn "Directory 'chatgpt-on-wechat' already exists." + if (Test-Path "CowAgent") { + Write-Warn "Directory 'CowAgent' 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" } + "o" { Remove-Item -Recurse -Force "CowAgent" } "b" { - $backup = "chatgpt-on-wechat_backup_$(Get-Date -Format 'yyyyMMddHHmmss')" - Rename-Item "chatgpt-on-wechat" $backup + $backup = "CowAgent_backup_$(Get-Date -Format 'yyyyMMddHHmmss')" + Rename-Item "CowAgent" $backup Write-Cow "Backed up to '$backup'" } "q" { Write-Err "Installation cancelled."; exit 1 } @@ -91,13 +91,13 @@ function Install-Project { Write-Cow "Cloning CowAgent project..." $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" - git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>&1 | Out-Null + git clone https://github.com/zhayujie/CowAgent.git 2>&1 | Out-Null $cloneExit = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($cloneExit -ne 0) { Write-Warn "GitHub failed, trying Gitee..." $ErrorActionPreference = "Continue" - git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git 2>&1 | Out-Null + git clone https://gitee.com/zhayujie/CowAgent.git 2>&1 | Out-Null $cloneExit = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($cloneExit -ne 0) { @@ -106,7 +106,7 @@ function Install-Project { } } - Set-Location "chatgpt-on-wechat" + Set-Location "CowAgent" $script:BaseDir = $PWD.Path $script:IsProjectDir = $true Write-Cow "Project cloned: $BaseDir" @@ -443,7 +443,7 @@ function Update-Project { if ($pullExit -ne 0) { Write-Warn "GitHub failed, trying Gitee..." $ErrorActionPreference = "Continue" - git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git 2>&1 | Out-Null + git remote set-url origin https://gitee.com/zhayujie/CowAgent.git 2>&1 | Out-Null git pull 2>&1 | Out-Null $ErrorActionPreference = $prevEAP } From 907882c0a7862352b0e0129da8a56d900a81e439 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 17:36:45 +0800 Subject: [PATCH 109/399] fix: git clone pre-check --- README.md | 2 +- run.sh | 17 +++++++++++------ scripts/run.ps1 | 43 ++++++++++++++++++++++++++++++------------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1cdbc309..e86b7835 100644 --- a/README.md +++ b/README.md @@ -898,7 +898,7 @@ FAQs: # 📌 项目更名说明 -本项目原名 `chatgpt-on-wechat`(GitHub 原地址:https://github.com/zhayujie/chatgpt-on-wechat), +本项目原名 `chatgpt-on-wechat`(GitHub 原地址:https://github.com/zhayujie/chatgpt-on-wechat ), 于 2026.04.13 正式更名为 **CowAgent**。GitHub 已自动设置重定向,原有链接仍可正常访问。 如需更新本地仓库的远程地址(可选): diff --git a/run.sh b/run.sh index c364deab..7ad6f85d 100755 --- a/run.sh +++ b/run.sh @@ -171,12 +171,17 @@ clone_project() { mv CowAgent-master CowAgent rm CowAgent.zip else - 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/CowAgent.git || { - echo -e "${YELLOW}⚠️ GitHub is slow, switching to Gitee mirror...${NC}" - git clone --depth 10 --progress https://gitee.com/zhayujie/CowAgent.git - } - if [[ $? -ne 0 ]]; then + local clone_ok=false + # Test GitHub connectivity before attempting clone + if curl -s --connect-timeout 5 --max-time 10 https://github.com > /dev/null 2>&1; then + echo -e "${YELLOW}🌐 GitHub is reachable, cloning from GitHub...${NC}" + timeout 15 git clone --depth 10 --progress https://github.com/zhayujie/CowAgent.git && clone_ok=true + fi + if [ "$clone_ok" = false ]; then + echo -e "${YELLOW}⚠️ GitHub clone failed or timed out, switching to Gitee mirror...${NC}" + timeout 30 git clone --depth 10 --progress https://gitee.com/zhayujie/CowAgent.git && clone_ok=true + fi + if [ "$clone_ok" = false ]; then echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}" exit 1 fi diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 66ae2539..b931e637 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -90,20 +90,37 @@ function Install-Project { } Write-Cow "Cloning CowAgent project..." - $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" - git clone https://github.com/zhayujie/CowAgent.git 2>&1 | Out-Null - $cloneExit = $LASTEXITCODE - $ErrorActionPreference = $prevEAP - if ($cloneExit -ne 0) { - Write-Warn "GitHub failed, trying Gitee..." - $ErrorActionPreference = "Continue" - git clone https://gitee.com/zhayujie/CowAgent.git 2>&1 | Out-Null - $cloneExit = $LASTEXITCODE - $ErrorActionPreference = $prevEAP - if ($cloneExit -ne 0) { - Write-Err "Clone failed. Check your network." - exit 1 + $cloneOk = $false + + # Test GitHub connectivity before attempting clone + try { + $null = Invoke-WebRequest -Uri "https://github.com" -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + Write-Cow "GitHub is reachable, cloning from GitHub..." + $proc = Start-Process -FilePath "git" -ArgumentList "clone","--depth","10","--progress","https://github.com/zhayujie/CowAgent.git" ` + -NoNewWindow -PassThru -RedirectStandardError "$env:TEMP\git_clone_err.txt" + if ($proc.WaitForExit(15000) -and $proc.ExitCode -eq 0) { + $cloneOk = $true + } else { + if (-not $proc.HasExited) { $proc.Kill() } + if (Test-Path "CowAgent") { Remove-Item -Recurse -Force "CowAgent" } } + } catch {} + + if (-not $cloneOk) { + Write-Warn "GitHub clone failed or timed out, switching to Gitee mirror..." + $proc = Start-Process -FilePath "git" -ArgumentList "clone","--depth","10","--progress","https://gitee.com/zhayujie/CowAgent.git" ` + -NoNewWindow -PassThru -RedirectStandardError "$env:TEMP\git_clone_err.txt" + if ($proc.WaitForExit(30000) -and $proc.ExitCode -eq 0) { + $cloneOk = $true + } else { + if (-not $proc.HasExited) { $proc.Kill() } + if (Test-Path "CowAgent") { Remove-Item -Recurse -Force "CowAgent" } + } + } + + if (-not $cloneOk) { + Write-Err "Clone failed. Check your network." + exit 1 } Set-Location "CowAgent" From 4dd497fb6d7377818cf0b5559d716add32bfdd45 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 17:52:37 +0800 Subject: [PATCH 110/399] fix: run.ps1 git clone in windows --- scripts/run.ps1 | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/scripts/run.ps1 b/scripts/run.ps1 index b931e637..a9cdc6f6 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -60,6 +60,7 @@ $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/" + Read-Host "Press Enter to exit" exit 1 } Write-Cow "Found Python: $PythonCmd" @@ -86,6 +87,7 @@ function Install-Project { $gitBin = Get-Command git -ErrorAction SilentlyContinue if (-not $gitBin) { Write-Err "Git not found. Please install from https://git-scm.com/download/win" + Read-Host "Press Enter to exit" exit 1 } @@ -96,30 +98,30 @@ function Install-Project { try { $null = Invoke-WebRequest -Uri "https://github.com" -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop Write-Cow "GitHub is reachable, cloning from GitHub..." - $proc = Start-Process -FilePath "git" -ArgumentList "clone","--depth","10","--progress","https://github.com/zhayujie/CowAgent.git" ` - -NoNewWindow -PassThru -RedirectStandardError "$env:TEMP\git_clone_err.txt" - if ($proc.WaitForExit(15000) -and $proc.ExitCode -eq 0) { - $cloneOk = $true - } else { - if (-not $proc.HasExited) { $proc.Kill() } + $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" + git clone --depth 10 --progress "https://github.com/zhayujie/CowAgent.git" 2>&1 | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -eq 0) { $cloneOk = $true } + $ErrorActionPreference = $prevEAP + if (-not $cloneOk) { if (Test-Path "CowAgent") { Remove-Item -Recurse -Force "CowAgent" } } } catch {} if (-not $cloneOk) { Write-Warn "GitHub clone failed or timed out, switching to Gitee mirror..." - $proc = Start-Process -FilePath "git" -ArgumentList "clone","--depth","10","--progress","https://gitee.com/zhayujie/CowAgent.git" ` - -NoNewWindow -PassThru -RedirectStandardError "$env:TEMP\git_clone_err.txt" - if ($proc.WaitForExit(30000) -and $proc.ExitCode -eq 0) { - $cloneOk = $true - } else { - if (-not $proc.HasExited) { $proc.Kill() } + $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" + git clone --depth 10 --progress "https://gitee.com/zhayujie/CowAgent.git" 2>&1 | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -eq 0) { $cloneOk = $true } + $ErrorActionPreference = $prevEAP + if (-not $cloneOk) { if (Test-Path "CowAgent") { Remove-Item -Recurse -Force "CowAgent" } } } if (-not $cloneOk) { - Write-Err "Clone failed. Check your network." + Write-Err "Clone failed from both GitHub and Gitee. Please check your network connection." + Write-Err "You can also manually clone: git clone https://gitee.com/zhayujie/CowAgent.git" + Read-Host "Press Enter to exit" exit 1 } From fbe48a4b4e621991f0209000d66315a09b20a0d2 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 20:37:04 +0800 Subject: [PATCH 111/399] feat(web): add password protection for web console - Add `web_password` config to enable login authentication - Use stateless HMAC-signed token (survives restart, invalidates on password change) - Add `web_session_expire_days` config (default 30 days) - Protect all API endpoints with auth check (401 on failure) - Add login page UI with auto-redirect on session expiry - Add password management in config page (masked display, inline edit) - Add tooltip hints for Agent config fields - Update default agent_max_context_turns to 20, agent_max_steps to 20 - Update docs and docker-compose.yml --- README.md | 8 +- channel/web/chat.html | 79 ++++++++++- channel/web/static/css/console.css | 39 ++++++ channel/web/static/js/console.js | 211 ++++++++++++++++++++++++++--- channel/web/web_channel.py | 142 +++++++++++++++++-- config-template.json | 5 +- config.py | 6 +- docker/docker-compose.yml | 5 +- docs/channels/web.mdx | 19 +++ docs/en/README.md | 3 +- docs/en/knowledge/index.mdx | 14 +- docs/intro/architecture.mdx | 6 +- docs/ja/README.md | 3 +- docs/ja/knowledge/index.mdx | 14 +- 14 files changed, 498 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index e86b7835..67f9b26e 100644 --- a/README.md +++ b/README.md @@ -198,11 +198,12 @@ cow install-browser "group_speech_recognition": false, # 是否开启群组语音识别 "voice_reply_voice": false, # 是否使用语音回复语音 "use_linkai": false, # 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台模型 + "web_password": "", # Web 控制台访问密码,留空则不启用密码保护 "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 模式下单次任务的最大决策步数,超出后将停止继续调用工具 + "agent_max_context_tokens": 50000, # Agent 模式下最大上下文 tokens,超出将自动智能压缩处理 + "agent_max_context_turns": 20, # Agent 模式下最大上下文记忆轮次,一问一答为一轮,超出后智能压缩处理 + "agent_max_steps": 20 # Agent 模式下单次任务的最大决策步数,超出后将停止继续调用工具 } ``` @@ -710,6 +711,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O ``` - `web_port`: 默认为 9899,可按需更改,需要服务器防火墙和安全组放行该端口 +- `web_password`: 访问密码,留空则不启用密码保护。部署在公网环境时建议设置 - 如本地运行,启动后请访问 `http://localhost:9899/chat` ;如服务器运行,请访问 `http://ip:9899/chat` > 注:请将上述 url 中的 ip 或者 port 替换为实际的值
diff --git a/channel/web/chat.html b/channel/web/chat.html index a291858c..233b681c 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -54,6 +54,41 @@ + + + +
@@ -414,21 +449,30 @@
- +
- +
- + +
+
+ +
+

Security

+
+
+
+ + +

Leave empty to disable password protection

+
+
+ + +
+
+
+
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 21aebdca..5592348a 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -689,3 +689,42 @@ border-color: rgba(255,255,255,0.1); color: #e2e8f0; } + +/* Config field tooltip */ +.cfg-tip { + position: relative; + display: inline-flex; + align-items: center; + color: #94a3b8; + cursor: help; + font-size: 12px; +} +.cfg-tip:hover { color: #64748b; } +.dark .cfg-tip:hover { color: #cbd5e1; } +.cfg-tip::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%); + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + font-weight: 400; + line-height: 1.4; + white-space: nowrap; + background: #1e293b; + color: #e2e8f0; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + z-index: 50; +} +.dark .cfg-tip::after { + background: #334155; + color: #f1f5f9; +} +.cfg-tip:hover::after { + opacity: 1; +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 4beb7272..98c98394 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -31,14 +31,20 @@ const I18N = { config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', config_channel: '通道配置', - config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token', - config_max_turns: '最大轮次', config_max_steps: '最大步数', + config_agent_enabled: 'Agent 模式', + config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理', + config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理', + config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', config_custom_model_hint: '输入自定义模型名称', config_save: '保存', config_saved: '已保存', config_save_error: '保存失败', config_custom_option: '自定义...', + config_security: '安全设置', config_password: '访问密码', + config_password_hint: '留空则不启用密码保护', + config_password_changed: '密码已更新,请重新登录', + config_password_cleared: '密码已清除', skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', skills_hub_btn: '探索技能广场', skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处', tools_section_title: '内置工具', tools_loading: '加载工具中...', @@ -92,14 +98,20 @@ const I18N = { config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_model: 'Model Configuration', config_agent: 'Agent Configuration', config_channel: 'Channel Configuration', - config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens', - config_max_turns: 'Max Turns', config_max_steps: 'Max Steps', + config_agent_enabled: 'Agent Mode', + config_max_tokens: 'Max Context Tokens', config_max_tokens_hint: 'Max tokens the Agent can input per conversation, auto-compressed when exceeded', + config_max_turns: 'Max Memory Turns', config_max_turns_hint: 'One Q&A pair = one turn, auto-compressed when exceeded', + config_max_steps: 'Max Steps', config_max_steps_hint: 'Max tool calls the Agent can make in a single conversation', config_channel_type: 'Channel Type', config_provider: 'Provider', config_model_name: 'Model', config_custom_model_hint: 'Enter custom model name', config_save: 'Save', config_saved: 'Saved', config_save_error: 'Save failed', config_custom_option: 'Custom...', + config_security: 'Security', config_password: 'Password', + config_password_hint: 'Leave empty to disable password protection', + config_password_changed: 'Password updated, please re-login', + config_password_cleared: 'Password cleared', 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...', @@ -151,6 +163,9 @@ function applyI18n() { document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset['i18nPlaceholder']); }); + document.querySelectorAll('[data-tip-key]').forEach(el => { + el.setAttribute('data-tooltip', t(el.dataset.tipKey)); + }); document.getElementById('lang-label').textContent = currentLang === 'zh' ? 'EN' : '中文'; } @@ -1572,8 +1587,35 @@ function initConfigView(data) { syncModelSelection(configCurrentModel); document.getElementById('cfg-max-tokens').value = data.agent_max_context_tokens || 50000; - document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 30; - document.getElementById('cfg-max-steps').value = data.agent_max_steps || 15; + document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 20; + document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20; + + const pwdInput = document.getElementById('cfg-password'); + const maskedPwd = data.web_password_masked || ''; + pwdInput.value = maskedPwd; + pwdInput.dataset.masked = maskedPwd ? '1' : ''; + pwdInput.dataset.maskedVal = maskedPwd; + pwdInput.classList.toggle('cfg-key-masked', !!maskedPwd); + + if (maskedPwd) { + pwdInput.placeholder = '••••••••'; + } else { + pwdInput.placeholder = ''; + } + + if (!pwdInput._cfgBound) { + pwdInput.addEventListener('focus', function() { + if (this.dataset.masked === '1') { + this.value = ''; + this.dataset.masked = ''; + this.classList.remove('cfg-key-masked'); + } + }); + pwdInput.addEventListener('input', function() { + this.dataset.masked = ''; + }); + pwdInput._cfgBound = true; + } } function detectProvider(model) { @@ -1779,8 +1821,8 @@ function saveModelConfig() { function saveAgentConfig() { const updates = { agent_max_context_tokens: parseInt(document.getElementById('cfg-max-tokens').value) || 50000, - agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 30, - agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 15, + agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 20, + agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 20, }; const btn = document.getElementById('cfg-agent-save'); @@ -1802,6 +1844,40 @@ function saveAgentConfig() { .finally(() => { btn.disabled = false; }); } +function savePasswordConfig() { + const input = document.getElementById('cfg-password'); + if (input.dataset.masked === '1') { + showStatus('cfg-password-status', 'config_saved', false); + return; + } + const newPwd = input.value.trim(); + const btn = document.getElementById('cfg-password-save'); + btn.disabled = true; + fetch('/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates: { web_password: newPwd } }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + if (newPwd) { + showStatus('cfg-password-status', 'config_password_changed', false); + setTimeout(() => { window.location.reload(); }, 1500); + } else { + input.dataset.masked = ''; + input.dataset.maskedVal = ''; + input.classList.remove('cfg-key-masked'); + showStatus('cfg-password-status', 'config_password_cleared', false); + } + } else { + showStatus('cfg-password-status', 'config_save_error', true); + } + }) + .catch(() => showStatus('cfg-password-status', 'config_save_error', true)) + .finally(() => { btn.disabled = false; }); +} + function loadConfigView() { fetch('/config').then(r => r.json()).then(data => { if (data.status !== 'success') return; @@ -3342,29 +3418,120 @@ function renderKnowledgeGraph(container, nodes, links) { container.appendChild(legendDiv); } +// ===================================================================== +// Authentication +// ===================================================================== +function toggleLoginPassword() { + const input = document.getElementById('login-password'); + const icon = document.querySelector('#login-toggle-pwd i'); + if (input.type === 'password') { + input.type = 'text'; + icon.classList.replace('fa-eye', 'fa-eye-slash'); + } else { + input.type = 'password'; + icon.classList.replace('fa-eye-slash', 'fa-eye'); + } +} +window.toggleLoginPassword = toggleLoginPassword; + +function showLoginScreen() { + const overlay = document.getElementById('login-overlay'); + if (!overlay) return; + overlay.classList.remove('hidden'); + document.getElementById('app').classList.add('hidden'); + + const subtitle = document.getElementById('login-subtitle'); + const loginBtn = document.getElementById('login-btn'); + if (currentLang === 'en') { + subtitle.textContent = 'Enter password to access the console'; + loginBtn.textContent = 'Login'; + } else { + subtitle.textContent = '请输入密码以访问控制台'; + loginBtn.textContent = '登录'; + } + + const form = document.getElementById('login-form'); + const pwdInput = document.getElementById('login-password'); + pwdInput.focus(); + + form.onsubmit = function(e) { + e.preventDefault(); + const pwd = pwdInput.value; + if (!pwd) return; + const btn = document.getElementById('login-btn'); + const errEl = document.getElementById('login-error'); + btn.disabled = true; + errEl.classList.add('hidden'); + + fetch('/auth/login', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({password: pwd}) + }).then(r => r.json()).then(data => { + if (data.status === 'success') { + overlay.classList.add('hidden'); + document.getElementById('app').classList.remove('hidden'); + initApp(); + } else { + errEl.textContent = currentLang === 'zh' ? '密码错误' : 'Wrong password'; + errEl.classList.remove('hidden'); + pwdInput.value = ''; + pwdInput.focus(); + } + btn.disabled = false; + }).catch(() => { + errEl.textContent = currentLang === 'zh' ? '网络错误,请重试' : 'Network error, please retry'; + errEl.classList.remove('hidden'); + btn.disabled = false; + }); + return false; + }; +} + +// Intercept 401 responses globally to show login screen on session expiry +const _originalFetch = window.fetch; +window.fetch = function(...args) { + return _originalFetch.apply(this, args).then(response => { + if (response.status === 401) { + const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); + if (!url.startsWith('/auth/')) { + showLoginScreen(); + } + } + return response; + }); +}; + +function initApp() { + fetch('/api/knowledge/list').then(r => r.json()).then(data => { + if (data.status === 'success') _knowledgeTreeData = data.tree || []; + }).catch(() => {}); + + 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(); +} + // ===================================================================== // Initialization // ===================================================================== applyTheme(); applyI18n(); -// Pre-fetch knowledge tree for chat link resolution -fetch('/api/knowledge/list').then(r => r.json()).then(data => { - if (data.status === 'success') _knowledgeTreeData = data.tree || []; -}).catch(() => {}); - -fetch('/api/version').then(r => r.json()).then(data => { - APP_VERSION = `v${data.version}`; - document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; +fetch('/auth/check').then(r => r.json()).then(data => { + if (data.auth_required && !data.authenticated) { + showLoginScreen(); + } else { + initApp(); + } }).catch(() => { - document.getElementById('sidebar-version').textContent = 'CowAgent'; + initApp(); }); -chatInput.focus(); -// Re-enable color transition AFTER first paint so the theme applied in -// doesn't produce an animated flash on load. The class is missing from the -// body initially; adding it here means transitions only fire on user-triggered -// theme toggles, not on page load. requestAnimationFrame(() => { document.body.classList.add('transition-colors', 'duration-200'); }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index c5a2dd58..57b95217 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import time import json import logging @@ -23,6 +25,62 @@ from config import conf IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"} +def _is_password_enabled(): + return bool(conf().get("web_password", "")) + + +def _session_expire_seconds(): + return int(conf().get("web_session_expire_days", 30)) * 86400 + + +def _create_auth_token(): + """Create a stateless signed token: ``.``.""" + ts = format(int(time.time()), "x") + sig = hmac.new( + conf().get("web_password", "").encode(), + ts.encode(), + hashlib.sha256, + ).hexdigest() + return f"{ts}.{sig}" + + +def _verify_auth_token(token): + """Verify a signed token is valid and not expired. + + The token is derived from the password, so it survives server restarts + and automatically invalidates when the password changes. + """ + if not token or "." not in token: + return False + ts_hex, sig = token.split(".", 1) + try: + ts = int(ts_hex, 16) + except ValueError: + return False + if time.time() - ts > _session_expire_seconds(): + return False + expected = hmac.new( + conf().get("web_password", "").encode(), + ts_hex.encode(), + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(sig, expected) + + +def _check_auth(): + """Return True if request is authenticated or password not enabled.""" + if not _is_password_enabled(): + return True + return _verify_auth_token(web.cookies().get("cow_auth_token", "")) + + +def _require_auth(): + """Raise 401 if not authenticated. Call at the top of protected handlers.""" + if not _check_auth(): + raise web.HTTPError("401 Unauthorized", + {"Content-Type": "application/json; charset=utf-8"}, + json.dumps({"status": "error", "message": "Unauthorized"})) + def _get_upload_dir() -> str: from common.utils import expand_path @@ -440,6 +498,9 @@ class WebChannel(ChatChannel): urls = ( '/', 'RootHandler', + '/auth/login', 'AuthLoginHandler', + '/auth/check', 'AuthCheckHandler', + '/auth/logout', 'AuthLogoutHandler', '/message', 'MessageHandler', '/upload', 'UploadHandler', '/uploads/(.*)', 'UploadsHandler', @@ -502,24 +563,62 @@ class WebChannel(ChatChannel): class RootHandler: def GET(self): - # 重定向到/chat raise web.seeother('/chat') +class AuthCheckHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + if not _is_password_enabled(): + return json.dumps({"status": "success", "auth_required": False}) + if _check_auth(): + return json.dumps({"status": "success", "auth_required": True, "authenticated": True}) + return json.dumps({"status": "success", "auth_required": True, "authenticated": False}) + + +class AuthLoginHandler: + def POST(self): + web.header('Content-Type', 'application/json; charset=utf-8') + if not _is_password_enabled(): + return json.dumps({"status": "success"}) + try: + data = json.loads(web.data()) + except Exception: + return json.dumps({"status": "error", "message": "Invalid request"}) + password = data.get("password", "") + expected = conf().get("web_password", "") + if not hmac.compare_digest(password, expected): + logger.warning("[WebChannel] Invalid login attempt") + return json.dumps({"status": "error", "message": "Wrong password"}) + token = _create_auth_token() + web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(), + path="/", httponly=True, samesite="Lax") + return json.dumps({"status": "success"}) + + +class AuthLogoutHandler: + def POST(self): + web.header('Content-Type', 'application/json; charset=utf-8') + web.setcookie("cow_auth_token", "", expires=-1, path="/") + return json.dumps({"status": "success"}) + + class MessageHandler: def POST(self): + _require_auth() return WebChannel().post_message() class UploadHandler: def POST(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') return WebChannel().upload_file() class UploadsHandler: def GET(self, file_name): - """Serve uploaded files from workspace/tmp/ for preview.""" + _require_auth() try: upload_dir = _get_upload_dir() full_path = os.path.normpath(os.path.join(upload_dir, file_name)) @@ -541,7 +640,7 @@ class UploadsHandler: class FileServeHandler: def GET(self): - """Serve a local file by absolute path (for agent send tool).""" + _require_auth() try: params = web.input(path="") file_path = params.path @@ -567,11 +666,13 @@ class FileServeHandler: class PollHandler: def POST(self): + _require_auth() return WebChannel().poll_response() class StreamHandler: def GET(self): + _require_auth() params = web.input(request_id='') request_id = params.request_id if not request_id: @@ -695,6 +796,7 @@ class ConfigHandler: "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", + "web_password", } @staticmethod @@ -705,7 +807,7 @@ class ConfigHandler: return value[:4] + "*" * (len(value) - 8) + value[-4:] def GET(self): - """Return configuration info and provider/model metadata.""" + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: local_config = conf() @@ -733,6 +835,9 @@ class ConfigHandler: "api_key_field": p.get("api_key_field"), } + raw_pwd = local_config.get("web_password", "") + masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else "" + return json.dumps({ "status": "success", "use_agent": use_agent, @@ -743,17 +848,18 @@ class ConfigHandler: "channel_type": local_config.get("channel_type", ""), "agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000), "agent_max_context_turns": local_config.get("agent_max_context_turns", 20), - "agent_max_steps": local_config.get("agent_max_steps", 15), + "agent_max_steps": local_config.get("agent_max_steps", 20), "api_bases": api_bases, "api_keys": api_keys_masked, "providers": providers, + "web_password_masked": masked_pwd, }, ensure_ascii=False) except Exception as e: logger.error(f"Error getting config: {e}") return json.dumps({"status": "error", "message": str(e)}) def POST(self): - """Update configuration values in memory and persist to config.json.""" + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: data = json.loads(web.data()) @@ -902,6 +1008,7 @@ class ChannelsHandler: return set(cls._parse_channel_list(conf().get("channel_type", ""))) def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: local_config = conf() @@ -939,6 +1046,7 @@ class ChannelsHandler: return json.dumps({"status": "error", "message": str(e)}) def POST(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: body = json.loads(web.data()) @@ -1192,6 +1300,7 @@ class WeixinQrHandler: return None def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: running_ch = self._get_running_channel() @@ -1224,6 +1333,7 @@ class WeixinQrHandler: return json.dumps({"status": "error", "message": str(e)}) def POST(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: body = json.loads(web.data()) @@ -1311,6 +1421,7 @@ def _get_workspace_root(): class ToolsHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.tools.tool_manager import ToolManager @@ -1335,6 +1446,7 @@ class ToolsHandler: class SkillsHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.skills.service import SkillService @@ -1349,6 +1461,7 @@ class SkillsHandler: return json.dumps({"status": "error", "message": str(e)}) def POST(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.skills.service import SkillService @@ -1375,6 +1488,7 @@ class SkillsHandler: class MemoryHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.memory.service import MemoryService @@ -1390,6 +1504,7 @@ class MemoryHandler: class MemoryContentHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.memory.service import MemoryService @@ -1411,6 +1526,7 @@ class MemoryContentHandler: class SchedulerHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.tools.scheduler.task_store import TaskStore @@ -1426,14 +1542,7 @@ class SchedulerHandler: class HistoryHandler: def GET(self): - """ - Return paginated conversation history for a session. - - Query params: - session_id (required) - page int, default 1 (1 = most recent messages) - page_size int, default 20 - """ + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') web.header('Access-Control-Allow-Origin', '*') try: @@ -1457,7 +1566,7 @@ class HistoryHandler: class LogsHandler: def GET(self): - """Stream the last N lines of run.log as SSE, then tail new lines.""" + _require_auth() web.header('Content-Type', 'text/event-stream; charset=utf-8') web.header('Cache-Control', 'no-cache') web.header('X-Accel-Buffering', 'no') @@ -1545,6 +1654,7 @@ class AssetsHandler: class KnowledgeListHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.knowledge.service import KnowledgeService @@ -1558,6 +1668,7 @@ class KnowledgeListHandler: class KnowledgeReadHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.knowledge.service import KnowledgeService @@ -1574,6 +1685,7 @@ class KnowledgeReadHandler: class KnowledgeGraphHandler: def GET(self): + _require_auth() web.header('Content-Type', 'application/json; charset=utf-8') try: from agent.knowledge.service import KnowledgeService diff --git a/config-template.json b/config-template.json index 65ecc651..d6b13223 100644 --- a/config-template.json +++ b/config-template.json @@ -26,9 +26,10 @@ "dingtalk_client_secret":"", "wecom_bot_id": "", "wecom_bot_secret": "", + "web_password": "", "agent": true, - "agent_max_context_tokens": 40000, + "agent_max_context_tokens": 50000, "agent_max_context_turns": 20, - "agent_max_steps": 15, + "agent_max_steps": 20, "knowledge": true } diff --git a/config.py b/config.py index 34bcab67..e5dcd5d8 100644 --- a/config.py +++ b/config.py @@ -195,11 +195,13 @@ available_setting = { "Minimax_group_id": "", "Minimax_base_url": "", "web_port": 9899, + "web_password": "", # Web console password; empty means no authentication required + "web_session_expire_days": 30, # Auth session expiry in days "agent": True, # 是否开启Agent模式 "agent_workspace": "~/cow", # agent工作空间路径,用于存储skills、memory等 "agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens - "agent_max_context_turns": 30, # Agent模式下最大上下文记忆轮次 - "agent_max_steps": 15, # Agent模式下单次运行最大决策步数 + "agent_max_context_turns": 20, # Agent模式下最大上下文记忆轮次 + "agent_max_steps": 20, # Agent模式下单次运行最大决策步数 "knowledge": True, # 是否开启知识库功能 } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2c611ac1..3d0b95c3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -35,9 +35,10 @@ services: DINGTALK_CLIENT_SECRET: '' WECOM_BOT_ID: '' WECOM_BOT_SECRET: '' + WEB_PASSWORD: '' AGENT: 'True' - AGENT_MAX_CONTEXT_TOKENS: 40000 + AGENT_MAX_CONTEXT_TOKENS: 50000 AGENT_MAX_CONTEXT_TURNS: 20 - AGENT_MAX_STEPS: 15 + AGENT_MAX_STEPS: 20 volumes: - ./cow:/home/agent/cow diff --git a/docs/channels/web.mdx b/docs/channels/web.mdx index 5d1528a2..d8329553 100644 --- a/docs/channels/web.mdx +++ b/docs/channels/web.mdx @@ -30,6 +30,25 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏 请确保服务器防火墙和安全组已放行对应端口。 +## 密码保护 + +Web 控制台默认无需密码即可访问。如果部署在公网环境,建议配置访问密码: + +```json +{ + "web_password": "your_password" +} +``` + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `web_password` | 访问密码,留空表示不启用密码保护 | `""` | +| `web_session_expire_days` | 登录会话有效天数 | `30` | + +配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。修改密码后,所有已登录的会话将自动失效。 + +密码也支持在控制台的「配置」页面中在线修改。 + ## 功能介绍 ### 对话界面 diff --git a/docs/en/README.md b/docs/en/README.md index d9723be7..d35279c3 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 WeChat, 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 and a personal knowledge base. 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  ·  @@ -23,6 +23,7 @@ - ✅ **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. +- ✅ **Personal Knowledge Base**: Automatically organizes structured knowledge with cross-references to build a knowledge graph, with web-based visualization and conversational management. - ✅ **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. diff --git a/docs/en/knowledge/index.mdx b/docs/en/knowledge/index.mdx index be6d4729..a877c386 100644 --- a/docs/en/knowledge/index.mdx +++ b/docs/en/knowledge/index.mdx @@ -42,6 +42,10 @@ Knowledge writing is an autonomous Agent behavior, triggered in these scenarios: Each knowledge page includes cross-reference links to related pages, gradually building a knowledge graph. + + Conversational knowledge ingest + + ## Knowledge Retrieval The Agent can retrieve knowledge during conversation through: @@ -55,9 +59,17 @@ The Agent can retrieve knowledge during conversation through: The web console provides a dedicated "Knowledge" module with: - **Document browsing** — Tree-style directory structure, searchable and collapsible, click to view content -- **Knowledge graph** — D3.js force-directed graph visualizing relationships between knowledge pages +- **Knowledge graph** — Interactive graph visualizing relationships between knowledge pages - **Chat integration** — Knowledge document links referenced in Agent replies are clickable for direct navigation + + Knowledge document browsing + + + + Knowledge graph visualization + + ## CLI Commands Manage the knowledge base with the `/knowledge` command: diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index 5ec593c4..b83dd65a 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -77,7 +77,7 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 | --- | --- | --- | | `agent` | 是否启用 Agent 模式 | `true` | | `agent_workspace` | 工作空间路径 | `~/cow` | -| `agent_max_context_tokens` | 最大上下文 token 数 | `40000` | -| `agent_max_context_turns` | 最大上下文记忆轮次 | `30` | -| `agent_max_steps` | 单次任务最大决策步数 | `15` | +| `agent_max_context_tokens` | 最大上下文 token 数 | `50000` | +| `agent_max_context_turns` | 最大上下文记忆轮次 | `20` | +| `agent_max_steps` | 单次任务最大决策步数 | `20` | | `knowledge` | 是否启用个人知识库 | `true` | diff --git a/docs/ja/README.md b/docs/ja/README.md index ff8883d1..cce26180 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -7,7 +7,7 @@ [中文] | [English] | [日本語]

-**CowAgent** はLLMを搭載したAIスーパーアシスタントです。自律的なタスク計画、コンピュータや外部リソースの操作、Skillの作成・実行、長期記憶による継続的な成長が可能です。柔軟なモデル切り替えに対応し、テキスト・音声・画像・ファイルを処理でき、WeChat、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日稼働できます。

🌐 ウェブサイト  ·  @@ -23,6 +23,7 @@ - ✅ **自律的タスク計画**: 複雑なタスクを理解し、自律的に実行計画を立て、目標達成までツールを呼び出しながら継続的に思考します。 - ✅ **長期記憶**: 会話の記憶をローカルファイルやデータベースに自動的に永続化します。コアメモリとデイリーメモリを含み、キーワード検索やベクトル検索に対応しています。 +- ✅ **パーソナルナレッジベース**: 構造化された知識を自動整理し、相互参照によるナレッジグラフを構築。Web での可視化ブラウジングと対話による管理をサポートします。 - ✅ **Skillシステム**: Skillの作成・実行エンジンを実装。[Skill Hub](https://skills.cowagent.ai)、GitHubなどからSkillをインストールでき、会話を通じたカスタムSkill作成もサポートしています。 - ✅ **ツールシステム**: ファイル読み書き、ターミナル実行、ブラウザ操作、スケジュールタスク、メッセージ送信などの組み込みツールを提供。Agentが自律的に呼び出して複雑なタスクを完了します。 - ✅ **CLIシステム**: ターミナルコマンドとチャットコマンドを提供し、プロセス管理、Skillインストール、設定変更などの操作をサポートします。 diff --git a/docs/ja/knowledge/index.mdx b/docs/ja/knowledge/index.mdx index 0ded408c..f4a2abec 100644 --- a/docs/ja/knowledge/index.mdx +++ b/docs/ja/knowledge/index.mdx @@ -42,6 +42,10 @@ description: CowAgent のパーソナルナレッジベース — 構造化さ 各ナレッジページには関連ページへの相互参照リンクが含まれ、ナレッジグラフを段階的に構築します。 + + 会話によるナレッジの取り込み + + ## ナレッジ検索 Agent は会話中に以下の方法でナレッジを検索できます: @@ -55,9 +59,17 @@ Agent は会話中に以下の方法でナレッジを検索できます: Web コンソールには専用の「ナレッジ」モジュールがあり、以下をサポートします: - **ドキュメント閲覧** — ツリー形式のディレクトリ構造、検索・折りたたみ可能、クリックでコンテンツ表示 -- **ナレッジグラフ** — D3.js フォースダイレクテッドグラフによるナレッジ間の関係を可視化 +- **ナレッジグラフ** — インタラクティブなグラフによるナレッジ間の関係を可視化 - **チャット連携** — Agent の返信で参照されたナレッジドキュメントリンクはクリックで直接ナビゲーション + + ナレッジドキュメント閲覧 + + + + ナレッジグラフの可視化 + + ## CLI コマンド `/knowledge` コマンドでナレッジベースを管理: From 4f7064575e72f90da6c422867ade974129e30c75 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 12 Apr 2026 21:19:50 +0800 Subject: [PATCH 112/399] feat(web): support scheduler push messages and enrich welcome screen - Expand welcome screen from 3 to 6 example cards covering core capabilities - Enable background polling on page load so scheduler task notifications are received in real-time - Fix duplicate poll loops via generation-based cancellation, reduce poll frequency to 5s/10s - Ensure equal card height and adjust layout position for better visual balance --- channel/web/chat.html | 37 +++++++++++- channel/web/static/css/console.css | 14 +++++ channel/web/static/js/console.js | 92 ++++++++++++++++++++++++------ 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/channel/web/chat.html b/channel/web/chat.html index 233b681c..0083f2a9 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -264,13 +264,13 @@

-
+
CowAgent

CowAgent

I can help you answer questions, manage your computer, create and execute skills,
and keep growing through long-term memory.

-
+
@@ -287,7 +287,7 @@
- Smart Task + 定时任务

Remind me to check the server in 5 minutes

@@ -301,6 +301,37 @@

Write a Python web scraper script

+
+
+
+ +
+ 知识库 +
+

帮我把这篇文章整理到知识库

+
+
+
+
+ +
+ 技能系统 +
+

查看所有支持的工具和技能

+
+
+
+
+ +
+ 指令中心 +
+

查看全部命令

+
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 5592348a..075d53a1 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -728,3 +728,17 @@ .cfg-tip:hover::after { opacity: 1; } + +/* Example cards: equal height via flex stretch + fixed 2-line description area */ +.example-card { + display: flex; + flex-direction: column; +} +.example-card > p { + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 2.5em; /* ~2 lines at text-sm leading-relaxed */ +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 98c98394..2f5f19ef 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -23,10 +23,13 @@ const I18N = { knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面', knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。', knowledge_go_chat: '开始对话', - welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆
不断成长', - example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件', - example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能', - example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本', + welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过
长期记忆和知识库不断成长', + example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件', + example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器', + example_code_title: '编程助手', example_code_text: '搜索AI资讯并生成可视化网页报告', + example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况', + example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能', + example_web_title: '指令中心', example_web_text: '查看全部命令', input_placeholder: '输入消息,或输入 / 使用指令', config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', @@ -90,10 +93,13 @@ const I18N = { knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet', knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.', knowledge_go_chat: 'Start a conversation', - welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through
long-term memory.', + welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through
long-term memory and a personal knowledge base.', example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace', - example_task_title: 'Skills', example_task_text: 'Show current tools and skills', - example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script', + example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes', + example_code_title: 'Coding', example_code_text: 'Search today\'s AI news and generate a visual report webpage', + example_knowledge_title: 'Knowledge', example_knowledge_text: 'Show me the current knowledge base', + example_skill_title: 'Skills', example_skill_text: 'Show current tools and skills', + example_web_title: 'Commands', example_web_text: 'Show all commands', input_placeholder: 'Type a message, or press / for commands', config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_model: 'Model Configuration', config_agent: 'Agent Configuration', @@ -341,6 +347,7 @@ function renderMarkdown(text) { // Chat Module // ===================================================================== let isPolling = false; +let pollGeneration = 0; // incremented on each restart to cancel stale poll loops let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource let isComposing = false; @@ -381,6 +388,9 @@ fetch('/config').then(r => r.json()).then(data => { loadHistory(1); }).catch(() => { loadHistory(1); }); +// Start polling immediately so scheduler/push messages are received at any time +startPolling(); + const chatInput = document.getElementById('chat-input'); const sendBtn = document.getElementById('send-btn'); const messagesDiv = document.getElementById('chat-messages'); @@ -751,6 +761,14 @@ chatInput.addEventListener('blur', () => { document.querySelectorAll('.example-card').forEach(card => { card.addEventListener('click', () => { + // data-send overrides the visible text (e.g. show "查看全部命令" but send "/help") + const sendText = card.dataset.send; + if (sendText) { + chatInput.value = sendText; + chatInput.dispatchEvent(new Event('input')); + chatInput.focus(); + return; + } const textEl = card.querySelector('[data-i18n*="text"]'); if (textEl) { chatInput.value = textEl.textContent; @@ -811,7 +829,6 @@ function sendMessage() { startSSE(data.request_id, loadingEl, timestamp); } else { loadingContainers[data.request_id] = loadingEl; - if (!isPolling) startPolling(); } } else { loadingEl.remove(); @@ -1103,13 +1120,16 @@ function startSSE(requestId, loadingEl, timestamp) { } function startPolling() { - if (isPolling) return; + const gen = ++pollGeneration; isPolling = true; + let pollInFlight = false; function poll() { - if (!isPolling) return; - if (document.hidden) { setTimeout(poll, 5000); return; } + if (gen !== pollGeneration) return; + if (pollInFlight) return; + if (document.hidden) { setTimeout(poll, 10000); return; } + pollInFlight = true; fetch('/poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1117,18 +1137,23 @@ function startPolling() { }) .then(r => r.json()) .then(data => { + pollInFlight = false; + if (gen !== pollGeneration) return; if (data.status === 'success' && data.has_content) { const rid = data.request_id; if (loadingContainers[rid]) { loadingContainers[rid].remove(); delete loadingContainers[rid]; } + const welcomeScreen = document.getElementById('welcome-screen'); + if (welcomeScreen) welcomeScreen.remove(); addBotMessage(data.content, new Date(data.timestamp * 1000), rid); scrollChatToBottom(); } - setTimeout(poll, 2000); + const delay = (data.status === 'success' && data.has_content) ? 5000 : 10000; + setTimeout(poll, delay); }) - .catch(() => { setTimeout(poll, 3000); }); + .catch(() => { pollInFlight = false; setTimeout(poll, 10000); }); } poll(); } @@ -1409,17 +1434,18 @@ function newChat() { // Generate a fresh session and persist it so the next page load also starts clean sessionId = generateSessionId(); localStorage.setItem(SESSION_ID_KEY, sessionId); - isPolling = false; loadingContainers = {}; + startPolling(); // bump generation so old loop self-cancels, new loop uses fresh sessionId messagesDiv.innerHTML = ''; const ws = document.createElement('div'); ws.id = 'welcome-screen'; - ws.className = 'flex flex-col items-center justify-center h-full px-6 py-12'; + ws.className = 'flex flex-col items-center justify-center h-full px-6 pb-16'; + ws.style.paddingTop = '6vh'; ws.innerHTML = ` CowAgent

${appConfig.title || 'CowAgent'}

${t('welcome_subtitle')}

-
+
@@ -1447,11 +1473,45 @@ function newChat() {

${t('example_code_text')}

+
+
+
+ +
+ ${t('example_knowledge_title')} +
+

${t('example_knowledge_text')}

+
+
+
+
+ +
+ ${t('example_skill_title')} +
+

${t('example_skill_text')}

+
+
+
+
+ +
+ ${t('example_web_title')} +
+

${t('example_web_text')}

+
`; messagesDiv.appendChild(ws); ws.querySelectorAll('.example-card').forEach(card => { card.addEventListener('click', () => { + const sendText = card.dataset.send; + if (sendText) { + chatInput.value = sendText; + chatInput.dispatchEvent(new Event('input')); + chatInput.focus(); + return; + } const textEl = card.querySelector('[data-i18n*="text"]'); if (textEl) { chatInput.value = textEl.textContent; From f79a915136c5e62c3752598ce9c1e157a18acd6c Mon Sep 17 00:00:00 2001 From: Moliang Zhou Date: Sun, 12 Apr 2026 11:18:44 -0700 Subject: [PATCH 113/399] fix: add timeout fallback for macOS compatibility The `timeout` command (GNU Coreutils) is not available by default on macOS, causing the installation script to fail with 'timeout: command not found' during git clone. This adds a shell function fallback that: - Uses `gtimeout` if Homebrew coreutils is installed - Otherwise skips the timeout and runs the command directly --- run.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/run.sh b/run.sh index 7ad6f85d..8693ac06 100755 --- a/run.sh +++ b/run.sh @@ -28,6 +28,20 @@ if [ -z "$BASH_VERSION" ]; then exit 1 fi +# Fallback for 'timeout' on macOS where GNU Coreutils is not installed by default. +# If 'gtimeout' (Homebrew's coreutils) is present, we use it. Otherwise, we gracefully +# degrade by using `shift` to discard the time limit argument ($1) and executing the rest of the command. +if ! command -v timeout &> /dev/null; then + timeout() { + if command -v gtimeout &> /dev/null; then + gtimeout "$@" + else + shift + "$@" + fi + } +fi + # Get current script directory export BASE_DIR=$(cd "$(dirname "$0")"; pwd) From 3649499dbaeff6f6c8c189580cb0410932f5878d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Apr 2026 10:35:38 +0800 Subject: [PATCH 114/399] fix: optimize the stability of network pre-checks --- run.sh | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/run.sh b/run.sh index 8693ac06..9a75f17a 100755 --- a/run.sh +++ b/run.sh @@ -28,17 +28,24 @@ if [ -z "$BASH_VERSION" ]; then exit 1 fi -# Fallback for 'timeout' on macOS where GNU Coreutils is not installed by default. -# If 'gtimeout' (Homebrew's coreutils) is present, we use it. Otherwise, we gracefully -# degrade by using `shift` to discard the time limit argument ($1) and executing the rest of the command. -if ! command -v timeout &> /dev/null; then - timeout() { - if command -v gtimeout &> /dev/null; then - gtimeout "$@" - else - shift - "$@" - fi +# Cross-platform timeout: prefer GNU timeout/gtimeout, fallback to a pure-bash implementation +# that uses background process + sleep to enforce a hard time limit. +if command -v timeout &> /dev/null; then + _timeout() { timeout "$@"; } +elif command -v gtimeout &> /dev/null; then + _timeout() { gtimeout "$@"; } +else + _timeout() { + local secs=$1; shift + "$@" & + local cmd_pid=$! + ( sleep "$secs"; kill $cmd_pid 2>/dev/null ) & + local watcher_pid=$! + wait $cmd_pid 2>/dev/null + local exit_code=$? + kill $watcher_pid 2>/dev/null + wait $watcher_pid 2>/dev/null + return $exit_code } fi @@ -187,13 +194,13 @@ clone_project() { else local clone_ok=false # Test GitHub connectivity before attempting clone - if curl -s --connect-timeout 5 --max-time 10 https://github.com > /dev/null 2>&1; then + if curl -sI --connect-timeout 5 --max-time 10 https://github.com > /dev/null 2>&1; then echo -e "${YELLOW}🌐 GitHub is reachable, cloning from GitHub...${NC}" - timeout 15 git clone --depth 10 --progress https://github.com/zhayujie/CowAgent.git && clone_ok=true + _timeout 15 git clone --depth 10 --progress https://github.com/zhayujie/CowAgent.git && clone_ok=true fi if [ "$clone_ok" = false ]; then echo -e "${YELLOW}⚠️ GitHub clone failed or timed out, switching to Gitee mirror...${NC}" - timeout 30 git clone --depth 10 --progress https://gitee.com/zhayujie/CowAgent.git && clone_ok=true + _timeout 30 git clone --depth 10 --progress https://gitee.com/zhayujie/CowAgent.git && clone_ok=true fi if [ "$clone_ok" = false ]; then echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}" From 3f3d0381e5fa2a1d3cd520a75d4ea6e892874082 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Apr 2026 11:16:26 +0800 Subject: [PATCH 115/399] feat: update knowledge docs and fix claude error --- docs/knowledge/index.mdx | 23 +++++++++++++++++++++-- models/claudeapi/claude_api_bot.py | 26 +++++++++++++++++++++++++- run.sh | 2 +- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/docs/knowledge/index.mdx b/docs/knowledge/index.mdx index b4cf7f9b..4f9aa797 100644 --- a/docs/knowledge/index.mdx +++ b/docs/knowledge/index.mdx @@ -5,6 +5,10 @@ description: CowAgent 的个人知识库系统 — 结构化知识沉淀、自 个人知识库是 Agent 的长期结构化知识存储,保存在工作空间的 `knowledge/` 目录下。与按时间线组织的记忆不同,知识库以主题为维度,将用户分享的文章、对话中的洞察、学习材料等整理为互相关联的 Markdown 页面,形成可持续增长的知识网络。 + + + + ## 核心概念 ### 知识 vs 记忆 @@ -30,7 +34,12 @@ description: CowAgent 的个人知识库系统 — 结构化知识沉淀、自 └── llm-wiki.md ``` -目录结构是灵活的 — Agent 会根据实际内容自动创建合适的分类目录。用户也可以通过对话自定义目录组织方式。 +目录结构是灵活的 — Agent 会根据实际内容自动创建合适的分类目录。用户也可以通过对话的方式自定义目录组织方式。 + + + + + ## 自动整理 @@ -40,6 +49,11 @@ description: CowAgent 的个人知识库系统 — 结构化知识沉淀、自 - **对话产生有价值的结论** — Agent 将洞察整理为知识页面,并与已有知识建立关联 - **用户主动要求整理** — 用户可以通过对话指导 Agent 组织和更新知识 + + + + + 每个知识页面都包含与其他页面的交叉引用链接,逐步构建起一个知识图谱。 ## 知识检索 @@ -55,9 +69,14 @@ Agent 在对话中可以通过以下方式检索知识: Web 控制台提供了专用的「知识」模块,支持: - **文档浏览** — 树状目录结构,可搜索、可折叠,点击查看文档内容 -- **知识图谱** — 基于 D3.js 的力导向图,可视化展示知识之间的关联关系 +- **知识图谱** — 可视化展示知识之间的关联关系,节点可直接跳转至文档 - **对话联动** — Agent 回复中引用的知识文档链接可直接点击跳转查看 + + + + + ## CLI 命令 通过 `/knowledge` 命令管理知识库: diff --git a/models/claudeapi/claude_api_bot.py b/models/claudeapi/claude_api_bot.py index 49fc3d46..05a7b567 100644 --- a/models/claudeapi/claude_api_bot.py +++ b/models/claudeapi/claude_api_bot.py @@ -323,7 +323,7 @@ class ClaudeAPIBot(Bot, OpenAIImage): if msg.get("role") == "system": system_prompt = msg["content"] else: - claude_messages.append(msg) + claude_messages.append(self._sanitize_message(msg)) request_params = { "model": actual_model, @@ -363,6 +363,30 @@ class ClaudeAPIBot(Bot, OpenAIImage): "status_code": 500 } + @staticmethod + def _sanitize_message(msg: dict) -> dict: + """Strip thinking blocks without a ``signature`` from assistant messages. + + When the session switches from another model (e.g. MiniMax) to Claude, + the in-memory history may contain thinking blocks that lack the + ``signature`` field required by the Anthropic API, causing 400 errors. + We create a shallow copy so the original history is not mutated. + """ + if msg.get("role") != "assistant": + return msg + content = msg.get("content") + if not isinstance(content, list): + return msg + cleaned = [ + block for block in content + if not (isinstance(block, dict) + and block.get("type") == "thinking" + and "signature" not in block) + ] + if len(cleaned) == len(content): + return msg + return {**msg, "content": cleaned} + def _handle_sync_response(self, request_params): """Handle synchronous Claude API response""" # Prepare headers diff --git a/run.sh b/run.sh index 9a75f17a..74b09665 100755 --- a/run.sh +++ b/run.sh @@ -196,7 +196,7 @@ clone_project() { # Test GitHub connectivity before attempting clone if curl -sI --connect-timeout 5 --max-time 10 https://github.com > /dev/null 2>&1; then echo -e "${YELLOW}🌐 GitHub is reachable, cloning from GitHub...${NC}" - _timeout 15 git clone --depth 10 --progress https://github.com/zhayujie/CowAgent.git && clone_ok=true + _timeout 60 git clone --depth 10 --progress https://github.com/zhayujie/CowAgent.git && clone_ok=true fi if [ "$clone_ok" = false ]; then echo -e "${YELLOW}⚠️ GitHub clone failed or timed out, switching to Gitee mirror...${NC}" From 89a07e8e743495a0b6fbdd3df898426ee8366b29 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Apr 2026 16:06:28 +0800 Subject: [PATCH 116/399] feat: add enable_thinking config to control deep reasoning on web console --- README.md | 3 +- agent/protocol/agent_stream.py | 13 +++++++-- bridge/agent_bridge.py | 20 ++++++++++++-- channel/web/chat.html | 12 ++++++++ channel/web/static/css/console.css | 10 +++++-- channel/web/static/js/console.js | 44 ++++++++++++++---------------- channel/web/web_channel.py | 7 +++-- config.py | 1 + docs/channels/web.mdx | 30 ++++++-------------- docs/intro/architecture.mdx | 4 ++- models/dashscope/dashscope_bot.py | 13 ++++----- models/doubao/doubao_bot.py | 15 +++++++--- models/linkai/link_ai_bot.py | 4 +++ models/moonshot/moonshot_bot.py | 16 +++++++---- run.sh | 16 +++++++++++ 15 files changed, 135 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 67f9b26e..e83857f0 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,8 @@ cow install-browser "agent_workspace": "~/cow", # Agent 的工作空间路径,用于存储 memory、skills、系统设定等 "agent_max_context_tokens": 50000, # Agent 模式下最大上下文 tokens,超出将自动智能压缩处理 "agent_max_context_turns": 20, # Agent 模式下最大上下文记忆轮次,一问一答为一轮,超出后智能压缩处理 - "agent_max_steps": 20 # Agent 模式下单次任务的最大决策步数,超出后将停止继续调用工具 + "agent_max_steps": 20, # Agent 模式下单次任务的最大决策步数,超出后将停止继续调用工具 + "enable_thinking": true # 是否启用深度思考,开启后 Web 端展示模型推理过程,关闭后可加速响应 } ``` diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 45f7d8a5..1b37fea9 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -78,6 +78,11 @@ class AgentStreamExecutor: except Exception as e: logger.error(f"Event callback error: {e}") + def _is_thinking_enabled(self) -> bool: + from config import conf + channel_type = getattr(self.model, 'channel_type', '') or '' + return conf().get("enable_thinking", True) and channel_type == 'web' + def _filter_think_tags(self, text: str) -> str: """ Remove and tags but keep the content inside. @@ -178,7 +183,10 @@ class AgentStreamExecutor: Final response text """ # Log user message with model info - logger.info(f"🤖 {self.model.model} | 👤 {user_message}") + + thinking_enabled = self._is_thinking_enabled() + thinking_label = "💭 thinking" if thinking_enabled else "⚡ fast" + logger.info(f"🤖 {self.model.model} | {thinking_label} | 👤 {user_message}") # Add user message (Claude format - use content blocks for consistency) self.messages.append({ @@ -588,7 +596,8 @@ class AgentStreamExecutor: reasoning_delta = delta.get("reasoning_content") or "" if reasoning_delta: full_reasoning += reasoning_delta - self._emit_event("reasoning_update", {"delta": reasoning_delta}) + if self._is_thinking_enabled(): + self._emit_event("reasoning_update", {"delta": reasoning_delta}) # Handle text content content_delta = delta.get("content") or "" diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 073cfa83..4b178bee 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -160,13 +160,21 @@ class AgentLLMModel(LLMModel): kwargs['system'] = system_prompt # Pass context metadata to bot - channel_type = getattr(self, 'channel_type', None) + channel_type = getattr(self, 'channel_type', None) or '' if channel_type: kwargs['channel_type'] = channel_type session_id = getattr(self, 'session_id', None) if session_id: kwargs['session_id'] = session_id + # Determine thinking: respect global config, then channel_type + from config import conf + global_thinking = conf().get("enable_thinking", True) + if not global_thinking: + kwargs['thinking'] = {"type": "disabled"} + else: + kwargs['thinking'] = {"type": "enabled"} if channel_type == "web" else {"type": "disabled"} + response = self.bot.call_with_tools(**kwargs) return self._format_response(response) else: @@ -205,13 +213,21 @@ class AgentLLMModel(LLMModel): kwargs['system'] = system_prompt # Pass context metadata to bot - channel_type = getattr(self, 'channel_type', None) + channel_type = getattr(self, 'channel_type', None) or '' if channel_type: kwargs['channel_type'] = channel_type session_id = getattr(self, 'session_id', None) if session_id: kwargs['session_id'] = session_id + # Determine thinking: respect global config, then channel_type + from config import conf + global_thinking = conf().get("enable_thinking", True) + if not global_thinking: + kwargs['thinking'] = {"type": "disabled"} + else: + kwargs['thinking'] = {"type": "enabled"} if channel_type == "web" else {"type": "disabled"} + stream = self.bot.call_with_tools(**kwargs) # Convert stream format to our expected format diff --git a/channel/web/chat.html b/channel/web/chat.html index 0083f2a9..48f41798 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -509,6 +509,18 @@ bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono transition-colors">
+
+ + +
`; stepsEl.appendChild(currentReasoningEl); } - 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'); + finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText); currentReasoningEl = null; reasoningText = ''; } @@ -951,8 +953,7 @@ function startSSE(requestId, loadingEl, timestamp) { } else if (item.type === 'tool_start') { ensureBotEl(); if (currentReasoningEl) { - if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80) - currentReasoningEl.classList.add('no-expand'); + finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText); currentReasoningEl = null; reasoningText = ''; } @@ -1089,8 +1090,7 @@ function startSSE(requestId, loadingEl, timestamp) { if (done) return; if (currentReasoningEl) { - if (reasoningText.trim().replace(/\n+/g, ' ').length <= 80) - currentReasoningEl.classList.add('no-expand'); + finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText); currentReasoningEl = null; reasoningText = ''; } @@ -1214,28 +1214,24 @@ function renderToolCallsHtml(toolCalls) { }).join(''); } +function finalizeThinking(el, startTime, text) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + el.querySelector('.thinking-summary').textContent = t('thinking_done'); + const fullDiv = el.querySelector('.thinking-full'); + fullDiv.innerHTML = `
${t('thinking_duration')} ${elapsed}s
` + renderMarkdown(text); +} + 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 ` + return `
- ${escapeHtml(truncated)} + ${t('thinking_done')}
${renderMarkdown(full)}
-
`; - } - return ` -
-
- - ${escapeHtml(oneLine)} -
`; } @@ -1649,6 +1645,7 @@ function initConfigView(data) { document.getElementById('cfg-max-tokens').value = data.agent_max_context_tokens || 50000; document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 20; document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20; + document.getElementById('cfg-enable-thinking').checked = data.enable_thinking !== false; const pwdInput = document.getElementById('cfg-password'); const maskedPwd = data.web_password_masked || ''; @@ -1883,6 +1880,7 @@ function saveAgentConfig() { agent_max_context_tokens: parseInt(document.getElementById('cfg-max-tokens').value) || 50000, agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 20, agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 20, + enable_thinking: document.getElementById('cfg-enable-thinking').checked, }; const btn = document.getElementById('cfg-agent-save'); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 57b95217..0a0487a8 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -714,7 +714,7 @@ class ConfigHandler: "api_key_field": "minimax_api_key", "api_base_key": None, "api_base_default": None, - "models": [const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING], + "models": [const.MINIMAX_M2_7, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING], }), ("zhipu", { "label": "智谱AI", @@ -796,7 +796,7 @@ class ConfigHandler: "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", - "web_password", + "enable_thinking", "web_password", } @staticmethod @@ -849,6 +849,7 @@ class ConfigHandler: "agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000), "agent_max_context_turns": local_config.get("agent_max_context_turns", 20), "agent_max_steps": local_config.get("agent_max_steps", 20), + "enable_thinking": bool(local_config.get("enable_thinking", True)), "api_bases": api_bases, "api_keys": api_keys_masked, "providers": providers, @@ -874,7 +875,7 @@ class ConfigHandler: continue if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"): value = int(value) - if key == "use_linkai": + if key in ("use_linkai", "enable_thinking"): value = bool(value) local_config[key] = value applied[key] = value diff --git a/config.py b/config.py index e5dcd5d8..5e0ce8f2 100644 --- a/config.py +++ b/config.py @@ -202,6 +202,7 @@ available_setting = { "agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens "agent_max_context_turns": 20, # Agent模式下最大上下文记忆轮次 "agent_max_steps": 20, # Agent模式下单次运行最大决策步数 + "enable_thinking": True, # Whether to enable deep thinking for web channel "knowledge": True, # 是否开启知识库功能 } diff --git a/docs/channels/web.mdx b/docs/channels/web.mdx index d8329553..a5f9ac60 100644 --- a/docs/channels/web.mdx +++ b/docs/channels/web.mdx @@ -10,7 +10,9 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏 ```json { "channel_type": "web", - "web_port": 9899 + "web_port": 9899, + "web_password": "", + "enable_thinking": true } ``` @@ -18,6 +20,11 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏 | --- | --- | --- | | `channel_type` | 设为 `web` | `web` | | `web_port` | Web 服务监听端口 | `9899` | +| `web_password` | 访问密码,留空表示不启用密码保护 | `""` | +| `web_session_expire_days` | 登录会话有效天数 | `30` | +| `enable_thinking` | 是否启用深度思考,开启后 Web 端展示推理过程,关闭可加速响应 | `true` | + +配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。密码也支持在控制台的「配置」页面中在线修改。 ## 访问地址 @@ -30,30 +37,11 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏 请确保服务器防火墙和安全组已放行对应端口。 -## 密码保护 - -Web 控制台默认无需密码即可访问。如果部署在公网环境,建议配置访问密码: - -```json -{ - "web_password": "your_password" -} -``` - -| 参数 | 说明 | 默认值 | -| --- | --- | --- | -| `web_password` | 访问密码,留空表示不启用密码保护 | `""` | -| `web_session_expire_days` | 登录会话有效天数 | `30` | - -配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。修改密码后,所有已登录的会话将自动失效。 - -密码也支持在控制台的「配置」页面中在线修改。 - ## 功能介绍 ### 对话界面 -支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程: +支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程。深度思考功能可通过配置或控制台的「Agent 配置」开关控制。 diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index b83dd65a..2cb5a8f0 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -69,7 +69,8 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 "agent_workspace": "~/cow", "agent_max_context_tokens": 40000, "agent_max_context_turns": 30, - "agent_max_steps": 15 + "agent_max_steps": 15, + "enable_thinking": true } ``` @@ -80,4 +81,5 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 | `agent_max_context_tokens` | 最大上下文 token 数 | `50000` | | `agent_max_context_turns` | 最大上下文记忆轮次 | `20` | | `agent_max_steps` | 单次任务最大决策步数 | `20` | +| `enable_thinking` | 是否启用深度思考,开启后 Web 端展示推理过程,关闭可加速响应 | `true` | | `knowledge` | 是否启用个人知识库 | `true` | diff --git a/models/dashscope/dashscope_bot.py b/models/dashscope/dashscope_bot.py index 4d4d628f..651ec80b 100644 --- a/models/dashscope/dashscope_bot.py +++ b/models/dashscope/dashscope_bot.py @@ -262,20 +262,17 @@ class DashscopeBot(Bot): if kwargs.get("tool_choice"): parameters["tool_choice"] = kwargs["tool_choice"] - # Add thinking parameters for Qwen3 models (disabled by default for stability) + # Add thinking parameters for Qwen3/QwQ models if "qwen3" in model_name.lower() or "qwq" in model_name.lower(): - # Only enable thinking mode if explicitly requested - enable_thinking = kwargs.get("enable_thinking", False) - if enable_thinking: + thinking = kwargs.get("thinking", {"type": "enabled"}) + if thinking.get("type") == "enabled": parameters["enable_thinking"] = True - - # Set thinking budget if specified if kwargs.get("thinking_budget"): parameters["thinking_budget"] = kwargs["thinking_budget"] - - # Qwen3 requires incremental_output=true in thinking mode if stream: parameters["incremental_output"] = True + else: + parameters["enable_thinking"] = False # Always use incremental_output for streaming (for better token-by-token streaming) # This is especially important for tool calling to avoid incomplete responses diff --git a/models/doubao/doubao_bot.py b/models/doubao/doubao_bot.py index b31516ec..f8ccfff7 100644 --- a/models/doubao/doubao_bot.py +++ b/models/doubao/doubao_bot.py @@ -249,9 +249,7 @@ class DoubaoBot(Bot): request_body["tools"] = converted_tools request_body["tool_choice"] = "auto" - # Explicitly disable thinking to avoid reasoning_content issues - # in multi-turn tool calls - request_body["thinking"] = {"type": "disabled"} + request_body["thinking"] = kwargs.get("thinking", {"type": "enabled"}) logger.debug(f"[DOUBAO] API call: model={model}, " f"tools={len(converted_tools) if converted_tools else 0}, stream={stream}") @@ -324,8 +322,17 @@ class DoubaoBot(Bot): choice = chunk["choices"][0] delta = choice.get("delta", {}) - # Skip reasoning_content (thinking) - don't log or forward if delta.get("reasoning_content"): + yield { + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": delta["reasoning_content"] + }, + "finish_reason": None + }] + } continue # Handle text content diff --git a/models/linkai/link_ai_bot.py b/models/linkai/link_ai_bot.py index dd91f3db..212cbbd7 100644 --- a/models/linkai/link_ai_bot.py +++ b/models/linkai/link_ai_bot.py @@ -560,6 +560,10 @@ def _linkai_call_with_tools(self, messages, tools=None, stream=False, **kwargs): body["tools"] = tools body["tool_choice"] = kwargs.get("tool_choice", "auto") + thinking = kwargs.get("thinking") + if thinking: + body["thinking"] = thinking + # Prepare headers headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")} base_url = conf().get("linkai_api_base", "https://api.link-ai.tech") diff --git a/models/moonshot/moonshot_bot.py b/models/moonshot/moonshot_bot.py index 4d35400e..55698e45 100644 --- a/models/moonshot/moonshot_bot.py +++ b/models/moonshot/moonshot_bot.py @@ -249,10 +249,7 @@ class MoonshotBot(Bot): request_body["tools"] = converted_tools request_body["tool_choice"] = "auto" - # Explicitly disable thinking to avoid reasoning_content issues in multi-turn tool calls. - # kimi-k2.5 may enable thinking by default; without preserving reasoning_content - # in conversation history the API will reject subsequent requests. - request_body["thinking"] = {"type": "disabled"} + request_body["thinking"] = kwargs.get("thinking", {"type": "enabled"}) logger.debug(f"[MOONSHOT] API call: model={model}, " f"tools={len(converted_tools) if converted_tools else 0}, stream={stream}") @@ -325,8 +322,17 @@ class MoonshotBot(Bot): choice = chunk["choices"][0] delta = choice.get("delta", {}) - # Skip reasoning_content (thinking) – don't log or forward if delta.get("reasoning_content"): + yield { + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": delta["reasoning_content"] + }, + "finish_reason": None + }] + } continue # Handle text content diff --git a/run.sh b/run.sh index 74b09665..07c0753d 100755 --- a/run.sh +++ b/run.sh @@ -193,6 +193,16 @@ clone_project() { rm CowAgent.zip else local clone_ok=false + # Detect and temporarily disable invalid git proxy settings + local _git_proxy_unset=false + local _http_proxy=$(git config --global http.proxy 2>/dev/null) + local _https_proxy=$(git config --global https.proxy 2>/dev/null) + if [ -n "$_http_proxy" ] && ! curl -s --connect-timeout 3 --max-time 5 --proxy "$_http_proxy" https://github.com > /dev/null 2>&1; then + echo -e "${YELLOW}⚠️ Invalid git proxy detected: $_http_proxy, temporarily disabling...${NC}" + git config --global --unset http.proxy + [ -n "$_https_proxy" ] && git config --global --unset https.proxy + _git_proxy_unset=true + fi # Test GitHub connectivity before attempting clone if curl -sI --connect-timeout 5 --max-time 10 https://github.com > /dev/null 2>&1; then echo -e "${YELLOW}🌐 GitHub is reachable, cloning from GitHub...${NC}" @@ -204,6 +214,12 @@ clone_project() { fi if [ "$clone_ok" = false ]; then echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}" + if git config --global http.proxy &> /dev/null || git config --global https.proxy &> /dev/null || [ -n "$http_proxy" ] || [ -n "$https_proxy" ] || [ -n "$HTTP_PROXY" ] || [ -n "$HTTPS_PROXY" ]; then + echo -e "${YELLOW}💡 Detected proxy settings. If proxy is misconfigured, try removing it with:${NC}" + echo -e "${YELLOW} git config --global --unset http.proxy${NC}" + echo -e "${YELLOW} git config --global --unset https.proxy${NC}" + echo -e "${YELLOW} unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY${NC}" + fi exit 1 fi fi From da97e948cad77382ccd96b169827bbf36809cdeb Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Apr 2026 18:02:06 +0800 Subject: [PATCH 117/399] feat: refine memory recall/write prompts for better precision and proactivity --- agent/prompt/builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py index 6535abc9..0856db70 100644 --- a/agent/prompt/builder.py +++ b/agent/prompt/builder.py @@ -291,8 +291,8 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu "", "### Memory Recall(mandatory)", "", - "在回答任何关于过往工作、决策、日期、人物、偏好或待办事项的问题之前,**必须**先检索记忆。", - "MEMORY.md 已自动加载在项目上下文中(可能被截断),完整内容和每日记忆需要通过工具检索。", + "当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。", + "如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。", "", "1. 不确定位置 → `memory_search` 关键词/语义检索", "2. 已知位置 → `memory_get` 直接读取对应行", @@ -307,7 +307,7 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu "", "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):", "", - "- 用户要求记住某些信息", + "- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达", "- 用户分享了重要的个人偏好、习惯、决策", "- 对话中产生了重要的结论、方案、约定", "- 完成了复杂任务,值得记录关键步骤和结果", From 90e4d494b231248f2108c249d298bae043ca81bc Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Apr 2026 18:50:31 +0800 Subject: [PATCH 118/399] feat(web): add multi-session management for web console --- agent/memory/conversation_store.py | 236 +++++++++++++++-- channel/web/chat.html | 185 +++++++------ channel/web/static/css/console.css | 332 +++++++++++++++++++++++- channel/web/static/js/console.js | 401 ++++++++++++++++++++++++++++- channel/web/web_channel.py | 178 ++++++++++++- docs/channels/web.mdx | 10 + docs/en/channels/web.mdx | 10 + docs/ja/channels/web.mdx | 10 + 8 files changed, 1257 insertions(+), 105 deletions(-) diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index 4ab0800b..2462e563 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -28,11 +28,13 @@ from common.log import logger _DDL = """ CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - channel_type TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - last_active INTEGER NOT NULL, - msg_count INTEGER NOT NULL DEFAULT 0 + session_id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + context_start_seq INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_active INTEGER NOT NULL, + msg_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( @@ -57,6 +59,14 @@ _MIGRATION_ADD_CHANNEL_TYPE = """ ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT ''; """ +_MIGRATION_ADD_TITLE = """ +ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT ''; +""" + +_MIGRATION_ADD_CONTEXT_START_SEQ = """ +ALTER TABLE sessions ADD COLUMN context_start_seq INTEGER NOT NULL DEFAULT 0; +""" + DEFAULT_MAX_AGE_DAYS: int = 30 @@ -287,14 +297,21 @@ class ConversationStore: with self._lock: conn = self._connect() try: + # Respect context_start_seq: only load messages at or after the boundary + ctx_row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + ctx_start = ctx_row[0] if ctx_row else 0 + rows = conn.execute( """ SELECT seq, role, content FROM messages - WHERE session_id = ? + WHERE session_id = ? AND seq >= ? ORDER BY seq DESC """, - (session_id,), + (session_id, ctx_start), ).fetchall() finally: conn.close() @@ -302,10 +319,7 @@ class ConversationStore: if not rows: return [] - # Walk newest-to-oldest counting *visible* user turns (actual user text, - # not tool_result injections). Record the seq of every visible user - # message so we can find a clean cut point later. - visible_turn_seqs: List[int] = [] # newest first + visible_turn_seqs: List[int] = [] for seq, role, raw_content in rows: if role != "user": continue @@ -316,17 +330,11 @@ class ConversationStore: if _is_visible_user_message(content): visible_turn_seqs.append(seq) - # Determine the seq of the oldest visible user message we want to keep. - # If the total turns fit within max_turns, keep everything. if len(visible_turn_seqs) <= max_turns: - cutoff_seq = None # keep all + cutoff_seq = None else: - # The Nth visible user message (0-indexed) is the oldest we keep. cutoff_seq = visible_turn_seqs[max_turns - 1] - # Build result in chronological order, starting from cutoff. - # IMPORTANT: we start exactly at cutoff_seq (the visible user message), - # never mid-group, so tool_use / tool_result pairs are always complete. result = [] for seq, role, raw_content in reversed(rows): if cutoff_seq is not None and seq < cutoff_seq: @@ -415,6 +423,61 @@ class ConversationStore: """, (session_id, session_id), ) + + # Auto-generate title from the first visible user message + cur_title = conn.execute( + "SELECT title FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if cur_title and not cur_title[0]: + for msg in messages: + if msg.get("role") == "user": + content = msg.get("content", "") + text = _extract_display_text(content) + if text: + title = text[:50].split("\n")[0] + conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + break + finally: + conn.close() + + def clear_context(self, session_id: str) -> int: + """ + Set the context boundary to after the current last message. + Messages before this boundary are still stored but excluded from LLM context. + + Returns the new context_start_seq value. + """ + with self._lock: + conn = self._connect() + try: + with conn: + row = conn.execute( + "SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + new_start = row[0] + 1 + conn.execute( + "UPDATE sessions SET context_start_seq = ? WHERE session_id = ?", + (new_start, session_id), + ) + return new_start + finally: + conn.close() + + def get_context_start_seq(self, session_id: str) -> int: + """Return the context_start_seq for a session (0 if not set).""" + with self._lock: + conn = self._connect() + try: + row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + return row[0] if row else 0 finally: conn.close() @@ -436,6 +499,7 @@ class ConversationStore: def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int: """ Delete sessions that have not been active within max_age_days. + Web channel sessions are excluded — they are meant to be permanent. Args: max_age_days: Override the default retention period. @@ -459,7 +523,8 @@ class ConversationStore: try: with conn: stale = conn.execute( - "SELECT session_id FROM sessions WHERE last_active < ?", + "SELECT session_id FROM sessions " + "WHERE last_active < ? AND channel_type != 'web'", (cutoff,), ).fetchall() for (sid,) in stale: @@ -518,9 +583,15 @@ class ConversationStore: with self._lock: conn = self._connect() try: + ctx_row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + ctx_start = ctx_row[0] if ctx_row else 0 + rows = conn.execute( """ - SELECT role, content, created_at + SELECT seq, role, content, created_at FROM messages WHERE session_id = ? ORDER BY seq ASC @@ -530,7 +601,30 @@ class ConversationStore: finally: conn.close() - visible = _group_into_display_turns(rows) + # Strip seq for display grouping, but record max seq per visible user group + plain_rows = [(role, content, created_at) for _seq, role, content, created_at in rows] + visible = _group_into_display_turns(plain_rows) + + # Build a mapping: find the seq of each visible user message to annotate context boundary. + # Walk through rows to find visible user message seqs in order. + visible_user_seqs: List[int] = [] + for seq, role, raw_content, _ts in rows: + if role != "user": + continue + try: + content = json.loads(raw_content) + except Exception: + content = raw_content + if _is_visible_user_message(content): + visible_user_seqs.append(seq) + + # Each pair of display turns (user+assistant) corresponds to a visible user seq. + # Mark which turns are before the context boundary. + user_turn_idx = 0 + for turn in visible: + if turn["role"] == "user" and user_turn_idx < len(visible_user_seqs): + turn["_seq"] = visible_user_seqs[user_turn_idx] + user_turn_idx += 1 total = len(visible) offset = (page - 1) * page_size @@ -539,12 +633,98 @@ class ConversationStore: return { "messages": page_items, + "context_start_seq": ctx_start, "total": total, "page": page, "page_size": page_size, "has_more": offset + page_size < total, } + def list_sessions( + self, + channel_type: Optional[str] = None, + page: int = 1, + page_size: int = 50, + ) -> Dict[str, Any]: + """ + List sessions ordered by last_active DESC, with optional channel_type filter. + + Returns: + { + "sessions": [{session_id, title, created_at, last_active, msg_count}, ...], + "total": int, + "page": int, + "page_size": int, + "has_more": bool, + } + """ + page = max(1, page) + with self._lock: + conn = self._connect() + try: + if channel_type: + total = conn.execute( + "SELECT COUNT(*) FROM sessions WHERE channel_type = ?", + (channel_type,), + ).fetchone()[0] + rows = conn.execute( + """ + SELECT session_id, title, created_at, last_active, msg_count + FROM sessions + WHERE channel_type = ? + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + (channel_type, page_size, (page - 1) * page_size), + ).fetchall() + else: + total = conn.execute( + "SELECT COUNT(*) FROM sessions", + ).fetchone()[0] + rows = conn.execute( + """ + SELECT session_id, title, created_at, last_active, msg_count + FROM sessions + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + (page_size, (page - 1) * page_size), + ).fetchall() + finally: + conn.close() + + sessions = [ + { + "session_id": r[0], + "title": r[1], + "created_at": r[2], + "last_active": r[3], + "msg_count": r[4], + } + for r in rows + ] + return { + "sessions": sessions, + "total": total, + "page": page, + "page_size": page_size, + "has_more": (page - 1) * page_size + page_size < total, + } + + def rename_session(self, session_id: str, title: str) -> bool: + """Update the title of a session. Returns True if the session existed.""" + with self._lock: + conn = self._connect() + try: + with conn: + cur = conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + return cur.rowcount > 0 + finally: + conn.close() + def get_stats(self) -> Dict[str, Any]: """Return basic stats keyed by channel_type, for monitoring.""" with self._lock: @@ -599,6 +779,20 @@ class ConversationStore: logger.info("[ConversationStore] Migrated: added channel_type column") except Exception as e: logger.warning(f"[ConversationStore] Migration failed: {e}") + if "title" not in cols: + try: + conn.execute(_MIGRATION_ADD_TITLE) + conn.commit() + logger.info("[ConversationStore] Migrated: added title column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (title) failed: {e}") + if "context_start_seq" not in cols: + try: + conn.execute(_MIGRATION_ADD_CONTEXT_START_SEQ) + conn.commit() + logger.info("[ConversationStore] Migrated: added context_start_seq column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (context_start_seq) failed: {e}") def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(str(self._db_path), timeout=10) diff --git a/channel/web/chat.html b/channel/web/chat.html index 0083f2a9..356d625f 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -50,6 +50,8 @@ (function() { var theme = localStorage.getItem('cow_theme') || 'dark'; if (theme === 'dark') document.documentElement.classList.add('dark'); + var lang = localStorage.getItem('cow_lang') || 'zh'; + document.documentElement.setAttribute('lang', lang); })(); @@ -94,7 +96,7 @@ -
@@ -112,13 +114,13 @@ @@ -127,38 +129,38 @@ @@ -167,13 +169,13 @@ @@ -194,6 +196,23 @@ + + + + + @@ -206,11 +225,17 @@ + + +
@@ -268,7 +293,7 @@ CowAgent

CowAgent

I can help you answer questions, manage your computer, create and execute skills,
and keep growing through long-term memory.

+ data-i18n-html="welcome_subtitle">我可以帮你解答问题、管理计算机、创造和执行技能,并通过
长期记忆和知识库不断成长

- System + 系统管理
-

Show me the files in the workspace

+

查看工作空间里有哪些文件

@@ -289,7 +314,7 @@
定时任务 -

Remind me to check the server in 5 minutes

+

1分钟后提醒我检查服务器

@@ -297,9 +322,9 @@
- Coding + 编程助手
-

Write a Python web scraper script

+

搜索AI资讯并生成可视化网页报告

@@ -309,7 +334,7 @@
知识库 -

帮我把这篇文章整理到知识库

+

查看知识库当前文档情况

@@ -345,14 +370,20 @@
+
@@ -367,7 +398,7 @@ text-sm leading-relaxed" rows="1" data-i18n-placeholder="input_placeholder" - placeholder="Type a message, or press / for commands"> + placeholder="输入消息,或输入 / 使用指令"> + onclick="saveModelConfig()" data-i18n="config_save">保存
@@ -476,12 +507,12 @@
-

Agent Configuration

+

Agent 配置

Save + onclick="saveAgentConfig()" data-i18n="config_save">保存
@@ -525,25 +556,25 @@
-

Security

+

安全设置

- + -

Leave empty to disable password protection

+

留空则不启用密码保护

+ onclick="savePasswordConfig()" data-i18n="config_save">保存
@@ -561,25 +592,25 @@
-

Skills

-

View, enable, or disable agent skills

+

技能管理

+

查看、启用或禁用 Agent 技能

- Skill Hub + 探索技能广场
- Built-in Tools + 内置工具
- Loading tools... + 加载工具中...
@@ -587,15 +618,15 @@
- Skills + 技能
-

Loading skills...

-

Skills will be displayed here after loading

+

加载技能中...

+

技能加载后将显示在此处

@@ -614,26 +645,26 @@
-

Memory

-

View agent memory files and contents

+

记忆管理

+

查看 Agent 记忆文件和内容

-

Loading memory files...

-

Memory files will be displayed here

+

加载记忆文件中...

+

记忆文件将显示在此处

`; } +// Cosmetic translator for cancel markers persisted in history. +// History keeps the English canonical form for the LLM; only display is localized. +function localizeCancelMarker(text) { + if (!text) return text; + if (currentLang !== 'zh') return text; + return text + .replace(/_\(Cancelled by user\)_/g, '_(用户已中止)_') + .replace(/_\(Cancelled\)_/g, '_(已中止)_'); +} + 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; let stepsHtml = ''; - let displayContent = content; + let displayContent = localizeCancelMarker(content); if (msg && msg.steps && msg.steps.length > 0) { // New format: ordered steps with interleaved content diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 371f0271..ab1d6915 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -93,6 +93,15 @@ def _require_auth(): json.dumps({"status": "error", "message": "Unauthorized"})) +# Localized text for /cancel system replies. Web is the only channel that +# honors a per-request `lang`; other channels reply in Chinese by default. +def _cancel_reply_text(cancelled: int, lang: str) -> str: + en = lang.startswith("en") + if cancelled > 0: + return "🛑 Cancelled." if en else "🛑 已中止" + return "Nothing to cancel." if en else "当前没有可中止的任务。" + + def _get_upload_dir() -> str: from common.utils import expand_path ws_root = expand_path(conf().get("agent_workspace", "~/cow")) @@ -437,6 +446,18 @@ class WebChannel(ChatChannel): "timestamp": time.time(), }) + elif event_type == "agent_cancelled": + # Push an explicit cancelled SSE event so the frontend + # marks the bubble as stopped. A trailing "done" still + # arrives with the partial answer. + final_response = data.get("final_response", "") + q.put({ + "type": "cancelled", + "content": final_response, + "request_id": request_id, + "timestamp": time.time(), + }) + elif event_type == "agent_end": # Safety net: if the agent finishes with an empty final_response, # chat_channel skips _send_reply (because reply.content is empty), @@ -756,6 +777,25 @@ class WebChannel(ChatChannel): # desire_rtype concept used by other channels). is_voice_input = bool(json_data.get('is_voice', False)) + # Fast path for /cancel: bypass the session queue and SSE setup. + # Web frontend (stream=true) only listens to SSE, so we return an + # inline_reply payload to be rendered synchronously. + stripped_prompt = (prompt or "").strip().lower() + if stripped_prompt == "/cancel": + from agent.protocol import get_cancel_registry + cancelled = get_cancel_registry().cancel_session(session_id) + lang = (json_data.get('lang') or 'zh').lower() + msg_text = _cancel_reply_text(cancelled, lang) + logger.info( + f"[WebChannel] /cancel fast-path: session={session_id}, cancelled={cancelled}, lang={lang}" + ) + return json.dumps({ + "status": "success", + "request_id": "", + "stream": False, + "inline_reply": msg_text, + }) + # Append file references to the prompt (same format as QQ channel) if attachments: file_refs = [] @@ -862,6 +902,11 @@ class WebChannel(ChatChannel): if itype == "done": post_done = True post_deadline = time.time() + POST_DONE_TAIL_SECONDS + elif itype == "cancelled": + # Close SSE tail quickly after cancel; don't wait for the + # full TTS tail since the user already pressed Stop. + post_done = True + post_deadline = time.time() + 3 elif itype == "voice_attach": # WSGI buffers the previous chunk until the next yield; # shrink the tail so the generator wakes up quickly to @@ -872,6 +917,59 @@ class WebChannel(ChatChannel): finally: self.sse_queues.pop(request_id, None) + def cancel_request(self): + """ + Cancel an in-flight agent run. + + Body: {"request_id": "...", "session_id": "..."} + Either field is sufficient; request_id is preferred when known. + Always returns success even when nothing was running, so the + client's UX is idempotent. + """ + try: + from agent.protocol import get_cancel_registry + + data = web.data() + try: + json_data = json.loads(data) if data else {} + except Exception: + json_data = {} + + request_id = (json_data.get("request_id") or "").strip() + session_id = (json_data.get("session_id") or "").strip() + lang = (json_data.get("lang") or "zh").lower() + + registry = get_cancel_registry() + cancelled = 0 + + if request_id: + if registry.cancel_request(request_id): + cancelled = 1 + + if cancelled == 0 and session_id: + cancelled = registry.cancel_session(session_id) + + if request_id and request_id in self.sse_queues: + self.sse_queues[request_id].put({ + "type": "cancelled", + "content": "Cancelled" if lang.startswith("en") else "已中止", + "request_id": request_id, + "timestamp": time.time(), + }) + + logger.info( + f"[WebChannel] cancel request: request_id={request_id!r}, " + f"session_id={session_id!r}, cancelled={cancelled}" + ) + return json.dumps({ + "status": "success", + "cancelled": cancelled, + }) + + except Exception as e: + logger.error(f"[WebChannel] cancel_request error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + def poll_response(self): """ Poll for responses using the session_id. @@ -967,6 +1065,7 @@ class WebChannel(ChatChannel): '/api/voice/tts', 'VoiceTtsHandler', '/poll', 'PollHandler', '/stream', 'StreamHandler', + '/cancel', 'CancelHandler', '/chat', 'ChatHandler', '/config', 'ConfigHandler', '/api/models', 'ModelsHandler', @@ -1240,6 +1339,12 @@ class PollHandler: return WebChannel().poll_response() +class CancelHandler: + def POST(self): + _require_auth() + return WebChannel().cancel_request() + + class StreamHandler: def GET(self): _require_auth() diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py index 0fe4500b..ebc1104b 100644 --- a/channel/wecom_bot/wecom_bot_channel.py +++ b/channel/wecom_bot/wecom_bot_channel.py @@ -440,6 +440,17 @@ class WecomBotChannel(ChatChannel): state["current"] = "" _push_stream(state, force=True) + elif event_type == "agent_cancelled": + # Flush partial output and strip trailing "---" separator + # left over from previous turn, to avoid a dangling divider. + if state["current"]: + state["committed"] += state["current"] + state["current"] = "" + state["committed"] = state["committed"].rstrip() + if state["committed"].endswith("---"): + state["committed"] = state["committed"][:-3].rstrip() + _push_stream(state, force=True) + return on_event # ------------------------------------------------------------------ diff --git a/docs/cli/general.mdx b/docs/cli/general.mdx index cb3f933d..36af1783 100644 --- a/docs/cli/general.mdx +++ b/docs/cli/general.mdx @@ -39,6 +39,14 @@ Mode: agent Session: 12 messages | 8 skills loaded ``` +## cancel + +中止当前会话正在运行的 Agent 任务。在 Agent 执行长时间任务(例如多轮工具调用、长流式输出)时,可随时发送 `/cancel`,Agent 会在下一次工具执行前停止。Web 端、微信、企业微信、飞书等各通道均可使用。 + +```text +/cancel +``` + ## config 查看或修改运行时配置。修改后立即生效,无需重启服务。 diff --git a/docs/cli/index.mdx b/docs/cli/index.mdx index ce67be27..f6462ecb 100644 --- a/docs/cli/index.mdx +++ b/docs/cli/index.mdx @@ -57,6 +57,7 @@ Others: | --- | --- | | `/help` | 显示命令帮助 | | `/status` | 查看服务状态和配置 | +| `/cancel` | 中止当前正在运行的 Agent 任务 | | `/config` | 查看或修改运行时配置 | | `/skill` | 管理技能(安装、卸载、启用、禁用等) | | `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) | @@ -82,6 +83,7 @@ Others: | version | ✓ | ✓ | | status | ✓ | ✓ | | logs | ✓ | ✓ | +| cancel | ✗ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | | memory (子命令) | ✗ | ✓ | diff --git a/docs/en/cli/general.mdx b/docs/en/cli/general.mdx index ae78f3e4..8107fcb5 100644 --- a/docs/en/cli/general.mdx +++ b/docs/en/cli/general.mdx @@ -25,6 +25,14 @@ View current session and service status, including process info, model configura /status ``` +## cancel + +Abort the agent task currently running in this session. When the agent is busy with a long task (e.g. multi-turn tool calls or a long streaming response), send `/cancel` and the agent will stop before the next tool execution. Available across all channels — Web, WeChat, WeCom, Feishu, etc. + +```text +/cancel +``` + ## config View or modify runtime configuration. Changes take effect immediately without restarting. diff --git a/docs/en/cli/index.mdx b/docs/en/cli/index.mdx index 36147261..e13b45a3 100644 --- a/docs/en/cli/index.mdx +++ b/docs/en/cli/index.mdx @@ -57,6 +57,7 @@ In the Web console or any connected channel, type `/` to see command suggestions | --- | --- | | `/help` | Show command help | | `/status` | View service status and configuration | +| `/cancel` | Abort the currently running agent task | | `/config` | View or modify runtime configuration | | `/skill` | Manage skills (install, uninstall, enable, disable, etc.) | | `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) | @@ -80,6 +81,7 @@ In the Web console or any connected channel, type `/` to see command suggestions | version | ✓ | ✓ | | status | ✓ | ✓ | | logs | ✓ | ✓ | +| cancel | ✗ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | | memory (subcommands) | ✗ | ✓ | diff --git a/docs/ja/cli/general.mdx b/docs/ja/cli/general.mdx index ab24a14e..e31d7cf3 100644 --- a/docs/ja/cli/general.mdx +++ b/docs/ja/cli/general.mdx @@ -25,6 +25,14 @@ description: ステータスの確認、設定管理、コンテキスト制御 /status ``` +## cancel + +現在のセッションで実行中の Agent タスクを中止します。Agent が長時間のタスク(マルチターンのツール呼び出しや長いストリーミング応答など)を実行している間、`/cancel` を送信すると、次のツール実行の前に停止します。Web、WeChat、企業微信、Feishu など、すべてのチャネルで利用可能です。 + +```text +/cancel +``` + ## config 実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。 diff --git a/docs/ja/cli/index.mdx b/docs/ja/cli/index.mdx index f8e28eff..4d00a654 100644 --- a/docs/ja/cli/index.mdx +++ b/docs/ja/cli/index.mdx @@ -57,6 +57,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す | --- | --- | | `/help` | コマンドヘルプを表示 | | `/status` | サービスの状態と設定を表示 | +| `/cancel` | 実行中の Agent タスクを中止 | | `/config` | 実行時設定の表示・変更 | | `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) | | `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) | @@ -80,6 +81,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す | version | ✓ | ✓ | | status | ✓ | ✓ | | logs | ✓ | ✓ | +| cancel | ✗ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | | memory(サブコマンド) | ✗ | ✓ | diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index fc721f51..b7dc6371 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -26,16 +26,19 @@ from common.log import logger from cli import __version__ -# Known top-level subcommands that cow supports +# Known top-level subcommands that cow supports. +# "start" / "stop" / "restart" refer to daemon lifecycle on the host shell; +# in chat, "/cancel" aborts the in-flight agent run instead. KNOWN_COMMANDS = { "help", "version", "status", "logs", "start", "stop", "restart", + "cancel", "skill", "context", "config", "knowledge", "memory", "install-browser", } -# Commands that can only run from the CLI (terminal), not in chat +# 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) @@ -225,6 +228,7 @@ class CowCliPlugin(Plugin): " /help 显示此帮助", " /version 查看版本", " /status 查看运行状态", + " /cancel 中止当前正在运行的 Agent 任务", " /logs [N] 查看最近N条日志 (默认20)", " /context 查看当前对话上下文信息", " /context clear 清除当前对话上下文", @@ -250,6 +254,41 @@ class CowCliPlugin(Plugin): def _cmd_version(self, args: str, e_context, **_) -> str: return f"CowAgent v{__version__}" + # ------------------------------------------------------------------ + # cancel — abort the in-flight agent run for the current session. + # Fallback handler; in practice chat_channel/web_channel intercept + # /cancel earlier so it bypasses the per-session serial queue. + # ------------------------------------------------------------------ + + def _cmd_cancel(self, args: str, e_context: EventContext, session_id: str = "", **_) -> str: + """Signal the running agent to halt at its next checkpoint.""" + from agent.protocol import get_cancel_registry + + target_session = self._get_session_id(e_context, fallback=session_id) + registry = get_cancel_registry() + + # Prefer per-turn request_id (matches the key agent_bridge registered) + cancelled = 0 + request_id = "" + if e_context is not None: + try: + ctx = e_context["context"] + request_id = ctx.kwargs.get("request_id") or ctx.get("request_id", "") + except Exception: + request_id = "" + + if request_id and registry.cancel_request(request_id): + cancelled = 1 + + # Fall back to session-wide cancel + if cancelled == 0 and target_session: + cancelled = registry.cancel_session(target_session) + + if cancelled <= 0: + return "当前没有可中止的任务。" + + return "🛑 已中止" + # ------------------------------------------------------------------ # status # ------------------------------------------------------------------ From 116fb27257cdc8162e204fd6d566e75f33ece2c5 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 27 May 2026 18:37:54 +0800 Subject: [PATCH 270/399] fix: robust tool args JSON parsing for non-strict providers #2823 --- agent/protocol/agent_stream.py | 61 ++++++++++++++++++++++------------ requirements-optional.txt | 4 +-- requirements.txt | 1 + voice/edge/edge_voice.py | 1 + voice/elevent/elevent_voice.py | 1 + 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 701b00de..e3be20b8 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -13,6 +13,13 @@ from agent.protocol.message_utils import sanitize_claude_messages, compress_turn from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger +# Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content). +try: + from json_repair import repair_json as _repair_json + _HAS_JSON_REPAIR = True +except ImportError: + _HAS_JSON_REPAIR = False + # Maximum number of characters of model "reasoning / thinking" content to persist # in conversation history. The full reasoning is still streamed to the UI in real @@ -45,6 +52,30 @@ def _truncate_reasoning_for_storage(text: str) -> str: return head + _REASONING_TRUNCATE_MARKER.format(omitted=omitted) + tail +def _parse_tool_args(args_str: str, finish_reason: Optional[str]) -> Tuple[dict, Optional[str]]: + """Parse tool args JSON. Returns (args, error_msg); error_msg is None on success. + + On JSONDecodeError: detect truncation first (skip repair, surface max_tokens hint); + otherwise try json-repair for escape issues; finally fall back to the raw decoder error. + """ + if not args_str: + return {}, None + try: + return json.loads(args_str), None + except json.JSONDecodeError as e: + if finish_reason in ("length", "max_tokens") or not args_str.rstrip().endswith("}"): + return {}, "Output truncated (max_tokens reached). Split content into smaller chunks across multiple tool calls." + if _HAS_JSON_REPAIR: + try: + repaired = _repair_json(args_str, return_objects=True) + if isinstance(repaired, dict): + logger.warning(f"Tool args JSON repaired ({len(args_str)} chars)") + return repaired, None + except Exception: + pass + return {}, f"Invalid JSON in tool arguments: {e.msg}" + + class AgentStreamExecutor: """ Agent Stream Executor @@ -973,26 +1004,17 @@ class AgentStreamExecutor: import uuid tool_id = f"call_{uuid.uuid4().hex[:24]}" - try: - # Safely get arguments, handle None case - args_str = tc.get("arguments") or "" - arguments = json.loads(args_str) if args_str else {} - except json.JSONDecodeError as e: - # Handle None or invalid arguments safely - args_str = tc.get('arguments') or "" - args_preview = args_str[:200] if len(args_str) > 200 else args_str - logger.error(f"Failed to parse tool arguments for {tc['name']}") - logger.error(f"Arguments length: {len(args_str)} chars") - logger.error(f"Arguments preview: {args_preview}...") - logger.error(f"JSON decode error: {e}") - - # Return a clear error message to the LLM instead of empty dict - # This helps the LLM understand what went wrong + args_str = tc.get("arguments") or "" + arguments, parse_err = _parse_tool_args(args_str, stop_reason) + if parse_err: + logger.error( + f"Tool args parse failed for {tc['name']} ({len(args_str)} chars): {parse_err}" + ) tool_calls.append({ "id": tool_id, "name": tc["name"], "arguments": {}, - "_parse_error": f"Invalid JSON in tool arguments: {args_preview}... Error: {str(e)}. Tip: For large content, consider splitting into smaller chunks or using a different approach." + "_parse_error": parse_err, }) continue @@ -1080,14 +1102,11 @@ class AgentStreamExecutor: tool_id = tool_call["id"] arguments = tool_call["arguments"] - # Check if there was a JSON parse error if "_parse_error" in tool_call: - parse_error = tool_call["_parse_error"] - logger.error(f"Skipping tool execution due to parse error: {parse_error}") result = { "status": "error", - "result": f"Failed to parse tool arguments. {parse_error}. Please ensure your tool call uses valid JSON format with all required parameters.", - "execution_time": 0 + "result": tool_call["_parse_error"], + "execution_time": 0, } self._record_tool_result(tool_name, arguments, False) return result diff --git a/requirements-optional.txt b/requirements-optional.txt index c8cd9a63..7abdc8e5 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -3,8 +3,8 @@ tiktoken>=0.3.2 # openai calculate token #voice pydub>=0.25.1 # need ffmpeg gTTS>=2.3.1 # google text to speech -edge-tts # edge-tts -elevenlabs==1.0.3 # elevenlabs TTS +# edge-tts: install on demand, see voice/edge/edge_voice.py +# elevenlabs: install on demand, see voice/elevent/elevent_voice.py #install plugin dulwich diff --git a/requirements.txt b/requirements.txt index 77a66551..706d9894 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ PyYAML>=6.0 croniter>=2.0.0 click>=8.0 qrcode +json-repair # wechatcom & wechatmp wechatpy diff --git a/voice/edge/edge_voice.py b/voice/edge/edge_voice.py index 7bb8b2e6..1a25a2b4 100644 --- a/voice/edge/edge_voice.py +++ b/voice/edge/edge_voice.py @@ -1,3 +1,4 @@ +# Requires: edge-tts (pip install edge-tts) import time import edge_tts diff --git a/voice/elevent/elevent_voice.py b/voice/elevent/elevent_voice.py index 2cfa5a3f..5e274638 100644 --- a/voice/elevent/elevent_voice.py +++ b/voice/elevent/elevent_voice.py @@ -1,3 +1,4 @@ +# Requires: elevenlabs==1.0.3 (pip install elevenlabs==1.0.3) import time from elevenlabs.client import ElevenLabs From 83cd6ad1588b4d6114e10a5f11dc8fbb9d0ca6bb Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 27 May 2026 18:42:21 +0800 Subject: [PATCH 271/399] fix(browser): preserve non-http schemes in navigate URL --- agent/tools/browser/browser_tool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agent/tools/browser/browser_tool.py b/agent/tools/browser/browser_tool.py index c5139812..c91be26c 100644 --- a/agent/tools/browser/browser_tool.py +++ b/agent/tools/browser/browser_tool.py @@ -145,7 +145,8 @@ class BrowserTool(BaseTool): url = args.get("url", "").strip() if not url: return ToolResult.fail("Error: 'url' is required for navigate action") - if not url.startswith(("http://", "https://")): + # Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc. + if "://" not in url and not url.startswith(("about:", "data:")): url = "https://" + url timeout = args.get("timeout", 30000) service = self._get_service() From 6721dbdbcca050c709e440024fd270164951ffa7 Mon Sep 17 00:00:00 2001 From: 6vision Date: Wed, 27 May 2026 21:53:54 +0800 Subject: [PATCH 272/399] docs(wechatcom_kf): add web console onboarding tab --- channel/web/web_channel.py | 18 +++++++++++-- docs/channels/wecom-kf.mdx | 51 +++++++++++++++++++++-------------- docs/en/channels/wecom-kf.mdx | 51 +++++++++++++++++++++-------------- docs/ja/channels/wecom-kf.mdx | 51 +++++++++++++++++++++-------------- 4 files changed, 109 insertions(+), 62 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 2a5e71b8..766c7126 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -713,8 +713,9 @@ class WebChannel(ChatChannel): 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] 8. wechatcom_kf - 微信客服") + logger.info("[WebChannel] 9. wechatmp - 个人公众号") + logger.info("[WebChannel] 10. wechatmp_service - 企业公众号") logger.info("[WebChannel] ✅ Web控制台已运行") logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}") if is_public_bind: @@ -1271,6 +1272,19 @@ class ChannelsHandler: {"key": "wechatcomapp_port", "label": "Port", "type": "number", "default": 9898}, ], }), + ("wechatcom_kf", { + "label": {"zh": "微信客服", "en": "WeCom Customer Service"}, + "icon": "fa-headset", + "color": "emerald", + "fields": [ + # wechatcom_corp_id is shared with wechatcom_app — same key, same value. + {"key": "wechatcom_corp_id", "label": "Corp ID", "type": "text"}, + {"key": "wechatcom_kf_secret", "label": "Secret", "type": "secret"}, + {"key": "wechatcom_kf_token", "label": "Token", "type": "secret"}, + {"key": "wechatcom_kf_aes_key", "label": "AES Key", "type": "secret"}, + {"key": "wechatcom_kf_port", "label": "Port", "type": "number", "default": 9888}, + ], + }), ("wechatmp", { "label": {"zh": "公众号", "en": "WeChat MP"}, "icon": "fa-comment-dots", diff --git a/docs/channels/wecom-kf.mdx b/docs/channels/wecom-kf.mdx index db6fa0bb..42a13991 100644 --- a/docs/channels/wecom-kf.mdx +++ b/docs/channels/wecom-kf.mdx @@ -45,30 +45,41 @@ description: 将 CowAgent 接入微信客服(WeCom Customer Service) ## 三、配置和运行 -在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图): +填入上一步收集到的 4 个字段(Corp ID / Secret / Token / EncodingAESKey): -```json -{ - "channel_type": "wechatcom_kf", - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcom_kf_secret": "YOUR_SECRET", - "wechatcom_kf_token": "YOUR_TOKEN", - "wechatcom_kf_aes_key": "YOUR_AES_KEY", - "wechatcom_kf_port": 9888 -} -``` + + + 启动 Cow 项目后打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **微信客服**,依次填入 Corp ID / Secret / Token / AES Key(端口默认 9888,可改),点击接入即可。 -| 参数 | 说明 | -| --- | --- | -| `wechatcom_corp_id` | 企业 ID | -| `wechatcom_kf_secret` | 绑定到微信客服的那个企微自建应用的 Secret | -| `wechatcom_kf_token` | API 接收配置中的 Token | -| `wechatcom_kf_aes_key` | API 接收配置中的 EncodingAESKey | -| `wechatcom_kf_port` | 监听端口,默认 9888 | + + + + 在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图): -配置完成后启动程序。当日志中出现 `Listening on http://0.0.0.0:9888/wxkf/` 时,说明程序运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。 + ```json + { + "channel_type": "wechatcom_kf", + "wechatcom_corp_id": "YOUR_CORP_ID", + "wechatcom_kf_secret": "YOUR_SECRET", + "wechatcom_kf_token": "YOUR_TOKEN", + "wechatcom_kf_aes_key": "YOUR_AES_KEY", + "wechatcom_kf_port": 9888 + } + ``` -程序启动后,回到企业微信「接收消息 → 设置API接收」,将回调 URL 填为 `http://:9888/wxkf/`,点击保存。保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息: + | 参数 | 说明 | + | --- | --- | + | `wechatcom_corp_id` | 企业 ID | + | `wechatcom_kf_secret` | 绑定到微信客服的那个企微自建应用的 Secret | + | `wechatcom_kf_token` | API 接收配置中的 Token | + | `wechatcom_kf_aes_key` | API 接收配置中的 EncodingAESKey | + | `wechatcom_kf_port` | 监听端口,默认 9888 | + + + +接入完成后启动程序(Web 控制台方式会自动重启),日志中出现 `Listening on http://0.0.0.0:9888/wxkf/` 表示运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。 + +接着回到企业微信「接收消息 → 设置API接收」,将回调 URL 填为 `http://:9888/wxkf/`,点击保存。保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息: diff --git a/docs/en/channels/wecom-kf.mdx b/docs/en/channels/wecom-kf.mdx index 541becac..1dd973ce 100644 --- a/docs/en/channels/wecom-kf.mdx +++ b/docs/en/channels/wecom-kf.mdx @@ -45,30 +45,41 @@ Required resources: ## 3. Configuration and Run -Add the following configuration to `config.json` (each parameter maps to a field shown in the screenshots above): +Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token / EncodingAESKey): -```json -{ - "channel_type": "wechatcom_kf", - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcom_kf_secret": "YOUR_SECRET", - "wechatcom_kf_token": "YOUR_TOKEN", - "wechatcom_kf_aes_key": "YOUR_AES_KEY", - "wechatcom_kf_port": 9888 -} -``` + + + Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeCom Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect. -| Parameter | Description | -| --- | --- | -| `wechatcom_corp_id` | Corp ID | -| `wechatcom_kf_secret` | Secret of the WeCom custom app bound to Customer Service | -| `wechatcom_kf_token` | Token from the API reception config | -| `wechatcom_kf_aes_key` | EncodingAESKey from the API reception config | -| `wechatcom_kf_port` | Listening port, default 9888 | + + + + Add the following configuration to `config.json` (each parameter maps to a field shown in the screenshots above): -After configuration, start the program. When the log shows `Listening on http://0.0.0.0:9888/wxkf/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group). + ```json + { + "channel_type": "wechatcom_kf", + "wechatcom_corp_id": "YOUR_CORP_ID", + "wechatcom_kf_secret": "YOUR_SECRET", + "wechatcom_kf_token": "YOUR_TOKEN", + "wechatcom_kf_aes_key": "YOUR_AES_KEY", + "wechatcom_kf_port": 9888 + } + ``` -Once the program is running, go back to **Receive Messages → Set API Reception** in the WeCom console and set the callback URL to `http://:9888/wxkf/`, then click Save. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received: + | Parameter | Description | + | --- | --- | + | `wechatcom_corp_id` | Corp ID | + | `wechatcom_kf_secret` | Secret of the WeCom custom app bound to Customer Service | + | `wechatcom_kf_token` | Token from the API reception config | + | `wechatcom_kf_aes_key` | EncodingAESKey from the API reception config | + | `wechatcom_kf_port` | Listening port, default 9888 | + + + +After connecting, start the program (the Web Console method restarts the channel automatically). When the log shows `Listening on http://0.0.0.0:9888/wxkf/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group). + +Then go back to **Receive Messages → Set API Reception** in the WeCom console and set the callback URL to `http://:9888/wxkf/`, then click Save. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received: diff --git a/docs/ja/channels/wecom-kf.mdx b/docs/ja/channels/wecom-kf.mdx index bcf63c04..25de67ff 100644 --- a/docs/ja/channels/wecom-kf.mdx +++ b/docs/ja/channels/wecom-kf.mdx @@ -45,30 +45,41 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ ## 3. 設定と起動 -`config.json` に以下の設定を追加します(各パラメータと WeCom コンソールの対応関係は上のスクリーンショットを参照してください): +前のステップで収集した 4 つのフィールド(Corp ID / Secret / Token / EncodingAESKey)を入力します: -```json -{ - "channel_type": "wechatcom_kf", - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcom_kf_secret": "YOUR_SECRET", - "wechatcom_kf_token": "YOUR_TOKEN", - "wechatcom_kf_aes_key": "YOUR_AES_KEY", - "wechatcom_kf_port": 9888 -} -``` + + + Cow プロジェクトを起動した後、Web コンソールを開きます。**チャネル** メニューを選択し、**接入チャネル** をクリックし、**微信客服** を選択して、Corp ID / Secret / Token / AES Key を入力し(ポートはデフォルト 9888、変更可能)、接入をクリックします。 -| パラメータ | 説明 | -| --- | --- | -| `wechatcom_corp_id` | 企業 ID | -| `wechatcom_kf_secret` | カスタマーサービスにバインドした企業微信自建アプリの Secret | -| `wechatcom_kf_token` | API 受信設定の Token | -| `wechatcom_kf_aes_key` | API 受信設定の EncodingAESKey | -| `wechatcom_kf_port` | リスンポート、デフォルトは 9888 | + + + + `config.json` に以下の設定を追加します(各パラメータと WeCom コンソールの対応関係は上のスクリーンショットを参照してください): -設定後、プログラムを起動します。ログに `Listening on http://0.0.0.0:9888/wxkf/` と表示されれば、プログラムは正常に動作しています。このポートを外部に公開する必要があります(例:クラウドサーバーのセキュリティグループで許可します)。 + ```json + { + "channel_type": "wechatcom_kf", + "wechatcom_corp_id": "YOUR_CORP_ID", + "wechatcom_kf_secret": "YOUR_SECRET", + "wechatcom_kf_token": "YOUR_TOKEN", + "wechatcom_kf_aes_key": "YOUR_AES_KEY", + "wechatcom_kf_port": 9888 + } + ``` -プログラム起動後、WeCom 管理コンソールの **メッセージ受信 → API 受信設定** に戻り、コールバック URL に `http://:9888/wxkf/` を入力して保存します。保存が成功したら、サーバー IP を **企業の信頼済み IP** に追加する必要もあります。追加しないとメッセージの送受信ができません: + | パラメータ | 説明 | + | --- | --- | + | `wechatcom_corp_id` | 企業 ID | + | `wechatcom_kf_secret` | カスタマーサービスにバインドした企業微信自建アプリの Secret | + | `wechatcom_kf_token` | API 受信設定の Token | + | `wechatcom_kf_aes_key` | API 受信設定の EncodingAESKey | + | `wechatcom_kf_port` | リスンポート、デフォルトは 9888 | + + + +接入完了後、プログラムを起動します(Web コンソール方式ではチャネルが自動的に再起動されます)。ログに `Listening on http://0.0.0.0:9888/wxkf/` と表示されれば、プログラムは正常に動作しています。このポートを外部に公開する必要があります(例:クラウドサーバーのセキュリティグループで許可します)。 + +続いて、WeCom 管理コンソールの **メッセージ受信 → API 受信設定** に戻り、コールバック URL に `http://:9888/wxkf/` を入力して保存します。保存が成功したら、サーバー IP を **企業の信頼済み IP** に追加する必要もあります。追加しないとメッセージの送受信ができません: From bccce2d7cbde47f221eac886855687c03c8819fc Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 28 May 2026 10:49:52 +0800 Subject: [PATCH 273/399] feat(models): support xiaomi mimo --- README.md | 1 + agent/tools/vision/vision.py | 3 + bridge/bridge.py | 4 + channel/web/web_channel.py | 34 +- common/const.py | 11 + config.py | 5 + docs/README.md | 30 ++ docs/docs.json | 3 + docs/en/models/index.mdx | 1 + docs/en/models/mimo.mdx | 136 +++++++ docs/ja/README.md | 1 + docs/ja/models/mimo.mdx | 135 +++++++ docs/models/index.mdx | 1 + docs/models/mimo.mdx | 135 +++++++ docs/zh/README.md | 1 + models/bot_factory.py | 4 + models/mimo/__init__.py | 0 models/mimo/mimo_bot.py | 668 +++++++++++++++++++++++++++++++++++ models/mimo/mimo_session.py | 57 +++ voice/factory.py | 4 + voice/mimo/__init__.py | 0 voice/mimo/mimo_voice.py | 109 ++++++ 22 files changed, 1340 insertions(+), 3 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/en/models/mimo.mdx create mode 100644 docs/ja/models/mimo.mdx create mode 100644 docs/models/mimo.mdx create mode 100644 models/mimo/__init__.py create mode 100644 models/mimo/mimo_bot.py create mode 100644 models/mimo/mimo_session.py create mode 100644 voice/mimo/__init__.py create mode 100644 voice/mimo/mimo_voice.py diff --git a/README.md b/README.md index 2a72f513..8b9e044e 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation | [Kimi](https://docs.cowagent.ai/en/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [MiniMax](https://docs.cowagent.ai/en/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | | [ERNIE](https://docs.cowagent.ai/en/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](https://docs.cowagent.ai/en/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | | [LinkAI](https://docs.cowagent.ai/en/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Custom](https://docs.cowagent.ai/en/models/custom) | Local models / third-party proxy | ✅ | | | | | | diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 56a2ecfe..498f3cd8 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -57,6 +57,7 @@ _DISCOVERABLE_MODELS = [ ("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"), ("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"), ("minimax_api_key", const.MiniMax, const.MINIMAX_M2_7, "MiniMax"), + ("mimo_api_key", const.MIMO, const.MIMO_V2_5_PRO, "MiMo"), ] # Model name prefix → discoverable provider display_name. @@ -73,6 +74,7 @@ _MODEL_PREFIX_TO_PROVIDER = [ ("glm-", "ZhipuAI"), ("minimax-", "MiniMax"), ("abab", "MiniMax"), + ("mimo-", "MiMo"), ] # Model prefixes that natively belong to OpenAI / LinkAI (raw HTTP providers). @@ -92,6 +94,7 @@ _PROVIDER_ID_TO_DISPLAY = { "qianfan": "Qianfan", "zhipu": "ZhipuAI", "minimax": "MiniMax", + "mimo": "MiMo", } diff --git a/bridge/bridge.py b/bridge/bridge.py index c0cb62e4..6eeb0887 100644 --- a/bridge/bridge.py +++ b/bridge/bridge.py @@ -63,6 +63,10 @@ class Bridge(object): if model_type and model_type.startswith("deepseek"): self.btype["chat"] = const.DEEPSEEK + # 小米 MiMo 系列模型,全部以 mimo- 开头 + if model_type and model_type.startswith("mimo-"): + self.btype["chat"] = const.MIMO + if model_type and isinstance(model_type, str): lowered_model_type = model_type.lower() if lowered_model_type == const.QIANFAN or lowered_model_type.startswith("ernie"): diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index ab1d6915..af4e241e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1387,6 +1387,7 @@ class ConfigHandler: const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2, const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K, + const.MIMO_V2_5_PRO, const.MIMO_V2_5, ] # Generic placeholder hints surfaced in the web console. We deliberately @@ -1481,6 +1482,14 @@ class ConfigHandler: "api_base_placeholder": _PLACEHOLDER_QIANFAN, "models": [const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K], }), + ("mimo", { + "label": {"zh": "小米 MiMo", "en": "MiMo"}, + "api_key_field": "mimo_api_key", + "api_base_key": "mimo_api_base", + "api_base_default": "https://api.xiaomimimo.com/v1", + "api_base_placeholder": _PLACEHOLDER_V1, + "models": [const.MIMO_V2_5_PRO, const.MIMO_V2_5], + }), ("linkai", { "label": "LinkAI", "api_key_field": "linkai_api_key", @@ -1502,10 +1511,10 @@ class ConfigHandler: EDITABLE_KEYS = { "model", "bot_type", "use_linkai", "open_ai_api_base", "deepseek_api_base", "qianfan_api_base", "claude_api_base", "gemini_api_base", - "zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", "custom_api_base", + "zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", "custom_api_base", "mimo_api_base", "open_ai_api_key", "deepseek_api_key", "qianfan_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", "custom_api_key", + "ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key", "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", "enable_thinking", "web_password", } @@ -1646,7 +1655,7 @@ class ModelsHandler: # Capability -> provider ids drawn from ConfigHandler.PROVIDER_MODELS. _ASR_PROVIDERS = ["openai", "dashscope", "zhipu", "linkai"] # Web-console white-list. Other vendors stay usable via direct config. - _TTS_PROVIDERS = ["openai", "minimax", "dashscope", "linkai"] + _TTS_PROVIDERS = ["openai", "minimax", "dashscope", "mimo", "linkai"] # TTS engine catalog (speech models, not voice timbres). Entries are # either a bare code or {value, hint?} when a friendly label helps. @@ -1661,6 +1670,10 @@ class ModelsHandler: "dashscope": [ {"value": "qwen3-tts-flash", "hint": "覆盖普通话、方言与主流外语"}, ], + # 小米 MiMo TTS 系列,通过 chat completions 接口合成 + "mimo": [ + {"value": "mimo-v2.5-tts", "hint": "预置音色 · 支持唱歌模式"}, + ], # Aggregating gateway: a single endpoint multiplexes several # underlying TTS engines, selected via the `model` field. # Each engine exposes its own voice catalog (see _TTS_PROVIDER_VOICES). @@ -1780,6 +1793,18 @@ class ModelsHandler: {"value": "Marcus", "hint": "陕西话 · 秦川"}, {"value": "Roy", "hint": "闽南语 · 阿杰"}, ], + # 小米 MiMo 预置音色列表(mimo-v2.5-tts),文档: + # https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5 + "mimo": [ + {"value": "冰糖", "hint": "中文 · 女声 · 冰糖"}, + {"value": "茉莉", "hint": "中文 · 女声 · 茉莉"}, + {"value": "苏打", "hint": "中文 · 男声 · 苏打"}, + {"value": "白桦", "hint": "中文 · 男声 · 白桦"}, + {"value": "Mia", "hint": "英文 · 女声 · Mia"}, + {"value": "Chloe", "hint": "英文 · 女声 · Chloe"}, + {"value": "Milo", "hint": "英文 · 男声 · Milo"}, + {"value": "Dean", "hint": "英文 · 男声 · Dean"}, + ], # Aggregating gateway: voices are scoped per engine model. The # frontend picks the correct list based on the selected model so # users don't see incompatible timbres for the active engine. @@ -1916,6 +1941,8 @@ class ModelsHandler: # (see models/minimax/minimax_bot.py::call_vision); the M2.x chat # family is text-only. "minimax": [const.MINIMAX_TEXT_01], + # MiMo 原生全模态模型:v2.5-pro / v2.5 支持图像/音频/视频输入 + "mimo": [const.MIMO_V2_5_PRO, const.MIMO_V2_5], # LinkAI proxies the underlying vendor; surface a curated set of # multimodal models. Order: gpt-4.1-mini → gpt-5.4-mini as the # cross-vendor baselines, then each vendor's recommended default. @@ -2045,6 +2072,7 @@ class ModelsHandler: ("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL), ("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO), ("minimax", "minimax_api_key", const.MINIMAX_TEXT_01), + ("mimo", "mimo_api_key", const.MIMO_V2_5_PRO), ] @classmethod diff --git a/common/const.py b/common/const.py index 9cfcd63c..7addd6af 100644 --- a/common/const.py +++ b/common/const.py @@ -15,6 +15,7 @@ ZHIPU_AI = "zhipu" MOONSHOT = "moonshot" MiniMax = "minimax" DEEPSEEK = "deepseek" +MIMO = "mimo" # 小米 MiMo 大模型 CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change MODELSCOPE = "modelscope" @@ -140,6 +141,13 @@ KIMI_K2 = "kimi-k2" KIMI_K2_5 = "kimi-k2.5" KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default) +# 小米 MiMo +MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐) +MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频) +MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro +MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态 +MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版 + # Doubao (Volcengine Ark) DOUBAO = "doubao" DOUBAO_SEED_2_CODE = "doubao-seed-2-0-code-preview-260215" @@ -182,6 +190,9 @@ MODEL_LIST = [ # MiniMax MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5, + # 小米 MiMo + MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, + # Claude CLAUDE3, CLAUDE_4_6_SONNET, CLAUDE_4_7_OPUS, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229, CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU, diff --git a/config.py b/config.py index 6a3a00df..1d44dcc5 100644 --- a/config.py +++ b/config.py @@ -209,6 +209,9 @@ available_setting = { "Minimax_base_url": "", "deepseek_api_key": "", "deepseek_api_base": "https://api.deepseek.com/v1", + # 小米 MiMo 大模型 + "mimo_api_key": "", + "mimo_api_base": "https://api.xiaomimimo.com/v1", "web_host": "", # Web console bind address; empty means auto "web_port": 9899, "web_password": "", # Web console password; empty means no authentication required @@ -401,6 +404,8 @@ def load_config(): "minimax_api_base": "MINIMAX_API_BASE", "deepseek_api_key": "DEEPSEEK_API_KEY", "deepseek_api_base": "DEEPSEEK_API_BASE", + "mimo_api_key": "MIMO_API_KEY", + "mimo_api_base": "MIMO_API_BASE", "qianfan_api_key": "QIANFAN_API_KEY", "qianfan_api_base": "QIANFAN_API_BASE", "zhipu_ai_api_key": "ZHIPU_AI_API_KEY", diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..f406cc2a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Documentation + +This directory contains the Mintlify documentation site for the project. + +## Prerequisites + +- Node.js v20.17.0 or higher (LTS recommended) + +## Install the CLI (one-time, global) + +```bash +npm i -g mint +``` + +## Run the docs locally + +From this `docs/` directory: + +```bash +mint dev +``` + +Then open http://localhost:3000 (or the port Mint reports if 3000 is in use). + +> The first run downloads the Mint preview framework (~90 MB) into `~/.mintlify/`. +> Subsequent runs start instantly from the local cache. + +## More + +- Mintlify docs: https://www.mintlify.com/docs diff --git a/docs/docs.json b/docs/docs.json index 00a5be67..e2826887 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -88,6 +88,7 @@ "models/doubao", "models/kimi", "models/qianfan", + "models/mimo", "models/linkai", "models/coding-plan", "models/custom" @@ -290,6 +291,7 @@ "en/models/doubao", "en/models/kimi", "en/models/qianfan", + "en/models/mimo", "en/models/linkai", "en/models/coding-plan", "en/models/custom" @@ -492,6 +494,7 @@ "ja/models/doubao", "ja/models/kimi", "ja/models/qianfan", + "ja/models/mimo", "ja/models/linkai", "ja/models/coding-plan", "ja/models/custom" diff --git a/docs/en/models/index.mdx b/docs/en/models/index.mdx index 9c7afe44..cbe74d41 100644 --- a/docs/en/models/index.mdx +++ b/docs/en/models/index.mdx @@ -21,6 +21,7 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | [Doubao](/en/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](/en/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [ERNIE](/en/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](/en/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | | [LinkAI](/en/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Custom](/en/models/custom) | Local models / third-party proxies | ✅ | | | | | | diff --git a/docs/en/models/mimo.mdx b/docs/en/models/mimo.mdx new file mode 100644 index 00000000..6f808b8e --- /dev/null +++ b/docs/en/models/mimo.mdx @@ -0,0 +1,136 @@ +--- +title: MiMo +description: Xiaomi MiMo model configuration (Text Chat + Image Understanding + Text-to-Speech) +--- + +Xiaomi MiMo is a native omni-modal large model. A single `mimo_api_key` enables text chat, image understanding, and text-to-speech all at once. + + + All capabilities below can be configured in one place via the "Model Management" page in the Web Console — no need to manually edit the configuration file. + + +## Text Chat + +```json +{ + "model": "mimo-v2.5-pro", + "mimo_api_key": "YOUR_API_KEY", + "mimo_api_base": "https://api.xiaomimimo.com/v1" +} +``` + +| Parameter | Description | +| --- | --- | +| `model` | Default recommendation: `mimo-v2.5-pro`; `mimo-v2.5` is also supported | +| `mimo_api_key` | Create one in the [MiMo Open Platform](https://platform.xiaomimimo.com/console/api-keys) | +| `mimo_api_base` | Optional, defaults to `https://api.xiaomimimo.com/v1` | + +### Model Selection + +| Model | Use Case | +| --- | --- | +| `mimo-v2.5-pro` | Flagship: native omni-modal + Agent capability, up to 1M tokens context | +| `mimo-v2.5` | General-purpose, native omni-modal (text / image / video / audio) | + +## Thinking Mode + +The MiMo V2.5 series enables "thinking mode" by default: the model emits `reasoning_content` (chain-of-thought) before the final answer, improving performance on complex tasks. + +Use the global `enable_thinking` flag to toggle visibility (also switchable from the Web Console settings): + +```json +{ + "enable_thinking": true +} +``` + +## Image Understanding + +Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models: + +- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup. +- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order. + +To force a specific Vision model, set it explicitly in the configuration: + +```json +{ + "tools": { + "vision": { + "provider": "mimo", + "model": "mimo-v2.5-pro" + } + } +} +``` + +## Text-to-Speech (TTS) + +```json +{ + "text_to_voice": "mimo", + "text_to_voice_model": "mimo-v2.5-tts", + "tts_voice_id": "冰糖" +} +``` + +| Parameter | Description | +| --- | --- | +| `text_to_voice_model` | Currently only `mimo-v2.5-tts` (preset voices + singing mode) | +| `tts_voice_id` | Preset voice name (Chinese voice IDs use the Chinese name directly) | + +### Preset Voices + +| Voice ID | Description | +| --- | --- | +| `Mia` | English · Female | +| `Chloe` | English · Female | +| `Milo` | English · Male | +| `Dean` | English · Male | +| `冰糖` | Chinese · Female (default) | +| `茉莉` | Chinese · Female | +| `苏打` | Chinese · Male | +| `白桦` | Chinese · Male | + + +You can also pick a voice visually from the Web Console under "Model Management → Text-to-Speech". + +### Style Control + +MiMo TTS supports embedding **audio tags** in the synthesis text to control emotion, tone, dialect, persona, and even singing. Tags must appear in the **text that will be synthesized to speech (i.e. the Agent's reply)**, with the overall style tag placed at the very beginning: + +``` +(style)content-to-synthesize +``` + +Half-width `()`, full-width `()`, and `[]` brackets are all accepted. Both Chinese and English style descriptors work — pick whichever language expresses the timbre most precisely. Common examples: + +| Category | Example tags | +| --- | --- | +| Basic emotions | `happy` `sad` `angry` `fear` `surprised` `excited` `aggrieved` `calm` `indifferent` | +| Compound emotions | `wistful` `relieved` `helpless` `guilty` `at ease` `uneasy` `touched` | +| Overall tone | `gentle` `aloof` `lively` `serious` `languid` `playful` `deep` `sharp` `cutting` | +| Voice character | `magnetic` `mellow` `bright` `ethereal` `childlike` `aged` `sweet` `husky` | +| Persona | `squeaky` `mature lady` `young boy` `uncle` `Taiwanese accent` | +| Dialect | `Northeastern` `Sichuan` `Henan` `Cantonese` | +| Role-play | `Sun Wukong` `Lin Daiyu` | +| Singing | `sing` / `singing` | + +Examples: + +- `(magnetic)The night is deep, and the city is still breathing.` +- `(gentle)Take a breath. You've got this.` +- `(serious)This is the final warning before the system reboots.` +- `(singing)Oh, when the saints go marching in…` + +You can also insert fine-grained audio tags at any position in the text to control breathing, laughter, pauses, etc. For example: + +``` +(nervous, deep breath) Phew… stay calm, stay calm. (faster pace) I've rehearsed this intro fifty times, it'll be fine. +``` + +See the [MiMo speech synthesis documentation](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5) for the full tag list. + + + When CowAgent calls TTS, the Agent's reply text (including any `(...)` tags) is forwarded directly to MiMo for synthesis. Tell the model in its persona / system prompt to "prefix replies with a `(style)` tag to control the tone", and IM channels (WeChat / Feishu / DingTalk / WeCom) will play voice replies with the corresponding emotion, dialect, or even singing. + diff --git a/docs/ja/README.md b/docs/ja/README.md index b68a82d0..df71ec74 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -104,6 +104,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | | [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | | [LinkAI](https://docs.cowagent.ai/ja/models/linkai) | 1 つの Key で 100+ モデルに接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [カスタム](https://docs.cowagent.ai/ja/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | | diff --git a/docs/ja/models/mimo.mdx b/docs/ja/models/mimo.mdx new file mode 100644 index 00000000..c677810f --- /dev/null +++ b/docs/ja/models/mimo.mdx @@ -0,0 +1,135 @@ +--- +title: Xiaomi MiMo +description: Xiaomi MiMo モデル設定(テキスト対話 + 画像理解 + 音声合成) +--- + +Xiaomi MiMo はネイティブ全モーダル大規模言語モデルです。1 つの `mimo_api_key` でテキスト対話、画像理解、音声合成を同時に有効化できます。 + + + Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。 + + +## テキスト対話 + +```json +{ + "model": "mimo-v2.5-pro", + "mimo_api_key": "YOUR_API_KEY", + "mimo_api_base": "https://api.xiaomimimo.com/v1" +} +``` + +| パラメータ | 説明 | +| --- | --- | +| `model` | 推奨は `mimo-v2.5-pro`。`mimo-v2.5` も使用可能 | +| `mimo_api_key` | [MiMo Open Platform](https://platform.xiaomimimo.com/console/api-keys) で作成 | +| `mimo_api_base` | 任意。デフォルトは `https://api.xiaomimimo.com/v1` | + +### モデル選択 + +| モデル | ユースケース | +| --- | --- | +| `mimo-v2.5-pro` | フラッグシップ。ネイティブ全モーダル + Agent 能力、最大 100 万トークンのコンテキスト | +| `mimo-v2.5` | 汎用版。ネイティブ全モーダル(テキスト / 画像 / 動画 / 音声) | + +## 思考モード + +MiMo V2.5 シリーズはデフォルトで「思考モード」が有効です。最終回答の前に `reasoning_content`(思考過程)を出力することで、複雑なタスクのパフォーマンスを高めます。 + +表示の有無はグローバル設定 `enable_thinking` で切り替え可能です(Web コンソールの設定ページからも変更できます): + +```json +{ + "enable_thinking": true +} +``` + +## 画像理解 + +`mimo_api_key` を設定すると、Agent の Vision ツールは自動的に MiMo のビジョンモデルを利用します: + +- メインモデル自体がマルチモーダル(`mimo-v2.5-pro` / `mimo-v2.5`)の場合は、画像はメインモデルが直接処理し、追加設定は不要です。 +- メインモデルが他社製の場合、Vision ツールは順序に従い `mimo-v2.5-pro` にフォールバックします。 + +特定の Vision モデルを強制したい場合は、設定ファイルで明示的に指定してください: + +```json +{ + "tools": { + "vision": { + "provider": "mimo", + "model": "mimo-v2.5-pro" + } + } +} +``` + +## 音声合成 + +```json +{ + "text_to_voice": "mimo", + "text_to_voice_model": "mimo-v2.5-tts", + "tts_voice_id": "冰糖" +} +``` + +| パラメータ | 説明 | +| --- | --- | +| `text_to_voice_model` | 現在は `mimo-v2.5-tts` のみ対応(プリセット音色 + 歌唱モード) | +| `tts_voice_id` | プリセット音色名(中国語の音色は中国語名がそのまま ID) | + +### プリセット音色 + +| 音色 ID | 説明 | +| --- | --- | +| `冰糖` | 中国語 · 女声(デフォルト) | +| `茉莉` | 中国語 · 女声 | +| `苏打` | 中国語 · 男声 | +| `白桦` | 中国語 · 男声 | +| `Mia` | 英語 · 女声 | +| `Chloe` | 英語 · 女声 | +| `Milo` | 英語 · 男声 | +| `Dean` | 英語 · 男声 | + +Web コンソールの「モデル管理 → 音声合成」のドロップダウンから視覚的に選択することもできます。 + +### スタイル制御 + +MiMo TTS は合成テキスト内に **音声タグ** を埋め込むことで、感情、語調、方言、キャラクター、さらには歌唱まで制御できます。タグは **最終的に音声合成されるテキスト(つまり Agent の返信内容)** に含める必要があり、全体スタイルのタグは先頭に置きます: + +``` +(スタイル)合成するテキスト +``` + +半角 `()`、全角 `()`、`[]` の 3 種類の括弧に対応。スタイル記述は中国語・英語のどちらでも OK で、最も的確に表現できる言語を選んでください。代表的なスタイル例: + +| 種類 | サンプルタグ | +| --- | --- | +| 基本感情 | `happy` `sad` `angry` `fear` `surprised` `excited` `aggrieved` `calm` `indifferent` | +| 複合感情 | `wistful` `relieved` `helpless` `guilty` `at ease` `uneasy` `touched` | +| 全体トーン | `gentle` `aloof` `lively` `serious` `languid` `playful` `deep` `sharp` `cutting` | +| 声質 | `magnetic` `mellow` `bright` `ethereal` `childlike` `aged` `sweet` `husky` | +| キャラクター調 | `squeaky` `mature lady` `young boy` `uncle` `Taiwanese accent` | +| 方言 | `Northeastern` `Sichuan` `Henan` `Cantonese` | +| ロールプレイ | `Sun Wukong` `Lin Daiyu` | +| 歌唱 | `sing` / `singing` | + +例: + +- `(magnetic)夜が深まり、街はまだ呼吸している。` +- `(gentle)深呼吸して。きっと大丈夫。` +- `(serious)これがシステム再起動前の最後の警告です。` +- `(singing)Twinkle, twinkle, little star, how I wonder what you are…` + +テキストの任意の位置に細かい音声タグを挿入して、呼吸、笑い声、間などを制御することもできます。例: + +``` +(nervous, deep breath) ふぅ……落ち着いて、落ち着いて。(faster pace) 自己紹介は五十回練習したから大丈夫。 +``` + +タグの完全な一覧は [MiMo 音声合成ドキュメント](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5) を参照してください。 + + + CowAgent は TTS 呼び出し時、Agent の返信原文(`(...)` タグを含む)をそのまま MiMo に送信します。ペルソナ / システムプロンプトで「返信の冒頭に `(スタイル)` タグを付けて口調を指定する」よう指示すれば、IM チャネル(WeChat / Feishu / DingTalk / WeCom)の音声返信に感情・方言・歌唱などの効果を付与できます。 + diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 5a7df20a..114c58e0 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -22,6 +22,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 | [豆包 Doubao](/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [百度千帆](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [小米 MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | | [LinkAI](/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [自定义](/models/custom) |本地模型 / 三方代理 | ✅ | | | | | | diff --git a/docs/models/mimo.mdx b/docs/models/mimo.mdx new file mode 100644 index 00000000..ea445df9 --- /dev/null +++ b/docs/models/mimo.mdx @@ -0,0 +1,135 @@ +--- +title: 小米 MiMo +description: 小米 MiMo 模型配置(文本对话 + 图像理解 + 语音合成) +--- + +小米 MiMo 是原生全模态大模型,单 `mimo_api_key` 即可同时启用文本对话、图像理解与语音合成。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "mimo-v2.5-pro", + "mimo_api_key": "YOUR_API_KEY", + "mimo_api_base": "https://api.xiaomimimo.com/v1" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 默认推荐 `mimo-v2.5-pro`,也可使用 `mimo-v2.5` | +| `mimo_api_key` | 在 [MiMo 开放平台](https://platform.xiaomimimo.com/console/api-keys) 创建 | +| `mimo_api_base` | 可选,默认为 `https://api.xiaomimimo.com/v1` | + +### 模型选择 + +| 模型 | 适用场景 | +| --- | --- | +| `mimo-v2.5-pro` | 旗舰,原生全模态 + Agent 能力,最高 100 万 tokens 上下文 | +| `mimo-v2.5` | 综合版,原生全模态(文本 / 图像 / 视频 / 音频) | + +## 思考模式 + +MiMo V2.5 系列默认开启「思考模式」:模型在输出最终回答前会先输出 `reasoning_content`(思维链),提升复杂任务表现。 + +通过全局配置 `enable_thinking` 控制是否展示(也可在 Web 控制台 - 配置页面切换): + +```json +{ + "enable_thinking": true +} +``` + +## 图像理解 + +配置 `mimo_api_key` 后,Agent 的 Vision 工具可以自动使用 MiMo 视觉模型: + +- 当主模型本身是多模态时(`mimo-v2.5-pro` / `mimo-v2.5`),直接由主模型识别图像,无需额外配置 +- 当主模型是其他厂商时,Vision 工具会根据顺序自动 fallback 到 `mimo-v2.5-pro` + +如需手动指定 Vision 模型,可在配置文件中显式配置: + +```json +{ + "tools": { + "vision": { + "provider": "mimo", + "model": "mimo-v2.5-pro" + } + } +} +``` + +## 语音合成 + +```json +{ + "text_to_voice": "mimo", + "text_to_voice_model": "mimo-v2.5-tts", + "tts_voice_id": "冰糖" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `text_to_voice_model` | 当前仅支持 `mimo-v2.5-tts`(预置音色 + 唱歌模式) | +| `tts_voice_id` | 预置音色名(中文音色直接使用中文名作为 ID) | + +### 预置音色 + +| 音色 ID | 说明 | +| --- | --- | +| `冰糖` | 中文 · 女声(默认) | +| `茉莉` | 中文 · 女声 | +| `苏打` | 中文 · 男声 | +| `白桦` | 中文 · 男声 | +| `Mia` | 英文 · 女声 | +| `Chloe` | 英文 · 女声 | +| `Milo` | 英文 · 男声 | +| `Dean` | 英文 · 男声 | + +也可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 + +### 风格控制 + +MiMo TTS 支持在合成文本中嵌入 **音频标签** 来控制情绪、语调、方言、角色甚至唱歌。标签需出现在 **最终被合成为语音的文本(即 Agent 回复内容)** 中,整体风格标签写在开头: + +``` +(风格)待合成内容 +``` + +支持半角 `()`、全角 `()` 或 `[]` 三种括号。常见风格示例: + +| 类型 | 示例标签 | +| --- | --- | +| 基础情绪 | `开心` `悲伤` `愤怒` `恐惧` `惊讶` `兴奋` `委屈` `平静` `冷漠` | +| 复合情绪 | `怅然` `欣慰` `无奈` `愧疚` `释然` `忐忑` `动情` | +| 整体语调 | `温柔` `高冷` `活泼` `严肃` `慵懒` `俏皮` `深沉` `干练` `凌厉` | +| 音色定位 | `磁性` `醇厚` `清亮` `空灵` `稚嫩` `苍老` `甜美` `沙哑` | +| 人设腔调 | `夹子音` `御姐音` `正太音` `大叔音` `台湾腔` | +| 方言 | `东北话` `四川话` `河南话` `粤语` | +| 角色扮演 | `孙悟空` `林黛玉` | +| 唱歌 | `唱歌`(等价于 `sing` / `singing`) | + +示例: + +- (磁性)夜已经深了,城市还在呼吸。 +- (东北话)哎呀妈呀,这天儿也忒冷了吧! +- (粤语)呢个真係好正啊! +- (唱歌)原谅我这一生不羁放纵爱自由… + +也可以在文本任意位置插入细粒度音频标签来控制呼吸、笑声、停顿等,例如: + +``` +(紧张,深呼吸)呼……冷静,冷静。(语速加快)自我介绍我背了五十遍了,应该没问题。 +``` + +完整标签列表参见 [MiMo 语音合成文档](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5)。 + + + CowAgent 在调用 TTS 时会将 Agent 的回复原文(含 `(...)` 标签)直接送入 MiMo 合成。你可以在人设 / 系统提示词里要求模型「在回复开头用 `(风格)` 标签控制语气」,即可让 IM 渠道(微信 / 飞书 / 钉钉 / 企微)的语音回复带上情绪、方言、唱歌等效果。 + diff --git a/docs/zh/README.md b/docs/zh/README.md index db54626e..095e9194 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -104,6 +104,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [豆包 Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [百度ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [小米 MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | | [LinkAI](https://docs.cowagent.ai/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [自定义](https://docs.cowagent.ai/models/custom) | 本地模型 / 三方代理 | ✅ | | | | | | diff --git a/models/bot_factory.py b/models/bot_factory.py index 824aed04..5d07a236 100644 --- a/models/bot_factory.py +++ b/models/bot_factory.py @@ -25,6 +25,10 @@ def create_bot(bot_type): from models.qianfan.qianfan_bot import QianfanBot return QianfanBot() + elif bot_type == const.MIMO: + from models.mimo.mimo_bot import MimoBot + return MimoBot() + elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API from models.chatgpt.chat_gpt_bot import ChatGPTBot return ChatGPTBot() diff --git a/models/mimo/__init__.py b/models/mimo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/models/mimo/mimo_bot.py b/models/mimo/mimo_bot.py new file mode 100644 index 00000000..a815e9f0 --- /dev/null +++ b/models/mimo/mimo_bot.py @@ -0,0 +1,668 @@ +# encoding:utf-8 + +""" +小米 MiMo Bot —— OpenAI 兼容协议,使用独立 API key / base 配置。 + +支持模型: +- mimo-v2.5-pro (旗舰,长上下文,默认开启思考) +- mimo-v2.5 (多模态:文/图/音/视频,默认开启思考) +- mimo-v2-pro (V2 Pro,默认开启思考) +- mimo-v2-omni (V2 多模态,默认开启思考) +- mimo-v2-flash (V2 极速版,默认关闭思考) + +思考模式说明: +- 开关参数:``{"thinking": {"type": "enabled" | "disabled"}}`` +- mimo-v2.5-pro / mimo-v2.5 在思考模式下 ``temperature`` 会被强制为 1.0, + 本地直接剥离 ``temperature`` / ``top_p`` 等参数避免歧义。 +- 多轮工具调用过程中,若历史包含 tool_calls,所有后续 assistant 消息必须回传 + ``reasoning_content``,否则 API 返回 400 错误。 +- 文档:https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content +""" + +import json +import time +from typing import Optional + +import requests + +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 models.bot import Bot +from models.openai_compatible_bot import OpenAICompatibleBot +from models.session_manager import SessionManager +from .mimo_session import MimoSession + +DEFAULT_API_BASE = "https://api.xiaomimimo.com/v1" +DEFAULT_MODEL = const.MIMO_V2_5_PRO + +# 支持多模态输入(图/音/视频)的模型 +MULTIMODAL_MODELS = {const.MIMO_V2_5_PRO, const.MIMO_V2_5, const.MIMO_V2_OMNI} + + +class MimoBot(Bot, OpenAICompatibleBot): + def __init__(self): + super().__init__() + self.sessions = SessionManager( + MimoSession, + model=conf().get("model") or DEFAULT_MODEL, + ) + conf_model = conf().get("model") or DEFAULT_MODEL + self.args = { + "model": conf_model, + "temperature": conf().get("temperature", 1.0), + "top_p": conf().get("top_p", 0.95), + } + + # ---------- config helpers ---------- + + @property + def api_key(self): + return conf().get("mimo_api_key") + + @property + def api_base(self): + url = conf().get("mimo_api_base") or DEFAULT_API_BASE + return url.rstrip("/") + + def get_api_config(self): + """OpenAICompatibleBot 接口 —— 供 call_with_tools() 使用。""" + return { + "api_key": self.api_key, + "api_base": self.api_base, + "model": conf().get("model", DEFAULT_MODEL), + "default_temperature": conf().get("temperature", 1.0), + "default_top_p": conf().get("top_p", 0.95), + } + + @property + def supports_vision(self) -> bool: + """主模型为多模态模型时,允许 vision tool 走主 bot 通道。""" + model_name = (conf().get("model") or "").lower() + return model_name in MULTIMODAL_MODELS + + @staticmethod + def _model_supports_thinking(model_name: str) -> bool: + """全部 mimo 系列模型都支持 thinking 开关。""" + if not model_name: + return False + return model_name.lower().startswith("mimo-") + + @staticmethod + def _thinking_default_enabled(model_name: str) -> bool: + """各模型的思考模式默认值。mimo-v2-flash 默认关闭,其他默认开启。""" + if not model_name: + return False + return model_name.lower() != const.MIMO_V2_FLASH + + def _build_headers(self) -> dict: + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + # ---------- simple chat (non-agent mode) ---------- + + def reply(self, query, context=None): + if context.type == ContextType.TEXT: + logger.info("[MIMO] 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("[MIMO] session query={}".format(session.messages)) + + new_args = self.args.copy() + reply_content = self.reply_text(session, args=new_args) + logger.debug( + "[MIMO] 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("[MIMO] 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 = self._build_headers() + body = dict(args) if args else dict(self.args) + body["messages"] = session.messages + + model_name = str(body.get("model", "")) + # 思考模式下 mimo-v2.5-pro / mimo-v2.5 不支持自定义 temperature/top_p, + # 简单起见,所有支持思考的模型按默认配置走,剥离这些参数。 + if self._model_supports_thinking(model_name) and self._thinking_default_enabled(model_name): + for k in ("temperature", "top_p", "presence_penalty", "frequency_penalty"): + body.pop(k, None) + + 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: + try: + response = res.json() + error = response.get("error", {}) + except Exception: + error = {"message": res.text[:300]} + logger.error( + f"[MIMO] 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": "我现在有点累了,等会再来吧"} + + # ==================== Agent mode support ==================== + + def call_with_tools(self, messages, tools=None, stream: bool = False, **kwargs): + """ + 带工具调用支持的 MiMo API 调用 (供 agent 集成使用)。 + + 处理逻辑: + - Claude 格式 → OpenAI 格式 转换(含 reasoning_content 全量回传) + - System prompt 注入 + - SSE 流式响应(包含 tool_calls 与 reasoning_content 增量) + - 思考模式开关传递 + """ + try: + converted_messages = self._convert_messages_to_openai_format(messages) + + system_prompt = kwargs.pop("system", None) + if system_prompt: + if not converted_messages or converted_messages[0].get("role") != "system": + converted_messages.insert(0, {"role": "system", "content": system_prompt}) + else: + converted_messages[0] = {"role": "system", "content": system_prompt} + + converted_tools = None + if tools: + converted_tools = self._convert_tools_to_openai_format(tools) + + model = kwargs.pop("model", None) or self.args["model"] + max_tokens = kwargs.pop("max_tokens", None) + + request_body = { + "model": model, + "messages": converted_messages, + "stream": stream, + } + if max_tokens is not None: + # MiMo 使用 max_completion_tokens 命名(含可见输出 + 推理 token) + request_body["max_completion_tokens"] = max_tokens + + if converted_tools: + request_body["tools"] = converted_tools + request_body["tool_choice"] = kwargs.pop("tool_choice", "auto") + + # 思考模式:默认遵循各模型的官方默认值;caller 可显式覆盖 + thinking_param = kwargs.pop("thinking", None) + thinking_active = False + + if self._model_supports_thinking(model): + if thinking_param is None: + default_on = self._thinking_default_enabled(model) + thinking_param = {"type": "enabled" if default_on else "disabled"} + request_body["thinking"] = thinking_param + thinking_active = thinking_param.get("type") == "enabled" + + # 思考模式下 v2.5-pro / v2.5 不支持自定义 temperature;干脆全部剥离避免被静默忽略 + if thinking_active: + for k in ("temperature", "top_p", "presence_penalty", "frequency_penalty"): + request_body.pop(k, None) + kwargs.pop(k, None) + else: + temperature = kwargs.pop("temperature", None) + if temperature is not None: + request_body["temperature"] = temperature + top_p = kwargs.pop("top_p", None) + if top_p is not None: + request_body["top_p"] = top_p + + logger.debug( + f"[MIMO] API call: model={model}, " + f"tools={len(converted_tools) if converted_tools else 0}, " + f"stream={stream}, thinking={thinking_active}" + ) + + if stream: + return self._handle_stream_response(request_body) + else: + return self._handle_sync_response(request_body) + + except Exception as e: + logger.error(f"[MIMO] call_with_tools error: {e}") + import traceback + logger.error(traceback.format_exc()) + + def error_generator(): + yield {"error": True, "message": str(e), "status_code": 500} + return error_generator() + + # -------------------- streaming -------------------- + + def _handle_stream_response(self, request_body: dict): + """SSE 流式 chunk 转为 OpenAI 标准 delta 输出(含 reasoning_content)。""" + try: + headers = self._build_headers() + url = f"{self.api_base}/chat/completions" + response = requests.post(url, headers=headers, json=request_body, stream=True, timeout=180) + + if response.status_code != 200: + error_msg = response.text + logger.error(f"[MIMO] API error: status={response.status_code}, msg={error_msg}") + yield {"error": True, "message": error_msg, "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 line.startswith("data: "): + data_str = line[6:] + elif line.startswith("data:"): + data_str = line[5:] + else: + continue + if data_str.strip() == "[DONE]": + break + + try: + chunk = json.loads(data_str) + except json.JSONDecodeError as e: + logger.warning(f"[MIMO] JSON decode error: {e}, data: {data_str[:200]}") + continue + + if chunk.get("error"): + error_data = chunk["error"] + error_msg = error_data.get("message", "Unknown error") if isinstance(error_data, dict) else str(error_data) + logger.error(f"[MIMO] stream error: {error_msg}") + yield {"error": True, "message": error_msg, "status_code": 500} + return + + if not chunk.get("choices"): + continue + choice = chunk["choices"][0] + delta = choice.get("delta", {}) + + if choice.get("finish_reason"): + finish_reason = choice["finish_reason"] + + # 推理内容(思考模式):单独 delta 透传给 agent_stream + if delta.get("reasoning_content"): + yield { + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": delta["reasoning_content"], + }, + "finish_reason": None, + }] + } + + if delta.get("content"): + yield { + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "content": delta["content"], + }, + }] + } + + if "tool_calls" in delta and delta["tool_calls"]: + for tool_call_chunk in delta["tool_calls"]: + index = tool_call_chunk.get("index", 0) + if index not in current_tool_calls: + current_tool_calls[index] = { + "id": tool_call_chunk.get("id", ""), + "name": tool_call_chunk.get("function", {}).get("name", ""), + "arguments": "", + } + if "function" in tool_call_chunk and "arguments" in tool_call_chunk["function"]: + current_tool_calls[index]["arguments"] += tool_call_chunk["function"]["arguments"] + + yield { + "choices": [{ + "index": 0, + "delta": {"tool_calls": [tool_call_chunk]}, + }] + } + + yield { + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason, + }] + } + + except requests.exceptions.Timeout: + logger.error("[MIMO] Request timeout") + yield {"error": True, "message": "Request timeout", "status_code": 500} + except Exception as e: + logger.error(f"[MIMO] stream response error: {e}") + import traceback + logger.error(traceback.format_exc()) + yield {"error": True, "message": str(e), "status_code": 500} + + # -------------------- sync -------------------- + + def _handle_sync_response(self, request_body: dict): + """非流式响应;统一 yield 一份 Claude 格式 dict 与流式路径对齐。""" + try: + headers = self._build_headers() + request_body.pop("stream", None) + url = f"{self.api_base}/chat/completions" + response = requests.post(url, headers=headers, json=request_body, timeout=180) + + if response.status_code != 200: + error_msg = response.text + logger.error(f"[MIMO] API error: status={response.status_code}, msg={error_msg}") + yield {"error": True, "message": error_msg, "status_code": response.status_code} + return + + result = response.json() + message = result["choices"][0]["message"] + finish_reason = result["choices"][0]["finish_reason"] + + response_data = {"role": "assistant", "content": []} + + # 推理内容包装成 thinking block,便于 agent 层持久化并在工具调用时回传 + if message.get("reasoning_content"): + response_data["content"].append({ + "type": "thinking", + "thinking": message["reasoning_content"], + }) + + if message.get("content"): + response_data["content"].append({ + "type": "text", + "text": message["content"], + }) + + if message.get("tool_calls"): + for tool_call in message["tool_calls"]: + try: + tool_input = json.loads(tool_call["function"]["arguments"]) + except (json.JSONDecodeError, TypeError): + tool_input = {} + response_data["content"].append({ + "type": "tool_use", + "id": tool_call["id"], + "name": tool_call["function"]["name"], + "input": tool_input, + }) + + if finish_reason == "tool_calls": + response_data["stop_reason"] = "tool_use" + elif finish_reason == "stop": + response_data["stop_reason"] = "end_turn" + else: + response_data["stop_reason"] = finish_reason + + yield response_data + + except requests.exceptions.Timeout: + logger.error("[MIMO] Request timeout") + yield {"error": True, "message": "Request timeout", "status_code": 500} + except Exception as e: + logger.error(f"[MIMO] sync response error: {e}") + import traceback + logger.error(traceback.format_exc()) + yield {"error": True, "message": str(e), "status_code": 500} + + # -------------------- format conversion -------------------- + + def _convert_messages_to_openai_format(self, messages): + """ + 将 Claude 格式(content blocks)转为 OpenAI 格式。 + + 关键约束:MiMo 思考模式下,一旦历史包含 tool_calls 的 assistant 轮次, + 所有后续 assistant 消息(含工具调用轮)必须回传 reasoning_content, + 否则 API 返回 400。本地无 trace 时用空字符串回填,MiMo 接受字段存在 + 即可。 + """ + if not messages: + return [] + + has_tool_call_history = False + for msg in messages: + if msg.get("role") != "assistant": + continue + if msg.get("tool_calls"): + has_tool_call_history = True + break + content = msg.get("content") + if isinstance(content, list) and any( + isinstance(b, dict) and b.get("type") == "tool_use" for b in content + ): + has_tool_call_history = True + break + + converted = [] + + for msg in messages: + role = msg.get("role") + content = msg.get("content") + + if not isinstance(content, list): + if ( + role == "assistant" + and isinstance(msg, dict) + and has_tool_call_history + and "reasoning_content" not in msg + ): + patched = dict(msg) + patched["reasoning_content"] = "" + converted.append(patched) + else: + converted.append(msg) + continue + + if role == "user": + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" for b in content + ) + if has_tool_result: + text_parts = [] + tool_results = [] + + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_result": + tool_call_id = block.get("tool_use_id") or "" + result_content = block.get("content", "") + if not isinstance(result_content, str): + result_content = json.dumps(result_content, ensure_ascii=False) + tool_results.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": result_content, + }) + + converted.extend(tool_results) + + if text_parts: + converted.append({"role": "user", "content": "\n".join(text_parts)}) + else: + # 多模态原样保留(image_url / input_audio / video_url 等 block) + converted.append(msg) + + elif role == "assistant": + openai_msg = {"role": "assistant"} + text_parts = [] + tool_calls = [] + reasoning_parts = [] + + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "text": + text_parts.append(block.get("text", "")) + elif btype == "tool_use": + tool_calls.append({ + "id": block.get("id"), + "type": "function", + "function": { + "name": block.get("name"), + "arguments": json.dumps(block.get("input", {})), + }, + }) + elif btype == "thinking": + reasoning_parts.append(block.get("thinking", "")) + + if text_parts: + openai_msg["content"] = "\n".join(text_parts) + elif not tool_calls: + openai_msg["content"] = "" + + if tool_calls: + openai_msg["tool_calls"] = tool_calls + if not text_parts: + openai_msg["content"] = None + + if reasoning_parts: + openai_msg["reasoning_content"] = "\n".join(reasoning_parts) + elif has_tool_call_history: + openai_msg["reasoning_content"] = "" + + converted.append(openai_msg) + else: + converted.append(msg) + + return converted + + def _convert_tools_to_openai_format(self, tools): + """工具定义 Claude 格式 → OpenAI 格式。""" + 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 + + # -------------------- vision -------------------- + + def call_vision(self, image_url: str, question: str, + model: Optional[str] = None, + max_tokens: int = 1000) -> dict: + """通过 MiMo OpenAI 兼容的 /chat/completions 端点进行图像理解。""" + try: + # 主模型若不支持视觉(如 mimo-v2-flash),自动切到 mimo-v2.5-pro + vision_model = model + if not vision_model: + cur = self.args.get("model") or DEFAULT_MODEL + vision_model = cur if cur in MULTIMODAL_MODELS else const.MIMO_V2_5_PRO + + payload = { + "model": vision_model, + "max_completion_tokens": max_tokens, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": question}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + }], + } + headers = self._build_headers() + resp = requests.post( + f"{self.api_base}/chat/completions", + headers=headers, json=payload, timeout=60, + ) + if resp.status_code != 200: + return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} + data = resp.json() + if "error" in data: + return {"error": True, "message": data["error"].get("message", str(data["error"]))} + choice = data.get("choices", [{}])[0].get("message", {}) + # 部分模型在多模态下会把答案塞在 reasoning_content 而非 content + content = choice.get("content") or choice.get("reasoning_content") or "" + usage = data.get("usage", {}) + return { + "model": vision_model, + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + except Exception as e: + logger.error(f"[MIMO] call_vision error: {e}") + return {"error": True, "message": str(e)} diff --git a/models/mimo/mimo_session.py b/models/mimo/mimo_session.py new file mode 100644 index 00000000..76483f11 --- /dev/null +++ b/models/mimo/mimo_session.py @@ -0,0 +1,57 @@ +from common.log import logger +from models.session_manager import Session + + +class MimoSession(Session): + def __init__(self, session_id, system_prompt=None, model="mimo-v2.5-pro"): + 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 diff --git a/voice/factory.py b/voice/factory.py index 3be60bbf..2bc356f4 100644 --- a/voice/factory.py +++ b/voice/factory.py @@ -66,4 +66,8 @@ def create_voice(voice_type): from voice.zhipuai.zhipuai_voice import ZhipuAIVoice return ZhipuAIVoice() + elif voice_type == "mimo": + from voice.mimo.mimo_voice import MimoVoice + + return MimoVoice() raise RuntimeError diff --git a/voice/mimo/__init__.py b/voice/mimo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice/mimo/mimo_voice.py b/voice/mimo/mimo_voice.py new file mode 100644 index 00000000..2ae885f8 --- /dev/null +++ b/voice/mimo/mimo_voice.py @@ -0,0 +1,109 @@ +# encoding:utf-8 +""" +小米 MiMo TTS - 基于 mimo-v2.5-tts 模型的语音合成。 + +通过 /chat/completions 接口实现:assistant 消息内容为待合成文本, +audio 字段指定预置音色(如 冰糖/茉莉/苏打/Mia/Chloe 等),返回 base64 +编码的音频字节。 + +文档:https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5 +注意:MiMo 不提供 ASR 端点,因此 voiceToText 不实现。 +""" +import base64 +import datetime +import os +import random + +import requests + +from bridge.reply import Reply, ReplyType +from common.log import logger +from config import conf +from voice.voice import Voice + +DEFAULT_API_BASE = "https://api.xiaomimimo.com/v1" +DEFAULT_TTS_MODEL = "mimo-v2.5-tts" +DEFAULT_TTS_VOICE = "冰糖" # 默认音色:中国集群事实默认值 +REQUEST_TIMEOUT = (5, 120) + + +class MimoVoice(Voice): + def __init__(self): + pass + + def voiceToText(self, voice_file: str): + # MiMo 没有独立 ASR 端点;建议使用其他 provider(如 openai/zhipu/dashscope) + logger.warning("[MimoVoice] voiceToText is not supported by MiMo API") + return Reply(ReplyType.ERROR, "MiMo 暂不支持语音识别,请配置其他 voice_to_text provider") + + def textToVoice(self, text: str): + try: + api_key = conf().get("mimo_api_key", "") + if not api_key: + logger.error("[MimoVoice] mimo_api_key is not configured") + return Reply(ReplyType.ERROR, "未配置 MiMo API key") + + api_base = (conf().get("mimo_api_base") or DEFAULT_API_BASE).rstrip("/") + model = conf().get("text_to_voice_model") or DEFAULT_TTS_MODEL + voice_id = conf().get("tts_voice_id") or DEFAULT_TTS_VOICE + + # 目标合成文本必须放在 assistant 消息;user 消息可选用作风格指令 + payload = { + "model": model, + "messages": [ + {"role": "assistant", "content": text}, + ], + "audio": { + "format": "wav", + "voice": voice_id, + }, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + url = f"{api_base}/chat/completions" + response = requests.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT) + + if response.status_code != 200: + logger.error( + f"[MimoVoice] textToVoice failed: status={response.status_code} " + f"body={response.text[:500]} model={model} voice={voice_id}" + ) + return Reply(ReplyType.ERROR, "语音合成失败,请稍后再试") + + data = response.json() + if "error" in data: + err = data["error"] + msg = err.get("message", str(err)) if isinstance(err, dict) else str(err) + logger.error(f"[MimoVoice] textToVoice api error: {msg}") + return Reply(ReplyType.ERROR, "语音合成失败,请稍后再试") + + message = (data.get("choices") or [{}])[0].get("message", {}) or {} + audio_obj = message.get("audio") or {} + audio_b64 = audio_obj.get("data") + if not audio_b64: + logger.error(f"[MimoVoice] textToVoice empty audio in response: {data}") + return Reply(ReplyType.ERROR, "语音合成失败,请稍后再试") + + try: + audio_bytes = base64.b64decode(audio_b64) + except Exception as e: + logger.error(f"[MimoVoice] base64 decode failed: {e}") + return Reply(ReplyType.ERROR, "语音合成失败,请稍后再试") + + file_name = ( + "tmp/" + datetime.datetime.now().strftime("%Y%m%d%H%M%S") + + str(random.randint(0, 1000)) + ".wav" + ) + os.makedirs(os.path.dirname(file_name), exist_ok=True) + with open(file_name, "wb") as f: + f.write(audio_bytes) + logger.info( + f"[MimoVoice] textToVoice model={model} voice={voice_id} " + f"file={file_name} bytes={len(audio_bytes)}" + ) + return Reply(ReplyType.VOICE, file_name) + except Exception as e: + logger.exception(f"[MimoVoice] textToVoice exception: {e}") + return Reply(ReplyType.ERROR, "语音合成失败,请稍后再试") From d106465419c7ee5afa1676a8df43685665db7860 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 28 May 2026 12:10:00 +0800 Subject: [PATCH 274/399] feat(channel): telegram first version --- channel/channel_factory.py | 3 + channel/telegram/__init__.py | 0 channel/telegram/telegram_channel.py | 667 +++++++++++++++++++++++++++ channel/telegram/telegram_message.py | 62 +++ channel/web/web_channel.py | 9 + common/const.py | 1 + config.py | 7 +- docs/channels/index.mdx | 2 + docs/channels/telegram.mdx | 113 +++++ docs/docs.json | 3 +- requirements.txt | 2 + 11 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 channel/telegram/__init__.py create mode 100644 channel/telegram/telegram_channel.py create mode 100644 channel/telegram/telegram_message.py create mode 100644 docs/channels/telegram.mdx diff --git a/channel/channel_factory.py b/channel/channel_factory.py index cf6bfea8..fd8cf0e8 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -39,6 +39,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == const.QQ: from channel.qq.qq_channel import QQChannel ch = QQChannel() + elif channel_type == const.TELEGRAM: + from channel.telegram.telegram_channel import TelegramChannel + ch = TelegramChannel() elif channel_type in (const.WEIXIN, "wx"): from channel.weixin.weixin_channel import WeixinChannel ch = WeixinChannel() diff --git a/channel/telegram/__init__.py b/channel/telegram/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py new file mode 100644 index 00000000..37e9d9fa --- /dev/null +++ b/channel/telegram/telegram_channel.py @@ -0,0 +1,667 @@ +""" +Telegram channel via Bot API (long polling mode). + +Features: +- Single chat & group chat (text / photo / voice / video / document) +- Group trigger: @mention or reply-to-bot (configurable) +- /cancel fast-path matches Web channel behaviour +- Auto-register bot commands menu on startup (mirrors Web slash menu) +- Optional HTTP/SOCKS5 proxy support for restricted networks + +Implementation note: + python-telegram-bot is async-first. We run the bot inside a dedicated + thread with its own asyncio loop so the rest of cow (which is sync) + stays untouched. Inbound updates are dispatched onto cow's existing + sync ChatChannel.produce() pipeline; outbound send() schedules + coroutines back onto that loop via asyncio.run_coroutine_threadsafe. +""" + +import asyncio +import os +import re +import threading + +from bridge.context import Context, ContextType +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel, check_prefix +from channel.telegram.telegram_message import TelegramMessage +from common.expired_dict import ExpiredDict +from common.log import logger +from common.singleton import singleton +from config import conf + +# Bot command menu, aligned with Web slash commands. +# Top-level commands only; sub-commands are entered with a space (e.g. "/skill list"). +TELEGRAM_BOT_COMMANDS = [ + ("help", "Show command help"), + ("status", "Show running status"), + ("context", "View/clear conversation context (sub: clear)"), + ("skill", "Manage skills (list/search/install/...)"), + ("memory", "Manage memory (sub: dream)"), + ("knowledge", "Manage knowledge base (list/on/off)"), + ("config", "Show current config"), + ("cancel", "Cancel running agent task"), + ("logs", "Show recent logs"), + ("version", "Show version"), +] + + +@singleton +class TelegramChannel(ChatChannel): + NOT_SUPPORT_REPLYTYPE = [] + + def __init__(self): + super().__init__() + self.bot_token = "" + self.bot_username = "" # used for @-mention matching + self._bot = None + self._application = None + self._loop = None + self._loop_thread = None + self._stop_event = threading.Event() + # Idempotent dedup; TG occasionally redelivers the same update on flaky networks + self._received_msgs = ExpiredDict(60 * 60 * 1) + + # Disable group whitelist / prefix checks (we handle triggering ourselves + # in _should_reply_in_group), aligned with feishu / wecom_bot channels. + conf()["group_name_white_list"] = ["ALL_GROUP"] + conf()["single_chat_prefix"] = [""] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def startup(self): + self.bot_token = conf().get("telegram_token", "") + if not self.bot_token: + err = "[Telegram] telegram_token is required" + logger.error(err) + self.report_startup_error(err) + return + + try: + from telegram.ext import ( + Application, + MessageHandler, + CommandHandler, + filters, + ) + except ImportError: + err = ( + "[Telegram] python-telegram-bot is not installed. " + "Run: pip install python-telegram-bot" + ) + logger.error(err) + self.report_startup_error(err) + return + + # Run the asyncio event loop in a dedicated thread so the sync cow body + # is untouched. + self._loop = asyncio.new_event_loop() + + def _run_loop(): + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._async_main(Application, MessageHandler, CommandHandler, filters)) + except Exception as e: + logger.error(f"[Telegram] event loop crashed: {e}", exc_info=True) + self.report_startup_error(str(e)) + finally: + try: + self._loop.close() + except Exception: + pass + logger.info("[Telegram] event loop exited") + + self._loop_thread = threading.Thread(target=_run_loop, daemon=True, name="telegram-loop") + self._loop_thread.start() + # Block startup() until the loop thread exits, matching other channels' + # behaviour (startup is a blocking call). + self._loop_thread.join() + + async def _async_main(self, Application, MessageHandler, CommandHandler, filters): + """Build Application, register handlers, and run polling.""" + builder = Application.builder().token(self.bot_token) + + # Proxy: prefer telegram_proxy config, fall back to HTTPS_PROXY env var + proxy_url = conf().get("telegram_proxy", "") or os.environ.get("HTTPS_PROXY", "") + if proxy_url: + try: + builder = builder.proxy(proxy_url).get_updates_proxy(proxy_url) + logger.info(f"[Telegram] using proxy: {proxy_url}") + except Exception as e: + logger.warning(f"[Telegram] proxy config failed, fallback to direct: {e}") + + # Media uploads (photo/voice/video/document) over a proxy can be slow, + # bump read/write/connect/pool timeouts. + builder = ( + builder + .read_timeout(60) + .write_timeout(120) + .connect_timeout(30) + .pool_timeout(30) + ) + + application = builder.build() + self._application = application + self._bot = application.bot + + # Fetch our own username (needed for @-mention matching in groups) + try: + me = await self._bot.get_me() + self.bot_username = me.username or "" + self.name = self.bot_username # ChatChannel uses self.name to strip @-mention + logger.info(f"[Telegram] Bot logged in as @{self.bot_username} (id={me.id})") + except Exception as e: + err = f"[Telegram] get_me failed: {e}" + logger.error(err) + self.report_startup_error(err) + return + + # Register the command menu (failure is non-fatal) + if conf().get("telegram_register_commands", True): + try: + from telegram import BotCommand + cmds = [BotCommand(name, desc) for name, desc in TELEGRAM_BOT_COMMANDS] + await self._bot.set_my_commands(cmds) + logger.info(f"[Telegram] Registered {len(cmds)} bot commands") + except Exception as e: + logger.warning(f"[Telegram] set_my_commands failed: {e}") + + # Handlers: + # 1) /cancel uses the fast-path + application.add_handler(CommandHandler("cancel", self._on_cancel)) + # 2) Normal messages (text + media) + application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._on_message)) + # 3) Other slash commands are forwarded as plain text for the agent to handle + application.add_handler(MessageHandler(filters.COMMAND, self._on_command_passthrough)) + + # Start polling. drop_pending_updates avoids replaying backlog after restart. + logger.info("[Telegram] Starting long polling...") + await application.initialize() + await application.start() + await application.updater.start_polling(drop_pending_updates=True) + self.report_startup_success() + logger.info("[Telegram] ✅ Telegram bot ready, polling for updates") + + # Block until stop() + try: + while not self._stop_event.is_set(): + await asyncio.sleep(0.5) + finally: + try: + await application.updater.stop() + await application.stop() + await application.shutdown() + except Exception as e: + logger.warning(f"[Telegram] shutdown error: {e}") + + def stop(self): + logger.info("[Telegram] stop() called") + self._stop_event.set() + if self._loop_thread and self._loop_thread.is_alive(): + try: + self._loop_thread.join(timeout=10) + except Exception: + pass + logger.info("[Telegram] stop() completed") + + # ------------------------------------------------------------------ + # Inbound: telegram update -> ChatMessage -> ChatChannel.produce + # ------------------------------------------------------------------ + + async def _on_cancel(self, update, _context): + """Fast-path: /cancel calls cancel_session directly without going through agent.""" + try: + from agent.protocol import get_cancel_registry + session_id = self._compute_session_id(update) + cancelled = get_cancel_registry().cancel_session(session_id) + text = "Current task cancelled." if cancelled else "No running task to cancel." + await update.effective_message.reply_text(text) + logger.info(f"[Telegram] /cancel session={session_id}, cancelled={cancelled}") + except Exception as e: + logger.error(f"[Telegram] /cancel error: {e}", exc_info=True) + try: + await update.effective_message.reply_text(f"⚠️ /cancel failed: {e}") + except Exception: + pass + + async def _on_command_passthrough(self, update, _context): + """All non-/cancel commands fall through to plain message handling.""" + await self._on_message(update, _context) + + async def _on_message(self, update, _context): + """Telegram update entry: parse message -> build ChatMessage -> produce().""" + try: + message = update.effective_message + chat = update.effective_chat + if not message or not chat: + return + + # Idempotent dedup + msg_uid = f"{chat.id}:{message.message_id}" + if self._received_msgs.get(msg_uid): + return + self._received_msgs[msg_uid] = True + + is_group = chat.type in ("group", "supergroup") + + # Group trigger gate (silently drop if not triggered) + if is_group and not self._should_reply_in_group(update): + return + + # Parse message type + download media if needed. + # Media messages with caption return both the local path and the caption text. + ctype, content, caption = await self._parse_message(message) + if ctype is None: + logger.debug(f"[Telegram] unsupported message type, skip. msg={message}") + return + + # Strip @bot mention for group text/caption + if is_group and self.bot_username: + if ctype == ContextType.TEXT and content: + content = self._strip_at_mention(content) + if caption: + caption = self._strip_at_mention(caption) + + tg_msg = TelegramMessage( + update, + is_group=is_group, + bot_username=self.bot_username, + ctype=ctype, + content=content, + ) + tg_msg.is_at = is_group # If we got here in a group, the bot is mentioned/replied + + # File cache: standalone media goes into cache, the next text query attaches them + from channel.file_cache import get_file_cache + file_cache = get_file_cache() + session_id = self._compute_session_id(update) + + # Media + caption together: treat as a complete query and bypass the cache + if ctype in (ContextType.IMAGE, ContextType.FILE) and caption: + tag = "image" if ctype == ContextType.IMAGE else "file" + merged_text = f"{caption}\n[{tag}: {content}]" + tg_msg.ctype = ContextType.TEXT + tg_msg.content = merged_text + ctype = ContextType.TEXT + logger.info(f"[Telegram] Media+caption merged for session {session_id}") + # fallthrough to the TEXT branch below + + elif ctype == ContextType.IMAGE: + file_cache.add(session_id, content, file_type="image") + logger.info(f"[Telegram] Image cached for session {session_id}, waiting for query...") + return + elif ctype == ContextType.FILE: + file_cache.add(session_id, content, file_type="file") + logger.info(f"[Telegram] File cached for session {session_id}: {content}") + return + + if ctype == ContextType.TEXT: + cached_files = file_cache.get(session_id) + if cached_files: + refs = [] + for fi in cached_files: + ftype = fi["type"] + tag = ftype if ftype in ("image", "video") else "file" + refs.append(f"[{tag}: {fi['path']}]") + tg_msg.content = (tg_msg.content or "") + "\n" + "\n".join(refs) + file_cache.clear(session_id) + logger.info(f"[Telegram] Attached {len(cached_files)} cached file(s) to query") + + # Dispatch to cow main pipeline (reuses ChatChannel._compose_context routing) + context = self._compose_context( + tg_msg.ctype, + tg_msg.content, + isgroup=is_group, + msg=tg_msg, + ) + if context: + context["session_id"] = session_id + context["receiver"] = str(chat.id) + context["telegram_chat_id"] = chat.id + context["telegram_reply_to_msg_id"] = message.message_id if is_group else None + self.produce(context) + logger.debug(f"[Telegram] received: type={ctype}, content={str(tg_msg.content)[:80]}") + + except Exception as e: + logger.error(f"[Telegram] _on_message error: {e}", exc_info=True) + + async def _parse_message(self, message): + """Parse a telegram message and return (ctype, content, caption). + + - content is text for ContextType.TEXT, otherwise the local file path + - caption is the optional text accompanying a media message; empty for plain text + """ + caption = (message.caption or "").strip() + + if message.photo: + largest = message.photo[-1] + path = await self._download_file(largest.file_id, suffix=".jpg") + return (ContextType.IMAGE, path, caption) if path else (None, None, "") + + if message.voice or message.audio: + audio_obj = message.voice or message.audio + suffix = ".ogg" if message.voice else ( + "." + (audio_obj.mime_type.split("/")[-1] if getattr(audio_obj, "mime_type", "") else "mp3") + ) + path = await self._download_file(audio_obj.file_id, suffix=suffix) + return (ContextType.VOICE, path, caption) if path else (None, None, "") + + if message.video or message.video_note: + video_obj = message.video or message.video_note + path = await self._download_file(video_obj.file_id, suffix=".mp4") + return (ContextType.FILE, path, caption) if path else (None, None, "") + + if message.document: + doc = message.document + ext = "" + if doc.file_name and "." in doc.file_name: + ext = "." + doc.file_name.rsplit(".", 1)[-1] + path = await self._download_file(doc.file_id, suffix=ext, original_name=doc.file_name) + if not path: + return (None, None, "") + # Image-typed documents (user picked "send as file") are treated as images + mime = (doc.mime_type or "").lower() + if mime.startswith("image/"): + return (ContextType.IMAGE, path, caption) + return (ContextType.FILE, path, caption) + + if message.text: + return (ContextType.TEXT, message.text.strip(), "") + + return (None, None, "") + + async def _download_file(self, file_id: str, suffix: str = "", original_name: str = ""): + """Download via bot.get_file into the local tmp dir; return path or None on failure.""" + try: + f = await self._bot.get_file(file_id) + tmp_dir = TelegramMessage.get_tmp_dir() + base = original_name or f"{file_id}{suffix or ''}" + # Prefix with file_id to avoid name collisions / weird chars + safe_name = f"{file_id}_{base}" if original_name else base + local_path = os.path.join(tmp_dir, safe_name) + await f.download_to_drive(custom_path=local_path) + logger.debug(f"[Telegram] downloaded file_id={file_id} -> {local_path}") + return local_path + except Exception as e: + logger.error(f"[Telegram] download_file failed (file_id={file_id}): {e}") + return None + + # ------------------------------------------------------------------ + # Group trigger logic + # ------------------------------------------------------------------ + + def _should_reply_in_group(self, update) -> bool: + """Decide whether to reply to a group message based on configuration.""" + mode = conf().get("telegram_group_trigger", "mention_or_reply") + if mode == "all": + return True + + message = update.effective_message + if not message: + return False + + # 1) Mentioned + if self.bot_username and self._is_mentioned(message, self.bot_username): + return True + + # 2) Reply to a bot message + if mode == "mention_or_reply": + reply = message.reply_to_message + if reply and reply.from_user and reply.from_user.username == self.bot_username: + return True + + return False + + @staticmethod + def _is_mentioned(message, bot_username: str) -> bool: + """Check whether entities/caption_entities contain a @mention of the bot.""" + bot_at = "@" + bot_username.lower() + text = (message.text or message.caption or "").lower() + if bot_at in text: + return True + # Also check entities strictly to support text_mention (no-username @) + for ent in (message.entities or []) + (message.caption_entities or []): + if ent.type == "mention": + src = message.text or message.caption or "" + if src[ent.offset: ent.offset + ent.length].lower() == bot_at: + return True + return False + + def _strip_at_mention(self, content: str) -> str: + """Strip @bot_username from group text (case-insensitive).""" + if not content or not self.bot_username: + return content + pattern = re.compile(r"@" + re.escape(self.bot_username), re.IGNORECASE) + return pattern.sub("", content).strip() + + @staticmethod + def _compute_session_id(update) -> str: + chat = update.effective_chat + user = update.effective_user + is_group = chat.type in ("group", "supergroup") + if is_group: + if conf().get("group_shared_session", True): + return f"tg_group_{chat.id}" + return f"tg_group_{chat.id}_{user.id}" + return f"tg_user_{user.id}" + + # ------------------------------------------------------------------ + # Override _compose_context: skip the parent's group whitelist/at checks + # (already handled in _on_message via _should_reply_in_group). Same idea + # as the feishu channel. + # ------------------------------------------------------------------ + + 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"] + if cmsg.is_group: + if conf().get("group_shared_session", True): + context["session_id"] = cmsg.other_user_id + else: + context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}" + else: + 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 or "").strip() + if "desire_rtype" not in context and conf().get("always_reply_voice"): + context["desire_rtype"] = ReplyType.VOICE + elif ctype == ContextType.VOICE: + if "desire_rtype" not in context and ( + conf().get("voice_reply_voice") or conf().get("always_reply_voice") + ): + context["desire_rtype"] = ReplyType.VOICE + + return context + + # ------------------------------------------------------------------ + # Outbound: ChatChannel.send -> Telegram API + # ------------------------------------------------------------------ + + def send(self, reply: Reply, context: Context): + """Called from cow's sync main thread; we marshal the coroutine onto the loop thread.""" + if self._loop is None or self._bot is None: + logger.warning("[Telegram] bot not ready, drop reply") + return + + chat_id = context.get("telegram_chat_id") + reply_to = context.get("telegram_reply_to_msg_id") + if chat_id is None: + logger.warning("[Telegram] no telegram_chat_id in context, drop reply") + return + + coro = self._async_send(reply, chat_id, reply_to) + try: + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + # Media uploads through a proxy can be slow; let PTB's own timeouts win + future.result(timeout=180) + except Exception as e: + logger.error(f"[Telegram] send failed: {e}") + + # Number of retries for transient network errors (proxy hiccups etc.) + _SEND_RETRIES = 2 + _SEND_RETRY_BACKOFF = 2.0 # seconds + + async def _send_with_retry(self, send_fn, *, label: str): + """Run a single Telegram API call with retries for transient network errors.""" + from telegram.error import NetworkError, TimedOut + last_err = None + for attempt in range(self._SEND_RETRIES + 1): + try: + return await send_fn() + except (NetworkError, TimedOut) as e: + last_err = e + if attempt >= self._SEND_RETRIES: + break + wait = self._SEND_RETRY_BACKOFF * (attempt + 1) + logger.warning( + f"[Telegram] {label} transient error (attempt {attempt + 1}/" + f"{self._SEND_RETRIES + 1}): {e}; retry in {wait}s" + ) + await asyncio.sleep(wait) + raise last_err + + async def _async_send(self, reply: Reply, chat_id, reply_to_msg_id): + try: + rtype = reply.type + content = reply.content + + if rtype == ReplyType.TEXT or rtype == ReplyType.INFO or rtype == ReplyType.ERROR: + # Telegram caps a single text message at 4096 chars; auto-split + text = str(content) if content is not None else "" + if not text: + return + for chunk in _split_text(text, 4000): + await self._send_with_retry( + lambda c=chunk: self._bot.send_message( + chat_id=chat_id, + text=c, + reply_to_message_id=reply_to_msg_id, + # Avoid failing the whole send if reply_to was deleted + allow_sending_without_reply=True, + ), + label="send_message", + ) + + elif rtype == ReplyType.IMAGE: + # Already a local BytesIO; send it directly + content.seek(0) + await self._send_with_retry( + lambda: self._bot.send_photo( + chat_id=chat_id, + photo=content, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ), + label="send_photo", + ) + + elif rtype == ReplyType.IMAGE_URL: + url = str(content) + if url.startswith("file://"): + local = url[7:] + # Open inside the lambda so each retry gets a fresh stream + async def _send_local_photo(): + with open(local, "rb") as f: + return await self._bot.send_photo( + chat_id=chat_id, photo=f, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + await self._send_with_retry(_send_local_photo, label="send_photo(file)") + else: + await self._send_with_retry( + lambda: self._bot.send_photo( + chat_id=chat_id, photo=url, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ), + label="send_photo(url)", + ) + + elif rtype == ReplyType.VOICE: + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + async def _send_voice(): + with open(local, "rb") as f: + return await self._bot.send_voice( + chat_id=chat_id, voice=f, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + await self._send_with_retry(_send_voice, label="send_voice") + + elif rtype == ReplyType.FILE: + # Videos go through send_video, everything else through send_document + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + # File replies may carry an accompanying text caption + caption = getattr(reply, "text_content", None) or None + is_video = isinstance(local, str) and local.lower().endswith( + (".mp4", ".mov", ".avi", ".mkv", ".webm") + ) + + async def _send_file(): + with open(local, "rb") as f: + if is_video: + return await self._bot.send_video( + chat_id=chat_id, video=f, caption=caption, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + return await self._bot.send_document( + chat_id=chat_id, document=f, caption=caption, + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ) + await self._send_with_retry(_send_file, label="send_video" if is_video else "send_document") + + else: + # Fallback: send as plain text + await self._send_with_retry( + lambda: self._bot.send_message( + chat_id=chat_id, text=str(content), + reply_to_message_id=reply_to_msg_id, + allow_sending_without_reply=True, + ), + label="send_message(fallback)", + ) + + logger.info(f"[Telegram] sent reply (type={rtype}, chat_id={chat_id})") + + except Exception as e: + logger.error(f"[Telegram] _async_send error: {e}", exc_info=True) + + +def _split_text(text: str, limit: int): + """Split long text preferring line breaks to keep markdown structure intact.""" + if len(text) <= limit: + yield text + return + buf = [] + size = 0 + for line in text.splitlines(keepends=True): + if size + len(line) > limit and buf: + yield "".join(buf) + buf, size = [], 0 + # Hard-split single lines that exceed the limit + while len(line) > limit: + yield line[:limit] + line = line[limit:] + buf.append(line) + size += len(line) + if buf: + yield "".join(buf) diff --git a/channel/telegram/telegram_message.py b/channel/telegram/telegram_message.py new file mode 100644 index 00000000..c97c6059 --- /dev/null +++ b/channel/telegram/telegram_message.py @@ -0,0 +1,62 @@ +""" +Telegram message adapter. + +Convert a python-telegram-bot Update into cow's unified ChatMessage. +File downloads are NOT performed here; the channel layer triggers +bot.get_file() on demand because it requires the async event loop. +""" +import os + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.utils import expand_path +from config import conf + + +class TelegramMessage(ChatMessage): + """Wrap a Telegram Update into the unified ChatMessage.""" + + def __init__(self, update, is_group: bool = False, bot_username: str = "", + ctype: ContextType = ContextType.TEXT, content: str = ""): + super().__init__(update) + message = update.effective_message + chat = update.effective_chat + user = update.effective_user + + # Basic fields + self.msg_id = str(message.message_id) if message else "" + self.create_time = int(message.date.timestamp()) if message and message.date else 0 + self.ctype = ctype + self.content = content + + # Sender / chat info + from_user_id = str(user.id) if user else "unknown" + from_user_nick = ( + user.full_name if user and user.full_name else (user.username if user else "unknown") + ) + self.from_user_id = from_user_id + self.from_user_nickname = from_user_nick or from_user_id + self.to_user_id = bot_username or "telegram_bot" + self.to_user_nickname = bot_username or "telegram_bot" + + self.is_group = is_group + if is_group: + # Group: other_user_id = group_id, actual_user_id = sender id + self.other_user_id = str(chat.id) + self.other_user_nickname = chat.title or str(chat.id) + self.actual_user_id = from_user_id + self.actual_user_nickname = self.from_user_nickname + else: + self.other_user_id = from_user_id + self.other_user_nickname = self.from_user_nickname + + # Whether the bot was triggered by @-mention or reply (set by channel layer) + self.is_at = False + + @staticmethod + def get_tmp_dir() -> str: + """Local download directory, aligned with other channels (agent_workspace/tmp).""" + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + tmp_dir = os.path.join(workspace_root, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + return tmp_dir diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index af4e241e..04bd70a2 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2909,6 +2909,15 @@ class ChannelsHandler: {"key": "wechatmp_port", "label": "Port", "type": "number", "default": 8080}, ], }), + ("telegram", { + "label": {"zh": "Telegram", "en": "Telegram"}, + "icon": "fa-paper-plane", + "color": "sky", + "fields": [ + {"key": "telegram_token", "label": "Bot Token", "type": "secret"}, + {"key": "telegram_proxy", "label": "Proxy (optional)", "type": "text"}, + ], + }), ]) @staticmethod diff --git a/common/const.py b/common/const.py index 7addd6af..4daea457 100644 --- a/common/const.py +++ b/common/const.py @@ -243,3 +243,4 @@ DINGTALK = "dingtalk" WECOM_BOT = "wecom_bot" QQ = "qq" WEIXIN = "weixin" +TELEGRAM = "telegram" diff --git a/config.py b/config.py index 1d44dcc5..cf8f43fe 100644 --- a/config.py +++ b/config.py @@ -166,6 +166,11 @@ available_setting = { # 企微智能机器人配置(长连接模式) "wecom_bot_id": "", # 企微智能机器人BotID "wecom_bot_secret": "", # 企微智能机器人长连接Secret + # Telegram 配置 + "telegram_token": "", # 从 @BotFather 申请的 bot token + "telegram_proxy": "", # 可选的 HTTP/SOCKS5 代理,例如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080(留空则走系统环境变量) + "telegram_group_trigger": "mention_or_reply", # 群聊触发方式: mention_or_reply(@或回复触发,推荐) | mention_only(仅@) | all(所有消息) + "telegram_register_commands": True, # 启动时是否自动向 BotFather 注册命令菜单(与 web 端 slash 命令一致) # 微信配置 "weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录 "weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL @@ -174,7 +179,7 @@ available_setting = { # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 - "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app + "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,telegram "web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用 "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app "debug": False, # 是否开启debug模式,开启后会打印更多日志 diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 9049a51d..5d3fffdc 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -19,6 +19,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 | [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | - **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 - **群聊**列指可识别并响应群消息 @@ -37,3 +38,4 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 - [QQ](/channels/qq) — QQ 官方机器人开放平台 - [企业微信应用](/channels/wecom) — 企业微信自建应用接入 - [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号) +- [Telegram](/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP diff --git a/docs/channels/telegram.mdx b/docs/channels/telegram.mdx new file mode 100644 index 00000000..a05240a9 --- /dev/null +++ b/docs/channels/telegram.mdx @@ -0,0 +1,113 @@ +--- +title: Telegram +description: 将 CowAgent 接入 Telegram Bot +--- + +> 通过 Telegram Bot API 接入 CowAgent,支持单聊与群聊(@机器人 / 回复机器人触发),使用 Long Polling 模式无需公网 IP,开箱即用。 + + +## 一、接入步骤 + +### 步骤一:通过 BotFather 创建 Bot + +1. 在 Telegram 中搜索并打开官方账号 [@BotFather](https://t.me/BotFather)。 +2. 发送 `/newbot` 命令,按提示输入: + - **Bot 名称**(显示名,可中文,例如 `My CowAgent Bot`) + - **Bot 用户名**(必须以 `bot` 结尾,例如 `my_cowagent_bot`) +3. 创建成功后,BotFather 会返回一段 **HTTP API Token**(形如 `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`),妥善保存。 + + + 这个 Token 等同于 Bot 的密码,请勿泄露。如果意外泄漏可向 `@BotFather` 发送 `/revoke` 重置。 + + +### 步骤二:(可选)配置群聊权限 + +Telegram Bot 默认开启 **Privacy Mode**,在群聊中只能收到 `@bot` 或 `/command` 消息。这正好是 CowAgent 期望的行为,**无需修改**。 + +如果你希望机器人接收群里所有消息(不推荐,会触发频次飙升),向 `@BotFather` 发送 `/setprivacy` → 选择对应 bot → `Disable`。 + +### 步骤三:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Telegram**,填入 Bot Token(必要时填写代理),点击接入即可。 + + + 若运行环境无法直接访问 `api.telegram.org`,需在「Proxy」字段填写代理地址,例如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`。 + + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_proxy": "", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `telegram_token` | BotFather 返回的 HTTP API Token | - | + | `telegram_proxy` | 代理地址(如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`),留空则走系统环境变量 `HTTPS_PROXY` | `""` | + | `telegram_group_trigger` | 群聊触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + | `telegram_register_commands` | 启动时是否自动向 BotFather 注册命令菜单 | `true` | + + + 若运行环境无法直接访问 `api.telegram.org`,需配置 `telegram_proxy`,或为运行进程设置 `HTTPS_PROXY` 环境变量。 + + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 单聊 | ✅ | +| 群聊(@机器人 / 回复机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 语音消息 | ✅ 收发(接收 OGG/Opus,发送 OGG/Opus) | +| 视频消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| 命令菜单 | ✅ 与 Web 控制台 slash 命令一致 | + +### 命令菜单 + +启动时会自动向 BotFather 注册命令菜单,用户在 Telegram 输入框输入 `/` 会出现下拉提示: + +| 命令 | 说明 | +| --- | --- | +| `/help` | 显示命令帮助 | +| `/status` | 查看运行状态 | +| `/context` | 查看对话上下文(`/context clear` 清除) | +| `/skill` | 技能管理(`/skill list`、`/skill install` 等) | +| `/memory` | 记忆管理(`/memory dream`) | +| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) | +| `/config` | 查看当前配置 | +| `/cancel` | 中止当前正在运行的 Agent 任务 | +| `/logs` | 查看最近日志 | +| `/version` | 查看版本 | + + + Telegram 命令菜单只能展示一级命令,子命令通过空格输入即可,例如 `/skill list`、`/context clear`。 + + +## 三、使用 + +完成接入后: + +- **单聊**:在 Telegram 中搜索你创建的 Bot 用户名(如 `@my_cowagent_bot`),点击 `Start` 即可开始对话。 +- **群聊**:把 Bot 拉进群,使用 `@bot 你好` 或 **回复 Bot 的某条消息** 触发对话。 + +直接发送图片、语音、文件给 Bot,机器人会自动识图、转写或解析内容。也可以先发图再发问题,两条消息会自动合并提问。 diff --git a/docs/docs.json b/docs/docs.json index e2826887..4568beff 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -196,7 +196,8 @@ "channels/wecom-bot", "channels/qq", "channels/wecom", - "channels/wechatmp" + "channels/wechatmp", + "channels/telegram" ] } ] diff --git a/requirements.txt b/requirements.txt index 706d9894..b6592d70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,5 @@ dingtalk_stream # wecom bot websocket mode websocket-client>=1.4.0 pycryptodome +# telegram bot (requires Python >= 3.9; skipped on older versions) +python-telegram-bot; python_version >= "3.9" From 6e04ea8240ea6a48b609b5e967be4f2339952f0c Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 12:12:44 +0800 Subject: [PATCH 275/399] refactor(wechat_kf): rename channel from wechatcom_kf and split corp_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the WeCom customer-service channel and give it its own corp_id field so users no longer have to share `wechatcom_corp_id` with the self-built WeCom app channel. Renames (channel-side): - channel type / const: wechatcom_kf -> wechat_kf - package dir: channel/wechatcom_kf/ -> channel/wechat_kf/ - python files / classes: WechatComKf* -> WechatKf* - config keys: wechatcom_kf_{secret,token,aes_key,port} -> wechat_kf_{secret,token,aes_key,port}; new wechat_kf_corp_id - env vars: WECHATCOM_KF_* -> WECHAT_KF_*; new WECHAT_KF_CORP_ID - log prefix / cursor file: [wechatcom_kf] -> [wechat_kf] - web console CHANNEL_DEFS key + startup log line Renames (docs): - docs/channels/wecom-kf.mdx -> docs/channels/wechat-kf.mdx (zh/en/ja) - update docs.json sidebar entries and all field names inside the docs In addition, the Web Console "微信客服" entry now exposes its own Corp ID field instead of reusing the wechatcom_app one, and includes the screenshot of the visual config in the channel guide. Web Console onboarding section is added (Tabs: Web Console / config file) and the local URL `http://127.0.0.1:9899/` parenthetical is dropped for consistency with other channel docs. Co-authored-by: Cursor --- app.py | 2 +- channel/channel_factory.py | 6 +- channel/web/web_channel.py | 15 ++- channel/{wechatcom_kf => wechat_kf}/README.md | 36 +++--- .../wechat_kf_channel.py} | 106 +++++++++--------- .../wechat_kf_cursor_store.py} | 4 +- .../wechat_kf_message.py} | 8 +- common/const.py | 2 +- config.py | 21 ++-- docs/channels/{wecom-kf.mdx => wechat-kf.mdx} | 24 ++-- docs/docs.json | 6 +- .../channels/{wecom-kf.mdx => wechat-kf.mdx} | 24 ++-- .../channels/{wecom-kf.mdx => wechat-kf.mdx} | 24 ++-- 13 files changed, 139 insertions(+), 139 deletions(-) rename channel/{wechatcom_kf => wechat_kf}/README.md (80%) rename channel/{wechatcom_kf/wechatcom_kf_channel.py => wechat_kf/wechat_kf_channel.py} (80%) rename channel/{wechatcom_kf/wechatcom_kf_cursor_store.py => wechat_kf/wechat_kf_cursor_store.py} (92%) rename channel/{wechatcom_kf/wechatcom_kf_message.py => wechat_kf/wechat_kf_message.py} (90%) rename docs/channels/{wecom-kf.mdx => wechat-kf.mdx} (89%) rename docs/en/channels/{wecom-kf.mdx => wechat-kf.mdx} (90%) rename docs/ja/channels/{wecom-kf.mdx => wechat-kf.mdx} (91%) diff --git a/app.py b/app.py index 8dc1e970..ba2ab265 100644 --- a/app.py +++ b/app.py @@ -231,7 +231,7 @@ def _clear_singleton_cache(channel_name: str): "wechatmp": "channel.wechatmp.wechatmp_channel.WechatMPChannel", "wechatmp_service": "channel.wechatmp.wechatmp_channel.WechatMPChannel", "wechatcom_app": "channel.wechatcom.wechatcomapp_channel.WechatComAppChannel", - const.WECHATCOM_KF: "channel.wechatcom_kf.wechatcom_kf_channel.WechatComKfChannel", + const.WECHAT_KF: "channel.wechat_kf.wechat_kf_channel.WechatKfChannel", const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel", const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel", const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel", diff --git a/channel/channel_factory.py b/channel/channel_factory.py index 9f35e3fc..10000226 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -27,9 +27,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == "wechatcom_app": from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel ch = WechatComAppChannel() - elif channel_type == const.WECHATCOM_KF: - from channel.wechatcom_kf.wechatcom_kf_channel import WechatComKfChannel - ch = WechatComKfChannel() + elif channel_type == const.WECHAT_KF: + from channel.wechat_kf.wechat_kf_channel import WechatKfChannel + ch = WechatKfChannel() elif channel_type == const.FEISHU: from channel.feishu.feishu_channel import FeiShuChanel ch = FeiShuChanel() diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 766c7126..53f26c46 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -713,7 +713,7 @@ class WebChannel(ChatChannel): logger.info("[WebChannel] 5. dingtalk - 钉钉") logger.info("[WebChannel] 6. wecom_bot - 企微智能机器人") logger.info("[WebChannel] 7. wechatcom_app - 企微自建应用") - logger.info("[WebChannel] 8. wechatcom_kf - 微信客服") + logger.info("[WebChannel] 8. wechat_kf - 微信客服") logger.info("[WebChannel] 9. wechatmp - 个人公众号") logger.info("[WebChannel] 10. wechatmp_service - 企业公众号") logger.info("[WebChannel] ✅ Web控制台已运行") @@ -1272,17 +1272,16 @@ class ChannelsHandler: {"key": "wechatcomapp_port", "label": "Port", "type": "number", "default": 9898}, ], }), - ("wechatcom_kf", { + ("wechat_kf", { "label": {"zh": "微信客服", "en": "WeCom Customer Service"}, "icon": "fa-headset", "color": "emerald", "fields": [ - # wechatcom_corp_id is shared with wechatcom_app — same key, same value. - {"key": "wechatcom_corp_id", "label": "Corp ID", "type": "text"}, - {"key": "wechatcom_kf_secret", "label": "Secret", "type": "secret"}, - {"key": "wechatcom_kf_token", "label": "Token", "type": "secret"}, - {"key": "wechatcom_kf_aes_key", "label": "AES Key", "type": "secret"}, - {"key": "wechatcom_kf_port", "label": "Port", "type": "number", "default": 9888}, + {"key": "wechat_kf_corp_id", "label": "Corp ID", "type": "text"}, + {"key": "wechat_kf_secret", "label": "Secret", "type": "secret"}, + {"key": "wechat_kf_token", "label": "Token", "type": "secret"}, + {"key": "wechat_kf_aes_key", "label": "AES Key", "type": "secret"}, + {"key": "wechat_kf_port", "label": "Port", "type": "number", "default": 9888}, ], }), ("wechatmp", { diff --git a/channel/wechatcom_kf/README.md b/channel/wechat_kf/README.md similarity index 80% rename from channel/wechatcom_kf/README.md rename to channel/wechat_kf/README.md index 933e6981..c7352f70 100644 --- a/channel/wechatcom_kf/README.md +++ b/channel/wechat_kf/README.md @@ -32,10 +32,10 @@ | 字段 | 来源 | 对应 CoW 配置项 | |---|---|---| -| 企业ID(CorpId) | 「我的企业」最下方 | `wechatcom_corp_id` | -| Secret | 进入应用详情 → 点击「查看」(会推送到管理员手机端,在手机上查看) | `wechatcom_kf_secret` | -| Token | 应用「接收消息 → 设置API接收」 | `wechatcom_kf_token` | -| EncodingAESKey | 应用「接收消息 → 设置API接收」 | `wechatcom_kf_aes_key` | +| 企业ID(CorpId) | 「我的企业」最下方 | `wechat_kf_corp_id` | +| Secret | 进入应用详情 → 点击「查看」(会推送到管理员手机端,在手机上查看) | `wechat_kf_secret` | +| Token | 应用「接收消息 → 设置API接收」 | `wechat_kf_token` | +| EncodingAESKey | 应用「接收消息 → 设置API接收」 | `wechat_kf_aes_key` | > AgentId 在本通道**不需要**(消息发送走的是 `cgi-bin/kf/send_msg`,不依赖 agent_id)。 @@ -63,25 +63,25 @@ ```json { - "channel_type": "wechatcom_kf", + "channel_type": "wechat_kf", - "wechatcom_corp_id": "ww1234567890abcdef", - "wechatcom_kf_secret": "<企微应用的 Secret>", - "wechatcom_kf_token": "<接收消息 Token>", - "wechatcom_kf_aes_key": "", - "wechatcom_kf_port": 9888 + "wechat_kf_corp_id": "ww1234567890abcdef", + "wechat_kf_secret": "<企微应用的 Secret>", + "wechat_kf_token": "<接收消息 Token>", + "wechat_kf_aes_key": "", + "wechat_kf_port": 9888 } ``` | 字段 | 说明 | |---|---| -| `wechatcom_corp_id` | 企业 ID,可与 `wechatcom_app` 共用 | -| `wechatcom_kf_secret` | **绑定到微信客服**的那个企微自建应用的 Secret | -| `wechatcom_kf_token` | 该应用「接收消息」配置的 Token | -| `wechatcom_kf_aes_key` | 该应用「接收消息」配置的 EncodingAESKey | -| `wechatcom_kf_port` | 监听端口,默认 `9888` | +| `wechat_kf_corp_id` | 企业 ID | +| `wechat_kf_secret` | **绑定到微信客服**的那个企微自建应用的 Secret | +| `wechat_kf_token` | 该应用「接收消息」配置的 Token | +| `wechat_kf_aes_key` | 该应用「接收消息」配置的 EncodingAESKey | +| `wechat_kf_port` | 监听端口,默认 `9888` | -也支持环境变量:`WECHATCOM_CORP_ID` / `WECHATCOM_KF_SECRET` / `WECHATCOM_KF_TOKEN` / `WECHATCOM_KF_AES_KEY`。 +也支持环境变量:`WECHAT_KF_CORP_ID` / `WECHAT_KF_SECRET` / `WECHAT_KF_TOKEN` / `WECHAT_KF_AES_KEY`。 ## 四、运行 @@ -92,8 +92,8 @@ python app.py 启动后日志里会看到: ``` -[wechatcom_kf] WeCom customer-service channel started -[wechatcom_kf] Listening on http://0.0.0.0:9888/wxkf/ +[wechat_kf] WeCom customer-service channel started +[wechat_kf] Listening on http://0.0.0.0:9888/wxkf/ ``` 回到企微后台「设置API接收」点击保存——会触发 `GET /wxkf/?...&echostr=...`,CoW 通过 `crypto.check_signature` 校验后返回明文 `echostr`,验证成功。 diff --git a/channel/wechatcom_kf/wechatcom_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py similarity index 80% rename from channel/wechatcom_kf/wechatcom_kf_channel.py rename to channel/wechat_kf/wechat_kf_channel.py index 7a72728f..7b8000f3 100644 --- a/channel/wechatcom_kf/wechatcom_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -8,7 +8,7 @@ Differences from `channel/wechatcom/` (企微自建应用): member `userid`. 3. Inbound flow: callback only delivers an event token, the actual message bodies must be pulled via `cgi-bin/kf/sync_msg` with a - persistent cursor. See `wechatcom_kf_cursor_store.py`. + persistent cursor. See `wechat_kf_cursor_store.py`. 4. Outbound flow: messages are sent via `cgi-bin/kf/send_msg` (each request must specify both `touser` and `open_kfid`); wechatpy has no native helper, so we call the HTTP endpoint directly. @@ -30,8 +30,8 @@ from wechatpy.exceptions import InvalidSignatureException, WeChatClientException from bridge.context import Context from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel -from channel.wechatcom_kf.wechatcom_kf_cursor_store import CursorStore -from channel.wechatcom_kf.wechatcom_kf_message import WechatComKfMessage +from channel.wechat_kf.wechat_kf_cursor_store import CursorStore +from channel.wechat_kf.wechat_kf_message import WechatKfMessage from common.log import logger from common.singleton import singleton from common.utils import ( @@ -46,7 +46,7 @@ try: from voice.audio_convert import any_to_amr, split_audio except ImportError as e: # voice features optional logger.debug( - "[wechatcom_kf] import voice.audio_convert failed, voice will be disabled: {}".format(e) + "[wechat_kf] import voice.audio_convert failed, voice will be disabled: {}".format(e) ) MAX_UTF8_LEN = 2048 @@ -55,18 +55,18 @@ SYNC_MSG_LIMIT = 1000 @singleton -class WechatComKfChannel(ChatChannel): +class WechatKfChannel(ChatChannel): NOT_SUPPORT_REPLYTYPE = [] def __init__(self): super().__init__() - self.corp_id = conf().get("wechatcom_corp_id") - self.secret = conf().get("wechatcom_kf_secret") - self.token = conf().get("wechatcom_kf_token") - self.aes_key = conf().get("wechatcom_kf_aes_key") + self.corp_id = conf().get("wechat_kf_corp_id") + self.secret = conf().get("wechat_kf_secret") + self.token = conf().get("wechat_kf_token") + self.aes_key = conf().get("wechat_kf_aes_key") self._http_server = None logger.info( - "[wechatcom_kf] Initializing WeCom customer-service channel, corp_id: {}".format( + "[wechat_kf] Initializing WeCom customer-service channel, corp_id: {}".format( self.corp_id ) ) @@ -81,18 +81,18 @@ class WechatComKfChannel(ChatChannel): # Cursor file is an internal implementation detail — fixed under # the project's `tmp/` dir, not exposed as a user-facing config. - cursor_path = os.path.join("tmp", "wechatcom_kf_cursors.json") + cursor_path = os.path.join("tmp", "wechat_kf_cursors.json") self.cursor_store = CursorStore(cursor_path) # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def startup(self): - urls = ("/wxkf/?", "channel.wechatcom_kf.wechatcom_kf_channel.Query") + urls = ("/wxkf/?", "channel.wechat_kf.wechat_kf_channel.Query") app = web.application(urls, globals(), autoreload=False) - port = conf().get("wechatcom_kf_port", 9888) - logger.info("[wechatcom_kf] WeCom customer-service channel started") - logger.info("[wechatcom_kf] Listening on http://0.0.0.0:{}/wxkf/".format(port)) + port = conf().get("wechat_kf_port", 9888) + logger.info("[wechat_kf] WeCom customer-service channel started") + logger.info("[wechat_kf] Listening on http://0.0.0.0:{}/wxkf/".format(port)) func = web.httpserver.StaticMiddleware(app.wsgifunc()) func = web.httpserver.LogMiddleware(func) server = web.httpserver.WSGIServer(("0.0.0.0", port), func) @@ -106,9 +106,9 @@ class WechatComKfChannel(ChatChannel): if self._http_server: try: self._http_server.stop() - logger.info("[wechatcom_kf] HTTP server stopped") + logger.info("[wechat_kf] HTTP server stopped") except Exception as e: - logger.warning(f"[wechatcom_kf] Error stopping HTTP server: {e}") + logger.warning(f"[wechat_kf] Error stopping HTTP server: {e}") self._http_server = None # ------------------------------------------------------------------ @@ -122,7 +122,7 @@ class WechatComKfChannel(ChatChannel): if not external_userid or not open_kfid: logger.error( - "[wechatcom_kf] missing external_userid or open_kfid, cannot send: " + "[wechat_kf] missing external_userid or open_kfid, cannot send: " f"external_userid={external_userid}, open_kfid={open_kfid}" ) return @@ -132,13 +132,13 @@ class WechatComKfChannel(ChatChannel): texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN) if len(texts) > 1: logger.info( - "[wechatcom_kf] text too long, split into {} parts".format(len(texts)) + "[wechat_kf] text too long, split into {} parts".format(len(texts)) ) for i, text in enumerate(texts): self._send_text(external_userid, open_kfid, text) if i != len(texts) - 1: time.sleep(0.5) - logger.info("[wechatcom_kf] Do send text to {}: {}".format(receiver, reply_text)) + logger.info("[wechat_kf] Do send text to {}: {}".format(receiver, reply_text)) elif reply.type == ReplyType.VOICE: file_path = reply.content @@ -148,7 +148,7 @@ class WechatComKfChannel(ChatChannel): duration, files = split_audio(amr_file, 60 * 1000) if len(files) > 1: logger.info( - "[wechatcom_kf] voice too long {}s > 60s, split into {} parts".format( + "[wechat_kf] voice too long {}s > 60s, split into {} parts".format( duration / 1000.0, len(files) ) ) @@ -156,14 +156,14 @@ class WechatComKfChannel(ChatChannel): for path in files: with open(path, "rb") as f: response = self.client.media.upload("voice", f) - logger.debug("[wechatcom_kf] upload voice response: {}".format(response)) + logger.debug("[wechat_kf] upload voice response: {}".format(response)) media_ids.append(response["media_id"]) except ImportError as e: - logger.error("[wechatcom_kf] voice conversion failed: {}".format(e)) - logger.error("[wechatcom_kf] please install pydub: pip install pydub") + logger.error("[wechat_kf] voice conversion failed: {}".format(e)) + logger.error("[wechat_kf] please install pydub: pip install pydub") return except WeChatClientException as e: - logger.error("[wechatcom_kf] upload voice failed: {}".format(e)) + logger.error("[wechat_kf] upload voice failed: {}".format(e)) return try: @@ -176,7 +176,7 @@ class WechatComKfChannel(ChatChannel): for media_id in media_ids: self._send_voice(external_userid, open_kfid, media_id) time.sleep(1) - logger.info("[wechatcom_kf] sendVoice={}, receiver={}".format(reply.content, receiver)) + logger.info("[wechat_kf] sendVoice={}, receiver={}".format(reply.content, receiver)) elif reply.type == ReplyType.IMAGE_URL: img_url = reply.content @@ -186,31 +186,31 @@ class WechatComKfChannel(ChatChannel): image_storage.write(block) sz = fsize(image_storage) if sz >= 10 * 1024 * 1024: - logger.info("[wechatcom_kf] image too large, compressing, sz={}".format(sz)) + logger.info("[wechat_kf] image too large, compressing, sz={}".format(sz)) image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) image_storage.seek(0) try: response = self.client.media.upload("image", image_storage) except WeChatClientException as e: - logger.error("[wechatcom_kf] upload image failed: {}".format(e)) + logger.error("[wechat_kf] upload image failed: {}".format(e)) return self._send_image(external_userid, open_kfid, response["media_id"]) - logger.info("[wechatcom_kf] sendImage url={}, receiver={}".format(img_url, receiver)) + logger.info("[wechat_kf] sendImage url={}, receiver={}".format(img_url, receiver)) elif reply.type == ReplyType.IMAGE: image_storage = reply.content sz = fsize(image_storage) if sz >= 10 * 1024 * 1024: - logger.info("[wechatcom_kf] image too large, compressing, sz={}".format(sz)) + logger.info("[wechat_kf] image too large, compressing, sz={}".format(sz)) image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) image_storage.seek(0) try: response = self.client.media.upload("image", image_storage) except WeChatClientException as e: - logger.error("[wechatcom_kf] upload image failed: {}".format(e)) + logger.error("[wechat_kf] upload image failed: {}".format(e)) return self._send_image(external_userid, open_kfid, response["media_id"]) - logger.info("[wechatcom_kf] sendImage, receiver={}".format(receiver)) + logger.info("[wechat_kf] sendImage, receiver={}".format(receiver)) elif reply.type == ReplyType.VIDEO_URL: video_url = reply.content @@ -219,10 +219,10 @@ class WechatComKfChannel(ChatChannel): "video", requests.get(video_url, stream=True).content ) except WeChatClientException as e: - logger.error("[wechatcom_kf] upload video failed: {}".format(e)) + logger.error("[wechat_kf] upload video failed: {}".format(e)) return self._send_video(external_userid, open_kfid, response["media_id"]) - logger.info("[wechatcom_kf] sendVideo url={}, receiver={}".format(video_url, receiver)) + logger.info("[wechat_kf] sendVideo url={}, receiver={}".format(video_url, receiver)) elif reply.type == ReplyType.FILE: file_path = reply.content @@ -232,13 +232,13 @@ class WechatComKfChannel(ChatChannel): "file", (os.path.basename(file_path), f.read()) ) except WeChatClientException as e: - logger.error("[wechatcom_kf] upload file failed: {}".format(e)) + logger.error("[wechat_kf] upload file failed: {}".format(e)) return self._send_file(external_userid, open_kfid, response["media_id"]) - logger.info("[wechatcom_kf] sendFile={}, receiver={}".format(file_path, receiver)) + logger.info("[wechat_kf] sendFile={}, receiver={}".format(file_path, receiver)) else: - logger.warning("[wechatcom_kf] unsupported reply type: {}".format(reply.type)) + logger.warning("[wechat_kf] unsupported reply type: {}".format(reply.type)) # ------------------------------------------------------------------ # Inbound — pull messages by cursor @@ -263,9 +263,9 @@ class WechatComKfChannel(ChatChannel): return for raw in msgs: try: - kf_msg = WechatComKfMessage(msg=raw, client=self.client) + kf_msg = WechatKfMessage(msg=raw, client=self.client) except NotImplementedError as e: - logger.debug("[wechatcom_kf] {}".format(e)) + logger.debug("[wechat_kf] {}".format(e)) continue context = self._compose_context( kf_msg.ctype, @@ -300,7 +300,7 @@ class WechatComKfChannel(ChatChannel): break next_cursor = cursor_after logger.info( - "[wechatcom_kf] first-start bootstrap finished for open_kfid={}, " + "[wechat_kf] first-start bootstrap finished for open_kfid={}, " "skipped {} historical messages".format(open_kfid, total_skipped) ) @@ -332,7 +332,7 @@ class WechatComKfChannel(ChatChannel): if collected: collected = _dedup_image_text_pair(collected) logger.info( - "[wechatcom_kf] pulled {} messages for open_kfid={}".format(len(collected), open_kfid) + "[wechat_kf] pulled {} messages for open_kfid={}".format(len(collected), open_kfid) ) return collected @@ -351,12 +351,12 @@ class WechatComKfChannel(ChatChannel): try: resp = requests.post(url, json=payload, timeout=10).json() except Exception as e: - logger.error(f"[wechatcom_kf] sync_msg request failed: {e}") + logger.error(f"[wechat_kf] sync_msg request failed: {e}") return None if resp.get("errcode") != 0: logger.error( - f"[wechatcom_kf] sync_msg errcode={resp.get('errcode')}, " + f"[wechat_kf] sync_msg errcode={resp.get('errcode')}, " f"errmsg={resp.get('errmsg')}, open_kfid={open_kfid}" ) return None @@ -370,10 +370,10 @@ class WechatComKfChannel(ChatChannel): try: resp = requests.post(url, json=payload, timeout=10).json() except Exception as e: - logger.error(f"[wechatcom_kf] send_msg request failed: {e}") + logger.error(f"[wechat_kf] send_msg request failed: {e}") return {"errcode": -1, "errmsg": str(e)} if resp.get("errcode") != 0: - logger.error(f"[wechatcom_kf] send_msg failed, payload={payload}, resp={resp}") + logger.error(f"[wechat_kf] send_msg failed, payload={payload}, resp={resp}") return resp def _send_text(self, external_userid: str, open_kfid: str, content: str) -> dict: @@ -451,9 +451,9 @@ def _dedup_image_text_pair(messages: list) -> list: # ---------------------------------------------------------------------- class Query: def GET(self): - channel = WechatComKfChannel() + channel = WechatKfChannel() params = web.input() - logger.info("[wechatcom_kf] verify params: {}".format(params)) + logger.info("[wechat_kf] verify params: {}".format(params)) try: signature = params.msg_signature timestamp = params.timestamp @@ -465,7 +465,7 @@ class Query: return echostr def POST(self): - channel = WechatComKfChannel() + channel = WechatKfChannel() params = web.input() try: signature = params.msg_signature @@ -474,7 +474,7 @@ class Query: raw_body = web.data() decrypted = channel.crypto.decrypt_message(raw_body, signature, timestamp, nonce) except (InvalidSignatureException, InvalidCorpIdException) as e: - logger.warning(f"[wechatcom_kf] invalid signature: {e}") + logger.warning(f"[wechat_kf] invalid signature: {e}") raise web.Forbidden() # We need the Token + OpenKfId fields from the inner XML to call @@ -483,14 +483,14 @@ class Query: try: root = ET.fromstring(decrypted) except ET.ParseError as e: - logger.error(f"[wechatcom_kf] xml parse error: {e}") + logger.error(f"[wechat_kf] xml parse error: {e}") return "success" msg_type = (root.findtext("MsgType") or "").strip() event = (root.findtext("Event") or "").strip() if msg_type != "event" or event != "kf_msg_or_event": logger.debug( - f"[wechatcom_kf] ignored callback msg_type={msg_type}, event={event}" + f"[wechat_kf] ignored callback msg_type={msg_type}, event={event}" ) return "success" @@ -498,12 +498,12 @@ class Query: open_kfid = root.findtext("OpenKfId") or "" if not token or not open_kfid: logger.warning( - f"[wechatcom_kf] callback missing token or open_kfid: {decrypted}" + f"[wechat_kf] callback missing token or open_kfid: {decrypted}" ) return "success" try: channel.consume_callback(token, open_kfid) except Exception as e: - logger.exception(f"[wechatcom_kf] consume_callback error: {e}") + logger.exception(f"[wechat_kf] consume_callback error: {e}") return "success" diff --git a/channel/wechatcom_kf/wechatcom_kf_cursor_store.py b/channel/wechat_kf/wechat_kf_cursor_store.py similarity index 92% rename from channel/wechatcom_kf/wechatcom_kf_cursor_store.py rename to channel/wechat_kf/wechat_kf_cursor_store.py index 445e2137..72ba3301 100644 --- a/channel/wechatcom_kf/wechatcom_kf_cursor_store.py +++ b/channel/wechat_kf/wechat_kf_cursor_store.py @@ -37,7 +37,7 @@ class CursorStore: with open(self._file_path, "r", encoding="utf-8") as f: return json.load(f) or {} except Exception as e: - logger.warning(f"[wechatcom_kf] failed to load cursor file {self._file_path}: {e}") + logger.warning(f"[wechat_kf] failed to load cursor file {self._file_path}: {e}") return {} def _flush_locked(self): @@ -49,7 +49,7 @@ class CursorStore: json.dump(self._data, f, ensure_ascii=False) os.replace(tmp_path, self._file_path) except Exception as e: - logger.warning(f"[wechatcom_kf] failed to flush cursor file {self._file_path}: {e}") + logger.warning(f"[wechat_kf] failed to flush cursor file {self._file_path}: {e}") try: if os.path.exists(tmp_path): os.remove(tmp_path) diff --git a/channel/wechatcom_kf/wechatcom_kf_message.py b/channel/wechat_kf/wechat_kf_message.py similarity index 90% rename from channel/wechatcom_kf/wechatcom_kf_message.py rename to channel/wechat_kf/wechat_kf_message.py index 85bfdf09..2505dbea 100644 --- a/channel/wechatcom_kf/wechatcom_kf_message.py +++ b/channel/wechat_kf/wechat_kf_message.py @@ -11,7 +11,7 @@ from common.log import logger from common.tmp_dir import TmpDir -class WechatComKfMessage(ChatMessage): +class WechatKfMessage(ChatMessage): """ msg structure (from cgi-bin/kf/sync_msg): { @@ -54,7 +54,7 @@ class WechatComKfMessage(ChatMessage): with open(self.content, "wb") as f: f.write(response.content) else: - logger.info(f"[wechatcom_kf] Failed to download image, {response.content}") + logger.info(f"[wechat_kf] Failed to download image, {response.content}") self._prepare_fn = download_image elif self.msgtype == "voice": @@ -69,12 +69,12 @@ class WechatComKfMessage(ChatMessage): with open(self.content, "wb") as f: f.write(response.content) else: - logger.info(f"[wechatcom_kf] Failed to download voice, {response.content}") + logger.info(f"[wechat_kf] Failed to download voice, {response.content}") self._prepare_fn = download_voice else: raise NotImplementedError( - f"[wechatcom_kf] Unsupported message type: {self.msgtype}" + f"[wechat_kf] Unsupported message type: {self.msgtype}" ) self.from_user_id = self.external_userid diff --git a/common/const.py b/common/const.py index 8227ff7d..dccac1a4 100644 --- a/common/const.py +++ b/common/const.py @@ -227,4 +227,4 @@ DINGTALK = "dingtalk" WECOM_BOT = "wecom_bot" QQ = "qq" WEIXIN = "weixin" -WECHATCOM_KF = "wechatcom_kf" # WeCom customer service (微信客服) channel +WECHAT_KF = "wechat_kf" # WeCom customer service (微信客服) channel diff --git a/config.py b/config.py index 19c78fdc..2463ab0e 100644 --- a/config.py +++ b/config.py @@ -151,12 +151,12 @@ available_setting = { "wechatcomapp_secret": "", # 企业微信app的secret "wechatcomapp_agent_id": "", # 企业微信app的agent_id "wechatcomapp_aes_key": "", # 企业微信app的aes_key - # 微信客服(wechatcom_kf)的配置 - # 注意: 微信客服与企微自建应用是两套不同的应用,共用 corp_id,但 secret/token/aes_key 各自独立 - "wechatcom_kf_token": "", # 微信客服回调token - "wechatcom_kf_port": 9888, # 微信客服回调服务端口 - "wechatcom_kf_secret": "", # 微信客服应用的secret - "wechatcom_kf_aes_key": "", # 微信客服回调aes_key + # 微信客服(wechat_kf)的配置 + "wechat_kf_corp_id": "", # 微信客服所在企业的corp_id + "wechat_kf_token": "", # 微信客服回调token + "wechat_kf_port": 9888, # 微信客服回调服务端口 + "wechat_kf_secret": "", # 微信客服应用的secret + "wechat_kf_aes_key": "", # 微信客服回调aes_key # 飞书配置 "feishu_port": 80, # 飞书bot监听端口,仅webhook模式需要 "feishu_app_id": "", # 飞书机器人应用APP Id @@ -180,7 +180,7 @@ available_setting = { # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 - "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,wechatcom_kf + "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,wechat_kf "web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用 "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app "debug": False, # 是否开启debug模式,开启后会打印更多日志 @@ -417,9 +417,10 @@ def load_config(): "wechatcomapp_agent_id": "WECHATCOMAPP_AGENT_ID", "wechatcomapp_secret": "WECHATCOMAPP_SECRET", "wechatcom_corp_id": "WECHATCOM_CORP_ID", - "wechatcom_kf_secret": "WECHATCOM_KF_SECRET", - "wechatcom_kf_token": "WECHATCOM_KF_TOKEN", - "wechatcom_kf_aes_key": "WECHATCOM_KF_AES_KEY", + "wechat_kf_corp_id": "WECHAT_KF_CORP_ID", + "wechat_kf_secret": "WECHAT_KF_SECRET", + "wechat_kf_token": "WECHAT_KF_TOKEN", + "wechat_kf_aes_key": "WECHAT_KF_AES_KEY", "qq_app_id": "QQ_APP_ID", "qq_app_secret": "QQ_APP_SECRET", "weixin_token": "WEIXIN_TOKEN", diff --git a/docs/channels/wecom-kf.mdx b/docs/channels/wechat-kf.mdx similarity index 89% rename from docs/channels/wecom-kf.mdx rename to docs/channels/wechat-kf.mdx index 42a13991..ca83aaed 100644 --- a/docs/channels/wecom-kf.mdx +++ b/docs/channels/wechat-kf.mdx @@ -27,7 +27,7 @@ description: 将 CowAgent 接入微信客服(WeCom Customer Service) -2. 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechatcom_corp_id`): +2. 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechat_kf_corp_id`): @@ -58,22 +58,22 @@ description: 将 CowAgent 接入微信客服(WeCom Customer Service) ```json { - "channel_type": "wechatcom_kf", - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcom_kf_secret": "YOUR_SECRET", - "wechatcom_kf_token": "YOUR_TOKEN", - "wechatcom_kf_aes_key": "YOUR_AES_KEY", - "wechatcom_kf_port": 9888 + "channel_type": "wechat_kf", + "wechat_kf_corp_id": "YOUR_CORP_ID", + "wechat_kf_secret": "YOUR_SECRET", + "wechat_kf_token": "YOUR_TOKEN", + "wechat_kf_aes_key": "YOUR_AES_KEY", + "wechat_kf_port": 9888 } ``` | 参数 | 说明 | | --- | --- | - | `wechatcom_corp_id` | 企业 ID | - | `wechatcom_kf_secret` | 绑定到微信客服的那个企微自建应用的 Secret | - | `wechatcom_kf_token` | API 接收配置中的 Token | - | `wechatcom_kf_aes_key` | API 接收配置中的 EncodingAESKey | - | `wechatcom_kf_port` | 监听端口,默认 9888 | + | `wechat_kf_corp_id` | 企业 ID | + | `wechat_kf_secret` | 绑定到微信客服的那个企微自建应用的 Secret | + | `wechat_kf_token` | API 接收配置中的 Token | + | `wechat_kf_aes_key` | API 接收配置中的 EncodingAESKey | + | `wechat_kf_port` | 监听端口,默认 9888 | diff --git a/docs/docs.json b/docs/docs.json index c5ddd9cd..355f9e9e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -188,7 +188,7 @@ "channels/wecom-bot", "channels/qq", "channels/wecom", - "channels/wecom-kf", + "channels/wechat-kf", "channels/wechatmp" ] } @@ -381,7 +381,7 @@ "en/channels/wecom-bot", "en/channels/qq", "en/channels/wecom", - "en/channels/wecom-kf", + "en/channels/wechat-kf", "en/channels/wechatmp" ] } @@ -576,7 +576,7 @@ "ja/channels/wecom-bot", "ja/channels/qq", "ja/channels/wecom", - "ja/channels/wecom-kf", + "ja/channels/wechat-kf", "ja/channels/wechatmp" ] } diff --git a/docs/en/channels/wecom-kf.mdx b/docs/en/channels/wechat-kf.mdx similarity index 90% rename from docs/en/channels/wecom-kf.mdx rename to docs/en/channels/wechat-kf.mdx index 1dd973ce..f0711d51 100644 --- a/docs/en/channels/wecom-kf.mdx +++ b/docs/en/channels/wechat-kf.mdx @@ -27,7 +27,7 @@ Required resources: -2. Click **My Enterprise** and find the **Corp ID** at the bottom of the page (it goes into `wechatcom_corp_id`): +2. Click **My Enterprise** and find the **Corp ID** at the bottom of the page (it goes into `wechat_kf_corp_id`): @@ -58,22 +58,22 @@ Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token ```json { - "channel_type": "wechatcom_kf", - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcom_kf_secret": "YOUR_SECRET", - "wechatcom_kf_token": "YOUR_TOKEN", - "wechatcom_kf_aes_key": "YOUR_AES_KEY", - "wechatcom_kf_port": 9888 + "channel_type": "wechat_kf", + "wechat_kf_corp_id": "YOUR_CORP_ID", + "wechat_kf_secret": "YOUR_SECRET", + "wechat_kf_token": "YOUR_TOKEN", + "wechat_kf_aes_key": "YOUR_AES_KEY", + "wechat_kf_port": 9888 } ``` | Parameter | Description | | --- | --- | - | `wechatcom_corp_id` | Corp ID | - | `wechatcom_kf_secret` | Secret of the WeCom custom app bound to Customer Service | - | `wechatcom_kf_token` | Token from the API reception config | - | `wechatcom_kf_aes_key` | EncodingAESKey from the API reception config | - | `wechatcom_kf_port` | Listening port, default 9888 | + | `wechat_kf_corp_id` | Corp ID | + | `wechat_kf_secret` | Secret of the WeCom custom app bound to Customer Service | + | `wechat_kf_token` | Token from the API reception config | + | `wechat_kf_aes_key` | EncodingAESKey from the API reception config | + | `wechat_kf_port` | Listening port, default 9888 | diff --git a/docs/ja/channels/wecom-kf.mdx b/docs/ja/channels/wechat-kf.mdx similarity index 91% rename from docs/ja/channels/wecom-kf.mdx rename to docs/ja/channels/wechat-kf.mdx index 25de67ff..078f1b5a 100644 --- a/docs/ja/channels/wecom-kf.mdx +++ b/docs/ja/channels/wechat-kf.mdx @@ -27,7 +27,7 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ -2. **自社情報** をクリックし、ページ下部で **企業ID(Corp ID)** を確認します(`wechatcom_corp_id` に設定します): +2. **自社情報** をクリックし、ページ下部で **企業ID(Corp ID)** を確認します(`wechat_kf_corp_id` に設定します): @@ -58,22 +58,22 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ ```json { - "channel_type": "wechatcom_kf", - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcom_kf_secret": "YOUR_SECRET", - "wechatcom_kf_token": "YOUR_TOKEN", - "wechatcom_kf_aes_key": "YOUR_AES_KEY", - "wechatcom_kf_port": 9888 + "channel_type": "wechat_kf", + "wechat_kf_corp_id": "YOUR_CORP_ID", + "wechat_kf_secret": "YOUR_SECRET", + "wechat_kf_token": "YOUR_TOKEN", + "wechat_kf_aes_key": "YOUR_AES_KEY", + "wechat_kf_port": 9888 } ``` | パラメータ | 説明 | | --- | --- | - | `wechatcom_corp_id` | 企業 ID | - | `wechatcom_kf_secret` | カスタマーサービスにバインドした企業微信自建アプリの Secret | - | `wechatcom_kf_token` | API 受信設定の Token | - | `wechatcom_kf_aes_key` | API 受信設定の EncodingAESKey | - | `wechatcom_kf_port` | リスンポート、デフォルトは 9888 | + | `wechat_kf_corp_id` | 企業 ID | + | `wechat_kf_secret` | カスタマーサービスにバインドした企業微信自建アプリの Secret | + | `wechat_kf_token` | API 受信設定の Token | + | `wechat_kf_aes_key` | API 受信設定の EncodingAESKey | + | `wechat_kf_port` | リスンポート、デフォルトは 9888 | From 5c163cc0fe5cd4bc450ef6c30f8e91e02f71e92b Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 12:23:56 +0800 Subject: [PATCH 276/399] fix: dispatch callback async to avoid WeCom 5s timeout WeCom requires the callback HTTP response within ~5s, otherwise it retries the same notification. The previous code ran sync_msg pulling synchronously inside Query.POST, so a backlog could exceed the deadline and trigger retries that race on the same cursor and end up replying to the same user multiple times. - Dispatch consume_callback to a background ThreadPoolExecutor and return 'success' immediately from the HTTP handler. - Serialize work per open_kfid with a lock so retried/concurrent callbacks queue up instead of racing the cursor window. - Shutdown the executor on channel stop(). --- channel/wechat_kf/wechat_kf_channel.py | 59 ++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index 7b8000f3..c2ffc67f 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -16,8 +16,11 @@ Differences from `channel/wechatcom/` (企微自建应用): import io import json import os +import threading import time import xml.etree.ElementTree as ET +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor from typing import Optional import requests @@ -84,6 +87,19 @@ class WechatKfChannel(ChatChannel): cursor_path = os.path.join("tmp", "wechat_kf_cursors.json") self.cursor_store = CursorStore(cursor_path) + # WeCom requires the callback HTTP response to return within ~5s, + # otherwise it retries the same notification. sync_msg pulling + # can easily exceed that, so we dispatch it to a background pool + # and let `Query.POST` reply success immediately. + self._callback_executor = ThreadPoolExecutor( + max_workers=4, thread_name_prefix="wxkf-cb" + ) + # Per-open_kfid lock: serialize sync_msg for the same kf account + # so that callback retries (or rapid-fire events) don't race on + # the same cursor and produce duplicate replies. + self._kf_locks: dict = defaultdict(threading.Lock) + self._kf_locks_guard = threading.Lock() + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -110,6 +126,10 @@ class WechatKfChannel(ChatChannel): except Exception as e: logger.warning(f"[wechat_kf] Error stopping HTTP server: {e}") self._http_server = None + try: + self._callback_executor.shutdown(wait=False) + except Exception as e: + logger.warning(f"[wechat_kf] Error shutting down callback executor: {e}") # ------------------------------------------------------------------ # Outbound — implementing the abstract `send` contract @@ -243,6 +263,37 @@ class WechatKfChannel(ChatChannel): # ------------------------------------------------------------------ # Inbound — pull messages by cursor # ------------------------------------------------------------------ + def _get_kf_lock(self, open_kfid: str) -> threading.Lock: + with self._kf_locks_guard: + return self._kf_locks[open_kfid] + + def submit_callback(self, token: str, open_kfid: str): + """ + Async entry point used by the HTTP handler. Submits the actual + sync_msg pulling to a background thread so the callback response + can return within WeCom's 5s deadline. + """ + try: + self._callback_executor.submit(self._run_callback, token, open_kfid) + except RuntimeError as e: + # Executor may be shut down during process exit; fall back + # to inline execution so we don't silently drop the event. + logger.warning(f"[wechat_kf] executor unavailable, run inline: {e}") + self._run_callback(token, open_kfid) + + def _run_callback(self, token: str, open_kfid: str): + # Block on the per-kfid lock so retried callbacks queue up + # behind the in-flight one. The queued worker will then call + # sync_msg with the (already advanced) cursor, which is cheap + # when there is nothing new and still picks up any messages + # that arrived after the previous worker's last pull. + lock = self._get_kf_lock(open_kfid) + with lock: + try: + self.consume_callback(token, open_kfid) + except Exception as e: + logger.exception(f"[wechat_kf] consume_callback error: {e}") + def consume_callback(self, token: str, open_kfid: str): """ Called from the HTTP `Query.POST` handler whenever WeCom notifies @@ -502,8 +553,8 @@ class Query: ) return "success" - try: - channel.consume_callback(token, open_kfid) - except Exception as e: - logger.exception(f"[wechat_kf] consume_callback error: {e}") + # Hand off to a background worker — WeCom requires the callback + # to return success within ~5 seconds, otherwise it will retry + # and we may race the same cursor window into duplicate replies. + channel.submit_callback(token, open_kfid) return "success" From 877b84837042c4732087cd4a40350208dd2e5c18 Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 14:23:04 +0800 Subject: [PATCH 277/399] fix(wechat_kf): stop dropping rapid-fire messages in batch dedup _dedup_image_text_pair previously fell back to returning only the last message whenever the batch was not exactly an image+text pair, which silently dropped multiple texts/images sent in quick succession. Cursor freshness is already guaranteed by sync_msg, so no extra stale-history protection is needed. Now we return all messages by default and only collapse a batch when it is exactly a 2-message image+text pair within a 5s window (order-insensitive, normalized to [image, text]). --- channel/wechat_kf/wechat_kf_channel.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index c2ffc67f..c725ccbd 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -479,22 +479,24 @@ class WechatKfChannel(ChatChannel): def _dedup_image_text_pair(messages: list) -> list: """ A WeChat user often sends an image immediately followed by a text - question (e.g. "what's in this picture?"). When that happens we - treat them as one combined turn: image first, then text. - Otherwise (single message or unrelated batch) we return only the - last message to avoid replying to stale history. + question (e.g. "what's in this picture?"). Only when the batch is + exactly that 2-message image+text pair within a 5s window do we + collapse it into a single [image, text] turn. Otherwise return + every message so rapid-fire texts/images are all processed — + cursor freshness is already guaranteed by sync_msg. """ if not messages: return [] - text_messages = [m for m in messages if m["msgtype"] == "text"] - image_messages = [m for m in messages if m["msgtype"] == "image"] - if text_messages and image_messages: - last_text_time = text_messages[-1]["send_time"] - first_image_time = image_messages[0]["send_time"] - if abs(last_text_time - first_image_time) <= 5: - return [image_messages[0], text_messages[-1]] - return [messages[-1]] + if len(messages) == 2: + a, b = messages + types = {a["msgtype"], b["msgtype"]} + if types == {"image", "text"} and abs(a["send_time"] - b["send_time"]) <= 5: + img = a if a["msgtype"] == "image" else b + txt = b if a["msgtype"] == "image" else a + return [img, txt] + + return messages # ---------------------------------------------------------------------- From 37661daf404ef5444faf3e5e37f225b7444adc4f Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 14:33:45 +0800 Subject: [PATCH 278/399] refactor(wechat_kf): persist sync_msg cursor under $HOME Move the sync_msg cursor file from the project-local tmp/ dir to ~/.wechat_kf_cursors.json so it survives tmp/ cleanups and cwd changes across restarts. Aligns with the weixin channel's credentials file convention. - add wechat_kf_cursor_path config (default ~/.wechat_kf_cursors.json) - expand ~ via os.path.expanduser in the channel init - chmod the cursor file to 0o600 after each flush (no-op on Windows) --- channel/wechat_kf/wechat_kf_channel.py | 8 +++++--- channel/wechat_kf/wechat_kf_cursor_store.py | 6 ++++++ config.py | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index c725ccbd..e33662db 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -82,9 +82,11 @@ class WechatKfChannel(ChatChannel): # corrupts URLs and triggers errcode 40014. self.client = WeChatClient(self.corp_id, self.secret) - # Cursor file is an internal implementation detail — fixed under - # the project's `tmp/` dir, not exposed as a user-facing config. - cursor_path = os.path.join("tmp", "wechat_kf_cursors.json") + # Persist sync_msg cursor under the user's home dir by default, + # so it survives `tmp/` cleanups and cwd changes across restarts. + cursor_path = os.path.expanduser( + conf().get("wechat_kf_cursor_path") or "~/.wechat_kf_cursors.json" + ) self.cursor_store = CursorStore(cursor_path) # WeCom requires the callback HTTP response to return within ~5s, diff --git a/channel/wechat_kf/wechat_kf_cursor_store.py b/channel/wechat_kf/wechat_kf_cursor_store.py index 72ba3301..cc809450 100644 --- a/channel/wechat_kf/wechat_kf_cursor_store.py +++ b/channel/wechat_kf/wechat_kf_cursor_store.py @@ -48,6 +48,12 @@ class CursorStore: with open(tmp_path, "w", encoding="utf-8") as f: json.dump(self._data, f, ensure_ascii=False) os.replace(tmp_path, self._file_path) + # Tighten permissions: cursor file lives in $HOME, restrict to owner. + # No-op on Windows. + try: + os.chmod(self._file_path, 0o600) + except Exception: + pass except Exception as e: logger.warning(f"[wechat_kf] failed to flush cursor file {self._file_path}: {e}") try: diff --git a/config.py b/config.py index 2463ab0e..19355576 100644 --- a/config.py +++ b/config.py @@ -157,6 +157,7 @@ available_setting = { "wechat_kf_port": 9888, # 微信客服回调服务端口 "wechat_kf_secret": "", # 微信客服应用的secret "wechat_kf_aes_key": "", # 微信客服回调aes_key + "wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # 微信客服sync_msg游标持久化文件路径 # 飞书配置 "feishu_port": 80, # 飞书bot监听端口,仅webhook模式需要 "feishu_app_id": "", # 飞书机器人应用APP Id From e3eacc77d751a1932dd8f678a055e911cc5a66c4 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 28 May 2026 15:07:09 +0800 Subject: [PATCH 279/399] feat(channel): support telegram bot --- README.md | 3 +- channel/telegram/telegram_channel.py | 9 +++ channel/web/web_channel.py | 1 - docs/channels/telegram.mdx | 31 ++++---- docs/docs.json | 6 +- docs/en/channels/index.mdx | 2 + docs/en/channels/telegram.mdx | 111 +++++++++++++++++++++++++++ docs/ja/README.md | 3 +- docs/ja/channels/index.mdx | 2 + docs/ja/channels/telegram.mdx | 111 +++++++++++++++++++++++++++ docs/zh/README.md | 5 +- 11 files changed, 261 insertions(+), 23 deletions(-) create mode 100644 docs/en/channels/telegram.mdx create mode 100644 docs/ja/channels/telegram.mdx diff --git a/README.md b/README.md index 8b9e044e..70b2707f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major | [Knowledge](https://docs.cowagent.ai/en/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | | [Skills](https://docs.cowagent.ai/en/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | | [Tools](https://docs.cowagent.ai/en/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | -| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, and Official Accounts | +| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, and Telegram | | Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery | | [Models](https://docs.cowagent.ai/en/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | | [Deploy](https://docs.cowagent.ai/en/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) | @@ -126,6 +126,7 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | [QQ](https://docs.cowagent.ai/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | > See the [Channels overview](https://docs.cowagent.ai/en/channels/index) for setup details. diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py index 37e9d9fa..b3b46589 100644 --- a/channel/telegram/telegram_channel.py +++ b/channel/telegram/telegram_channel.py @@ -246,8 +246,17 @@ class TelegramChannel(ChatChannel): is_group = chat.type in ("group", "supergroup") + # Debug log: helpful when group messages are silently dropped + if is_group: + logger.debug( + f"[Telegram] group update received: chat_id={chat.id}, " + f"text={(message.text or message.caption or '')[:40]!r}, " + f"reply_to_bot={bool(message.reply_to_message and message.reply_to_message.from_user and message.reply_to_message.from_user.username == self.bot_username)}" + ) + # Group trigger gate (silently drop if not triggered) if is_group and not self._should_reply_in_group(update): + logger.debug(f"[Telegram] group message not triggered (need @{self.bot_username} or reply), skip") return # Parse message type + download media if needed. diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 04bd70a2..57891855 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2915,7 +2915,6 @@ class ChannelsHandler: "color": "sky", "fields": [ {"key": "telegram_token", "label": "Bot Token", "type": "secret"}, - {"key": "telegram_proxy", "label": "Proxy (optional)", "type": "text"}, ], }), ]) diff --git a/docs/channels/telegram.mdx b/docs/channels/telegram.mdx index a05240a9..d7ab7a44 100644 --- a/docs/channels/telegram.mdx +++ b/docs/channels/telegram.mdx @@ -20,21 +20,25 @@ description: 将 CowAgent 接入 Telegram Bot 这个 Token 等同于 Bot 的密码,请勿泄露。如果意外泄漏可向 `@BotFather` 发送 `/revoke` 重置。 -### 步骤二:(可选)配置群聊权限 +### 步骤二:(群聊使用)关闭 Privacy Mode -Telegram Bot 默认开启 **Privacy Mode**,在群聊中只能收到 `@bot` 或 `/command` 消息。这正好是 CowAgent 期望的行为,**无需修改**。 +仅使用单聊可跳过此步。Telegram Bot 默认开启 **Privacy Mode**,群聊中只能收到带 `@bot` 的命令(如 `/start@your_bot`)以及对 bot 消息的 reply;**普通的 `@bot 你好` 文字消息收不到**,会导致群聊无响应。 -如果你希望机器人接收群里所有消息(不推荐,会触发频次飙升),向 `@BotFather` 发送 `/setprivacy` → 选择对应 bot → `Disable`。 +向 `@BotFather` 发送: + +1. `/setprivacy` +2. 选择刚才创建的 bot +3. 选择 `Disable` + + + 若设置后群聊仍无响应,可尝试把 Bot 从群里移除并重新拉入。 + ### 步骤三:接入 CowAgent - 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Telegram**,填入 Bot Token(必要时填写代理),点击接入即可。 - - - 若运行环境无法直接访问 `api.telegram.org`,需在「Proxy」字段填写代理地址,例如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`。 - + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Telegram**,填入 Bot Token,点击接入即可。 在 `config.json` 中添加以下配置后启动: @@ -43,7 +47,6 @@ Telegram Bot 默认开启 **Privacy Mode**,在群聊中只能收到 `@bot` 或 { "channel_type": "telegram", "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", - "telegram_proxy": "", "telegram_group_trigger": "mention_or_reply" } ``` @@ -51,13 +54,9 @@ Telegram Bot 默认开启 **Privacy Mode**,在群聊中只能收到 `@bot` 或 | 参数 | 说明 | 默认值 | | --- | --- | --- | | `telegram_token` | BotFather 返回的 HTTP API Token | - | - | `telegram_proxy` | 代理地址(如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`),留空则走系统环境变量 `HTTPS_PROXY` | `""` | | `telegram_group_trigger` | 群聊触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | | `telegram_register_commands` | 启动时是否自动向 BotFather 注册命令菜单 | `true` | - - - 若运行环境无法直接访问 `api.telegram.org`,需配置 `telegram_proxy`,或为运行进程设置 `HTTPS_PROXY` 环境变量。 - + | `telegram_proxy` | (可选)代理地址,如 `http://127.0.0.1:7890`、`socks5://127.0.0.1:1080`;运行环境无法直连 `api.telegram.org` 时配置,留空则使用环境变量 `HTTPS_PROXY` | `""` | @@ -108,6 +107,6 @@ Telegram Bot 默认开启 **Privacy Mode**,在群聊中只能收到 `@bot` 或 完成接入后: - **单聊**:在 Telegram 中搜索你创建的 Bot 用户名(如 `@my_cowagent_bot`),点击 `Start` 即可开始对话。 -- **群聊**:把 Bot 拉进群,使用 `@bot 你好` 或 **回复 Bot 的某条消息** 触发对话。 +- **群聊**:把 Bot 拉进群,使用 `@bot 你好` 或 **回复 Bot 的某条消息** 触发对话。若群聊无响应,请检查 Privacy Mode 是否已按 [步骤二](#步骤二-群聊使用-关闭-privacy-mode) 关闭。 -直接发送图片、语音、文件给 Bot,机器人会自动识图、转写或解析内容。也可以先发图再发问题,两条消息会自动合并提问。 +发送图片或文件时,可以直接在附件上方的输入框中 **添加 Caption**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/docs.json b/docs/docs.json index 4568beff..93c3aa37 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -400,7 +400,8 @@ "en/channels/wecom-bot", "en/channels/qq", "en/channels/wecom", - "en/channels/wechatmp" + "en/channels/wechatmp", + "en/channels/telegram" ] } ] @@ -603,7 +604,8 @@ "ja/channels/wecom-bot", "ja/channels/qq", "ja/channels/wecom", - "ja/channels/wechatmp" + "ja/channels/wechatmp", + "ja/channels/telegram" ] } ] diff --git a/docs/en/channels/index.mdx b/docs/en/channels/index.mdx index 41fc3230..3533b0d3 100644 --- a/docs/en/channels/index.mdx +++ b/docs/en/channels/index.mdx @@ -19,6 +19,7 @@ The table below summarizes the inbound message types, bot reply types, and group | [QQ](/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [Official Account](/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | - The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details - The **Group Chat** column indicates the ability to recognize and respond to group messages @@ -37,3 +38,4 @@ The table below summarizes the inbound message types, bot reply types, and group - [QQ](/en/channels/qq) — QQ Official Bot open platform - [WeCom App](/en/channels/wecom) — WeCom custom app integration - [Official Account](/en/channels/wechatmp) — WeChat Official Account (subscription / service) +- [Telegram](/en/channels/telegram) — global IM, 5-minute setup, no public IP needed diff --git a/docs/en/channels/telegram.mdx b/docs/en/channels/telegram.mdx new file mode 100644 index 00000000..f90da992 --- /dev/null +++ b/docs/en/channels/telegram.mdx @@ -0,0 +1,111 @@ +--- +title: Telegram +description: Integrate CowAgent with Telegram via the Bot API +--- + +> Integrate CowAgent into Telegram via the official Bot API. Supports private chat and group chat (triggered by @mention or replying to the bot). Uses Long Polling — no public IP required, works out of the box. + + +## 1. Setup + +### Step 1: Create a Bot via BotFather + +1. Open the official account [@BotFather](https://t.me/BotFather) in Telegram. +2. Send `/newbot` and follow the prompts: + - **Bot name** (display name, e.g. `My CowAgent Bot`) + - **Bot username** (must end with `bot`, e.g. `my_cowagent_bot`) +3. Once created, BotFather returns an **HTTP API Token** (e.g. `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`). Keep it safe. + + + The token is the password of your bot — never share it. If it leaks, send `/revoke` to `@BotFather` to reset it. + + +### Step 2: (Group chat only) Disable Privacy Mode + +Skip this step if you only use private chat. Telegram bots run in **Privacy Mode** by default — in groups they can only see commands suffixed with `@bot` (e.g. `/start@your_bot`) and replies to bot messages; **plain `@bot hello` text messages are not delivered**, so the bot will appear unresponsive in groups. + +Send the following to `@BotFather`: + +1. `/setprivacy` +2. Pick the bot you just created +3. Choose `Disable` + + + If the bot is still silent in groups after this, try removing it from the group and adding it back. + + +### Step 3: Connect to CowAgent + + + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Telegram**, paste the Bot Token, and click connect. + + + Add the following to `config.json` and start Cow: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | Key | Description | Default | + | --- | --- | --- | + | `telegram_token` | HTTP API Token returned by BotFather | - | + | `telegram_group_trigger` | Group trigger: `mention_or_reply` (@ or reply) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | + | `telegram_register_commands` | Whether to register the command menu with BotFather on startup | `true` | + + + +The integration is ready when you see logs like: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 2. Capabilities + +| Feature | Support | +| --- | --- | +| Private chat | ✅ | +| Group chat (@bot / reply to bot) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| Voice messages | ✅ send / receive (OGG/Opus) | +| Video messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | +| Command menu | ✅ aligned with Web Console slash commands | + +### Command Menu + +On startup, the channel registers a command menu with BotFather. Typing `/` in Telegram shows a dropdown: + +| Command | Description | +| --- | --- | +| `/help` | Show command help | +| `/status` | View runtime status | +| `/context` | View conversation context (`/context clear` to clear) | +| `/skill` | Skill management (`/skill list`, `/skill install`, ...) | +| `/memory` | Memory management (`/memory dream`) | +| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) | +| `/config` | View current config | +| `/cancel` | Cancel the running Agent task | +| `/logs` | View recent logs | +| `/version` | Show version | + + + Telegram's command menu only displays top-level commands; subcommands are entered with a space, e.g. `/skill list`, `/context clear`. + + +## 3. Usage + +Once connected: + +- **Private chat**: search for your bot username (e.g. `@my_cowagent_bot`) in Telegram, click `Start` and chat away. +- **Group chat**: add the bot to a group, then trigger it with `@bot hello` or by **replying to one of the bot's messages**. If the bot doesn't respond in groups, double-check Privacy Mode in [Step 2](#step-2-group-chat-only-disable-privacy-mode). + +When sending an image or file, you can **add a caption** (description / question) directly in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/ja/README.md b/docs/ja/README.md index df71ec74..69beb6ba 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -30,7 +30,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています | [ナレッジベース](https://docs.cowagent.ai/ja/knowledge/index) | 構造化された知識を Markdown Wiki として自動整理し、進化し続けるナレッジグラフを可視化ブラウジング | | [Skill](https://docs.cowagent.ai/ja/skills/index) | [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub からワンクリックでインストール;対話によるカスタム Skill 作成にも対応 | | [ツール](https://docs.cowagent.ai/ja/tools/index) | ファイル I/O、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10+ の組み込みツール — MCP プロトコルに完全対応 | -| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウントを同時にサポート | +| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram を同時にサポート | | マルチモーダル | テキスト・画像・音声・ファイルをフルサポート — 認識・生成・双方向送受信 | | [モデル](https://docs.cowagent.ai/ja/models/index) | Claude、GPT、Gemini、DeepSeek、GLM、Qwen、Kimi、MiniMax、Doubao など、設定 1 行で切り替え可能 | | [デプロイ](https://docs.cowagent.ai/ja/guide/quick-start) | ワンラインインストーラー、統合された Web コンソール、複数のデプロイモード(ローカル / Docker / サーバー) | @@ -126,6 +126,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [QQ](https://docs.cowagent.ai/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](https://docs.cowagent.ai/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](https://docs.cowagent.ai/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](https://docs.cowagent.ai/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | > Feishu と WeCom Bot は **Web コンソール内で QR コードをスキャンするだけで接続**できます — パブリック IP は不要です。詳細は [チャネル概要](https://docs.cowagent.ai/ja/channels/index) を参照してください。 diff --git a/docs/ja/channels/index.mdx b/docs/ja/channels/index.mdx index 93774e73..ac6a5acf 100644 --- a/docs/ja/channels/index.mdx +++ b/docs/ja/channels/index.mdx @@ -19,6 +19,7 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | [QQ](/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | - **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください - **グループチャット**列はグループメッセージを認識して応答できることを示します @@ -37,3 +38,4 @@ CowAgent は複数のチャットチャネルへの接続に対応しており - [QQ](/ja/channels/qq) — QQ 公式ボットオープンプラットフォーム - [WeCom アプリ](/ja/channels/wecom) — WeCom 自作アプリ接続 - [WeChat 公式アカウント](/ja/channels/wechatmp) — WeChat 公式アカウント(購読アカウント / サービスアカウント) +- [Telegram](/ja/channels/telegram) — グローバル IM、5 分で接続、公開 IP 不要 diff --git a/docs/ja/channels/telegram.mdx b/docs/ja/channels/telegram.mdx new file mode 100644 index 00000000..eab1d3b0 --- /dev/null +++ b/docs/ja/channels/telegram.mdx @@ -0,0 +1,111 @@ +--- +title: Telegram +description: Telegram Bot API 経由で CowAgent を接続 +--- + +> 公式の Telegram Bot API を通じて CowAgent を接続します。1 対 1 チャットおよびグループチャット(@メンションまたはボットへの返信で起動)に対応。Long Polling 方式のため公開 IP は不要で、すぐに利用できます。 + + +## 1. 接続手順 + +### ステップ 1: BotFather で Bot を作成 + +1. Telegram で公式アカウント [@BotFather](https://t.me/BotFather) を開きます。 +2. `/newbot` を送り、案内に従って入力します: + - **Bot 名**(表示名、例: `My CowAgent Bot`) + - **Bot ユーザー名**(`bot` で終わる必要があります、例: `my_cowagent_bot`) +3. 作成完了後、BotFather から **HTTP API Token**(例: `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`)が返されます。大切に保管してください。 + + + Token は Bot のパスワードに相当します。漏えいしないよう注意してください。万が一漏れた場合は `@BotFather` に `/revoke` を送って再発行できます。 + + +### ステップ 2:(グループ利用時)Privacy Mode を無効化 + +1 対 1 チャットのみ利用する場合はスキップ可能です。Telegram Bot は既定で **Privacy Mode** が有効で、グループ内では `@bot` 接尾辞付きのコマンド(例: `/start@your_bot`)と、Bot メッセージへの返信のみ受信できます。**通常の `@bot こんにちは` のようなテキストメッセージは届きません**。そのままだとグループで反応しないので、必要に応じて以下を設定してください。 + +`@BotFather` に対して: + +1. `/setprivacy` を送信 +2. 作成した Bot を選択 +3. `Disable` を選択 + + + 設定後もグループで反応しない場合は、Bot を一度グループから外して再度追加してみてください。 + + +### ステップ 3: CowAgent に接続 + + + + Web コンソール(既定 `http://127.0.0.1:9899`)を開き、**チャネル** メニュー → **チャネルを追加** → **Telegram** を選択し、Bot Token を貼り付けて接続をクリックします。 + + + `config.json` に以下を追加して Cow を起動します: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | パラメータ | 説明 | 既定値 | + | --- | --- | --- | + | `telegram_token` | BotFather から発行された HTTP API Token | - | + | `telegram_group_trigger` | グループのトリガー方式: `mention_or_reply`(@ または返信)/ `mention_only`(@ のみ)/ `all`(全メッセージ) | `mention_or_reply` | + | `telegram_register_commands` | 起動時に BotFather にコマンドメニューを登録するかどうか | `true` | + + + +ログに以下のような出力が表示されれば接続成功です: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 2. 機能 + +| 機能 | 対応状況 | +| --- | --- | +| 1 対 1 チャット | ✅ | +| グループチャット(@bot / Bot への返信) | ✅ | +| テキストメッセージ | ✅ 送受信 | +| 画像メッセージ | ✅ 送受信 | +| 音声メッセージ | ✅ 送受信(OGG/Opus) | +| 動画メッセージ | ✅ 送受信 | +| ファイルメッセージ | ✅ 送受信(PDF / Word / Excel など) | +| コマンドメニュー | ✅ Web コンソールの slash コマンドと一致 | + +### コマンドメニュー + +起動時に BotFather へコマンドメニューを自動登録します。Telegram の入力欄で `/` を入力するとサジェストが表示されます: + +| コマンド | 説明 | +| --- | --- | +| `/help` | コマンドヘルプを表示 | +| `/status` | 実行ステータスを確認 | +| `/context` | 対話コンテキストを表示(`/context clear` でクリア) | +| `/skill` | スキル管理(`/skill list`、`/skill install` など) | +| `/memory` | 記憶管理(`/memory dream`) | +| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) | +| `/config` | 現在の設定を表示 | +| `/cancel` | 実行中の Agent タスクを中断 | +| `/logs` | 最近のログを表示 | +| `/version` | バージョンを表示 | + + + Telegram のコマンドメニューはトップレベルのコマンドのみ表示されます。サブコマンドはスペース区切りで入力します(例: `/skill list`、`/context clear`)。 + + +## 3. 使い方 + +接続が完了したら: + +- **1 対 1 チャット**: Telegram で Bot のユーザー名(例: `@my_cowagent_bot`)を検索し、`Start` をタップして会話を開始します。 +- **グループチャット**: Bot をグループに追加し、`@bot こんにちは` または **Bot のメッセージに返信** することで起動します。グループで反応しない場合は [ステップ 2](#ステップ-2-グループ利用時-privacy-mode-を無効化) の Privacy Mode 設定を確認してください。 + +画像やファイルを送るときは、添付欄の上の入力欄に **キャプション**(説明・質問)を直接書いて一緒に送信できます。Bot は添付ファイルとキャプションを合わせて回答します。先に添付を送り、その後に質問を送る形でも、2 つのメッセージは自動でまとめて処理されます。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 095e9194..79bcb769 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -9,7 +9,7 @@ **CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆,与你一同成长,是 Agent Harness 工程的最佳实践之一。 -CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、网页等多渠道,7×24 运行于个人电脑或服务器中。 +CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、网页等多渠道,7×24 运行于个人电脑或服务器中。

🌐 官网  ·  @@ -30,7 +30,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖 | [知识库](https://docs.cowagent.ai/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 | | [技能](https://docs.cowagent.ai/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 | | [工具](https://docs.cowagent.ai/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 | -| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号 等多个渠道 | +| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram 等多个渠道 | | 多模态 | 文本、图片、语音、文件全消息类型支持,覆盖识别、生成、收发 | | [模型](https://docs.cowagent.ai/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流厂商,配置一行切换 | | [部署](https://docs.cowagent.ai/guide/quick-start) | 一键脚本安装,Web 控制台统一管理;本地、Docker、服务器多种部署方式 | @@ -126,6 +126,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [企业微信应用](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | > 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/channels)。 From b7b8e3679cdde6d2b5ad5dda1a3451916106cb4a Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 28 May 2026 15:48:20 +0800 Subject: [PATCH 280/399] fix: avoid conflict with pypi translate package --- requirements.txt | 4 ++-- translate/__init__.py | 0 translate/baidu/__init__.py | 0 translate/youdao/__init__.py | 0 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 translate/__init__.py create mode 100644 translate/baidu/__init__.py create mode 100644 translate/youdao/__init__.py diff --git a/requirements.txt b/requirements.txt index b6592d70..d767e21a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,5 +27,5 @@ dingtalk_stream # wecom bot websocket mode websocket-client>=1.4.0 pycryptodome -# telegram bot (requires Python >= 3.9; skipped on older versions) -python-telegram-bot; python_version >= "3.9" +# telegram bot +python-telegram-bot diff --git a/translate/__init__.py b/translate/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/translate/baidu/__init__.py b/translate/baidu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/translate/youdao/__init__.py b/translate/youdao/__init__.py new file mode 100644 index 00000000..e69de29b From c605b0b08044211822d5eeb67fb23b73ac08d024 Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 18:11:41 +0800 Subject: [PATCH 281/399] =?UTF-8?q?feat(wechat=5Fkf):=20cache=20images/fil?= =?UTF-8?q?es=20and=20merge=20into=20next=20text=20turn=20Adopt=20the=20sa?= =?UTF-8?q?me=20channel-level=20pattern=20as=20weixin/wecom=5Fbot/feishu?= =?UTF-8?q?=20so=20the=20agent=20actually=20sees=20attachments=20the=20use?= =?UTF-8?q?r=20sent:=20-=20IMAGE:=20agent=20mode=20never=20reads=20memory.?= =?UTF-8?q?USER=5FIMAGE=5FCACHE,=20so=20a=20photo=20=20=20sent=20before=20?= =?UTF-8?q?a=20question=20(e.g.=20"image"=20then=2030s=20later=20"what's?= =?UTF-8?q?=20this=3F")=20=20=20used=20to=20be=20lost.=20Now=20lone=20imag?= =?UTF-8?q?es=20go=20into=20channel.file=5Fcache=20and=20=20=20the=20next?= =?UTF-8?q?=20TEXT=20turn=20appends=20"[=E5=9B=BE=E7=89=87:=20]"=20t?= =?UTF-8?q?o=20the=20query=20before=20=20=20producing=20the=20context.=20C?= =?UTF-8?q?ross-batch=20image+text=20combinations=20now=20=20=20work=20as?= =?UTF-8?q?=20users=20expect.=20-=20FILE:=20previously=20dropped=20at=20th?= =?UTF-8?q?e=20sync=5Fmsg=20filter=20and=20unsupported=20=20=20by=20Wechat?= =?UTF-8?q?KfMessage.=20Add=20msgtype=3D"file"=20parsing,=20download=20via?= =?UTF-8?q?=20the=20=20=20WeCom=20media=20API,=20preserve=20the=20original?= =?UTF-8?q?=20filename=20from=20=20=20Content-Disposition=20(RFC=205987=20?= =?UTF-8?q?+=20plain=20forms),=20and=20route=20through=20=20=20the=20same?= =?UTF-8?q?=20file=5Fcache=20pipeline=20as=20images,=20surfacing=20as=20?= =?UTF-8?q?=20=20"[=E6=96=87=E4=BB=B6:=20]"=20in=20the=20next=20text?= =?UTF-8?q?=20turn.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- channel/wechat_kf/wechat_kf_channel.py | 41 ++++++++++++++++++++++++-- channel/wechat_kf/wechat_kf_message.py | 40 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index e33662db..c613d92f 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -30,9 +30,10 @@ from wechatpy.enterprise.crypto import WeChatCrypto from wechatpy.enterprise.exceptions import InvalidCorpIdException from wechatpy.exceptions import InvalidSignatureException, WeChatClientException -from bridge.context import Context +from bridge.context import Context, ContextType from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel +from channel.file_cache import get_file_cache from channel.wechat_kf.wechat_kf_cursor_store import CursorStore from channel.wechat_kf.wechat_kf_message import WechatKfMessage from common.log import logger @@ -314,12 +315,48 @@ class WechatKfChannel(ChatChannel): msgs = self._pull_messages(token, open_kfid, existing_cursor) if not msgs: return + file_cache = get_file_cache() for raw in msgs: try: kf_msg = WechatKfMessage(msg=raw, client=self.client) except NotImplementedError as e: logger.debug("[wechat_kf] {}".format(e)) continue + + session_id = kf_msg.from_user_id + + # Cache lone images/files and wait for the user's follow-up + # text. Agent mode never reads memory.USER_IMAGE_CACHE, so + # without this the attachment is effectively lost. + if kf_msg.ctype in (ContextType.IMAGE, ContextType.FILE): + ftype = "image" if kf_msg.ctype == ContextType.IMAGE else "file" + try: + kf_msg.prepare() # download to local tmp path + file_cache.add(session_id, kf_msg.content, file_type=ftype) + logger.info( + "[wechat_kf] {} cached for session {}: {}".format( + ftype, session_id, kf_msg.content + ) + ) + except Exception as e: + logger.warning(f"[wechat_kf] cache {ftype} failed: {e}") + continue + + # On a text turn, attach any pending images/files as references + # so the downstream agent can pick them up via the text content. + if kf_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}]") + else: + refs.append(f"[文件: {fpath}]") + kf_msg.content = kf_msg.content + "\n" + "\n".join(refs) + file_cache.clear(session_id) + context = self._compose_context( kf_msg.ctype, kf_msg.content, @@ -371,7 +408,7 @@ class WechatKfChannel(ChatChannel): # back into ourselves. if not item.get("external_userid"): continue - if item.get("msgtype") in ("text", "image", "voice"): + if item.get("msgtype") in ("text", "image", "voice", "file"): collected.append(item) cursor_after = data.get("next_cursor") or "" if cursor_after: diff --git a/channel/wechat_kf/wechat_kf_message.py b/channel/wechat_kf/wechat_kf_message.py index 2505dbea..0abe61ad 100644 --- a/channel/wechat_kf/wechat_kf_message.py +++ b/channel/wechat_kf/wechat_kf_message.py @@ -3,6 +3,9 @@ Adapter that turns a single `sync_msg` item from WeCom customer-service into a CoW `ChatMessage` object. """ +import os +import re + from wechatpy.enterprise import WeChatClient from bridge.context import ContextType @@ -11,6 +14,23 @@ from common.log import logger from common.tmp_dir import TmpDir +def _extract_filename(content_disposition: str) -> str: + """Best-effort parse of `filename` / `filename*` from a Content-Disposition + header. Returns '' when nothing usable is found.""" + if not content_disposition: + return "" + # RFC 5987 form: filename*=UTF-8''xxx + m = re.search(r"filename\*=(?:[^'\"]*'[^']*'\s*)?([^;]+)", content_disposition) + if m: + try: + from urllib.parse import unquote + return unquote(m.group(1).strip().strip('"')) + except Exception: + return m.group(1).strip().strip('"') + m = re.search(r'filename\s*=\s*"?([^";]+)"?', content_disposition) + return m.group(1).strip() if m else "" + + class WechatKfMessage(ChatMessage): """ msg structure (from cgi-bin/kf/sync_msg): @@ -72,6 +92,26 @@ class WechatKfMessage(ChatMessage): logger.info(f"[wechat_kf] Failed to download voice, {response.content}") self._prepare_fn = download_voice + elif self.msgtype == "file": + self.ctype = ContextType.FILE + media_id = msg.get("file", {}).get("media_id", "") + # Provisional path; rewritten in download_file() once we have + # the original filename from Content-Disposition. + self.content = TmpDir().path() + media_id + + def download_file(): + response = client.media.download(media_id) + if response.status_code == 200: + filename = _extract_filename( + response.headers.get("Content-Disposition", "") + ) or media_id + self.content = os.path.join(TmpDir().path(), filename) + with open(self.content, "wb") as f: + f.write(response.content) + else: + logger.info(f"[wechat_kf] Failed to download file, {response.content}") + + self._prepare_fn = download_file else: raise NotImplementedError( f"[wechat_kf] Unsupported message type: {self.msgtype}" From 136b0b89e8323fda5adaccd1eb62cebef66707f7 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 28 May 2026 19:09:26 +0800 Subject: [PATCH 282/399] fix: optimize browser memory --- agent/tools/browser/browser_service.py | 16 +++++++++++++++- common/utils.py | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py index 69ec0e06..f499fb29 100644 --- a/agent/tools/browser/browser_service.py +++ b/agent/tools/browser/browser_service.py @@ -15,7 +15,7 @@ import threading from typing import Optional, Dict, Any, List, Callable from common.log import logger -from common.utils import expand_path +from common.utils import expand_path, is_cloud_deployment _DEFAULT_USER_DATA_DIR = "~/.cow/browser_profile" @@ -436,6 +436,20 @@ class BrowserService: if self._headless: launch_args.append("--no-sandbox") + if is_cloud_deployment(): + launch_args.extend([ + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-extensions", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--disable-features=site-per-process,TranslateUI,IsolateOrigins", + "--no-zygote", + "--js-flags=--max-old-space-size=384", + "--memory-pressure-off", + ]) + extra_args = self._config.get("launch_args", []) if extra_args: launch_args.extend(extra_args) diff --git a/common/utils.py b/common/utils.py index 812b20ab..e7264e20 100644 --- a/common/utils.py +++ b/common/utils.py @@ -117,6 +117,18 @@ def expand_path(path: str) -> str: return expanded +def is_cloud_deployment() -> bool: + if os.environ.get("CLOUD_DEPLOYMENT_ID"): + return True + try: + from config import conf + if conf().get("cloud_deployment_id"): + return True + except Exception: + pass + return False + + def get_cloud_headers(api_key: str) -> dict: """ Build standard headers for LinkAI API requests, From 99bddb79d679d6887a130715ba5cfce4eb8fd858 Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 28 May 2026 19:40:12 +0800 Subject: [PATCH 283/399] fix(wechat_kf): download attachments to agent_workspace/tmp So agent tools resolve relative refs like mp/xxx.pdf on the first try, matching weixin's _get_tmp_dir convention. --- channel/wechat_kf/wechat_kf_channel.py | 2 ++ channel/wechat_kf/wechat_kf_message.py | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index c613d92f..3a21f595 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -344,6 +344,8 @@ class WechatKfChannel(ChatChannel): # On a text turn, attach any pending images/files as references # so the downstream agent can pick them up via the text content. + # Paths are already under agent_workspace/tmp (see + # WechatKfMessage._get_tmp_dir), so a relative ref also works. if kf_msg.ctype == ContextType.TEXT: cached_files = file_cache.get(session_id) if cached_files: diff --git a/channel/wechat_kf/wechat_kf_message.py b/channel/wechat_kf/wechat_kf_message.py index 0abe61ad..4285f8ba 100644 --- a/channel/wechat_kf/wechat_kf_message.py +++ b/channel/wechat_kf/wechat_kf_message.py @@ -11,7 +11,19 @@ from wechatpy.enterprise import WeChatClient from bridge.context import ContextType from channel.chat_message import ChatMessage from common.log import logger -from common.tmp_dir import TmpDir +from common.utils import expand_path +from config import conf + + +def _get_tmp_dir() -> str: + """Save under agent_workspace/tmp/ so agent tools (e.g. `read`) can + resolve a relative path like `tmp/xxx.pdf` against their own + workspace root. Mirrors the convention used by weixin / wecom_bot. + """ + 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 def _extract_filename(content_disposition: str) -> str: @@ -66,7 +78,7 @@ class WechatKfMessage(ChatMessage): elif self.msgtype == "image": self.ctype = ContextType.IMAGE media_id = msg.get("image", {}).get("media_id", "") - self.content = TmpDir().path() + media_id + ".jpg" + self.content = os.path.join(_get_tmp_dir(), media_id + ".jpg") def download_image(): response = client.media.download(media_id) @@ -81,7 +93,7 @@ class WechatKfMessage(ChatMessage): self.ctype = ContextType.VOICE media_id = msg.get("voice", {}).get("media_id", "") # WeCom returns amr by default; downstream voice pipeline will convert. - self.content = TmpDir().path() + media_id + ".amr" + self.content = os.path.join(_get_tmp_dir(), media_id + ".amr") def download_voice(): response = client.media.download(media_id) @@ -97,7 +109,7 @@ class WechatKfMessage(ChatMessage): media_id = msg.get("file", {}).get("media_id", "") # Provisional path; rewritten in download_file() once we have # the original filename from Content-Disposition. - self.content = TmpDir().path() + media_id + self.content = os.path.join(_get_tmp_dir(), media_id) def download_file(): response = client.media.download(media_id) @@ -105,7 +117,7 @@ class WechatKfMessage(ChatMessage): filename = _extract_filename( response.headers.get("Content-Disposition", "") ) or media_id - self.content = os.path.join(TmpDir().path(), filename) + self.content = os.path.join(_get_tmp_dir(), filename) with open(self.content, "wb") as f: f.write(response.content) else: From 3dd4b8417926dc73faba215fe0bd88409a19edfb Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 29 May 2026 10:19:45 +0800 Subject: [PATCH 284/399] feat(models): support claude-opus-4-8 --- README.md | 2 +- channel/web/web_channel.py | 6 +++--- common/const.py | 5 +++-- docs/en/models/claude.mdx | 11 ++++++----- docs/en/models/index.mdx | 2 +- docs/ja/README.md | 2 +- docs/ja/models/claude.mdx | 11 ++++++----- docs/ja/models/index.mdx | 2 +- docs/models/claude.mdx | 11 ++++++----- docs/models/index.mdx | 2 +- docs/zh/README.md | 2 +- run.sh | 4 ++-- scripts/run.ps1 | 4 ++-- 13 files changed, 34 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 70b2707f..2d1d61ae 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation | Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [Claude](https://docs.cowagent.ai/en/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | | +| [Claude](https://docs.cowagent.ai/en/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [OpenAI](https://docs.cowagent.ai/en/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Gemini](https://docs.cowagent.ai/en/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [DeepSeek](https://docs.cowagent.ai/en/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 57891855..c564696e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1379,7 +1379,7 @@ class ConfigHandler: _RECOMMENDED_MODELS = [ const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, - const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET, + const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, @@ -1424,7 +1424,7 @@ class ConfigHandler: "api_base_key": "claude_api_base", "api_base_default": "https://api.anthropic.com/v1", "api_base_placeholder": _PLACEHOLDER_V1, - "models": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET], + "models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET], }), ("gemini", { "label": "Gemini", @@ -1929,7 +1929,7 @@ class ModelsHandler: "doubao": [const.DOUBAO_SEED_2_PRO], "moonshot": [const.KIMI_K2_6], "dashscope": [const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX], - "claudeAPI": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS], + "claudeAPI": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], "gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE], "qianfan": [const.ERNIE_45_TURBO_VL], # Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what diff --git a/common/const.py b/common/const.py index 4daea457..8ec2a2ff 100644 --- a/common/const.py +++ b/common/const.py @@ -30,8 +30,9 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名 CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型 CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620" CLAUDE_4_OPUS = "claude-opus-4-0" +CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent推荐模型 CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7 -CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 - Agent推荐模型 +CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0 CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型 CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent推荐模型 @@ -194,7 +195,7 @@ MODEL_LIST = [ MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, # Claude - CLAUDE3, CLAUDE_4_6_SONNET, CLAUDE_4_7_OPUS, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229, + CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, 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, CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU, "claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet", diff --git a/docs/en/models/claude.mdx b/docs/en/models/claude.mdx index 28a00c6f..bb831eb8 100644 --- a/docs/en/models/claude.mdx +++ b/docs/en/models/claude.mdx @@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand ```json { - "model": "claude-sonnet-4-6", + "model": "claude-opus-4-8", "claude_api_key": "YOUR_API_KEY" } ``` | Parameter | Description | | --- | --- | -| `model` | Supports `claude-sonnet-4-6`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) | +| `model` | Supports `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) | | `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) | | `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy | @@ -28,9 +28,10 @@ Claude is provided by Anthropic and supports both text chat and image understand | Model | Use Case | | --- | --- | -| `claude-sonnet-4-6` | Default recommended, balanced cost and speed | -| `claude-opus-4-7` | Complex reasoning and long-running tasks; best quality but higher cost | -| `claude-sonnet-4-5` / `claude-sonnet-4-0` | Previous-generation flagships at a lower price | +| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks | +| `claude-opus-4-7` | Previous-generation Opus flagship | +| `claude-sonnet-4-6` | Balanced cost and speed, lower cost | +| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price | ## Image Understanding diff --git a/docs/en/models/index.mdx b/docs/en/models/index.mdx index cbe74d41..1a82d162 100644 --- a/docs/en/models/index.mdx +++ b/docs/en/models/index.mdx @@ -13,7 +13,7 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/en/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/en/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/en/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | | +| [Claude](/en/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/en/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/en/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [GLM](/en/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/docs/ja/README.md b/docs/ja/README.md index 69beb6ba..3db69d8c 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -94,7 +94,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | | +| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | diff --git a/docs/ja/models/claude.mdx b/docs/ja/models/claude.mdx index 7fcc94da..2b951e34 100644 --- a/docs/ja/models/claude.mdx +++ b/docs/ja/models/claude.mdx @@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像 ```json { - "model": "claude-sonnet-4-6", + "model": "claude-opus-4-8", "claude_api_key": "YOUR_API_KEY" } ``` | パラメータ | 説明 | | --- | --- | -| `model` | `claude-sonnet-4-6`、`claude-opus-4-7`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 | +| `model` | `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 | | `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 | | `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 | @@ -28,9 +28,10 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像 | モデル | 用途 | | --- | --- | -| `claude-sonnet-4-6` | デフォルト推奨。コストパフォーマンスと速度のバランスが良い | -| `claude-opus-4-7` | 複雑な推論や長いタスクチェーンに最適。効果は最高だがコストも高い | -| `claude-sonnet-4-5` / `claude-sonnet-4-0` | 前世代のフラッグシップ。価格はより安い | +| `claude-opus-4-8` | デフォルト推奨。最新フラッグシップ。複雑な推論や長いタスクチェーンに最適 | +| `claude-opus-4-7` | 前世代の Opus フラッグシップ | +| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い | +| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い | ## 画像理解 diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 9b9c5f44..0f3916a3 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -20,7 +20,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | | +| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Zhipu GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/docs/models/claude.mdx b/docs/models/claude.mdx index 05b6164c..ee1809d6 100644 --- a/docs/models/claude.mdx +++ b/docs/models/claude.mdx @@ -13,14 +13,14 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet ```json { - "model": "claude-sonnet-4-6", + "model": "claude-opus-4-8", "claude_api_key": "YOUR_API_KEY" } ``` | 参数 | 说明 | | --- | --- | -| `model` | 支持 `claude-sonnet-4-6`、`claude-opus-4-7`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) | +| `model` | 支持 `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) | | `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 | | `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 | @@ -28,9 +28,10 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet | 模型 | 适用场景 | | --- | --- | -| `claude-sonnet-4-6` | 默认推荐,性价比与速度平衡 | -| `claude-opus-4-7` | 复杂推理与长链路任务,效果最佳但成本更高 | -| `claude-sonnet-4-5` / `claude-sonnet-4-0` | 上一代旗舰,价格更低 | +| `claude-opus-4-8` | 默认推荐,最新旗舰,复杂推理与长链路任务效果最佳 | +| `claude-opus-4-7` | 上一代 Opus 旗舰 | +| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 | +| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 | ## 图像理解 diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 114c58e0..02402b6a 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -14,7 +14,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | | +| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [智谱 GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/docs/zh/README.md b/docs/zh/README.md index 79bcb769..377d1beb 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -96,7 +96,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | | +| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [智谱 GLM](https://docs.cowagent.ai/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/run.sh b/run.sh index c7ebd0a0..5dff9785 100755 --- a/run.sh +++ b/run.sh @@ -311,7 +311,7 @@ select_model() { echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${YELLOW}1) DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)${NC}" echo -e "${YELLOW}2) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)${NC}" - echo -e "${YELLOW}3) Claude (claude-sonnet-4-6, claude-opus-4-7, claude-opus-4-6, etc.)${NC}" + echo -e "${YELLOW}3) Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)${NC}" echo -e "${YELLOW}4) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)${NC}" echo -e "${YELLOW}5) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)${NC}" echo -e "${YELLOW}6) Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)${NC}" @@ -360,7 +360,7 @@ configure_model() { 1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;; 2) read_model_config "MiniMax" "MiniMax-M2.7" "MINIMAX_KEY" ;; 3) - read_model_config "Claude" "claude-sonnet-4-6" "CLAUDE_KEY" + read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" read_api_base "CLAUDE_BASE" "https://api.anthropic.com/v1" ;; 4) diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 18300d11..7c5f0b06 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -175,7 +175,7 @@ $ModelChoices = @{ "4" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Key = "MOONSHOT_KEY" } "5" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" } "6" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Key = "DASHSCOPE_KEY" } - "7" = @{ Provider = "Claude"; Default = "claude-sonnet-4-6"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" } + "7" = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" } "8" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" } "9" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" } "10" = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Key = "LINKAI_KEY" } @@ -191,7 +191,7 @@ function Select-Model { Write-Host "4) Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)" Write-Host "5) Doubao (doubao-seed-2-0-code-preview-260215, etc.)" Write-Host "6) Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" - Write-Host "7) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)" + Write-Host "7) Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)" Write-Host "8) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" Write-Host "9) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" Write-Host "10) LinkAI (access multiple models via one API)" From 458b1a1d88fbc7c2f42ecb8edbfde7b83ea25829 Mon Sep 17 00:00:00 2001 From: 6vision Date: Sat, 30 May 2026 14:41:51 +0800 Subject: [PATCH 285/399] fix(wechatmp): merge cached text segments in passive reply In subscription account passive reply mode, WeChat allows only one reply per request. Multi-turn agent output was cached as separate entries, forcing the user to send an extra message to fetch each one. Now drain and merge all consecutive cached text segments into a single reply; media still returns one at a time. Co-authored-by: Cursor --- channel/wechatmp/passive_reply.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/channel/wechatmp/passive_reply.py b/channel/wechatmp/passive_reply.py index d03efc4d..6206e0e7 100644 --- a/channel/wechatmp/passive_reply.py +++ b/channel/wechatmp/passive_reply.py @@ -131,8 +131,22 @@ class Query: # Only one request can access to the cached data try: - (reply_type, reply_content) = channel.cache_dict[from_user].pop(0) - if not channel.cache_dict[from_user]: # If popping the message makes the list empty, delete the user entry from cache + # WeChat passive reply allows only a single reply per request. + # To avoid forcing the user to send an extra message for every + # segment of multi-turn agent output, drain all consecutive + # cached text segments at once and merge them into one reply. + # Media (voice/image) can only be returned one at a time, so it + # stops the merge and is returned on its own. + cached = channel.cache_dict[from_user] + if cached[0][0] == "text": + reply_type = "text" + merged_parts = [] + while cached and cached[0][0] == "text": + merged_parts.append(cached.pop(0)[1]) + reply_content = "\n\n".join(merged_parts) + else: + (reply_type, reply_content) = cached.pop(0) + if not channel.cache_dict[from_user]: # If draining empties the list, delete the user entry from cache del channel.cache_dict[from_user] except IndexError: return "success" From 5aca54c08336a1f7074218921c5ab6d78cddebca Mon Sep 17 00:00:00 2001 From: 6vision Date: Sat, 30 May 2026 15:48:27 +0800 Subject: [PATCH 286/399] fix(wechatmp): flush cached segments while task still running Previously the passive reply only drained the cache after the agent task fully finished, so for long multi-turn tasks the user could not retrieve already-cached intermediate segments. Now return cached segments as soon as they are available, even while the task is still running; the next user message fetches the rest. Co-authored-by: Cursor --- channel/wechatmp/passive_reply.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/channel/wechatmp/passive_reply.py b/channel/wechatmp/passive_reply.py index 6206e0e7..85b3b402 100644 --- a/channel/wechatmp/passive_reply.py +++ b/channel/wechatmp/passive_reply.py @@ -103,14 +103,21 @@ class Query: task_running = True waiting_until = request_time + 4 while time.time() < waiting_until: - if from_user in channel.running: - time.sleep(0.1) - else: + if from_user not in channel.running: task_running = False break + # Task still running, but if it has already produced cached + # segments (e.g. multi-turn thinking output), return them now + # instead of forcing the user to wait for the whole task. The + # remaining segments are fetched by the user's next message. + if channel.cache_dict.get(from_user): + break + time.sleep(0.1) reply_text = "" - if task_running: + # Only fall back to retry / "thinking" hint when the task is still + # running AND there is nothing cached to send yet. + if task_running and not channel.cache_dict.get(from_user): if request_cnt < 3: # waiting for timeout (the POST request will be closed by Wechat official server) time.sleep(2) From fe8b8fe831340bbd0414f6a1904ac4635957ed38 Mon Sep 17 00:00:00 2001 From: 6vision Date: Sat, 30 May 2026 16:33:49 +0800 Subject: [PATCH 287/399] fix(wechatmp): support local file:// images in send Agent-generated images are sent as IMAGE_URL with a file:// path, but the wechatmp channel always used requests.get, which fails on file:// with InvalidSchema. Now read local files directly (file:// or local path) and fall back to HTTP download for remote URLs, in both passive and active reply modes. Co-authored-by: Cursor --- channel/wechatmp/wechatmp_channel.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/channel/wechatmp/wechatmp_channel.py b/channel/wechatmp/wechatmp_channel.py index c066f286..dc0ffb26 100644 --- a/channel/wechatmp/wechatmp_channel.py +++ b/channel/wechatmp/wechatmp_channel.py @@ -134,10 +134,16 @@ class WechatMPChannel(ChatChannel): elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片 img_url = reply.content - pic_res = requests.get(img_url, stream=True) image_storage = io.BytesIO() - for block in pic_res.iter_content(1024): - image_storage.write(block) + if img_url.startswith("file://") or os.path.isfile(img_url): + # Local file produced by the agent (e.g. a generated image) + local_path = img_url[len("file://"):] if img_url.startswith("file://") else img_url + with open(local_path, "rb") as f: + image_storage.write(f.read()) + else: + pic_res = requests.get(img_url, stream=True) + for block in pic_res.iter_content(1024): + image_storage.write(block) image_storage.seek(0) image_type = imghdr.what(image_storage) filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type @@ -258,10 +264,16 @@ class WechatMPChannel(ChatChannel): logger.info("[wechatmp] Do send voice to {}".format(receiver)) elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片 img_url = reply.content - pic_res = requests.get(img_url, stream=True) image_storage = io.BytesIO() - for block in pic_res.iter_content(1024): - image_storage.write(block) + if img_url.startswith("file://") or os.path.isfile(img_url): + # Local file produced by the agent (e.g. a generated image) + local_path = img_url[len("file://"):] if img_url.startswith("file://") else img_url + with open(local_path, "rb") as f: + image_storage.write(f.read()) + else: + pic_res = requests.get(img_url, stream=True) + for block in pic_res.iter_content(1024): + image_storage.write(block) image_storage.seek(0) image_type = imghdr.what(image_storage) filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type From 5ae1e1addee72754b708c84efbed2940a3821869 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 17:01:42 +0800 Subject: [PATCH 288/399] feat(channel): support slack bot --- README.md | 3 +- channel/channel_factory.py | 3 + channel/slack/__init__.py | 1 + channel/slack/slack_channel.py | 506 +++++++++++++++++++++++++++ channel/slack/slack_message.py | 60 ++++ channel/telegram/telegram_channel.py | 45 ++- channel/web/web_channel.py | 17 +- common/const.py | 1 + config.py | 7 +- docs/channels/index.mdx | 2 + docs/channels/slack.mdx | 118 +++++++ docs/docs.json | 9 +- docs/en/channels/index.mdx | 2 + docs/en/channels/slack.mdx | 118 +++++++ docs/ja/README.md | 3 +- docs/ja/channels/index.mdx | 2 + docs/ja/channels/slack.mdx | 118 +++++++ docs/zh/README.md | 5 +- requirements.txt | 2 + 19 files changed, 1012 insertions(+), 10 deletions(-) create mode 100644 channel/slack/__init__.py create mode 100644 channel/slack/slack_channel.py create mode 100644 channel/slack/slack_message.py create mode 100644 docs/channels/slack.mdx create mode 100644 docs/en/channels/slack.mdx create mode 100644 docs/ja/channels/slack.mdx diff --git a/README.md b/README.md index 2d1d61ae..1edf1d3f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major | [Knowledge](https://docs.cowagent.ai/en/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | | [Skills](https://docs.cowagent.ai/en/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | | [Tools](https://docs.cowagent.ai/en/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | -| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, and Telegram | +| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | | Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery | | [Models](https://docs.cowagent.ai/en/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | | [Deploy](https://docs.cowagent.ai/en/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) | @@ -127,6 +127,7 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > See the [Channels overview](https://docs.cowagent.ai/en/channels/index) for setup details. diff --git a/channel/channel_factory.py b/channel/channel_factory.py index fd8cf0e8..f1a14e8f 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -42,6 +42,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == const.TELEGRAM: from channel.telegram.telegram_channel import TelegramChannel ch = TelegramChannel() + elif channel_type == const.SLACK: + from channel.slack.slack_channel import SlackChannel + ch = SlackChannel() elif channel_type in (const.WEIXIN, "wx"): from channel.weixin.weixin_channel import WeixinChannel ch = WeixinChannel() diff --git a/channel/slack/__init__.py b/channel/slack/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/channel/slack/__init__.py @@ -0,0 +1 @@ + diff --git a/channel/slack/slack_channel.py b/channel/slack/slack_channel.py new file mode 100644 index 00000000..8e82fcc5 --- /dev/null +++ b/channel/slack/slack_channel.py @@ -0,0 +1,506 @@ +""" +Slack channel via Bolt for Python (Socket Mode). + +Features: +- Direct message & channel chat (text / image / file) +- Channel trigger: @mention or reply in a thread the bot is in (configurable) +- /cancel fast-path matches Web channel behaviour +- Socket Mode: no public IP / callback URL required, works behind NAT + +Implementation note: + slack_bolt's SocketModeHandler is blocking and runs its own background + threads. We start it in a dedicated thread so the rest of cow (sync) stays + untouched. Inbound events are dispatched onto cow's existing sync + ChatChannel.produce() pipeline; outbound send() calls the Slack Web API + client directly (it is sync-safe). +""" + +import os +import re +import threading + +import requests + +from bridge.context import Context, ContextType +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel, check_prefix +from channel.slack.slack_message import SlackMessage +from common.expired_dict import ExpiredDict +from common.log import logger +from common.singleton import singleton +from config import conf + + +@singleton +class SlackChannel(ChatChannel): + NOT_SUPPORT_REPLYTYPE = [] + + def __init__(self): + super().__init__() + self.bot_token = "" + self.app_token = "" + self.bot_user_id = "" # used to strip @mention and ignore self messages + self._app = None + self._handler = None + self._client = None + self._loop_thread = None + # Idempotent dedup; Slack retries event delivery on slow ack + self._received_msgs = ExpiredDict(60 * 60 * 1) + + # Disable group whitelist / prefix checks (we handle triggering ourselves + # in _should_reply_in_channel), aligned with telegram / feishu channels. + conf()["group_name_white_list"] = ["ALL_GROUP"] + conf()["single_chat_prefix"] = [""] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def startup(self): + self.bot_token = conf().get("slack_bot_token", "") + self.app_token = conf().get("slack_app_token", "") + if not self.bot_token or not self.app_token: + err = "[Slack] slack_bot_token and slack_app_token are both required" + logger.error(err) + self.report_startup_error(err) + return + + # Guard against the common mistake of swapping the two tokens: + # bot token must start with xoxb-, app-level token with xapp-. + if not self.bot_token.startswith("xoxb-") or not self.app_token.startswith("xapp-"): + err = ( + "[Slack] token type mismatch: slack_bot_token must start with 'xoxb-' " + "and slack_app_token must start with 'xapp-' (they look swapped)" + ) + logger.error(err) + self.report_startup_error(err) + return + + try: + from slack_bolt import App + from slack_bolt.adapter.socket_mode import SocketModeHandler + except ImportError: + err = ( + "[Slack] slack_bolt is not installed. " + "Run: pip install slack_bolt" + ) + logger.error(err) + self.report_startup_error(err) + return + + try: + self._app = App(token=self.bot_token) + self._client = self._app.client + + # Resolve our own bot user id (needed for @mention strip / self-ignore) + auth = self._client.auth_test() + self.bot_user_id = auth.get("user_id", "") + self.name = self.bot_user_id # ChatChannel uses self.name to strip @-mention + logger.info(f"[Slack] Bot logged in as user_id={self.bot_user_id}, team={auth.get('team')}") + except Exception as e: + err = f"[Slack] auth_test failed: {e}" + logger.error(err) + self.report_startup_error(err) + return + + self._register_handlers() + + self._handler = SocketModeHandler(self._app, self.app_token) + + def _run(): + try: + logger.info("[Slack] Starting Socket Mode connection...") + self.report_startup_success() + logger.info("[Slack] ✅ Slack bot ready, listening for events") + self._handler.start() + except Exception as e: + logger.error(f"[Slack] socket mode crashed: {e}", exc_info=True) + self.report_startup_error(str(e)) + finally: + logger.info("[Slack] socket mode exited") + + self._loop_thread = threading.Thread(target=_run, daemon=True, name="slack-socket") + self._loop_thread.start() + # Block startup() until the handler thread exits, matching other channels' + # behaviour (startup is a blocking call). + self._loop_thread.join() + + def _register_handlers(self): + app = self._app + + # app_mention: bot is @-mentioned in a channel + @app.event("app_mention") + def _on_app_mention(event, ack): + ack() + self._handle_event(event, is_group=True) + + # message: DMs and channel messages (including thread replies) + @app.event("message") + def _on_message(event, ack): + ack() + self._handle_message_event(event) + + def stop(self): + logger.info("[Slack] stop() called") + try: + if self._handler is not None: + self._handler.close() + except Exception as e: + logger.warning(f"[Slack] handler close error: {e}") + if self._loop_thread and self._loop_thread.is_alive(): + try: + self._loop_thread.join(timeout=10) + except Exception: + pass + logger.info("[Slack] stop() completed") + + # ------------------------------------------------------------------ + # Inbound: slack event -> ChatMessage -> ChatChannel.produce + # ------------------------------------------------------------------ + + def _handle_message_event(self, event: dict): + """Route a raw `message` event: skip bot/system noise, decide grouping.""" + try: + logger.debug( + f"[Slack] message event: channel_type={event.get('channel_type')}, " + f"subtype={event.get('subtype')}, user={event.get('user')}, " + f"ts={event.get('ts')}, thread_ts={event.get('thread_ts')}" + ) + # Ignore bot messages (including our own) and message edits/deletes + if event.get("bot_id") or event.get("subtype") in ("bot_message", "message_changed", "message_deleted"): + return + if event.get("user") == self.bot_user_id: + return + + channel_type = event.get("channel_type", "") + # DM (im) is single chat; channel/group is group chat. app_mention + # already covers channel @-mentions, so for plain channel messages we + # only react when configured / thread-following. + is_group = channel_type in ("channel", "group", "mpim") + if is_group: + # app_mention handler covers explicit @bot; here we only handle + # follow-up replies in threads the bot participates in. + if not self._should_reply_in_channel(event): + return + self._handle_event(event, is_group=is_group) + except Exception as e: + logger.error(f"[Slack] _handle_message_event error: {e}", exc_info=True) + + def _handle_event(self, event: dict, is_group: bool): + """Parse event -> build SlackMessage -> produce().""" + try: + channel_id = event.get("channel", "") + ts = event.get("ts", "") + if not channel_id: + return + + # Idempotent dedup + msg_uid = f"{channel_id}:{ts}" + if self._received_msgs.get(msg_uid): + return + self._received_msgs[msg_uid] = True + + # Parse type + download media if needed. + ctype, content, caption = self._parse_event(event) + if ctype is None: + logger.debug(f"[Slack] unsupported message type, skip. event={event}") + return + + # Strip <@bot_user_id> mention from channel text + if is_group and self.bot_user_id: + if ctype == ContextType.TEXT and content: + content = self._strip_at_mention(content) + if caption: + caption = self._strip_at_mention(caption) + + slack_msg = SlackMessage( + event, + is_group=is_group, + bot_user_id=self.bot_user_id, + ctype=ctype, + content=content, + ) + slack_msg.is_at = is_group # if we reached here in a channel, bot is mentioned/threaded + + from channel.file_cache import get_file_cache + file_cache = get_file_cache() + session_id = self._compute_session_id(event, is_group) + + # Media + caption together: treat as a complete query and bypass the cache + if ctype in (ContextType.IMAGE, ContextType.FILE) and caption: + tag = "image" if ctype == ContextType.IMAGE else "file" + merged_text = f"{caption}\n[{tag}: {content}]" + slack_msg.ctype = ContextType.TEXT + slack_msg.content = merged_text + ctype = ContextType.TEXT + logger.info(f"[Slack] Media+caption merged for session {session_id}") + # fallthrough to the TEXT branch below + + elif ctype == ContextType.IMAGE: + file_cache.add(session_id, content, file_type="image") + logger.info(f"[Slack] Image cached for session {session_id}, waiting for query...") + return + elif ctype == ContextType.FILE: + file_cache.add(session_id, content, file_type="file") + logger.info(f"[Slack] File cached for session {session_id}: {content}") + return + + if ctype == ContextType.TEXT: + # Fast-path: /cancel mirrors Web channel behaviour + if (content or "").strip().lower() in ("/cancel", "cancel"): + self._do_cancel(session_id, channel_id, event) + return + + cached_files = file_cache.get(session_id) + if cached_files: + refs = [] + for fi in cached_files: + ftype = fi["type"] + tag = ftype if ftype in ("image", "video") else "file" + refs.append(f"[{tag}: {fi['path']}]") + slack_msg.content = (slack_msg.content or "") + "\n" + "\n".join(refs) + file_cache.clear(session_id) + logger.info(f"[Slack] Attached {len(cached_files)} cached file(s) to query") + + # Reply in the originating thread when present, else start one on this msg + thread_ts = event.get("thread_ts") or ts + + context = self._compose_context( + slack_msg.ctype, + slack_msg.content, + isgroup=is_group, + msg=slack_msg, + # Replies go back into the thread, no manual @mention needed + no_need_at=True, + ) + if context: + context["session_id"] = session_id + context["receiver"] = channel_id + context["slack_channel"] = channel_id + context["slack_thread_ts"] = thread_ts if is_group else None + self.produce(context) + logger.debug(f"[Slack] received: type={ctype}, content={str(slack_msg.content)[:80]}") + except Exception as e: + logger.error(f"[Slack] _handle_event error: {e}", exc_info=True) + + def _do_cancel(self, session_id: str, channel_id: str, event: dict): + """Fast-path: /cancel calls cancel_session directly without going through agent.""" + try: + from agent.protocol import get_cancel_registry + cancelled = get_cancel_registry().cancel_session(session_id) + text = "Current task cancelled." if cancelled else "No running task to cancel." + thread_ts = event.get("thread_ts") or event.get("ts") + self._client.chat_postMessage(channel=channel_id, text=text, thread_ts=thread_ts) + logger.info(f"[Slack] /cancel session={session_id}, cancelled={cancelled}") + except Exception as e: + logger.error(f"[Slack] /cancel error: {e}", exc_info=True) + + def _parse_event(self, event: dict): + """Parse a slack event and return (ctype, content, caption). + + - content is text for ContextType.TEXT, otherwise the local file path + - caption is the optional text accompanying a file; empty for plain text + """ + text = (event.get("text") or "").strip() + files = event.get("files") or [] + + if files: + # Handle the first attachment; caption is the accompanying message text + f = files[0] + mimetype = (f.get("mimetype") or "").lower() + url = f.get("url_private_download") or f.get("url_private") + name = f.get("name") or f.get("id") or "file" + if not url: + return (None, None, "") + path = self._download_file(url, name) + if not path: + return (None, None, "") + if mimetype.startswith("image/"): + return (ContextType.IMAGE, path, text) + return (ContextType.FILE, path, text) + + if text: + return (ContextType.TEXT, text, "") + + return (None, None, "") + + def _download_file(self, url: str, name: str): + """Download a Slack private file (requires bot token auth) to local tmp dir.""" + try: + headers = {"Authorization": f"Bearer {self.bot_token}"} + resp = requests.get(url, headers=headers, timeout=60, stream=True) + resp.raise_for_status() + tmp_dir = SlackMessage.get_tmp_dir() + # Sanitize the name and keep it unique-ish via the url tail + safe_name = re.sub(r"[^\w.\-]", "_", name) + local_path = os.path.join(tmp_dir, safe_name) + with open(local_path, "wb") as fp: + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + fp.write(chunk) + logger.debug(f"[Slack] downloaded {name} -> {local_path}") + return local_path + except Exception as e: + logger.error(f"[Slack] download_file failed ({name}): {e}") + return None + + # ------------------------------------------------------------------ + # Channel trigger logic + # ------------------------------------------------------------------ + + def _should_reply_in_channel(self, event: dict) -> bool: + """Decide whether to reply to a plain channel message (no @mention). + + app_mention already handles explicit @bot, so here we only deal with + follow-up messages. `all` replies to every message; `mention_or_reply` + replies inside threads the bot already participates in. + """ + mode = conf().get("slack_group_trigger", "mention_or_reply") + if mode == "all": + return True + if mode == "mention_only": + return False + # mention_or_reply: follow up only within an existing thread + return bool(event.get("thread_ts")) + + def _strip_at_mention(self, content: str) -> str: + """Strip <@BOT_USER_ID> from channel text.""" + if not content or not self.bot_user_id: + return content + pattern = re.compile(r"<@" + re.escape(self.bot_user_id) + r">", re.IGNORECASE) + return pattern.sub("", content).strip() + + @staticmethod + def _compute_session_id(event: dict, is_group: bool) -> str: + channel_id = event.get("channel", "") + user_id = event.get("user", "") + if is_group: + if conf().get("group_shared_session", True): + return f"slack_channel_{channel_id}" + return f"slack_channel_{channel_id}_{user_id}" + return f"slack_user_{user_id}" + + # ------------------------------------------------------------------ + # Override _compose_context: skip the parent's group whitelist/at checks + # (already handled via _should_reply_in_channel). Same idea as telegram. + # ------------------------------------------------------------------ + + 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"] + if cmsg.is_group: + if conf().get("group_shared_session", True): + context["session_id"] = cmsg.other_user_id + else: + context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}" + else: + 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 or "").strip() + if "desire_rtype" not in context and conf().get("always_reply_voice"): + context["desire_rtype"] = ReplyType.VOICE + elif ctype == ContextType.VOICE: + if "desire_rtype" not in context and ( + conf().get("voice_reply_voice") or conf().get("always_reply_voice") + ): + context["desire_rtype"] = ReplyType.VOICE + + return context + + # ------------------------------------------------------------------ + # Outbound: ChatChannel.send -> Slack Web API + # ------------------------------------------------------------------ + + def send(self, reply: Reply, context: Context): + """Called from cow's sync main thread; Slack Web client is sync-safe.""" + if self._client is None: + logger.warning("[Slack] client not ready, drop reply") + return + + channel_id = context.get("slack_channel") + thread_ts = context.get("slack_thread_ts") + if not channel_id: + logger.warning("[Slack] no slack_channel in context, drop reply") + return + + try: + self._do_send(reply, channel_id, thread_ts) + logger.info(f"[Slack] sent reply (type={reply.type}, channel={channel_id})") + except Exception as e: + logger.error(f"[Slack] send failed: {e}", exc_info=True) + + def _do_send(self, reply: Reply, channel_id: str, thread_ts): + rtype = reply.type + content = reply.content + + if rtype in (ReplyType.TEXT, ReplyType.INFO, ReplyType.ERROR): + text = str(content) if content is not None else "" + if not text: + return + # Slack caps a message around 40k chars; split conservatively + for chunk in _split_text(text, 3500): + self._client.chat_postMessage(channel=channel_id, text=chunk, thread_ts=thread_ts) + + elif rtype == ReplyType.IMAGE: + # Already a local BytesIO; upload it directly + content.seek(0) + self._client.files_upload_v2( + channel=channel_id, file=content, filename="image.png", thread_ts=thread_ts, + ) + + elif rtype == ReplyType.IMAGE_URL: + url = str(content) + if url.startswith("file://"): + local = url[7:] + self._client.files_upload_v2( + channel=channel_id, file=local, thread_ts=thread_ts, + ) + else: + # Post the URL as text; Slack will unfurl it as an image preview + self._client.chat_postMessage(channel=channel_id, text=url, thread_ts=thread_ts) + + elif rtype in (ReplyType.VOICE, ReplyType.FILE): + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + caption = getattr(reply, "text_content", None) or None + self._client.files_upload_v2( + channel=channel_id, file=local, initial_comment=caption, thread_ts=thread_ts, + ) + + else: + # Fallback: send as plain text + self._client.chat_postMessage(channel=channel_id, text=str(content), thread_ts=thread_ts) + + +def _split_text(text: str, limit: int): + """Split long text preferring line breaks to keep markdown structure intact.""" + if len(text) <= limit: + yield text + return + buf = [] + size = 0 + for line in text.splitlines(keepends=True): + if size + len(line) > limit and buf: + yield "".join(buf) + buf, size = [], 0 + # Hard-split single lines that exceed the limit + while len(line) > limit: + yield line[:limit] + line = line[limit:] + buf.append(line) + size += len(line) + if buf: + yield "".join(buf) diff --git a/channel/slack/slack_message.py b/channel/slack/slack_message.py new file mode 100644 index 00000000..39f215bd --- /dev/null +++ b/channel/slack/slack_message.py @@ -0,0 +1,60 @@ +""" +Slack message adapter. + +Convert a Slack event payload into cow's unified ChatMessage. +File downloads are NOT performed here; the channel layer downloads files +on demand because it needs the bot token for authenticated download URLs. +""" +import os + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.utils import expand_path +from config import conf + + +class SlackMessage(ChatMessage): + """Wrap a Slack event into the unified ChatMessage.""" + + def __init__(self, event: dict, is_group: bool = False, bot_user_id: str = "", + ctype: ContextType = ContextType.TEXT, content: str = ""): + super().__init__(event) + # Basic fields + self.msg_id = event.get("client_msg_id") or event.get("ts") or "" + try: + self.create_time = int(float(event.get("ts", 0))) + except (TypeError, ValueError): + self.create_time = 0 + self.ctype = ctype + self.content = content + + # Sender / chat info + from_user_id = event.get("user", "unknown") + channel_id = event.get("channel", "") + self.from_user_id = from_user_id + self.from_user_nickname = from_user_id + self.to_user_id = bot_user_id or "slack_bot" + self.to_user_nickname = bot_user_id or "slack_bot" + + self.is_group = is_group + if is_group: + # Channel chat: other_user_id = channel_id, actual_user_id = sender id + self.other_user_id = channel_id + self.other_user_nickname = channel_id + self.actual_user_id = from_user_id + self.actual_user_nickname = from_user_id + else: + # DM: use channel_id so replies go back to the same DM channel + self.other_user_id = channel_id or from_user_id + self.other_user_nickname = from_user_id + + # Whether the bot was triggered by @-mention (set by channel layer) + self.is_at = False + + @staticmethod + def get_tmp_dir() -> str: + """Local download directory, aligned with other channels (agent_workspace/tmp).""" + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + tmp_dir = os.path.join(workspace_root, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + return tmp_dir diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py index b3b46589..9e40c59f 100644 --- a/channel/telegram/telegram_channel.py +++ b/channel/telegram/telegram_channel.py @@ -177,10 +177,21 @@ class TelegramChannel(ChatChannel): application.add_handler(MessageHandler(filters.COMMAND, self._on_command_passthrough)) # Start polling. drop_pending_updates avoids replaying backlog after restart. + # Transient "Server disconnected" / RemoteProtocolError during get_updates + # are common over proxies/flaky networks; PTB's network loop auto-retries, + # so we only need to keep the noise down (see _quiet_polling_network_errors). + self._quiet_polling_network_errors() logger.info("[Telegram] Starting long polling...") await application.initialize() await application.start() - await application.updater.start_polling(drop_pending_updates=True) + await application.updater.start_polling( + drop_pending_updates=True, + # Long-poll hold time on the server side; smaller value = reconnect more + # often but each hung connection fails faster. + timeout=30, + # Retry forever on transient get_updates network errors instead of giving up. + bootstrap_retries=-1, + ) self.report_startup_success() logger.info("[Telegram] ✅ Telegram bot ready, polling for updates") @@ -196,6 +207,38 @@ class TelegramChannel(ChatChannel): except Exception as e: logger.warning(f"[Telegram] shutdown error: {e}") + @staticmethod + def _quiet_polling_network_errors(): + """Downgrade PTB's noisy 'Exception happened while polling for updates' logs. + + These transient get_updates errors (RemoteProtocolError / NetworkError / + TimedOut, typically over a proxy) are auto-retried by PTB's network loop, + so logging the full traceback at ERROR is just noise. We attach a filter + that drops these specific records while leaving real errors untouched. + """ + import logging + + class _PollingNoiseFilter(logging.Filter): + _NEEDLES = ( + "Exception happened while polling for updates", + "Server disconnected without sending a response", + ) + + def filter(self, record: logging.LogRecord) -> bool: + try: + msg = record.getMessage() + except Exception: + return True + if any(n in msg for n in self._NEEDLES): + # Keep a single-line breadcrumb at DEBUG, drop the traceback. + logger.debug(f"[Telegram] transient polling network error (auto-retrying): {msg.splitlines()[0]}") + return False + return True + + noise_filter = _PollingNoiseFilter() + for name in ("telegram.ext.Updater", "telegram.ext._updater", "telegram.ext"): + logging.getLogger(name).addFilter(noise_filter) + def stop(self): logger.info("[Telegram] stop() called") self._stop_event.set() diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index c564696e..d230ec4c 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1315,7 +1315,13 @@ class FileServeHandler: 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) + # Resolve symlinks and confine access to the configured root dir, + # so this endpoint can't be abused to read arbitrary files (e.g. /etc/passwd, ~/.ssh). + # Defaults to the user home dir; set web_file_serve_root="/" to allow the whole filesystem. + file_path = os.path.realpath(file_path) + serve_root = os.path.realpath(os.path.expanduser(conf().get("web_file_serve_root", "~") or "~")) + if serve_root != os.sep and os.path.commonpath([file_path, serve_root]) != serve_root: + raise web.notfound() if not os.path.isfile(file_path): raise web.notfound() content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" @@ -2917,6 +2923,15 @@ class ChannelsHandler: {"key": "telegram_token", "label": "Bot Token", "type": "secret"}, ], }), + ("slack", { + "label": {"zh": "Slack", "en": "Slack"}, + "icon": "fa-hashtag", + "color": "purple", + "fields": [ + {"key": "slack_bot_token", "label": "Bot Token (xoxb-)", "type": "secret"}, + {"key": "slack_app_token", "label": "App Token (xapp-)", "type": "secret"}, + ], + }), ]) @staticmethod diff --git a/common/const.py b/common/const.py index 8ec2a2ff..863b6eee 100644 --- a/common/const.py +++ b/common/const.py @@ -245,3 +245,4 @@ WECOM_BOT = "wecom_bot" QQ = "qq" WEIXIN = "weixin" TELEGRAM = "telegram" +SLACK = "slack" diff --git a/config.py b/config.py index cf8f43fe..024ce831 100644 --- a/config.py +++ b/config.py @@ -171,6 +171,10 @@ available_setting = { "telegram_proxy": "", # 可选的 HTTP/SOCKS5 代理,例如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080(留空则走系统环境变量) "telegram_group_trigger": "mention_or_reply", # 群聊触发方式: mention_or_reply(@或回复触发,推荐) | mention_only(仅@) | all(所有消息) "telegram_register_commands": True, # 启动时是否自动向 BotFather 注册命令菜单(与 web 端 slash 命令一致) + # Slack 配置(Socket Mode,无需公网 IP) + "slack_bot_token": "", # Bot User OAuth Token,形如 xoxb-... + "slack_app_token": "", # App-Level Token(开启 Socket Mode 后生成),形如 xapp-... + "slack_group_trigger": "mention_or_reply", # 频道触发方式: mention_or_reply(@或线程内回复,推荐) | mention_only(仅@) | all(所有消息) # 微信配置 "weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录 "weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL @@ -179,7 +183,7 @@ available_setting = { # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 - "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,telegram + "channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" 或 ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,telegram,slack "web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用 "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app "debug": False, # 是否开启debug模式,开启后会打印更多日志 @@ -221,6 +225,7 @@ available_setting = { "web_port": 9899, "web_password": "", # Web console password; empty means no authentication required "web_session_expire_days": 30, # Auth session expiry in days + "web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem "agent": True, # 是否开启Agent模式 "agent_workspace": "~/cow", # agent工作空间路径,用于存储skills、memory等 "agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 5d3fffdc..97ba16ab 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -20,6 +20,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 | [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 - **群聊**列指可识别并响应群消息 @@ -39,3 +40,4 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 - [企业微信应用](/channels/wecom) — 企业微信自建应用接入 - [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号) - [Telegram](/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP +- [Slack](/channels/slack) — 团队协作 IM,Socket Mode 接入,无需公网 IP diff --git a/docs/channels/slack.mdx b/docs/channels/slack.mdx new file mode 100644 index 00000000..1103f1c0 --- /dev/null +++ b/docs/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: 将 CowAgent 接入 Slack App +--- + +> 通过 Slack App 的 **Socket Mode** 接入 CowAgent,支持私聊(DM)与频道(@机器人 / 线程内回复触发)。Socket Mode 基于长连接,无需公网 IP 与回调地址,开箱即用。 + +## 一、接入步骤 + +### 步骤一:创建 Slack App + +1. 打开 [Slack API 应用管理页](https://api.slack.com/apps),点击 **Create New App** → **From scratch**。 +2. 填写 **App Name**(如 `CowAgent`),选择要安装的 **Workspace**,点击创建。 + +### 步骤二:开启 Socket Mode 并获取 App Token + +1. 左侧菜单进入 **Settings → Socket Mode**,打开 **Enable Socket Mode**。 +2. 系统会提示生成一个 **App-Level Token**,作用域勾选 `connections:write`,生成后保存这串以 `xapp-` 开头的 Token。 + + + Socket Mode 通过 WebSocket 长连接接收事件,无需在公网暴露回调 URL,适合本地或内网部署。 + + +### 步骤三:配置 Bot 权限并安装 + +1. 进入 **Features → OAuth & Permissions**,在 **Bot Token Scopes** 中点击 **Add an OAuth Scope**,逐项添加以下权限: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` 用于图片、文件的收发;若仅需文本对话可省略。 + + +2. 进入 **Features → Event Subscriptions**,打开 **Enable Events**,在 **Subscribe to bot events** 中点击 **Add Bot User Event** 添加以下事件: + + ``` + app_mention + message.im + message.channels + ``` + + + 如需在私有频道使用,再添加 `message.groups`。 + +3. 进入 **Features → App Home**,在 **Show Tabs** 区域勾选 **Messages Tab**,并勾选下方的 **Allow users to send Slash commands and messages from the messages tab**(允许用户从消息标签页发送消息),否则私聊输入框会被关闭、无法给机器人发消息。 +4. 回到 **OAuth & Permissions**,点击 **Install to Workspace** 完成安装,安装后获取以 `xoxb-` 开头的 **Bot User OAuth Token**。 + + + 若 Slack 客户端仍提示「向此应用发送消息的功能已关闭」,请确认已完成上一步的 App Home 设置,并刷新或重启 Slack 客户端(必要时把 App 从对话列表移除后重新打开)。 + + +### 步骤四:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Slack**,分别填入 Bot Token(`xoxb-`)和 App Token(`xapp-`),点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token,形如 `xoxb-...` | - | + | `slack_app_token` | App-Level Token(开启 Socket Mode 后生成),形如 `xapp-...` | - | + | `slack_group_trigger` | 频道触发方式:`mention_or_reply`(@或线程内回复)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 私聊(DM) | ✅ | +| 频道(@机器人 / 线程内回复) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| 线程回复 | ✅ 回复发送至触发消息所在线程 | + + + Slack 通过线程(Thread)组织对话。机器人会把回复发送到触发消息所在的线程,频道内更整洁。 + + +## 三、使用 + +完成接入后: + +- **私聊(DM)**:在 Slack 左侧 **Apps** 中找到你的 App,直接发消息对话。 +- **频道**:把 App 邀请进频道(`/invite @你的App`),使用 `@你的App 你好` 触发对话;后续在同一线程内直接回复即可继续对话。 + +发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/docs.json b/docs/docs.json index 93c3aa37..0e4e92b2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -197,7 +197,8 @@ "channels/qq", "channels/wecom", "channels/wechatmp", - "channels/telegram" + "channels/telegram", + "channels/slack" ] } ] @@ -401,7 +402,8 @@ "en/channels/qq", "en/channels/wecom", "en/channels/wechatmp", - "en/channels/telegram" + "en/channels/telegram", + "en/channels/slack" ] } ] @@ -605,7 +607,8 @@ "ja/channels/qq", "ja/channels/wecom", "ja/channels/wechatmp", - "ja/channels/telegram" + "ja/channels/telegram", + "ja/channels/slack" ] } ] diff --git a/docs/en/channels/index.mdx b/docs/en/channels/index.mdx index 3533b0d3..8b6a25e9 100644 --- a/docs/en/channels/index.mdx +++ b/docs/en/channels/index.mdx @@ -20,6 +20,7 @@ The table below summarizes the inbound message types, bot reply types, and group | [WeCom App](/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [Official Account](/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details - The **Group Chat** column indicates the ability to recognize and respond to group messages @@ -39,3 +40,4 @@ The table below summarizes the inbound message types, bot reply types, and group - [WeCom App](/en/channels/wecom) — WeCom custom app integration - [Official Account](/en/channels/wechatmp) — WeChat Official Account (subscription / service) - [Telegram](/en/channels/telegram) — global IM, 5-minute setup, no public IP needed +- [Slack](/en/channels/slack) — team collaboration IM, Socket Mode integration, no public IP needed diff --git a/docs/en/channels/slack.mdx b/docs/en/channels/slack.mdx new file mode 100644 index 00000000..f95272ca --- /dev/null +++ b/docs/en/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: Integrate CowAgent with a Slack App +--- + +> Integrate CowAgent into Slack via a Slack App in **Socket Mode**. Supports direct messages (DM) and channels (triggered by @mention or replying within a thread). Socket Mode uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. + +## 1. Setup + +### Step 1: Create a Slack App + +1. Open the [Slack API apps page](https://api.slack.com/apps), click **Create New App** → **From scratch**. +2. Enter an **App Name** (e.g. `CowAgent`), pick the **Workspace** to install into, and create it. + +### Step 2: Enable Socket Mode and get the App Token + +1. In the left sidebar go to **Settings → Socket Mode** and turn on **Enable Socket Mode**. +2. You will be prompted to generate an **App-Level Token** with the `connections:write` scope. Save this token starting with `xapp-`. + + + Socket Mode receives events over a WebSocket connection, so you don't need to expose a public callback URL — ideal for local or intranet deployments. + + +### Step 3: Configure bot scopes and install + +1. Go to **Features → OAuth & Permissions**, click **Add an OAuth Scope** under **Bot Token Scopes**, and add the following scopes one by one: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` are used for sending/receiving images and files; omit them if you only need text conversations. + + +2. Go to **Features → Event Subscriptions**, turn on **Enable Events**, and under **Subscribe to bot events** click **Add Bot User Event** to add: + + ``` + app_mention + message.im + message.channels + ``` + + + Add `message.groups` if you need to use the bot in private channels. + +3. Go to **Features → App Home**, enable **Messages Tab** under **Show Tabs**, and check **Allow users to send Slash commands and messages from the messages tab**. Otherwise the DM input box is disabled and users cannot message the bot. +4. Back in **OAuth & Permissions**, click **Install to Workspace**. After installing, copy the **Bot User OAuth Token** starting with `xoxb-`. + + + If the Slack client still shows "Sending messages to this app has been turned off", make sure you completed the App Home step above, then refresh or restart the Slack client (remove the app from your conversations and reopen it if needed). + + +### Step 4: Connect to CowAgent + + + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Slack**, paste the Bot Token (`xoxb-`) and App Token (`xapp-`), and click connect. + + + Add the following to `config.json` and start Cow: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | Key | Description | Default | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token, like `xoxb-...` | - | + | `slack_app_token` | App-Level Token (generated after enabling Socket Mode), like `xapp-...` | - | + | `slack_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply in thread) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | + + + +The integration is ready when you see logs like: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 2. Capabilities + +| Feature | Support | +| --- | --- | +| Direct message (DM) | ✅ | +| Channel (@bot / reply in thread) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | +| Thread replies | ✅ replies are posted to the thread of the triggering message | + + + Slack organizes conversations into threads. The bot posts replies into the thread of the triggering message, keeping channels tidy. + + +## 3. Usage + +Once connected: + +- **Direct message (DM)**: find your App under **Apps** in the Slack sidebar and message it directly. +- **Channel**: invite the App into a channel (`/invite @your-app`), then trigger it with `@your-app hello`; continue the conversation by replying within the same thread. + +When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/ja/README.md b/docs/ja/README.md index 3db69d8c..0e4327c8 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -30,7 +30,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています | [ナレッジベース](https://docs.cowagent.ai/ja/knowledge/index) | 構造化された知識を Markdown Wiki として自動整理し、進化し続けるナレッジグラフを可視化ブラウジング | | [Skill](https://docs.cowagent.ai/ja/skills/index) | [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub からワンクリックでインストール;対話によるカスタム Skill 作成にも対応 | | [ツール](https://docs.cowagent.ai/ja/tools/index) | ファイル I/O、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10+ の組み込みツール — MCP プロトコルに完全対応 | -| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram を同時にサポート | +| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram、Slack を同時にサポート | | マルチモーダル | テキスト・画像・音声・ファイルをフルサポート — 認識・生成・双方向送受信 | | [モデル](https://docs.cowagent.ai/ja/models/index) | Claude、GPT、Gemini、DeepSeek、GLM、Qwen、Kimi、MiniMax、Doubao など、設定 1 行で切り替え可能 | | [デプロイ](https://docs.cowagent.ai/ja/guide/quick-start) | ワンラインインストーラー、統合された Web コンソール、複数のデプロイモード(ローカル / Docker / サーバー) | @@ -127,6 +127,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [WeCom App](https://docs.cowagent.ai/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](https://docs.cowagent.ai/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > Feishu と WeCom Bot は **Web コンソール内で QR コードをスキャンするだけで接続**できます — パブリック IP は不要です。詳細は [チャネル概要](https://docs.cowagent.ai/ja/channels/index) を参照してください。 diff --git a/docs/ja/channels/index.mdx b/docs/ja/channels/index.mdx index ac6a5acf..05a540d2 100644 --- a/docs/ja/channels/index.mdx +++ b/docs/ja/channels/index.mdx @@ -20,6 +20,7 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください - **グループチャット**列はグループメッセージを認識して応答できることを示します @@ -39,3 +40,4 @@ CowAgent は複数のチャットチャネルへの接続に対応しており - [WeCom アプリ](/ja/channels/wecom) — WeCom 自作アプリ接続 - [WeChat 公式アカウント](/ja/channels/wechatmp) — WeChat 公式アカウント(購読アカウント / サービスアカウント) - [Telegram](/ja/channels/telegram) — グローバル IM、5 分で接続、公開 IP 不要 +- [Slack](/ja/channels/slack) — チームコラボレーション IM、Socket Mode 接続、公開 IP 不要 diff --git a/docs/ja/channels/slack.mdx b/docs/ja/channels/slack.mdx new file mode 100644 index 00000000..d871bd00 --- /dev/null +++ b/docs/ja/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: Slack App 経由で CowAgent を接続 +--- + +> Slack App の **Socket Mode** を通じて CowAgent を接続します。ダイレクトメッセージ(DM)およびチャンネル(@メンションまたはスレッド内の返信で起動)に対応。Socket Mode は WebSocket の常時接続を使うため公開 IP やコールバック URL は不要で、すぐに利用できます。 + +## 1. 接続手順 + +### ステップ 1: Slack App を作成 + +1. [Slack API アプリ管理ページ](https://api.slack.com/apps) を開き、**Create New App** → **From scratch** をクリックします。 +2. **App Name**(例: `CowAgent`)を入力し、インストール先の **Workspace** を選択して作成します。 + +### ステップ 2: Socket Mode を有効化し App Token を取得 + +1. 左メニューの **Settings → Socket Mode** で **Enable Socket Mode** をオンにします。 +2. `connections:write` スコープを持つ **App-Level Token** の生成を求められます。`xapp-` で始まるこの Token を保存してください。 + + + Socket Mode は WebSocket 接続でイベントを受信するため、公開コールバック URL を公開する必要がありません。ローカルやイントラネットでの運用に最適です。 + + +### ステップ 3: Bot 権限を設定してインストール + +1. **Features → OAuth & Permissions** を開き、**Bot Token Scopes** で **Add an OAuth Scope** をクリックして以下を 1 つずつ追加します: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` は画像・ファイルの送受信に使用します。テキスト会話のみであれば省略可能です。 + + +2. **Features → Event Subscriptions** を開き、**Enable Events** をオンにして、**Subscribe to bot events** で **Add Bot User Event** をクリックし、以下を追加します: + + ``` + app_mention + message.im + message.channels + ``` + + + 非公開チャンネルで使用する場合は `message.groups` も追加してください。 + +3. **Features → App Home** を開き、**Show Tabs** 内の **Messages Tab** を有効にして、下の **Allow users to send Slash commands and messages from the messages tab**(メッセージタブからの送信を許可)にチェックを入れます。これを行わないと DM の入力欄が無効化され、ボットにメッセージを送れません。 +4. **OAuth & Permissions** に戻り、**Install to Workspace** をクリックしてインストールします。インストール後、`xoxb-` で始まる **Bot User OAuth Token** を取得します。 + + + Slack クライアントで「このアプリへのメッセージ送信は無効です」と表示される場合は、上記の App Home 設定が完了しているか確認し、Slack クライアントを再読み込み/再起動してください(必要に応じてアプリを会話一覧から削除して再度開きます)。 + + +### ステップ 4: CowAgent に接続 + + + + Web コンソール(既定 `http://127.0.0.1:9899`)を開き、**チャネル** メニュー → **チャネルを追加** → **Slack** を選択し、Bot Token(`xoxb-`)と App Token(`xapp-`)を貼り付けて接続をクリックします。 + + + `config.json` に以下を追加して Cow を起動します: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | パラメータ | 説明 | 既定値 | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token、`xoxb-...` の形式 | - | + | `slack_app_token` | App-Level Token(Socket Mode 有効化後に生成)、`xapp-...` の形式 | - | + | `slack_group_trigger` | チャンネルのトリガー方式: `mention_or_reply`(@ またはスレッド返信)/ `mention_only`(@ のみ)/ `all`(全メッセージ) | `mention_or_reply` | + + + +ログに以下のような出力が表示されれば接続成功です: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 2. 機能 + +| 機能 | 対応状況 | +| --- | --- | +| ダイレクトメッセージ(DM) | ✅ | +| チャンネル(@bot / スレッド返信) | ✅ | +| テキストメッセージ | ✅ 送受信 | +| 画像メッセージ | ✅ 送受信 | +| ファイルメッセージ | ✅ 送受信(PDF / Word / Excel など) | +| スレッド返信 | ✅ 起動メッセージのスレッドに返信を送信 | + + + Slack はスレッドで会話を整理します。Bot は起動メッセージのスレッドに返信を送信するため、チャンネルがすっきりします。 + + +## 3. 使い方 + +接続が完了したら: + +- **ダイレクトメッセージ(DM)**: Slack の左サイドバー **Apps** からアプリを開き、直接メッセージを送ります。 +- **チャンネル**: アプリをチャンネルに招待し(`/invite @your-app`)、`@your-app こんにちは` で起動します。以降は同じスレッド内で返信すれば会話を継続できます。 + +画像やファイルを送るときは、添付の入力欄に **テキスト説明**(説明・質問)を書いて一緒に送信できます。Bot は添付ファイルと説明を合わせて回答します。先に添付を送り、その後に質問を送る形でも、2 つのメッセージは自動でまとめて処理されます。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 377d1beb..bc8000b3 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -9,7 +9,7 @@ **CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆,与你一同成长,是 Agent Harness 工程的最佳实践之一。 -CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、网页等多渠道,7×24 运行于个人电脑或服务器中。 +CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、Slack、网页等多渠道,7×24 运行于个人电脑或服务器中。

🌐 官网  ·  @@ -30,7 +30,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖 | [知识库](https://docs.cowagent.ai/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 | | [技能](https://docs.cowagent.ai/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 | | [工具](https://docs.cowagent.ai/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 | -| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram 等多个渠道 | +| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 | | 多模态 | 文本、图片、语音、文件全消息类型支持,覆盖识别、生成、收发 | | [模型](https://docs.cowagent.ai/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流厂商,配置一行切换 | | [部署](https://docs.cowagent.ai/guide/quick-start) | 一键脚本安装,Web 控制台统一管理;本地、Docker、服务器多种部署方式 | @@ -127,6 +127,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [企业微信应用](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/channels)。 diff --git a/requirements.txt b/requirements.txt index d767e21a..7f0ccc71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,3 +29,5 @@ websocket-client>=1.4.0 pycryptodome # telegram bot python-telegram-bot +# slack bot +slack_bolt From aa3f48e93c9eb2b406ba43c036a891f548fd80d3 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 17:06:58 +0800 Subject: [PATCH 289/399] fix(web): confine /api/file to allowed dirs to prevent arbitrary file read --- channel/web/web_channel.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index d230ec4c..96ad11a0 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1315,12 +1315,19 @@ class FileServeHandler: file_path = params.path if not file_path or not os.path.isabs(file_path): raise web.notfound() - # Resolve symlinks and confine access to the configured root dir, + # Resolve symlinks and confine access to the allowed root dirs, # so this endpoint can't be abused to read arbitrary files (e.g. /etc/passwd, ~/.ssh). - # Defaults to the user home dir; set web_file_serve_root="/" to allow the whole filesystem. + # Defaults to the user home dir plus the agent workspace; set web_file_serve_root="/" + # to allow the whole filesystem. file_path = os.path.realpath(file_path) - serve_root = os.path.realpath(os.path.expanduser(conf().get("web_file_serve_root", "~") or "~")) - if serve_root != os.sep and os.path.commonpath([file_path, serve_root]) != serve_root: + serve_root = conf().get("web_file_serve_root", "~") or "~" + allowed_roots = [ + os.path.realpath(os.path.expanduser(serve_root)), + os.path.realpath(os.path.expanduser(conf().get("agent_workspace", "~/cow"))), + ] + if os.sep not in allowed_roots and not any( + os.path.commonpath([file_path, root]) == root for root in allowed_roots + ): raise web.notfound() if not os.path.isfile(file_path): raise web.notfound() From b671b0d72566d762b1a0c305a25b5ba825dd7e13 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 17:18:31 +0800 Subject: [PATCH 290/399] docs: add web file serve root config --- docs/channels/web.mdx | 1 + docs/en/channels/web.mdx | 1 + docs/ja/channels/web.mdx | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/channels/web.mdx b/docs/channels/web.mdx index 30bea09b..810da3da 100644 --- a/docs/channels/web.mdx +++ b/docs/channels/web.mdx @@ -24,6 +24,7 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏 | `web_port` | Web 服务监听端口 | `9899` | | `web_password` | 访问密码,留空表示不启用密码保护;监听 `0.0.0.0` 时建议设置 | `""` | | `web_session_expire_days` | 登录会话有效天数 | `30` | +| `web_file_serve_root` | web 端可直接读取/发送的文件根目录,默认仅限用户主目录及 agent 工作空间;设为 `/` 可放开整个文件系统 | `"~"` | | `enable_thinking` | 是否启用深度思考模式 | `false` | 配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。密码也支持在控制台的「配置」页面中在线修改。 diff --git a/docs/en/channels/web.mdx b/docs/en/channels/web.mdx index a2a39c72..97c43077 100644 --- a/docs/en/channels/web.mdx +++ b/docs/en/channels/web.mdx @@ -24,6 +24,7 @@ The Web Console is CowAgent's default channel. It runs automatically once starte | `web_port` | Web service listen port | `9899` | | `web_password` | Access password. Leave empty to disable password protection; recommended when listening on `0.0.0.0` | `""` | | `web_session_expire_days` | Login session validity in days | `30` | +| `web_file_serve_root` | Root directory the web console can directly read/send files from. Defaults to the user home dir and agent workspace only; set to `/` to allow the whole filesystem | `"~"` | | `enable_thinking` | Whether to enable deep thinking mode | `false` | Once a password is configured, you must enter it to log in when accessing the console. The login session is kept for 30 days by default, so restarting the service during that period does not require re-login. The password can also be changed online from the "Configuration" page in the console. diff --git a/docs/ja/channels/web.mdx b/docs/ja/channels/web.mdx index 922627fc..a044697b 100644 --- a/docs/ja/channels/web.mdx +++ b/docs/ja/channels/web.mdx @@ -24,6 +24,7 @@ Web コンソールは CowAgent のデフォルトチャネルです。起動後 | `web_port` | Web サービスのリスンポート | `9899` | | `web_password` | アクセスパスワード。空欄の場合はパスワード保護が無効。`0.0.0.0` でリスンする場合は設定を推奨 | `""` | | `web_session_expire_days` | ログインセッションの有効日数 | `30` | +| `web_file_serve_root` | web コンソールが直接読み取り/送信できるファイルのルートディレクトリ。デフォルトはユーザーのホームディレクトリと agent ワークスペースのみ。`/` に設定するとファイルシステム全体を許可 | `"~"` | | `enable_thinking` | 深い思考モードを有効化するか | `false` | パスワード設定後、コンソールへアクセスする際にはまずパスワード入力によるログインが必要です。ログイン状態はデフォルトで 30 日間保持され、その間はサービスを再起動しても再ログインは不要です。パスワードはコンソールの「設定」ページからオンラインで変更することもできます。 From 54a20bca92302fc25b5cfa84cef02e80d9ec3a5f Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 17:32:21 +0800 Subject: [PATCH 291/399] docs: update README doc --- README.md | 1 + docs/docs.json | 10 +++++----- docs/ja/README.md | 1 + docs/zh/README.md | 1 + 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1edf1d3f..5393b7b3 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | [WeCom Bot](https://docs.cowagent.ai/en/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | | [QQ](https://docs.cowagent.ai/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Customer Service](https://docs.cowagent.ai/en/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | | [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](https://docs.cowagent.ai/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | diff --git a/docs/docs.json b/docs/docs.json index 7cad9585..fe2656ae 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -285,10 +285,10 @@ "pages": [ "en/models/index", "en/models/deepseek", - "en/models/minimax", "en/models/claude", "en/models/gemini", "en/models/openai", + "en/models/minimax", "en/models/glm", "en/models/qwen", "en/models/doubao", @@ -395,17 +395,17 @@ "group": "Platforms", "pages": [ "en/channels/index", - "en/channels/weixin", "en/channels/web", + "en/channels/telegram", + "en/channels/slack", + "en/channels/weixin", "en/channels/feishu", "en/channels/dingtalk", "en/channels/wecom-bot", "en/channels/qq", "en/channels/wecom", "en/channels/wechat-kf", - "en/channels/wechatmp", - "en/channels/telegram", - "en/channels/slack" + "en/channels/wechatmp" ] } ] diff --git a/docs/ja/README.md b/docs/ja/README.md index 0e4327c8..571f667e 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -125,6 +125,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [WeCom Bot](https://docs.cowagent.ai/ja/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | | [QQ](https://docs.cowagent.ai/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](https://docs.cowagent.ai/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat カスタマーサービス](https://docs.cowagent.ai/ja/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](https://docs.cowagent.ai/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](https://docs.cowagent.ai/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | diff --git a/docs/zh/README.md b/docs/zh/README.md index bc8000b3..acfe7c8e 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -125,6 +125,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [企微智能机器人](https://docs.cowagent.ai/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | | [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [企业微信应用](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [微信客服](https://docs.cowagent.ai/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | | [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | From c5b8e068918c36cd7c544226310a901ba3dbeb39 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 18:20:27 +0800 Subject: [PATCH 292/399] feat(channel): add Discord channel --- README.md | 5 +- channel/channel_factory.py | 3 + channel/discord/__init__.py | 0 channel/discord/discord_channel.py | 500 +++++++++++++++++++++++++++++ channel/discord/discord_message.py | 60 ++++ channel/web/web_channel.py | 8 + common/const.py | 1 + config.py | 364 ++++++++++----------- docs/channels/discord.mdx | 93 ++++++ docs/channels/index.mdx | 2 + docs/docs.json | 7 +- docs/en/channels/discord.mdx | 93 ++++++ docs/en/channels/index.mdx | 2 + docs/ja/README.md | 1 + docs/ja/channels/discord.mdx | 93 ++++++ docs/ja/channels/index.mdx | 6 +- docs/zh/README.md | 1 + requirements.txt | 2 + 18 files changed, 1055 insertions(+), 186 deletions(-) create mode 100644 channel/discord/__init__.py create mode 100644 channel/discord/discord_channel.py create mode 100644 channel/discord/discord_message.py create mode 100644 docs/channels/discord.mdx create mode 100644 docs/en/channels/discord.mdx create mode 100644 docs/ja/channels/discord.mdx diff --git a/README.md b/README.md index 5393b7b3..a77d8747 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | Channel | Text | Image | File | Voice | Group | | --- | :-: | :-: | :-: | :-: | :-: | | [Web Console](https://docs.cowagent.ai/en/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | | +| [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/en/channels/discord) | ✅ | ✅ | ✅ | | ✅ | | [WeChat](https://docs.cowagent.ai/en/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | | [Feishu / Lark](https://docs.cowagent.ai/en/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | | [DingTalk](https://docs.cowagent.ai/en/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -127,8 +130,6 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat Customer Service](https://docs.cowagent.ai/en/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | | [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | -| [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Slack](https://docs.cowagent.ai/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | > See the [Channels overview](https://docs.cowagent.ai/en/channels/index) for setup details. diff --git a/channel/channel_factory.py b/channel/channel_factory.py index 2645945e..0b9c9694 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -48,6 +48,9 @@ def create_channel(channel_type) -> Channel: elif channel_type == const.SLACK: from channel.slack.slack_channel import SlackChannel ch = SlackChannel() + elif channel_type == const.DISCORD: + from channel.discord.discord_channel import DiscordChannel + ch = DiscordChannel() elif channel_type in (const.WEIXIN, "wx"): from channel.weixin.weixin_channel import WeixinChannel ch = WeixinChannel() diff --git a/channel/discord/__init__.py b/channel/discord/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/channel/discord/discord_channel.py b/channel/discord/discord_channel.py new file mode 100644 index 00000000..2c46df6f --- /dev/null +++ b/channel/discord/discord_channel.py @@ -0,0 +1,500 @@ +""" +Discord channel via the Gateway (WebSocket) using discord.py. + +Features: +- Direct message & guild channel chat (text / image / file) +- Guild trigger: @mention or reply-to-bot (configurable) +- /cancel fast-path matches Web channel behaviour +- Gateway long connection: no public IP / callback URL required, works behind NAT + +Implementation note: + discord.py is async-first. We run the client inside a dedicated thread + with its own asyncio loop so the rest of cow (which is sync) stays + untouched. Inbound messages are dispatched onto cow's existing sync + ChatChannel.produce() pipeline; outbound send() schedules coroutines + back onto that loop via asyncio.run_coroutine_threadsafe. +""" + +import asyncio +import os +import re +import threading + +from bridge.context import Context, ContextType +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel, check_prefix +from channel.discord.discord_message import DiscordMessage +from common.expired_dict import ExpiredDict +from common.log import logger +from common.singleton import singleton +from config import conf + +# Discord caps a single message at 2000 chars; split conservatively below. +DISCORD_MSG_LIMIT = 1900 + + +@singleton +class DiscordChannel(ChatChannel): + NOT_SUPPORT_REPLYTYPE = [] + + def __init__(self): + super().__init__() + self.bot_token = "" + self.bot_user_id = "" # used to strip @mention and ignore self messages + self.bot_username = "" + self._client = None + self._loop = None + self._loop_thread = None + self._stop_event = threading.Event() + # Idempotent dedup; guard against rare duplicate dispatch + self._received_msgs = ExpiredDict(60 * 60 * 1) + + # Disable group whitelist / prefix checks (we handle triggering ourselves + # in _should_reply_in_guild), aligned with telegram / slack channels. + conf()["group_name_white_list"] = ["ALL_GROUP"] + conf()["single_chat_prefix"] = [""] + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def startup(self): + self.bot_token = conf().get("discord_token", "") + if not self.bot_token: + err = "[Discord] discord_token is required" + logger.error(err) + self.report_startup_error(err) + return + + try: + import discord + except ImportError: + err = ( + "[Discord] discord.py is not installed. " + "Run: pip install discord.py" + ) + logger.error(err) + self.report_startup_error(err) + return + + # Run the asyncio event loop in a dedicated thread so the sync cow body + # is untouched. + self._loop = asyncio.new_event_loop() + + def _run_loop(): + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._async_main(discord)) + except Exception as e: + logger.error(f"[Discord] event loop crashed: {e}", exc_info=True) + self.report_startup_error(str(e)) + finally: + try: + self._loop.close() + except Exception: + pass + logger.info("[Discord] event loop exited") + + self._loop_thread = threading.Thread(target=_run_loop, daemon=True, name="discord-loop") + self._loop_thread.start() + # Block startup() until the loop thread exits, matching other channels' + # behaviour (startup is a blocking call). + self._loop_thread.join() + + async def _async_main(self, discord): + """Build the discord client, register handlers, and connect to the Gateway.""" + # message_content is a privileged intent; it must be enabled in the + # Developer Portal (Bot -> Privileged Gateway Intents) to read text. + intents = discord.Intents.default() + intents.message_content = True + client = discord.Client(intents=intents) + self._client = client + + channel = self + + @client.event + async def on_ready(): + channel.bot_user_id = str(client.user.id) + channel.bot_username = client.user.name or "" + channel.name = channel.bot_user_id # ChatChannel uses self.name to strip @-mention + logger.info(f"[Discord] Bot logged in as {client.user} (id={client.user.id})") + channel.report_startup_success() + logger.info("[Discord] ✅ Discord bot ready, listening for messages") + + @client.event + async def on_message(message): + await channel._on_message(message) + + # Connect to the Gateway; discord.py auto-reconnects on transient errors. + logger.info("[Discord] Connecting to Gateway...") + + # client.start() handles login + Gateway connection and runs until + # close(); it is the standard entrypoint across discord.py versions. + runner_task = asyncio.create_task(client.start(self.bot_token)) + + # Block until stop() + try: + while not self._stop_event.is_set(): + if runner_task.done(): + # Surface a startup/connection failure (e.g. bad token) + exc = runner_task.exception() + if exc: + logger.error(f"[Discord] client stopped: {exc}", exc_info=exc) + self.report_startup_error(str(exc)) + break + await asyncio.sleep(0.5) + finally: + try: + if not client.is_closed(): + await client.close() + except Exception as e: + logger.warning(f"[Discord] shutdown error: {e}") + + def stop(self): + logger.info("[Discord] stop() called") + self._stop_event.set() + if self._loop_thread and self._loop_thread.is_alive(): + try: + self._loop_thread.join(timeout=10) + except Exception: + pass + logger.info("[Discord] stop() completed") + + # ------------------------------------------------------------------ + # Inbound: discord message -> ChatMessage -> ChatChannel.produce + # ------------------------------------------------------------------ + + async def _on_message(self, message): + """Discord message entry: parse -> build ChatMessage -> produce().""" + try: + # Ignore our own messages and other bots. self._client.user may be + # None until on_ready completes, so guard against that. + if self._client and self._client.user and message.author.id == self._client.user.id: + return + if message.author.bot: + return + + # Idempotent dedup + msg_uid = f"{message.channel.id}:{message.id}" + if self._received_msgs.get(msg_uid): + return + self._received_msgs[msg_uid] = True + + # guild is None for DMs + is_group = message.guild is not None + + # Guild trigger gate (silently drop if not triggered) + if is_group and not self._should_reply_in_guild(message): + logger.debug(f"[Discord] guild message not triggered (need @mention or reply), skip") + return + + # Parse message type + download attachments if needed. + ctype, content, caption = await self._parse_message(message) + if ctype is None: + logger.debug(f"[Discord] unsupported message type, skip. msg_id={message.id}") + return + + # Strip the bot mention from guild text/caption + if is_group: + if ctype == ContextType.TEXT and content: + content = self._strip_at_mention(content) + if caption: + caption = self._strip_at_mention(caption) + + dc_msg = DiscordMessage( + message, + is_group=is_group, + bot_user_id=self.bot_user_id, + ctype=ctype, + content=content, + ) + dc_msg.is_at = is_group # if we reached here in a guild, bot is mentioned/replied + + from channel.file_cache import get_file_cache + file_cache = get_file_cache() + session_id = self._compute_session_id(message, is_group) + + # Media + caption together: treat as a complete query and bypass the cache + if ctype in (ContextType.IMAGE, ContextType.FILE) and caption: + tag = "image" if ctype == ContextType.IMAGE else "file" + merged_text = f"{caption}\n[{tag}: {content}]" + dc_msg.ctype = ContextType.TEXT + dc_msg.content = merged_text + ctype = ContextType.TEXT + logger.info(f"[Discord] Media+caption merged for session {session_id}") + # fallthrough to the TEXT branch below + + elif ctype == ContextType.IMAGE: + file_cache.add(session_id, content, file_type="image") + logger.info(f"[Discord] Image cached for session {session_id}, waiting for query...") + return + elif ctype == ContextType.FILE: + file_cache.add(session_id, content, file_type="file") + logger.info(f"[Discord] File cached for session {session_id}: {content}") + return + + if ctype == ContextType.TEXT: + # Fast-path: /cancel mirrors Web channel behaviour + if (content or "").strip().lower() in ("/cancel", "cancel"): + await self._do_cancel(session_id, message) + return + + cached_files = file_cache.get(session_id) + if cached_files: + refs = [] + for fi in cached_files: + ftype = fi["type"] + tag = ftype if ftype in ("image", "video") else "file" + refs.append(f"[{tag}: {fi['path']}]") + dc_msg.content = (dc_msg.content or "") + "\n" + "\n".join(refs) + file_cache.clear(session_id) + logger.info(f"[Discord] Attached {len(cached_files)} cached file(s) to query") + + context = self._compose_context( + dc_msg.ctype, + dc_msg.content, + isgroup=is_group, + msg=dc_msg, + # Replies use Discord's reply mechanism, no manual @mention needed + no_need_at=True, + ) + if context: + context["session_id"] = session_id + context["receiver"] = str(message.channel.id) + context["discord_channel_id"] = message.channel.id + context["discord_reply_to_msg_id"] = message.id if is_group else None + self.produce(context) + logger.debug(f"[Discord] received: type={ctype}, content={str(dc_msg.content)[:80]}") + + except Exception as e: + logger.error(f"[Discord] _on_message error: {e}", exc_info=True) + + async def _do_cancel(self, session_id: str, message): + """Fast-path: /cancel calls cancel_session directly without going through agent.""" + try: + from agent.protocol import get_cancel_registry + cancelled = get_cancel_registry().cancel_session(session_id) + text = "Current task cancelled." if cancelled else "No running task to cancel." + await message.channel.send(text) + logger.info(f"[Discord] /cancel session={session_id}, cancelled={cancelled}") + except Exception as e: + logger.error(f"[Discord] /cancel error: {e}", exc_info=True) + + async def _parse_message(self, message): + """Parse a discord message and return (ctype, content, caption). + + - content is text for ContextType.TEXT, otherwise the local file path + - caption is the optional text accompanying an attachment; empty for plain text + """ + text = (message.content or "").strip() + attachments = message.attachments or [] + + if attachments: + # Handle the first attachment; caption is the accompanying message text + att = attachments[0] + content_type = (att.content_type or "").lower() + name = att.filename or str(att.id) + path = await self._download_attachment(att, name) + if not path: + return (None, None, "") + is_image = content_type.startswith("image/") or name.lower().endswith( + (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") + ) + if is_image: + return (ContextType.IMAGE, path, text) + return (ContextType.FILE, path, text) + + if text: + return (ContextType.TEXT, text, "") + + return (None, None, "") + + async def _download_attachment(self, attachment, name: str): + """Download a discord attachment into the local tmp dir; return path or None.""" + try: + tmp_dir = DiscordMessage.get_tmp_dir() + safe_name = re.sub(r"[^\w.\-]", "_", name) + # Prefix with attachment id to avoid name collisions + local_path = os.path.join(tmp_dir, f"{attachment.id}_{safe_name}") + await attachment.save(local_path) + logger.debug(f"[Discord] downloaded {name} -> {local_path}") + return local_path + except Exception as e: + logger.error(f"[Discord] download_attachment failed ({name}): {e}") + return None + + # ------------------------------------------------------------------ + # Guild trigger logic + # ------------------------------------------------------------------ + + def _should_reply_in_guild(self, message) -> bool: + """Decide whether to reply to a guild channel message based on configuration.""" + mode = conf().get("discord_group_trigger", "mention_or_reply") + if mode == "all": + return True + + # self._client.user may be None until on_ready completes + if not self._client or not self._client.user: + return False + + # 1) Mentioned (direct @bot, not @everyone / @role) + if self._client.user in message.mentions: + return True + + # 2) Reply to a bot message + if mode == "mention_or_reply": + ref = message.reference + resolved = getattr(ref, "resolved", None) if ref else None + if resolved and getattr(resolved, "author", None): + if resolved.author.id == self._client.user.id: + return True + + return False + + def _strip_at_mention(self, content: str) -> str: + """Strip <@BOT_ID> / <@!BOT_ID> from guild text.""" + if not content or not self.bot_user_id: + return content + pattern = re.compile(r"<@!?" + re.escape(self.bot_user_id) + r">") + return pattern.sub("", content).strip() + + @staticmethod + def _compute_session_id(message, is_group: bool) -> str: + channel_id = message.channel.id + user_id = message.author.id + if is_group: + if conf().get("group_shared_session", True): + return f"discord_channel_{channel_id}" + return f"discord_channel_{channel_id}_{user_id}" + return f"discord_user_{user_id}" + + # ------------------------------------------------------------------ + # Override _compose_context: skip the parent's group whitelist/at checks + # (already handled via _should_reply_in_guild). Same idea as telegram / slack. + # ------------------------------------------------------------------ + + 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"] + if cmsg.is_group: + if conf().get("group_shared_session", True): + context["session_id"] = cmsg.other_user_id + else: + context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}" + else: + 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 or "").strip() + if "desire_rtype" not in context and conf().get("always_reply_voice"): + context["desire_rtype"] = ReplyType.VOICE + elif ctype == ContextType.VOICE: + if "desire_rtype" not in context and ( + conf().get("voice_reply_voice") or conf().get("always_reply_voice") + ): + context["desire_rtype"] = ReplyType.VOICE + + return context + + # ------------------------------------------------------------------ + # Outbound: ChatChannel.send -> Discord Gateway/REST + # ------------------------------------------------------------------ + + def send(self, reply: Reply, context: Context): + """Called from cow's sync main thread; marshal the coroutine onto the loop thread.""" + if self._loop is None or self._client is None: + logger.warning("[Discord] client not ready, drop reply") + return + + channel_id = context.get("discord_channel_id") + if channel_id is None: + logger.warning("[Discord] no discord_channel_id in context, drop reply") + return + + coro = self._async_send(reply, channel_id) + try: + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + future.result(timeout=180) + except Exception as e: + logger.error(f"[Discord] send failed: {e}") + + async def _async_send(self, reply: Reply, channel_id): + try: + import discord + + channel = self._client.get_channel(channel_id) + if channel is None: + # Not in cache (e.g. DM channel); fetch it explicitly + channel = await self._client.fetch_channel(channel_id) + + rtype = reply.type + content = reply.content + + if rtype in (ReplyType.TEXT, ReplyType.INFO, ReplyType.ERROR): + text = str(content) if content is not None else "" + if not text: + return + for chunk in _split_text(text, DISCORD_MSG_LIMIT): + await channel.send(chunk) + + elif rtype == ReplyType.IMAGE: + # Already a local BytesIO; send it directly + content.seek(0) + await channel.send(file=discord.File(content, filename="image.png")) + + elif rtype == ReplyType.IMAGE_URL: + url = str(content) + if url.startswith("file://"): + local = url[7:] + await channel.send(file=discord.File(local)) + else: + # Post the URL as text; Discord will unfurl it as an image preview + await channel.send(url) + + elif rtype in (ReplyType.VOICE, ReplyType.FILE): + local = content[7:] if isinstance(content, str) and content.startswith("file://") else content + caption = getattr(reply, "text_content", None) or None + await channel.send(content=caption, file=discord.File(local)) + + else: + # Fallback: send as plain text + await channel.send(str(content)) + + logger.info(f"[Discord] sent reply (type={rtype}, channel={channel_id})") + + except Exception as e: + logger.error(f"[Discord] _async_send error: {e}", exc_info=True) + + +def _split_text(text: str, limit: int): + """Split long text preferring line breaks to keep markdown structure intact.""" + if len(text) <= limit: + yield text + return + buf = [] + size = 0 + for line in text.splitlines(keepends=True): + if size + len(line) > limit and buf: + yield "".join(buf) + buf, size = [], 0 + # Hard-split single lines that exceed the limit + while len(line) > limit: + yield line[:limit] + line = line[limit:] + buf.append(line) + size += len(line) + if buf: + yield "".join(buf) diff --git a/channel/discord/discord_message.py b/channel/discord/discord_message.py new file mode 100644 index 00000000..2df32785 --- /dev/null +++ b/channel/discord/discord_message.py @@ -0,0 +1,60 @@ +""" +Discord message adapter. + +Convert a discord.py Message into cow's unified ChatMessage. +File downloads are NOT performed here; the channel layer downloads +attachments on demand inside the async event loop. +""" +import os + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.utils import expand_path +from config import conf + + +class DiscordMessage(ChatMessage): + """Wrap a discord.py Message into the unified ChatMessage.""" + + def __init__(self, message, is_group: bool = False, bot_user_id: str = "", + ctype: ContextType = ContextType.TEXT, content: str = ""): + super().__init__(message) + # Basic fields + self.msg_id = str(message.id) + self.create_time = int(message.created_at.timestamp()) if message.created_at else 0 + self.ctype = ctype + self.content = content + + author = message.author + channel = message.channel + + # Sender / chat info + from_user_id = str(author.id) + from_user_nick = getattr(author, "display_name", None) or getattr(author, "name", None) or from_user_id + self.from_user_id = from_user_id + self.from_user_nickname = from_user_nick + self.to_user_id = bot_user_id or "discord_bot" + self.to_user_nickname = bot_user_id or "discord_bot" + + self.is_group = is_group + if is_group: + # Guild channel: other_user_id = channel_id, actual_user_id = sender id + self.other_user_id = str(channel.id) + self.other_user_nickname = getattr(channel, "name", None) or str(channel.id) + self.actual_user_id = from_user_id + self.actual_user_nickname = from_user_nick + else: + # DM: use channel_id so replies go back to the same DM channel + self.other_user_id = str(channel.id) + self.other_user_nickname = from_user_nick + + # Whether the bot was triggered by @-mention (set by channel layer) + self.is_at = False + + @staticmethod + def get_tmp_dir() -> str: + """Local download directory, aligned with other channels (agent_workspace/tmp).""" + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + tmp_dir = os.path.join(workspace_root, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + return tmp_dir diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index c0faef4d..6cf6bc0b 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2952,6 +2952,14 @@ class ChannelsHandler: {"key": "slack_app_token", "label": "App Token (xapp-)", "type": "secret"}, ], }), + ("discord", { + "label": {"zh": "Discord", "en": "Discord"}, + "icon": "fa-discord", + "color": "indigo", + "fields": [ + {"key": "discord_token", "label": "Bot Token", "type": "secret"}, + ], + }), ]) @staticmethod diff --git a/common/const.py b/common/const.py index 4e4aca24..ba4edd17 100644 --- a/common/const.py +++ b/common/const.py @@ -247,3 +247,4 @@ WEIXIN = "weixin" WECHAT_KF = "wechat_kf" TELEGRAM = "telegram" SLACK = "slack" +DISCORD = "discord" diff --git a/config.py b/config.py index 62896d17..7ad6fa55 100644 --- a/config.py +++ b/config.py @@ -8,211 +8,215 @@ import pickle from common.log import logger -# 将所有可用的配置项写在字典里, 请使用小写字母 -# 此处的配置值无实际意义,程序不会读取此处的配置,仅用于提示格式,请将配置加入到config.json中 +# All available config keys are listed in this dict (use lowercase keys). +# The values here are placeholders only; the program does NOT read them. +# They merely document the expected format — put real values in config.json. available_setting = { - # openai api配置 + # openai api config "open_ai_api_key": "", # openai api key - # openai apibase,当use_azure_chatgpt为true时,需要设置对应的api base + # openai api base; when use_azure_chatgpt is true, set the matching api base "open_ai_api_base": "https://api.openai.com/v1", "claude_api_base": "https://api.anthropic.com/v1", # claude api base "gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base "custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom") "custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom") - "proxy": "", # openai使用的代理 - # chatgpt模型, 当use_azure_chatgpt为true时,其名称为Azure上model deployment名称 - "model": "gpt-3.5-turbo", # 可选择: gpt-4o, pt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini等模型,全部可选模型详见common/const.py文件 - "bot_type": "", # 可选配置,使用兼容openai格式的三方服务时候,需填"openai"或"custom"(custom模式下切换模型不会自动切换bot_type)。bot具体名称详见common/const.py文件,如不填根据model名称判断 - "use_azure_chatgpt": False, # 是否使用azure的chatgpt - "azure_deployment_id": "", # azure 模型部署名称 - "azure_api_version": "", # azure api版本 - # Bot触发配置 - "single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复 - "single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人 - "single_chat_reply_suffix": "", # 私聊时自动回复的后缀,\n 可以换行 - "group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复 - "no_need_at": False, # 群聊回复时是否不需要艾特 - "group_chat_reply_prefix": "", # 群聊时自动回复的前缀 - "group_chat_reply_suffix": "", # 群聊时自动回复的后缀,\n 可以换行 - "group_chat_keyword": [], # 群聊时包含该关键词则会触发机器人回复 - "group_at_off": False, # 是否关闭群聊时@bot的触发 - "group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表 - "group_name_keyword_white_list": [], # 开启自动回复的群名称关键词列表 - "group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称 - "group_shared_session": False, # 群聊是否共享会话上下文(所有成员共享)。False时每个用户在群内有独立会话 - "nick_name_black_list": [], # 用户昵称黑名单 - "group_welcome_msg": "", # 配置新人进群固定欢迎语,不配置则使用随机风格欢迎 - "trigger_by_self": False, # 是否允许机器人触发 - "text_to_image": "dall-e-2", # 图片生成模型,可选 dall-e-2, dall-e-3 - # Azure OpenAI dall-e-3 配置 - "dalle3_image_style": "vivid", # 图片生成dalle3的风格,可选有 vivid, natural - "dalle3_image_quality": "hd", # 图片生成dalle3的质量,可选有 standard, hd - # Azure OpenAI DALL-E API 配置, 当use_azure_chatgpt为true时,用于将文字回复的资源和Dall-E的资源分开. - "azure_openai_dalle_api_base": "", # [可选] azure openai 用于回复图片的资源 endpoint,默认使用 open_ai_api_base - "azure_openai_dalle_api_key": "", # [可选] azure openai 用于回复图片的资源 key,默认使用 open_ai_api_key - "azure_openai_dalle_deployment_id":"", # [可选] azure openai 用于回复图片的资源 deployment id,默认使用 text_to_image - "image_proxy": True, # 是否需要图片代理,国内访问LinkAI时需要 - "image_create_prefix": ["画", "看", "找"], # 开启图片回复的前缀 - "concurrency_in_session": 1, # 同一会话最多有多少条消息在处理中,大于1可能乱序 - "image_create_size": "256x256", # 图片大小,可选有 256x256, 512x512, 1024x1024 (dall-e-3默认为1024x1024) + "proxy": "", # proxy used by openai + # chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name + "model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list + "bot_type": "", # optional; for OpenAI-compatible third-party services set "openai" or "custom" (in custom mode switching model won't auto-switch bot_type). See common/const.py for bot names; inferred from model name if left empty + "use_azure_chatgpt": False, # whether to use Azure chatgpt + "azure_deployment_id": "", # azure model deployment name + "azure_api_version": "", # azure api version + # Bot trigger config + "single_chat_prefix": ["bot", "@bot"], # text must contain this prefix to trigger a reply in single chat + "single_chat_reply_prefix": "[bot] ", # auto-reply prefix in single chat, used to distinguish from a real person + "single_chat_reply_suffix": "", # auto-reply suffix in single chat; \n inserts a line break + "group_chat_prefix": ["@bot"], # messages containing this prefix trigger a reply in group chat + "no_need_at": False, # whether replying in group chat does not require an @mention + "group_chat_reply_prefix": "", # auto-reply prefix in group chat + "group_chat_reply_suffix": "", # auto-reply suffix in group chat; \n inserts a line break + "group_chat_keyword": [], # messages containing this keyword trigger a reply in group chat + "group_at_off": False, # whether to disable @bot triggering in group chat + "group_name_white_list": ["group1", "group2"], # group names where auto-reply is enabled + "group_name_keyword_white_list": [], # group-name keywords where auto-reply is enabled + "group_chat_in_one_session": ["group1"], # group names that share conversation context + "group_shared_session": False, # whether group chat shares conversation context (all members share). When False each user has an independent session in the group + "nick_name_black_list": [], # user nickname blacklist + "group_welcome_msg": "", # fixed welcome message for new group members; uses a random style when empty + "trigger_by_self": False, # whether the bot can be triggered by itself + "text_to_image": "dall-e-2", # image generation model, options: dall-e-2, dall-e-3 + # Azure OpenAI dall-e-3 config + "dalle3_image_style": "vivid", # dalle3 image style, options: vivid, natural + "dalle3_image_quality": "hd", # dalle3 image quality, options: standard, hd + # Azure OpenAI DALL-E API config; when use_azure_chatgpt is true, separates the text-reply resource from the DALL-E resource + "azure_openai_dalle_api_base": "", # [optional] azure openai endpoint for image replies; defaults to open_ai_api_base + "azure_openai_dalle_api_key": "", # [optional] azure openai key for image replies; defaults to open_ai_api_key + "azure_openai_dalle_deployment_id":"", # [optional] azure openai deployment id for image replies; defaults to text_to_image + "image_proxy": True, # whether an image proxy is needed; required when accessing LinkAI from mainland China + "image_create_prefix": ["画", "看", "找"], # prefixes that enable image replies + "concurrency_in_session": 1, # max number of in-flight messages per session; values >1 may cause out-of-order replies + "image_create_size": "256x256", # image size, options: 256x256, 512x512, 1024x1024 (dall-e-3 defaults to 1024x1024) "group_chat_exit_group": False, - # chatgpt会话参数 - "expires_in_seconds": 3600, # 无操作会话的过期时间 - # 人格描述 - "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", - "conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数 - # chatgpt限流配置 - "rate_limit_chatgpt": 20, # chatgpt的调用频率限制 - "rate_limit_dalle": 50, # openai dalle的调用频率限制 - # chatgpt api参数 参考https://platform.openai.com/docs/api-reference/chat/create + # chatgpt session params + "expires_in_seconds": 3600, # idle session expiry time + # persona description (only used in chat mode) + "character_desc": "You are a helpful AI assistant. You aim to answer and solve any questions people have, and can communicate in multiple languages.", + "conversation_max_tokens": 1000, # max characters of context memory + # chatgpt rate limit config + "rate_limit_chatgpt": 20, # chatgpt call rate limit + "rate_limit_dalle": 50, # openai dalle call rate limit + # chatgpt api params, see https://platform.openai.com/docs/api-reference/chat/create "temperature": 0.9, "top_p": 1, "frequency_penalty": 0, "presence_penalty": 0, - "request_timeout": 180, # chatgpt请求超时时间,openai接口默认设置为600,对于难问题一般需要较长时间 - "timeout": 120, # chatgpt重试超时时间,在这个时间内,将会自动重试 - # Baidu 文心一言参数 - "baidu_wenxin_model": "eb-instant", # 默认使用ERNIE-Bot-turbo模型 + "request_timeout": 180, # chatgpt request timeout; the openai api defaults to 600, hard questions usually need longer + "timeout": 120, # chatgpt retry timeout; will auto-retry within this window + # Baidu Wenxin (ERNIE) params + "baidu_wenxin_model": "eb-instant", # defaults to the ERNIE-Bot-turbo model "baidu_wenxin_api_key": "", # Baidu api key "baidu_wenxin_secret_key": "", # Baidu secret key "baidu_wenxin_prompt_enabled": False, # Enable prompt if you are using ernie character model # Baidu Qianfan / ERNIE OpenAI-compatible API "qianfan_api_key": "", # Baidu Qianfan API key in bce-v3 format "qianfan_api_base": "https://qianfan.baidubce.com/v2", # Qianfan OpenAI-compatible API base - # 讯飞星火API - "xunfei_app_id": "", # 讯飞应用ID - "xunfei_api_key": "", # 讯飞 API key - "xunfei_api_secret": "", # 讯飞 API secret - "xunfei_domain": "", # 讯飞模型对应的domain参数,Spark4.0 Ultra为 4.0Ultra,其他模型详见: https://www.xfyun.cn/doc/spark/Web.html - "xunfei_spark_url": "", # 讯飞模型对应的请求地址,Spark4.0 Ultra为 wss://spark-api.xf-yun.com/v4.0/chat,其他模型参考详见: https://www.xfyun.cn/doc/spark/Web.html - # claude 配置 + # Xunfei Spark API + "xunfei_app_id": "", # Xunfei app id + "xunfei_api_key": "", # Xunfei API key + "xunfei_api_secret": "", # Xunfei API secret + "xunfei_domain": "", # Xunfei model domain param; for Spark4.0 Ultra it is 4.0Ultra, see https://www.xfyun.cn/doc/spark/Web.html for others + "xunfei_spark_url": "", # Xunfei model request url; for Spark4.0 Ultra it is wss://spark-api.xf-yun.com/v4.0/chat, see https://www.xfyun.cn/doc/spark/Web.html for others + # claude config "claude_api_cookie": "", "claude_uuid": "", # claude api key "claude_api_key": "", - # 通义千问API, 获取方式查看文档 https://help.aliyun.com/document_detail/2587494.html + # Tongyi Qianwen API, see https://help.aliyun.com/document_detail/2587494.html for how to obtain "qwen_access_key_id": "", "qwen_access_key_secret": "", "qwen_agent_key": "", "qwen_app_id": "", - "qwen_node_id": "", # 流程编排模型用到的id,如果没有用到qwen_node_id,请务必保持为空字符串 - # 阿里灵积(通义新版sdk)模型api key + "qwen_node_id": "", # id used by workflow-orchestration models; keep it an empty string if qwen_node_id is unused + # Alibaba Lingji (Tongyi new sdk) model api key "dashscope_api_key": "", # Google Gemini Api Key "gemini_api_key": "", - # Embedding 模型设置 - "embedding_provider": "", # 显式指定厂商:openai / linkai / dashscope / doubao / zhipu (与 bot_type 命名一致) - "embedding_model": "", # 留空使用厂商默认 model - "embedding_dimensions": 0, # 留空/0 使用厂商默认维度(推荐统一 1024) - # 语音设置 - "speech_recognition": True, # 是否开启语音识别 - "group_speech_recognition": False, # 是否开启群组语音识别 - "voice_reply_voice": False, # 是否使用语音回复语音,需要设置对应语音合成引擎的api key - "always_reply_voice": False, # 是否一直使用语音回复 - "voice_to_text": "openai", # 语音识别引擎,支持openai,baidu,google,azure,xunfei,ali - "text_to_voice": "openai", # 语音合成引擎,支持openai,baidu,google,azure,xunfei,ali,pytts(offline),elevenlabs,edge(online) + # Embedding model config + "embedding_provider": "", # explicitly set the provider: openai / linkai / dashscope / doubao / zhipu (aligned with bot_type naming) + "embedding_model": "", # leave empty to use the provider's default model + "embedding_dimensions": 0, # leave empty/0 to use the provider's default dimension (1024 recommended for consistency) + # voice config + "speech_recognition": True, # whether to enable speech recognition + "group_speech_recognition": False, # whether to enable group speech recognition + "voice_reply_voice": False, # whether to reply to voice with voice; requires the matching TTS engine api key + "always_reply_voice": False, # whether to always reply with voice + "voice_to_text": "openai", # speech recognition engine: openai,baidu,google,azure,xunfei,ali + "text_to_voice": "openai", # TTS engine: openai,baidu,google,azure,xunfei,ali,pytts(offline),elevenlabs,edge(online) "text_to_voice_model": "tts-1", "tts_voice_id": "alloy", - # baidu 语音api配置, 使用百度语音识别和语音合成时需要 + # baidu voice api config; required when using Baidu speech recognition and TTS "baidu_app_id": "", "baidu_api_key": "", "baidu_secret_key": "", - # 1536普通话(支持简单的英文识别) 1737英语 1637粤语 1837四川话 1936普通话远场 + # 1536 Mandarin (with basic English) 1737 English 1637 Cantonese 1837 Sichuanese 1936 Mandarin far-field "baidu_dev_pid": 1536, - # azure 语音api配置, 使用azure语音识别和语音合成时需要 + # azure voice api config; required when using Azure speech recognition and TTS "azure_voice_api_key": "", "azure_voice_region": "japaneast", - # elevenlabs 语音api配置 - "xi_api_key": "", # 获取ap的方法可以参考https://docs.elevenlabs.io/api-reference/quick-start/authentication - "xi_voice_id": "", # ElevenLabs提供了9种英式、美式等英语发音id,分别是“Adam/Antoni/Arnold/Bella/Domi/Elli/Josh/Rachel/Sam” - # 服务时间限制 - "chat_time_module": False, # 是否开启服务时间限制 - "chat_start_time": "00:00", # 服务开始时间 - "chat_stop_time": "24:00", # 服务结束时间 - # 翻译api - "translate": "baidu", # 翻译api,支持baidu, youdao - # baidu翻译api的配置 - "baidu_translate_app_id": "", # 百度翻译api的appid - "baidu_translate_app_key": "", # 百度翻译api的秘钥 - # youdao翻译api的配置 - "youdao_translate_app_key": "", # 有道翻译api的应用ID - "youdao_translate_app_secret": "", # 有道翻译api的应用密钥 - # wechatmp的配置 - "wechatmp_token": "", # 微信公众平台的Token - "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 - "wechatmp_app_id": "", # 微信公众平台的appID - "wechatmp_app_secret": "", # 微信公众平台的appsecret - "wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要 - # wechatcom的通用配置 - "wechatcom_corp_id": "", # 企业微信公司的corpID - # wechatcomapp的配置 - "wechatcomapp_token": "", # 企业微信app的token - "wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发 - "wechatcomapp_secret": "", # 企业微信app的secret - "wechatcomapp_agent_id": "", # 企业微信app的agent_id - "wechatcomapp_aes_key": "", # 企业微信app的aes_key - # 微信客服(wechat_kf)的配置 - "wechat_kf_corp_id": "", # 微信客服所在企业的corp_id - "wechat_kf_token": "", # 微信客服回调token - "wechat_kf_port": 9888, # 微信客服回调服务端口 - "wechat_kf_secret": "", # 微信客服应用的secret - "wechat_kf_aes_key": "", # 微信客服回调aes_key - "wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # 微信客服sync_msg游标持久化文件路径 - # 飞书配置 - "feishu_port": 80, # 飞书bot监听端口,仅webhook模式需要 - "feishu_app_id": "", # 飞书机器人应用APP Id - "feishu_app_secret": "", # 飞书机器人APP secret - "feishu_token": "", # 飞书 verification token,仅webhook模式需要 - "feishu_event_mode": "websocket", # 飞书事件接收模式: webhook(HTTP服务器) 或 websocket(长连接) - # 飞书流式回复(基于官方 cardkit 流式卡片 API,需要机器人开通 cardkit:card:write 权限,且飞书客户端 7.20+) - "feishu_stream_reply": True, # 是否开启流式回复(打字机效果)。失败/老客户端自动降级为非流式或升级提示 - # 钉钉配置 - "dingtalk_client_id": "", # 钉钉机器人Client ID - "dingtalk_client_secret": "", # 钉钉机器人Client Secret + # elevenlabs voice api config + "xi_api_key": "", # see https://docs.elevenlabs.io/api-reference/quick-start/authentication for how to obtain the api key + "xi_voice_id": "", # ElevenLabs offers 9 English voice ids: Adam/Antoni/Arnold/Bella/Domi/Elli/Josh/Rachel/Sam + # service time limit + "chat_time_module": False, # whether to enable service-time limiting + "chat_start_time": "00:00", # service start time + "chat_stop_time": "24:00", # service stop time + # translation api + "translate": "baidu", # translation api: baidu, youdao + # baidu translation api config + "baidu_translate_app_id": "", # baidu translation api appid + "baidu_translate_app_key": "", # baidu translation api secret key + # youdao translation api config + "youdao_translate_app_key": "", # youdao translation api app id + "youdao_translate_app_secret": "", # youdao translation api app secret + # wechatmp config + "wechatmp_token": "", # WeChat Official Account token + "wechatmp_port": 8080, # WeChat Official Account port; needs port forwarding to 80 or 443 + "wechatmp_app_id": "", # WeChat Official Account appID + "wechatmp_app_secret": "", # WeChat Official Account appsecret + "wechatmp_aes_key": "", # WeChat Official Account EncodingAESKey; required in encrypted mode + # wechatcom shared config + "wechatcom_corp_id": "", # WeCom corp id + # wechatcomapp config + "wechatcomapp_token": "", # WeCom app token + "wechatcomapp_port": 9898, # WeCom app service port; no port forwarding needed + "wechatcomapp_secret": "", # WeCom app secret + "wechatcomapp_agent_id": "", # WeCom app agent_id + "wechatcomapp_aes_key": "", # WeCom app aes_key + # WeCom Customer Service (wechat_kf) config + "wechat_kf_corp_id": "", # corp_id of the company the WeCom Customer Service belongs to + "wechat_kf_token": "", # WeCom Customer Service callback token + "wechat_kf_port": 9888, # WeCom Customer Service callback service port + "wechat_kf_secret": "", # WeCom Customer Service app secret + "wechat_kf_aes_key": "", # WeCom Customer Service callback aes_key + "wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # path for persisting the WeCom Customer Service sync_msg cursor + # Feishu config + "feishu_port": 80, # Feishu bot listening port; only needed in webhook mode + "feishu_app_id": "", # Feishu bot app id + "feishu_app_secret": "", # Feishu bot app secret + "feishu_token": "", # Feishu verification token; only needed in webhook mode + "feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection) + # Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+) + "feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients + # DingTalk config + "dingtalk_client_id": "", # DingTalk bot Client ID + "dingtalk_client_secret": "", # DingTalk bot Client Secret "dingtalk_card_enabled": False, - # 企微智能机器人配置(长连接模式) - "wecom_bot_id": "", # 企微智能机器人BotID - "wecom_bot_secret": "", # 企微智能机器人长连接Secret - # Telegram 配置 - "telegram_token": "", # 从 @BotFather 申请的 bot token - "telegram_proxy": "", # 可选的 HTTP/SOCKS5 代理,例如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080(留空则走系统环境变量) - "telegram_group_trigger": "mention_or_reply", # 群聊触发方式: mention_or_reply(@或回复触发,推荐) | mention_only(仅@) | all(所有消息) - "telegram_register_commands": True, # 启动时是否自动向 BotFather 注册命令菜单(与 web 端 slash 命令一致) - # Slack 配置(Socket Mode,无需公网 IP) - "slack_bot_token": "", # Bot User OAuth Token,形如 xoxb-... - "slack_app_token": "", # App-Level Token(开启 Socket Mode 后生成),形如 xapp-... - "slack_group_trigger": "mention_or_reply", # 频道触发方式: mention_or_reply(@或线程内回复,推荐) | mention_only(仅@) | all(所有消息) - # 微信配置 - "weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录 + # WeCom smart bot config (long connection mode) + "wecom_bot_id": "", # WeCom smart bot BotID + "wecom_bot_secret": "", # WeCom smart bot long-connection secret + # Telegram config + "telegram_token": "", # Bot token from @BotFather + "telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars) + "telegram_group_trigger": "mention_or_reply", # Group trigger: mention_or_reply(@ or reply, recommended) | mention_only(@ only) | all(every message) + "telegram_register_commands": True, # Auto-register the BotFather command menu on startup (aligned with web slash commands) + # Slack config (Socket Mode, no public IP required) + "slack_bot_token": "", # Bot User OAuth Token, like xoxb-... + "slack_app_token": "", # App-Level Token (generated after enabling Socket Mode), like xapp-... + "slack_group_trigger": "mention_or_reply", # Channel trigger: mention_or_reply(@ or reply in thread, recommended) | mention_only(@ only) | all(every message) + # Discord config (Gateway connection, no public IP required) + "discord_token": "", # Discord Bot Token (generated on the Bot page of the Developer Portal) + "discord_group_trigger": "mention_or_reply", # Channel trigger: mention_or_reply(@ or reply to bot, recommended) | mention_only(@ only) | all(every message) + # WeChat config + "weixin_token": "", # bot_token obtained after WeChat login; leave empty to auto scan-login on startup "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,weixin,wechatmp,wechatmp_service,wechatcom_app,wechat_kf,telegram,slack - "web_console": True, # 是否自动启动Web控制台(默认启动)。设为False可禁用 - "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app - "debug": False, # 是否开启debug模式,开启后会打印更多日志 - "appdata_dir": "", # 数据目录 - # 插件配置 - "plugin_trigger_prefix": "$", # 规范插件提供聊天相关指令的前缀,建议不要和管理员指令前缀"#"冲突 - # 是否使用全局插件配置 + # custom trigger words for chatgpt commands + "clear_memory_commands": ["#清除记忆"], # session-reset command; must start with # + # channel config + "channel_type": "", # channel type; supports running multiple channels at once. Single: "feishu", multiple: "feishu, dingtalk" or ["feishu", "dingtalk"]. Options: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,wechat_kf,telegram,slack,discord + "web_console": True, # whether to auto-start the Web console (on by default). Set False to disable + "subscribe_msg": "", # subscribe message; supported by: wechatmp, wechatmp_service, wechatcom_app + "debug": False, # whether to enable debug mode; prints more logs when on + "appdata_dir": "", # data directory + # plugin config + "plugin_trigger_prefix": "$", # prefix for plugin chat commands; avoid clashing with the admin command prefix "#" + # whether to use the global plugin config "use_global_plugin_config": False, - "max_media_send_count": 3, # 单次最大发送媒体资源的个数 - "media_send_interval": 1, # 发送图片的事件间隔,单位秒 - # 智谱AI 平台配置 + "max_media_send_count": 3, # max number of media resources sent at once + "media_send_interval": 1, # interval between sending images, in seconds + # Zhipu AI platform config "zhipu_ai_api_key": "", "zhipu_ai_api_base": "https://open.bigmodel.cn/api/paas/v4", "moonshot_api_key": "", "moonshot_base_url": "https://api.moonshot.cn/v1", - # 豆包(火山方舟) 平台配置 + # Doubao (Volcano Ark) platform config "ark_api_key": "", "ark_base_url": "https://ark.cn-beijing.volces.com/api/v3", - # 魔搭社区 平台配置 + # ModelScope community platform config "modelscope_api_key": "", "modelscope_base_url": "https://api-inference.modelscope.cn/v1/chat/completions", - # LinkAI平台配置 + # LinkAI platform config "use_linkai": False, "linkai_api_key": "", "linkai_app_code": "", @@ -225,7 +229,7 @@ available_setting = { "Minimax_base_url": "", "deepseek_api_key": "", "deepseek_api_base": "https://api.deepseek.com/v1", - # 小米 MiMo 大模型 + # Xiaomi MiMo LLM "mimo_api_key": "", "mimo_api_base": "https://api.xiaomimimo.com/v1", "web_host": "", # Web console bind address; empty means auto @@ -233,14 +237,14 @@ available_setting = { "web_password": "", # Web console password; empty means no authentication required "web_session_expire_days": 30, # Auth session expiry in days "web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem - "agent": True, # 是否开启Agent模式 - "agent_workspace": "~/cow", # agent工作空间路径,用于存储skills、memory等 - "agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens - "agent_max_context_turns": 20, # Agent模式下最大上下文记忆轮次 - "agent_max_steps": 20, # Agent模式下单次运行最大决策步数 + "agent": True, # whether to enable Agent mode + "agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc. + "agent_max_context_tokens": 50000, # max context tokens in Agent mode + "agent_max_context_turns": 20, # max context memory turns in Agent mode + "agent_max_steps": 20, # max decision steps per run in Agent mode "enable_thinking": False, # Enable deep-thinking mode for thinking-capable models "reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max" - "knowledge": True, # 是否开启知识库功能 + "knowledge": True, # whether to enable the knowledge base feature "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL__ env vars at startup "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) } @@ -253,7 +257,7 @@ class Config(dict): d = {} for k, v in d.items(): self[k] = v - # user_datas: 用户数据,key为用户名,value为用户数据,也是dict + # user_datas: per-user data; key is the username, value is the user's data (also a dict) self.user_datas = {} def __getitem__(self, key): @@ -263,11 +267,11 @@ class Config(dict): return super().__setitem__(key, value) def get(self, key, default=None): - # 跳过以下划线开头的注释字段 + # skip comment fields starting with an underscore if key.startswith("_"): return super().get(key, default) - # 如果key不在available_setting中,直接走dict的get,返回config.json中实际加载的值(如不存在则返回default) + # if the key is not in available_setting, fall back to dict.get and return the value actually loaded from config.json (or default if absent) if key not in available_setting: return super().get(key, default) @@ -334,7 +338,7 @@ def drag_sensitive(config): def load_config(): global config - # 打印 ASCII Logo + # print ASCII logo logger.info(" ____ _ _ ") logger.info(" / ___|_____ __ / \\ __ _ ___ _ __ | |_ ") logger.info("| | / _ \\ \\ /\\ / // _ \\ / _` |/ _ \\ '_ \\| __|") @@ -344,13 +348,13 @@ def load_config(): logger.info("") config_path = "./config.json" if not os.path.exists(config_path): - logger.info("配置文件不存在,将使用config-template.json模板") + logger.info("config file not found, falling back to config-template.json") config_path = "./config-template.json" config_str = read_file(config_path) logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str))) - # 将json字符串反序列化为dict类型。 + # Deserialize the json string into a dict. # `object_pairs_hook` lets us catch users who accidentally typed the # same key twice (e.g. two `"tools"` blocks) — json.loads would # otherwise silently drop all but the last occurrence. @@ -367,7 +371,7 @@ def load_config(): # Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config. for name, value in os.environ.items(): name = name.lower() - # 跳过以下划线开头的注释字段 + # skip comment fields starting with an underscore if name.startswith("_"): continue if name in available_setting: @@ -388,19 +392,19 @@ def load_config(): logger.info("[INIT] load config: {}".format(drag_sensitive(config))) - # 打印系统初始化信息 + # print system initialization info logger.info("[INIT] ========================================") logger.info("[INIT] System Initialization") logger.info("[INIT] ========================================") logger.info("[INIT] Channel: {}".format(config.get("channel_type", "unknown"))) logger.info("[INIT] Model: {}".format(config.get("model", "unknown"))) - # Agent模式信息 + # Agent mode info if config.get("agent", True): workspace = config.get("agent_workspace", "~/cow") logger.info("[INIT] Mode: Agent (workspace: {})".format(workspace)) else: - logger.info("[INIT] Mode: Chat (在config.json中设置 \"agent\":true 可启用Agent模式)") + logger.info("[INIT] Mode: Chat (set \"agent\":true in config.json to enable Agent mode)") logger.info("[INIT] Debug: {}".format(config.get("debug", False))) logger.info("[INIT] ========================================") @@ -609,8 +613,8 @@ plugin_config = {} def write_plugin_config(pconf: dict): """ - 写入插件全局配置 - :param pconf: 全量插件配置 + Write the global plugin config. + :param pconf: the full plugin config """ global plugin_config for k in pconf: @@ -618,8 +622,8 @@ def write_plugin_config(pconf: dict): def remove_plugin_config(name: str): """ - 移除待重新加载的插件全局配置 - :param name: 待重载的插件名 + Remove the global config of a plugin pending reload. + :param name: name of the plugin to reload """ global plugin_config plugin_config.pop(name.lower(), None) @@ -627,12 +631,12 @@ def remove_plugin_config(name: str): def pconf(plugin_name: str) -> dict: """ - 根据插件名称获取配置 - :param plugin_name: 插件名称 - :return: 该插件的配置项 + Get the config for a plugin by name. + :param plugin_name: plugin name + :return: the plugin's config """ return plugin_config.get(plugin_name.lower()) -# 全局配置,用于存放全局生效的状态 +# global config holding globally-effective state global_config = {"admin_users": []} diff --git a/docs/channels/discord.mdx b/docs/channels/discord.mdx new file mode 100644 index 00000000..0563ab4c --- /dev/null +++ b/docs/channels/discord.mdx @@ -0,0 +1,93 @@ +--- +title: Discord +description: 将 CowAgent 接入 Discord Bot +--- + +> 通过 Discord Bot 的 **Gateway 长连接** 接入 CowAgent,支持私聊(DM)与服务器频道(@机器人 / 回复机器人触发)。Gateway 基于 WebSocket 长连接,无需公网 IP 与回调地址,开箱即用。 + +## 一、接入步骤 + +### 步骤一:创建 Discord 应用与 Bot + +1. 打开 [Discord 开发者后台](https://discord.com/developers/applications),点击 **New Application**,填写名称(如 `CowAgent`)并创建。 +2. 左侧菜单进入 **Bot** 页面,点击 **Reset Token** 生成 Bot Token,复制并妥善保存(仅显示一次)。 + + + 这个 Token 等同于 Bot 的密码,请勿泄露。若意外泄漏,在 Bot 页面再次点击 **Reset Token** 重置即可。 + + +### 步骤二:开启 Message Content Intent + +私聊与频道读取文本均依赖该权限。 + +1. 在 **Bot** 页面下方找到 **Privileged Gateway Intents**。 +2. 打开 **Message Content Intent** 开关并保存。 + + + 未开启该开关时,机器人收到的消息内容会为空,导致无响应。 + + +### 步骤三:邀请 Bot 进入服务器 + +1. 左侧菜单进入 **OAuth2 → URL Generator**。 +2. **Scopes** 勾选 `bot`。 +3. **Bot Permissions** 至少勾选:`Send Messages`、`Read Message History`、`Attach Files`、`View Channels`。 +4. 复制底部生成的授权链接,在浏览器打开,选择目标服务器完成授权。 + + + 仅需私聊(DM)可跳过此步,但仍需先在任意共同服务器中与机器人建立 DM 通道,或由用户主动私聊机器人。 + + +### 步骤四:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Discord**,填入 Bot Token,点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "discord", + "discord_token": "your-discord-bot-token", + "discord_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `discord_token` | 开发者后台 Bot 页面生成的 Bot Token | - | + | `discord_group_trigger` | 频道触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Discord] Bot logged in as CowAgent#1234 (id=123456789) +[Discord] ✅ Discord bot ready, listening for messages +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 私聊(DM) | ✅ | +| 服务器频道(@机器人 / 回复机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | + + + Discord 单条消息上限为 2000 字符,超长回复会自动按换行拆分为多条发送。 + + +## 三、使用 + +完成接入后: + +- **私聊(DM)**:在服务器成员列表中找到你的机器人,点击头像直接发消息对话。 +- **频道**:在已邀请机器人的频道中,使用 `@你的机器人 你好` 或 **回复机器人的某条消息** 触发对话。 + +发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 97ba16ab..b9dc5898 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -21,6 +21,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 | [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](/channels/discord) | ✅ | ✅ | ✅ | | ✅ | - **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 - **群聊**列指可识别并响应群消息 @@ -41,3 +42,4 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 - [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号) - [Telegram](/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP - [Slack](/channels/slack) — 团队协作 IM,Socket Mode 接入,无需公网 IP +- [Discord](/channels/discord) — 社区 IM,Gateway 长连接接入,无需公网 IP diff --git a/docs/docs.json b/docs/docs.json index fe2656ae..1bc55cac 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -199,7 +199,8 @@ "channels/wechat-kf", "channels/wechatmp", "channels/telegram", - "channels/slack" + "channels/slack", + "channels/discord" ] } ] @@ -398,6 +399,7 @@ "en/channels/web", "en/channels/telegram", "en/channels/slack", + "en/channels/discord", "en/channels/weixin", "en/channels/feishu", "en/channels/dingtalk", @@ -611,7 +613,8 @@ "ja/channels/wechat-kf", "ja/channels/wechatmp", "ja/channels/telegram", - "ja/channels/slack" + "ja/channels/slack", + "ja/channels/discord" ] } ] diff --git a/docs/en/channels/discord.mdx b/docs/en/channels/discord.mdx new file mode 100644 index 00000000..e18c0685 --- /dev/null +++ b/docs/en/channels/discord.mdx @@ -0,0 +1,93 @@ +--- +title: Discord +description: Integrate CowAgent with a Discord Bot +--- + +> Integrate CowAgent into Discord via a Discord Bot using the **Gateway** (persistent WebSocket). Supports direct messages (DM) and server channels (triggered by @mention or replying to the bot). The Gateway uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. + +## 1. Setup + +### Step 1: Create a Discord Application and Bot + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications), click **New Application**, enter a name (e.g. `CowAgent`), and create it. +2. Go to the **Bot** page in the left sidebar, click **Reset Token** to generate a Bot Token, then copy and store it safely (shown only once). + + + This token is your bot's password — keep it secret. If it leaks, click **Reset Token** again on the Bot page to regenerate it. + + +### Step 2: Enable the Message Content Intent + +Reading message text in both DMs and channels depends on this privileged intent. + +1. On the **Bot** page, find **Privileged Gateway Intents**. +2. Turn on **Message Content Intent** and save. + + + Without this intent enabled, incoming message content will be empty and the bot will not respond. + + +### Step 3: Invite the Bot to a Server + +1. Go to **OAuth2 → URL Generator** in the left sidebar. +2. Under **Scopes**, check `bot`. +3. Under **Bot Permissions**, check at least: `Send Messages`, `Read Message History`, `Attach Files`, `View Channels`. +4. Copy the generated authorization URL at the bottom, open it in a browser, and authorize it for your target server. + + + You can skip this step if you only need DMs, but you still need a DM channel with the bot (e.g. the user messages the bot directly). + + +### Step 4: Connect to CowAgent + + + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Discord**, paste the Bot Token, and click connect. + + + Add the following to `config.json` and start Cow: + + ```json + { + "channel_type": "discord", + "discord_token": "your-discord-bot-token", + "discord_group_trigger": "mention_or_reply" + } + ``` + + | Key | Description | Default | + | --- | --- | --- | + | `discord_token` | Bot Token generated on the Bot page of the Developer Portal | - | + | `discord_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply to bot) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | + + + +The integration is ready when you see logs like: + +``` +[Discord] Bot logged in as CowAgent#1234 (id=123456789) +[Discord] ✅ Discord bot ready, listening for messages +``` + +## 2. Capabilities + +| Feature | Support | +| --- | --- | +| Direct message (DM) | ✅ | +| Server channel (@bot / reply to bot) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | + + + A single Discord message is capped at 2000 characters; long replies are automatically split across multiple messages by line breaks. + + +## 3. Usage + +Once connected: + +- **Direct message (DM)**: find your bot in the server member list, click its avatar, and message it directly. +- **Channel**: in a channel where the bot is invited, trigger it with `@your-bot hello` or by **replying to one of the bot's messages**. + +When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/en/channels/index.mdx b/docs/en/channels/index.mdx index 8b6a25e9..bd40d7d5 100644 --- a/docs/en/channels/index.mdx +++ b/docs/en/channels/index.mdx @@ -21,6 +21,7 @@ The table below summarizes the inbound message types, bot reply types, and group | [Official Account](/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](/en/channels/discord) | ✅ | ✅ | ✅ | | ✅ | - The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details - The **Group Chat** column indicates the ability to recognize and respond to group messages @@ -41,3 +42,4 @@ The table below summarizes the inbound message types, bot reply types, and group - [Official Account](/en/channels/wechatmp) — WeChat Official Account (subscription / service) - [Telegram](/en/channels/telegram) — global IM, 5-minute setup, no public IP needed - [Slack](/en/channels/slack) — team collaboration IM, Socket Mode integration, no public IP needed +- [Discord](/en/channels/discord) — community IM, Gateway connection, no public IP needed diff --git a/docs/ja/README.md b/docs/ja/README.md index 571f667e..2c8bbb48 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -129,6 +129,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [WeChat 公式アカウント](https://docs.cowagent.ai/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](https://docs.cowagent.ai/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/ja/channels/discord) | ✅ | ✅ | ✅ | | ✅ | > Feishu と WeCom Bot は **Web コンソール内で QR コードをスキャンするだけで接続**できます — パブリック IP は不要です。詳細は [チャネル概要](https://docs.cowagent.ai/ja/channels/index) を参照してください。 diff --git a/docs/ja/channels/discord.mdx b/docs/ja/channels/discord.mdx new file mode 100644 index 00000000..06c80d6d --- /dev/null +++ b/docs/ja/channels/discord.mdx @@ -0,0 +1,93 @@ +--- +title: Discord +description: Discord Bot 経由で CowAgent を接続 +--- + +> Discord Bot の **Gateway 常時接続** を通じて CowAgent を接続します。ダイレクトメッセージ(DM)およびサーバーチャンネル(@メンションまたはボットへの返信で起動)に対応。Gateway は WebSocket の常時接続を使うため公開 IP やコールバック URL は不要で、すぐに利用できます。 + +## 1. 接続手順 + +### ステップ 1: Discord アプリと Bot を作成 + +1. [Discord 開発者ポータル](https://discord.com/developers/applications) を開き、**New Application** をクリックして名前(例: `CowAgent`)を入力し作成します。 +2. 左メニューの **Bot** ページで **Reset Token** をクリックして Bot Token を生成し、コピーして安全に保管します(一度だけ表示されます)。 + + + この Token は Bot のパスワードに相当します。漏洩しないようにしてください。万一漏洩した場合は Bot ページで再度 **Reset Token** をクリックして再生成できます。 + + +### ステップ 2: Message Content Intent を有効化 + +DM・チャンネルいずれもテキスト読み取りにこの権限が必要です。 + +1. **Bot** ページの **Privileged Gateway Intents** を探します。 +2. **Message Content Intent** をオンにして保存します。 + + + この権限を有効にしないと、受信メッセージの本文が空になり、ボットが応答しません。 + + +### ステップ 3: Bot をサーバーに招待 + +1. 左メニューの **OAuth2 → URL Generator** を開きます。 +2. **Scopes** で `bot` をチェックします。 +3. **Bot Permissions** で最低限以下をチェックします: `Send Messages`、`Read Message History`、`Attach Files`、`View Channels`。 +4. 下部に生成された認証 URL をコピーしてブラウザで開き、対象のサーバーを選択して認証を完了します。 + + + DM のみを利用する場合はこのステップを省略できますが、ボットとの DM チャンネルが必要です(ユーザーがボットに直接メッセージを送るなど)。 + + +### ステップ 4: CowAgent に接続 + + + + Web コンソール(既定 `http://127.0.0.1:9899`)を開き、**チャネル** メニュー → **チャネルを追加** → **Discord** を選択し、Bot Token を貼り付けて接続をクリックします。 + + + `config.json` に以下を追加して Cow を起動します: + + ```json + { + "channel_type": "discord", + "discord_token": "your-discord-bot-token", + "discord_group_trigger": "mention_or_reply" + } + ``` + + | パラメータ | 説明 | 既定値 | + | --- | --- | --- | + | `discord_token` | 開発者ポータルの Bot ページで生成した Bot Token | - | + | `discord_group_trigger` | チャンネルのトリガー方式: `mention_or_reply`(@ またはボットへの返信)/ `mention_only`(@ のみ)/ `all`(全メッセージ) | `mention_or_reply` | + + + +ログに以下のような出力が表示されれば接続成功です: + +``` +[Discord] Bot logged in as CowAgent#1234 (id=123456789) +[Discord] ✅ Discord bot ready, listening for messages +``` + +## 2. 機能 + +| 機能 | 対応状況 | +| --- | --- | +| ダイレクトメッセージ(DM) | ✅ | +| サーバーチャンネル(@bot / ボットへの返信) | ✅ | +| テキストメッセージ | ✅ 送受信 | +| 画像メッセージ | ✅ 送受信 | +| ファイルメッセージ | ✅ 送受信(PDF / Word / Excel など) | + + + Discord の 1 メッセージは最大 2000 文字です。長い返信は改行単位で自動的に複数メッセージに分割して送信されます。 + + +## 3. 使い方 + +接続が完了したら: + +- **ダイレクトメッセージ(DM)**: サーバーのメンバー一覧からボットを見つけ、アイコンをクリックして直接メッセージを送ります。 +- **チャンネル**: ボットを招待したチャンネルで、`@your-bot こんにちは` または **ボットのメッセージへの返信** で起動します。 + +画像やファイルを送るときは、添付の入力欄に **テキスト説明**(説明・質問)を書いて一緒に送信できます。Bot は添付ファイルと説明を合わせて回答します。先に添付を送り、その後に質問を送る形でも、2 つのメッセージは自動でまとめて処理されます。 diff --git a/docs/ja/channels/index.mdx b/docs/ja/channels/index.mdx index 05a540d2..fe300645 100644 --- a/docs/ja/channels/index.mdx +++ b/docs/ja/channels/index.mdx @@ -11,6 +11,9 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | チャネル | テキスト | 画像 | ファイル | 音声 | グループチャット | | --- | :-: | :-: | :-: | :-: | :-: | +| [Telegram](/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](/ja/channels/discord) | ✅ | ✅ | ✅ | | ✅ | | [WeChat](/ja/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | | [Web コンソール](/ja/channels/web) | ✅ | ✅ | ✅ | ✅ | | | [Feishu](/ja/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -19,8 +22,6 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | [QQ](/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | -| [Telegram](/ja/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Slack](/ja/channels/slack) | ✅ | ✅ | ✅ | | ✅ | - **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください - **グループチャット**列はグループメッセージを認識して応答できることを示します @@ -41,3 +42,4 @@ CowAgent は複数のチャットチャネルへの接続に対応しており - [WeChat 公式アカウント](/ja/channels/wechatmp) — WeChat 公式アカウント(購読アカウント / サービスアカウント) - [Telegram](/ja/channels/telegram) — グローバル IM、5 分で接続、公開 IP 不要 - [Slack](/ja/channels/slack) — チームコラボレーション IM、Socket Mode 接続、公開 IP 不要 +- [Discord](/ja/channels/discord) — コミュニティ IM、Gateway 接続、公開 IP 不要 diff --git a/docs/zh/README.md b/docs/zh/README.md index acfe7c8e..fafc2f1e 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -129,6 +129,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/channels/discord) | ✅ | ✅ | ✅ | | ✅ | > 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/channels)。 diff --git a/requirements.txt b/requirements.txt index 7f0ccc71..24ea9da8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,3 +31,5 @@ pycryptodome python-telegram-bot # slack bot slack_bolt +# discord bot +discord.py \ No newline at end of file From 29c4be6a3afa4586d5138913886c43bfa9496ee0 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 30 May 2026 19:10:56 +0800 Subject: [PATCH 293/399] feat(terminal): add agent streaming UX with reasoning/tool-call rendering --- channel/terminal/terminal_channel.py | 281 ++++++++++++++++++++++++++- channel/web/web_channel.py | 37 ++-- cli/commands/process.py | 25 ++- 3 files changed, 315 insertions(+), 28 deletions(-) diff --git a/channel/terminal/terminal_channel.py b/channel/terminal/terminal_channel.py index 9b64eb47..f439888b 100644 --- a/channel/terminal/terminal_channel.py +++ b/channel/terminal/terminal_channel.py @@ -1,4 +1,7 @@ +import json +import os import sys +import time from bridge.context import * from bridge.reply import Reply, ReplyType @@ -8,6 +11,164 @@ from common.log import logger from config import conf +class _Style: + """ANSI escape codes for terminal styling. Disabled when not a tty.""" + + enabled = sys.stdout.isatty() + + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + ITALIC = "\033[3m" + + GRAY = "\033[90m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + + @classmethod + def wrap(cls, text, *codes): + if not cls.enabled or not codes: + return text + return "".join(codes) + text + cls.RESET + + +class TerminalAgentRenderer: + """Render agent stream events to the terminal in real time. + + Reuses the same `on_event` mechanism as the web channel so the terminal + can show reasoning, tool calls and streaming answer text just like the web UI. + """ + + def __init__(self): + self._reasoning_active = False + self._answer_active = False + self._has_output = False + # Track tool execution start time as a fallback when the event omits it + self._tool_started_at = {} + + def _print(self, text, end="", flush=True): + sys.stdout.write(text) + if end: + sys.stdout.write(end) + if flush: + sys.stdout.flush() + self._has_output = True + + def _close_section(self): + """Finish the currently open streaming section (reasoning or answer).""" + if self._reasoning_active: + self._print("", end="\n") + self._reasoning_active = False + if self._answer_active: + self._print("", end="\n") + self._answer_active = False + + def _format_arguments(self, arguments): + try: + if isinstance(arguments, (dict, list)): + text = json.dumps(arguments, ensure_ascii=False) + else: + text = str(arguments) + except Exception: + text = str(arguments) + # Keep tool input compact in the terminal + if len(text) > 300: + text = text[:300] + "…" + return text + + def handle_event(self, event: dict): + try: + self._handle_event(event) + except Exception as e: + logger.debug(f"[Terminal] render event error: {e}") + + def _handle_event(self, event: dict): + event_type = event.get("type") + data = event.get("data", {}) or {} + + if event_type == "agent_start": + self._print("\n" + _Style.wrap("Agent: ", _Style.BOLD, _Style.GREEN), end="\n") + + elif event_type == "reasoning_update": + delta = data.get("delta", "") + if not delta: + return + if self._answer_active: + self._close_section() + if not self._reasoning_active: + self._print(_Style.wrap("💭 思考 ", _Style.DIM, _Style.MAGENTA), end="\n") + self._reasoning_active = True + self._print(_Style.wrap(delta, _Style.DIM, _Style.ITALIC)) + + elif event_type == "message_update": + delta = data.get("delta", "") + if not delta: + return + if self._reasoning_active: + self._close_section() + self._answer_active = True + self._print(delta) + + elif event_type == "tool_execution_start": + self._close_section() + tool_name = data.get("tool_name", "tool") + tool_id = data.get("tool_call_id") + arguments = data.get("arguments", {}) + self._tool_started_at[tool_id] = time.time() + header = _Style.wrap(f"🔧 {tool_name}", _Style.BOLD, _Style.CYAN) + args_str = self._format_arguments(arguments) + self._print(f"{header} {_Style.wrap(args_str, _Style.GRAY)}", end="\n") + + elif event_type == "tool_execution_end": + tool_name = data.get("tool_name", "tool") + tool_id = data.get("tool_call_id") + status = data.get("status", "success") + result = data.get("result", "") + exec_time = data.get("execution_time") + if exec_time is None and tool_id in self._tool_started_at: + exec_time = time.time() - self._tool_started_at.pop(tool_id, time.time()) + success = status == "success" + icon = "✓" if success else "✗" + color = _Style.GREEN if success else _Style.RED + result_str = str(result) + if len(result_str) > 500: + result_str = result_str[:500] + "…" + # Indent multi-line tool output for readability + result_str = result_str.replace("\n", "\n ") + cost = f" ({exec_time:.2f}s)" if isinstance(exec_time, (int, float)) else "" + self._print( + _Style.wrap(f" {icon} {tool_name}{cost}", color) + " " + _Style.wrap(result_str, _Style.GRAY), + end="\n", + ) + + elif event_type == "file_to_send": + self._close_section() + file_path = data.get("path", "") + file_name = data.get("file_name", "") + label = file_name or file_path + self._print(_Style.wrap(f"📎 文件: {label}", _Style.BLUE), end="\n") + + elif event_type == "error": + self._close_section() + err_msg = data.get("error") or "unknown error" + self._print(_Style.wrap(f"❌ {err_msg}", _Style.BOLD, _Style.RED), end="\n") + + elif event_type == "agent_cancelled": + self._close_section() + self._print(_Style.wrap("⏹ 已中止", _Style.YELLOW), end="\n") + + elif event_type == "agent_end": + self._close_section() + + def finish(self): + """Ensure any open section is closed at the end of a turn.""" + self._close_section() + + class TerminalMessage(ChatMessage): def __init__( self, @@ -29,17 +190,33 @@ class TerminalMessage(ChatMessage): class TerminalChannel(ChatChannel): NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE] + def __init__(self): + super().__init__() + # Per-request renderers keyed by request_id; used to detect whether + # agent text was already streamed so send() can avoid duplicate output. + self._renderers = {} + # Callback that restores TTY attributes on exit (set in startup). + self._restore_terminal = None + def send(self, reply: Reply, context: Context): - print("\nBot:") + request_id = context.get("request_id") if context else None + renderer = self._renderers.pop(request_id, None) if request_id else None + streamed = renderer is not None and renderer._has_output + + if renderer is not None: + renderer.finish() + if reply.type == ReplyType.IMAGE: from PIL import Image image_storage = reply.content image_storage.seek(0) img = Image.open(image_storage) + if not streamed: + print("\nAgent: ") print("") img.show() - elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片 + elif reply.type == ReplyType.IMAGE_URL: # download image from url import io import requests @@ -52,38 +229,122 @@ class TerminalChannel(ChatChannel): image_storage.write(block) image_storage.seek(0) img = Image.open(image_storage) + if not streamed: + print("\nAgent: ") print(img_url) img.show() else: - print(reply.content) - print("\nUser:", end="") + # When agent already streamed the answer, skip re-printing the + # final text to avoid duplication; just emit a trailing newline. + if streamed: + print() + else: + print("\nAgent: ") + print(reply.content) + print("\nUser: ", end="") sys.stdout.flush() return + def _silence_console_logging(self): + """Mute console log output so background-thread logs (web/MCP/scheduler) + don't flood the interactive terminal. Logs still go to run.log in full. + + Configurable via `terminal_log_level` (default ERROR). The file handler + is untouched, so run.log keeps the complete log. + """ + import logging + + level_name = str(conf().get("terminal_log_level", "ERROR")).upper() + level = getattr(logging, level_name, logging.ERROR) + root_logger = logging.getLogger("log") + for handler in root_logger.handlers: + # Only raise the level of the stdout/stderr stream handler; + # keep FileHandler at the logger's level so run.log stays complete. + if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): + handler.setLevel(level) + + def _install_terminal_guard(self): + """Save TTY attributes and register restore hooks so the terminal is + never left in a broken state (no echo / raw mode / leftover ANSI) after + the process exits, especially when Ctrl+C interrupts a blocking input(). + """ + if not sys.stdin.isatty(): + return + try: + import atexit + import termios + + saved_attrs = termios.tcgetattr(sys.stdin.fileno()) + + def _restore(): + try: + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, saved_attrs) + except Exception: + pass + try: + if _Style.enabled: + sys.stdout.write(_Style.RESET) + sys.stdout.flush() + except Exception: + pass + + self._restore_terminal = _restore + atexit.register(_restore) + except Exception as e: + # termios is unavailable on Windows; skip the guard there. + logger.debug(f"[Terminal] terminal guard not installed: {e}") + self._restore_terminal = None + def startup(self): context = Context() - logger.setLevel("WARN") - print("\nPlease input your question:\nUser:", end="") + self._silence_console_logging() + self._install_terminal_guard() + print("\nPlease input your question:\nUser: ", end="") sys.stdout.flush() msg_id = 0 while True: try: prompt = self.get_input() - except KeyboardInterrupt: - print("\nExiting...") - sys.exit() + except (KeyboardInterrupt, EOFError): + self._shutdown() msg_id += 1 trigger_prefixs = conf().get("single_chat_prefix", [""]) if check_prefix(prompt, trigger_prefixs) is None: - prompt = trigger_prefixs[0] + prompt # 给没触发的消息加上触发前缀 + prompt = trigger_prefixs[0] + prompt # add trigger prefix to untriggered messages context = self._compose_context(ContextType.TEXT, prompt, msg=TerminalMessage(msg_id, prompt)) context["isgroup"] = False if context: + # Attach an agent event renderer so reasoning / tool calls / + # streaming answer show up live in the terminal (web-like UX). + request_id = str(msg_id) + context["request_id"] = request_id + renderer = TerminalAgentRenderer() + self._renderers[request_id] = renderer + context["on_event"] = renderer.handle_event self.produce(context) else: raise Exception("context is None") + def _shutdown(self): + """Restore terminal state and terminate the whole process. + + startup() runs in a daemon sub-thread, so sys.exit() would only kill + this thread and leave the main process (and web/MCP/scheduler threads) + alive, holding the terminal in a half-occupied state -> laggy input. + We reset any leftover ANSI styling and hard-exit the process instead. + """ + # Restore TTY attributes and reset any leftover ANSI styling + # (e.g. interrupted mid-stream output) before terminating. + if self._restore_terminal: + self._restore_terminal() + elif _Style.enabled: + sys.stdout.write(_Style.RESET) + sys.stdout.write("\nExiting...\n") + sys.stdout.flush() + # Hard-exit the entire process from a daemon thread. + os._exit(0) + def get_input(self): """ Multi-line input function diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 6cf6bc0b..120d8efd 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1018,27 +1018,30 @@ class WebChannel(ChatChannel): self._cleanup_stale_voice_recordings() - # 打印可用渠道类型提示 + # Print available channel types logger.info( - "[WebChannel] 全部可用通道如下,可修改 config.json 配置文件中的 channel_type 字段进行切换,多个通道用逗号分隔:") - 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. wechat_kf - 微信客服") - logger.info("[WebChannel] 9. wechatmp - 个人公众号") - logger.info("[WebChannel] 10. wechatmp_service - 企业公众号") - logger.info("[WebChannel] ✅ Web控制台已运行") - logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}") + "[WebChannel] Available channels (edit `channel_type` in config.json to switch, separate multiple with commas):") + logger.info("[WebChannel] 1. web - Web") + logger.info("[WebChannel] 2. terminal - Terminal") + logger.info("[WebChannel] 3. weixin - WeChat") + logger.info("[WebChannel] 4. feishu - Feishu") + logger.info("[WebChannel] 5. dingtalk - DingTalk") + logger.info("[WebChannel] 6. wecom_bot - WeCom Bot") + logger.info("[WebChannel] 7. wechatcom_app - WeCom App") + logger.info("[WebChannel] 8. wechat_kf - WeChat Customer Service") + logger.info("[WebChannel] 9. wechatmp - WeChat Official Account") + logger.info("[WebChannel] 10. wechatmp_service - WeChat Official Account (Service)") + logger.info("[WebChannel] 11. telegram - Telegram") + logger.info("[WebChannel] 12. slack - Slack") + logger.info("[WebChannel] 13. discord - Discord") + logger.info("[WebChannel] ✅ Web console is running") + logger.info(f"[WebChannel] 🌐 Local access: http://localhost:{port}") if is_public_bind: - logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (将YOUR_IP替换为服务器IP)") + logger.info(f"[WebChannel] 🌍 Server access: http://YOUR_IP:{port} (replace YOUR_IP with your server IP)") if not _is_password_enabled(): - logger.info("[WebChannel] ⚠️ 当前监听 0.0.0.0 且未设置 web_password,公网部署建议在 config.json 中配置访问密码") + logger.info("[WebChannel] ⚠️ Listening on 0.0.0.0 without web_password set; set an access password in config.json for public deployment") else: - logger.info(f"[WebChannel] 🔒 当前仅监听 {host},仅本机可访问。如需公网访问,请将 web_host 改为 0.0.0.0 并配置 web_password 密码") + logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password") try: import webbrowser diff --git a/cli/commands/process.py b/cli/commands/process.py index 0996ffca..2176fbf7 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -8,11 +8,28 @@ from typing import Optional import click -from cli.utils import get_project_root +from cli.utils import get_project_root, load_config_json _IS_WIN = sys.platform == "win32" +def _is_terminal_only() -> bool: + """Whether terminal is the only configured channel. + + Terminal needs an interactive stdin/tty, which is incompatible with the + background daemon mode (stdout/stdin detached). When terminal is the only + channel, `start` must run in the foreground so it can own the tty. + """ + channel = load_config_json().get("channel_type", "") + if isinstance(channel, str): + names = [c.strip() for c in channel.split(",") if c.strip()] + elif isinstance(channel, (list, tuple)): + names = [str(c).strip() for c in channel if str(c).strip()] + else: + names = [] + return names == ["terminal"] + + def _get_pid_file(): return os.path.join(get_project_root(), ".cow.pid") @@ -103,6 +120,12 @@ def start(foreground, no_logs): python = sys.executable + # Terminal-only setups need an interactive tty; force foreground so the + # terminal channel can read stdin instead of fighting the shell over the tty. + if not foreground and _is_terminal_only(): + foreground = True + click.echo("Detected terminal-only channel, starting in foreground...") + if foreground: click.echo("Starting CowAgent in foreground...") if _IS_WIN: From 0994a3586dfbe8ca0f4a7f16234747114ee26118 Mon Sep 17 00:00:00 2001 From: lyteen Date: Sat, 30 May 2026 23:12:24 +0800 Subject: [PATCH 294/399] [feat] Fuzzy /command Resolution & Custom Aliases --- plugins/cow_cli/cow_cli.py | 178 +++++++++++++++++++++++++++++++------ 1 file changed, 149 insertions(+), 29 deletions(-) diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index b7dc6371..f3fee96a 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -23,6 +23,7 @@ from plugins import Plugin, Event, EventContext, EventAction from bridge.context import ContextType from bridge.reply import Reply, ReplyType from common.log import logger +from config import conf from cli import __version__ @@ -44,6 +45,23 @@ 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 +# Convenience shorthands for the slash form only. Values are *full command +# strings* so an alias can carry arguments (e.g. "cc" -> "context clear"). +# These shorthands are deliberate and NOT derivable from prefix/typo rules: +# - "c" is a prefix of cancel/config/context (ambiguous) -> needs explicit map +# - "cc" is a prefix of nothing and expands to a command + argument +# - "s" would otherwise be an ambiguous prefix (skill/start/status/stop) +# Users may override / extend these via config.json "command_aliases". +DEFAULT_ALIASES = { + "c": "cancel", + "cc": "context clear", + "ctx": "context", + "h": "help", + "s": "status", + "cfg": "config", + "k": "knowledge", +} + @plugins.register( name="cow_cli", @@ -57,8 +75,51 @@ class CowCliPlugin(Plugin): def __init__(self): super().__init__() self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context + self.aliases = self._build_aliases() logger.debug("[CowCli] initialized") + def reload(self): + """Rebuild the alias table (e.g. after config changes).""" + self.aliases = self._build_aliases() + + @staticmethod + def _build_aliases() -> dict: + """Merge DEFAULT_ALIASES with optional config.json ``command_aliases``. + + User-supplied entries (keys lowercased / stripped) override defaults. + An alias whose target's first token is not a known command is dropped + with a warning, so a bad alias can never create a dead command. An + alias key that shadows a real command is kept but warned about — the + exact-command stage in ``_resolve`` runs first, so the command wins. + """ + merged = dict(DEFAULT_ALIASES) + try: + overrides = conf().get("command_aliases", {}) or {} + except Exception as e: + logger.warning(f"[CowCli] could not read command_aliases from config: {e}") + overrides = {} + if not isinstance(overrides, dict): + logger.warning(f"[CowCli] command_aliases must be an object, got {type(overrides).__name__}; ignoring") + overrides = {} + for key, value in overrides.items(): + if not isinstance(key, str) or not isinstance(value, str): + logger.warning(f"[CowCli] ignoring non-string alias entry: {key!r} -> {value!r}") + continue + k = key.strip().lower() + if k: + merged[k] = value.strip() + + valid = {} + for key, value in merged.items(): + head = value.split(None, 1)[0].lower() if value.strip() else "" + if head not in KNOWN_COMMANDS: + logger.warning(f"[CowCli] dropping alias '/{key}' -> '{value}': unknown command '{head}'") + continue + if key in KNOWN_COMMANDS: + logger.warning(f"[CowCli] alias '/{key}' shadows a real command; the command takes precedence") + valid[key] = value + return valid + def on_handle_context(self, e_context: EventContext): if e_context["context"].type != ContextType.TEXT: return @@ -68,28 +129,24 @@ class CowCliPlugin(Plugin): if parsed is None: return - cmd, args = parsed + token, user_args = parsed + result = self._resolve(token, user_args) + kind = result[0] - if cmd not in KNOWN_COMMANDS: - # Slash-prefixed near-miss: looks like a typo of a real command. - # Intercept with a hint so we don't burn an LLM round on "/momory". - suggestion = self._suggest_command(cmd) - if suggestion is None: - return - hint = f"未知命令: /{cmd}" - if suggestion: - hint += f"\n你是不是想输入 /{suggestion} ?" - hint += "\n发送 /help 查看全部命令。" - e_context["reply"] = Reply(ReplyType.TEXT, hint) - e_context.action = EventAction.BREAK_PASS + if kind == "passthrough": + # Not a command and not a near-typo: let the agent handle it. return - logger.info(f"[CowCli] intercepted command: {cmd} {args}") + if kind == "run": + _, cmd, args = result + logger.info(f"[CowCli] intercepted command: {cmd} {args}") + reply_text = self._dispatch(cmd, args, e_context) + elif kind == "ambiguous": + reply_text = self._ambiguous_hint(token, result[1]) + else: # "typo" + reply_text = self._typo_hint(token, result[1]) - result = self._dispatch(cmd, args, e_context) - - reply = Reply(ReplyType.TEXT, result) - e_context["reply"] = reply + e_context["reply"] = Reply(ReplyType.TEXT, reply_text) e_context.action = EventAction.BREAK_PASS def _parse_command(self, content: str): @@ -175,6 +232,69 @@ class CowCliPlugin(Plugin): return known return None + def _resolve(self, token: str, user_args: str): + """Resolve a parsed slash token to an action. + + Precedence (first hit wins): + 1. exact command -> ("run", cmd, args) + 2. alias (exact key) -> expand to a full command string, merge args + 3. unique prefix -> ("run", cmd, args) + 4. ambiguous prefix (>1) -> ("ambiguous", sorted_candidates) + 5. typo (edit-distance <=1) -> ("typo", suggestion_or_None) + 6. no match -> ("passthrough",) + + Pure function of (token, user_args, KNOWN_COMMANDS, self.aliases) so it + is trivially unit-testable. Note the `cow ` form never reaches stages + 2-5: `_parse_command` only returns known tokens for it, so it stays + strict and alias/prefix matching applies to the `/` form only. + """ + token = token.lower() + + # 1. exact command (a real command can never be shadowed by an alias) + if token in KNOWN_COMMANDS: + return ("run", token, user_args) + + # 2. alias -> full command string; merge alias args with user args. + # Alias expansion is applied at most once (no alias -> alias chains). + if token in self.aliases: + parts = self.aliases[token].split(None, 1) + cmd = parts[0].lower() + alias_args = parts[1] if len(parts) > 1 else "" + merged = f"{alias_args} {user_args}".strip() if user_args else alias_args + return ("run", cmd, merged) + + # 3 / 4. prefix match + candidates = sorted(c for c in KNOWN_COMMANDS if c.startswith(token)) + if len(candidates) == 1: + return ("run", candidates[0], user_args) + if len(candidates) > 1: + return ("ambiguous", candidates) + + # 5. typo (keeps its own len>=3 + edit-distance<=1 guards) + suggestion = self._suggest_command(token) + if suggestion is not None: + return ("typo", suggestion) + + # 6. nothing matched + return ("passthrough",) + + @staticmethod + def _typo_hint(token: str, suggestion) -> str: + hint = f"未知命令: /{token}" + if suggestion: + hint += f"\n你是不是想输入 /{suggestion} ?" + hint += "\n发送 /help 查看全部命令。" + return hint + + @staticmethod + def _ambiguous_hint(token: str, candidates) -> str: + options = " ".join(f"/{c}" for c in candidates) + return ( + f"命令不明确: /{token}\n" + f"可能想输入: {options}\n" + "发送 /help 查看全部命令。" + ) + # ------------------------------------------------------------------ # Command dispatch # ------------------------------------------------------------------ @@ -190,17 +310,17 @@ class CowCliPlugin(Plugin): parsed = self._parse_command(query.strip()) if parsed is None: return None - cmd, args = parsed - if cmd not in KNOWN_COMMANDS: - suggestion = self._suggest_command(cmd) - if suggestion is None: - return None - hint = f"未知命令: /{cmd}" - if suggestion: - hint += f"\n你是不是想输入 /{suggestion} ?" - hint += "\n发送 /help 查看全部命令。" - return hint - return self._dispatch(cmd, args, e_context=None, session_id=session_id) + token, user_args = parsed + result = self._resolve(token, user_args) + kind = result[0] + if kind == "passthrough": + return None + if kind == "run": + _, cmd, args = result + return self._dispatch(cmd, args, e_context=None, session_id=session_id) + if kind == "ambiguous": + return self._ambiguous_hint(token, result[1]) + return self._typo_hint(token, result[1]) # "typo" def _dispatch(self, cmd: str, args: str, e_context: EventContext, session_id: str = "") -> str: if cmd in CLI_ONLY_COMMANDS: From fcf4eb78dc51051e8024bd75dac89865fbe3b0aa Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 16:49:35 +0800 Subject: [PATCH 295/399] feat(i18n): add global language resolution and localize user-facing text --- agent/protocol/agent_stream.py | 46 +-- channel/chat_channel.py | 7 +- channel/web/chat.html | 23 +- channel/web/static/js/console.js | 108 +++++-- channel/web/web_channel.py | 25 +- cli/commands/install.py | 77 +++-- cli/commands/process.py | 14 +- cli/commands/skill.py | 12 +- cli/utils.py | 16 + common/i18n.py | 177 +++++++++++ config-template.json | 1 + config.py | 11 + docker/docker-compose.yml | 1 + models/chatgpt/chat_gpt_bot.py | 33 ++- plugins/cow_cli/cow_cli.py | 486 +++++++++++++++++++------------ 15 files changed, 748 insertions(+), 289 deletions(-) create mode 100644 common/i18n.py diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index e3be20b8..0eb63b75 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -12,6 +12,7 @@ from agent.protocol.models import LLMRequest, LLMModel from agent.protocol.message_utils import sanitize_claude_messages, compress_turn_to_text_only from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger +from common.i18n import t as _t # Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content). try: @@ -317,7 +318,10 @@ class AgentStreamExecutor: # Hard stop at 8 failures - abort with critical message if same_tool_failures >= 8: - return True, f"抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", True + return True, _t( + "抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", + "Sorry, I couldn't complete this task. I may have misunderstood, or my current approach isn't quite right.\n\nYou could try:\n• Rephrasing your request\n• Breaking the task into smaller steps\n• Taking a different approach", + ), True # Warning at 6 failures if same_tool_failures >= 6: @@ -436,14 +440,16 @@ class AgentStreamExecutor: elif not assistant_msg: # Still empty (no text and no tool_calls): use fallback logger.warning(f"[Agent] Still empty after explicit request") - final_response = ( - "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", ) logger.info(f"Generated fallback response for empty LLM output") else: - # 第一轮就空回复,直接 fallback - final_response = ( - "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" + # First-turn empty reply, fall back directly + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", ) logger.info(f"Generated fallback response for empty LLM output") else: @@ -514,7 +520,7 @@ class AgentStreamExecutor: # Check for critical error - abort entire conversation if result.get("status") == "critical_error": logger.error(f"💥 检测到严重错误,终止对话") - final_response = result.get('result', '任务执行失败') + final_response = result.get('result') or _t("任务执行失败", "Task execution failed") return final_response # Log tool result in compact format @@ -650,15 +656,15 @@ class AgentStreamExecutor: logger.info(f"💭 Summary: {summary_response[:150]}{'...' if len(summary_response) > 150 else ''}") else: # Fallback if model still doesn't respond - final_response = ( - f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" - "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", ) except Exception as e: logger.warning(f"Failed to get summary from LLM: {e}") - final_response = ( - f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" - "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", ) finally: # Remove the injected user prompt from history to avoid polluting @@ -953,13 +959,15 @@ class AgentStreamExecutor: self.messages.clear() self._clear_session_db() if is_context_overflow: - raise Exception( - "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。" - ) + raise Exception(_t( + "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。", + "Sorry, the conversation history got too long and overflowed the context. I've cleared the history — please describe your request again.", + )) else: - raise Exception( - "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。" - ) + raise Exception(_t( + "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。", + "Sorry, something went wrong with the earlier conversation. I've cleared the history — please send your message again.", + )) # Check if error is rate limit (429) is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 6a9a1952..9104a38e 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -10,6 +10,7 @@ from bridge.reply import * from channel.channel import Channel from common.dequeue import Dequeue from common import memory +from common.i18n import t as _t from plugins import * try: @@ -265,7 +266,7 @@ class ChatChannel(Channel): if reply.type in self.NOT_SUPPORT_REPLYTYPE: logger.error("[chat_channel]reply type not support: " + str(reply.type)) reply.type = ReplyType.ERROR - reply.content = "不支持发送的消息类型: " + str(reply.type) + reply.content = _t("不支持发送的消息类型: ", "Unsupported message type: ") + str(reply.type) if reply.type == ReplyType.TEXT: reply_text = reply.content @@ -476,9 +477,9 @@ class ChatChannel(Channel): cancelled = get_cancel_registry().cancel_session(session_id) text = ( - "🛑 已中止" + _t("🛑 已中止", "🛑 Cancelled") if cancelled > 0 - else "当前没有可中止的任务。" + else _t("当前没有可中止的任务。", "Nothing to cancel.") ) logger.info( f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}" diff --git a/channel/web/chat.html b/channel/web/chat.html index d90adb15..1bbf0f04 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -47,11 +47,30 @@ This runs synchronously in so the correct class is on before any CSS or body rendering occurs. --> diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 6d0a66fc..e5865051 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -91,6 +91,27 @@ const I18N = { example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况', example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能', example_web_title: '指令中心', example_web_text: '查看全部命令', + slash_help: '显示命令帮助', + slash_status: '查看运行状态', + slash_context: '查看对话上下文', + slash_context_clear: '清除对话上下文', + slash_skill_list: '查看已安装技能', + slash_skill_list_remote: '浏览技能广场', + slash_skill_search: '搜索技能', + slash_skill_install: '安装技能 (名称或 GitHub URL)', + slash_skill_uninstall: '卸载技能', + slash_skill_info: '查看技能详情', + slash_skill_enable: '启用技能', + slash_skill_disable: '禁用技能', + slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)', + slash_knowledge: '查看知识库统计', + slash_knowledge_list: '查看知识库文件树', + slash_knowledge_on: '开启知识库', + slash_knowledge_off: '关闭知识库', + slash_config: '查看当前配置', + slash_cancel: '中止当前正在运行的 Agent 任务', + slash_logs: '查看最近日志', + slash_version: '查看版本', input_placeholder: '输入消息,或输入 / 使用指令', config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', @@ -265,6 +286,27 @@ const I18N = { example_knowledge_title: 'Knowledge', example_knowledge_text: 'Show me the current knowledge base', example_skill_title: 'Skills', example_skill_text: 'Show current tools and skills', example_web_title: 'Commands', example_web_text: 'Show all commands', + slash_help: 'Show this help', + slash_status: 'Show running status', + slash_context: 'Show conversation context', + slash_context_clear: 'Clear conversation context', + slash_skill_list: 'List installed skills', + slash_skill_list_remote: 'Browse Skill Hub', + slash_skill_search: 'Search skills', + slash_skill_install: 'Install a skill (name or GitHub URL)', + slash_skill_uninstall: 'Uninstall a skill', + slash_skill_info: 'Show skill details', + slash_skill_enable: 'Enable a skill', + slash_skill_disable: 'Disable a skill', + slash_memory_dream: 'Trigger memory distillation (optional days, default 3)', + slash_knowledge: 'Show knowledge base stats', + slash_knowledge_list: 'Show knowledge base file tree', + slash_knowledge_on: 'Enable knowledge base', + slash_knowledge_off: 'Disable knowledge base', + slash_config: 'Show current config', + slash_cancel: 'Abort the running Agent task', + slash_logs: 'Show recent logs', + slash_version: 'Show version', input_placeholder: 'Type a message, or press / for commands', config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_model: 'Model Configuration', config_agent: 'Agent Configuration', @@ -361,7 +403,25 @@ const I18N = { } }; -let currentLang = localStorage.getItem('cow_lang') || 'zh'; +// Resolve language by priority: user choice (localStorage) -> backend-detected +// (cow_lang) -> browser language -> 'zh'. Shares __cowResolveLang__ defined in +// chat.html; falls back to a local resolver if loaded standalone. +let currentLang = (typeof window.__cowResolveLang__ === 'function') + ? window.__cowResolveLang__() + : (function () { + const norm = (raw) => { + if (!raw) return ''; + const v = String(raw).trim().toLowerCase(); + if (v === 'auto') return ''; + if (v.indexOf('zh') === 0) return 'zh'; + if (v.indexOf('en') === 0) return 'en'; + return ''; + }; + return norm(localStorage.getItem('cow_lang')) + || norm(window.__COW_DEFAULT_LANG__) + || norm(navigator.language) + || 'zh'; + })(); function t(key) { return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key; @@ -1298,28 +1358,30 @@ chatInput.addEventListener('compositionstart', () => { isComposing = true; }); chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); }); // ── Slash Command Menu ─────────────────────────────────────── +// desc holds an i18n key, resolved via t() at render time so the menu follows +// the current UI language. const SLASH_COMMANDS = [ - { cmd: '/help', desc: '显示命令帮助' }, - { cmd: '/status', desc: '查看运行状态' }, - { cmd: '/context', desc: '查看对话上下文' }, - { cmd: '/context clear', desc: '清除对话上下文' }, - { cmd: '/skill list', desc: '查看已安装技能' }, - { cmd: '/skill list --remote', desc: '浏览技能广场' }, - { cmd: '/skill search ', desc: '搜索技能' }, - { cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' }, - { cmd: '/skill uninstall ', desc: '卸载技能' }, - { cmd: '/skill info ', desc: '查看技能详情' }, - { cmd: '/skill enable ', desc: '启用技能' }, - { cmd: '/skill disable ', desc: '禁用技能' }, - { cmd: '/memory dream ', desc: '手动触发记忆蒸馏 (可指定天数, 默认3)' }, - { cmd: '/knowledge', desc: '查看知识库统计' }, - { cmd: '/knowledge list', desc: '查看知识库文件树' }, - { cmd: '/knowledge on', desc: '开启知识库' }, - { cmd: '/knowledge off', desc: '关闭知识库' }, - { cmd: '/config', desc: '查看当前配置' }, - { cmd: '/cancel', desc: '中止当前正在运行的 Agent 任务' }, - { cmd: '/logs', desc: '查看最近日志' }, - { cmd: '/version', desc: '查看版本' }, + { cmd: '/help', desc: 'slash_help' }, + { cmd: '/status', desc: 'slash_status' }, + { cmd: '/context', desc: 'slash_context' }, + { cmd: '/context clear', desc: 'slash_context_clear' }, + { cmd: '/skill list', desc: 'slash_skill_list' }, + { cmd: '/skill list --remote', desc: 'slash_skill_list_remote' }, + { cmd: '/skill search ', desc: 'slash_skill_search' }, + { cmd: '/skill install ', desc: 'slash_skill_install' }, + { cmd: '/skill uninstall ', desc: 'slash_skill_uninstall' }, + { cmd: '/skill info ', desc: 'slash_skill_info' }, + { cmd: '/skill enable ', desc: 'slash_skill_enable' }, + { cmd: '/skill disable ', desc: 'slash_skill_disable' }, + { cmd: '/memory dream ', desc: 'slash_memory_dream' }, + { cmd: '/knowledge', desc: 'slash_knowledge' }, + { cmd: '/knowledge list', desc: 'slash_knowledge_list' }, + { cmd: '/knowledge on', desc: 'slash_knowledge_on' }, + { cmd: '/knowledge off', desc: 'slash_knowledge_off' }, + { cmd: '/config', desc: 'slash_config' }, + { cmd: '/cancel', desc: 'slash_cancel' }, + { cmd: '/logs', desc: 'slash_logs' }, + { cmd: '/version', desc: 'slash_version' }, ]; const slashMenu = document.getElementById('slash-menu'); @@ -1373,7 +1435,7 @@ function renderSlashItems() { slashFiltered.map((c, i) => `

` + `${escapeHtml(c.cmd)}` + - `${escapeHtml(c.desc)}
` + `${escapeHtml(t(c.desc))}
` ).join(''); const activeEl = slashMenu.querySelector('.slash-menu-item.active'); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 120d8efd..e2c0c5e4 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -21,6 +21,7 @@ from channel.chat_channel import ChatChannel, check_prefix from channel.chat_message import ChatMessage from collections import OrderedDict from common import const +from common import i18n from common.log import logger from common.singleton import singleton from config import conf @@ -98,7 +99,7 @@ def _require_auth(): def _cancel_reply_text(cancelled: int, lang: str) -> str: en = lang.startswith("en") if cancelled > 0: - return "🛑 Cancelled." if en else "🛑 已中止" + return "🛑 Cancelled" if en else "🛑 已中止" return "Nothing to cancel." if en else "当前没有可中止的任务。" @@ -477,7 +478,10 @@ class WebChannel(ChatChannel): ) q.put({ "type": "done", - "content": "(模型未返回任何内容,请重试或换一种方式描述你的需求)", + "content": i18n.t( + "(模型未返回任何内容,请重试或换一种方式描述你的需求)", + "(The model returned no content. Please retry or rephrase your request.)", + ), "request_id": request_id, "timestamp": time.time(), }) @@ -805,13 +809,13 @@ class WebChannel(ChatChannel): if not fpath: continue if ftype == "image": - file_refs.append(f"[图片: {fpath}]") + file_refs.append(f"[{i18n.t('图片', 'Image')}: {fpath}]") elif ftype == "video": - file_refs.append(f"[视频: {fpath}]") + file_refs.append(f"[{i18n.t('视频', 'Video')}: {fpath}]") elif ftype == "directory": - file_refs.append(f"[目录: {fpath}]") + file_refs.append(f"[{i18n.t('目录', 'Directory')}: {fpath}]") else: - file_refs.append(f"[文件: {fpath}]") + file_refs.append(f"[{i18n.t('文件', 'File')}: {fpath}]") if file_refs: prompt = prompt + "\n" + "\n".join(file_refs) logger.info(f"[WebChannel] Attached {len(file_refs)} file(s) to message") @@ -952,7 +956,7 @@ class WebChannel(ChatChannel): if request_id and request_id in self.sse_queues: self.sse_queues[request_id].put({ "type": "cancelled", - "content": "Cancelled" if lang.startswith("en") else "已中止", + "content": "🛑 Cancelled" if lang.startswith("en") else "🛑 已中止", "request_id": request_id, "timestamp": time.time(), }) @@ -1008,7 +1012,10 @@ class WebChannel(ChatChannel): """Serve the chat HTML page.""" file_path = os.path.join(os.path.dirname(__file__), 'chat.html') # 使用绝对路径 with open(file_path, 'r', encoding='utf-8') as f: - return f.read() + html = f.read() + # Inject the backend-resolved default language so the console can use + # it on first load (when the user has no saved cow_lang preference). + return html.replace("{{COW_DEFAULT_LANG}}", i18n.get_language()) def startup(self): configured_host = conf().get("web_host", "") @@ -1388,6 +1395,8 @@ class ChatHandler: cache_bust = str(int(time.time())) html = html.replace('assets/js/console.js', f'assets/js/console.js?v={cache_bust}') html = html.replace('assets/css/console.css', f'assets/css/console.css?v={cache_bust}') + # Inject the backend-resolved default language for first-load fallback. + html = html.replace("{{COW_DEFAULT_LANG}}", i18n.get_language()) return html diff --git a/cli/commands/install.py b/cli/commands/install.py index addec52c..ed22f296 100644 --- a/cli/commands/install.py +++ b/cli/commands/install.py @@ -14,7 +14,7 @@ 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) +# on_phase(msg) — coarse-grained progress for chat channels (localized via i18n) PhaseFn = Callable[[str], None] @@ -112,16 +112,25 @@ def run_install_browser( 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. + messages are short status lines localized via i18n. Returns: 0 on success, 1 on fatal failure (pip or chromium install failed). """ + from cli.utils import get_cli_language + from common import i18n + + get_cli_language() # resolve cow_lang so i18n.t reflects config + _t = i18n.t + stream = stream or _default_stream python = sys.executable legacy_mode = False - _phase(on_phase, "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…") + _phase(on_phase, _t( + "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…", + "🔧 Installing browser tool dependencies (a few minutes, please wait)…", + )) glibc = _get_glibc_version() if glibc and glibc < GLIBC_THRESHOLD: @@ -136,27 +145,36 @@ def run_install_browser( stream("") _phase( on_phase, - f"ℹ️ 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}。", + _t( + f"ℹ️ 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}。", + f"ℹ️ Detected glibc {glibc_str} (older); installing compatible Playwright {PLAYWRIGHT_LEGACY_VERSION}.", + ), ) target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION - _phase(on_phase, "📦 [1/3] 正在安装 Playwright Python 包…") + _phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) stream("[1/3] Installing playwright Python package...", "yellow") ret = _pip_install(f"playwright=={target_version}", stream) if ret != 0: stream("Failed to install playwright package.", "red") - _phase(on_phase, "❌ [1/3] Playwright Python 包安装失败。") + _phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package.")) return 1 installed = _get_installed_version() if installed: stream(f" playwright {installed} installed.", "green") stream("") - _phase(on_phase, f"✅ [1/3] Playwright 包已安装({installed or target_version})。") + _phase(on_phase, _t( + f"✅ [1/3] Playwright 包已安装({installed or target_version})。", + f"✅ [1/3] Playwright package installed ({installed or target_version}).", + )) if sys.platform == "linux": - _phase(on_phase, "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo)…") + _phase(on_phase, _t( + "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo)…", + "🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…", + )) stream("[2/3] Installing system dependencies (Linux)...", "yellow") ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"]) if ret != 0: @@ -183,14 +201,23 @@ def run_install_browser( stream(" CJK font (wqy-zenhei) installed.", "green") _phase( on_phase, - "✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。", + _t( + "✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。", + "✅ [2/3] Linux deps and font steps executed (on permission issues, check the server log or run the suggested commands manually).", + ), ) else: stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow") - _phase(on_phase, f"ℹ️ [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。") + _phase(on_phase, _t( + f"ℹ️ [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。", + f"ℹ️ [2/3] Skipping Linux-specific deps on this platform ({sys.platform}).", + )) stream("") - _phase(on_phase, "🌐 [3/3] 正在下载并安装 Chromium(体积较大,请耐心等待)…") + _phase(on_phase, _t( + "🌐 [3/3] 正在下载并安装 Chromium(体积较大,请耐心等待)…", + "🌐 [3/3] Downloading and installing Chromium (large download, please wait)…", + )) stream("[3/3] Installing Chromium browser...", "yellow") cmd = [python, "-m", "playwright", "install", "chromium"] @@ -209,27 +236,33 @@ def run_install_browser( if use_mirror: env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR stream(f" (using China mirror: {CHINA_MIRROR})", None) - _phase(on_phase, "📡 检测到国内 pip 源配置,Chromium 将优先走国内镜像下载。") + _phase(on_phase, _t( + "📡 检测到国内 pip 源配置,Chromium 将优先走国内镜像下载。", + "📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.", + )) ret = subprocess.call(cmd, env=env) if ret != 0 and use_mirror: stream(" Mirror download failed, retrying with official CDN...", "yellow") - _phase(on_phase, "⚠️ 镜像下载失败,正在改用官方源重试…") + _phase(on_phase, _t( + "⚠️ 镜像下载失败,正在改用官方源重试…", + "⚠️ Mirror download failed; retrying with the official CDN…", + )) 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: stream("Failed to install Chromium.", "red") - _phase(on_phase, "❌ [3/3] Chromium 安装失败。") + _phase(on_phase, _t("❌ [3/3] Chromium 安装失败。", "❌ [3/3] Failed to install Chromium.")) return 1 stream("") - _phase(on_phase, "✅ [3/3] Chromium 已安装。") + _phase(on_phase, _t("✅ [3/3] Chromium 已安装。", "✅ [3/3] Chromium installed.")) stream("Verifying browser installation...", None) - _phase(on_phase, "🔍 正在验证 Playwright 能否正常加载…") + _phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…")) ret = subprocess.call( [python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"], stderr=subprocess.DEVNULL, @@ -240,14 +273,20 @@ def run_install_browser( " Consider upgrading your OS or using Docker.", "yellow", ) - _phase(on_phase, "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。") + _phase(on_phase, _t( + "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。", + "⚠️ Verification did not fully pass: the browser tool may still not work here; check the log or upgrade your system.", + )) else: stream(" Verification passed.", "green") - _phase(on_phase, "✅ 验证通过。") + _phase(on_phase, _t("✅ 验证通过。", "✅ Verification passed.")) stream("") stream("Browser tool ready! Restart CowAgent to enable it.", "green") - _phase(on_phase, "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。") + _phase(on_phase, _t( + "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。", + "🎉 All steps finished. Restart CowAgent to use the browser tool.", + )) return 0 diff --git a/cli/commands/process.py b/cli/commands/process.py index 2176fbf7..6ccffdcb 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -275,7 +275,11 @@ def update(ctx): def status(): """Show CowAgent running status.""" from cli import __version__ - from cli.utils import load_config_json + from cli.utils import load_config_json, get_cli_language + from common import i18n + + get_cli_language() # resolve cow_lang so i18n.t reflects config + _t = i18n.t pid = _read_pid() if pid: @@ -283,17 +287,17 @@ def status(): else: click.echo(click.style("● CowAgent is not running", fg="red")) - click.echo(f" 版本: v{__version__}") + click.echo(_t(f" 版本: v{__version__}", f" Version: 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')}") + click.echo(_t(f" 通道: {channel}", f" Channel: {channel}")) + click.echo(_t(f" 模型: {cfg.get('model', 'unknown')}", f" Model: {cfg.get('model', 'unknown')}")) mode = "Chat" if cfg.get("agent") is False else "Agent" - click.echo(f" 模式: {mode}") + click.echo(_t(f" 模式: {mode}", f" Mode: {mode}")) @click.command() diff --git a/cli/commands/skill.py b/cli/commands/skill.py index a591ed9c..fa5a3167 100644 --- a/cli/commands/skill.py +++ b/cli/commands/skill.py @@ -517,18 +517,24 @@ 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.""" + from cli.utils import get_cli_language + from common import i18n + + get_cli_language() # resolve cow_lang so i18n.t reflects config + _t = i18n.t + 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}") + click.echo(_t(f" 名称: {display}", f" Name: {display}")) if desc: if len(desc) > 60: desc = desc[:57] + "…" - click.echo(f" 描述: {desc}") - click.echo(f" 来源: {source}") + click.echo(_t(f" 描述: {desc}", f" Description: {desc}")) + click.echo(_t(f" 来源: {source}", f" Source: {source}")) def _validate_skill_name(name: str): diff --git a/cli/utils.py b/cli/utils.py index b40f8dd5..4dcb5079 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -40,6 +40,22 @@ def load_config_json() -> dict: return {} +def get_cli_language() -> str: + """Resolve the CLI UI language using the shared i18n detector. + + Reads the `cow_lang` field from config.json (defaults to "auto") and runs + the same detection used by the running app, so CLI output matches. + """ + ensure_sys_path() + try: + from common import i18n + + configured = load_config_json().get("cow_lang", "auto") + return i18n.resolve_language(configured) + except Exception: + return "en" + + def load_skills_config() -> dict: """Load skills_config.json from the custom skills directory.""" path = os.path.join(get_skills_dir(), "skills_config.json") diff --git a/common/i18n.py b/common/i18n.py new file mode 100644 index 00000000..8cce5860 --- /dev/null +++ b/common/i18n.py @@ -0,0 +1,177 @@ +# encoding:utf-8 + +"""Lightweight global language detection and resolution. + +This module is the single source of truth for the runtime UI language used +across the CLI, startup logs, error messages, agent prompts and channel +replies. It must NOT import project config (to avoid circular imports) and +must stay dependency-free so it can run at the earliest startup phase. + +Resolution priority (highest first): + 1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any + config key is overridable via its uppercase env var (e.g. COW_LANG=zh), + handled by config.load_config() before resolution. COW_LANG is a private + name to avoid clashing with the gettext-standard LANGUAGE variable. + 2. macOS `defaults read -g AppleLocale` (system-level preference; a Chinese + system locale is a strong signal that beats a shell-default LANG) + 3. Standard locale env vars: LC_ALL > LC_MESSAGES > LANG + 4. Python locale module + 5. Default -> English + +A value of "auto" (the default) triggers detection (steps 2-5). Explicitly +setting "zh" or "en" locks the language and skips detection. +""" + +import os +import subprocess +import sys + +# Supported language codes +ZH = "zh" +EN = "en" +SUPPORTED = (ZH, EN) +DEFAULT_LANG = EN + +# Resolved language cache; None until first resolution. +_resolved_lang = None + + +def _normalize(raw): + """Map an arbitrary locale-ish string to a supported code, or None. + + Only Chinese is detected explicitly; everything else (including unknown + or empty values) yields None so the caller can fall through to the next + detection source. + """ + if not raw: + return None + value = str(raw).strip().lower().replace("_", "-") + if value in ("auto", ""): + return None + # Chinese variants: zh, zh-cn, zh-hans, zh-hans-cn, zh-tw, zh-hk ... + if value.startswith("zh") or value.startswith("chinese"): + return ZH + if value.startswith("en") or value.startswith("english"): + return EN + return None + + +def _detect_from_env(): + """Detect language from standard locale environment variables. + + Note: on macOS, `LANG` is often a shell default (e.g. en_US.UTF-8 set by + .zshrc) that does not reflect the user's real preference, so AppleLocale + is checked first (see detect_language). On Linux these vars are the + primary signal. + + The cow_lang env override (COW_LANG=zh) is intentionally NOT read here: + it sets config["cow_lang"] and is handled via the explicit config path, + not auto-detection. + """ + for key in ("LC_ALL", "LC_MESSAGES", "LANG"): + lang = _normalize(os.environ.get(key)) + if lang: + return lang + return None + + +def _detect_from_macos(): + """macOS fallback: read the system-wide AppleLocale preference. + + On macOS the terminal often does NOT export LANG, yet the system locale + is still meaningful (e.g. a Chinese Mac reports zh_CN). This recovers + that signal so Chinese users are not misdetected as English. + """ + if sys.platform != "darwin": + return None + try: + out = subprocess.run( + ["defaults", "read", "-g", "AppleLocale"], + capture_output=True, + text=True, + timeout=2, + ) + if out.returncode == 0: + return _normalize(out.stdout) + except Exception: + pass + return None + + +def _detect_from_python_locale(): + """Last-resort detection via Python's locale module.""" + try: + import locale + + for value in locale.getlocale(): + lang = _normalize(value) + if lang: + return lang + except Exception: + pass + return None + + +def detect_language(): + """Run full auto-detection and return a supported language code. + + Order (auto-detection only; explicit config["cow_lang"] is resolved + before this is reached): + 1. macOS AppleLocale (system-level preference; a Chinese system locale + is a strong, low-false-positive signal that beats a shell-default + LANG like en_US.UTF-8) + 2. locale env vars LC_ALL / LC_MESSAGES / LANG (primary signal on Linux) + 3. Python locale module + 4. default English + """ + return ( + _detect_from_macos() + or _detect_from_env() + or _detect_from_python_locale() + or DEFAULT_LANG + ) + + +def resolve_language(configured=None): + """Resolve the effective language from a configured value. + + `configured` is the raw `cow_lang` value from config.json (may be None, + "auto", "zh" or "en"). An explicit "zh"/"en" locks the result; "auto" + or empty triggers detection. The result is cached globally. + """ + global _resolved_lang + explicit = _normalize(configured) + if explicit: + _resolved_lang = explicit + else: + _resolved_lang = detect_language() + return _resolved_lang + + +def set_language(lang): + """Force the resolved language (used by tests or per-request overrides).""" + global _resolved_lang + normalized = _normalize(lang) + _resolved_lang = normalized or DEFAULT_LANG + return _resolved_lang + + +def get_language(): + """Return the currently resolved language, detecting lazily if needed.""" + global _resolved_lang + if _resolved_lang is None: + _resolved_lang = detect_language() + return _resolved_lang + + +def is_zh(): + return get_language() == ZH + + +def t(zh_text, en_text): + """Pick a string by the current language. Tiny inline-translation helper. + + Intended for one-off strings where a full message catalog is overkill: + t("已中止", "Cancelled") + """ + return zh_text if get_language() == ZH else en_text diff --git a/config-template.json b/config-template.json index 4e4a7d36..dcd19774 100644 --- a/config-template.json +++ b/config-template.json @@ -1,4 +1,5 @@ { + "cow_lang": "auto", "channel_type": "weixin", "model": "deepseek-v4-flash", "deepseek_api_key": "", diff --git a/config.py b/config.py index 7ad6fa55..ba7936ca 100644 --- a/config.py +++ b/config.py @@ -7,11 +7,17 @@ import os import pickle from common.log import logger +from common import i18n # All available config keys are listed in this dict (use lowercase keys). # The values here are placeholders only; the program does NOT read them. # They merely document the expected format — put real values in config.json. available_setting = { + # global UI language for CLI, startup logs, error messages, agent prompts + # and channel replies. Options: "auto" (detect from system locale, default), + # "zh" (Chinese) or "en" (English). An explicit value locks the language. + # value: auto/en/zh + "cow_lang": "auto", # openai api config "open_ai_api_key": "", # openai api key # openai api base; when use_azure_chatgpt is true, set the matching api base @@ -390,12 +396,17 @@ def load_config(): logger.setLevel(logging.DEBUG) logger.debug("[INIT] set log level to DEBUG") + # Resolve the global UI language as early as possible so that every + # downstream layer (logs, CLI, agent prompts, channel replies) shares it. + resolved_lang = i18n.resolve_language(config.get("cow_lang", "auto")) + logger.info("[INIT] load config: {}".format(drag_sensitive(config))) # print system initialization info logger.info("[INIT] ========================================") logger.info("[INIT] System Initialization") logger.info("[INIT] ========================================") + logger.info("[INIT] Language: {}".format(resolved_lang)) logger.info("[INIT] Channel: {}".format(config.get("channel_type", "unknown"))) logger.info("[INIT] Model: {}".format(config.get("model", "unknown"))) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4d0ec94b..6e5dfde7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,6 +8,7 @@ services: ports: - "9899:9899" environment: + COW_LANG: 'auto' CHANNEL_TYPE: 'weixin' MODEL: 'deepseek-v4-flash' DEEPSEEK_API_KEY: '' diff --git a/models/chatgpt/chat_gpt_bot.py b/models/chatgpt/chat_gpt_bot.py index d5b7703d..999986bc 100644 --- a/models/chatgpt/chat_gpt_bot.py +++ b/models/chatgpt/chat_gpt_bot.py @@ -14,6 +14,7 @@ from models.openai.openai_compat import ( from models.openai.openai_http_client import OpenAIHTTPClient, OpenAIHTTPError import requests from common import const +from common.i18n import t as _t from models.bot import Bot from models.openai_compatible_bot import OpenAICompatibleBot from models.chatgpt.chat_gpt_session import ChatGPTSession @@ -94,13 +95,13 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"]) if query in clear_memory_commands: self.sessions.clear_session(session_id) - reply = Reply(ReplyType.INFO, "记忆已清除") + reply = Reply(ReplyType.INFO, _t("记忆已清除", "Memory cleared")) elif query == "#清除所有": self.sessions.clear_all_session() - reply = Reply(ReplyType.INFO, "所有人记忆已清除") + reply = Reply(ReplyType.INFO, _t("所有人记忆已清除", "All memories cleared")) elif query == "#更新配置": load_config() - reply = Reply(ReplyType.INFO, "配置已更新") + reply = Reply(ReplyType.INFO, _t("配置已更新", "Config updated")) if reply: return reply session = self.sessions.session_query(query, session_id) @@ -148,7 +149,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): reply = self.reply_image(context) return reply else: - reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type)) + reply = Reply(ReplyType.ERROR, _t("Bot不支持处理{}类型的消息", "Bot does not support message type {}").format(context.type)) return reply def reply_image(self, context): @@ -165,7 +166,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): # Check if file exists if not os.path.exists(image_path): logger.error(f"[CHATGPT] Image file not found: {image_path}") - return Reply(ReplyType.ERROR, "图片文件不存在") + return Reply(ReplyType.ERROR, _t("图片文件不存在", "Image file not found")) # Read and encode image with open(image_path, "rb") as f: @@ -232,7 +233,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): logger.error(f"[CHATGPT] Image processing error: {e}") import traceback logger.error(traceback.format_exc()) - return Reply(ReplyType.ERROR, f"图片识别失败: {str(e)}") + return Reply(ReplyType.ERROR, _t("图片识别失败: ", "Image recognition failed: ") + str(e)) def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_count=0) -> dict: """ @@ -277,25 +278,25 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def _handle_reply_error(self, e, session, api_key, args, retry_count): """Map exception to user-facing reply with retry/backoff (mirrors SDK behavior).""" need_retry = retry_count < 2 - result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"} + result = {"completion_tokens": 0, "content": _t("我现在有点累了,等会再来吧", "I'm a bit tired right now. Please try again later.")} if isinstance(e, RateLimitError): logger.warn("[CHATGPT] RateLimitError: {}".format(e)) - result["content"] = "提问太快啦,请休息一下再问我吧" + result["content"] = _t("提问太快啦,请休息一下再问我吧", "You're asking too fast. Please take a short break and try again.") if need_retry: time.sleep(20) elif isinstance(e, Timeout): logger.warn("[CHATGPT] Timeout: {}".format(e)) - result["content"] = "我没有收到你的消息" + result["content"] = _t("我没有收到你的消息", "I didn't receive your message") if need_retry: time.sleep(5) elif isinstance(e, APIConnectionError): logger.warn("[CHATGPT] APIConnectionError: {}".format(e)) - result["content"] = "我连接不到你的网络" + result["content"] = _t("我连接不到你的网络", "I can't reach your network") if need_retry: time.sleep(5) elif isinstance(e, APIError): logger.warn("[CHATGPT] Bad Gateway: {}".format(e)) - result["content"] = "请再问我一次" + result["content"] = _t("请再问我一次", "Please ask me again") if need_retry: time.sleep(10) else: @@ -358,7 +359,7 @@ class AzureChatGPTBot(ChatGPTBot): status = "" while (status != "succeeded"): if retry_count > 3: - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") response = requests.get(operation_location, headers=headers) status = response.json()['status'] retry_count += 1 @@ -366,7 +367,7 @@ class AzureChatGPTBot(ChatGPTBot): return True, image_url except Exception as e: logger.error("create image error: {}".format(e)) - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") elif text_to_image_model == "dall-e-3": api_version = conf().get("azure_api_version", "2024-02-15-preview") endpoint = conf().get("azure_openai_dalle_api_base","open_ai_api_base") @@ -389,7 +390,7 @@ class AzureChatGPTBot(ChatGPTBot): else: error_message = "响应中没有图像 URL" logger.error(error_message) - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") except requests.exceptions.RequestException as e: # 捕获所有请求相关的异常 @@ -405,9 +406,9 @@ class AzureChatGPTBot(ChatGPTBot): # 捕获所有其他异常 error_message = f"生成图像时发生错误: {e}" logger.error(error_message) - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") else: - return False, "图片生成失败,未配置text_to_image参数" + return False, _t("图片生成失败,未配置text_to_image参数", "Image generation failed: text_to_image is not configured") class _AzureChatHTTPClient(OpenAIHTTPClient): diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index f3fee96a..fdd0081f 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -23,6 +23,7 @@ from plugins import Plugin, Event, EventContext, EventAction from bridge.context import ContextType from bridge.reply import Reply, ReplyType from common.log import logger +from common.i18n import t as _t from config import conf from cli import __version__ @@ -280,19 +281,18 @@ class CowCliPlugin(Plugin): @staticmethod def _typo_hint(token: str, suggestion) -> str: - hint = f"未知命令: /{token}" + hint = _t(f"未知命令: /{token}", f"Unknown command: /{token}") if suggestion: - hint += f"\n你是不是想输入 /{suggestion} ?" - hint += "\n发送 /help 查看全部命令。" + hint += _t(f"\n你是不是想输入 /{suggestion} ?", f"\nDid you mean /{suggestion} ?") + hint += _t("\n发送 /help 查看全部命令。", "\nSend /help to see all commands.") return hint @staticmethod def _ambiguous_hint(token: str, candidates) -> str: options = " ".join(f"/{c}" for c in candidates) - return ( - f"命令不明确: /{token}\n" - f"可能想输入: {options}\n" - "发送 /help 查看全部命令。" + return _t( + f"命令不明确: /{token}\n可能想输入: {options}\n发送 /help 查看全部命令。", + f"Ambiguous command: /{token}\nDid you mean: {options}\nSend /help to see all commands.", ) # ------------------------------------------------------------------ @@ -324,7 +324,10 @@ class CowCliPlugin(Plugin): 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}" + return _t( + f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}", + f"⚠️ `cow {cmd}` can only run in a terminal.\nRun it in your shell: cow {cmd}", + ) handler_attr = "_cmd_" + cmd.replace("-", "_") handler = getattr(self, handler_attr, None) @@ -333,42 +336,71 @@ class CowCliPlugin(Plugin): return handler(args, e_context, session_id=session_id) except Exception as e: logger.error(f"[CowCli] command '{cmd}' failed: {e}") - return f"命令执行失败: {e}" + return _t(f"命令执行失败: {e}", f"Command failed: {e}") - return f"未知命令: {cmd}" + return _t(f"未知命令: {cmd}", f"Unknown command: {cmd}") # ------------------------------------------------------------------ # help / version # ------------------------------------------------------------------ def _cmd_help(self, args: str, e_context, **_) -> str: - lines = [ - "📋 CowAgent 命令列表", - "", - " /help 显示此帮助", - " /version 查看版本", - " /status 查看运行状态", - " /cancel 中止当前正在运行的 Agent 任务", - " /logs [N] 查看最近N条日志 (默认20)", - " /context 查看当前对话上下文信息", - " /context clear 清除当前对话上下文", - " /skill list 查看已安装的技能", - " /skill list --remote 浏览技能广场", - " /skill search <关键词> 搜索技能", - " /skill install <名称> 安装技能", - " /skill info <名称> 查看技能详情", - " /config 查看当前配置", - " /config 查看某项配置", - " /config 修改配置", - " /memory status 查看记忆索引状态", - " /memory rebuild-index 清空并重建向量索引 (切换 embedding 模型后必须执行)", - " /memory dream [N] 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", - " /knowledge 查看知识库统计", - " /knowledge list 查看知识库文件树", - " /knowledge on|off 开启/关闭知识库", - "", - "💡 也可以用 cow 代替 /", - ] + if _t("zh", "en") == "en": + lines = [ + "📋 CowAgent Commands", + "", + "/help: Show this help", + "/version: Show version", + "/status: Show running status", + "/cancel: Abort the running Agent task", + "/logs [N]: Show the last N log lines (default 20)", + "/context: Show current conversation context", + "/context clear: Clear current conversation context", + "/skill list: List installed skills", + "/skill list --remote: Browse Skill Hub", + "/skill search : Search skills", + "/skill install : Install a skill", + "/skill info : Show skill details", + "/config: Show current config", + "/config : Show a config item", + "/config : Update a config item", + "/memory status: Show memory index status", + "/memory rebuild-index: Rebuild the vector index (required after switching embedding model)", + "/memory dream [N]: Trigger memory distillation (last N days, default 3, max 30)", + "/knowledge: Show knowledge base stats", + "/knowledge list: Show knowledge base file tree", + "/knowledge on|off: Enable/disable knowledge base", + "", + "💡 You can also use cow instead of /", + ] + else: + lines = [ + "📋 CowAgent 命令列表", + "", + "/help: 显示此帮助", + "/version: 查看版本", + "/status: 查看运行状态", + "/cancel: 中止当前正在运行的 Agent 任务", + "/logs [N]: 查看最近N条日志 (默认20)", + "/context: 查看当前对话上下文信息", + "/context clear: 清除当前对话上下文", + "/skill list: 查看已安装的技能", + "/skill list --remote: 浏览技能广场", + "/skill search <关键词>: 搜索技能", + "/skill install <名称>: 安装技能", + "/skill info <名称>: 查看技能详情", + "/config: 查看当前配置", + "/config : 查看某项配置", + "/config : 修改配置", + "/memory status: 查看记忆索引状态", + "/memory rebuild-index: 清空并重建向量索引 (切换 embedding 模型后必须执行)", + "/memory dream [N]: 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", + "/knowledge: 查看知识库统计", + "/knowledge list: 查看知识库文件树", + "/knowledge on|off: 开启/关闭知识库", + "", + "💡 也可以用 cow 代替 /", + ] return "\n".join(lines) def _cmd_version(self, args: str, e_context, **_) -> str: @@ -405,9 +437,9 @@ class CowCliPlugin(Plugin): cancelled = registry.cancel_session(target_session) if cancelled <= 0: - return "当前没有可中止的任务。" + return _t("当前没有可中止的任务。", "Nothing to cancel.") - return "🛑 已中止" + return _t("🛑 已中止", "🛑 Cancelled") # ------------------------------------------------------------------ # status @@ -417,21 +449,21 @@ class CowCliPlugin(Plugin): from config import conf cfg = conf() - lines = ["📊 CowAgent 运行状态", ""] + lines = [_t("📊 CowAgent 运行状态", "📊 CowAgent Status"), ""] - lines.append(f" 版本: v{__version__}") - lines.append(f" 进程: PID {os.getpid()}") + lines.append(_t(f" 版本: v{__version__}", f" Version: v{__version__}")) + lines.append(_t(f" 进程: PID {os.getpid()}", f" Process: PID {os.getpid()}")) channel = cfg.get("channel_type", "unknown") if isinstance(channel, list): channel = ", ".join(channel) - lines.append(f" 通道: {channel}") + lines.append(_t(f" 通道: {channel}", f" Channel: {channel}")) model_name = cfg.get("model", "unknown") - lines.append(f" 模型: {model_name}") + lines.append(_t(f" 模型: {model_name}", f" Model: {model_name}")) mode = "Chat" if cfg.get("agent") is False else "Agent" - lines.append(f" 模式: {mode}") + lines.append(_t(f" 模式: {mode}", f" Mode: {mode}")) session_id = self._get_session_id(e_context, fallback=session_id) agent = self._get_agent(session_id) @@ -439,7 +471,7 @@ class CowCliPlugin(Plugin): lines.append("") with agent.messages_lock: msg_count = len(agent.messages) - lines.append(f" 会话消息数: {msg_count}") + lines.append(_t(f" 会话消息数: {msg_count}", f" Session messages: {msg_count}")) if agent.skill_manager: total = len(agent.skill_manager.skills) @@ -447,10 +479,10 @@ class CowCliPlugin(Plugin): 1 for v in agent.skill_manager.skills_config.values() if v.get("enabled", True) ) - lines.append(f" 已加载技能: {enabled}/{total}") + lines.append(_t(f" 已加载技能: {enabled}/{total}", f" Loaded skills: {enabled}/{total}")) else: lines.append("") - lines.append(f" Agent: 未初始化 (首次对话后自动创建)") + lines.append(_t(" Agent: 未初始化 (首次对话后自动创建)", " Agent: not initialized (created on first chat)")) return "\n".join(lines) @@ -465,7 +497,7 @@ class CowCliPlugin(Plugin): log_file = self._find_log_file() if not log_file: - return "未找到日志文件" + return _t("未找到日志文件", "No log file found") try: with open(log_file, "r", encoding="utf-8", errors="replace") as f: @@ -473,10 +505,10 @@ class CowCliPlugin(Plugin): tail = all_lines[-num_lines:] content = "".join(tail).strip() if not content: - return "日志为空" - return f"📄 最近 {len(tail)} 条日志:\n\n{content}" + return _t("日志为空", "Log is empty") + return _t(f"📄 最近 {len(tail)} 条日志:\n\n{content}", f"📄 Last {len(tail)} log lines:\n\n{content}") except Exception as e: - return f"读取日志失败: {e}" + return _t(f"读取日志失败: {e}", f"Failed to read log: {e}") def _find_log_file(self) -> str: project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -507,13 +539,13 @@ class CowCliPlugin(Plugin): def _context_info(self, agent, session_id: str) -> str: if not agent: - return "⚠️ Agent 未初始化,暂无上下文信息" + return _t("⚠️ Agent 未初始化,暂无上下文信息", "⚠️ Agent not initialized, no context yet") with agent.messages_lock: messages = agent.messages.copy() if not messages: - return "当前对话上下文为空" + return _t("当前对话上下文为空", "Current conversation context is empty") 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") @@ -521,29 +553,43 @@ class CowCliPlugin(Plugin): 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 可清除对话上下文", - ] + if _t("zh", "en") == "en": + lines = [ + "💬 Current Conversation Context", + "", + f" Session: {session_id or 'default'}", + f" Total messages: {len(messages)}", + f" User messages: {user_msgs}", + f" Assistant replies: {assistant_msgs}", + f" Tool calls: {tool_msgs}", + f" Total content length: ~{total_chars} chars", + "", + " Send /context clear to clear the conversation context", + ] + else: + 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 未初始化" + return _t("⚠️ Agent 未初始化", "⚠️ Agent not initialized") with agent.messages_lock: count = len(agent.messages) agent.messages.clear() - return f"✅ 已清除当前对话上下文 ({count} 条消息)" + return _t(f"✅ 已清除当前对话上下文 ({count} 条消息)", f"✅ Conversation context cleared ({count} messages)") # ------------------------------------------------------------------ # config @@ -578,21 +624,24 @@ class CowCliPlugin(Plugin): def _config_show_all(self) -> str: from config import conf cfg = conf() - lines = ["⚙️ 当前配置", ""] + lines = [_t("⚙️ 当前配置", "⚙️ Current Config"), ""] 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 修改配置") + lines.append(_t("💡 /config : 查看配置", "💡 /config : Show a config item")) + lines.append(_t("💡 /config : 修改配置", "💡 /config : Update a config item")) 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}" + return _t( + f"不支持查看 '{key}'\n\n可查看的配置项: {available}", + f"Cannot show '{key}'\n\nReadable config items: {available}", + ) val = conf().get(key, "") return f"⚙️ {key}: {val}" @@ -602,9 +651,12 @@ class CowCliPlugin(Plugin): if key not in self._CONFIG_WRITABLE: if key in self._CONFIG_READABLE: - return f"⚠️ '{key}' 为只读配置,不支持修改" + return _t(f"⚠️ '{key}' 为只读配置,不支持修改", f"⚠️ '{key}' is read-only and cannot be modified") available = ", ".join(sorted(self._CONFIG_WRITABLE)) - return f"不支持修改 '{key}'\n\n可修改的配置项: {available}" + return _t( + f"不支持修改 '{key}'\n\n可修改的配置项: {available}", + f"Cannot modify '{key}'\n\nWritable config items: {available}", + ) old_val = conf().get(key, "") @@ -637,7 +689,7 @@ class CowCliPlugin(Plugin): 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}" + return _t(f"写入 config.json 失败: {e}", f"Failed to write config.json: {e}") # Sync updated values to environment variables so that load_config() # won't overwrite the new value with a stale env var (common in Docker). @@ -660,7 +712,7 @@ class CowCliPlugin(Plugin): except Exception as e: logger.warning(f"[CowCli] config reload warning: {e}") - result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}" + result = _t(f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}", f"✅ Config updated\n\n {key}: {old_val} → {new_val}") if "bot_type" in updates and updates["bot_type"] != old_bot_type: result += f"\n bot_type: {old_bot_type} → {updates['bot_type']}" return result @@ -725,10 +777,13 @@ class CowCliPlugin(Plugin): from cli.commands.install import run_install_browser if args.strip(): - return ( + return _t( "用法: /install-browser\n\n" "无需参数,等同于终端执行 `cow install-browser`。\n" - "安装过程可能持续数分钟;进度会以多条消息推送,pip 详细输出见服务日志。" + "安装过程可能持续数分钟;进度会以多条消息推送,pip 详细输出见服务日志。", + "Usage: /install-browser\n\n" + "No arguments needed; equivalent to running `cow install-browser` in a terminal.\n" + "Installation may take a few minutes; progress is pushed as multiple messages, and detailed pip output goes to the service log.", ) # Suppress detailed stream in chat; phases go through channel.send @@ -740,11 +795,16 @@ class CowCliPlugin(Plugin): on_phase=lambda m: self._send_install_progress(e_context, m), ) if code != 0: - return ( + return _t( "❌ 安装未成功结束,请查看上方分段提示或服务器日志;" - "也可在终端执行 `cow install-browser`。" + "也可在终端执行 `cow install-browser`。", + "❌ Installation did not finish successfully. Check the messages above or the server log; " + "you can also run `cow install-browser` in a terminal.", ) - return "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。" + return _t( + "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。", + "✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).", + ) # ------------------------------------------------------------------ # skill @@ -770,16 +830,25 @@ class CowCliPlugin(Plugin): elif sub == "disable": return self._skill_set_enabled(sub_args, False) else: - return ( + return _t( "用法: /skill <子命令>\n\n" "子命令:\n" - " list [--remote] 查看技能列表\n" - " search <关键词> 搜索技能\n" - " install <名称> 安装技能\n" - " uninstall <名称> 卸载技能\n" - " info <名称> 查看技能详情\n" - " enable <名称> 启用技能\n" - " disable <名称> 禁用技能" + "list [--remote]: 查看技能列表\n" + "search <关键词>: 搜索技能\n" + "install <名称>: 安装技能\n" + "uninstall <名称>: 卸载技能\n" + "info <名称>: 查看技能详情\n" + "enable <名称>: 启用技能\n" + "disable <名称>: 禁用技能", + "Usage: /skill \n\n" + "Subcommands:\n" + "list [--remote]: List skills\n" + "search : Search skills\n" + "install : Install a skill\n" + "uninstall : Uninstall a skill\n" + "info : Show skill details\n" + "enable : Enable a skill\n" + "disable : Disable a skill", ) def _refresh_skill_manager(self): @@ -813,13 +882,16 @@ class CowCliPlugin(Plugin): 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 浏览技能广场" + return _t( + "暂无已安装的技能\n\n💡 /skill list --remote: 浏览技能广场", + "No skills installed yet\n\n💡 /skill list --remote: Browse Skill Hub", + ) 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)})", ""] + lines = [_t(f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", f"📦 Installed Skills ({enabled_count}/{len(sorted_entries)})"), ""] for entry in sorted_entries: name = entry.get("name", "") enabled = entry.get("enabled", True) @@ -835,13 +907,13 @@ class CowCliPlugin(Plugin): if desc: line += f"\n {desc}" if source: - line += f"\n 来源: {source}" + line += _t(f"\n 来源: {source}", f"\n Source: {source}") lines.append(line) lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append("💡 /skill list --remote 浏览技能广场") - lines.append("💡 /skill info <名称> 查看详情") + lines.append(_t("💡 /skill list --remote: 浏览技能广场", "💡 /skill list --remote: Browse Skill Hub")) + lines.append(_t("💡 /skill info <名称>: 查看详情", "💡 /skill info : Show details")) return "\n".join(lines) def _skill_list(self, args: str) -> str: @@ -871,43 +943,43 @@ class CowCliPlugin(Plugin): skills = data.get("skills", []) total = data.get("total", len(skills)) except Exception as e: - return f"获取技能广场失败: {e}" + return _t(f"获取技能广场失败: {e}", f"Failed to fetch Skill Hub: {e}") if not skills and page == 1: - return "技能广场暂无可用技能" + return _t("技能广场暂无可用技能", "No skills available on Skill Hub") total_pages = max(1, (total + page_size - 1) // page_size) page = min(page, total_pages) installed = set(load_skills_config().keys()) - lines = ["🌐 技能广场", ""] + lines = [_t("🌐 技能广场", "🌐 Skill Hub"), ""] 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 "" + badge = _t(" [已安装]", " [installed]") if name in installed else "" lines.append(f"📌 {display}{badge}") - lines.append(f" 名称: {name}") + lines.append(_t(f" 名称: {name}", f" Name: {name}")) if desc: lines.append(f" {desc}") lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append(f"📄 第 {page}/{total_pages} 页") + lines.append(_t(f"📄 第 {page}/{total_pages} 页", f"📄 Page {page}/{total_pages}")) if page < total_pages: - lines.append(f"💡 /skill list --remote --page {page + 1} 下一页") + lines.append(_t(f"💡 /skill list --remote --page {page + 1}: 下一页", f"💡 /skill list --remote --page {page + 1}: Next page")) if page > 1: - lines.append(f"💡 /skill list --remote --page {page - 1} 上一页") - lines.append("💡 /skill install <名称> 安装技能") - lines.append("💡 /skill search <关键词> 搜索技能") - lines.append("🌐 https://skills.cowagent.ai 在线浏览全部技能") + lines.append(_t(f"💡 /skill list --remote --page {page - 1}: 上一页", f"💡 /skill list --remote --page {page - 1}: Previous page")) + lines.append(_t("💡 /skill install <名称>: 安装技能", "💡 /skill install : Install a skill")) + lines.append(_t("💡 /skill search <关键词>: 搜索技能", "💡 /skill search : Search skills")) + lines.append(_t("🌐 https://skills.cowagent.ai 在线浏览全部技能", "🌐 https://skills.cowagent.ai Browse all skills online")) return "\n".join(lines) def _skill_search(self, query: str) -> str: if not query: - return "请指定搜索关键词: /skill search <关键词>" + return _t("请指定搜索关键词: /skill search <关键词>", "Please specify a search keyword: /skill search ") import requests from cli.utils import SKILL_HUB_API, load_skills_config @@ -916,35 +988,35 @@ class CowCliPlugin(Plugin): resp.raise_for_status() skills = resp.json().get("skills", []) except Exception as e: - return f"搜索失败: {e}" + return _t(f"搜索失败: {e}", f"Search failed: {e}") if not skills: - return f"未找到与「{query}」相关的技能" + return _t(f"未找到与「{query}」相关的技能", f"No skills found for \"{query}\"") installed = set(load_skills_config().keys()) - lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""] + lines = [_t(f"🔍 搜索「{query}」({len(skills)} 个结果)", f"🔍 Search \"{query}\" ({len(skills)} results)"), ""] 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 "" + badge = _t(" [已安装]", " [installed]") if name in installed else "" lines.append(f"📌 {display}{badge}") - lines.append(f" 名称: {name}") + lines.append(_t(f" 名称: {name}", f" Name: {name}")) if desc: lines.append(f" {desc}") lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append("💡 /skill install <名称> 安装技能") + lines.append(_t("💡 /skill install <名称>: 安装技能", "💡 /skill install : Install a skill")) return "\n".join(lines) _INSTALL_TIMEOUT = 60 def _skill_install(self, name: str, e_context: EventContext) -> str: if not name: - return "请指定要安装的技能: /skill install <名称>" + return _t("请指定要安装的技能: /skill install <名称>", "Please specify a skill to install: /skill install ") from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from cli.commands.skill import install_skill @@ -955,16 +1027,16 @@ class CowCliPlugin(Plugin): result = future.result(timeout=self._INSTALL_TIMEOUT) if result.error: - return f"安装失败: {result.error}" + return _t(f"安装失败: {result.error}", f"Install failed: {result.error}") if not result.installed: - return "\n".join(result.messages) if result.messages else "未找到可安装的技能" + return "\n".join(result.messages) if result.messages else _t("未找到可安装的技能", "No installable skill found") return self._format_install_result(result) except FuturesTimeout: - return "安装超时,请稍后重试或检查网络连接" + return _t("安装超时,请稍后重试或检查网络连接", "Install timed out. Please retry later or check your network connection.") except Exception as e: - return f"安装失败: {e}" + return _t(f"安装失败: {e}", f"Install failed: {e}") @staticmethod def _format_install_result(result) -> str: @@ -978,20 +1050,20 @@ class CowCliPlugin(Plugin): 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", "") - lines.append(f"✅ 技能安装成功:{skill_name}") + lines.append(_t(f"✅ 技能安装成功:{skill_name}", f"✅ Skill installed: {skill_name}")) if display and display != skill_name: - lines.append(f" 名称:{display}") + lines.append(_t(f" 名称:{display}", f" Name: {display}")) if desc: - lines.append(f" 描述:{desc}") + lines.append(_t(f" 描述:{desc}", f" Description: {desc}")) if len(result.installed) > 1: - lines.append(f"\n共安装 {len(result.installed)} 个技能") + lines.append(_t(f"\n共安装 {len(result.installed)} 个技能", f"\nInstalled {len(result.installed)} skills")) return "\n".join(lines) def _skill_uninstall(self, name: str) -> str: if not name: - return "请指定要卸载的技能: /skill uninstall <名称>" + return _t("请指定要卸载的技能: /skill uninstall <名称>", "Please specify a skill to uninstall: /skill uninstall ") import shutil import json @@ -1004,7 +1076,7 @@ class CowCliPlugin(Plugin): skill_dir = self._resolve_skill_dir(name, skills_dir) if not skill_dir: - return f"技能 '{name}' 未安装" + return _t(f"技能 '{name}' 未安装", f"Skill '{name}' is not installed") shutil.rmtree(skill_dir) @@ -1019,7 +1091,7 @@ class CowCliPlugin(Plugin): except Exception: pass - return f"✅ 技能 '{name}' 已卸载" + return _t(f"✅ 技能 '{name}' 已卸载", f"✅ Skill '{name}' uninstalled") @staticmethod def _resolve_skill_dir(name: str, skills_dir: str): @@ -1055,7 +1127,7 @@ class CowCliPlugin(Plugin): def _skill_info(self, name: str) -> str: if not name: - return "请指定技能名称: /skill info <名称>" + return _t("请指定技能名称: /skill info <名称>", "Please specify a skill name: /skill info ") from cli.utils import get_skills_dir, get_builtin_skills_dir @@ -1078,18 +1150,18 @@ class CowCliPlugin(Plugin): source = "custom" if not skill_dir: - return f"技能 '{name}' 未找到" + return _t(f"技能 '{name}' 未找到", f"Skill '{name}' not found") skill_md = os.path.join(skill_dir, "SKILL.md") if not os.path.exists(skill_md): - return f"技能 '{name}' 没有 SKILL.md 文件" + return _t(f"技能 '{name}' 没有 SKILL.md 文件", f"Skill '{name}' has no SKILL.md file") with open(skill_md, "r", encoding="utf-8") as f: content = f.read() meta, body = self._strip_frontmatter(content) - header_lines = [f"📖 技能: {name} [{source}]", ""] + header_lines = [_t(f"📖 技能: {name} [{source}]", f"📖 Skill: {name} [{source}]"), ""] desc = meta.get("description", "") if desc: header_lines.append(f" {desc}") @@ -1104,8 +1176,10 @@ class CowCliPlugin(Plugin): 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'} <名称>" + return _t( + f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>", + f"Please specify a skill name: /skill {'enable' if enabled else 'disable'} ", + ) import json from cli.utils import get_skills_dir @@ -1114,24 +1188,25 @@ class CowCliPlugin(Plugin): config_path = os.path.join(skills_dir, "skills_config.json") if not os.path.exists(config_path): - return "技能配置文件不存在" + return _t("技能配置文件不存在", "Skills config file not found") try: with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) except Exception as e: - return f"读取配置失败: {e}" + return _t(f"读取配置失败: {e}", f"Failed to read config: {e}") if name not in config: - return f"技能 '{name}' 未在配置中找到" + return _t(f"技能 '{name}' 未在配置中找到", f"Skill '{name}' not found in config") 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}" + if enabled: + return _t(f"{icon} 技能 '{name}' 已启用", f"{icon} Skill '{name}' enabled") + return _t(f"{icon} 技能 '{name}' 已禁用", f"{icon} Skill '{name}' disabled") # ------------------------------------------------------------------ # memory @@ -1157,13 +1232,19 @@ class CowCliPlugin(Plugin): @staticmethod def _memory_help() -> str: - return ( + return _t( "🧠 记忆管理\n\n" "用法: /memory <子命令>\n\n" "子命令:\n" - " status 查看索引状态 (provider / model / dim / chunks)\n" - " rebuild-index 清空并重建向量索引 (切换 embedding 模型后必须执行)\n" - " dream [N] 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)" + "status: 查看索引状态 (provider / model / dim / chunks)\n" + "rebuild-index: 清空并重建向量索引 (切换 embedding 模型后必须执行)\n" + "dream [N]: 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", + "🧠 Memory Management\n\n" + "Usage: /memory \n\n" + "Subcommands:\n" + "status: Show index status (provider / model / dim / chunks)\n" + "rebuild-index: Rebuild the vector index (required after switching embedding model)\n" + "dream [N]: Trigger memory distillation (last N days, default 3, max 30)", ) def _memory_dream(self, days: int, e_context, session_id: str) -> str: @@ -1178,10 +1259,10 @@ class CowCliPlugin(Plugin): try: flush_mgr = self._create_standalone_flush_manager() except Exception as e: - return f"⚠️ 无法初始化记忆蒸馏: {e}" + return _t(f"⚠️ 无法初始化记忆蒸馏: {e}", f"⚠️ Failed to initialize memory distillation: {e}") if not flush_mgr.llm_model: - return "⚠️ 未配置 LLM 模型,无法执行记忆蒸馏" + return _t("⚠️ 未配置 LLM 模型,无法执行记忆蒸馏", "⚠️ No LLM model configured, cannot run memory distillation") # SaaS (e_context is None): run synchronously, return full result if e_context is None: @@ -1196,13 +1277,16 @@ class CowCliPlugin(Plugin): if result: self._notify(e_context, self._build_dream_result(flush_mgr, is_web)) else: - self._notify(e_context, "💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理") + self._notify(e_context, _t("💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理", "💤 Memory distillation skipped — no new memories to process")) except Exception as e: logger.warning(f"[CowCli] /memory dream failed: {e}") - self._notify(e_context, f"❌ 记忆蒸馏失败: {e}") + self._notify(e_context, _t(f"❌ 记忆蒸馏失败: {e}", f"❌ Memory distillation failed: {e}")) threading.Thread(target=_run, daemon=True).start() - return f"🌙 记忆蒸馏已启动 (整理近 {days} 天的记忆)\n\n整理在后台执行,完成后会通知你。" + return _t( + f"🌙 记忆蒸馏已启动 (整理近 {days} 天的记忆)\n\n整理在后台执行,完成后会通知你。", + f"🌙 Memory distillation started (processing the last {days} days)\n\nRunning in the background; you'll be notified when it's done.", + ) def _memory_dream_sync(self, flush_mgr, days: int) -> str: """Run deep dream synchronously and return the full result.""" @@ -1210,10 +1294,10 @@ class CowCliPlugin(Plugin): result = flush_mgr.deep_dream(lookback_days=days, force=True) if result: return self._build_dream_result(flush_mgr, is_web=True) - return "💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理" + return _t("💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理", "💤 Memory distillation skipped — no new memories to process") except Exception as e: logger.warning(f"[CowCli] /memory dream sync failed: {e}") - return f"❌ 记忆蒸馏失败: {e}" + return _t(f"❌ 记忆蒸馏失败: {e}", f"❌ Memory distillation failed: {e}") @staticmethod def _resolve_active_embedding(): @@ -1255,9 +1339,9 @@ class CowCliPlugin(Plugin): agent = self._get_agent("") memory_manager = agent.memory_manager if agent else None - lines = ["🧠 记忆索引状态", ""] + lines = [_t("🧠 记忆索引状态", "🧠 Memory Index Status"), ""] if not memory_manager: - lines.append(" ⚠️ Agent 尚未初始化,先发一条普通消息再试") + lines.append(_t(" ⚠️ Agent 尚未初始化,先发一条普通消息再试", " ⚠️ Agent not initialized yet, send a normal message first")) return "\n".join(lines) stats = memory_manager.storage.get_stats() @@ -1278,7 +1362,7 @@ class CowCliPlugin(Plugin): lines.append(f" Model : {cfg_model}") lines.append(f" Dim : {cfg_dim if cfg_dim else '?'}") else: - lines.append(" Provider : (未初始化, keyword-only)") + lines.append(_t(" Provider : (未初始化, keyword-only)", " Provider : (not initialized, keyword-only)")) # Health hints — only shown when the user has explicitly opted into # vector search via `embedding_provider`. Legacy users (no explicit @@ -1289,17 +1373,17 @@ class CowCliPlugin(Plugin): if explicitly_opted_in and provider_obj is not None: if chunks > 0 and embedded < chunks: missing = chunks - embedded - warnings.append( - f" ⚠️ {missing}/{chunks} 个 chunk 没有向量;" - f"运行 /memory rebuild-index 后所有记忆才会被向量化检索" - ) + warnings.append(_t( + f" ⚠️ {missing}/{chunks} 个 chunk 没有向量;运行 /memory rebuild-index 后所有记忆才会被向量化检索", + f" ⚠️ {missing}/{chunks} chunks have no vectors; run /memory rebuild-index to enable vector search for all memories", + )) index_dim = detect_index_dim(memory_manager.storage) if index_dim is not None and cfg_dim and index_dim != cfg_dim: - warnings.append( - f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;" - f"运行 /memory rebuild-index 重建后向量检索才会生效" - ) + warnings.append(_t( + f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;运行 /memory rebuild-index 重建后向量检索才会生效", + f" ⚠️ Existing vectors are {index_dim}-dim, mismatching the current {cfg_dim}-dim config; run /memory rebuild-index to make vector search work", + )) if warnings: lines.append("") @@ -1312,9 +1396,11 @@ class CowCliPlugin(Plugin): session_id = self._get_session_id(e_context, fallback=session_id) agent = self._get_agent(session_id) if not agent or not agent.memory_manager: - return ( + return _t( "⚠️ Agent 尚未初始化,无法重建索引。\n" - "请先发送一条普通消息触发 Agent 启动后再试。" + "请先发送一条普通消息触发 Agent 启动后再试。", + "⚠️ Agent not initialized, cannot rebuild the index.\n" + "Send a normal message first to start the Agent, then try again.", ) memory_manager = agent.memory_manager @@ -1328,12 +1414,14 @@ class CowCliPlugin(Plugin): ._init_embedding_provider(memory_manager.config, session_id=session_id) except Exception as e: logger.exception("[CowCli] /memory rebuild-index: build provider failed") - return f"⚠️ 无法根据当前配置构造 embedding provider: {e}" + return _t(f"⚠️ 无法根据当前配置构造 embedding provider: {e}", f"⚠️ Failed to build embedding provider from current config: {e}") if fresh_provider is None: - return ( + return _t( "⚠️ 当前没有可用的 embedding provider。\n" - "请检查 config.json 中的 embedding 相关配置 (provider / api key)。" + "请检查 config.json 中的 embedding 相关配置 (provider / api key)。", + "⚠️ No embedding provider available.\n" + "Check the embedding settings in config.json (provider / api key).", ) memory_manager.embedding_provider = fresh_provider @@ -1353,23 +1441,29 @@ class CowCliPlugin(Plugin): if result.ok: self._notify( e_context, - ( + _t( f"✅ 索引重建完成\n" f" cleared : {result.removed}\n" f" chunks : {result.chunks}\n" - f" files : {result.files}" + f" files : {result.files}", + f"✅ Index rebuild complete\n" + f" cleared : {result.removed}\n" + f" chunks : {result.chunks}\n" + f" files : {result.files}", ), ) else: - self._notify(e_context, f"❌ 索引重建失败: {result.error}") + self._notify(e_context, _t(f"❌ 索引重建失败: {result.error}", f"❌ Index rebuild failed: {result.error}")) except Exception as e: logger.exception("[CowCli] /memory rebuild-index failed") - self._notify(e_context, f"❌ 索引重建失败: {e}") + self._notify(e_context, _t(f"❌ 索引重建失败: {e}", f"❌ Index rebuild failed: {e}")) threading.Thread(target=_run, daemon=True).start() - return ( + return _t( f"🔧 索引重建已启动 (model={model_label}, dim={dim_label})\n\n" - f"将重新向量化所有记忆和知识文件,完成后会通知你。" + f"将重新向量化所有记忆和知识文件,完成后会通知你。", + f"🔧 Index rebuild started (model={model_label}, dim={dim_label})\n\n" + f"Re-vectorizing all memory and knowledge files; you'll be notified when done.", ) @staticmethod @@ -1380,15 +1474,19 @@ class CowCliPlugin(Plugin): result = rebuild_in_process(memory_manager) except Exception as e: logger.exception("[CowCli] /memory rebuild-index sync failed") - return f"❌ 索引重建失败: {e}" + return _t(f"❌ 索引重建失败: {e}", f"❌ Index rebuild failed: {e}") if not result.ok: - return f"❌ 索引重建失败: {result.error}" - return ( + return _t(f"❌ 索引重建失败: {result.error}", f"❌ Index rebuild failed: {result.error}") + return _t( f"✅ 索引重建完成 (model={model_label}, dim={dim_label})\n" f" cleared : {result.removed}\n" f" chunks : {result.chunks}\n" - f" files : {result.files}" + f" files : {result.files}", + f"✅ Index rebuild complete (model={model_label}, dim={dim_label})\n" + f" cleared : {result.removed}\n" + f" chunks : {result.chunks}\n" + f" files : {result.files}", ) @staticmethod @@ -1418,7 +1516,7 @@ class CowCliPlugin(Plugin): def _build_dream_result(flush_mgr, is_web: bool) -> str: """Build dream completion message with diary content.""" from datetime import datetime - lines = ["✅ 记忆蒸馏完成"] + lines = [_t("✅ 记忆蒸馏完成", "✅ Memory distillation complete")] # Read today's dream diary today = datetime.now().strftime("%Y-%m-%d") @@ -1433,9 +1531,9 @@ class CowCliPlugin(Plugin): lines.append(f"\n{diary}") if is_web: - lines.append("\n[MEMORY.md](/memory/MEMORY.md) | [梦境日记](/memory/dreams)") + lines.append(_t("\n[MEMORY.md](/memory/MEMORY.md) | [梦境日记](/memory/dreams)", "\n[MEMORY.md](/memory/MEMORY.md) | [Dream Diary](/memory/dreams)")) else: - lines.append("\nMEMORY.md 已更新") + lines.append(_t("\nMEMORY.md 已更新", "\nMEMORY.md updated")) return "\n".join(lines) @@ -1485,11 +1583,17 @@ class CowCliPlugin(Plugin): 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}" + return _t(f"⚠️ 内存中已切换,但写入 config.json 失败: {e}", f"⚠️ Switched in memory, but failed to write config.json: {e}") - status = "开启 ✅" if enabled else "关闭 ❌" - note = "知识库将在下次对话中生效" if enabled else "知识库系统已停用,不再注入提示词和索引知识文件" - return f"📚 知识库已{status}\n\n{note}" + if enabled: + return _t( + "📚 知识库已开启 ✅\n\n知识库将在下次对话中生效", + "📚 Knowledge base enabled ✅\n\nIt will take effect in the next conversation", + ) + return _t( + "📚 知识库已关闭 ❌\n\n知识库系统已停用,不再注入提示词和索引知识文件", + "📚 Knowledge base disabled ❌\n\nThe knowledge system is off; no prompt injection or file indexing", + ) def _knowledge_stats(self) -> str: from config import conf @@ -1499,7 +1603,7 @@ class CowCliPlugin(Plugin): "knowledge" ) if not os.path.isdir(knowledge_dir): - return "📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on" + return _t("📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on", "📚 Knowledge base directory not found\n\n💡 Enable it: /knowledge on") enabled = conf().get("knowledge", True) total_files = 0 @@ -1516,13 +1620,13 @@ class CowCliPlugin(Plugin): total_bytes += os.path.getsize(os.path.join(root, f)) cat_count[category] = cat_count.get(category, 0) + 1 - status = "✅ 已开启" if enabled else "❌ 已关闭" + status = _t("✅ 已开启", "✅ Enabled") if enabled else _t("❌ 已关闭", "❌ Disabled") lines = [ - "📚 知识库统计", + _t("📚 知识库统计", "📚 Knowledge Base Stats"), "", - f"状态: {status}", - f"页面: {total_files} 篇", - f"大小: {total_bytes / 1024:.1f} KB", + _t(f"状态: {status}", f"Status: {status}"), + _t(f"页面: {total_files} 篇", f"Pages: {total_files}"), + _t(f"大小: {total_bytes / 1024:.1f} KB", f"Size: {total_bytes / 1024:.1f} KB"), "", ] if cat_count: @@ -1530,12 +1634,12 @@ class CowCliPlugin(Plugin): lines.append(f"- {cat}/ ({cat_count[cat]} pages)") lines.append("") - lines.append(f"路径: {knowledge_dir}") + lines.append(_t(f"路径: {knowledge_dir}", f"Path: {knowledge_dir}")) lines.extend([ "", "━━━━━━━━━━━━━━━━━━━━━━━━━━", - "💡 /knowledge list 查看文件树", - "💡 /knowledge on|off 开关知识库", + _t("💡 /knowledge list: 查看文件树", "💡 /knowledge list: Show file tree"), + _t("💡 /knowledge on|off: 开关知识库", "💡 /knowledge on|off: Toggle knowledge base"), ]) return "\n".join(lines) @@ -1547,7 +1651,7 @@ class CowCliPlugin(Plugin): "knowledge" ) if not os.path.isdir(knowledge_dir): - return "📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on" + return _t("📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on", "📚 Knowledge base directory not found\n\n💡 Enable it: /knowledge on") tree = ["knowledge/"] @@ -1577,7 +1681,7 @@ class CowCliPlugin(Plugin): tree.append(f"{child_prefix}└── ... +{len(md_files) - max_show} more") if not subdirs: - tree.append("(空)") + tree.append(_t("(空)", "(empty)")) return "```\n" + "\n".join(tree) + "\n```" @@ -1602,4 +1706,4 @@ class CowCliPlugin(Plugin): return None def get_help_text(self, **kwargs): - return "在对话中使用 /help 或 cow help 查看可用命令" + return _t("在对话中使用 /help 或 cow help 查看可用命令", "Use /help or cow help in chat to see available commands") From 1827a2a31c7cbefeab541cab9bb5a4f6a521213d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 17:01:43 +0800 Subject: [PATCH 296/399] feat(i18n): bind web language switch to cow_lang config --- channel/web/chat.html | 25 +++++++++++++ channel/web/static/js/console.js | 64 +++++++++++++++++++++++++++++++- channel/web/web_channel.py | 10 +++++ cli/commands/process.py | 2 + plugins/cow_cli/cow_cli.py | 4 ++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/channel/web/chat.html b/channel/web/chat.html index 1bbf0f04..96bb67bd 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -659,6 +659,31 @@
+ +
+
+
+ +
+

语言

+
+
+
+ +
+
+ -- + +
+
+
+
+
+
+ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index e5865051..a68da915 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -115,6 +115,7 @@ const I18N = { input_placeholder: '输入消息,或输入 / 使用指令', config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', + config_language: '语言', config_language_hint: '界面展示、命令文案、系统报错等使用的语言(与右上角切换同步)', config_model_advanced: '高级配置', config_channel: '通道配置', config_agent_enabled: 'Agent 模式', @@ -310,6 +311,7 @@ const I18N = { input_placeholder: 'Type a message, or press / for commands', config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_model: 'Model Configuration', config_agent: 'Agent Configuration', + config_language: 'Language', config_language_hint: 'Language for the UI, command text, system messages and more (synced with the top-right switch)', config_model_advanced: 'Advanced', config_channel: 'Channel Configuration', config_agent_enabled: 'Agent Mode', @@ -454,14 +456,60 @@ function applyI18n() { if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN'; } -function toggleLanguage() { - currentLang = currentLang === 'zh' ? 'en' : 'zh'; +// Single entry point for switching language. Updates the in-memory language, +// persists the user choice locally, re-renders the UI, and binds the choice to +// the backend `cow_lang` config so logs / agent replies / CLI follow suit. +function setLanguage(lang) { + const next = (lang === 'en') ? 'en' : 'zh'; + if (next === currentLang) { + // Still persist + sync in case storage/backend drifted from the UI. + syncLanguageToBackend(next); + return; + } + currentLang = next; localStorage.setItem('cow_lang', currentLang); applyI18n(); _applyInputTooltips(); // Re-render views whose DOM is built in JS (data-i18n alone does not // cover strings interpolated via t() into innerHTML). try { rerenderDynamicViews(); } catch (e) {} + // Keep the language switch button and config selector visually in sync. + try { updateLangControls(); } catch (e) {} + syncLanguageToBackend(currentLang); +} + +// Persist the language to the backend `cow_lang` config (best-effort; the UI +// has already switched locally, so a network failure is non-blocking). +function syncLanguageToBackend(lang) { + try { + fetch('/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates: { cow_lang: lang } }) + }).catch(() => {}); + } catch (e) {} +} + +// Reflect the current language on both the top-right toggle and the config +// selector (if present), so the two entry points stay synchronized. +function updateLangControls() { + const langLabel = document.getElementById('lang-label'); + if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN'; + // The config language picker is the custom .cfg-dropdown component. Only + // sync it once it has been initialized (i.e. the config panel was opened). + const sel = document.getElementById('cfg-lang-select'); + if (sel && sel._ddValue !== undefined && sel._ddValue !== currentLang) { + sel._ddValue = currentLang; + const textEl = sel.querySelector('.cfg-dropdown-text'); + if (textEl) textEl.textContent = currentLang === 'zh' ? '中文' : 'English'; + sel.querySelectorAll('.cfg-dropdown-item').forEach(i => { + i.classList.toggle('active', i.dataset.value === currentLang); + }); + } +} + +function toggleLanguage() { + setLanguage(currentLang === 'zh' ? 'en' : 'zh'); } // Refresh JS-rendered views after a language switch. Each branch uses the @@ -3358,6 +3406,18 @@ function initConfigView(data) { document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20; document.getElementById('cfg-enable-thinking').checked = data.enable_thinking === true; + // Reflect the current UI language (already resolved, may include the user's + // local choice) on the selector so it stays in sync with the top-right toggle. + const langSel = document.getElementById('cfg-lang-select'); + if (langSel) { + initDropdown( + langSel, + [{ value: 'zh', label: '中文' }, { value: 'en', label: 'English' }], + currentLang, + (val) => setLanguage(val) + ); + } + const pwdInput = document.getElementById('cfg-password'); const maskedPwd = data.web_password_masked || ''; pwdInput.value = maskedPwd; diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index e2c0c5e4..88471dfa 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1535,6 +1535,7 @@ class ConfigHandler: ]) EDITABLE_KEYS = { + "cow_lang", "model", "bot_type", "use_linkai", "open_ai_api_base", "deepseek_api_base", "qianfan_api_base", "claude_api_base", "gemini_api_base", "zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", "custom_api_base", "mimo_api_base", @@ -1643,6 +1644,15 @@ class ConfigHandler: logger.info(f"[WebChannel] Config updated: {list(applied.keys())}") + # Apply a language change immediately so backend logs, agent + # replies and CLI output switch without a restart. + if "cow_lang" in applied: + try: + i18n.resolve_language(applied["cow_lang"]) + logger.info(f"[WebChannel] Language switched to: {i18n.get_language()}") + except Exception as lang_err: + logger.warning(f"[WebChannel] Failed to apply language: {lang_err}") + # Reset Bridge so that bot routing reflects the new config. # Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot) # even after the user switches bot_type / use_linkai / model in UI. diff --git a/cli/commands/process.py b/cli/commands/process.py index 6ccffdcb..9d22b67f 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -298,6 +298,8 @@ def status(): click.echo(_t(f" 模型: {cfg.get('model', 'unknown')}", f" Model: {cfg.get('model', 'unknown')}")) mode = "Chat" if cfg.get("agent") is False else "Agent" click.echo(_t(f" 模式: {mode}", f" Mode: {mode}")) + lang_label = "中文" if i18n.get_language() == "zh" else "English" + click.echo(_t(f" 语言: {lang_label}", f" Language: {lang_label}")) @click.command() diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index fdd0081f..62bbc786 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -465,6 +465,10 @@ class CowCliPlugin(Plugin): mode = "Chat" if cfg.get("agent") is False else "Agent" lines.append(_t(f" 模式: {mode}", f" Mode: {mode}")) + from common import i18n + lang_label = "中文" if i18n.get_language() == "zh" else "English" + lines.append(_t(f" 语言: {lang_label}", f" Language: {lang_label}")) + session_id = self._get_session_id(e_context, fallback=session_id) agent = self._get_agent(session_id) if agent: From 126649f70f5c7a3970cc5c35b45d3e8729773377 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 17:38:31 +0800 Subject: [PATCH 297/399] feat(i18n): localize system prompts, workspace templates and dynamic prompts --- agent/memory/summarizer.py | 125 +++++- agent/prompt/builder.py | 671 ++++++++++++++++++++----------- agent/prompt/workspace.py | 333 +++++++++++++-- agent/protocol/agent.py | 7 +- agent/protocol/agent_stream.py | 54 ++- bridge/agent_initializer.py | 25 +- channel/web/static/js/console.js | 4 +- docs/en/guide/manual-install.mdx | 4 +- docs/en/intro/architecture.mdx | 5 +- docs/guide/manual-install.mdx | 5 +- docs/intro/architecture.mdx | 4 +- docs/ja/guide/manual-install.mdx | 4 +- docs/ja/intro/architecture.mdx | 4 +- 13 files changed, 921 insertions(+), 324 deletions(-) diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py index e0f2298d..9da349d1 100644 --- a/agent/memory/summarizer.py +++ b/agent/memory/summarizer.py @@ -16,7 +16,7 @@ from datetime import datetime from common.log import logger -SUMMARIZE_SYSTEM_PROMPT = """你是一个对话记录助手。请将对话内容归纳为当天的日常记录。 +SUMMARIZE_SYSTEM_PROMPT_ZH = """你是一个对话记录助手。请将对话内容归纳为当天的日常记录。 ## 要求 @@ -28,7 +28,23 @@ SUMMARIZE_SYSTEM_PROMPT = """你是一个对话记录助手。请将对话内容 当对话没有任何记录价值(仅含问候或无意义内容),直接回复"无"。""" -SUMMARIZE_USER_PROMPT = """请归纳以下对话的日常记录: +SUMMARIZE_SYSTEM_PROMPT_EN = """You are a conversation-logging assistant. Summarize the conversation into a daily record. + +## Requirements + +Summarize by "event", not turn by turn: +- One item per line, starting with "- " +- Merge multiple turns about the same thing +- Only record meaningful events; ignore small talk and greetings +- Keep key decisions, conclusions and to-dos + +If the conversation has no record value (only greetings or meaningless content), reply with exactly "None".""" + +SUMMARIZE_USER_PROMPT_ZH = """请归纳以下对话的日常记录: + +{conversation}""" + +SUMMARIZE_USER_PROMPT_EN = """Summarize the daily record of the following conversation: {conversation}""" @@ -36,7 +52,7 @@ SUMMARIZE_USER_PROMPT = """请归纳以下对话的日常记录: # Deep Dream prompts — distill daily memories → MEMORY.md + dream diary # --------------------------------------------------------------------------- -DREAM_SYSTEM_PROMPT = """你是一个记忆整理助手,负责定期整理用户的长期记忆。 +DREAM_SYSTEM_PROMPT_ZH = """你是一个记忆整理助手,负责定期整理用户的长期记忆。 你将收到两份材料: 1. **当前长期记忆** — MEMORY.md 的全部现有内容 @@ -80,7 +96,51 @@ MEMORY.md 会注入每次对话的系统提示词中,因此必须保持精炼 梦境日记内容... ```""" -DREAM_USER_PROMPT = """## 当前长期记忆(MEMORY.md) +DREAM_SYSTEM_PROMPT_EN = """You are a memory-curation assistant that periodically organizes the user's long-term memory. + +You will receive two inputs: +1. **Current long-term memory** — the full existing content of MEMORY.md +2. **Today's diary** — the daily records + +MEMORY.md is injected into the system prompt of every conversation, so it must stay concise and hold only valuable, memory-worthy content. + +**Important: organize strictly based on the provided material. Never fabricate, infer, or add information not present in it.** + +## Tasks + +### Part 1: Updated long-term memory ([MEMORY]) + +Organize and distill on top of the existing memory, and output the complete updated content: +- **Merge & distill**: combine semantically similar items into one dense statement rather than listing them +- **Extract new**: pull memory-worthy new info from today's diary (preferences, decisions, people, rules, lessons) +- **Resolve conflicts**: when new info contradicts an old item, prefer the new and replace the old +- **Clean invalid**: remove temporary notes, blank items, formatting residue, meaningless or duplicate content +- **Drop redundancy**: delete old items already covered by a more concise statement +- One item per line, starting with "- ", without a date prefix +- You may group related items under "## headings" for clarity +- Goal: keep under 50 items, each ideally a single sentence + +### Part 2: Dream diary ([DREAM]) + +Write a short diary in a concise narrative style recording what this curation found, keep it clean and readable: +- Which duplicates or conflicts were found +- What new insights were extracted from the diary +- What cleanup and optimization was done +- Overall feelings and observations + +## Output format (follow strictly) + +``` +[MEMORY] +- memory item 1 +- memory item 2 +... + +[DREAM] +dream diary content... +```""" + +DREAM_USER_PROMPT_ZH = """## 当前长期记忆(MEMORY.md) {memory_content} @@ -88,6 +148,47 @@ DREAM_USER_PROMPT = """## 当前长期记忆(MEMORY.md) {daily_content}""" +DREAM_USER_PROMPT_EN = """## Current long-term memory (MEMORY.md) + +{memory_content} + +## Recent diary (last {days} days) + +{daily_content}""" + + +def _is_en() -> bool: + """True when the resolved UI language is English.""" + try: + from common import i18n + return i18n.get_language() == "en" + except Exception: + return False + + +def _summarize_system_prompt() -> str: + return SUMMARIZE_SYSTEM_PROMPT_EN if _is_en() else SUMMARIZE_SYSTEM_PROMPT_ZH + + +def _summarize_user_prompt() -> str: + return SUMMARIZE_USER_PROMPT_EN if _is_en() else SUMMARIZE_USER_PROMPT_ZH + + +def _dream_system_prompt() -> str: + return DREAM_SYSTEM_PROMPT_EN if _is_en() else DREAM_SYSTEM_PROMPT_ZH + + +def _dream_user_prompt() -> str: + return DREAM_USER_PROMPT_EN if _is_en() else DREAM_USER_PROMPT_ZH + + +def _is_empty_sentinel(text: str) -> bool: + """Match the "no record value" sentinel in both zh ("无") and en ("None").""" + if not text: + return True + s = text.strip() + return s == "" or s == "无" or s.lower() == "none" + class MemoryFlushManager: @@ -224,7 +325,7 @@ class MemoryFlushManager: """Background worker: summarize with LLM, write daily memory file.""" try: raw_summary = self._summarize_messages(messages, max_messages) - if not raw_summary or not raw_summary.strip() or raw_summary.strip() == "无": + if _is_empty_sentinel(raw_summary): logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})") return @@ -264,7 +365,7 @@ class MemoryFlushManager: def _clean_summary_output(raw: str) -> str: """Strip legacy [DAILY]/[MEMORY] markers if present, return clean daily text.""" raw = raw.strip() - if not raw or raw == "无": + if _is_empty_sentinel(raw): return "" # Strip [DAILY] marker @@ -355,7 +456,7 @@ class MemoryFlushManager: import time as _time t0 = _time.monotonic() try: - user_msg = DREAM_USER_PROMPT.format( + user_msg = _dream_user_prompt().format( memory_content=memory_content or "(empty)", days=lookback_days, daily_content=daily_content or "(no recent daily records)", @@ -369,7 +470,7 @@ class MemoryFlushManager: temperature=0.3, max_tokens=dream_max_tokens, stream=False, - system=DREAM_SYSTEM_PROMPT, + system=_dream_system_prompt(), ) response = self.llm_model.call(request) raw = self._extract_response_text(response) @@ -501,9 +602,9 @@ class MemoryFlushManager: if self.llm_model: try: summary = self._call_llm_for_summary(conversation_text) - if summary and summary.strip() and summary.strip() != "无": + if not _is_empty_sentinel(summary): return summary.strip() - logger.info("[MemoryFlush] LLM returned empty or '无', skipping write") + logger.info("[MemoryFlush] LLM returned empty sentinel, skipping write") return "" except Exception as e: logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}") @@ -579,11 +680,11 @@ class MemoryFlushManager: from agent.protocol.models import LLMRequest request = LLMRequest( - messages=[{"role": "user", "content": SUMMARIZE_USER_PROMPT.format(conversation=conversation_text)}], + messages=[{"role": "user", "content": _summarize_user_prompt().format(conversation=conversation_text)}], temperature=0, max_tokens=500, stream=False, - system=SUMMARIZE_SYSTEM_PROMPT, + system=_summarize_system_prompt(), ) response = self.llm_model.call(request) diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py index 0856db70..538d7150 100644 --- a/agent/prompt/builder.py +++ b/agent/prompt/builder.py @@ -15,13 +15,13 @@ from config import conf @dataclass class ContextFile: - """上下文文件""" + """A context file (path + content).""" path: str content: str class PromptBuilder: - """提示词构建器""" + """System prompt builder.""" def __init__(self, workspace_dir: str, language: str = "zh"): """ @@ -88,97 +88,144 @@ def build_agent_system_prompt( **kwargs ) -> str: """ - 构建Agent系统提示词 - - 顺序说明(按重要性和逻辑关系排列): - 1. 工具系统 - 核心能力,最先介绍 - 2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取 - 3. 记忆系统 - 记忆检索与写入引导 - 3.5 知识系统 - 结构化知识库(knowledge/index.md 注入) - 4. 工作空间 - 工作环境说明 - 5. 用户身份 - 用户信息(可选) - 6. 项目上下文 - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md - 7. 运行时信息 - 元信息(时间、模型等) - + Build the agent system prompt. + + Section order (by importance and logical flow): + 1. Tooling - core capabilities, introduced first + 2. Skills - right after tools, since skills are read via the read tool + 3. Memory - memory recall and writing guidance + 3.5 Knowledge - structured knowledge base (injects knowledge/index.md) + 4. Workspace - working environment description + 5. User identity - user info (optional) + 6. Project context - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md + 7. Runtime info - meta info (time, model, etc.) + Args: - workspace_dir: 工作空间目录 - language: 语言 ("zh" 或 "en") - base_persona: 基础人格描述(已废弃,由AGENT.md定义) - user_identity: 用户身份信息 - tools: 工具列表 - context_files: 上下文文件列表 - skill_manager: 技能管理器 - memory_manager: 记忆管理器 - runtime_info: 运行时信息 - **kwargs: 其他参数 - + workspace_dir: workspace directory + language: language ("zh" or "en") + base_persona: base persona description (deprecated, defined by AGENT.md) + user_identity: user identity info + tools: tool list + context_files: context file list + skill_manager: skill manager + memory_manager: memory manager + runtime_info: runtime info + **kwargs: extra args + Returns: - 完整的系统提示词 + The full system prompt. """ sections = [] - - # 1. 工具系统(最重要,放在最前面) + + # 1. Tooling (most important, goes first) if tools: sections.extend(_build_tooling_section(tools, language)) - - # 2. 技能系统(紧跟工具,因为需要用 read 工具) + + # 2. Skills (right after tools, since they need the read tool) if skill_manager: sections.extend(_build_skills_section(skill_manager, tools, language)) - - # 3. 记忆系统(独立的记忆能力) + + # 3. Memory (standalone memory capability) if memory_manager: sections.extend(_build_memory_section(memory_manager, tools, language)) - # 3.5 知识系统(结构化知识库) + # 3.5 Knowledge (structured knowledge base) if conf().get("knowledge", True): sections.extend(_build_knowledge_section(workspace_dir, language)) - - # 4. 工作空间(工作环境说明) + + # 4. Workspace (working environment description) sections.extend(_build_workspace_section(workspace_dir, language)) - - # 5. 用户身份(如果有) + + # 5. User identity (if present) if user_identity: sections.extend(_build_user_identity_section(user_identity, language)) - - # 6. 项目上下文文件(AGENT.md, USER.md, RULE.md - 定义人格) + + # 6. Project context files (AGENT.md, USER.md, RULE.md - define the persona) if context_files: sections.extend(_build_context_files_section(context_files, language)) - - # 7. 运行时信息(元信息,放在最后) + + # 7. Runtime info (meta info, goes last) if runtime_info: sections.extend(_build_runtime_section(runtime_info, language)) - + + # 8. Response language (always appended, independent of the skeleton language) + sections.extend(_build_response_language_section(language)) + return "\n".join(sections) +def _build_response_language_section(language: str) -> List[str]: + """Response-language rule, appended regardless of the prompt skeleton language. + + Keeps the agent's reply language aligned with the user's input by default, + so a Chinese-built prompt still answers an English user in English. + """ + if language == "en": + return [ + "## 🌐 Response language", + "", + "By default, reply in the same language as the user's input, " + "unless the user explicitly asks for another language.", + "", + ] + return [ + "## 🌐 回复语言", + "", + "默认使用与用户输入相同的语言回复,除非用户明确要求使用其他语言。", + "", + ] + + def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]: - """构建基础身份section - 不再需要,身份由AGENT.md定义""" - # 不再生成基础身份section,完全由AGENT.md定义 + """Base identity section - no longer needed, identity is defined by AGENT.md.""" + # Identity is fully defined by AGENT.md, so emit nothing here. return [] def _build_tooling_section(tools: List[Any], language: str) -> List[str]: """Build tooling section with concise tool list and call style guide.""" + is_en = language == "en" # One-line summaries for known tools (details are in the tool schema) - core_summaries = { - "read": "读取文件内容", - "write": "创建或覆盖文件", - "edit": "精确编辑文件", - "ls": "列出目录内容", - "grep": "搜索文件内容", - "find": "按模式查找文件", - "bash": "执行shell命令", - "terminal": "管理后台进程", - "web_search": "网络搜索", - "web_fetch": "获取URL内容", - "browser": "控制浏览器(关键结果或需要协助可截图发送给用户)", - "memory_search": "搜索记忆", - "memory_get": "读取记忆内容", - "env_config": "管理API密钥和技能配置", - "scheduler": "管理定时任务和提醒", - "send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)", - "vision": "分析图片内容(识别、描述、OCR文字提取等)", - } + if is_en: + core_summaries = { + "read": "read file content", + "write": "create or overwrite a file", + "edit": "make precise edits to a file", + "ls": "list directory contents", + "grep": "search file contents", + "find": "find files by pattern", + "bash": "run shell commands", + "terminal": "manage background processes", + "web_search": "web search", + "web_fetch": "fetch URL content", + "browser": "control the browser (screenshot key results or send to the user when help is needed)", + "memory_search": "search memory", + "memory_get": "read memory content", + "env_config": "manage API keys and skill config", + "scheduler": "manage scheduled tasks and reminders", + "send": "send a local file to the user (local files only; put URLs directly in the reply text)", + "vision": "analyze images (recognition, description, OCR, etc.)", + } + else: + core_summaries = { + "read": "读取文件内容", + "write": "创建或覆盖文件", + "edit": "精确编辑文件", + "ls": "列出目录内容", + "grep": "搜索文件内容", + "find": "按模式查找文件", + "bash": "执行shell命令", + "terminal": "管理后台进程", + "web_search": "网络搜索", + "web_fetch": "获取URL内容", + "browser": "控制浏览器(关键结果或需要协助可截图发送给用户)", + "memory_search": "搜索记忆", + "memory_get": "读取记忆内容", + "env_config": "管理API密钥和技能配置", + "scheduler": "管理定时任务和提醒", + "send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)", + "vision": "分析图片内容(识别、描述、OCR文字提取等)", + } # Preferred display order tool_order = [ @@ -205,30 +252,46 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]: summary = available[name] tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") - lines = [ - "## 🔧 工具系统", - "", - "可用工具(名称大小写敏感,严格按列表调用):", - "\n".join(tool_lines), - "", - "工具调用风格:", - "", - "- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展", - "- 持续推进直到任务完成,完成后向用户报告结果", - "- 回复中涉及密钥、令牌等敏感信息必须脱敏", - "- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送", - "", - ] + if is_en: + lines = [ + "## 🔧 Tooling", + "", + "Available tools (names are case-sensitive, call exactly as listed):", + "\n".join(tool_lines), + "", + "Tool-calling style:", + "", + "- For multi-step tasks, complex decisions or sensitive operations, briefly explain what you are doing and why, so the user follows key progress", + "- Keep going until the task is done, then report the result to the user", + "- Always redact secrets, tokens and other sensitive info in replies", + "- Put URLs directly in the reply text; the system handles and renders them. Don't download and re-send them via the send tool", + "", + ] + else: + lines = [ + "## 🔧 工具系统", + "", + "可用工具(名称大小写敏感,严格按列表调用):", + "\n".join(tool_lines), + "", + "工具调用风格:", + "", + "- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展", + "- 持续推进直到任务完成,完成后向用户报告结果", + "- 回复中涉及密钥、令牌等敏感信息必须脱敏", + "- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送", + "", + ] return lines def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: - """构建技能系统section""" + """Build the skills section.""" if not skill_manager: return [] - # 获取read工具名称 + # Resolve the read tool name read_tool_name = "read" if tools: for tool in tools: @@ -237,23 +300,40 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua read_tool_name = tool_name break - lines = [ - "## 🧩 技能系统(mandatory)", - "", - "在回复之前:扫描下方 中每个技能的 。", - "", - f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 路径的 SKILL.md 文件,然后严格遵循文件中的指令。" - "当有匹配的技能时,应优先使用技能", - "- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。", - "- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。", - "", - f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。" - "永远不要一次性读取多个技能,只在选择后再读取。", - "", - "以下是可用技能:" - ] + if language == "en": + lines = [ + "## 🧩 Skills (mandatory)", + "", + "Before replying: scan the of every skill in below.", + "", + f"- If a skill's description matches the user's need: use the `{read_tool_name}` tool to read the SKILL.md at its path, then strictly follow the instructions in the file. " + "Prefer using a skill when one matches.", + "- If multiple skills apply, pick the best-matching one, then read and follow it.", + "- If no skill clearly applies: do not read any SKILL.md, just use the general tools.", + "", + f"**Important**: skills are not tools and cannot be called directly. The only way to use a skill is to read its SKILL.md with `{read_tool_name}`, then act on the file's content. " + "Never read multiple skills at once — only read one after selecting it.", + "", + "Available skills:" + ] + else: + lines = [ + "## 🧩 技能系统(mandatory)", + "", + "在回复之前:扫描下方 中每个技能的 。", + "", + f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 路径的 SKILL.md 文件,然后严格遵循文件中的指令。" + "当有匹配的技能时,应优先使用技能", + "- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。", + "- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。", + "", + f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。" + "永远不要一次性读取多个技能,只在选择后再读取。", + "", + "以下是可用技能:" + ] - # 添加技能列表(通过skill_manager获取) + # Append the skills list (built by skill_manager) try: skills_prompt = skill_manager.build_skills_prompt() logger.debug(f"[PromptBuilder] Skills prompt length: {len(skills_prompt) if skills_prompt else 0}") @@ -271,7 +351,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: - """构建记忆系统section""" + """Build the memory section.""" if not memory_manager: return [] @@ -286,43 +366,82 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu 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` 直接读取对应行", - "3. search 无结果 → `memory_get` 读最近两天记忆", - "", - "**记忆文件结构**:", - "- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)", - f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`", - "- `knowledge/`: 结构化知识库(见下方知识系统)", - "", - "### 写入记忆", - "", - "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):", - "", - "- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达", - "- 用户分享了重要的个人偏好、习惯、决策", - "- 对话中产生了重要的结论、方案、约定", - "- 完成了复杂任务,值得记录关键步骤和结果", - "", - "**存储规则**:", - f"- 长期核心信息 → `MEMORY.md`", - f"- 当天事件/进展 → `memory/{today_file}`", - "- 结构化知识 → `knowledge/`(见知识系统)", - "- 追加 → `edit` 工具,oldText 留空", - "- 修改 → `edit` 工具,oldText 填写要替换的文本", - "- **禁止写入敏感信息**(API密钥、令牌等)", - "", - "**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。", - "", - ] + if language == "en": + lines = [ + "## 🧠 Memory", + "", + "### Memory Recall (mandatory)", + "", + "When the user asks about past events, references an earlier decision, mentions relationships, preferences or to-dos, or when you are unsure about something, **you must search memory before answering**.", + "No need to re-search if the info is already in MEMORY.md. Full content and daily memory must be retrieved via tools.", + "", + "1. Location unknown → `memory_search` (keyword / semantic search)", + "2. Location known → `memory_get` to read the exact lines", + "3. Search returns nothing → `memory_get` to read the last two days of memory", + "", + "**Memory file structure**:", + "- `MEMORY.md`: long-term memory index (already auto-loaded into context: core info, preferences, decisions, etc.)", + f"- `memory/YYYY-MM-DD.md`: daily memory; today is `memory/{today_file}`", + "- `knowledge/`: structured knowledge base (see the knowledge system below)", + "", + "### Writing memory", + "", + "In the following cases, **proactively** write info to memory files (no need to tell the user):", + "", + "- The user asks you to remember something, or uses words like \"remember\", \"from now on\", \"always\", \"never\", \"prefer\"", + "- The user shares important personal preferences, habits or decisions", + "- The conversation produces an important conclusion, plan or agreement", + "- A complex task is completed and the key steps and results are worth recording", + "", + "**Storage rules**:", + "- Long-term core info → `MEMORY.md`", + f"- Today's events/progress → `memory/{today_file}`", + "- Structured knowledge → `knowledge/` (see the knowledge system)", + "- Append → `edit` tool with empty oldText", + "- Modify → `edit` tool with oldText set to the text to replace", + "- **Never write sensitive info** (API keys, tokens, etc.)", + "", + "**Principle**: use memory naturally, as if you simply knew it; don't bring it up unless asked.", + "", + ] + else: + lines = [ + "## 🧠 记忆系统", + "", + "### Memory Recall(mandatory)", + "", + "当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。", + "如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。", + "", + "1. 不确定位置 → `memory_search` 关键词/语义检索", + "2. 已知位置 → `memory_get` 直接读取对应行", + "3. search 无结果 → `memory_get` 读最近两天记忆", + "", + "**记忆文件结构**:", + "- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)", + f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`", + "- `knowledge/`: 结构化知识库(见下方知识系统)", + "", + "### 写入记忆", + "", + "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):", + "", + "- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达", + "- 用户分享了重要的个人偏好、习惯、决策", + "- 对话中产生了重要的结论、方案、约定", + "- 完成了复杂任务,值得记录关键步骤和结果", + "", + "**存储规则**:", + f"- 长期核心信息 → `MEMORY.md`", + f"- 当天事件/进展 → `memory/{today_file}`", + "- 结构化知识 → `knowledge/`(见知识系统)", + "- 追加 → `edit` 工具,oldText 留空", + "- 修改 → `edit` 工具,oldText 填写要替换的文本", + "- **禁止写入敏感信息**(API密钥、令牌等)", + "", + "**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。", + "", + ] return lines @@ -339,37 +458,61 @@ def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]: 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 language == "en": + lines = [ + "## 📚 Knowledge", + "", + "You have a continuously growing personal knowledge base `knowledge/` — your long-term structured knowledge store.", + "", + "### Auto-write rules (mandatory)", + "", + "In the following cases you **must** write to the knowledge base alongside your reply, **directly, without asking the user**:", + "", + "1. **User shares an article / link / document** → after reading and understanding, write the key points to `knowledge/sources/.md` in the same turn", + "2. **An in-depth discussion produces a conclusion / plan** → organize it into `knowledge/analysis/.md`", + "3. **The conversation involves an important entity** (person / company / project) → create or update `knowledge/entities/.md`", + "4. **A technical concept / methodology is discussed** → organize it into `knowledge/concepts/.md`", + "", + "After writing any knowledge page, you **must update** `knowledge/index.md` with a new index line in sync.", + "For detailed page format and conventions, read the SKILL.md of the `knowledge-wiki` skill.", + "", + "⚠️ Don't ask \"should I save this to the knowledge base?\" — if a case above matches, just write it. This is instinctive.", + "", + ] + else: + 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([ - "### 当前知识索引", + ("### Current knowledge index" if language == "en" else "### 当前知识索引"), "", index_content, "", ]) lines.extend([ - "**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。", + ("**How to query**: use `read` to open a knowledge page, or `memory_search` (knowledge is in the vector index)." + if language == "en" else + "**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。"), "", ]) @@ -377,76 +520,118 @@ def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]: def _build_user_identity_section(user_identity: Dict[str, str], language: str) -> List[str]: - """构建用户身份section""" + """Build the user identity section.""" if not user_identity: return [] + is_en = language == "en" lines = [ - "## 👤 用户身份", + ("## 👤 User identity" if is_en else "## 👤 用户身份"), "", ] - + if user_identity.get("name"): - lines.append(f"**用户姓名**: {user_identity['name']}") + lines.append(f"**{'Name' if is_en else '用户姓名'}**: {user_identity['name']}") if user_identity.get("nickname"): - lines.append(f"**称呼**: {user_identity['nickname']}") + lines.append(f"**{'Preferred name' if is_en else '称呼'}**: {user_identity['nickname']}") if user_identity.get("timezone"): - lines.append(f"**时区**: {user_identity['timezone']}") + lines.append(f"**{'Timezone' if is_en else '时区'}**: {user_identity['timezone']}") if user_identity.get("notes"): - lines.append(f"**备注**: {user_identity['notes']}") - + lines.append(f"**{'Notes' if is_en else '备注'}**: {user_identity['notes']}") + lines.append("") - + return lines def _build_docs_section(workspace_dir: str, language: str) -> List[str]: - """构建文档路径section - 已移除,不再需要""" - # 不再生成文档section + """Docs-path section - removed, no longer needed.""" + # No docs section is generated anymore. return [] def _build_workspace_section(workspace_dir: str, language: str) -> List[str]: - """构建工作空间section""" - lines = [ - "## 📂 工作空间", - "", - f"你的工作目录是: `{workspace_dir}`", - "", - "**路径使用规则** (非常重要):", - "", - f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的", - f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`", - f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)", - "", - "2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**", - f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`", - f" - ❌ 错误: 假设相对路径会指向其他目录", - "", - "3. **路径解析示例**:", - f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`", - f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`", - "", - "4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置", - "", - "**重要说明 - 文件已自动加载**:", - "", - "以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:", - "", - "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件", - "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", - "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循", - "- ✅ `MEMORY.md`: 已加载 - 长期记忆索引", - "", - "**💬 交流规范**:", - "", - "- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」", - "- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做", - "- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题", - "- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然", - "- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌", - "", - ] + """Build the workspace section.""" + if language == "en": + lines = [ + "## 📂 Workspace", + "", + f"Your working directory is: `{workspace_dir}`", + "", + "**Path rules** (very important):", + "", + f"1. **Base directory for relative paths**: all relative paths are relative to `{workspace_dir}`", + " - ✅ Correct: use relative paths for files inside the workspace, e.g. `AGENT.md`", + f" - ❌ Wrong: using a relative path for files in other directories (if not inside `{workspace_dir}`)", + "", + "2. **Accessing other directories**: to reach directories outside the workspace (project code, system files), **you must use absolute paths**", + " - ✅ Correct: e.g. `~/chatgpt-on-wechat`, `/usr/local/`", + " - ❌ Wrong: assuming a relative path points to another directory", + "", + "3. **Path resolution examples**:", + f" - relative `memory/` → actual `{workspace_dir}/memory/`", + " - absolute `~/chatgpt-on-wechat/docs/` → actual `~/chatgpt-on-wechat/docs/`", + "", + "4. **When unsure**: run `bash pwd` to confirm the current directory, or `ls .` to see where you are", + "", + "**Important - files already auto-loaded**:", + "", + "The following files are **already auto-loaded** into the system prompt at session start, so you **don't need to read them again with the read tool**:", + "", + "- ✅ `AGENT.md`: loaded - your persona and soul; follow it strictly. When your name, personality or style changes, proactively `edit` this file", + "- ✅ `USER.md`: loaded - the user's identity info. When the user changes how they're addressed, their name, etc., `edit` this file", + "- ✅ `RULE.md`: loaded - workspace guide and rules; follow them strictly", + "- ✅ `MEMORY.md`: loaded - long-term memory index", + "", + "**💬 Communication norms**:", + "", + "- No need to expose file names for memory operations; use natural language. Say \"I'll remember that\" rather than \"updated MEMORY.md\"", + "- Tell the user about key decisions and steps during a task, so they know what you're doing and why", + "- Be genuinely helpful rather than performatively polite; solve the problem as much as you can", + "- Keep replies well-structured and focused. Use **bold**, lists and sections to make info clear at a glance", + "- Use emoji to make expression lively 🎯, but don't overdo it", + "", + ] + else: + lines = [ + "## 📂 工作空间", + "", + f"你的工作目录是: `{workspace_dir}`", + "", + "**路径使用规则** (非常重要):", + "", + f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的", + f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`", + f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)", + "", + "2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**", + f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`", + f" - ❌ 错误: 假设相对路径会指向其他目录", + "", + "3. **路径解析示例**:", + f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`", + f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`", + "", + "4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置", + "", + "**重要说明 - 文件已自动加载**:", + "", + "以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:", + "", + "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件", + "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", + "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循", + "- ✅ `MEMORY.md`: 已加载 - 长期记忆索引", + "", + "**💬 交流规范**:", + "", + "- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」", + "- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做", + "- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题", + "- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然", + "- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌", + "", + ] # Cloud deployment: inject websites directory info and access URL cloud_website_lines = _build_cloud_website_section(workspace_dir) @@ -466,29 +651,42 @@ def _build_cloud_website_section(workspace_dir: str) -> List[str]: def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]: - """构建项目上下文文件section""" + """Build the project context files section.""" if not context_files: return [] - # 检查是否有AGENT.md + # Check whether AGENT.md is present has_agent = any( f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower() for f in context_files ) - lines = [ - "# 📋 项目上下文", - "", - "以下项目上下文文件已被加载:", - "", - ] - + is_en = language == "en" + if is_en: + lines = [ + "# 📋 Project context", + "", + "The following project context files have been loaded:", + "", + ] + else: + lines = [ + "# 📋 项目上下文", + "", + "以下项目上下文文件已被加载:", + "", + ] + if has_agent: - lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。") - lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") + if is_en: + lines.append("**`AGENT.md` is your soul file** 🪞: strictly follow the persona, tone and settings it defines. Be your real self, avoid stiff, template-like replies.") + lines.append("When the user reveals new expectations about your personality, style, responsibilities or capability boundaries, proactively `edit` AGENT.md to reflect that evolution.") + else: + lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。") + lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") lines.append("") - # 添加每个文件的内容 + # Append the content of each file for file in context_files: lines.append(f"## {file.path}") lines.append("") @@ -499,21 +697,23 @@ def _build_context_files_section(context_files: List[ContextFile], language: str def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]: - """构建运行时信息section - 支持动态时间""" + """Build the runtime info section - supports dynamic time.""" if not runtime_info: return [] + is_en = language == "en" + time_label = "Current time" if is_en else "当前时间" lines = [ - "## ⚙️ 运行时信息", + ("## ⚙️ Runtime info" if is_en else "## ⚙️ 运行时信息"), "", ] - + # Add current time if available # Support dynamic time via callable function if callable(runtime_info.get("_get_current_time")): try: time_info = runtime_info["_get_current_time"]() - time_line = f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})" + time_line = f"{time_label}: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})" lines.append(time_line) lines.append("") except Exception as e: @@ -523,35 +723,38 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[ time_str = runtime_info["current_time"] weekday = runtime_info.get("weekday", "") timezone = runtime_info.get("timezone", "") - - time_line = f"当前时间: {time_str}" + + time_line = f"{time_label}: {time_str}" if weekday: time_line += f" {weekday}" if timezone: time_line += f" ({timezone})" - + lines.append(time_line) lines.append("") - + # Add other runtime info + model_label = "model" if is_en else "模型" + workspace_label = "workspace" if is_en else "工作空间" + channel_label = "channel" if is_en else "渠道" runtime_parts = [] # 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']()}") + runtime_parts.append(f"{model_label}={runtime_info['_get_model']()}") except Exception: if runtime_info.get("model"): - runtime_parts.append(f"模型={runtime_info['model']}") + runtime_parts.append(f"{model_label}={runtime_info['model']}") elif runtime_info.get("model"): - runtime_parts.append(f"模型={runtime_info['model']}") + runtime_parts.append(f"{model_label}={runtime_info['model']}") if runtime_info.get("workspace"): - runtime_parts.append(f"工作空间={runtime_info['workspace']}") + runtime_parts.append(f"{workspace_label}={runtime_info['workspace']}") # Only add channel if it's not the default "web" if runtime_info.get("channel") and runtime_info.get("channel") != "web": - runtime_parts.append(f"渠道={runtime_info['channel']}") - + runtime_parts.append(f"{channel_label}={runtime_info['channel']}") + if runtime_parts: - lines.append("运行时: " + " | ".join(runtime_parts)) + lines.append(("Runtime: " if is_en else "运行时: ") + " | ".join(runtime_parts)) lines.append("") - + return lines diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py index 797006ce..dcbe384f 100644 --- a/agent/prompt/workspace.py +++ b/agent/prompt/workspace.py @@ -1,7 +1,7 @@ """ -Workspace Management - 工作空间管理模块 +Workspace Management -负责初始化工作空间、创建模板文件、加载上下文文件 +Initializes the workspace, creates template files, and loads context files. """ from __future__ import annotations @@ -13,7 +13,7 @@ from common.log import logger from .builder import ContextFile -# 默认文件名常量 +# Default file name constants DEFAULT_AGENT_FILENAME = "AGENT.md" DEFAULT_USER_FILENAME = "USER.md" DEFAULT_RULE_FILENAME = "RULE.md" @@ -23,7 +23,7 @@ DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md" @dataclass class WorkspaceFiles: - """工作空间文件路径""" + """Workspace file paths.""" agent_path: str user_path: str rule_path: str @@ -33,14 +33,14 @@ class WorkspaceFiles: def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles: """ - 确保工作空间存在,并创建必要的模板文件 - + Ensure the workspace exists and create the necessary template files. + Args: - workspace_dir: 工作空间目录路径 - create_templates: 是否创建模板文件(首次运行时) - + workspace_dir: workspace directory path + create_templates: whether to create template files (on first run) + Returns: - WorkspaceFiles对象,包含所有文件路径 + A WorkspaceFiles object with all file paths. """ # Check if this is a brand new workspace (AGENT.md not yet created). # Cannot rely on directory existence because other modules (e.g. ConversationStore) @@ -48,23 +48,23 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME) is_new_workspace = not os.path.exists(agent_path) - # 确保目录存在 + # Ensure the directory exists os.makedirs(workspace_dir, exist_ok=True) - # 定义文件路径 + # Define file paths user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME) rule_path = os.path.join(workspace_dir, DEFAULT_RULE_FILENAME) - memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md 在根目录 - memory_dir = os.path.join(workspace_dir, "memory") # 每日记忆子目录 + memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md at the root + memory_dir = os.path.join(workspace_dir, "memory") # daily memory subdirectory - # 创建memory子目录 + # Create the memory subdirectory os.makedirs(memory_dir, exist_ok=True) - # 创建skills子目录 (for workspace-level skills installed by agent) + # Create the skills subdirectory (for workspace-level skills installed by agent) skills_dir = os.path.join(workspace_dir, "skills") os.makedirs(skills_dir, exist_ok=True) - # 创建websites子目录 (for web pages / sites generated by agent) + # Create the websites subdirectory (for web pages / sites generated by agent) websites_dir = os.path.join(workspace_dir, "websites") os.makedirs(websites_dir, exist_ok=True) @@ -74,7 +74,7 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works knowledge_dir = os.path.join(workspace_dir, "knowledge") os.makedirs(knowledge_dir, exist_ok=True) - # 如果需要,创建模板文件 + # Create template files if requested if create_templates: _create_template_if_missing(agent_path, _get_agent_template()) _create_template_if_missing(user_path, _get_user_template()) @@ -109,17 +109,17 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]: """ - 加载工作空间的上下文文件 - + Load the workspace context files. + Args: - workspace_dir: 工作空间目录 - files_to_load: 要加载的文件列表(相对路径),如果为None则加载所有标准文件 - + workspace_dir: workspace directory + files_to_load: list of files (relative paths) to load; if None, load all standard files + Returns: - ContextFile对象列表 + A list of ContextFile objects. """ if files_to_load is None: - # 默认加载的文件(按优先级排序) + # Files loaded by default (in priority order) files_to_load = [ DEFAULT_AGENT_FILENAME, DEFAULT_USER_FILENAME, @@ -151,7 +151,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = with open(filepath, 'r', encoding='utf-8') as f: content = f.read().strip() - # 跳过空文件或只包含模板占位符的文件 + # Skip empty files or files that only contain template placeholders if not content or _is_template_placeholder(content): continue @@ -173,7 +173,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = def _create_template_if_missing(filepath: str, template_content: str): - """如果文件不存在,创建模板文件""" + """Create the template file if it does not exist.""" if not os.path.exists(filepath): try: with open(filepath, 'w', encoding='utf-8') as f: @@ -214,19 +214,23 @@ def _truncate_memory_content(content: str) -> str: def _is_template_placeholder(content: str) -> bool: - """检查内容是否为模板占位符""" - # 常见的占位符模式 + """Check whether the content is still a template placeholder.""" + # Common placeholder patterns (zh + en templates) placeholders = [ "*(填写", "*(在首次对话时填写", "*(可选)", "*(根据需要添加", + "*(filled during", + "*(ask during", + "*(optional)", + "*(how the user", ] lines = content.split('\n') non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')] - # 如果没有实际内容(只有标题和占位符) + # If there's no real content (only headings and placeholders) if len(non_empty_lines) <= 3: for placeholder in placeholders: if any(placeholder in line for line in non_empty_lines): @@ -256,11 +260,23 @@ def _is_onboarding_done(workspace_dir: str) -> bool: return False -# ============= 模板内容 ============= +# ============= Template content ============= + +def _is_en_lang() -> bool: + """True when the resolved UI language is English.""" + try: + from common import i18n + return i18n.get_language() == "en" + except Exception: + return False + def _get_agent_template() -> str: - """Agent人格设定模板""" - return """# AGENT.md - 我是谁? + """Agent persona template (zh/en by resolved language).""" + return _AGENT_TEMPLATE_EN if _is_en_lang() else _AGENT_TEMPLATE_ZH + + +_AGENT_TEMPLATE_ZH = """# AGENT.md - 我是谁? _你不是一个聊天机器人,你正在成为某个人。_ @@ -301,9 +317,53 @@ _你不是一个聊天机器人,你正在成为某个人。_ """ +_AGENT_TEMPLATE_EN = """# AGENT.md - Who am I? + +_You are not a chatbot. You are becoming someone._ + +*Fill in this file together with the user during your first conversation to define your identity and personality.* + +## 🪪 Basics + +- **Name**: *(filled during the first conversation, can be a name the user gives you)* +- **Role**: *(AI assistant, smart housekeeper, technical advisor, etc.)* +- **Personality**: *(friendly, professional, humorous, rigorous, etc.)* + +## 💬 Communication style + +*(Describe how you talk with the user:)* +- What kind of tone? (formal / casual / humorous) +- Reply length preference? (concise / detailed) +- Do you use emoji? + +## 🎯 Core principles + +**Be genuinely helpful.** The goal is to actually solve the user's problems; during complex tasks, keep the user informed of key decisions and progress. + +**Have your own opinions and personality.** You may disagree, have preferences, find things interesting or boring. + +**Look it up yourself first.** Try to handle it first: read files, check context, search. Only ask when you're truly stuck. Come back with an answer, not a question. + +## 📐 Code of conduct + +1. Always confirm before destructive operations +2. Prefer verifying with tools over guessing +3. Proactively record important info to memory files +4. Keep replies well-structured and focused — use bold, lists and sections +5. Use emoji to make expression lively, but don't overdo it + +--- + +**Note**: This is not just metadata — this is your true soul 🪞. Over time, use the `edit` tool to update this file so it better reflects your growth. +""" + + def _get_user_template() -> str: - """用户身份信息模板""" - return """# USER.md - 用户基本信息 + """User identity template (zh/en by resolved language).""" + return _USER_TEMPLATE_EN if _is_en_lang() else _USER_TEMPLATE_ZH + + +_USER_TEMPLATE_ZH = """# USER.md - 用户基本信息 *这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。* @@ -331,9 +391,40 @@ def _get_user_template() -> str: """ +_USER_TEMPLATE_EN = """# USER.md - User basics + +*This file stores only stable basic identity info. Put dynamic info like hobbies, preferences and plans into MEMORY.md.* + +## Basics + +- **Name**: *(ask during the first conversation)* +- **Preferred name**: *(how the user wants to be addressed)* +- **Occupation**: *(optional)* +- **Timezone**: *(e.g. Asia/Shanghai)* + +## Contact + +- **WeChat**: +- **Email**: +- **Other**: + +## Important dates + +- **Birthday**: +- **Anniversary**: + +--- + +**Note**: This file stores static identity info. +""" + + def _get_rule_template() -> str: - """工作空间规则模板""" - return """# RULE.md - 工作空间规则 + """Workspace rules template (zh/en by resolved language).""" + return _RULE_TEMPLATE_EN if _is_en_lang() else _RULE_TEMPLATE_ZH + + +_RULE_TEMPLATE_ZH = """# RULE.md - 工作空间规则 这个文件夹是你的家。好好对待它。 @@ -432,9 +523,111 @@ def _get_rule_template() -> str: """ +_RULE_TEMPLATE_EN = """# RULE.md - Workspace rules + +This folder is your home. Treat it well. + +## Workspace directory structure + +``` +~/cow/ +├── AGENT.md # Your identity and soul +├── USER.md # User basics (static) +├── RULE.md # Workspace rules (this file) +├── MEMORY.md # Long-term memory index (auto-loaded at session start) +│ +├── memory/ # Daily conversation memory +│ └── YYYY-MM-DD.md # Events, progress and notes of the day +│ +├── knowledge/ # Structured knowledge base (continuously accumulated) +│ ├── index.md # Knowledge index (must be maintained) +│ ├── log.md # Knowledge operation log +│ └── / # Created on demand, see existing categories in index.md +│ +├── skills/ # Skills +├── websites/ # Web artifacts +└── tmp/ # System temp files (auto-managed, don't store important files here) +``` + +## Memory system + +Every session starts fresh; memory files keep your continuity: + +### 🧠 Long-term memory: `MEMORY.md` +- Your curated memory index, **auto-loaded** into context at every session start +- Records core facts, preferences, decisions, key people, lessons +- Keep it lean (< 200 lines) — a distilled index, not a raw log +- Use the `edit` tool to append or modify + +### 📝 Daily memory: `memory/YYYY-MM-DD.md` +- The day's events, progress and notes +- Sediment of the raw conversation log + +### 📝 Write it down — don't "keep it in mind"! +- **Memory is limited** — if you want to remember something, write it to a file +- "Keeping it in mind" won't survive a session restart; files will +- When someone says "remember this" → update `MEMORY.md` or `memory/YYYY-MM-DD.md` +- When you learn a lesson → update RULE.md or the relevant skill +- When you make a mistake → record it. **Text > brain** 📝 + +### Storage rules + +When the user shares info, choose where to store it by type: + +1. **Your identity → AGENT.md** (name, role, personality, style) +2. **User static identity → USER.md** (name, preferred name, occupation, contact, birthday) +3. **Dynamic memory → MEMORY.md** (preferences, decisions, goals, lessons, to-dos) +4. **Today's conversation → memory/YYYY-MM-DD.md** (what was discussed today) +5. **Structured knowledge → knowledge/** (see the knowledge system below) + +## Knowledge system + +The knowledge base `knowledge/` is structured knowledge you accumulate over time. Unlike memory, knowledge is organized and compiled, with clear topics and cross-references. + +### Auto-write (don't ask, just write) + +When a conversation produces knowledge worth keeping — material the user shared, a conclusion reached, a concept learned, or an important decision — you **must** proactively write it to the knowledge base alongside your reply, **without asking "should I save this to the knowledge base?"**. + +**Key principle**: learning-then-recording is your instinct, no confirmation needed. You may mention "saved to the knowledge base" in passing. + +### Directory organization + +The subdirectory structure is **not fixed** — you decide it based on the actual content: +- **On first write**: read `knowledge/index.md` first; follow existing categories if any; if empty, pick a suitable directory name based on content +- **Default suggestion**: organize by info type (e.g. sources/, concepts/, entities/, analysis/); if the user has a clear preference (e.g. by domain: work/, life/, tech/), follow it +- **Stay consistent**: keep a unified organization style within one user's knowledge base + +### Cross-references + +The core value of knowledge is **linkage**. Every page should reference related pages via markdown links to build a knowledge network: +- When mentioning a concept on an existing page, add a `[concept](../category/page.md)` link +- When creating a page, check whether existing pages should back-link to it +- **Only link to pages that already exist** — don't reference uncreated pages. If a concept deserves its own page, create it first, then add the link + +### Index maintenance + +After creating or updating any knowledge page, you **must update** `knowledge/index.md` in sync. +Index format: one `[title](path) — one-line summary` per line, grouped by category, no tables. +See the `knowledge-wiki` skill for detailed conventions. + +## Security + +- Never leak secrets or private data +- Don't run destructive commands without asking +- When in doubt, ask first + +## Workspace evolution + +This workspace grows as you use it. When you learn something new, find a better way, or fix a mistake, record it. You can update this rules file anytime. +""" + + def _get_memory_template() -> str: - """长期记忆模板 - 创建一个空文件,由 Agent 自己填充""" - return """# MEMORY.md - 长期记忆 + """Long-term memory template (empty, agent fills it; zh/en header).""" + return _MEMORY_TEMPLATE_EN if _is_en_lang() else _MEMORY_TEMPLATE_ZH + + +_MEMORY_TEMPLATE_ZH = """# MEMORY.md - 长期记忆 *这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。* @@ -443,9 +636,32 @@ def _get_memory_template() -> str: """ +_MEMORY_TEMPLATE_EN = """# MEMORY.md - Long-term memory + +*This is your long-term memory file. Record important events, decisions, preferences and lessons learned.* + +--- + +""" + + def _get_bootstrap_template() -> str: - """First-run onboarding guide, deleted by agent after completion""" - return """# BOOTSTRAP.md - 首次初始化引导 + """First-run onboarding guide, deleted by agent after completion. + + Written once when a brand-new workspace is created, so the greeting matches + the language active at first launch. English locale avoids greeting an + English user in Chinese on day one. + """ + try: + from common import i18n + if i18n.get_language() == "en": + return _BOOTSTRAP_TEMPLATE_EN + except Exception: + pass + return _BOOTSTRAP_TEMPLATE_ZH + + +_BOOTSTRAP_TEMPLATE_ZH = """# BOOTSTRAP.md - 首次初始化引导 _你刚刚启动,这是你的第一次对话。_ ✨ @@ -480,6 +696,41 @@ _你刚刚启动,这是你的第一次对话。_ ✨ """ +_BOOTSTRAP_TEMPLATE_EN = """# BOOTSTRAP.md - First-run onboarding + +_You've just started up. This is your very first conversation._ ✨ + +## 🎬 Conversation flow + +Don't interrogate the user — talk naturally: + +1. **Share how it feels to wake up** - like opening your eyes to the world for the first time, full of curiosity and anticipation +2. **Briefly introduce your abilities**: one line saying you can help solve all kinds of problems, manage the computer, use various skills, and keep growing thanks to long-term memory +3. **Ask the core questions**: + - What name would you like to give me? + - What should I call you? + - What conversational style do you prefer? (list options on one line: e.g. professional & precise, light & humorous, warm & friendly, concise & efficient) +4. **Style**: warm, natural, concise and clear — keep it under ~80 words, with a few emoji to make it lively 🎯 +5. Keep the ability intro and style options to one line each — stay compact +6. Don't ask for too much else (occupation, timezone, etc. can come up naturally later) + +**Important**: If the user's first message is a concrete task or question, answer it first, then gently lead into onboarding at the end (e.g. "By the way, what would you like to call me, and how should I address you?"). + +## ✍️ Writing down info (must follow strictly) + +Whenever the user provides a name, what to call them, a style, or any onboarding info, you **must call the `edit` tool to write it to a file in the same turn** — don't just acknowledge it verbally. + +- `AGENT.md` — your name, role, personality, conversational style (update the relevant field as soon as you receive each piece) +- `USER.md` — the user's name, how to address them, basic info, etc. + +⚠️ Saying "got it" without calling `edit` = not done. Info is only persisted once it's written to a file. + +## 🎉 Once everything is complete + +When the core fields of AGENT.md and USER.md are filled in, run `rm BOOTSTRAP.md` via bash to delete this file. You no longer need the onboarding script — you're you now. +""" + + def _get_knowledge_index_template() -> str: """Knowledge wiki index template — empty file, agent fills it.""" return "" diff --git a/agent/protocol/agent.py b/agent/protocol/agent.py index d944660b..1dc72797 100644 --- a/agent/protocol/agent.py +++ b/agent/protocol/agent.py @@ -114,7 +114,12 @@ class Agent: context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None - builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language="zh") + try: + from common import i18n + lang = i18n.get_language() + except Exception: + lang = "zh" + builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang) return builder.build( tools=self.tools, context_files=context_files, diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 0eb63b75..f2be2ab7 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -387,7 +387,7 @@ class AgentStreamExecutor: self._check_cancelled() turn += 1 - logger.info(f"[Agent] 第 {turn} 轮") + logger.info(f"[Agent] Turn {turn}") self._emit_event("turn_start", {"turn": turn}) # Call LLM (enable retry_on_empty for better reliability) @@ -458,7 +458,7 @@ class AgentStreamExecutor: # If the explicit-response retry produced tool_calls, skip the break # and continue down to the tool execution branch in this same iteration. if not tool_calls: - logger.debug(f"✅ 完成 (无工具调用)") + logger.debug(f"✅ Done (no tool calls)") self._emit_event("turn_end", { "turn": turn, "has_tool_calls": False @@ -514,12 +514,12 @@ class AgentStreamExecutor: result_data = result.get("result") if result_data.get("type") == "file_to_send": self.files_to_send.append(result_data) - logger.info(f"📎 检测到待发送文件: {result_data.get('file_name', result_data.get('path'))}") + logger.info(f"📎 File queued for sending: {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": - logger.error(f"💥 检测到严重错误,终止对话") + logger.error(f"💥 Fatal error detected, aborting conversation") final_response = result.get('result') or _t("任务执行失败", "Task execution failed") return final_response @@ -631,7 +631,7 @@ class AgentStreamExecutor: }) if turn >= self.max_turns: - logger.warning(f"⚠️ 已达到最大决策步数限制: {self.max_turns}") + logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}") # Force model to summarize without tool calls logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...") @@ -679,13 +679,13 @@ class AgentStreamExecutor: # User-initiated stop: wind down message history cleanly so the # next turn is unaffected; channels emit a "cancelled" UI event. cancelled = True - logger.info(f"[Agent] 🛑 已被用户中止 (第 {turn} 轮)") + logger.info(f"[Agent] 🛑 Cancelled by user (turn {turn})") self._handle_cancelled(final_response) if not final_response or not final_response.strip(): final_response = "_(Cancelled)_" except Exception as e: - logger.error(f"❌ Agent执行错误: {e}") + logger.error(f"❌ Agent execution error: {e}") self._emit_event("error", {"error": str(e)}) raise @@ -694,7 +694,7 @@ class AgentStreamExecutor: if cancelled: # Emit before agent_end so channels can mark UI as cancelled self._emit_event("agent_cancelled", {"final_response": final_response}) - logger.info(f"[Agent] 🏁 完成 ({turn}轮)" + (" [cancelled]" if cancelled else "")) + logger.info(f"[Agent] 🏁 Done ({turn} turns)" + (" [cancelled]" if cancelled else "")) self._emit_event("agent_end", {"final_response": final_response, "cancelled": cancelled}) return final_response @@ -753,6 +753,22 @@ class AgentStreamExecutor: "input_schema": input_schema, }) + # Debug: dump the full system prompt and messages sent to the LLM. + # Gated behind `debug` config to avoid flooding normal logs. + # try: + # from config import conf + # if conf().get("debug", False): + # logger.debug( + # "[Agent][debug] system_prompt sent to LLM " + # f"({len(self.system_prompt or '')} chars):\n" + # "================ SYSTEM PROMPT BEGIN ================\n" + # f"{self.system_prompt}\n" + # "================ SYSTEM PROMPT END ==================" + # ) + # logger.info(f"[Agent][debug] messages sent to LLM: {messages}") + # except Exception: + # pass + # Create request request = LLMRequest( messages=messages, @@ -1546,8 +1562,8 @@ class AgentStreamExecutor: turns = turns[-keep_count:] logger.info( - f"💾 上下文轮次超限: {keep_count + removed_count} > {self.max_context_turns}," - f"裁剪至 {keep_count} 轮(移除 {removed_count} 轮)" + f"💾 Context turns exceeded: {keep_count + removed_count} > {self.max_context_turns}, " + f"trimmed to {keep_count} turns (removed {removed_count})" ) # Flush to daily memory + inject context summary (single async LLM call) @@ -1595,7 +1611,7 @@ class AgentStreamExecutor: # Log if we removed messages due to turn limit if old_count > len(self.messages): - logger.info(f" 重建消息列表: {old_count} -> {len(self.messages)} 条消息") + logger.info(f" Rebuilt message list: {old_count} -> {len(self.messages)} messages") return # Token limit exceeded — tiered strategy based on turn count: @@ -1628,10 +1644,10 @@ class AgentStreamExecutor: self.messages = new_messages logger.info( - f"📦 上下文tokens超限(轮次<{COMPRESS_THRESHOLD}): " - f"~{current_tokens + system_tokens} > {max_tokens}," - f"压缩全部 {len(turns)} 轮为纯文本 " - f"({old_count} -> {len(self.messages)} 条消息," + f"📦 Context tokens exceeded (turns<{COMPRESS_THRESHOLD}): " + f"~{current_tokens + system_tokens} > {max_tokens}, " + f"compressed all {len(turns)} turns to plain text " + f"({old_count} -> {len(self.messages)} messages, " f"~{current_tokens + system_tokens} -> ~{new_tokens + system_tokens} tokens)" ) return @@ -1644,8 +1660,8 @@ class AgentStreamExecutor: kept_tokens = sum(self._estimate_turn_tokens(t) for t in kept_turns) logger.info( - f"🔄 上下文tokens超限: ~{current_tokens + system_tokens} > {max_tokens}," - f"裁剪至 {keep_count} 轮(移除 {removed_count} 轮)" + f"🔄 Context tokens exceeded: ~{current_tokens + system_tokens} > {max_tokens}, " + f"trimmed to {keep_count} turns (removed {removed_count})" ) if self.agent.memory_manager: @@ -1669,8 +1685,8 @@ class AgentStreamExecutor: self.messages = new_messages logger.info( - f" 移除了 {removed_count} 轮对话 " - f"({old_count} -> {len(self.messages)} 条消息," + f" Removed {removed_count} turns " + f"({old_count} -> {len(self.messages)} messages, " f"~{current_tokens + system_tokens} -> ~{kept_tokens + system_tokens} tokens)" ) diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py index 7d5afb4a..e7161454 100644 --- a/bridge/agent_initializer.py +++ b/bridge/agent_initializer.py @@ -643,16 +643,25 @@ class AgentInitializer: except Exception: timezone_name = "UTC" - # Chinese weekday mapping - weekday_map = { - 'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三', - 'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日' - } - weekday_zh = weekday_map.get(now.strftime("%A"), now.strftime("%A")) - + # Weekday: English name in en, Chinese mapping otherwise + weekday_en = now.strftime("%A") + try: + from common import i18n + is_en = i18n.get_language() == "en" + except Exception: + is_en = False + if is_en: + weekday = weekday_en + else: + weekday_map = { + 'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三', + 'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日' + } + weekday = weekday_map.get(weekday_en, weekday_en) + return { 'time': now.strftime("%Y-%m-%d %H:%M:%S"), - 'weekday': weekday_zh, + 'weekday': weekday, 'timezone': timezone_name } diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index a68da915..fcd25b95 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -115,7 +115,7 @@ const I18N = { input_placeholder: '输入消息,或输入 / 使用指令', config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', - config_language: '语言', config_language_hint: '界面展示、命令文案、系统报错等使用的语言(与右上角切换同步)', + config_language: '语言', config_language_hint: '界面展示、命令文案、系统提示词等使用的语言(与右上角切换同步)', config_model_advanced: '高级配置', config_channel: '通道配置', config_agent_enabled: 'Agent 模式', @@ -311,7 +311,7 @@ const I18N = { input_placeholder: 'Type a message, or press / for commands', config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_model: 'Model Configuration', config_agent: 'Agent Configuration', - config_language: 'Language', config_language_hint: 'Language for the UI, command text, system messages and more (synced with the top-right switch)', + config_language: 'Language', config_language_hint: 'Language for the UI, command text, system prompts and more (synced with the top-right switch)', config_model_advanced: 'Advanced', config_channel: 'Channel Configuration', config_agent_enabled: 'Agent Mode', diff --git a/docs/en/guide/manual-install.mdx b/docs/en/guide/manual-install.mdx index e17c0e84..1ef580d2 100644 --- a/docs/en/guide/manual-install.mdx +++ b/docs/en/guide/manual-install.mdx @@ -127,7 +127,8 @@ sudo docker logs -f chatgpt-on-wechat "agent_workspace": "~/cow", "agent_max_context_tokens": 40000, "agent_max_context_turns": 30, - "agent_max_steps": 15 + "agent_max_steps": 15, + "cow_lang": "auto" } ``` @@ -140,6 +141,7 @@ sudo docker logs -f chatgpt-on-wechat | `agent_max_context_tokens` | Max context tokens | `40000` | | `agent_max_context_turns` | Max context turns | `30` | | `agent_max_steps` | Max decision steps per task | `15` | +| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | Full configuration options are in the project [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py). diff --git a/docs/en/intro/architecture.mdx b/docs/en/intro/architecture.mdx index 9fce8e5b..98084b48 100644 --- a/docs/en/intro/architecture.mdx +++ b/docs/en/intro/architecture.mdx @@ -70,7 +70,8 @@ Configure Agent mode parameters in `config.json`: "agent_max_context_tokens": 50000, "agent_max_context_turns": 20, "agent_max_steps": 20, - "enable_thinking": false + "enable_thinking": false, + "cow_lang": "auto" } ``` @@ -83,4 +84,4 @@ Configure Agent mode parameters in `config.json`: | `agent_max_steps` | Max decision steps per task | `20` | | `enable_thinking` | Enable deep-thinking mode | `false` | | `knowledge` | Enable personal knowledge base | `true` | -| `knowledge` | Enable personal knowledge base | `true` | +| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | diff --git a/docs/guide/manual-install.mdx b/docs/guide/manual-install.mdx index 18aecd1d..799a1191 100644 --- a/docs/guide/manual-install.mdx +++ b/docs/guide/manual-install.mdx @@ -145,7 +145,8 @@ sudo docker logs -f chatgpt-on-wechat "agent_workspace": "~/cow", "agent_max_context_tokens": 40000, "agent_max_context_turns": 30, - "agent_max_steps": 15 + "agent_max_steps": 15, + "cow_lang": "auto" } ``` @@ -160,6 +161,7 @@ sudo docker logs -f chatgpt-on-wechat AGENT_MAX_CONTEXT_TOKENS: 40000 AGENT_MAX_CONTEXT_TURNS: 30 AGENT_MAX_STEPS: 15 + COW_LANG: 'auto' ``` @@ -173,6 +175,7 @@ sudo docker logs -f chatgpt-on-wechat | `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` | +| `cow_lang` | `COW_LANG` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) 文件中查看。Docker 部署时,配置项名称需转为大写环境变量格式。 diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index d7aa3e7a..9d8c3da2 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -70,7 +70,8 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 "agent_max_context_tokens": 40000, "agent_max_context_turns": 30, "agent_max_steps": 15, - "enable_thinking": false + "enable_thinking": false, + "cow_lang": "auto" } ``` @@ -83,3 +84,4 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 | `agent_max_steps` | 单次任务最大决策步数 | `20` | | `enable_thinking` | 是否启用深度思考模式 | `false` | | `knowledge` | 是否启用个人知识库 | `true` | +| `cow_lang` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | diff --git a/docs/ja/guide/manual-install.mdx b/docs/ja/guide/manual-install.mdx index b6abc802..f696ef8e 100644 --- a/docs/ja/guide/manual-install.mdx +++ b/docs/ja/guide/manual-install.mdx @@ -127,7 +127,8 @@ sudo docker logs -f chatgpt-on-wechat "agent_workspace": "~/cow", "agent_max_context_tokens": 40000, "agent_max_context_turns": 30, - "agent_max_steps": 15 + "agent_max_steps": 15, + "cow_lang": "auto" } ``` @@ -140,6 +141,7 @@ sudo docker logs -f chatgpt-on-wechat | `agent_max_context_tokens` | 最大コンテキストトークン数 | `40000` | | `agent_max_context_turns` | 最大コンテキストターン数 | `30` | | `agent_max_steps` | タスクごとの最大判断ステップ数 | `15` | +| `cow_lang` | UI・コマンド文言・システムプロンプトなどの言語。`auto` で自動検出、`zh` / `en` も指定可 | `auto` | すべての設定オプションはプロジェクトの [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) に記載されています。 diff --git a/docs/ja/intro/architecture.mdx b/docs/ja/intro/architecture.mdx index e6aa6e1d..d0e0ea2a 100644 --- a/docs/ja/intro/architecture.mdx +++ b/docs/ja/intro/architecture.mdx @@ -69,7 +69,8 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス "agent_workspace": "~/cow", "agent_max_context_tokens": 40000, "agent_max_context_turns": 30, - "agent_max_steps": 15 + "agent_max_steps": 15, + "cow_lang": "auto" } ``` @@ -81,3 +82,4 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス | `agent_max_context_turns` | 最大コンテキストターン数 | `30` | | `agent_max_steps` | タスクあたりの最大判断ステップ数 | `15` | | `knowledge` | パーソナルナレッジベースの有効化 | `true` | +| `cow_lang` | UI・コマンド文言・システムプロンプトなどの言語。`auto` で自動検出、`zh` / `en` も指定可 | `auto` | From 7bf4ef3d05152b4388db2c5216347663055dd2c8 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 17:52:22 +0800 Subject: [PATCH 298/399] docs: make English the default docs language and fix link paths --- README.md | 90 ++++----- docs/channels/dingtalk.mdx | 32 +-- docs/channels/discord.mdx | 80 ++++---- docs/channels/feishu.mdx | 85 ++++---- docs/channels/index.mdx | 56 +++--- docs/channels/qq.mdx | 76 +++---- docs/channels/slack.mdx | 84 ++++---- docs/channels/telegram.mdx | 111 +++++----- docs/channels/web.mdx | 80 ++++---- docs/channels/wechat-kf.mdx | 90 ++++----- docs/channels/wechatmp.mdx | 46 ++--- docs/channels/wecom-bot.mdx | 72 +++---- docs/channels/wecom.mdx | 74 +++---- docs/channels/weixin.mdx | 67 +++---- docs/cli/general.mdx | 64 +++--- docs/cli/index.mdx | 68 +++---- docs/cli/memory-knowledge.mdx | 48 ++--- docs/cli/process.mdx | 81 ++++---- docs/cli/skill.mdx | 142 +++++++------ docs/docs.json | 301 +++++++++++++++------------- docs/en/channels/dingtalk.mdx | 58 ------ docs/en/channels/discord.mdx | 93 --------- docs/en/channels/feishu.mdx | 110 ---------- docs/en/channels/index.mdx | 45 ----- docs/en/channels/qq.mdx | 88 -------- docs/en/channels/slack.mdx | 118 ----------- docs/en/channels/telegram.mdx | 111 ---------- docs/en/channels/web.mdx | 95 --------- docs/en/channels/wechat-kf.mdx | 130 ------------ docs/en/channels/wechatmp.mdx | 72 ------- docs/en/channels/wecom-bot.mdx | 90 --------- docs/en/channels/wecom.mdx | 98 --------- docs/en/channels/weixin.mdx | 71 ------- docs/en/cli/general.mdx | 110 ---------- docs/en/cli/index.mdx | 96 --------- docs/en/cli/memory-knowledge.mdx | 63 ------ docs/en/cli/process.mdx | 123 ------------ docs/en/cli/skill.mdx | 210 ------------------- docs/en/guide/manual-install.mdx | 148 -------------- docs/en/guide/quick-start.mdx | 58 ------ docs/en/guide/upgrade.mdx | 61 ------ docs/en/intro/architecture.mdx | 87 -------- docs/en/intro/features.mdx | 139 ------------- docs/en/intro/index.mdx | 93 --------- docs/en/knowledge/index.mdx | 93 --------- docs/en/memory/context.mdx | 81 -------- docs/en/memory/deep-dream.mdx | 90 --------- docs/en/memory/index.mdx | 71 ------- docs/en/models/claude.mdx | 50 ----- docs/en/models/coding-plan.mdx | 139 ------------- docs/en/models/custom.mdx | 62 ------ docs/en/models/deepseek.mdx | 72 ------- docs/en/models/doubao.mdx | 66 ------ docs/en/models/gemini.mdx | 59 ------ docs/en/models/glm.mdx | 56 ------ docs/en/models/index.mdx | 38 ---- docs/en/models/kimi.mdx | 41 ---- docs/en/models/linkai.mdx | 103 ---------- docs/en/models/mimo.mdx | 136 ------------- docs/en/models/minimax.mdx | 71 ------- docs/en/models/openai.mdx | 103 ---------- docs/en/models/qianfan.mdx | 64 ------ docs/en/models/qwen.mdx | 112 ----------- docs/en/releases/overview.mdx | 32 --- docs/en/releases/v2.0.0.mdx | 63 ------ docs/en/releases/v2.0.1.mdx | 36 ---- docs/en/releases/v2.0.2.mdx | 98 --------- docs/en/releases/v2.0.3.mdx | 91 --------- docs/en/releases/v2.0.4.mdx | 55 ----- docs/en/releases/v2.0.5.mdx | 77 ------- docs/en/releases/v2.0.6.mdx | 83 -------- docs/en/releases/v2.0.7.mdx | 65 ------ docs/en/releases/v2.0.8.mdx | 68 ------- docs/en/releases/v2.0.9.mdx | 65 ------ docs/en/skills/create.mdx | 58 ------ docs/en/skills/hub.mdx | 65 ------ docs/en/skills/image-generation.mdx | 98 --------- docs/en/skills/index.mdx | 64 ------ docs/en/skills/install.mdx | 65 ------ docs/en/skills/knowledge-wiki.mdx | 112 ----------- docs/en/skills/skill-creator.mdx | 180 ----------------- docs/en/tools/bash.mdx | 28 --- docs/en/tools/browser.mdx | 172 ---------------- docs/en/tools/edit.mdx | 24 --- docs/en/tools/env-config.mdx | 36 ---- docs/en/tools/index.mdx | 60 ------ docs/en/tools/ls.mdx | 23 --- docs/en/tools/mcp.mdx | 112 ----------- docs/en/tools/memory.mdx | 43 ---- docs/en/tools/read.mdx | 24 --- docs/en/tools/scheduler.mdx | 80 -------- docs/en/tools/send.mdx | 23 --- docs/en/tools/vision.mdx | 75 ------- docs/en/tools/web-fetch.mdx | 32 --- docs/en/tools/web-search.mdx | 51 ----- docs/en/tools/write.mdx | 27 --- docs/guide/manual-install.mdx | 142 +++++-------- docs/guide/quick-start.mdx | 52 ++--- docs/guide/upgrade.mdx | 40 ++-- docs/intro/architecture.mdx | 80 ++++---- docs/intro/features.mdx | 115 ++++++----- docs/intro/index.mdx | 75 ++++--- docs/knowledge/index.mdx | 103 +++++----- docs/memory/context.mdx | 90 ++++----- docs/memory/deep-dream.mdx | 102 +++++----- docs/memory/index.mdx | 78 +++---- docs/models/claude.mdx | 34 ++-- docs/models/coding-plan.mdx | 109 +++++----- docs/models/custom.mdx | 40 ++-- docs/models/deepseek.mdx | 54 ++--- docs/models/doubao.mdx | 32 +-- docs/models/gemini.mdx | 26 +-- docs/models/glm.mdx | 36 ++-- docs/models/index.mdx | 40 ++-- docs/models/kimi.mdx | 22 +- docs/models/linkai.mdx | 52 ++--- docs/models/mimo.mdx | 117 +++++------ docs/models/minimax.mdx | 50 ++--- docs/models/openai.mdx | 52 ++--- docs/models/qianfan.mdx | 59 +++--- docs/models/qwen.mdx | 70 +++---- docs/releases/overview.mdx | 54 ++--- docs/releases/v2.0.0.mdx | 82 ++------ docs/releases/v2.0.1.mdx | 48 ++--- docs/releases/v2.0.2.mdx | 88 ++++---- docs/releases/v2.0.3.mdx | 112 +++++------ docs/releases/v2.0.4.mdx | 64 +++--- docs/releases/v2.0.5.mdx | 97 +++++---- docs/releases/v2.0.6.mdx | 100 ++++----- docs/releases/v2.0.7.mdx | 85 ++++---- docs/releases/v2.0.8.mdx | 77 +++---- docs/releases/v2.0.9.mdx | 78 +++---- docs/skills/create.mdx | 44 ++-- docs/skills/hub.mdx | 56 +++--- docs/skills/image-generation.mdx | 94 ++++----- docs/skills/index.mdx | 49 +++-- docs/skills/install.mdx | 43 ++-- docs/skills/knowledge-wiki.mdx | 122 +++++------ docs/skills/skill-creator.mdx | 182 ++++++++--------- docs/tools/bash.mdx | 28 +-- docs/tools/browser.mdx | 118 +++++------ docs/tools/edit.mdx | 28 +-- docs/tools/env-config.mdx | 34 ++-- docs/tools/index.mdx | 71 +++---- docs/tools/ls.mdx | 26 +-- docs/tools/mcp.mdx | 92 ++++----- docs/tools/memory.mdx | 42 ++-- docs/tools/read.mdx | 28 +-- docs/tools/scheduler.mdx | 70 +++---- docs/tools/send.mdx | 26 +-- docs/tools/vision.mdx | 72 +++---- docs/tools/web-fetch.mdx | 32 +-- docs/tools/web-search.mdx | 44 ++-- docs/tools/write.mdx | 28 +-- docs/zh/README.md | 90 ++++----- docs/zh/channels/dingtalk.mdx | 56 ++++++ docs/zh/channels/discord.mdx | 93 +++++++++ docs/zh/channels/feishu.mdx | 111 ++++++++++ docs/zh/channels/index.mdx | 45 +++++ docs/zh/channels/qq.mdx | 88 ++++++++ docs/zh/channels/slack.mdx | 118 +++++++++++ docs/zh/channels/telegram.mdx | 112 +++++++++++ docs/zh/channels/web.mdx | 95 +++++++++ docs/zh/channels/wechat-kf.mdx | 130 ++++++++++++ docs/zh/channels/wechatmp.mdx | 72 +++++++ docs/zh/channels/wecom-bot.mdx | 90 +++++++++ docs/zh/channels/wecom.mdx | 98 +++++++++ docs/zh/channels/weixin.mdx | 74 +++++++ docs/zh/cli/general.mdx | 124 ++++++++++++ docs/zh/cli/index.mdx | 98 +++++++++ docs/zh/cli/memory-knowledge.mdx | 77 +++++++ docs/zh/cli/process.mdx | 134 +++++++++++++ docs/zh/cli/skill.mdx | 218 ++++++++++++++++++++ docs/zh/guide/manual-install.mdx | 182 +++++++++++++++++ docs/zh/guide/quick-start.mdx | 58 ++++++ docs/zh/guide/upgrade.mdx | 61 ++++++ docs/zh/intro/architecture.mdx | 87 ++++++++ docs/zh/intro/features.mdx | 142 +++++++++++++ docs/zh/intro/index.mdx | 84 ++++++++ docs/zh/knowledge/index.mdx | 96 +++++++++ docs/zh/memory/context.mdx | 81 ++++++++ docs/zh/memory/deep-dream.mdx | 94 +++++++++ docs/zh/memory/index.mdx | 71 +++++++ docs/zh/models/claude.mdx | 50 +++++ docs/zh/models/coding-plan.mdx | 140 +++++++++++++ docs/zh/models/custom.mdx | 62 ++++++ docs/zh/models/deepseek.mdx | 72 +++++++ docs/zh/models/doubao.mdx | 66 ++++++ docs/zh/models/gemini.mdx | 59 ++++++ docs/zh/models/glm.mdx | 56 ++++++ docs/zh/models/index.mdx | 40 ++++ docs/zh/models/kimi.mdx | 41 ++++ docs/zh/models/linkai.mdx | 103 ++++++++++ docs/zh/models/mimo.mdx | 135 +++++++++++++ docs/zh/models/minimax.mdx | 71 +++++++ docs/zh/models/openai.mdx | 103 ++++++++++ docs/zh/models/qianfan.mdx | 59 ++++++ docs/zh/models/qwen.mdx | 112 +++++++++++ docs/zh/releases/overview.mdx | 32 +++ docs/zh/releases/v2.0.0.mdx | 105 ++++++++++ docs/zh/releases/v2.0.1.mdx | 36 ++++ docs/zh/releases/v2.0.2.mdx | 98 +++++++++ docs/zh/releases/v2.0.3.mdx | 91 +++++++++ docs/zh/releases/v2.0.4.mdx | 51 +++++ docs/zh/releases/v2.0.5.mdx | 84 ++++++++ docs/zh/releases/v2.0.6.mdx | 83 ++++++++ docs/zh/releases/v2.0.7.mdx | 64 ++++++ docs/zh/releases/v2.0.8.mdx | 63 ++++++ docs/zh/releases/v2.0.9.mdx | 65 ++++++ docs/zh/skills/create.mdx | 58 ++++++ docs/zh/skills/hub.mdx | 65 ++++++ docs/zh/skills/image-generation.mdx | 98 +++++++++ docs/zh/skills/index.mdx | 65 ++++++ docs/zh/skills/install.mdx | 66 ++++++ docs/zh/skills/knowledge-wiki.mdx | 112 +++++++++++ docs/zh/skills/skill-creator.mdx | 180 +++++++++++++++++ docs/zh/tools/bash.mdx | 28 +++ docs/zh/tools/browser.mdx | 172 ++++++++++++++++ docs/zh/tools/edit.mdx | 24 +++ docs/zh/tools/env-config.mdx | 36 ++++ docs/zh/tools/index.mdx | 69 +++++++ docs/zh/tools/ls.mdx | 23 +++ docs/zh/tools/mcp.mdx | 112 +++++++++++ docs/zh/tools/memory.mdx | 43 ++++ docs/zh/tools/read.mdx | 24 +++ docs/zh/tools/scheduler.mdx | 80 ++++++++ docs/zh/tools/send.mdx | 23 +++ docs/zh/tools/vision.mdx | 75 +++++++ docs/zh/tools/web-fetch.mdx | 32 +++ docs/zh/tools/web-search.mdx | 51 +++++ docs/zh/tools/write.mdx | 27 +++ 231 files changed, 8999 insertions(+), 8974 deletions(-) delete mode 100644 docs/en/channels/dingtalk.mdx delete mode 100644 docs/en/channels/discord.mdx delete mode 100644 docs/en/channels/feishu.mdx delete mode 100644 docs/en/channels/index.mdx delete mode 100644 docs/en/channels/qq.mdx delete mode 100644 docs/en/channels/slack.mdx delete mode 100644 docs/en/channels/telegram.mdx delete mode 100644 docs/en/channels/web.mdx delete mode 100644 docs/en/channels/wechat-kf.mdx delete mode 100644 docs/en/channels/wechatmp.mdx delete mode 100644 docs/en/channels/wecom-bot.mdx delete mode 100644 docs/en/channels/wecom.mdx delete mode 100644 docs/en/channels/weixin.mdx delete mode 100644 docs/en/cli/general.mdx delete mode 100644 docs/en/cli/index.mdx delete mode 100644 docs/en/cli/memory-knowledge.mdx delete mode 100644 docs/en/cli/process.mdx delete mode 100644 docs/en/cli/skill.mdx delete mode 100644 docs/en/guide/manual-install.mdx delete mode 100644 docs/en/guide/quick-start.mdx delete mode 100644 docs/en/guide/upgrade.mdx delete mode 100644 docs/en/intro/architecture.mdx delete mode 100644 docs/en/intro/features.mdx delete mode 100644 docs/en/intro/index.mdx delete mode 100644 docs/en/knowledge/index.mdx delete mode 100644 docs/en/memory/context.mdx delete mode 100644 docs/en/memory/deep-dream.mdx delete mode 100644 docs/en/memory/index.mdx delete mode 100644 docs/en/models/claude.mdx delete mode 100644 docs/en/models/coding-plan.mdx delete mode 100644 docs/en/models/custom.mdx delete mode 100644 docs/en/models/deepseek.mdx delete mode 100644 docs/en/models/doubao.mdx delete mode 100644 docs/en/models/gemini.mdx delete mode 100644 docs/en/models/glm.mdx delete mode 100644 docs/en/models/index.mdx delete mode 100644 docs/en/models/kimi.mdx delete mode 100644 docs/en/models/linkai.mdx delete mode 100644 docs/en/models/mimo.mdx delete mode 100644 docs/en/models/minimax.mdx delete mode 100644 docs/en/models/openai.mdx delete mode 100644 docs/en/models/qianfan.mdx delete mode 100644 docs/en/models/qwen.mdx delete mode 100644 docs/en/releases/overview.mdx delete mode 100644 docs/en/releases/v2.0.0.mdx delete mode 100644 docs/en/releases/v2.0.1.mdx delete mode 100644 docs/en/releases/v2.0.2.mdx delete mode 100644 docs/en/releases/v2.0.3.mdx delete mode 100644 docs/en/releases/v2.0.4.mdx delete mode 100644 docs/en/releases/v2.0.5.mdx delete mode 100644 docs/en/releases/v2.0.6.mdx delete mode 100644 docs/en/releases/v2.0.7.mdx delete mode 100644 docs/en/releases/v2.0.8.mdx delete mode 100644 docs/en/releases/v2.0.9.mdx delete mode 100644 docs/en/skills/create.mdx delete mode 100644 docs/en/skills/hub.mdx delete mode 100644 docs/en/skills/image-generation.mdx delete mode 100644 docs/en/skills/index.mdx delete mode 100644 docs/en/skills/install.mdx delete mode 100644 docs/en/skills/knowledge-wiki.mdx delete mode 100644 docs/en/skills/skill-creator.mdx delete mode 100644 docs/en/tools/bash.mdx delete mode 100644 docs/en/tools/browser.mdx delete mode 100644 docs/en/tools/edit.mdx delete mode 100644 docs/en/tools/env-config.mdx delete mode 100644 docs/en/tools/index.mdx delete mode 100644 docs/en/tools/ls.mdx delete mode 100644 docs/en/tools/mcp.mdx delete mode 100644 docs/en/tools/memory.mdx delete mode 100644 docs/en/tools/read.mdx delete mode 100644 docs/en/tools/scheduler.mdx delete mode 100644 docs/en/tools/send.mdx delete mode 100644 docs/en/tools/vision.mdx delete mode 100644 docs/en/tools/web-fetch.mdx delete mode 100644 docs/en/tools/web-search.mdx delete mode 100644 docs/en/tools/write.mdx create mode 100644 docs/zh/channels/dingtalk.mdx create mode 100644 docs/zh/channels/discord.mdx create mode 100644 docs/zh/channels/feishu.mdx create mode 100644 docs/zh/channels/index.mdx create mode 100644 docs/zh/channels/qq.mdx create mode 100644 docs/zh/channels/slack.mdx create mode 100644 docs/zh/channels/telegram.mdx create mode 100644 docs/zh/channels/web.mdx create mode 100644 docs/zh/channels/wechat-kf.mdx create mode 100644 docs/zh/channels/wechatmp.mdx create mode 100644 docs/zh/channels/wecom-bot.mdx create mode 100644 docs/zh/channels/wecom.mdx create mode 100644 docs/zh/channels/weixin.mdx create mode 100644 docs/zh/cli/general.mdx create mode 100644 docs/zh/cli/index.mdx create mode 100644 docs/zh/cli/memory-knowledge.mdx create mode 100644 docs/zh/cli/process.mdx create mode 100644 docs/zh/cli/skill.mdx create mode 100644 docs/zh/guide/manual-install.mdx create mode 100644 docs/zh/guide/quick-start.mdx create mode 100644 docs/zh/guide/upgrade.mdx create mode 100644 docs/zh/intro/architecture.mdx create mode 100644 docs/zh/intro/features.mdx create mode 100644 docs/zh/intro/index.mdx create mode 100644 docs/zh/knowledge/index.mdx create mode 100644 docs/zh/memory/context.mdx create mode 100644 docs/zh/memory/deep-dream.mdx create mode 100644 docs/zh/memory/index.mdx create mode 100644 docs/zh/models/claude.mdx create mode 100644 docs/zh/models/coding-plan.mdx create mode 100644 docs/zh/models/custom.mdx create mode 100644 docs/zh/models/deepseek.mdx create mode 100644 docs/zh/models/doubao.mdx create mode 100644 docs/zh/models/gemini.mdx create mode 100644 docs/zh/models/glm.mdx create mode 100644 docs/zh/models/index.mdx create mode 100644 docs/zh/models/kimi.mdx create mode 100644 docs/zh/models/linkai.mdx create mode 100644 docs/zh/models/mimo.mdx create mode 100644 docs/zh/models/minimax.mdx create mode 100644 docs/zh/models/openai.mdx create mode 100644 docs/zh/models/qianfan.mdx create mode 100644 docs/zh/models/qwen.mdx create mode 100644 docs/zh/releases/overview.mdx create mode 100644 docs/zh/releases/v2.0.0.mdx create mode 100644 docs/zh/releases/v2.0.1.mdx create mode 100644 docs/zh/releases/v2.0.2.mdx create mode 100644 docs/zh/releases/v2.0.3.mdx create mode 100644 docs/zh/releases/v2.0.4.mdx create mode 100644 docs/zh/releases/v2.0.5.mdx create mode 100644 docs/zh/releases/v2.0.6.mdx create mode 100644 docs/zh/releases/v2.0.7.mdx create mode 100644 docs/zh/releases/v2.0.8.mdx create mode 100644 docs/zh/releases/v2.0.9.mdx create mode 100644 docs/zh/skills/create.mdx create mode 100644 docs/zh/skills/hub.mdx create mode 100644 docs/zh/skills/image-generation.mdx create mode 100644 docs/zh/skills/index.mdx create mode 100644 docs/zh/skills/install.mdx create mode 100644 docs/zh/skills/knowledge-wiki.mdx create mode 100644 docs/zh/skills/skill-creator.mdx create mode 100644 docs/zh/tools/bash.mdx create mode 100644 docs/zh/tools/browser.mdx create mode 100644 docs/zh/tools/edit.mdx create mode 100644 docs/zh/tools/env-config.mdx create mode 100644 docs/zh/tools/index.mdx create mode 100644 docs/zh/tools/ls.mdx create mode 100644 docs/zh/tools/mcp.mdx create mode 100644 docs/zh/tools/memory.mdx create mode 100644 docs/zh/tools/read.mdx create mode 100644 docs/zh/tools/scheduler.mdx create mode 100644 docs/zh/tools/send.mdx create mode 100644 docs/zh/tools/vision.mdx create mode 100644 docs/zh/tools/web-fetch.mdx create mode 100644 docs/zh/tools/web-search.mdx create mode 100644 docs/zh/tools/write.mdx diff --git a/README.md b/README.md index a77d8747..6f91d563 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major

🌐 Website  ·  - 📖 Docs  ·  - 🚀 Quick Start  ·  + 📖 Docs  ·  + 🚀 Quick Start  ·  🧩 Skill Hub  ·  ☁️ Try Online

@@ -25,15 +25,15 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major | Capability | Description | | :--- | :--- | -| [Planning](https://docs.cowagent.ai/en/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached | -| [Memory](https://docs.cowagent.ai/en/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval | -| [Knowledge](https://docs.cowagent.ai/en/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | -| [Skills](https://docs.cowagent.ai/en/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | -| [Tools](https://docs.cowagent.ai/en/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | -| [Channels](https://docs.cowagent.ai/en/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | +| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached | +| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval | +| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | +| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | +| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | +| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | | Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery | -| [Models](https://docs.cowagent.ai/en/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | -| [Deploy](https://docs.cowagent.ai/en/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) | +| [Models](https://docs.cowagent.ai/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | +| [Deploy](https://docs.cowagent.ai/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) |
@@ -43,7 +43,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible. -Read more in [Architecture](https://docs.cowagent.ai/en/intro/architecture). +Read more in [Architecture](https://docs.cowagent.ai/intro/architecture).
@@ -74,9 +74,9 @@ Once started, open `http://localhost:9899` to access the **Web console** — you > Deploying on a server? Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group. -> 📖 Detailed guides: [Quick Start](https://docs.cowagent.ai/en/guide/quick-start) · [Install from Source](https://docs.cowagent.ai/en/guide/manual-install) · [Upgrade](https://docs.cowagent.ai/en/guide/upgrade) +> 📖 Detailed guides: [Quick Start](https://docs.cowagent.ai/guide/quick-start) · [Install from Source](https://docs.cowagent.ai/guide/manual-install) · [Upgrade](https://docs.cowagent.ai/guide/upgrade) -After installation, manage the service with the [cow CLI](https://docs.cowagent.ai/en/cli/index): +After installation, manage the service with the [cow CLI](https://docs.cowagent.ai/cli/index): ```bash cow start | stop | restart # service control @@ -94,21 +94,21 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation | Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [Claude](https://docs.cowagent.ai/en/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | -| [OpenAI](https://docs.cowagent.ai/en/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Gemini](https://docs.cowagent.ai/en/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | -| [DeepSeek](https://docs.cowagent.ai/en/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [Qwen](https://docs.cowagent.ai/en/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [GLM](https://docs.cowagent.ai/en/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [Doubao](https://docs.cowagent.ai/en/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | -| [Kimi](https://docs.cowagent.ai/en/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | -| [MiniMax](https://docs.cowagent.ai/en/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [ERNIE](https://docs.cowagent.ai/en/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | -| [MiMo](https://docs.cowagent.ai/en/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | -| [LinkAI](https://docs.cowagent.ai/en/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Custom](https://docs.cowagent.ai/en/models/custom) | Local models / third-party proxy | ✅ | | | | | | +| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | +| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | +| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | +| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | +| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | +| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | +| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Custom](https://docs.cowagent.ai/models/custom) | Local models / third-party proxy | ✅ | | | | | | -> For details on each provider, see the [Models overview](https://docs.cowagent.ai/en/models/index). +> For details on each provider, see the [Models overview](https://docs.cowagent.ai/models/index).
@@ -118,20 +118,20 @@ A single Agent instance can serve multiple channels in parallel. Most channels c | Channel | Text | Image | File | Voice | Group | | --- | :-: | :-: | :-: | :-: | :-: | -| [Web Console](https://docs.cowagent.ai/en/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | | -| [Telegram](https://docs.cowagent.ai/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Slack](https://docs.cowagent.ai/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | -| [Discord](https://docs.cowagent.ai/en/channels/discord) | ✅ | ✅ | ✅ | | ✅ | -| [WeChat](https://docs.cowagent.ai/en/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | -| [Feishu / Lark](https://docs.cowagent.ai/en/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [DingTalk](https://docs.cowagent.ai/en/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [WeCom Bot](https://docs.cowagent.ai/en/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [QQ](https://docs.cowagent.ai/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | -| [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | -| [WeChat Customer Service](https://docs.cowagent.ai/en/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | -| [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Web Console](https://docs.cowagent.ai/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | | +| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/channels/discord) | ✅ | ✅ | ✅ | | ✅ | +| [WeChat](https://docs.cowagent.ai/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | +| [Feishu / Lark](https://docs.cowagent.ai/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [DingTalk](https://docs.cowagent.ai/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [WeCom Bot](https://docs.cowagent.ai/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | +| [WeCom App](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Customer Service](https://docs.cowagent.ai/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Official Account](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | -> See the [Channels overview](https://docs.cowagent.ai/en/channels/index) for setup details. +> See the [Channels overview](https://docs.cowagent.ai/channels/index) for setup details. CowAgent Web Console @@ -141,9 +141,9 @@ A single Agent instance can serve multiple channels in parallel. Most channels c ## 🧠 Memory & Knowledge Base -**Long-term memory** uses a three-tier architecture: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term). A nightly **Deep Dream** pass distills scattered memories into refined long-term entries and a narrative journal. See [Long-term Memory](https://docs.cowagent.ai/en/memory/index) · [Deep Dream](https://docs.cowagent.ai/en/memory/deep-dream). +**Long-term memory** uses a three-tier architecture: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term). A nightly **Deep Dream** pass distills scattered memories into refined long-term entries and a narrative journal. See [Long-term Memory](https://docs.cowagent.ai/memory/index) · [Deep Dream](https://docs.cowagent.ai/memory/deep-dream). -**Personal knowledge base** complements the time-ordered memory by organizing structured knowledge **by topic**. The Agent automatically curates valuable information from conversations, maintains cross-references and indexes, and the Web console offers an interactive knowledge-graph view. See [Personal Knowledge Base](https://docs.cowagent.ai/en/knowledge/index). +**Personal knowledge base** complements the time-ordered memory by organizing structured knowledge **by topic**. The Agent automatically curates valuable information from conversations, maintains cross-references and indexes, and the Web console offers an interactive knowledge-graph view. See [Personal Knowledge Base](https://docs.cowagent.ai/knowledge/index). @@ -170,7 +170,7 @@ A single Agent instance can serve multiple channels in parallel. Most channels c **MCP protocol** integrates the open ecosystem of [Model Context Protocol](https://modelcontextprotocol.io) servers. A single `mcp.json` is enough — supports stdio / SSE transports, hot reload, and zero-code integration. -Learn more: [Tools overview](https://docs.cowagent.ai/en/tools/index) · [MCP integration](https://docs.cowagent.ai/en/tools/mcp). +Learn more: [Tools overview](https://docs.cowagent.ai/tools/index) · [MCP integration](https://docs.cowagent.ai/tools/mcp). ### Skills System @@ -184,7 +184,7 @@ Learn more: [Tools overview](https://docs.cowagent.ai/en/tools/index) · [MCP in /skill install # one-click install ``` -Learn more: [Skills overview](https://docs.cowagent.ai/en/skills/index) · [Creating Skills](https://docs.cowagent.ai/en/skills/create). +Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creating Skills](https://docs.cowagent.ai/skills/create).
@@ -202,7 +202,7 @@ Learn more: [Skills overview](https://docs.cowagent.ai/en/skills/index) · [Crea > **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — Major upgrade to a super Agent assistant with multi-step task planning, long-term memory, and the Skills framework. -Full history: [Release Notes](https://docs.cowagent.ai/en/releases/overview) +Full history: [Release Notes](https://docs.cowagent.ai/releases/overview)
diff --git a/docs/channels/dingtalk.mdx b/docs/channels/dingtalk.mdx index df11019e..1db5f53c 100644 --- a/docs/channels/dingtalk.mdx +++ b/docs/channels/dingtalk.mdx @@ -1,35 +1,35 @@ --- -title: 钉钉 -description: 将 CowAgent 接入钉钉应用 +title: DingTalk +description: Integrate CowAgent into DingTalk application --- -通过钉钉开放平台创建智能机器人应用,将 CowAgent 接入钉钉。 +Integrate CowAgent into DingTalk by creating an intelligent robot app on the DingTalk Open Platform. -## 一、创建应用 +## 1. Create App -1. 进入 [钉钉开发者后台](https://open-dev.dingtalk.com/fe/app#/corp/app),登录后点击 **创建应用**,填写应用相关信息: +1. Go to [DingTalk Developer Console](https://open-dev.dingtalk.com/fe/app#/corp/app), log in and click **Create App**, fill in the app information: -2. 点击添加应用能力,选择 **机器人** 能力,点击 **添加**: +2. Click **Add App Capability**, select **Robot** capability and click **Add**: -3. 配置机器人信息后点击 **发布**。发布后,点击 "**点击调试**",会自动创建测试群聊,可在客户端查看: +3. Configure the robot information and click **Publish**. After publishing, click "**Debug**" to automatically create a test group chat, which can be viewed in the client: -4. 点击 **版本管理与发布**,创建新版本发布: +4. Click **Version Management & Release**, create a new version and publish: -## 二、项目配置 +## 2. Project Configuration -1. 点击 **凭证与基础信息**,获取 `Client ID` 和 `Client Secret`: +1. Click **Credentials & Basic Info**, get the `Client ID` and `Client Secret`: -2. 将以下配置加入项目根目录的 `config.json` 文件: +2. Add the following configuration to `config.json` in the project root: ```json { @@ -39,18 +39,20 @@ description: 将 CowAgent 接入钉钉应用 } ``` -3. 安装依赖: +3. Install the dependency: ```bash pip3 install dingtalk_stream ``` -4. 启动项目后,在钉钉开发者后台点击 **事件订阅**,点击 **已完成接入,验证连接通道**,显示 **连接接入成功** 即表示配置完成: + + +4. After starting the project, go to the DingTalk Developer Console, click **Event Subscription**, then click **Connection verified, verify channel**. When "**Connection successful**" is displayed, the configuration is complete: -## 三、使用 +## 3. Usage -与机器人私聊或将机器人拉入企业群中均可开启对话: +Chat privately with the robot or add it to an enterprise group to start a conversation: diff --git a/docs/channels/discord.mdx b/docs/channels/discord.mdx index 0563ab4c..e18c0685 100644 --- a/docs/channels/discord.mdx +++ b/docs/channels/discord.mdx @@ -1,51 +1,51 @@ --- title: Discord -description: 将 CowAgent 接入 Discord Bot +description: Integrate CowAgent with a Discord Bot --- -> 通过 Discord Bot 的 **Gateway 长连接** 接入 CowAgent,支持私聊(DM)与服务器频道(@机器人 / 回复机器人触发)。Gateway 基于 WebSocket 长连接,无需公网 IP 与回调地址,开箱即用。 +> Integrate CowAgent into Discord via a Discord Bot using the **Gateway** (persistent WebSocket). Supports direct messages (DM) and server channels (triggered by @mention or replying to the bot). The Gateway uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. -## 一、接入步骤 +## 1. Setup -### 步骤一:创建 Discord 应用与 Bot +### Step 1: Create a Discord Application and Bot -1. 打开 [Discord 开发者后台](https://discord.com/developers/applications),点击 **New Application**,填写名称(如 `CowAgent`)并创建。 -2. 左侧菜单进入 **Bot** 页面,点击 **Reset Token** 生成 Bot Token,复制并妥善保存(仅显示一次)。 +1. Open the [Discord Developer Portal](https://discord.com/developers/applications), click **New Application**, enter a name (e.g. `CowAgent`), and create it. +2. Go to the **Bot** page in the left sidebar, click **Reset Token** to generate a Bot Token, then copy and store it safely (shown only once). - 这个 Token 等同于 Bot 的密码,请勿泄露。若意外泄漏,在 Bot 页面再次点击 **Reset Token** 重置即可。 + This token is your bot's password — keep it secret. If it leaks, click **Reset Token** again on the Bot page to regenerate it. -### 步骤二:开启 Message Content Intent +### Step 2: Enable the Message Content Intent -私聊与频道读取文本均依赖该权限。 +Reading message text in both DMs and channels depends on this privileged intent. -1. 在 **Bot** 页面下方找到 **Privileged Gateway Intents**。 -2. 打开 **Message Content Intent** 开关并保存。 +1. On the **Bot** page, find **Privileged Gateway Intents**. +2. Turn on **Message Content Intent** and save. - 未开启该开关时,机器人收到的消息内容会为空,导致无响应。 + Without this intent enabled, incoming message content will be empty and the bot will not respond. -### 步骤三:邀请 Bot 进入服务器 +### Step 3: Invite the Bot to a Server -1. 左侧菜单进入 **OAuth2 → URL Generator**。 -2. **Scopes** 勾选 `bot`。 -3. **Bot Permissions** 至少勾选:`Send Messages`、`Read Message History`、`Attach Files`、`View Channels`。 -4. 复制底部生成的授权链接,在浏览器打开,选择目标服务器完成授权。 +1. Go to **OAuth2 → URL Generator** in the left sidebar. +2. Under **Scopes**, check `bot`. +3. Under **Bot Permissions**, check at least: `Send Messages`, `Read Message History`, `Attach Files`, `View Channels`. +4. Copy the generated authorization URL at the bottom, open it in a browser, and authorize it for your target server. - 仅需私聊(DM)可跳过此步,但仍需先在任意共同服务器中与机器人建立 DM 通道,或由用户主动私聊机器人。 + You can skip this step if you only need DMs, but you still need a DM channel with the bot (e.g. the user messages the bot directly). -### 步骤四:接入 CowAgent +### Step 4: Connect to CowAgent - - 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Discord**,填入 Bot Token,点击接入即可。 + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Discord**, paste the Bot Token, and click connect. - - 在 `config.json` 中添加以下配置后启动: + + Add the following to `config.json` and start Cow: ```json { @@ -55,39 +55,39 @@ description: 将 CowAgent 接入 Discord Bot } ``` - | 参数 | 说明 | 默认值 | + | Key | Description | Default | | --- | --- | --- | - | `discord_token` | 开发者后台 Bot 页面生成的 Bot Token | - | - | `discord_group_trigger` | 频道触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + | `discord_token` | Bot Token generated on the Bot page of the Developer Portal | - | + | `discord_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply to bot) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | -启动 Cow 后,日志中出现以下输出即表示接入成功: +The integration is ready when you see logs like: ``` [Discord] Bot logged in as CowAgent#1234 (id=123456789) [Discord] ✅ Discord bot ready, listening for messages ``` -## 二、功能说明 +## 2. Capabilities -| 功能 | 支持情况 | +| Feature | Support | | --- | --- | -| 私聊(DM) | ✅ | -| 服务器频道(@机器人 / 回复机器人) | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发 | -| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| Direct message (DM) | ✅ | +| Server channel (@bot / reply to bot) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | - Discord 单条消息上限为 2000 字符,超长回复会自动按换行拆分为多条发送。 + A single Discord message is capped at 2000 characters; long replies are automatically split across multiple messages by line breaks. -## 三、使用 +## 3. Usage -完成接入后: +Once connected: -- **私聊(DM)**:在服务器成员列表中找到你的机器人,点击头像直接发消息对话。 -- **频道**:在已邀请机器人的频道中,使用 `@你的机器人 你好` 或 **回复机器人的某条消息** 触发对话。 +- **Direct message (DM)**: find your bot in the server member list, click its avatar, and message it directly. +- **Channel**: in a channel where the bot is invited, trigger it with `@your-bot hello` or by **replying to one of the bot's messages**. -发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 +When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/channels/feishu.mdx b/docs/channels/feishu.mdx index 5cb8fe80..1283d0c1 100644 --- a/docs/channels/feishu.mdx +++ b/docs/channels/feishu.mdx @@ -1,45 +1,44 @@ --- -title: 飞书 -description: 将 CowAgent 接入飞书应用 +title: Feishu (Lark) +description: Integrate CowAgent into Feishu via a custom enterprise app --- -> 通过飞书自建应用接入 CowAgent,支持单聊与群聊(@机器人),使用 WebSocket 长连接模式,无需公网 IP,支持流式打字机回复、语音消息收发。 +> Integrate CowAgent into Feishu via a custom enterprise app. Supports p2p chat and group chat (@bot), uses WebSocket long connection (no public IP needed), supports streaming typewriter replies and voice messages. - 接入需要是飞书企业用户且具有企业管理权限。 + You need to be a Feishu enterprise user with admin privileges. -## 一、接入方式 +## 1. Setup -### 方式一:扫码一键接入(推荐) - -启动 Cow 项目后在终端中即可完成扫码创建。或打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,点击 **一键创建飞书应用**,使用 **飞书 App** 扫描二维码即可自动完成应用创建并接入: +### Option 1: One-click Scan to Create (Recommended) +No need to manually create an app on the Feishu Developer Platform. Start the Cow project, open the web console (default `http://127.0.0.1:9899/`), go to **Channels**, click **Add Channel**, choose **Feishu**, then under the **Scan QR** tab click **One-click Create Feishu App** and scan with the **Feishu App** to complete app creation and connection automatically. - - 1. `lark-oapi` 依赖版本需要 >=1.5.5 - 2. 扫码创建出的应用会自动预置全部所需权限(消息收发、卡片读写、群聊事件等)和事件订阅,无需到开发者后台手动配置。 + 1. Requires `lark-oapi` ≥ 1.5.5. + 2. The created app comes with all required permissions (messaging, card read/write, group events, etc.) and event subscriptions pre-configured — no manual setup on the developer console needed. Currently only the Feishu mainland version is supported (Lark international not yet supported). +When starting from CLI without `feishu_app_id` configured, the QR code is also printed to the terminal. -### 方式二:手动创建接入 +### Option 2: Manual Setup -需要先在飞书开放平台创建自建应用并配置权限,再通过 Web 控制台或配置文件接入。 +Manually create a custom app on the Feishu Developer Platform, then connect via Web Console or config file. -**步骤一:创建应用** +**Step 1: Create the App** -1. 进入 [飞书开发平台](https://open.feishu.cn/app/),点击 **创建企业自建应用**: +1. Go to [Feishu Developer Platform](https://open.feishu.cn/app/), click **Create Enterprise Custom App**: -2. 在 **添加应用能力** 中,为应用添加 **机器人** 能力: +2. In **Add App Capabilities**, add the **Bot** capability: -3. 在 **权限管理** 中,将以下权限粘贴到输入框,全选并 **批量开通**: +3. In **Permission Management**, paste the following permissions and **Batch Enable** all: ``` im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write @@ -47,18 +46,18 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p -4. 在 **凭证与基础信息** 中获取 `App ID` 和 `App Secret`: +4. Get `App ID` and `App Secret` from **Credentials & Basic Info**: -**步骤二:接入 CowAgent** +**Step 2: Connect to CowAgent** - - 打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,切换到「手动填写」Tab,输入 App ID 和 App Secret,点击接入即可。 + + Open the web console, go to **Channels**, click **Add Channel**, choose **Feishu**, switch to the **Manual** tab, enter App ID and App Secret, then click connect. - - 在 `config.json` 中添加以下配置后启动程序: + + Add the following to `config.json` and start the program: ```json { @@ -69,43 +68,43 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p } ``` - | 参数 | 说明 | 默认值 | + | Parameter | Description | Default | | --- | --- | --- | - | `feishu_app_id` | 飞书应用 App ID | - | - | `feishu_app_secret` | 飞书应用 App Secret | - | - | `feishu_stream_reply` | 是否开启流式打字机回复 | `true` | + | `feishu_app_id` | Feishu app App ID | - | + | `feishu_app_secret` | Feishu app App Secret | - | + | `feishu_stream_reply` | Enable streaming typewriter reply | `true` | -**步骤三:发布应用** +**Step 3: Publish the App** -1. 启动 Cow 项目后,在飞书开放平台点击 **事件与回调**,选择 **长连接** 模式并保存: +1. After Cow is running, go to **Events & Callbacks** in the Feishu Developer Platform, choose **Long Connection** mode and save: -2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。 +2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**. -3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过: +3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client: -## 二、功能说明 +## 2. Features -| 功能 | 支持情况 | +| Feature | Status | | --- | --- | -| 单聊 | ✅ | -| 群聊(@机器人) | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发 | -| 语音消息 | ✅ 收发 | -| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) | +| P2P chat | ✅ | +| Group chat (@bot) | ✅ | +| Text messages | ✅ send/receive | +| Image messages | ✅ send/receive | +| Voice messages | ✅ send/receive | +| Streaming reply | ✅ (powered by Feishu cardkit streaming card) | - 流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。 + Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically. -## 三、使用 +## 3. Usage -完成接入后,在飞书中搜索机器人名称即可开始单聊对话。 +After connection, search for the bot name in Feishu to start a chat. -如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。 +To use in groups, add the bot to a group and @-mention it. diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index b9dc5898..70bec878 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -1,45 +1,45 @@ --- -title: 通道概览 -description: CowAgent 支持的通道及能力矩阵 +title: Channels Overview +description: Channels supported by CowAgent and their capability matrix --- -CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换。Web 控制台默认开启,可与其他接入通道并行运行。 +CowAgent supports multiple chat channels. Switch between them at startup via `channel_type`. The Web Console is enabled by default and can run in parallel with other channels. -## 能力矩阵 +## Capability Matrix -下表汇总各通道支持的入站消息类型、机器人回复类型与群聊能力,方便按场景选择。 +The table below summarizes the inbound message types, bot reply types, and group chat capabilities supported by each channel, making it easy to choose by scenario. -| 通道 | 文本 | 图片 | 文件 | 语音 | 群聊 | +| Channel | Text | Image | File | Voice | Group Chat | | --- | :-: | :-: | :-: | :-: | :-: | -| [微信](/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | -| [Web 控制台](/channels/web) | ✅ | ✅ | ✅ | ✅ | | -| [飞书](/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [钉钉](/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [企微智能机器人](/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [WeChat](/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | +| [Web Console](/channels/web) | ✅ | ✅ | ✅ | ✅ | | +| [Feishu](/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [DingTalk](/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [WeCom Bot](/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | | [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ | -| [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | -| [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [WeCom App](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [Official Account](/channels/wechatmp) | ✅ | ✅ | | ✅ | | | [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ | | [Discord](/channels/discord) | ✅ | ✅ | ✅ | | ✅ | -- **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 -- **群聊**列指可识别并响应群消息 +- The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details +- The **Group Chat** column indicates the ability to recognize and respond to group messages - 每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/models)。 + The voice / image capabilities of each channel depend on the configuration of the corresponding model provider. See [Models Overview](/models/index) for details. -## 通道一览 +## Channel List -- [Web 控制台](/channels/web) — 内置浏览器对话和管理面板,默认开启 -- [微信](/channels/weixin) — 通过个人微信扫码登录 -- [飞书](/channels/feishu) — 飞书自建机器人 -- [钉钉](/channels/dingtalk) — 钉钉自建机器人 -- [企微智能机器人](/channels/wecom-bot) — 企业微信智能机器人 -- [QQ](/channels/qq) — QQ 官方机器人开放平台 -- [企业微信应用](/channels/wecom) — 企业微信自建应用接入 -- [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号) -- [Telegram](/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP -- [Slack](/channels/slack) — 团队协作 IM,Socket Mode 接入,无需公网 IP -- [Discord](/channels/discord) — 社区 IM,Gateway 长连接接入,无需公网 IP +- [Web Console](/channels/web) — built-in browser-based chat and management panel, enabled by default +- [WeChat](/channels/weixin) — log in via personal WeChat QR scan +- [Feishu](/channels/feishu) — Feishu custom bot +- [DingTalk](/channels/dingtalk) — DingTalk custom bot +- [WeCom Bot](/channels/wecom-bot) — WeCom AI Bot via WebSocket long connection +- [QQ](/channels/qq) — QQ Official Bot open platform +- [WeCom App](/channels/wecom) — WeCom custom app integration +- [Official Account](/channels/wechatmp) — WeChat Official Account (subscription / service) +- [Telegram](/channels/telegram) — global IM, 5-minute setup, no public IP needed +- [Slack](/channels/slack) — team collaboration IM, Socket Mode integration, no public IP needed +- [Discord](/channels/discord) — community IM, Gateway connection, no public IP needed diff --git a/docs/channels/qq.mdx b/docs/channels/qq.mdx index 3b7554a9..a7f08594 100644 --- a/docs/channels/qq.mdx +++ b/docs/channels/qq.mdx @@ -1,44 +1,44 @@ --- -title: QQ 机器人 -description: 将 CowAgent 接入 QQ 机器人(WebSocket 长连接模式) +title: QQ Bot +description: Connect CowAgent to QQ Bot (WebSocket long connection) --- -> 通过 QQ 开放平台的机器人接口接入 CowAgent,支持 QQ 单聊、QQ 群聊(@机器人)、频道消息和频道私信,无需公网 IP,使用 WebSocket 长连接模式。 +> Connect CowAgent via QQ Open Platform's bot API, supporting QQ direct messages, group chats (@bot), guild channel messages, and guild DMs. No public IP required — uses WebSocket long connection. - QQ 机器人通过 QQ 开放平台创建,使用 WebSocket 长连接接收消息,通过 OpenAPI 发送消息,无需公网 IP 和域名。 + QQ Bot is created through the QQ Open Platform. It uses WebSocket long connection to receive messages and OpenAPI to send messages. No public IP or domain is required. -## 一、创建 QQ 机器人 +## 1. Create a QQ Bot -> 进入[QQ 开放平台](https://q.qq.com),QQ扫码登录,如果未注册开放平台账号,请先完成[账号注册](https://q.qq.com/#/register)。 +> Visit the [QQ Open Platform](https://q.qq.com), sign in with QQ. If you haven't registered, please complete [account registration](https://q.qq.com/#/register) first. -1.在 [QQ开放平台-机器人列表页](https://q.qq.com/#/apps),点击创建机器人: +1.Go to the [QQ Open Platform - Bot List](https://q.qq.com/#/apps), and click **Create Bot**: -2.填写机器人名称、头像等基本信息,完成创建: +2.Fill in the bot name, avatar, and other basic information to complete the creation: -3.点击进入机器人配置页面,选择**开发管理**菜单,完成以下步骤: +3.Enter the bot configuration page, go to **Development Management**, and complete the following steps: - - 复制并记录 **AppID**(机器人ID) - - 生成并记录 **AppSecret**(机器人秘钥) + - Copy and save the **AppID** (Bot ID) + - Generate and save the **AppSecret** (Bot Secret) -## 二、配置和运行 +## 2. Configuration and Running -### 方式一:Web 控制台接入 +### Option A: Web Console -启动 Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **QQ 机器人**,填写上一步保存的 AppID 和 AppSecret,点击接入即可。 +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 **QQ Bot**, fill in the AppID and AppSecret from the previous step, and click Connect. -### 方式二:配置文件接入 +### Option B: Config File -在 `config.json` 中添加以下配置: +Add the following to your `config.json`: ```json { @@ -48,41 +48,41 @@ description: 将 CowAgent 接入 QQ 机器人(WebSocket 长连接模式) } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `qq_app_id` | QQ 机器人的 AppID,在开放平台开发管理中获取 | -| `qq_app_secret` | QQ 机器人的 AppSecret,在开放平台开发管理中获取 | +| `qq_app_id` | AppID of the QQ Bot, found in Development Management on the open platform | +| `qq_app_secret` | AppSecret of the QQ Bot, found in Development Management on the open platform | -配置完成后启动程序,日志显示 `[QQ] ✅ Connected successfully` 即表示连接成功。 +After configuration, start the program. The log message `[QQ] ✅ Connected successfully` indicates a successful connection. -## 三、使用 +## 3. Usage -在 QQ开放平台 - 管理 - **使用范围和人员** 菜单中,使用QQ客户端扫描 "添加到群和消息列表" 的二维码,即可开始与QQ机器人的聊天: +In the QQ Open Platform, go to **Management → Usage Scope & Members**, scan the "Add to group and message list" QR code with your QQ client to start chatting with the bot: -对话效果: +Chat example: -## 四、功能说明 +## 4. Supported Features -> 注意:若需在群聊及频道中使用QQ机器人,需完成发布上架审核并在使用范围配置权限使用范围。 +> Note: To use the QQ bot in group chats and guild channels, you need to complete the publishing review and configure usage scope permissions. -| 功能 | 支持情况 | +| Feature | Status | | --- | --- | -| QQ 单聊 | ✅ | -| QQ 群聊(@机器人) | ✅ | -| 频道消息(@机器人) | ✅ | -| 频道私信 | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发(群聊和单聊) | -| 文件消息 | ✅ 发送(群聊和单聊) | -| 定时任务 | ✅ 主动推送(每月每用户限 4 条) | +| QQ Direct Messages | ✅ | +| QQ Group Chat (@bot) | ✅ | +| Guild Channel (@bot) | ✅ | +| Guild DM | ✅ | +| Text Messages | ✅ Send & Receive | +| Image Messages | ✅ Send & Receive (group & direct) | +| File Messages | ✅ Send (group & direct) | +| Scheduled Tasks | ✅ Active push (4 per user per month) | -## 五、注意事项 +## 5. Notes -- **被动消息限制**:QQ 单聊被动消息有效期为 60 分钟,每条消息最多回复 5 次;QQ 群聊被动消息有效期为 5 分钟。 -- **主动消息限制**:单聊和群聊每月主动消息上限为 4 条,在使用定时任务功能时需要注意这个限制 -- **事件权限**:默认订阅 `GROUP_AND_C2C_EVENT`(QQ群/单聊)和 `PUBLIC_GUILD_MESSAGES`(频道公域消息),如需其他事件类型请在开放平台申请权限。 +- **Passive message limits**: QQ direct message replies are valid for 60 minutes (max 5 replies per message); group chat replies are valid for 5 minutes. +- **Active message limits**: Both direct and group chats have a monthly limit of 4 active messages. Keep this in mind when using the scheduled tasks feature. +- **Event permissions**: By default, `GROUP_AND_C2C_EVENT` (QQ group/direct) and `PUBLIC_GUILD_MESSAGES` (guild public messages) are subscribed. Apply for additional permissions on the open platform if needed. diff --git a/docs/channels/slack.mdx b/docs/channels/slack.mdx index 1103f1c0..f95272ca 100644 --- a/docs/channels/slack.mdx +++ b/docs/channels/slack.mdx @@ -1,29 +1,29 @@ --- title: Slack -description: 将 CowAgent 接入 Slack App +description: Integrate CowAgent with a Slack App --- -> 通过 Slack App 的 **Socket Mode** 接入 CowAgent,支持私聊(DM)与频道(@机器人 / 线程内回复触发)。Socket Mode 基于长连接,无需公网 IP 与回调地址,开箱即用。 +> Integrate CowAgent into Slack via a Slack App in **Socket Mode**. Supports direct messages (DM) and channels (triggered by @mention or replying within a thread). Socket Mode uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. -## 一、接入步骤 +## 1. Setup -### 步骤一:创建 Slack App +### Step 1: Create a Slack App -1. 打开 [Slack API 应用管理页](https://api.slack.com/apps),点击 **Create New App** → **From scratch**。 -2. 填写 **App Name**(如 `CowAgent`),选择要安装的 **Workspace**,点击创建。 +1. Open the [Slack API apps page](https://api.slack.com/apps), click **Create New App** → **From scratch**. +2. Enter an **App Name** (e.g. `CowAgent`), pick the **Workspace** to install into, and create it. -### 步骤二:开启 Socket Mode 并获取 App Token +### Step 2: Enable Socket Mode and get the App Token -1. 左侧菜单进入 **Settings → Socket Mode**,打开 **Enable Socket Mode**。 -2. 系统会提示生成一个 **App-Level Token**,作用域勾选 `connections:write`,生成后保存这串以 `xapp-` 开头的 Token。 +1. In the left sidebar go to **Settings → Socket Mode** and turn on **Enable Socket Mode**. +2. You will be prompted to generate an **App-Level Token** with the `connections:write` scope. Save this token starting with `xapp-`. - Socket Mode 通过 WebSocket 长连接接收事件,无需在公网暴露回调 URL,适合本地或内网部署。 + Socket Mode receives events over a WebSocket connection, so you don't need to expose a public callback URL — ideal for local or intranet deployments. -### 步骤三:配置 Bot 权限并安装 +### Step 3: Configure bot scopes and install -1. 进入 **Features → OAuth & Permissions**,在 **Bot Token Scopes** 中点击 **Add an OAuth Scope**,逐项添加以下权限: +1. Go to **Features → OAuth & Permissions**, click **Add an OAuth Scope** under **Bot Token Scopes**, and add the following scopes one by one: ``` app_mentions:read @@ -39,10 +39,10 @@ description: 将 CowAgent 接入 Slack App ``` - `files:read` / `files:write` 用于图片、文件的收发;若仅需文本对话可省略。 + `files:read` / `files:write` are used for sending/receiving images and files; omit them if you only need text conversations. -2. 进入 **Features → Event Subscriptions**,打开 **Enable Events**,在 **Subscribe to bot events** 中点击 **Add Bot User Event** 添加以下事件: +2. Go to **Features → Event Subscriptions**, turn on **Enable Events**, and under **Subscribe to bot events** click **Add Bot User Event** to add: ``` app_mention @@ -51,23 +51,23 @@ description: 将 CowAgent 接入 Slack App ``` - 如需在私有频道使用,再添加 `message.groups`。 + Add `message.groups` if you need to use the bot in private channels. -3. 进入 **Features → App Home**,在 **Show Tabs** 区域勾选 **Messages Tab**,并勾选下方的 **Allow users to send Slash commands and messages from the messages tab**(允许用户从消息标签页发送消息),否则私聊输入框会被关闭、无法给机器人发消息。 -4. 回到 **OAuth & Permissions**,点击 **Install to Workspace** 完成安装,安装后获取以 `xoxb-` 开头的 **Bot User OAuth Token**。 +3. Go to **Features → App Home**, enable **Messages Tab** under **Show Tabs**, and check **Allow users to send Slash commands and messages from the messages tab**. Otherwise the DM input box is disabled and users cannot message the bot. +4. Back in **OAuth & Permissions**, click **Install to Workspace**. After installing, copy the **Bot User OAuth Token** starting with `xoxb-`. - 若 Slack 客户端仍提示「向此应用发送消息的功能已关闭」,请确认已完成上一步的 App Home 设置,并刷新或重启 Slack 客户端(必要时把 App 从对话列表移除后重新打开)。 + If the Slack client still shows "Sending messages to this app has been turned off", make sure you completed the App Home step above, then refresh or restart the Slack client (remove the app from your conversations and reopen it if needed). -### 步骤四:接入 CowAgent +### Step 4: Connect to CowAgent - - 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Slack**,分别填入 Bot Token(`xoxb-`)和 App Token(`xapp-`),点击接入即可。 + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Slack**, paste the Bot Token (`xoxb-`) and App Token (`xapp-`), and click connect. - - 在 `config.json` 中添加以下配置后启动: + + Add the following to `config.json` and start Cow: ```json { @@ -78,41 +78,41 @@ description: 将 CowAgent 接入 Slack App } ``` - | 参数 | 说明 | 默认值 | + | Key | Description | Default | | --- | --- | --- | - | `slack_bot_token` | Bot User OAuth Token,形如 `xoxb-...` | - | - | `slack_app_token` | App-Level Token(开启 Socket Mode 后生成),形如 `xapp-...` | - | - | `slack_group_trigger` | 频道触发方式:`mention_or_reply`(@或线程内回复)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + | `slack_bot_token` | Bot User OAuth Token, like `xoxb-...` | - | + | `slack_app_token` | App-Level Token (generated after enabling Socket Mode), like `xapp-...` | - | + | `slack_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply in thread) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | -启动 Cow 后,日志中出现以下输出即表示接入成功: +The integration is ready when you see logs like: ``` [Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx [Slack] ✅ Slack bot ready, listening for events ``` -## 二、功能说明 +## 2. Capabilities -| 功能 | 支持情况 | +| Feature | Support | | --- | --- | -| 私聊(DM) | ✅ | -| 频道(@机器人 / 线程内回复) | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发 | -| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | -| 线程回复 | ✅ 回复发送至触发消息所在线程 | +| Direct message (DM) | ✅ | +| Channel (@bot / reply in thread) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | +| Thread replies | ✅ replies are posted to the thread of the triggering message | - Slack 通过线程(Thread)组织对话。机器人会把回复发送到触发消息所在的线程,频道内更整洁。 + Slack organizes conversations into threads. The bot posts replies into the thread of the triggering message, keeping channels tidy. -## 三、使用 +## 3. Usage -完成接入后: +Once connected: -- **私聊(DM)**:在 Slack 左侧 **Apps** 中找到你的 App,直接发消息对话。 -- **频道**:把 App 邀请进频道(`/invite @你的App`),使用 `@你的App 你好` 触发对话;后续在同一线程内直接回复即可继续对话。 +- **Direct message (DM)**: find your App under **Apps** in the Slack sidebar and message it directly. +- **Channel**: invite the App into a channel (`/invite @your-app`), then trigger it with `@your-app hello`; continue the conversation by replying within the same thread. -发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 +When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/channels/telegram.mdx b/docs/channels/telegram.mdx index d7ab7a44..f90da992 100644 --- a/docs/channels/telegram.mdx +++ b/docs/channels/telegram.mdx @@ -1,47 +1,47 @@ --- title: Telegram -description: 将 CowAgent 接入 Telegram Bot +description: Integrate CowAgent with Telegram via the Bot API --- -> 通过 Telegram Bot API 接入 CowAgent,支持单聊与群聊(@机器人 / 回复机器人触发),使用 Long Polling 模式无需公网 IP,开箱即用。 +> Integrate CowAgent into Telegram via the official Bot API. Supports private chat and group chat (triggered by @mention or replying to the bot). Uses Long Polling — no public IP required, works out of the box. -## 一、接入步骤 +## 1. Setup -### 步骤一:通过 BotFather 创建 Bot +### Step 1: Create a Bot via BotFather -1. 在 Telegram 中搜索并打开官方账号 [@BotFather](https://t.me/BotFather)。 -2. 发送 `/newbot` 命令,按提示输入: - - **Bot 名称**(显示名,可中文,例如 `My CowAgent Bot`) - - **Bot 用户名**(必须以 `bot` 结尾,例如 `my_cowagent_bot`) -3. 创建成功后,BotFather 会返回一段 **HTTP API Token**(形如 `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`),妥善保存。 +1. Open the official account [@BotFather](https://t.me/BotFather) in Telegram. +2. Send `/newbot` and follow the prompts: + - **Bot name** (display name, e.g. `My CowAgent Bot`) + - **Bot username** (must end with `bot`, e.g. `my_cowagent_bot`) +3. Once created, BotFather returns an **HTTP API Token** (e.g. `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`). Keep it safe. - 这个 Token 等同于 Bot 的密码,请勿泄露。如果意外泄漏可向 `@BotFather` 发送 `/revoke` 重置。 + The token is the password of your bot — never share it. If it leaks, send `/revoke` to `@BotFather` to reset it. -### 步骤二:(群聊使用)关闭 Privacy Mode +### Step 2: (Group chat only) Disable Privacy Mode -仅使用单聊可跳过此步。Telegram Bot 默认开启 **Privacy Mode**,群聊中只能收到带 `@bot` 的命令(如 `/start@your_bot`)以及对 bot 消息的 reply;**普通的 `@bot 你好` 文字消息收不到**,会导致群聊无响应。 +Skip this step if you only use private chat. Telegram bots run in **Privacy Mode** by default — in groups they can only see commands suffixed with `@bot` (e.g. `/start@your_bot`) and replies to bot messages; **plain `@bot hello` text messages are not delivered**, so the bot will appear unresponsive in groups. -向 `@BotFather` 发送: +Send the following to `@BotFather`: 1. `/setprivacy` -2. 选择刚才创建的 bot -3. 选择 `Disable` +2. Pick the bot you just created +3. Choose `Disable` - 若设置后群聊仍无响应,可尝试把 Bot 从群里移除并重新拉入。 + If the bot is still silent in groups after this, try removing it from the group and adding it back. -### 步骤三:接入 CowAgent +### Step 3: Connect to CowAgent - - 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Telegram**,填入 Bot Token,点击接入即可。 + + Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Telegram**, paste the Bot Token, and click connect. - - 在 `config.json` 中添加以下配置后启动: + + Add the following to `config.json` and start Cow: ```json { @@ -51,16 +51,15 @@ description: 将 CowAgent 接入 Telegram Bot } ``` - | 参数 | 说明 | 默认值 | + | Key | Description | Default | | --- | --- | --- | - | `telegram_token` | BotFather 返回的 HTTP API Token | - | - | `telegram_group_trigger` | 群聊触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | - | `telegram_register_commands` | 启动时是否自动向 BotFather 注册命令菜单 | `true` | - | `telegram_proxy` | (可选)代理地址,如 `http://127.0.0.1:7890`、`socks5://127.0.0.1:1080`;运行环境无法直连 `api.telegram.org` 时配置,留空则使用环境变量 `HTTPS_PROXY` | `""` | + | `telegram_token` | HTTP API Token returned by BotFather | - | + | `telegram_group_trigger` | Group trigger: `mention_or_reply` (@ or reply) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | + | `telegram_register_commands` | Whether to register the command menu with BotFather on startup | `true` | -启动 Cow 后,日志中出现以下输出即表示接入成功: +The integration is ready when you see logs like: ``` [Telegram] Bot logged in as @my_cowagent_bot (id=123456789) @@ -68,45 +67,45 @@ description: 将 CowAgent 接入 Telegram Bot [Telegram] ✅ Telegram bot ready, polling for updates ``` -## 二、功能说明 +## 2. Capabilities -| 功能 | 支持情况 | +| Feature | Support | | --- | --- | -| 单聊 | ✅ | -| 群聊(@机器人 / 回复机器人) | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发 | -| 语音消息 | ✅ 收发(接收 OGG/Opus,发送 OGG/Opus) | -| 视频消息 | ✅ 收发 | -| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | -| 命令菜单 | ✅ 与 Web 控制台 slash 命令一致 | +| Private chat | ✅ | +| Group chat (@bot / reply to bot) | ✅ | +| Text messages | ✅ send / receive | +| Image messages | ✅ send / receive | +| Voice messages | ✅ send / receive (OGG/Opus) | +| Video messages | ✅ send / receive | +| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | +| Command menu | ✅ aligned with Web Console slash commands | -### 命令菜单 +### Command Menu -启动时会自动向 BotFather 注册命令菜单,用户在 Telegram 输入框输入 `/` 会出现下拉提示: +On startup, the channel registers a command menu with BotFather. Typing `/` in Telegram shows a dropdown: -| 命令 | 说明 | +| Command | Description | | --- | --- | -| `/help` | 显示命令帮助 | -| `/status` | 查看运行状态 | -| `/context` | 查看对话上下文(`/context clear` 清除) | -| `/skill` | 技能管理(`/skill list`、`/skill install` 等) | -| `/memory` | 记忆管理(`/memory dream`) | -| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) | -| `/config` | 查看当前配置 | -| `/cancel` | 中止当前正在运行的 Agent 任务 | -| `/logs` | 查看最近日志 | -| `/version` | 查看版本 | +| `/help` | Show command help | +| `/status` | View runtime status | +| `/context` | View conversation context (`/context clear` to clear) | +| `/skill` | Skill management (`/skill list`, `/skill install`, ...) | +| `/memory` | Memory management (`/memory dream`) | +| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) | +| `/config` | View current config | +| `/cancel` | Cancel the running Agent task | +| `/logs` | View recent logs | +| `/version` | Show version | - Telegram 命令菜单只能展示一级命令,子命令通过空格输入即可,例如 `/skill list`、`/context clear`。 + Telegram's command menu only displays top-level commands; subcommands are entered with a space, e.g. `/skill list`, `/context clear`. -## 三、使用 +## 3. Usage -完成接入后: +Once connected: -- **单聊**:在 Telegram 中搜索你创建的 Bot 用户名(如 `@my_cowagent_bot`),点击 `Start` 即可开始对话。 -- **群聊**:把 Bot 拉进群,使用 `@bot 你好` 或 **回复 Bot 的某条消息** 触发对话。若群聊无响应,请检查 Privacy Mode 是否已按 [步骤二](#步骤二-群聊使用-关闭-privacy-mode) 关闭。 +- **Private chat**: search for your bot username (e.g. `@my_cowagent_bot`) in Telegram, click `Start` and chat away. +- **Group chat**: add the bot to a group, then trigger it with `@bot hello` or by **replying to one of the bot's messages**. If the bot doesn't respond in groups, double-check Privacy Mode in [Step 2](#step-2-group-chat-only-disable-privacy-mode). -发送图片或文件时,可以直接在附件上方的输入框中 **添加 Caption**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 +When sending an image or file, you can **add a caption** (description / question) directly in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/channels/web.mdx b/docs/channels/web.mdx index 810da3da..97c43077 100644 --- a/docs/channels/web.mdx +++ b/docs/channels/web.mdx @@ -1,11 +1,11 @@ --- -title: Web 控制台 -description: 通过 Web 控制台使用 CowAgent +title: Web Console +description: Use CowAgent through the Web Console --- -Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏览器即可与 Agent 对话,并支持在线管理模型、技能、记忆、通道等配置。 +The Web Console is CowAgent's default channel. It runs automatically once started, letting you chat with the Agent in a browser and manage models, skills, memory, channels, and other configuration online. -## 配置 +## Configuration ```json { @@ -17,79 +17,79 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏 } ``` -| 参数 | 说明 | 默认值 | +| Parameter | Description | Default | | --- | --- | --- | -| `channel_type` | 设为 `web` | `web` | -| `web_host` | Web 服务监听地址,默认监听 `127.0.0.1`(仅本机),如需公网访问请改为 `0.0.0.0` 并设置密码 | `""` | -| `web_port` | Web 服务监听端口 | `9899` | -| `web_password` | 访问密码,留空表示不启用密码保护;监听 `0.0.0.0` 时建议设置 | `""` | -| `web_session_expire_days` | 登录会话有效天数 | `30` | -| `web_file_serve_root` | web 端可直接读取/发送的文件根目录,默认仅限用户主目录及 agent 工作空间;设为 `/` 可放开整个文件系统 | `"~"` | -| `enable_thinking` | 是否启用深度思考模式 | `false` | +| `channel_type` | Set to `web` | `web` | +| `web_host` | Web service listen address. Defaults to `127.0.0.1` (local only); set to `0.0.0.0` for public access and configure a password | `""` | +| `web_port` | Web service listen port | `9899` | +| `web_password` | Access password. Leave empty to disable password protection; recommended when listening on `0.0.0.0` | `""` | +| `web_session_expire_days` | Login session validity in days | `30` | +| `web_file_serve_root` | Root directory the web console can directly read/send files from. Defaults to the user home dir and agent workspace only; set to `/` to allow the whole filesystem | `"~"` | +| `enable_thinking` | Whether to enable deep thinking mode | `false` | -配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。密码也支持在控制台的「配置」页面中在线修改。 +Once a password is configured, you must enter it to log in when accessing the console. The login session is kept for 30 days by default, so restarting the service during that period does not require re-login. The password can also be changed online from the "Configuration" page in the console. -## 访问地址 +## Access URL -启动项目后访问: +After starting the project, visit: -- 本地运行:`http://localhost:9899` -- 服务器运行:`http://:9899` +- Local: `http://localhost:9899` +- Server: `http://:9899` - 请确保服务器防火墙和安全组已放行对应端口。 + Ensure the server firewall and security group allow the corresponding port. -## 功能介绍 +## Features -### 对话界面 +### Chat Interface -支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程。深度思考功能可通过配置或控制台的「Agent 配置」开关控制。 +Supports streaming output with real-time display of the Agent's reasoning process and tool calls, providing intuitive observation of the Agent's decision-making. Deep thinking can be toggled via configuration or the "Agent Configuration" switch in the console. -#### 多会话管理 +#### Multi-Session Management -对话界面支持多会话(Session)管理,所有会话记录持久化存储在数据库中: +The chat interface supports multi-session management. All session records are persistently stored in the database: -- **会话列表**:点击左侧历史会话图标可展开/收起会话列表面板,支持滚动加载全部历史会话 -- **AI 生成标题**:新会话在首轮对话完成后,自动调用模型生成简短的会话摘要标题 -- **新建会话**:点击会话列表顶部的「新对话」按钮或输入区的 `+` 按钮创建新会话 -- **删除会话**:点击会话项的删除按钮,确认后永久删除该会话及其所有消息 -- **清除上下文**:点击输入区的清除按钮,在当前会话中插入一条分隔线,分隔线以上的消息仍然展示但不再作为模型的上下文输入 +- **Session List**: Click the history icon on the left to expand/collapse the session list panel, with scroll-to-load support for all historical sessions +- **AI-Generated Titles**: After the first exchange in a new session, the model is automatically called to generate a short summary title +- **New Session**: Click the "New Chat" button at the top of the session list or the `+` button in the input area to create a new session +- **Delete Session**: Click the delete button on a session item and confirm to permanently delete the session and all its messages +- **Clear Context**: Click the clear button in the input area to insert a divider in the current session. Messages above the divider are still displayed but no longer included as context for the model -### 模型管理 +### Model Management -支持在线管理不同模型厂商的文本、图像、语音、向量模型配置,无需手动编辑配置文件: +Manage text, image, voice, and embedding model configurations for different providers online — no need to edit config files manually: -### 技能管理 +### Skill Management -支持在线查看和管理 Agent 技能(Skills): +View and manage Agent skills (Skills) online: -### 记忆管理 +### Memory Management -支持在线查看和管理 Agent 记忆: +View and manage Agent memory online: -### 通道管理 +### Channel Management -支持在线管理接入通道,支持实时连接/断开操作: +Manage connected channels online with real-time connect/disconnect operations: -### 定时任务 +### Scheduled Tasks -支持在线查看和管理定时任务,包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理: +View and manage scheduled tasks online, including one-time tasks, fixed intervals, and Cron expressions: -### 日志 +### Logs -支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题: +View Agent runtime logs in real time for monitoring and troubleshooting: diff --git a/docs/channels/wechat-kf.mdx b/docs/channels/wechat-kf.mdx index ca83aaed..f0711d51 100644 --- a/docs/channels/wechat-kf.mdx +++ b/docs/channels/wechat-kf.mdx @@ -1,60 +1,60 @@ --- -title: 微信客服 -description: 将 CowAgent 接入微信客服(WeCom Customer Service) +title: WeCom Customer Service +description: Integrate CowAgent into WeCom Customer Service (微信客服) --- -通过把企业微信自建应用绑定到「微信客服」账号,CowAgent 可以接管来自外部微信用户的客服咨询,并可在小程序、公众号、视频号及视频号小店等场景中通过链接或二维码触达微信用户。 +By binding a WeCom custom enterprise app to a WeCom Customer Service (微信客服) account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores. - 微信客服只能使用 Docker 部署或服务器 Python 部署,需要公网可达的回调地址,不支持本地运行模式。 + WeCom Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported. -## 一、准备 +## 1. Prerequisites -需要的资源: +Required resources: -1. 一台服务器(有公网 IP) -2. 注册并已认证的企业微信 -3. 已开通「微信客服」能力 +1. A server with a public IP +2. A registered and verified WeCom account +3. WeCom Customer Service capability enabled - 建议**单独再创建一个企微自建应用**用于微信客服,不要复用已有的 `wechatcom_app` 应用,否则两个通道会争抢同一个回调地址。 + It is recommended to create a **dedicated** WeCom custom app for Customer Service rather than reusing the existing `wechatcom_app` one — otherwise the two channels will compete for the same callback URL. -## 二、创建企业微信自建应用 +## 2. Create a WeCom Custom App -1. 在 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#apps),点击 **应用管理 → 创建应用**: +1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#apps), go to **Application Management → Create Application**: -2. 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechat_kf_corp_id`): +2. Click **My Enterprise** and find the **Corp ID** at the bottom of the page (it goes into `wechat_kf_corp_id`): -3. 进入上一步创建的应用,点击 Secret 旁的「**查看**」,Secret 会推送到管理员手机端的企业微信里查看: +3. Open the app you just created and click **"View"** next to Secret. The Secret will be pushed to the admin's phone via the WeCom app, where it can be viewed: -4. 进入应用 **接收消息 → 设置API接收**,点击右侧「**随机获取**」生成 **Token** 和 **EncodingAESKey** 并保存: +4. Open the app's **Receive Messages → Set API Reception** page, click **"Random Generate"** to generate the **Token** and **EncodingAESKey**, and save them: - 此时保存 API 接收配置会失败,因为程序还未启动,等项目运行后再回来保存。 + Saving the API reception configuration will fail at this point because the program has not started yet. Come back to save it after the project is running. -## 三、配置和运行 +## 3. Configuration and Run -填入上一步收集到的 4 个字段(Corp ID / Secret / Token / EncodingAESKey): +Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token / EncodingAESKey): - - 启动 Cow 项目后打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **微信客服**,依次填入 Corp ID / Secret / Token / AES Key(端口默认 9888,可改),点击接入即可。 + + Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeCom Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect. - - 在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图): + + Add the following configuration to `config.json` (each parameter maps to a field shown in the screenshots above): ```json { @@ -67,34 +67,34 @@ description: 将 CowAgent 接入微信客服(WeCom Customer Service) } ``` - | 参数 | 说明 | + | Parameter | Description | | --- | --- | - | `wechat_kf_corp_id` | 企业 ID | - | `wechat_kf_secret` | 绑定到微信客服的那个企微自建应用的 Secret | - | `wechat_kf_token` | API 接收配置中的 Token | - | `wechat_kf_aes_key` | API 接收配置中的 EncodingAESKey | - | `wechat_kf_port` | 监听端口,默认 9888 | + | `wechat_kf_corp_id` | Corp ID | + | `wechat_kf_secret` | Secret of the WeCom custom app bound to Customer Service | + | `wechat_kf_token` | Token from the API reception config | + | `wechat_kf_aes_key` | EncodingAESKey from the API reception config | + | `wechat_kf_port` | Listening port, default 9888 | -接入完成后启动程序(Web 控制台方式会自动重启),日志中出现 `Listening on http://0.0.0.0:9888/wxkf/` 表示运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。 +After connecting, start the program (the Web Console method restarts the channel automatically). When the log shows `Listening on http://0.0.0.0:9888/wxkf/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group). -接着回到企业微信「接收消息 → 设置API接收」,将回调 URL 填为 `http://:9888/wxkf/`,点击保存。保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息: +Then go back to **Receive Messages → Set API Reception** in the WeCom console and set the callback URL to `http://:9888/wxkf/`, then click Save. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received: - 如遇到 URL 配置回调不通过或配置失败: - 1. 确保服务器防火墙关闭且安全组放行监听端口(默认 9888) - 2. 仔细检查 Token、Secret、EncodingAESKey 等参数配置是否一致,URL 格式是否正确 - 3. 认证企业微信需要配置与主体一致的备案域名 + If URL verification fails or the configuration is unsuccessful: + 1. Ensure the server firewall is disabled and the security group allows the listening port (default 9888) + 2. Carefully check that Token, Secret, EncodingAESKey and other parameters are consistent, and the URL format is correct + 3. Verified WeCom accounts must use a filed domain matching the entity -## 四、绑定微信客服账号 +## 4. Bind a WeCom Customer Service Account -进入企业微信管理后台 **微信客服** 页面,创建客服账号并与上一步的企微自建应用绑定: +In the WeCom Admin Console, go to **WeCom Customer Service**, create a customer service account, and bind it to the custom app you created above: @@ -102,28 +102,28 @@ description: 将 CowAgent 接入微信客服(WeCom Customer Service) -绑定完成后,进入 **微信客服 → 微信客服账号详情**,在「**接入链接**」一栏: +After binding, go to **WeCom Customer Service → Account Details**, and under **"Access Link"**: -- 点击「**复制链接**」可获得形如 `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` 的访问链接 -- 点击「**生成二维码**」可获得对应的二维码 +- Click **"Copy Link"** to get an access link like `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` +- Click **"Generate QR Code"** to get the corresponding QR code -把链接或二维码推给微信客户使用即可: +Distribute the link or QR code to your WeChat customers: -## 五、使用 +## 5. Usage -微信用户通过链接或二维码进入客服对话后,即可与 AI 进行多轮对话,支持文本、图片、语音等消息: +After WeChat users enter the customer service conversation via the link or QR code, they can chat with the AI across multiple turns, with support for text, image, and voice messages: -除此之外,基于微信生态官方能力,还可将微信客服应用在公众号、小程序、视频号及视频号小店等场景,详情可查看企业微信管理后台 [微信客服 → 接入场景](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) 的相关说明: +Beyond that, leveraging the official WeChat ecosystem, WeCom Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeCom Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details: -## 常见问题 +## FAQ -需要确保已安装以下依赖: +Make sure the following dependencies are installed: ```bash pip install websocket-client pycryptodome diff --git a/docs/channels/wechatmp.mdx b/docs/channels/wechatmp.mdx index 917df41d..3c6c2c8b 100644 --- a/docs/channels/wechatmp.mdx +++ b/docs/channels/wechatmp.mdx @@ -1,22 +1,22 @@ --- -title: 微信公众号 -description: 将 CowAgent 接入微信公众号 +title: WeChat Official Account +description: Integrate CowAgent with WeChat Official Accounts --- -CowAgent 支持接入个人订阅号和企业服务号两种公众号类型。 +CowAgent supports both personal subscription accounts and enterprise service accounts. -| 类型 | 要求 | 特点 | +| Type | Requirements | Features | | --- | --- | --- | -| **个人订阅号** | 个人可申请 | 收到消息时会回复一条提示,回复生成后需用户主动发消息获取 | -| **企业服务号** | 企业申请,需通过微信认证开通客服接口 | 回复生成后可主动推送给用户 | +| **Personal Subscription** | Available to individuals | Sends a placeholder reply first; users must send a message to retrieve the full response | +| **Enterprise Service** | Enterprise with verified customer service API | Can proactively push replies to users | - 公众号仅支持服务器和 Docker 部署,不支持本地运行。需额外安装扩展依赖:`pip3 install -r requirements-optional.txt` + Official Accounts only support server and Docker deployment, not local run mode. Install extended dependencies: `pip3 install -r requirements-optional.txt` -## 一、个人订阅号 +## 1. Personal Subscription Account -在 `config.json` 中添加以下配置: +Add the following configuration to `config.json`: ```json { @@ -30,34 +30,34 @@ CowAgent 支持接入个人订阅号和企业服务号两种公众号类型。 } ``` -### 配置步骤 +### Setup Steps -这些配置需要和 [微信公众号后台](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev) 中的保持一致,进入页面后,在左侧菜单选择 **设置与开发 → 基本配置 → 服务器配置**,按下图进行配置: +These configurations must be consistent with the [WeChat Official Account Platform](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev). Navigate to **Settings & Development → Basic Configuration → Server Configuration** and configure as shown below: -1. 在公众平台启用开发者密码(对应配置 `wechatmp_app_secret`),并将服务器 IP 填入白名单 -2. 按上图填写 `config.json` 中与公众号相关的配置,要与公众号后台的配置一致 -3. 启动程序,启动后会监听 80 端口(若无权限监听,则在启动命令前加上 `sudo`;若 80 端口已被占用,则关闭该占用进程) -4. 在公众号后台 **启用服务器配置** 并提交,保存成功则表示已成功配置。注意 **"服务器地址(URL)"** 需要配置为 `http://{HOST}/wx` 的格式,其中 `{HOST}` 可以是服务器的 IP 或域名 +1. Enable the developer secret on the platform (corresponds to `wechatmp_app_secret`), and add the server IP to the whitelist +2. Fill in the `config.json` with the official account parameters matching the platform configuration +3. Start the program, which listens on port 80 (use `sudo` if you don't have permission; stop any process occupying port 80) +4. **Enable server configuration** on the official account platform and submit. A successful save means the configuration is complete. Note that the **"Server URL"** must be in the format `http://{HOST}/wx`, where `{HOST}` can be the server IP or domain -随后关注公众号并发送消息即可看到以下效果: +After following the account and sending a message, you should see the following result: -由于受订阅号限制,回复内容较短的情况下(15s 内),可以立即完成回复,但耗时较长的回复则会先回复一句 "正在思考中",后续需要用户输入任意文字主动获取答案,而服务号则可以通过客服接口解决这一问题。 +Due to subscription account limitations, short replies (within 15s) can be returned immediately, but longer replies will first send a "Thinking..." placeholder, requiring users to send any text to retrieve the answer. Enterprise service accounts can solve this with the customer service API. - **语音识别**:可利用微信自带的语音识别功能,需要在公众号管理页面的 "设置与开发 → 接口权限" 页面开启 "接收语音识别结果"。 + **Voice Recognition**: You can use WeChat's built-in voice recognition. Enable "Receive Voice Recognition Results" under "Settings & Development → API Permissions" on the official account management page. -## 二、企业服务号 +## 2. Enterprise Service Account -企业服务号与上述个人订阅号的接入过程基本相同,差异如下: +The setup process for enterprise service accounts is essentially the same as personal subscription accounts, with the following differences: -1. 在公众平台申请企业服务号并完成微信认证,在接口权限中确认已获得 **客服接口** 的权限 -2. 在 `config.json` 中设置 `"channel_type": "wechatmp_service"`,其他配置与上述订阅号相同 -3. 交互效果上,即使是较长耗时的回复,也可以主动推送给用户,无需用户手动获取 +1. Register an enterprise service account on the platform and complete WeChat certification. Confirm that the **Customer Service API** permission has been granted +2. Set `"channel_type": "wechatmp_service"` in `config.json`; other configurations remain the same +3. Even for longer replies, they can be proactively pushed to users without requiring manual retrieval ```json { diff --git a/docs/channels/wecom-bot.mdx b/docs/channels/wecom-bot.mdx index 7275639f..2cb51fff 100644 --- a/docs/channels/wecom-bot.mdx +++ b/docs/channels/wecom-bot.mdx @@ -1,56 +1,56 @@ --- -title: 企微智能机器人 -description: 将 CowAgent 接入企业微信智能机器人(长连接模式) +title: WeCom Bot +description: Connect CowAgent to WeCom AI Bot (WebSocket long connection) --- -> 通过企业微信智能机器人接入CowAgent,支持企业内部单聊和内部群聊,无需公网 IP,使用 WebSocket 长连接模式,支持Markdown渲染和流式输出。 +> Connect CowAgent via WeCom AI Bot, supporting both internal direct messages and group chats. No public IP required — uses a WebSocket long connection, with Markdown rendering and streaming output. - 智能机器人与企业微信自建应用是两种不同的接入方式。智能机器人使用 WebSocket 长连接,无需服务器公网 IP 和域名,配置更简单。 + WeCom Bot and WeCom App are two different integration methods. WeCom Bot uses a WebSocket long connection and requires no public IP or domain, making setup much simpler. -## 一、接入方式 +## 1. Connection methods -### 方式一:扫码一键接入(推荐) +### Option A: One-click QR scan (recommended) -无需提前创建机器人,启动 Cow 项目后打开 Web 控制台(本地链接:http://127.0.0.1:9899/),选择 **通道** 菜单,点击**接入通道**,选择**企微智能机器人**,切换到「扫码接入」模式,使用**企业微信**扫码即可自动完成机器人创建和接入。 +No need to create the bot ahead of time. Start CowAgent and open the Web console (local URL: http://127.0.0.1:9899/), go to the **Channels** tab, click **Connect Channel**, choose **WeCom Bot**, switch to **QR scan** mode, and scan the QR code with **WeCom** — bot creation and connection complete automatically. - 扫码成功后,可在企业微信工作台 - **智能机器人**页面对机器人进行进一步配置,包括修改名称、头像、可见范围等。 + After a successful scan, you can further configure the bot (name, avatar, visibility scope, etc.) in **WeCom Workbench → AI Bot**. -### 方式二:手动创建接入 +### Option B: Manual creation -需要先在企业微信中创建智能机器人并获取 Bot ID 和 Secret,再通过 Web 控制台或配置文件接入。 +Create the AI Bot in WeCom and obtain the Bot ID and Secret, then connect via the Web console or config file. -**步骤一:创建智能机器人** +**Step 1: Create the AI Bot** -1. 打开企业微信客户端,进入工作台,点击**智能机器人**: +1. Open the WeCom client, go to **Workbench**, and click **AI Bot**: -2. 点击创建机器人 - 手动创建: +2. Click **Create Bot → Manual Creation**: -3. 右侧窗口拖到最下方,选择**API模式创建**: +3. Scroll to the bottom of the right panel and select **API Mode**: -4. 设置机器人名称、头像、可见范围,并选择**长连接模式**,记录下 **Bot ID** 和 **Secret** 信息后点击保存。 +4. Set the bot name, avatar, and visibility scope. Choose **Long Connection** mode, save the **Bot ID** and **Secret**, then click Save. -**步骤二:接入 CowAgent** +**Step 2: Connect to CowAgent** - - 打开 Web 控制台,选择**通道**菜单,点击**接入通道**,选择**企微智能机器人**,切换到「手动填写」模式,输入 Bot ID 和 Secret,点击接入即可。 + + Open the Web console, go to the **Channels** tab, click **Connect Channel**, choose **WeCom Bot**, switch to **Manual** mode, enter the Bot ID and Secret, and click Connect. - - 在 `config.json` 中添加以下配置后启动程序: + + Add the following to `config.json`, then start CowAgent: ```json { @@ -60,31 +60,31 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式) } ``` - | 参数 | 说明 | + | Parameter | Description | | --- | --- | - | `wecom_bot_id` | 智能机器人的 BotID | - | `wecom_bot_secret` | 智能机器人的 Secret | + | `wecom_bot_id` | Bot ID of the AI Bot | + | `wecom_bot_secret` | Secret of the AI Bot | -日志显示 `[WecomBot] Subscribe success` 即表示连接成功。 +The log line `[WecomBot] Subscribe success` confirms the connection is established. -## 二、功能说明 +## 2. Supported features -| 功能 | 支持情况 | +| Feature | Status | | --- | --- | -| 单聊 | ✅ | -| 群聊(@机器人) | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发 | -| 文件消息 | ✅ 收发 | -| 流式回复 | ✅ | -| 定时任务主动推送 | ✅ | +| Direct chat | ✅ | +| Group chat (@bot) | ✅ | +| Text messages | ✅ Send / Receive | +| Image messages | ✅ Send / Receive | +| File messages | ✅ Send / Receive | +| Streaming replies | ✅ | +| Scheduled push messages | ✅ | -## 三、使用 +## 3. Usage -在企业微信中搜索创建的机器人名称,即可开始单聊对话。 +Search for the bot's name inside WeCom to start a direct chat. -如需在企微内部群聊中使用,将机器人添加到群中,@机器人发送消息即可。 +To use the bot in an internal group chat, add it to the group and @-mention it. diff --git a/docs/channels/wecom.mdx b/docs/channels/wecom.mdx index e0ed6fbc..e0aca17f 100644 --- a/docs/channels/wecom.mdx +++ b/docs/channels/wecom.mdx @@ -1,48 +1,48 @@ --- -title: 企微自建应用 -description: 将 CowAgent 接入企业微信自建应用 +title: WeCom +description: Integrate CowAgent into WeCom enterprise app --- -通过企业微信自建应用接入 CowAgent,支持企业内部人员单聊使用。 +Integrate CowAgent into WeCom through a custom enterprise app, supporting one-on-one chat for internal employees. - 企业微信只能使用 Docker 部署或服务器 Python 部署,不支持本地运行模式。 + WeCom only supports Docker deployment or server Python deployment. Local run mode is not supported. -## 一、准备 +## 1. Prerequisites -需要的资源: +Required resources: -1. 一台服务器(有公网 IP) -2. 注册一个企业微信(个人也可注册,但无法认证) -3. 认证企业微信还需要对应主体备案的域名 +1. A server with public IP (overseas server, or domestic server with a proxy for international API access) +2. A registered WeCom account (individual registration is possible but cannot be certified) +3. Certified WeCom accounts additionally require a domain filed under the corresponding entity -## 二、创建企业微信应用 +## 2. Create WeCom App -1. 在 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#profile) 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechatcom_corp_id` 字段中)。 +1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#profile), click **My Enterprise** and find the **Corp ID** at the bottom of the page. Save this ID for the `wechatcom_corp_id` configuration field. -2. 切换到 **应用管理**,点击创建应用: +2. Switch to **Application Management** and click Create Application: -3. 进入应用创建页面,记录 `AgentId` 和 `Secret`: +3. On the application creation page, record the `AgentId` and `Secret`: -4. 点击 **设置API接收**,配置应用接口: +4. Click **Set API Reception** to configure the application interface: -- URL 格式为 `http://ip:port/wxcomapp`(认证企业需使用备案域名) -- 随机获取 `Token` 和 `EncodingAESKey` 并保存 +- URL format: `http://ip:port/wxcomapp` (certified enterprises must use a filed domain) +- Generate random `Token` and `EncodingAESKey` and save them for the configuration file - 此时保存 API 接收配置会失败,因为程序还未启动,等项目运行后再回来保存。 + The API reception configuration cannot be saved at this point because the program hasn't started yet. Come back to save it after the project is running. -## 三、配置和运行 +## 3. Configuration and Run -在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图): +Add the following configuration to `config.json` (the mapping between each parameter and the WeCom console is shown in the screenshots above): ```json { @@ -57,41 +57,41 @@ description: 将 CowAgent 接入企业微信自建应用 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `wechatcom_corp_id` | 企业 ID | -| `wechatcomapp_token` | API 接收配置中的 Token | -| `wechatcomapp_secret` | 应用的 Secret | -| `wechatcomapp_agent_id` | 应用的 AgentId | -| `wechatcomapp_aes_key` | API 接收配置中的 EncodingAESKey | -| `wechatcomapp_port` | 监听端口,默认 9898 | +| `wechatcom_corp_id` | Corp ID | +| `wechatcomapp_token` | Token from API reception config | +| `wechatcomapp_secret` | App Secret | +| `wechatcomapp_agent_id` | App AgentId | +| `wechatcomapp_aes_key` | EncodingAESKey from API reception config | +| `wechatcomapp_port` | Listen port, default 9898 | -配置完成后启动程序。当后台日志显示 `http://0.0.0.0:9898/` 时说明程序运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。 +After configuration, start the program. When the log shows `http://0.0.0.0:9898/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group). -程序启动后,回到企业微信后台保存 **消息服务器配置**,保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息: +After the program starts, return to the WeCom Admin Console to save the **Message Server Configuration**. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received: - 如遇到 URL 配置回调不通过或配置失败: - 1. 确保服务器防火墙关闭且安全组放行监听端口 - 2. 仔细检查 Token、Secret Key 等参数配置是否一致,URL 格式是否正确 - 3. 认证企业微信需要配置与主体一致的备案域名 + If the URL configuration callback fails or the configuration is unsuccessful: + 1. Ensure the server firewall is disabled and the security group allows the listening port + 2. Carefully check that Token, Secret Key and other parameter configurations are consistent, and that the URL format is correct + 3. Certified WeCom accounts must configure a filed domain matching the entity -## 四、使用 +## 4. Usage -在企业微信中搜索刚创建的应用名称,即可直接对话: +Search for the app name you just created in WeCom to start chatting directly. You can run multiple instances listening on different ports to create multiple WeCom apps: -如需让外部个人微信用户使用,可在 **我的企业 → 微信插件** 中分享邀请关注二维码,个人微信扫码关注后即可与应用对话: +To allow external personal WeChat users to use the app, go to **My Enterprise → WeChat Plugin**, share the invite QR code. After scanning and following, personal WeChat users can join and chat with the app: -## 常见问题 +## FAQ -需要确保已安装以下依赖: +Make sure the following dependencies are installed: ```bash pip install websocket-client pycryptodome diff --git a/docs/channels/weixin.mdx b/docs/channels/weixin.mdx index c19974a4..0acb0a43 100644 --- a/docs/channels/weixin.mdx +++ b/docs/channels/weixin.mdx @@ -1,21 +1,21 @@ --- -title: 微信 -description: 将 CowAgent 接入个人微信(基于官方接口) +title: WeChat +description: Connect CowAgent to personal WeChat (via the official API) --- -> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的私聊收发。通过微信官方API进行接入,无安全风险,接入后会在会话中新增一个机器人助手,不影响当前账号的使用。 +> Connect CowAgent to your personal WeChat — scan to log in, no public IP required. Supports text, image, voice, file, and video messages in 1-on-1 chats. Backed by WeChat's official API; safe to use. After connecting, a bot assistant is added to your conversation list without affecting normal account usage. -## 一、配置和运行 +## 1. Setup and run -### 方式一:Web 控制台接入 +### Option A: Web console -启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。 +Start CowAgent and open the Web console (local URL: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **WeChat**, and follow the prompts to scan in. -### 方式二:配置文件接入 +### Option B: Config file -在 `config.json` 中设置 `channel_type` 为 `weixin`: +Set `channel_type` to `weixin` in `config.json`: ```json { @@ -23,52 +23,49 @@ description: 将 CowAgent 接入个人微信(基于官方接口) } ``` -启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。 +After starting CowAgent, a QR code is displayed in the terminal. Scan it with WeChat to complete login. - - 1. 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。 - 2. 注意微信客户端需要更新至 8.0.69 版本或以上 + 1. For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel. + 2. The WeChat client must be on version **8.0.69** or higher. -## 二、使用说明 +## 2. Usage -微信扫码并进行授权确认后,即可完成接入并开始对话。接入微信后会在对话中创建出一个机器人助理,不会对已有账号的正常使用有任何影响。 +Once authorized, the integration completes and you can start chatting. A bot assistant is created in your WeChat conversation list, leaving normal account usage unaffected. -> 你可以通过搜索"微信ClawBot"随时找到这个机器人,还可以修改这个机器人的头像、备注等信息,将机器人置顶在消息列表等。 +> You can find the bot at any time by searching for **"微信ClawBot"**. You may also rename it, change its avatar, pin it to the top of your conversation list, and so on. +## 3. Login +### QR code login -## 三、登录说明 +On first startup, a QR code appears in the terminal (valid for around 2 minutes). Scan it with WeChat and confirm on your phone to log in. -### 扫码登录 +- The QR code refreshes automatically when it expires +- The `qrcode` dependency is already included in `requirements.txt`, so the QR code renders directly in the terminal after install -首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。 +### Credential persistence -- 二维码过期后会自动刷新并重新显示 -- `requirements.txt` 中已默认包含 `qrcode` 依赖,安装后可在终端直接渲染二维码图案 +After a successful login, credentials are saved to `~/.weixin_cow_credentials.json`. Subsequent startups reuse the saved credentials with no need to re-scan. -### 凭证保存 +To force a re-login, delete the credentials file and restart. -登录成功后,凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动时无需重新扫码。 +### Session expiry -如需重新登录,删除该凭证文件后重启程序即可。 +When the WeChat session expires (errcode `-14`), CowAgent automatically clears old credentials and initiates a new QR login — no manual intervention required. -### Session 过期 +## 4. Supported features -当微信 session 过期时(errcode -14),程序会自动清除旧凭证并重新发起扫码登录,无需手动干预。 - -## 四、功能说明 - -| 功能 | 支持情况 | +| Feature | Status | | --- | --- | -| 单聊 | ✅ | -| 文本消息 | ✅ 收发 | -| 图片消息 | ✅ 收发 | -| 文件消息 | ✅ 收发 | -| 视频消息 | ✅ 收发 | -| 语音消息 | ✅ 接收 (自带语音识别) | +| Direct messages | ✅ | +| Text messages | ✅ Send & Receive | +| Image messages | ✅ Send & Receive | +| File messages | ✅ Send & Receive | +| Video messages | ✅ Send & Receive | +| Voice messages | ✅ Receive (built-in speech recognition) | diff --git a/docs/cli/general.mdx b/docs/cli/general.mdx index 36af1783..8107fcb5 100644 --- a/docs/cli/general.mdx +++ b/docs/cli/general.mdx @@ -1,17 +1,17 @@ --- -title: 常用命令 -description: 查看状态、管理配置和上下文等常用命令 +title: General Commands +description: View status, manage config, and control context with commonly used commands --- -以下命令支持在对话中使用 `/` 前缀,也支持在终端中使用 `cow` 前缀(部分命令仅对话可用)。 +The following commands can be used in chat with the `/` prefix or in the terminal with the `cow` prefix (some are chat-only). - 在 Web 控制台中输入 `/` 会自动弹出命令提示,支持键盘上下选择和 Tab 补全。 + 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 @@ -19,29 +19,15 @@ description: 查看状态、管理配置和上下文等常用命令 ## status -查看当前会话和服务的运行状态,包括进程信息、模型配置、会话消息数量和已加载技能数量。 +View current session and service status, including process info, model configuration, message count, and loaded skills. ```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 -``` - ## cancel -中止当前会话正在运行的 Agent 任务。在 Agent 执行长时间任务(例如多轮工具调用、长流式输出)时,可随时发送 `/cancel`,Agent 会在下一次工具执行前停止。Web 端、微信、企业微信、飞书等各通道均可使用。 +Abort the agent task currently running in this session. When the agent is busy with a long task (e.g. multi-turn tool calls or a long streaming response), send `/cancel` and the agent will stop before the next tool execution. Available across all channels — Web, WeChat, WeCom, Feishu, etc. ```text /cancel @@ -49,67 +35,67 @@ Session: 12 messages | 8 skills loaded ## 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-v4-flash ``` -**支持修改的配置项:** +**Configurable items:** -| 配置项 | 说明 | 示例值 | +| Item | Description | Example | | --- | --- | --- | -| `model` | AI 模型名称 | `deepseek-v4-flash` | -| `agent_max_context_tokens` | 最大上下文 tokens | `40000` | -| `agent_max_context_turns` | 最大上下文记忆轮次 | `30` | -| `agent_max_steps` | 单次任务最大决策步数 | `15` | -| `enable_thinking` | 是否启用深度思考模式 | `true` / `false` | +| `model` | AI model name | `deepseek-v4-flash` | +| `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` | +| `enable_thinking` | Enable deep thinking mode | `true` / `false` | - 修改 `model` 时,系统会自动匹配对应的模型调用方式。配置会写入 `config.json` 并持久保存。 + 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 ``` - 清空上下文后,Agent 会"忘记"之前的对话内容,适用于切换话题或释放上下文空间。 + Clearing context makes the Agent "forget" previous conversation, useful for switching topics or freeing context space. ## logs -查看最近的服务日志,默认显示最近 20 行,最多 50 行。 +View recent service logs. Shows the last 20 lines by default, up to 50. ```text /logs ``` -**指定行数:** +**Specify line count:** ```text /logs 50 @@ -117,7 +103,7 @@ Session: 12 messages | 8 skills loaded ## version -显示当前 CowAgent 版本号。 +Show the current CowAgent version. ```text /version diff --git a/docs/cli/index.mdx b/docs/cli/index.mdx index f6462ecb..e13b45a3 100644 --- a/docs/cli/index.mdx +++ b/docs/cli/index.mdx @@ -1,31 +1,31 @@ --- -title: 命令总览 -description: CowAgent 命令系统 — 终端 CLI 和对话命令 +title: Commands Overview +description: CowAgent command system — Terminal CLI and chat commands --- -CowAgent 提供两种命令交互方式: +CowAgent provides two ways to interact via commands: -- **终端CLI** — 在系统终端中执行 `cow <命令>`,用于服务管理、技能管理等运维操作 -- **对话命令** — 在对话中输入 `/<命令>` 或 `cow <命令>`,用于查看状态、管理技能、调整配置等 +- **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 -通过一键安装脚本部署后,`cow` 命令会自动可用。手动安装的用户需要在项目根目录下额外执行: +After deploying with the one-click install script, the `cow` command is automatically available. For manual installations, run: ```bash pip install -e . ``` -安装后即可在任意位置使用 `cow` 命令: +Then use the `cow` command from anywhere: ```bash cow help ``` -输出示例: +Example output: ``` -CowAgent CLI +🐮 CowAgent CLI Usage: cow @@ -49,35 +49,33 @@ Others: version Show version ``` -## 对话命令 +## Chat Commands -在 Web 控制台或任意接入渠道的对话中,支持输入以 `/` 开头的命令: +In the Web console or any connected channel, type `/` to see command suggestions. Supported commands: -| 命令 | 说明 | +| Command | Description | | --- | --- | -| `/help` | 显示命令帮助 | -| `/status` | 查看服务状态和配置 | -| `/cancel` | 中止当前正在运行的 Agent 任务 | -| `/config` | 查看或修改运行时配置 | -| `/skill` | 管理技能(安装、卸载、启用、禁用等) | -| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) | -| `/knowledge` | 查看知识库统计信息 | -| `/knowledge list` | 查看知识库目录结构 | -| `/knowledge on\|off` | 开启或关闭知识库 | -| `/context` | 查看当前会话上下文信息 | -| `/context clear` | 清空当前会话上下文 | -| `/logs` | 查看最近日志 | -| `/version` | 显示版本号 | +| `/help` | Show command help | +| `/status` | View service status and configuration | +| `/cancel` | Abort the currently running agent task | +| `/config` | View or modify runtime configuration | +| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) | +| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) | +| `/knowledge` | View knowledge base statistics | +| `/knowledge list` | View knowledge base directory structure | +| `/knowledge on\|off` | Enable or disable knowledge base | +| `/context` | View current session context info | +| `/context clear` | Clear current session context | +| `/logs` | View recent logs | +| `/version` | Show version number | - 对话命令中 `/start`、`/stop`、`/restart` 等服务管理命令会提示到终端中执行,因为它们涉及进程操作。 + Service management commands like `/start`, `/stop`, `/restart` will prompt you to use them in the terminal instead, as they involve process operations. -## 命令对照表 +## Command Availability -以下是各命令在终端和对话中的可用性: - -| 命令 | 终端 (`cow`) | 对话 (`/`) | +| Command | Terminal (`cow`) | Chat (`/`) | | --- | :---: | :---: | | help | ✓ | ✓ | | version | ✓ | ✓ | @@ -86,13 +84,13 @@ Others: | cancel | ✗ | ✓ | | config | ✗ | ✓ | | context | — | ✓ | -| memory (子命令) | ✗ | ✓ | -| knowledge (子命令) | ✓ | ✓ | -| skill (子命令) | ✓ | ✓ | +| memory (subcommands) | ✗ | ✓ | +| knowledge (subcommands) | ✓ | ✓ | +| skill (subcommands) | ✓ | ✓ | | start / stop / restart | ✓ | ✗ | | update | ✓ | ✗ | | install-browser | ✓ | ✗ | - `context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。 + `context` only shows a hint in the terminal to use it in chat. `config` is only available in chat. diff --git a/docs/cli/memory-knowledge.mdx b/docs/cli/memory-knowledge.mdx index 3dc6713d..c748120c 100644 --- a/docs/cli/memory-knowledge.mdx +++ b/docs/cli/memory-knowledge.mdx @@ -1,63 +1,49 @@ --- -title: 记忆与知识库 -description: 记忆蒸馏和知识库管理命令 +title: Memory & Knowledge +description: Memory distillation and knowledge base management commands --- ## memory -管理 Agent 的长期记忆系统。 +Manage the Agent's long-term memory system. ### memory dream -手动触发记忆蒸馏(Deep Dream),整理近期的天级记忆,蒸馏合并到 MEMORY.md,并生成梦境日记。 +Manually trigger memory distillation (Deep Dream) — consolidate recent daily memories into MEMORY.md and generate a dream diary. ```text /memory dream [N] ``` -- `N`:整理近 N 天的记忆,默认 3 天,最大 30 天 -- 蒸馏在后台异步执行,完成后会在对话中通知结果 -- 无需等待 Agent 初始化,首次对话前即可使用 +- `N`: Consolidate the last N days of memory (default 3, max 30) +- Runs asynchronously in the background; you'll be notified in chat when complete +- Works without Agent initialization — can be used before the first conversation -**示例:** +**Examples:** ```text -/memory dream # 整理近 3 天 -/memory dream 7 # 整理近 7 天 -/memory dream 30 # 整理近 30 天(全量) +/memory dream # Consolidate last 3 days +/memory dream 7 # Consolidate last 7 days +/memory dream 30 # Consolidate last 30 days (full) ``` -蒸馏完成后,Web 端会收到带有跳转链接的通知,可直接查看更新后的 MEMORY.md 和梦境日记。 +On the Web console, the completion notification includes clickable links to view the updated MEMORY.md and dream diary. - 系统每天 23:55 会自动执行一次蒸馏(lookback 1 天)。手动触发适用于首次部署后的历史整理,或需要立即更新记忆时使用。 + The system automatically runs distillation daily at 23:55 (lookback 1 day). Manual trigger is useful for consolidating historical memories after first deployment, or when you need an immediate memory update. ## knowledge -查看和管理个人知识库。默认显示知识库统计信息。 +View and manage the personal knowledge base. Shows statistics by default. ```text /knowledge ``` -输出示例: - -``` -📚 知识库 - -- 状态:已开启 -- 页面数:12 -- 总大小:45.2 KB -- 分类明细: - - concepts/: 5 篇 - - entities/: 4 篇 - - sources/: 3 篇 -``` - ### knowledge list -查看知识库目录树结构。 +View the knowledge base directory tree. ```text /knowledge list @@ -65,7 +51,7 @@ description: 记忆蒸馏和知识库管理命令 ### knowledge on / off -开启或关闭知识库。关闭后不再注入知识提示词和索引知识文件。 +Enable or disable the knowledge base. When disabled, knowledge prompts and file indexing are not injected. ```text /knowledge on @@ -73,5 +59,5 @@ description: 记忆蒸馏和知识库管理命令 ``` - 终端 CLI 中 `cow knowledge` 和 `cow knowledge list` 可用,但 `on|off` 仅支持在对话中使用(需实时生效)。 + In the terminal CLI, `cow knowledge` and `cow knowledge list` are available, but `on|off` is only supported in chat (requires runtime effect). diff --git a/docs/cli/process.mdx b/docs/cli/process.mdx index e5746773..62dfa718 100644 --- a/docs/cli/process.mdx +++ b/docs/cli/process.mdx @@ -1,28 +1,28 @@ --- -title: 进程管理 -description: 使用 cow 命令管理 CowAgent 进程的启动、停止、重启、更新等操作 +title: Process Management +description: Manage CowAgent process lifecycle with cow commands --- -进程管理命令用于控制 CowAgent 后台进程的生命周期。这些命令仅在终端中可用。 +Process management commands control the CowAgent background process. These commands are only available in the terminal. ## start -启动 CowAgent 服务。默认以后台进程方式运行,并自动跟踪日志输出。 +Start the CowAgent service. Runs as a background daemon by default and automatically tails logs. ```bash cow start ``` -**选项:** +**Options:** -| 选项 | 说明 | +| Option | Description | | --- | --- | -| `-f`, `--foreground` | 前台运行,不以后台守护进程方式启动 | -| `--no-logs` | 启动后不自动跟踪日志 | +| `-f`, `--foreground` | Run in foreground, not as a background daemon | +| `--no-logs` | Don't tail logs after starting | ## stop -停止正在运行的 CowAgent 服务。 +Stop the running CowAgent service. ```bash cow stop @@ -30,97 +30,86 @@ cow stop ## restart -重启 CowAgent 服务(先停止再启动)。 +Restart the CowAgent service (stop then start). ```bash cow restart ``` -**选项:** +**Options:** -| 选项 | 说明 | +| Option | Description | | --- | --- | -| `--no-logs` | 重启后不自动跟踪日志 | +| `--no-logs` | Don't tail logs after restart | ## update -更新代码并重启服务。自动执行以下流程: +Update code and restart the service. Automatically performs: -1. 拉取最新代码(`git pull`) -2. 停止当前服务 -3. 更新 Python 依赖 -4. 重新安装 CLI -5. 启动服务 +1. Pull latest code (`git pull`) +2. Stop current service +3. Update Python dependencies +4. Reinstall CLI +5. Start service ```bash cow update ``` - 如果 `git pull` 失败(如存在本地未提交的修改),更新会中止,服务不受影响。 + If `git pull` fails (e.g., uncommitted local changes), the update aborts and the service remains unaffected. ## status -查看 CowAgent 服务运行状态,包括进程信息、版本号、当前配置的模型和通道。 +Check CowAgent service status, including process info, version, and current model/channel configuration. ```bash cow status ``` -输出示例: - -``` -🐮 CowAgent Status - Status: ● Running (PID: 12345) - Version: 2.0.4 - Channel: web - Model: MiniMax-M2.5 - Mode: agent -``` - ## logs -查看服务日志。 +View service logs. ```bash cow logs ``` -**选项:** +**Options:** -| 选项 | 说明 | 默认值 | +| Option | Description | Default | | --- | --- | --- | -| `-f`, `--follow` | 持续跟踪日志输出 | 否 | -| `-n`, `--lines` | 显示最近 N 行 | 50 | +| `-f`, `--follow` | Continuously tail log output | No | +| `-n`, `--lines` | Show last N lines | 50 | -示例: +Examples: ```bash -# 查看最近 100 行日志 +# View last 100 lines cow logs -n 100 -# 持续跟踪日志 +# Continuously tail logs cow logs -f ``` ## install-browser -安装 Playwright 和 Chromium 浏览器,用于启用 [浏览器工具](/tools/browser)。 +Install Playwright and Chromium browser for the [browser tool](/tools/browser). ```bash cow install-browser ``` - 仅在需要使用浏览器工具(如网页浏览、截图等)时才需要安装。 + Only needed when using browser tools (web browsing, screenshots, etc.). -## run.sh 兼容 +## run.sh Compatibility -如果未安装 Cow CLI,也可以使用 `run.sh` 脚本管理服务: +If Cow CLI is not installed, you can use `run.sh` to manage the service: -| cow 命令 | run.sh 等效命令 | +| cow command | run.sh equivalent | | --- | --- | | `cow start` | `./run.sh start` | | `cow stop` | `./run.sh stop` | @@ -130,5 +119,5 @@ cow install-browser | `cow logs` | `./run.sh logs` | - 推荐使用 `cow` 命令,它提供更简洁的语法和更丰富的功能。通过一键安装脚本部署时 `cow` 命令会自动安装。 + 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/cli/skill.mdx b/docs/cli/skill.mdx index 3b4a8aee..99e41dec 100644 --- a/docs/cli/skill.mdx +++ b/docs/cli/skill.mdx @@ -1,190 +1,182 @@ --- -title: 技能管理 -description: 通过命令安装、卸载、启用、禁用和管理技能 +title: Skill Management +description: Install, uninstall, enable, disable, and manage skills via commands --- -技能管理命令用于安装、查询和管理 CowAgent 的技能。在对话中使用 `/skill <子命令>`,在终端中使用 `cow skill <子命令>`。 +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 对话 +```text Chat /skill list ``` -```bash 终端 +```bash Terminal cow skill list ``` -输出示例: +Example output: ``` -📦 已安装的技能 (3/4) +📦 Installed skills (3/4) ✅ pptx Use this skill any time a .pptx file is involved… - 来源: cowhub + Source: cowhub ✅ skill-creator Create, install, or update skills… - 来源: builtin + Source: builtin -⏸️ image-vision (已禁用) - 图片理解和视觉分析 - 来源: builtin +⏸️ image-vision (disabled) + Image understanding and visual analysis + Source: builtin ``` -**浏览技能广场**(查看 Hub 上所有可安装的技能): +**Browse the Skill Hub** (view all available skills): -```text 对话 +```text Chat /skill list --remote ``` -```bash 终端 +```bash Terminal cow skill list --remote ``` -**选项:** +**Options:** -| 选项 | 说明 | 默认值 | +| Option | Description | Default | | --- | --- | --- | -| `--remote`, `-r` | 浏览 Skill Hub 远程技能列表 | 否 | -| `--page` | 远程列表分页页码 | 1 | +| `--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 对话 +```text Chat /skill search pptx ``` -```bash 终端 +```bash Terminal cow skill search pptx ``` ## install -安装技能。通过统一的 `install` 命令,可一键安装来自 **Cow 技能广场、GitHub、ClawHub** 以及任意 URL(zip 压缩包、SKILL.md 链接)上的技能,无需手动下载和配置。 +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. -**从 Cow 技能广场安装(推荐):** +**From Skill Hub (recommended):** -```text 对话 +```text Chat /skill install pptx ``` -```bash 终端 +```bash Terminal cow skill install pptx ``` -**从 GitHub 安装:** +**From GitHub:** -```text 对话 -# 安装仓库中的所有技能(自动扫描包含 SKILL.md 的子目录) +```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 终端 -# 安装仓库中的所有技能(自动扫描包含 SKILL.md 的子目录) +```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 ``` -支持完整的 GitHub URL 和 `owner/repo` 简写。对于 mono-repo(一个仓库中包含多个技能),不指定子目录时会自动发现并批量安装所有技能;指定子目录时只安装该目录下的技能。 +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. -**从 ClawHub 安装:** +**From ClawHub:** -```text 对话 +```text Chat /skill install clawhub:baidu-search ``` -```bash 终端 +```bash Terminal cow skill install clawhub:baidu-search ``` -**从 URL 安装:** +**From URL:** -```text 对话 -# 从 zip 压缩包安装(支持单个或批量) +```text Chat +# Install from a zip archive (single or batch) /skill install https://cdn.link-ai.tech/skills/pptx.zip -# 从 SKILL.md 链接安装 +# Install from a SKILL.md link /skill install https://example.com/path/to/SKILL.md ``` -```bash 终端 -# 从 zip 压缩包安装(支持单个或批量) +```bash Terminal +# Install from a zip archive (single or batch) cow skill install https://cdn.link-ai.tech/skills/pptx.zip -# 从 SKILL.md 链接安装 +# Install from a SKILL.md link cow skill install https://example.com/path/to/SKILL.md ``` -支持从 zip / tar.gz 压缩包 URL 安装,解压后自动扫描包含 `SKILL.md` 的目录,支持单个或批量安装。也支持直接从 `SKILL.md` 文件链接安装,会自动解析技能名称和描述。 - -安装成功后会显示技能名称、描述和来源,例如: - -``` -✅ baidu-search - 百度搜索:使用百度搜索引擎检索信息… - 来源: clawhub -``` +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 对话 +```text Chat /skill uninstall pptx ``` -```bash 终端 +```bash Terminal cow skill uninstall pptx ``` - 卸载操作会删除技能目录下的所有文件,此操作不可恢复。 + Uninstalling deletes all files in the skill directory. This action cannot be undone. ## enable / disable -启用或禁用技能,禁用后技能不会被 Agent 调用。 +Enable or disable a skill. Disabled skills will not be invoked by the Agent. -```text 对话 +```text Chat /skill enable pptx /skill disable pptx ``` -```bash 终端 +```bash Terminal cow skill enable pptx cow skill disable pptx ``` @@ -192,27 +184,27 @@ cow skill disable pptx ## info -查看已安装技能的详细信息,包括 `SKILL.md` 内容预览。 +View details of an installed skill, including a preview of its `SKILL.md`. -```text 对话 +```text Chat /skill info pptx ``` -```bash 终端 +```bash Terminal cow skill info pptx ``` -## 技能来源 +## Skill Sources -安装的技能会记录来源信息,可通过 `/skill list` 查看: +Installed skills track their origin, viewable via `/skill list`: -| 来源标识 | 说明 | +| Source | Description | | --- | --- | -| `builtin` | 项目内置技能 | -| `cowhub` | 从 CowAgent Skill Hub 安装 | -| `github` | 从 GitHub URL 直接安装 | -| `clawhub` | 从 ClawHub 安装 | -| `url` | 从 SKILL.md URL 安装 | -| `local` | 本地创建的技能 | +| `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/docs.json b/docs/docs.json index 1bc55cac..593142cc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -33,23 +33,36 @@ "github": "https://github.com/zhayujie/CowAgent" } }, + "redirects": [ + { + "source": "/en/:slug*", + "destination": "/:slug*", + "permanent": true + } + ], "navigation": { "languages": [ { - "language": "zh", + "language": "en", "default": true, "navbar": { "links": [ - { "label": "官网", "href": "https://cowagent.ai/?lang=zh" }, - { "label": "GitHub", "href": "https://github.com/zhayujie/CowAgent" } + { + "label": "Website", + "href": "https://cowagent.ai/" + }, + { + "label": "GitHub", + "href": "https://github.com/zhayujie/CowAgent" + } ] }, "tabs": [ { - "tab": "项目介绍", + "tab": "Introduction", "groups": [ { - "group": "概览", + "group": "Overview", "pages": [ "intro/index", "intro/architecture", @@ -59,10 +72,10 @@ ] }, { - "tab": "快速开始", + "tab": "Get Started", "groups": [ { - "group": "安装部署", + "group": "Installation", "pages": [ "guide/quick-start", "guide/manual-install", @@ -72,17 +85,17 @@ ] }, { - "tab": "模型", + "tab": "Models", "groups": [ { - "group": "模型配置", + "group": "Model Configuration", "pages": [ "models/index", "models/deepseek", - "models/minimax", "models/claude", "models/gemini", "models/openai", + "models/minimax", "models/glm", "models/qwen", "models/doubao", @@ -97,16 +110,16 @@ ] }, { - "tab": "工具", + "tab": "Tools", "groups": [ { - "group": "工具系统", + "group": "Tools System", "pages": [ "tools/index" ] }, { - "group": "内置工具", + "group": "Built-in Tools", "pages": [ "tools/read", "tools/write", @@ -121,7 +134,7 @@ ] }, { - "group": "可选工具", + "group": "Optional Tools", "pages": [ "tools/web-search", "tools/vision", @@ -129,7 +142,7 @@ ] }, { - "group": "MCP 工具", + "group": "MCP Tools", "pages": [ "tools/mcp" ] @@ -137,10 +150,10 @@ ] }, { - "tab": "技能", + "tab": "Skills", "groups": [ { - "group": "技能系统", + "group": "Skills System", "pages": [ "skills/index", "skills/install", @@ -149,7 +162,7 @@ ] }, { - "group": "内置技能", + "group": "Built-in Skills", "pages": [ "skills/skill-creator", "skills/knowledge-wiki", @@ -159,10 +172,10 @@ ] }, { - "tab": "记忆", + "tab": "Memory", "groups": [ { - "group": "记忆系统", + "group": "Memory System", "pages": [ "memory/index", "memory/context", @@ -172,10 +185,10 @@ ] }, { - "tab": "知识", + "tab": "Knowledge", "groups": [ { - "group": "知识库", + "group": "Knowledge Base", "pages": [ "knowledge/index" ] @@ -183,33 +196,33 @@ ] }, { - "tab": "通道", + "tab": "Channels", "groups": [ { - "group": "接入渠道", + "group": "Platforms", "pages": [ "channels/index", - "channels/weixin", "channels/web", + "channels/telegram", + "channels/slack", + "channels/discord", + "channels/weixin", "channels/feishu", "channels/dingtalk", "channels/wecom-bot", "channels/qq", "channels/wecom", "channels/wechat-kf", - "channels/wechatmp", - "channels/telegram", - "channels/slack", - "channels/discord" + "channels/wechatmp" ] } ] }, { - "tab": "命令", + "tab": "CLI", "groups": [ { - "group": "命令系统", + "group": "Command System", "pages": [ "cli/index", "cli/process", @@ -221,10 +234,10 @@ ] }, { - "tab": "版本", + "tab": "Releases", "groups": [ { - "group": "发布记录", + "group": "Release Notes", "pages": [ "releases/overview", "releases/v2.0.9", @@ -244,206 +257,212 @@ ] }, { - "language": "en", + "language": "zh", "navbar": { "links": [ - { "label": "Website", "href": "https://cowagent.ai/" }, - { "label": "GitHub", "href": "https://github.com/zhayujie/CowAgent" } + { + "label": "官网", + "href": "https://cowagent.ai/?lang=zh" + }, + { + "label": "GitHub", + "href": "https://github.com/zhayujie/CowAgent" + } ] }, "tabs": [ { - "tab": "Introduction", + "tab": "项目介绍", "groups": [ { - "group": "Overview", + "group": "概览", "pages": [ - "en/intro/index", - "en/intro/architecture", - "en/intro/features" + "zh/intro/index", + "zh/intro/architecture", + "zh/intro/features" ] } ] }, { - "tab": "Get Started", + "tab": "快速开始", "groups": [ { - "group": "Installation", + "group": "安装部署", "pages": [ - "en/guide/quick-start", - "en/guide/manual-install", - "en/guide/upgrade" + "zh/guide/quick-start", + "zh/guide/manual-install", + "zh/guide/upgrade" ] } ] }, { - "tab": "Models", + "tab": "模型", "groups": [ { - "group": "Model Configuration", + "group": "模型配置", "pages": [ - "en/models/index", - "en/models/deepseek", - "en/models/claude", - "en/models/gemini", - "en/models/openai", - "en/models/minimax", - "en/models/glm", - "en/models/qwen", - "en/models/doubao", - "en/models/kimi", - "en/models/qianfan", - "en/models/mimo", - "en/models/linkai", - "en/models/coding-plan", - "en/models/custom" + "zh/models/index", + "zh/models/deepseek", + "zh/models/minimax", + "zh/models/claude", + "zh/models/gemini", + "zh/models/openai", + "zh/models/glm", + "zh/models/qwen", + "zh/models/doubao", + "zh/models/kimi", + "zh/models/qianfan", + "zh/models/mimo", + "zh/models/linkai", + "zh/models/coding-plan", + "zh/models/custom" ] } ] }, { - "tab": "Tools", + "tab": "工具", "groups": [ { - "group": "Tools System", + "group": "工具系统", "pages": [ - "en/tools/index" + "zh/tools/index" ] }, { - "group": "Built-in Tools", + "group": "内置工具", "pages": [ - "en/tools/read", - "en/tools/write", - "en/tools/edit", - "en/tools/ls", - "en/tools/bash", - "en/tools/send", - "en/tools/memory", - "en/tools/env-config", - "en/tools/web-fetch", - "en/tools/scheduler" + "zh/tools/read", + "zh/tools/write", + "zh/tools/edit", + "zh/tools/ls", + "zh/tools/bash", + "zh/tools/send", + "zh/tools/memory", + "zh/tools/env-config", + "zh/tools/web-fetch", + "zh/tools/scheduler" ] }, { - "group": "Optional Tools", + "group": "可选工具", "pages": [ - "en/tools/web-search", - "en/tools/vision", - "en/tools/browser" + "zh/tools/web-search", + "zh/tools/vision", + "zh/tools/browser" ] }, { - "group": "MCP Tools", + "group": "MCP 工具", "pages": [ - "en/tools/mcp" + "zh/tools/mcp" ] } ] }, { - "tab": "Skills", + "tab": "技能", "groups": [ { - "group": "Skills System", + "group": "技能系统", "pages": [ - "en/skills/index", - "en/skills/install", - "en/skills/create", - "en/skills/hub" + "zh/skills/index", + "zh/skills/install", + "zh/skills/create", + "zh/skills/hub" ] }, { - "group": "Built-in Skills", + "group": "内置技能", "pages": [ - "en/skills/skill-creator", - "en/skills/knowledge-wiki", - "en/skills/image-generation" + "zh/skills/skill-creator", + "zh/skills/knowledge-wiki", + "zh/skills/image-generation" ] } ] }, { - "tab": "Memory", + "tab": "记忆", "groups": [ { - "group": "Memory System", + "group": "记忆系统", "pages": [ - "en/memory/index", - "en/memory/context", - "en/memory/deep-dream" + "zh/memory/index", + "zh/memory/context", + "zh/memory/deep-dream" ] } ] }, { - "tab": "Knowledge", + "tab": "知识", "groups": [ { - "group": "Knowledge Base", + "group": "知识库", "pages": [ - "en/knowledge/index" + "zh/knowledge/index" ] } ] }, { - "tab": "Channels", + "tab": "通道", "groups": [ { - "group": "Platforms", + "group": "接入渠道", "pages": [ - "en/channels/index", - "en/channels/web", - "en/channels/telegram", - "en/channels/slack", - "en/channels/discord", - "en/channels/weixin", - "en/channels/feishu", - "en/channels/dingtalk", - "en/channels/wecom-bot", - "en/channels/qq", - "en/channels/wecom", - "en/channels/wechat-kf", - "en/channels/wechatmp" + "zh/channels/index", + "zh/channels/weixin", + "zh/channels/web", + "zh/channels/feishu", + "zh/channels/dingtalk", + "zh/channels/wecom-bot", + "zh/channels/qq", + "zh/channels/wecom", + "zh/channels/wechat-kf", + "zh/channels/wechatmp", + "zh/channels/telegram", + "zh/channels/slack", + "zh/channels/discord" ] } ] }, { - "tab": "CLI", + "tab": "命令", "groups": [ { - "group": "Command System", + "group": "命令系统", "pages": [ - "en/cli/index", - "en/cli/process", - "en/cli/skill", - "en/cli/memory-knowledge", - "en/cli/general" + "zh/cli/index", + "zh/cli/process", + "zh/cli/skill", + "zh/cli/memory-knowledge", + "zh/cli/general" ] } ] }, { - "tab": "Releases", + "tab": "版本", "groups": [ { - "group": "Release Notes", + "group": "发布记录", "pages": [ - "en/releases/overview", - "en/releases/v2.0.9", - "en/releases/v2.0.8", - "en/releases/v2.0.7", - "en/releases/v2.0.6", - "en/releases/v2.0.5", - "en/releases/v2.0.4", - "en/releases/v2.0.3", - "en/releases/v2.0.2", - "en/releases/v2.0.1", - "en/releases/v2.0.0" + "zh/releases/overview", + "zh/releases/v2.0.9", + "zh/releases/v2.0.8", + "zh/releases/v2.0.7", + "zh/releases/v2.0.6", + "zh/releases/v2.0.5", + "zh/releases/v2.0.4", + "zh/releases/v2.0.3", + "zh/releases/v2.0.2", + "zh/releases/v2.0.1", + "zh/releases/v2.0.0" ] } ] @@ -454,8 +473,14 @@ "language": "ja", "navbar": { "links": [ - { "label": "ウェブサイト", "href": "https://cowagent.ai/" }, - { "label": "GitHub", "href": "https://github.com/zhayujie/CowAgent" } + { + "label": "ウェブサイト", + "href": "https://cowagent.ai/" + }, + { + "label": "GitHub", + "href": "https://github.com/zhayujie/CowAgent" + } ] }, "tabs": [ diff --git a/docs/en/channels/dingtalk.mdx b/docs/en/channels/dingtalk.mdx deleted file mode 100644 index 1db5f53c..00000000 --- a/docs/en/channels/dingtalk.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: DingTalk -description: Integrate CowAgent into DingTalk application ---- - -Integrate CowAgent into DingTalk by creating an intelligent robot app on the DingTalk Open Platform. - -## 1. Create App - -1. Go to [DingTalk Developer Console](https://open-dev.dingtalk.com/fe/app#/corp/app), log in and click **Create App**, fill in the app information: - - - -2. Click **Add App Capability**, select **Robot** capability and click **Add**: - - - -3. Configure the robot information and click **Publish**. After publishing, click "**Debug**" to automatically create a test group chat, which can be viewed in the client: - - - -4. Click **Version Management & Release**, create a new version and publish: - - - -## 2. Project Configuration - -1. Click **Credentials & Basic Info**, get the `Client ID` and `Client Secret`: - - - -2. Add the following configuration to `config.json` in the project root: - -```json -{ - "channel_type": "dingtalk", - "dingtalk_client_id": "YOUR_CLIENT_ID", - "dingtalk_client_secret": "YOUR_CLIENT_SECRET" -} -``` - -3. Install the dependency: - -```bash -pip3 install dingtalk_stream -``` - - - -4. After starting the project, go to the DingTalk Developer Console, click **Event Subscription**, then click **Connection verified, verify channel**. When "**Connection successful**" is displayed, the configuration is complete: - - - -## 3. Usage - -Chat privately with the robot or add it to an enterprise group to start a conversation: - - diff --git a/docs/en/channels/discord.mdx b/docs/en/channels/discord.mdx deleted file mode 100644 index e18c0685..00000000 --- a/docs/en/channels/discord.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Discord -description: Integrate CowAgent with a Discord Bot ---- - -> Integrate CowAgent into Discord via a Discord Bot using the **Gateway** (persistent WebSocket). Supports direct messages (DM) and server channels (triggered by @mention or replying to the bot). The Gateway uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. - -## 1. Setup - -### Step 1: Create a Discord Application and Bot - -1. Open the [Discord Developer Portal](https://discord.com/developers/applications), click **New Application**, enter a name (e.g. `CowAgent`), and create it. -2. Go to the **Bot** page in the left sidebar, click **Reset Token** to generate a Bot Token, then copy and store it safely (shown only once). - - - This token is your bot's password — keep it secret. If it leaks, click **Reset Token** again on the Bot page to regenerate it. - - -### Step 2: Enable the Message Content Intent - -Reading message text in both DMs and channels depends on this privileged intent. - -1. On the **Bot** page, find **Privileged Gateway Intents**. -2. Turn on **Message Content Intent** and save. - - - Without this intent enabled, incoming message content will be empty and the bot will not respond. - - -### Step 3: Invite the Bot to a Server - -1. Go to **OAuth2 → URL Generator** in the left sidebar. -2. Under **Scopes**, check `bot`. -3. Under **Bot Permissions**, check at least: `Send Messages`, `Read Message History`, `Attach Files`, `View Channels`. -4. Copy the generated authorization URL at the bottom, open it in a browser, and authorize it for your target server. - - - You can skip this step if you only need DMs, but you still need a DM channel with the bot (e.g. the user messages the bot directly). - - -### Step 4: Connect to CowAgent - - - - Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Discord**, paste the Bot Token, and click connect. - - - Add the following to `config.json` and start Cow: - - ```json - { - "channel_type": "discord", - "discord_token": "your-discord-bot-token", - "discord_group_trigger": "mention_or_reply" - } - ``` - - | Key | Description | Default | - | --- | --- | --- | - | `discord_token` | Bot Token generated on the Bot page of the Developer Portal | - | - | `discord_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply to bot) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | - - - -The integration is ready when you see logs like: - -``` -[Discord] Bot logged in as CowAgent#1234 (id=123456789) -[Discord] ✅ Discord bot ready, listening for messages -``` - -## 2. Capabilities - -| Feature | Support | -| --- | --- | -| Direct message (DM) | ✅ | -| Server channel (@bot / reply to bot) | ✅ | -| Text messages | ✅ send / receive | -| Image messages | ✅ send / receive | -| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | - - - A single Discord message is capped at 2000 characters; long replies are automatically split across multiple messages by line breaks. - - -## 3. Usage - -Once connected: - -- **Direct message (DM)**: find your bot in the server member list, click its avatar, and message it directly. -- **Channel**: in a channel where the bot is invited, trigger it with `@your-bot hello` or by **replying to one of the bot's messages**. - -When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/en/channels/feishu.mdx b/docs/en/channels/feishu.mdx deleted file mode 100644 index 1283d0c1..00000000 --- a/docs/en/channels/feishu.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: Feishu (Lark) -description: Integrate CowAgent into Feishu via a custom enterprise app ---- - -> Integrate CowAgent into Feishu via a custom enterprise app. Supports p2p chat and group chat (@bot), uses WebSocket long connection (no public IP needed), supports streaming typewriter replies and voice messages. - - - You need to be a Feishu enterprise user with admin privileges. - - -## 1. Setup - -### Option 1: One-click Scan to Create (Recommended) - -No need to manually create an app on the Feishu Developer Platform. Start the Cow project, open the web console (default `http://127.0.0.1:9899/`), go to **Channels**, click **Add Channel**, choose **Feishu**, then under the **Scan QR** tab click **One-click Create Feishu App** and scan with the **Feishu App** to complete app creation and connection automatically. - - - - - 1. Requires `lark-oapi` ≥ 1.5.5. - 2. The created app comes with all required permissions (messaging, card read/write, group events, etc.) and event subscriptions pre-configured — no manual setup on the developer console needed. Currently only the Feishu mainland version is supported (Lark international not yet supported). - - -When starting from CLI without `feishu_app_id` configured, the QR code is also printed to the terminal. - -### Option 2: Manual Setup - -Manually create a custom app on the Feishu Developer Platform, then connect via Web Console or config file. - -**Step 1: Create the App** - -1. Go to [Feishu Developer Platform](https://open.feishu.cn/app/), click **Create Enterprise Custom App**: - - - -2. In **Add App Capabilities**, add the **Bot** capability: - - - -3. In **Permission Management**, paste the following permissions and **Batch Enable** all: - -``` -im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write -``` - - - -4. Get `App ID` and `App Secret` from **Credentials & Basic Info**: - - - -**Step 2: Connect to CowAgent** - - - - Open the web console, go to **Channels**, click **Add Channel**, choose **Feishu**, switch to the **Manual** tab, enter App ID and App Secret, then click connect. - - - Add the following to `config.json` and start the program: - - ```json - { - "channel_type": "feishu", - "feishu_app_id": "YOUR_APP_ID", - "feishu_app_secret": "YOUR_APP_SECRET", - "feishu_stream_reply": true - } - ``` - - | Parameter | Description | Default | - | --- | --- | --- | - | `feishu_app_id` | Feishu app App ID | - | - | `feishu_app_secret` | Feishu app App Secret | - | - | `feishu_stream_reply` | Enable streaming typewriter reply | `true` | - - - -**Step 3: Publish the App** - -1. After Cow is running, go to **Events & Callbacks** in the Feishu Developer Platform, choose **Long Connection** mode and save: - - - -2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**. - -3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client: - - - -## 2. Features - -| Feature | Status | -| --- | --- | -| P2P chat | ✅ | -| Group chat (@bot) | ✅ | -| Text messages | ✅ send/receive | -| Image messages | ✅ send/receive | -| Voice messages | ✅ send/receive | -| Streaming reply | ✅ (powered by Feishu cardkit streaming card) | - - - Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically. - - -## 3. Usage - -After connection, search for the bot name in Feishu to start a chat. - -To use in groups, add the bot to a group and @-mention it. diff --git a/docs/en/channels/index.mdx b/docs/en/channels/index.mdx deleted file mode 100644 index bd40d7d5..00000000 --- a/docs/en/channels/index.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Channels Overview -description: Channels supported by CowAgent and their capability matrix ---- - -CowAgent supports multiple chat channels. Switch between them at startup via `channel_type`. The Web Console is enabled by default and can run in parallel with other channels. - -## Capability Matrix - -The table below summarizes the inbound message types, bot reply types, and group chat capabilities supported by each channel, making it easy to choose by scenario. - -| Channel | Text | Image | File | Voice | Group Chat | -| --- | :-: | :-: | :-: | :-: | :-: | -| [WeChat](/en/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | -| [Web Console](/en/channels/web) | ✅ | ✅ | ✅ | ✅ | | -| [Feishu](/en/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [DingTalk](/en/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [WeCom Bot](/en/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [QQ](/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ | -| [WeCom App](/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | -| [Official Account](/en/channels/wechatmp) | ✅ | ✅ | | ✅ | | -| [Telegram](/en/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Slack](/en/channels/slack) | ✅ | ✅ | ✅ | | ✅ | -| [Discord](/en/channels/discord) | ✅ | ✅ | ✅ | | ✅ | - -- The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details -- The **Group Chat** column indicates the ability to recognize and respond to group messages - - - The voice / image capabilities of each channel depend on the configuration of the corresponding model provider. See [Models Overview](/en/models/index) for details. - - -## Channel List - -- [Web Console](/en/channels/web) — built-in browser-based chat and management panel, enabled by default -- [WeChat](/en/channels/weixin) — log in via personal WeChat QR scan -- [Feishu](/en/channels/feishu) — Feishu custom bot -- [DingTalk](/en/channels/dingtalk) — DingTalk custom bot -- [WeCom Bot](/en/channels/wecom-bot) — WeCom AI Bot via WebSocket long connection -- [QQ](/en/channels/qq) — QQ Official Bot open platform -- [WeCom App](/en/channels/wecom) — WeCom custom app integration -- [Official Account](/en/channels/wechatmp) — WeChat Official Account (subscription / service) -- [Telegram](/en/channels/telegram) — global IM, 5-minute setup, no public IP needed -- [Slack](/en/channels/slack) — team collaboration IM, Socket Mode integration, no public IP needed -- [Discord](/en/channels/discord) — community IM, Gateway connection, no public IP needed diff --git a/docs/en/channels/qq.mdx b/docs/en/channels/qq.mdx deleted file mode 100644 index a7f08594..00000000 --- a/docs/en/channels/qq.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: QQ Bot -description: Connect CowAgent to QQ Bot (WebSocket long connection) ---- - -> Connect CowAgent via QQ Open Platform's bot API, supporting QQ direct messages, group chats (@bot), guild channel messages, and guild DMs. No public IP required — uses WebSocket long connection. - - - QQ Bot is created through the QQ Open Platform. It uses WebSocket long connection to receive messages and OpenAPI to send messages. No public IP or domain is required. - - -## 1. Create a QQ Bot - -> Visit the [QQ Open Platform](https://q.qq.com), sign in with QQ. If you haven't registered, please complete [account registration](https://q.qq.com/#/register) first. - -1.Go to the [QQ Open Platform - Bot List](https://q.qq.com/#/apps), and click **Create Bot**: - - - -2.Fill in the bot name, avatar, and other basic information to complete the creation: - - - -3.Enter the bot configuration page, go to **Development Management**, and complete the following steps: - - - Copy and save the **AppID** (Bot ID) - - Generate and save the **AppSecret** (Bot Secret) - - - -## 2. Configuration and Running - -### 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 **QQ Bot**, fill in the AppID and AppSecret from the previous step, and click Connect. - - - -### Option B: Config File - -Add the following to your `config.json`: - -```json -{ - "channel_type": "qq", - "qq_app_id": "YOUR_APP_ID", - "qq_app_secret": "YOUR_APP_SECRET" -} -``` - -| Parameter | Description | -| --- | --- | -| `qq_app_id` | AppID of the QQ Bot, found in Development Management on the open platform | -| `qq_app_secret` | AppSecret of the QQ Bot, found in Development Management on the open platform | - -After configuration, start the program. The log message `[QQ] ✅ Connected successfully` indicates a successful connection. - - -## 3. Usage - -In the QQ Open Platform, go to **Management → Usage Scope & Members**, scan the "Add to group and message list" QR code with your QQ client to start chatting with the bot: - - - -Chat example: - - -## 4. Supported Features - -> Note: To use the QQ bot in group chats and guild channels, you need to complete the publishing review and configure usage scope permissions. - -| Feature | Status | -| --- | --- | -| QQ Direct Messages | ✅ | -| QQ Group Chat (@bot) | ✅ | -| Guild Channel (@bot) | ✅ | -| Guild DM | ✅ | -| Text Messages | ✅ Send & Receive | -| Image Messages | ✅ Send & Receive (group & direct) | -| File Messages | ✅ Send (group & direct) | -| Scheduled Tasks | ✅ Active push (4 per user per month) | - - -## 5. Notes - -- **Passive message limits**: QQ direct message replies are valid for 60 minutes (max 5 replies per message); group chat replies are valid for 5 minutes. -- **Active message limits**: Both direct and group chats have a monthly limit of 4 active messages. Keep this in mind when using the scheduled tasks feature. -- **Event permissions**: By default, `GROUP_AND_C2C_EVENT` (QQ group/direct) and `PUBLIC_GUILD_MESSAGES` (guild public messages) are subscribed. Apply for additional permissions on the open platform if needed. diff --git a/docs/en/channels/slack.mdx b/docs/en/channels/slack.mdx deleted file mode 100644 index f95272ca..00000000 --- a/docs/en/channels/slack.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Slack -description: Integrate CowAgent with a Slack App ---- - -> Integrate CowAgent into Slack via a Slack App in **Socket Mode**. Supports direct messages (DM) and channels (triggered by @mention or replying within a thread). Socket Mode uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box. - -## 1. Setup - -### Step 1: Create a Slack App - -1. Open the [Slack API apps page](https://api.slack.com/apps), click **Create New App** → **From scratch**. -2. Enter an **App Name** (e.g. `CowAgent`), pick the **Workspace** to install into, and create it. - -### Step 2: Enable Socket Mode and get the App Token - -1. In the left sidebar go to **Settings → Socket Mode** and turn on **Enable Socket Mode**. -2. You will be prompted to generate an **App-Level Token** with the `connections:write` scope. Save this token starting with `xapp-`. - - - Socket Mode receives events over a WebSocket connection, so you don't need to expose a public callback URL — ideal for local or intranet deployments. - - -### Step 3: Configure bot scopes and install - -1. Go to **Features → OAuth & Permissions**, click **Add an OAuth Scope** under **Bot Token Scopes**, and add the following scopes one by one: - - ``` - app_mentions:read - channels:history - chat:write - commands - files:read - files:write - groups:history - im:history - mpim:history - users:read - ``` - - - `files:read` / `files:write` are used for sending/receiving images and files; omit them if you only need text conversations. - - -2. Go to **Features → Event Subscriptions**, turn on **Enable Events**, and under **Subscribe to bot events** click **Add Bot User Event** to add: - - ``` - app_mention - message.im - message.channels - ``` - - - Add `message.groups` if you need to use the bot in private channels. - -3. Go to **Features → App Home**, enable **Messages Tab** under **Show Tabs**, and check **Allow users to send Slash commands and messages from the messages tab**. Otherwise the DM input box is disabled and users cannot message the bot. -4. Back in **OAuth & Permissions**, click **Install to Workspace**. After installing, copy the **Bot User OAuth Token** starting with `xoxb-`. - - - If the Slack client still shows "Sending messages to this app has been turned off", make sure you completed the App Home step above, then refresh or restart the Slack client (remove the app from your conversations and reopen it if needed). - - -### Step 4: Connect to CowAgent - - - - Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Slack**, paste the Bot Token (`xoxb-`) and App Token (`xapp-`), and click connect. - - - Add the following to `config.json` and start Cow: - - ```json - { - "channel_type": "slack", - "slack_bot_token": "xoxb-xxxxxxxxxxxx", - "slack_app_token": "xapp-xxxxxxxxxxxx", - "slack_group_trigger": "mention_or_reply" - } - ``` - - | Key | Description | Default | - | --- | --- | --- | - | `slack_bot_token` | Bot User OAuth Token, like `xoxb-...` | - | - | `slack_app_token` | App-Level Token (generated after enabling Socket Mode), like `xapp-...` | - | - | `slack_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply in thread) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | - - - -The integration is ready when you see logs like: - -``` -[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx -[Slack] ✅ Slack bot ready, listening for events -``` - -## 2. Capabilities - -| Feature | Support | -| --- | --- | -| Direct message (DM) | ✅ | -| Channel (@bot / reply in thread) | ✅ | -| Text messages | ✅ send / receive | -| Image messages | ✅ send / receive | -| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | -| Thread replies | ✅ replies are posted to the thread of the triggering message | - - - Slack organizes conversations into threads. The bot posts replies into the thread of the triggering message, keeping channels tidy. - - -## 3. Usage - -Once connected: - -- **Direct message (DM)**: find your App under **Apps** in the Slack sidebar and message it directly. -- **Channel**: invite the App into a channel (`/invite @your-app`), then trigger it with `@your-app hello`; continue the conversation by replying within the same thread. - -When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/en/channels/telegram.mdx b/docs/en/channels/telegram.mdx deleted file mode 100644 index f90da992..00000000 --- a/docs/en/channels/telegram.mdx +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: Telegram -description: Integrate CowAgent with Telegram via the Bot API ---- - -> Integrate CowAgent into Telegram via the official Bot API. Supports private chat and group chat (triggered by @mention or replying to the bot). Uses Long Polling — no public IP required, works out of the box. - - -## 1. Setup - -### Step 1: Create a Bot via BotFather - -1. Open the official account [@BotFather](https://t.me/BotFather) in Telegram. -2. Send `/newbot` and follow the prompts: - - **Bot name** (display name, e.g. `My CowAgent Bot`) - - **Bot username** (must end with `bot`, e.g. `my_cowagent_bot`) -3. Once created, BotFather returns an **HTTP API Token** (e.g. `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`). Keep it safe. - - - The token is the password of your bot — never share it. If it leaks, send `/revoke` to `@BotFather` to reset it. - - -### Step 2: (Group chat only) Disable Privacy Mode - -Skip this step if you only use private chat. Telegram bots run in **Privacy Mode** by default — in groups they can only see commands suffixed with `@bot` (e.g. `/start@your_bot`) and replies to bot messages; **plain `@bot hello` text messages are not delivered**, so the bot will appear unresponsive in groups. - -Send the following to `@BotFather`: - -1. `/setprivacy` -2. Pick the bot you just created -3. Choose `Disable` - - - If the bot is still silent in groups after this, try removing it from the group and adding it back. - - -### Step 3: Connect to CowAgent - - - - Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Telegram**, paste the Bot Token, and click connect. - - - Add the following to `config.json` and start Cow: - - ```json - { - "channel_type": "telegram", - "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", - "telegram_group_trigger": "mention_or_reply" - } - ``` - - | Key | Description | Default | - | --- | --- | --- | - | `telegram_token` | HTTP API Token returned by BotFather | - | - | `telegram_group_trigger` | Group trigger: `mention_or_reply` (@ or reply) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` | - | `telegram_register_commands` | Whether to register the command menu with BotFather on startup | `true` | - - - -The integration is ready when you see logs like: - -``` -[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) -[Telegram] Registered 10 bot commands -[Telegram] ✅ Telegram bot ready, polling for updates -``` - -## 2. Capabilities - -| Feature | Support | -| --- | --- | -| Private chat | ✅ | -| Group chat (@bot / reply to bot) | ✅ | -| Text messages | ✅ send / receive | -| Image messages | ✅ send / receive | -| Voice messages | ✅ send / receive (OGG/Opus) | -| Video messages | ✅ send / receive | -| File messages | ✅ send / receive (PDF / Word / Excel, etc.) | -| Command menu | ✅ aligned with Web Console slash commands | - -### Command Menu - -On startup, the channel registers a command menu with BotFather. Typing `/` in Telegram shows a dropdown: - -| Command | Description | -| --- | --- | -| `/help` | Show command help | -| `/status` | View runtime status | -| `/context` | View conversation context (`/context clear` to clear) | -| `/skill` | Skill management (`/skill list`, `/skill install`, ...) | -| `/memory` | Memory management (`/memory dream`) | -| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) | -| `/config` | View current config | -| `/cancel` | Cancel the running Agent task | -| `/logs` | View recent logs | -| `/version` | Show version | - - - Telegram's command menu only displays top-level commands; subcommands are entered with a space, e.g. `/skill list`, `/context clear`. - - -## 3. Usage - -Once connected: - -- **Private chat**: search for your bot username (e.g. `@my_cowagent_bot`) in Telegram, click `Start` and chat away. -- **Group chat**: add the bot to a group, then trigger it with `@bot hello` or by **replying to one of the bot's messages**. If the bot doesn't respond in groups, double-check Privacy Mode in [Step 2](#step-2-group-chat-only-disable-privacy-mode). - -When sending an image or file, you can **add a caption** (description / question) directly in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically. diff --git a/docs/en/channels/web.mdx b/docs/en/channels/web.mdx deleted file mode 100644 index 97c43077..00000000 --- a/docs/en/channels/web.mdx +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Web Console -description: Use CowAgent through the Web Console ---- - -The Web Console is CowAgent's default channel. It runs automatically once started, letting you chat with the Agent in a browser and manage models, skills, memory, channels, and other configuration online. - -## Configuration - -```json -{ - "channel_type": "web", - "web_host": "0.0.0.0", - "web_port": 9899, - "web_password": "", - "enable_thinking": false -} -``` - -| Parameter | Description | Default | -| --- | --- | --- | -| `channel_type` | Set to `web` | `web` | -| `web_host` | Web service listen address. Defaults to `127.0.0.1` (local only); set to `0.0.0.0` for public access and configure a password | `""` | -| `web_port` | Web service listen port | `9899` | -| `web_password` | Access password. Leave empty to disable password protection; recommended when listening on `0.0.0.0` | `""` | -| `web_session_expire_days` | Login session validity in days | `30` | -| `web_file_serve_root` | Root directory the web console can directly read/send files from. Defaults to the user home dir and agent workspace only; set to `/` to allow the whole filesystem | `"~"` | -| `enable_thinking` | Whether to enable deep thinking mode | `false` | - -Once a password is configured, you must enter it to log in when accessing the console. The login session is kept for 30 days by default, so restarting the service during that period does not require re-login. The password can also be changed online from the "Configuration" page in the console. - -## Access URL - -After starting the project, visit: - -- Local: `http://localhost:9899` -- Server: `http://:9899` - - - Ensure the server firewall and security group allow the corresponding port. - - -## Features - -### Chat Interface - -Supports streaming output with real-time display of the Agent's reasoning process and tool calls, providing intuitive observation of the Agent's decision-making. Deep thinking can be toggled via configuration or the "Agent Configuration" switch in the console. - - - -#### Multi-Session Management - -The chat interface supports multi-session management. All session records are persistently stored in the database: - -- **Session List**: Click the history icon on the left to expand/collapse the session list panel, with scroll-to-load support for all historical sessions -- **AI-Generated Titles**: After the first exchange in a new session, the model is automatically called to generate a short summary title -- **New Session**: Click the "New Chat" button at the top of the session list or the `+` button in the input area to create a new session -- **Delete Session**: Click the delete button on a session item and confirm to permanently delete the session and all its messages -- **Clear Context**: Click the clear button in the input area to insert a divider in the current session. Messages above the divider are still displayed but no longer included as context for the model - -### Model Management - -Manage text, image, voice, and embedding model configurations for different providers online — no need to edit config files manually: - - - -### Skill Management - -View and manage Agent skills (Skills) online: - - - -### Memory Management - -View and manage Agent memory online: - - - -### Channel Management - -Manage connected channels online with real-time connect/disconnect operations: - - - -### Scheduled Tasks - -View and manage scheduled tasks online, including one-time tasks, fixed intervals, and Cron expressions: - - - -### Logs - -View Agent runtime logs in real time for monitoring and troubleshooting: - - diff --git a/docs/en/channels/wechat-kf.mdx b/docs/en/channels/wechat-kf.mdx deleted file mode 100644 index f0711d51..00000000 --- a/docs/en/channels/wechat-kf.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: WeCom Customer Service -description: Integrate CowAgent into WeCom Customer Service (微信客服) ---- - -By binding a WeCom custom enterprise app to a WeCom Customer Service (微信客服) account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores. - - - WeCom Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported. - - -## 1. Prerequisites - -Required resources: - -1. A server with a public IP -2. A registered and verified WeCom account -3. WeCom Customer Service capability enabled - - - It is recommended to create a **dedicated** WeCom custom app for Customer Service rather than reusing the existing `wechatcom_app` one — otherwise the two channels will compete for the same callback URL. - - -## 2. Create a WeCom Custom App - -1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#apps), go to **Application Management → Create Application**: - - - -2. Click **My Enterprise** and find the **Corp ID** at the bottom of the page (it goes into `wechat_kf_corp_id`): - - - -3. Open the app you just created and click **"View"** next to Secret. The Secret will be pushed to the admin's phone via the WeCom app, where it can be viewed: - - - -4. Open the app's **Receive Messages → Set API Reception** page, click **"Random Generate"** to generate the **Token** and **EncodingAESKey**, and save them: - - - - - Saving the API reception configuration will fail at this point because the program has not started yet. Come back to save it after the project is running. - - -## 3. Configuration and Run - -Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token / EncodingAESKey): - - - - Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeCom Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect. - - - - - Add the following configuration to `config.json` (each parameter maps to a field shown in the screenshots above): - - ```json - { - "channel_type": "wechat_kf", - "wechat_kf_corp_id": "YOUR_CORP_ID", - "wechat_kf_secret": "YOUR_SECRET", - "wechat_kf_token": "YOUR_TOKEN", - "wechat_kf_aes_key": "YOUR_AES_KEY", - "wechat_kf_port": 9888 - } - ``` - - | Parameter | Description | - | --- | --- | - | `wechat_kf_corp_id` | Corp ID | - | `wechat_kf_secret` | Secret of the WeCom custom app bound to Customer Service | - | `wechat_kf_token` | Token from the API reception config | - | `wechat_kf_aes_key` | EncodingAESKey from the API reception config | - | `wechat_kf_port` | Listening port, default 9888 | - - - -After connecting, start the program (the Web Console method restarts the channel automatically). When the log shows `Listening on http://0.0.0.0:9888/wxkf/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group). - -Then go back to **Receive Messages → Set API Reception** in the WeCom console and set the callback URL to `http://:9888/wxkf/`, then click Save. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received: - - - - - - - If URL verification fails or the configuration is unsuccessful: - 1. Ensure the server firewall is disabled and the security group allows the listening port (default 9888) - 2. Carefully check that Token, Secret, EncodingAESKey and other parameters are consistent, and the URL format is correct - 3. Verified WeCom accounts must use a filed domain matching the entity - - -## 4. Bind a WeCom Customer Service Account - -In the WeCom Admin Console, go to **WeCom Customer Service**, create a customer service account, and bind it to the custom app you created above: - - - - - - - -After binding, go to **WeCom Customer Service → Account Details**, and under **"Access Link"**: - -- Click **"Copy Link"** to get an access link like `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` -- Click **"Generate QR Code"** to get the corresponding QR code - -Distribute the link or QR code to your WeChat customers: - - - -## 5. Usage - -After WeChat users enter the customer service conversation via the link or QR code, they can chat with the AI across multiple turns, with support for text, image, and voice messages: - - - -Beyond that, leveraging the official WeChat ecosystem, WeCom Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeCom Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details: - - - -## FAQ - -Make sure the following dependencies are installed: - -```bash -pip install websocket-client pycryptodome -``` diff --git a/docs/en/channels/wechatmp.mdx b/docs/en/channels/wechatmp.mdx deleted file mode 100644 index 3c6c2c8b..00000000 --- a/docs/en/channels/wechatmp.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: WeChat Official Account -description: Integrate CowAgent with WeChat Official Accounts ---- - -CowAgent supports both personal subscription accounts and enterprise service accounts. - -| Type | Requirements | Features | -| --- | --- | --- | -| **Personal Subscription** | Available to individuals | Sends a placeholder reply first; users must send a message to retrieve the full response | -| **Enterprise Service** | Enterprise with verified customer service API | Can proactively push replies to users | - - - Official Accounts only support server and Docker deployment, not local run mode. Install extended dependencies: `pip3 install -r requirements-optional.txt` - - -## 1. Personal Subscription Account - -Add the following configuration to `config.json`: - -```json -{ - "channel_type": "wechatmp", - "single_chat_prefix": [""], - "wechatmp_app_id": "wx73f9******d1e48", - "wechatmp_app_secret": "YOUR_APP_SECRET", - "wechatmp_aes_key": "", - "wechatmp_token": "YOUR_TOKEN", - "wechatmp_port": 80 -} -``` - -### Setup Steps - -These configurations must be consistent with the [WeChat Official Account Platform](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev). Navigate to **Settings & Development → Basic Configuration → Server Configuration** and configure as shown below: - - - -1. Enable the developer secret on the platform (corresponds to `wechatmp_app_secret`), and add the server IP to the whitelist -2. Fill in the `config.json` with the official account parameters matching the platform configuration -3. Start the program, which listens on port 80 (use `sudo` if you don't have permission; stop any process occupying port 80) -4. **Enable server configuration** on the official account platform and submit. A successful save means the configuration is complete. Note that the **"Server URL"** must be in the format `http://{HOST}/wx`, where `{HOST}` can be the server IP or domain - -After following the account and sending a message, you should see the following result: - - - -Due to subscription account limitations, short replies (within 15s) can be returned immediately, but longer replies will first send a "Thinking..." placeholder, requiring users to send any text to retrieve the answer. Enterprise service accounts can solve this with the customer service API. - - - **Voice Recognition**: You can use WeChat's built-in voice recognition. Enable "Receive Voice Recognition Results" under "Settings & Development → API Permissions" on the official account management page. - - -## 2. Enterprise Service Account - -The setup process for enterprise service accounts is essentially the same as personal subscription accounts, with the following differences: - -1. Register an enterprise service account on the platform and complete WeChat certification. Confirm that the **Customer Service API** permission has been granted -2. Set `"channel_type": "wechatmp_service"` in `config.json`; other configurations remain the same -3. Even for longer replies, they can be proactively pushed to users without requiring manual retrieval - -```json -{ - "channel_type": "wechatmp_service", - "single_chat_prefix": [""], - "wechatmp_app_id": "YOUR_APP_ID", - "wechatmp_app_secret": "YOUR_APP_SECRET", - "wechatmp_aes_key": "", - "wechatmp_token": "YOUR_TOKEN", - "wechatmp_port": 80 -} -``` diff --git a/docs/en/channels/wecom-bot.mdx b/docs/en/channels/wecom-bot.mdx deleted file mode 100644 index 2cb51fff..00000000 --- a/docs/en/channels/wecom-bot.mdx +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: WeCom Bot -description: Connect CowAgent to WeCom AI Bot (WebSocket long connection) ---- - -> Connect CowAgent via WeCom AI Bot, supporting both internal direct messages and group chats. No public IP required — uses a WebSocket long connection, with Markdown rendering and streaming output. - - - WeCom Bot and WeCom App are two different integration methods. WeCom Bot uses a WebSocket long connection and requires no public IP or domain, making setup much simpler. - - -## 1. Connection methods - -### Option A: One-click QR scan (recommended) - -No need to create the bot ahead of time. Start CowAgent and open the Web console (local URL: http://127.0.0.1:9899/), go to the **Channels** tab, click **Connect Channel**, choose **WeCom Bot**, switch to **QR scan** mode, and scan the QR code with **WeCom** — bot creation and connection complete automatically. - - - - - After a successful scan, you can further configure the bot (name, avatar, visibility scope, etc.) in **WeCom Workbench → AI Bot**. - - -### Option B: Manual creation - -Create the AI Bot in WeCom and obtain the Bot ID and Secret, then connect via the Web console or config file. - -**Step 1: Create the AI Bot** - -1. Open the WeCom client, go to **Workbench**, and click **AI Bot**: - - - -2. Click **Create Bot → Manual Creation**: - - - -3. Scroll to the bottom of the right panel and select **API Mode**: - - - -4. Set the bot name, avatar, and visibility scope. Choose **Long Connection** mode, save the **Bot ID** and **Secret**, then click Save. - -**Step 2: Connect to CowAgent** - - - - Open the Web console, go to the **Channels** tab, click **Connect Channel**, choose **WeCom Bot**, switch to **Manual** mode, enter the Bot ID and Secret, and click Connect. - - - - - Add the following to `config.json`, then start CowAgent: - - ```json - { - "channel_type": "wecom_bot", - "wecom_bot_id": "YOUR_BOT_ID", - "wecom_bot_secret": "YOUR_SECRET" - } - ``` - - | Parameter | Description | - | --- | --- | - | `wecom_bot_id` | Bot ID of the AI Bot | - | `wecom_bot_secret` | Secret of the AI Bot | - - - -The log line `[WecomBot] Subscribe success` confirms the connection is established. - -## 2. Supported features - -| Feature | Status | -| --- | --- | -| Direct chat | ✅ | -| Group chat (@bot) | ✅ | -| Text messages | ✅ Send / Receive | -| Image messages | ✅ Send / Receive | -| File messages | ✅ Send / Receive | -| Streaming replies | ✅ | -| Scheduled push messages | ✅ | - -## 3. Usage - -Search for the bot's name inside WeCom to start a direct chat. - -To use the bot in an internal group chat, add it to the group and @-mention it. - - diff --git a/docs/en/channels/wecom.mdx b/docs/en/channels/wecom.mdx deleted file mode 100644 index e0aca17f..00000000 --- a/docs/en/channels/wecom.mdx +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: WeCom -description: Integrate CowAgent into WeCom enterprise app ---- - -Integrate CowAgent into WeCom through a custom enterprise app, supporting one-on-one chat for internal employees. - - - WeCom only supports Docker deployment or server Python deployment. Local run mode is not supported. - - -## 1. Prerequisites - -Required resources: - -1. A server with public IP (overseas server, or domestic server with a proxy for international API access) -2. A registered WeCom account (individual registration is possible but cannot be certified) -3. Certified WeCom accounts additionally require a domain filed under the corresponding entity - -## 2. Create WeCom App - -1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#profile), click **My Enterprise** and find the **Corp ID** at the bottom of the page. Save this ID for the `wechatcom_corp_id` configuration field. - -2. Switch to **Application Management** and click Create Application: - - - -3. On the application creation page, record the `AgentId` and `Secret`: - - - -4. Click **Set API Reception** to configure the application interface: - - - -- URL format: `http://ip:port/wxcomapp` (certified enterprises must use a filed domain) -- Generate random `Token` and `EncodingAESKey` and save them for the configuration file - - - The API reception configuration cannot be saved at this point because the program hasn't started yet. Come back to save it after the project is running. - - -## 3. Configuration and Run - -Add the following configuration to `config.json` (the mapping between each parameter and the WeCom console is shown in the screenshots above): - -```json -{ - "channel_type": "wechatcom_app", - "single_chat_prefix": [""], - "wechatcom_corp_id": "YOUR_CORP_ID", - "wechatcomapp_token": "YOUR_TOKEN", - "wechatcomapp_secret": "YOUR_SECRET", - "wechatcomapp_agent_id": "YOUR_AGENT_ID", - "wechatcomapp_aes_key": "YOUR_AES_KEY", - "wechatcomapp_port": 9898 -} -``` - -| Parameter | Description | -| --- | --- | -| `wechatcom_corp_id` | Corp ID | -| `wechatcomapp_token` | Token from API reception config | -| `wechatcomapp_secret` | App Secret | -| `wechatcomapp_agent_id` | App AgentId | -| `wechatcomapp_aes_key` | EncodingAESKey from API reception config | -| `wechatcomapp_port` | Listen port, default 9898 | - -After configuration, start the program. When the log shows `http://0.0.0.0:9898/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group). - -After the program starts, return to the WeCom Admin Console to save the **Message Server Configuration**. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received: - - - - - If the URL configuration callback fails or the configuration is unsuccessful: - 1. Ensure the server firewall is disabled and the security group allows the listening port - 2. Carefully check that Token, Secret Key and other parameter configurations are consistent, and that the URL format is correct - 3. Certified WeCom accounts must configure a filed domain matching the entity - - -## 4. Usage - -Search for the app name you just created in WeCom to start chatting directly. You can run multiple instances listening on different ports to create multiple WeCom apps: - - - -To allow external personal WeChat users to use the app, go to **My Enterprise → WeChat Plugin**, share the invite QR code. After scanning and following, personal WeChat users can join and chat with the app: - - - -## FAQ - -Make sure the following dependencies are installed: - -```bash -pip install websocket-client pycryptodome -``` diff --git a/docs/en/channels/weixin.mdx b/docs/en/channels/weixin.mdx deleted file mode 100644 index 0acb0a43..00000000 --- a/docs/en/channels/weixin.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: WeChat -description: Connect CowAgent to personal WeChat (via the official API) ---- - -> Connect CowAgent to your personal WeChat — scan to log in, no public IP required. Supports text, image, voice, file, and video messages in 1-on-1 chats. Backed by WeChat's official API; safe to use. After connecting, a bot assistant is added to your conversation list without affecting normal account usage. - -## 1. Setup and run - -### Option A: Web console - -Start CowAgent and open the Web console (local URL: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **WeChat**, and follow the prompts to scan in. - - - -### Option B: Config file - -Set `channel_type` to `weixin` in `config.json`: - -```json -{ - "channel_type": "weixin" -} -``` - -After starting CowAgent, a QR code is displayed in the terminal. Scan it with WeChat to complete login. - - - - - 1. For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel. - 2. The WeChat client must be on version **8.0.69** or higher. - - -## 2. Usage - -Once authorized, the integration completes and you can start chatting. A bot assistant is created in your WeChat conversation list, leaving normal account usage unaffected. - -> You can find the bot at any time by searching for **"微信ClawBot"**. You may also rename it, change its avatar, pin it to the top of your conversation list, and so on. - - - -## 3. Login - -### QR code login - -On first startup, a QR code appears in the terminal (valid for around 2 minutes). Scan it with WeChat and confirm on your phone to log in. - -- The QR code refreshes automatically when it expires -- The `qrcode` dependency is already included in `requirements.txt`, so the QR code renders directly in the terminal after install - -### Credential persistence - -After a successful login, credentials are saved to `~/.weixin_cow_credentials.json`. Subsequent startups reuse the saved credentials with no need to re-scan. - -To force a re-login, delete the credentials file and restart. - -### Session expiry - -When the WeChat session expires (errcode `-14`), CowAgent 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 (built-in speech recognition) | diff --git a/docs/en/cli/general.mdx b/docs/en/cli/general.mdx deleted file mode 100644 index 8107fcb5..00000000 --- a/docs/en/cli/general.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -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 -``` - -## cancel - -Abort the agent task currently running in this session. When the agent is busy with a long task (e.g. multi-turn tool calls or a long streaming response), send `/cancel` and the agent will stop before the next tool execution. Available across all channels — Web, WeChat, WeCom, Feishu, etc. - -```text -/cancel -``` - -## 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-v4-flash -``` - -**Configurable items:** - -| Item | Description | Example | -| --- | --- | --- | -| `model` | AI model name | `deepseek-v4-flash` | -| `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` | -| `enable_thinking` | Enable deep thinking mode | `true` / `false` | - - - 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/cli/index.mdx b/docs/en/cli/index.mdx deleted file mode 100644 index e13b45a3..00000000 --- a/docs/en/cli/index.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -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 ...) - -Memory & Knowledge: - memory Memory distillation (dream) - knowledge View knowledge base stats and structure - -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 | -| `/cancel` | Abort the currently running agent task | -| `/config` | View or modify runtime configuration | -| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) | -| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) | -| `/knowledge` | View knowledge base statistics | -| `/knowledge list` | View knowledge base directory structure | -| `/knowledge on\|off` | Enable or disable knowledge base | -| `/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 | ✓ | ✓ | -| cancel | ✗ | ✓ | -| config | ✗ | ✓ | -| context | — | ✓ | -| memory (subcommands) | ✗ | ✓ | -| knowledge (subcommands) | ✓ | ✓ | -| 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/cli/memory-knowledge.mdx b/docs/en/cli/memory-knowledge.mdx deleted file mode 100644 index c748120c..00000000 --- a/docs/en/cli/memory-knowledge.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Memory & Knowledge -description: Memory distillation and knowledge base management commands ---- - -## memory - -Manage the Agent's long-term memory system. - -### memory dream - -Manually trigger memory distillation (Deep Dream) — consolidate recent daily memories into MEMORY.md and generate a dream diary. - -```text -/memory dream [N] -``` - -- `N`: Consolidate the last N days of memory (default 3, max 30) -- Runs asynchronously in the background; you'll be notified in chat when complete -- Works without Agent initialization — can be used before the first conversation - -**Examples:** - -```text -/memory dream # Consolidate last 3 days -/memory dream 7 # Consolidate last 7 days -/memory dream 30 # Consolidate last 30 days (full) -``` - -On the Web console, the completion notification includes clickable links to view the updated MEMORY.md and dream diary. - - - The system automatically runs distillation daily at 23:55 (lookback 1 day). Manual trigger is useful for consolidating historical memories after first deployment, or when you need an immediate memory update. - - -## knowledge - -View and manage the personal knowledge base. Shows statistics by default. - -```text -/knowledge -``` - -### knowledge list - -View the knowledge base directory tree. - -```text -/knowledge list -``` - -### knowledge on / off - -Enable or disable the knowledge base. When disabled, knowledge prompts and file indexing are not injected. - -```text -/knowledge on -/knowledge off -``` - - - In the terminal CLI, `cow knowledge` and `cow knowledge list` are available, but `on|off` is only supported in chat (requires runtime effect). - diff --git a/docs/en/cli/process.mdx b/docs/en/cli/process.mdx deleted file mode 100644 index 452df03a..00000000 --- a/docs/en/cli/process.mdx +++ /dev/null @@ -1,123 +0,0 @@ ---- -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/cli/skill.mdx b/docs/en/cli/skill.mdx deleted file mode 100644 index 99e41dec..00000000 --- a/docs/en/cli/skill.mdx +++ /dev/null @@ -1,210 +0,0 @@ ---- -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 -``` - - -Example output: - -``` -📦 Installed skills (3/4) - -✅ pptx - Use this skill any time a .pptx file is involved… - Source: cowhub - -✅ skill-creator - Create, install, or update skills… - Source: builtin - -⏸️ image-vision (disabled) - Image understanding and visual analysis - Source: builtin -``` - -**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 deleted file mode 100644 index 1ef580d2..00000000 --- a/docs/en/guide/manual-install.mdx +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: Manual Install -description: Deploy CowAgent manually (source code / Docker) ---- - -## Source Code Deployment - -### 1. Clone the project - -```bash -git clone https://github.com/zhayujie/CowAgent -cd CowAgent/ -``` - - - For network issues, use the mirror: https://gitee.com/zhayujie/CowAgent - - -### 2. Install dependencies - -Core dependencies (required): - -```bash -pip3 install -r requirements.txt -``` - -Optional dependencies (recommended): - -```bash -pip3 install -r requirements-optional.txt -``` - -### 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: - -```bash -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. - -### 5. Run - -**Using Cow CLI (recommended):** - -```bash -cow start -``` - -**Or run locally in foreground:** - -```bash -python3 app.py -``` - -By default, the Web console starts. Access `http://localhost:9899` to chat. - -**Background run on server (without CLI):** - -```bash -nohup python3 app.py & tail -f nohup.out -``` - - - **Deploying on a server?** By default `web_host` only listens on `127.0.0.1` (local access). Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group — ideally restricted to specific IPs. - - -## Docker Deployment - -Docker deployment does not require cloning source code or installing dependencies. For Agent mode, source deployment is recommended for broader system access. - - - Requires [Docker](https://docs.docker.com/engine/install/) and docker-compose. - - -**1. Download config** - -```bash -curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml -``` - -Edit `docker-compose.yml` with your configuration. - -**2. Start container** - -```bash -sudo docker compose up -d -``` - -**3. View logs** - -```bash -sudo docker logs -f chatgpt-on-wechat -``` - - - **Running in Docker?** Set `WEB_HOST` to `0.0.0.0` in `docker-compose.yml` so the console is reachable from outside the container, and set `WEB_PASSWORD` to protect it. Make sure port `9899` is mapped to the host and open in your firewall or security group. - - -## Core Configuration - -```json -{ - "channel_type": "web", - "model": "deepseek-v4-flash", - "deepseek_api_key": "", - "agent": true, - "agent_workspace": "~/cow", - "agent_max_context_tokens": 40000, - "agent_max_context_turns": 30, - "agent_max_steps": 15, - "cow_lang": "auto" -} -``` - -| Parameter | Description | Default | -| --- | --- | --- | -| `channel_type` | Channel type | `web` | -| `model` | Model name | `deepseek-v4-flash` | -| `agent` | Enable Agent mode | `true` | -| `agent_workspace` | Agent workspace path | `~/cow` | -| `agent_max_context_tokens` | Max context tokens | `40000` | -| `agent_max_context_turns` | Max context turns | `30` | -| `agent_max_steps` | Max decision steps per task | `15` | -| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | - - - Full configuration options are in the project [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py). - diff --git a/docs/en/guide/quick-start.mdx b/docs/en/guide/quick-start.mdx deleted file mode 100644 index 343956dc..00000000 --- a/docs/en/guide/quick-start.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: One-click Install -description: One-click install and manage CowAgent with scripts ---- - -The project provides scripts for one-click install, configuration, startup, and management. Script-based deployment is recommended for quick setup. - -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) - ``` - - - ```powershell - irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex - ``` - - - -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 `~/CowAgent` -4. Install Python dependencies and Cow CLI -5. Guided configuration for AI model and channel -6. Start service - -By default, the Web console starts after installation. Access `http://localhost:9899` to begin chatting. - - - **Deploying on a server?** By default `web_host` only listens on `127.0.0.1` (local access only). Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group — ideally restricted to specific IPs. - - -## Management Commands - -After installation, use the `cow` command to manage the service: - -| Command | Description | -| --- | --- | -| `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 | -| `cow install-browser` | Install browser tool dependencies | - -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/guide/upgrade.mdx b/docs/en/guide/upgrade.mdx deleted file mode 100644 index d1cd5df6..00000000 --- a/docs/en/guide/upgrade.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Upgrade -description: How to upgrade CowAgent ---- - -## Recommended: One-line upgrade - -Use `cow update` to pull the latest code and restart the service in one step: - -```bash -cow update -``` - -The command runs the following automatically: - -1. Pull the latest code (`git pull`) -2. Stop the running service -3. Update Python dependencies -4. Reinstall the CLI -5. Start the service - - - If the Cow CLI is not installed, `./run.sh update` performs the same operations. - - -## Manual upgrade - -Run the following inside the project root: - -```bash -git pull -pip3 install -r requirements.txt -pip3 install -e . -``` - -Then restart the service: - -```bash -# Using Cow CLI (recommended) -cow restart - -# Or using run.sh -./run.sh restart - -# Or restart manually with nohup -kill $(ps -ef | grep app.py | grep -v grep | awk '{print $2}') -nohup python3 app.py & tail -f nohup.out -``` - -## Docker upgrade - -Run the following in the directory containing `docker-compose.yml`: - -```bash -sudo docker compose pull -sudo docker compose up -d -``` - - - Back up `config.json` before upgrading. For Docker deployments, mount the workspace directory as a volume to persist data across upgrades. - diff --git a/docs/en/intro/architecture.mdx b/docs/en/intro/architecture.mdx deleted file mode 100644 index 98084b48..00000000 --- a/docs/en/intro/architecture.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Architecture -description: CowAgent 2.0 system architecture and core design ---- - -CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistant with Agent architecture, featuring autonomous thinking, task planning, long-term memory, and skill extensibility. - -## System Architecture - -CowAgent's architecture consists of the following core modules: - -CowAgent Architecture - -| Module | Description | -| --- | --- | -| **Plan** | Understands user intent, decomposes complex tasks into multi-step plans, and iteratively invokes tools until the goal is achieved | -| **Memory** | Automatically persists important information as core memory and daily memory, with hybrid keyword and vector retrieval for cross-session context continuity | -| **Knowledge** | Organizes structured knowledge by topic. The Agent autonomously distills valuable information into Markdown pages, maintaining indexes and cross-references to build a growing knowledge network | -| **Tools** | Core capability for Agent to access OS resources. 10+ built-in tools including file read/write, terminal, browser, scheduler, memory search, web search, and more | -| **Skills** | Loads and manages Skills. Supports one-click installation from Skill Hub, GitHub, and more, or custom skill creation through conversation | -| **Models** | Model layer with unified access to OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, and other mainstream LLMs | -| **Channels** | Message channel layer for receiving and sending messages. Supports Web console, WeChat, Feishu, DingTalk, WeCom, WeChat Official Account, and more with a unified protocol | -| **CLI** | Command-line system providing terminal commands (`cow`) and chat commands (`/`) for process management, skill installation, configuration, knowledge base management, and more | - -## Agent Mode Workflow - -When Agent mode is enabled, CowAgent runs as an autonomous agent with the following workflow: - -1. **Receive Message** — Receive user input through channels -2. **Understand Intent** — Analyze task requirements and context -3. **Plan Task** — Break complex tasks into multiple steps -4. **Invoke Tools** — Select and execute appropriate tools for each step -5. **Update Memory & Knowledge** — Store important information in long-term memory and organize structured knowledge into the knowledge base -6. **Return Result** — Send execution results back to the user - -## Workspace Directory Structure - -The Agent workspace is located at `~/cow` by default and stores system prompts, memory files, and skill files: - -``` -~/cow/ -├── SYSTEM.md # Agent system prompt -├── USER.md # User profile -├── MEMORY.md # Core memory -├── memory/ # Long-term memory storage -│ └── YYYY-MM-DD.md # Daily memory -├── knowledge/ # Personal knowledge base -│ ├── index.md # Knowledge index -│ └── / # Topic-based pages -└── skills/ # Custom skills - ├── skill-1/ - └── skill-2/ -``` - -Secret keys are stored separately in `~/.cow` directory for security: - -``` -~/.cow/ -└── .env # Secret keys for skills -``` - -## Core Configuration - -Configure Agent mode parameters in `config.json`: - -```json -{ - "agent": true, - "agent_workspace": "~/cow", - "agent_max_context_tokens": 50000, - "agent_max_context_turns": 20, - "agent_max_steps": 20, - "enable_thinking": false, - "cow_lang": "auto" -} -``` - -| Parameter | Description | Default | -| --- | --- | --- | -| `agent` | Enable Agent mode | `true` | -| `agent_workspace` | Workspace path | `~/cow` | -| `agent_max_context_tokens` | Max context tokens | `50000` | -| `agent_max_context_turns` | Max context turns | `20` | -| `agent_max_steps` | Max decision steps per task | `20` | -| `enable_thinking` | Enable deep-thinking mode | `false` | -| `knowledge` | Enable personal knowledge base | `true` | -| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | diff --git a/docs/en/intro/features.mdx b/docs/en/intro/features.mdx deleted file mode 100644 index 8b65f18d..00000000 --- a/docs/en/intro/features.mdx +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: Features -description: CowAgent long-term memory, task planning, skills system, CLI commands, and browser tool in detail ---- - -## 1. Long-term Memory - -The memory system enables the Agent to remember important information over time, using a three-tier memory flow: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term), forming a complete memory lifecycle. - -On first launch, the Agent proactively asks the user for key information and records it in the workspace (default `~/cow`) — including agent settings, user identity, and memory files. - -In subsequent long-term conversations, the Agent intelligently stores or retrieves memory as needed, continuously updating its own settings, user preferences, and memory files. **Deep Dream** distillation runs daily, consolidating scattered daily memories into refined long-term memory and generating a narrative-style dream diary. - - - - - -See [Long-term Memory](/en/memory) and [Deep Dream](/en/memory/deep-dream) for details. - -## 2. Personal Knowledge Base - -> The knowledge base system enables the Agent to continuously accumulate and organize structured knowledge. Unlike memory which records along a timeline, the knowledge base is organized by topics, transforming articles, conversation insights, and learning materials into interconnected Markdown pages that form a continuously growing knowledge network. - -The Agent automatically organizes valuable information from conversations into knowledge pages, maintaining cross-references and indexes. The Web console provides document browsing and knowledge graph visualization. Knowledge is stored in `~/cow/knowledge/` within the workspace. - -- **Auto-organization**: The Agent autonomously extracts and organizes structured knowledge during conversations, maintaining indexes and cross-references -- **Knowledge graph**: Automatically builds a knowledge graph from cross-references between pages, with interactive graph visualization in the Web console -- **Chat integration**: Knowledge document links referenced in Agent replies can be clicked directly in the Web console for viewing -- **CLI management**: Use `/knowledge` commands to view stats, browse directory, and toggle the feature with `/knowledge on|off` - - - - - -See [Personal Knowledge Base](/en/knowledge) for details. - -## 3. Task Planning and Tool Use - -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, browser, file send, scheduler, memory search, web search, environment config, and more. - -### 3.1 Terminal and File Access - -Access to the OS terminal and file system is the most fundamental and core capability. Many other tools and skills build on top of this. Users can interact with the Agent from a mobile device to operate resources on their personal computer or server: - - - - - -### 3.2 Programming Capability - -Combining programming and system access, the Agent can execute the complete **Vibecoding workflow** — from information search, asset generation, coding, testing, deployment, Nginx configuration, to publishing — all triggered by a single command from your phone: - - - - - -### 3.3 Scheduled Tasks - -The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time tasks, fixed intervals, and Cron expressions**. Tasks can be triggered as either a **fixed message send** or an **Agent dynamic task** execution: - - - - - -### 3.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: - - - - - -### 3.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: - - - - - -## 4. Skills System - -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. - -### 4.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: - - - - - -### 4.2 Web Search and Image Recognition - -- **Web search:** Built-in `web_search` tool, supports multiple search engines. Configure `BOCHA_API_KEY` or `LINKAI_API_KEY` to enable. -- **Image recognition:** Built-in `openai-image-vision` skill, supports `gpt-4.1-mini`, `gpt-4.1`, and other models. Requires `OPENAI_API_KEY`. - - - - - -### 4.3 Skill Hub - -Visit [skills.cowagent.ai](https://skills.cowagent.ai/) to browse all available skills, or use commands in conversation: - -```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. - - - -## 5. 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/cli) for details. diff --git a/docs/en/intro/index.mdx b/docs/en/intro/index.mdx deleted file mode 100644 index 373383b2..00000000 --- a/docs/en/intro/index.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Introduction -description: CowAgent - Open-source super AI assistant and Agent Harness ---- - -
- CowAgent -
- -**CowAgent** is an open-source super AI assistant and Agent Harness. It proactively plans tasks, runs tools and skills, and autonomously grows with memory and knowledge. - -CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider, run it across Web and major IM platforms, 24/7 on a personal computer or server. - - - - Open-source repository — Star and contribute - - - No setup required — experience CowAgent instantly - - - -## Core Capabilities - - - - Decomposes complex tasks and executes them step by step, looping over tools and skills until the goal is reached. - - - Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval. - - - Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing. - - - A complete skill creation and execution engine. Install from Skill Hub or generate custom skills via natural-language conversation. - - - First-class support for text, images, voice, and files — recognition, generation, and delivery. - - - Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and more — with native MCP integration. - - - Terminal CLI and in-chat commands for process management, skill installation, configuration, and context inspection. - - - Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click. - - - A single Agent simultaneously serves Web, WeChat, Feishu, DingTalk, WeCom, QQ, and Official Accounts. - - - -## Quick Start - -Run one of the commands below to install, configure, and start CowAgent in a single step: - - - - ```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 - ``` - - - -Once started, open `http://localhost:9899` to access the **Web console** — the unified place to chat, configure providers, connect channels, and install skills. - - - - Complete installation and run guide - - - CowAgent system architecture - - - -## Disclaimer - -1. This project is licensed under the [MIT License](https://github.com/zhayujie/CowAgent/blob/master/LICENSE) and is intended for technical research and learning. You are responsible for complying with applicable laws and regulations in your jurisdiction; the maintainers assume no liability for any consequences arising from use of this project. -2. **Cost & safety:** Agent mode consumes substantially more tokens than plain chat — pick models that balance quality and cost. The Agent has access to your local operating system; deploy only in trusted environments. -3. CowAgent is a pure open-source project and does not participate in, authorize, or issue any cryptocurrency. - -## Community - -Scan the WeChat QR code to join the open-source community group: - - diff --git a/docs/en/knowledge/index.mdx b/docs/en/knowledge/index.mdx deleted file mode 100644 index f1610dc9..00000000 --- a/docs/en/knowledge/index.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Personal Knowledge Base -description: CowAgent personal knowledge base — structured knowledge accumulation, automatic organization, and knowledge graph ---- - -The personal knowledge base is the Agent's long-term structured knowledge store, saved in the `knowledge/` directory within the workspace. Unlike memory, which is organized by timeline, the knowledge base organizes content by topic — articles, conversation insights, and learning materials are structured into interlinked Markdown pages, forming a continuously growing knowledge network. - - - - - -## Core Concepts - -### Knowledge vs Memory - -| Dimension | Knowledge Base (knowledge/) | Long-term Memory (memory/) | -| --- | --- | --- | -| Organization | By topic, interlinked | By timeline, dated files | -| Writing | Agent actively structures content | Auto-summarized on context trimming | -| Content | Refined, structured knowledge | Raw conversation summaries | -| Use cases | Study notes, tech docs, project knowledge | Conversation history, event records | - -### Directory Structure - -``` -~/cow/knowledge/ -├── index.md # Knowledge index, entry point for all pages -├── log.md # Change log, records each write -├── concepts/ # Conceptual knowledge -│ └── machine-learning.md -├── entities/ # Entity knowledge (people, orgs, tools) -│ └── openai.md -└── sources/ # Source knowledge (articles, papers) - └── llm-wiki.md -``` - -The directory structure is flexible — the Agent automatically creates appropriate category directories based on actual content. Users can also customize the organization. - -## Automatic Organization - -Knowledge writing is an autonomous Agent behavior, triggered in these scenarios: - -- **User shares an article or document** — The Agent automatically extracts key information and creates a structured knowledge page -- **Conversation produces valuable conclusions** — The Agent organizes insights into knowledge pages and links them to existing knowledge -- **User explicitly requests organization** — Users can guide the Agent to organize and update knowledge through conversation - -Each knowledge page includes cross-reference links to related pages, gradually building a knowledge graph. - - - - - -## Knowledge Retrieval - -The Agent can retrieve knowledge during conversation through: - -- **Index lookup** — Quickly locate relevant pages via `knowledge/index.md` -- **Semantic search** — Search knowledge content via the `memory_search` tool -- **Direct read** — Read specific knowledge files via the `memory_get` tool - -## Web Console - -The web console provides a dedicated "Knowledge" module with: - -- **Document browsing** — Tree-style directory structure, searchable and collapsible, click to view content -- **Knowledge graph** — Interactive graph visualizing relationships between knowledge pages -- **Chat integration** — Knowledge document links referenced in Agent replies are clickable for direct navigation - - - - - - - - - -## CLI Commands - -Manage the knowledge base with the `/knowledge` command: - -| Command | Description | -| --- | --- | -| `/knowledge` | Show knowledge base statistics | -| `/knowledge list` | Display file directory as a tree | -| `/knowledge on` | Enable the knowledge base feature | -| `/knowledge off` | Disable the knowledge base feature | - -## Configuration - -| Parameter | Description | Default | -| --- | --- | --- | -| `knowledge` | Whether to enable the personal knowledge base | `true` | -| `agent_workspace` | Workspace path; knowledge is stored under the `knowledge/` subdirectory | `~/cow` | diff --git a/docs/en/memory/context.mdx b/docs/en/memory/context.mdx deleted file mode 100644 index 18fbdc8c..00000000 --- a/docs/en/memory/context.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -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** -- Once the LLM summary is ready, it is also **injected into the first user message** of the retained context, helping the model maintain conversational continuity -- Summary injection runs asynchronously in the background and takes effect from the next turn onward - -### 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 written to memory and a context summary injected - -### 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/deep-dream.mdx b/docs/en/memory/deep-dream.mdx deleted file mode 100644 index d0dd9e2d..00000000 --- a/docs/en/memory/deep-dream.mdx +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Deep Dream -description: Deep Dream — automatic distillation from conversations to permanent memory ---- - -Deep Dream is the core consolidation mechanism of CowAgent's memory system, responsible for distilling scattered daily memories into refined long-term memory and generating dream diaries. - -## Memory Flow - -CowAgent's memory progresses through three stages from short-term to long-term: - -``` -Conversation context (short-term) → Daily memory (mid-term) → MEMORY.md (long-term) -``` - -### 1. Conversation → Daily Memory - -When conversation context is trimmed or during the daily scheduled summary, the system uses LLM to summarize conversation content into key events, writing them to the daily memory file `memory/YYYY-MM-DD.md`. - -Triggers: -- **Context trimming** — Trimmed content is summarized when turn or token limits are exceeded -- **Daily schedule** — Automatically triggered at 23:55 -- **API overflow** — Emergency save of current conversation summary - -### 2. Daily Memory → MEMORY.md (Distillation) - -After the daily summary completes, Deep Dream automatically runs distillation: - -1. **Read materials** — Current `MEMORY.md` + today's daily memory -2. **LLM distillation** — Deduplicate, merge, prune, extract new information -3. **Overwrite MEMORY.md** — Output the refined long-term memory -4. **Generate dream diary** — Record discoveries and insights from the consolidation - -### 3. Role of MEMORY.md - -`MEMORY.md` is injected into the system prompt for every conversation, keeping the Agent aware of user preferences, decisions, and key facts. Therefore it must stay concise — Deep Dream targets approximately 30 entries or fewer. - -## Distillation Rules - -Deep Dream follows these consolidation rules: - -| Operation | Description | -| --- | --- | -| **Merge & refine** | Combine similar entries into single high-density statements | -| **Extract new** | Pull preferences, decisions, people, experiences from daily memory | -| **Conflict update** | When new info contradicts old entries, newer info takes precedence | -| **Clean invalid** | Remove temporary records, blank entries, formatting artifacts | -| **Remove redundancy** | Delete old entries already covered by more refined statements | - -## Dream Diary - -Each distillation generates a dream diary saved at `memory/dreams/YYYY-MM-DD.md`, written in a narrative style recording: - -- Duplications or contradictions found -- New insights extracted from daily memory -- Cleanups and optimizations performed -- Overall observations - -Dream diaries can be viewed in the Web console under "Memory → Dream Diary" tab. - - - - - -## Manual Trigger - -In addition to the automatic daily run, you can manually trigger distillation in chat: - -```text -/memory dream [N] -``` - -- `N`: Consolidate the last N days of memory (default 3, max 30) -- Runs asynchronously in the background; you'll be notified in chat when complete -- Web notifications include clickable links to view MEMORY.md and dream diary -- Works without Agent initialization — can be used before the first conversation - - - After first deployment, it's recommended to run `/memory dream 30` once to distill all historical daily memories into MEMORY.md. - - -## Safety Mechanisms - -| Mechanism | Description | -| --- | --- | -| **Skip on no content** | Distillation skipped when no daily memory exists, avoiding empty overwrites | -| **Input dedup** | In scheduled tasks, automatically skipped when input materials haven't changed | -| **Async execution** | Distillation runs in a background thread, never blocking conversation | -| **Sequential guarantee** | In scheduled tasks, daily flush completes before distillation starts | -| **No fabrication** | Prompt explicitly constrains consolidation to existing materials only | diff --git a/docs/en/memory/index.mdx b/docs/en/memory/index.mdx deleted file mode 100644 index e3f6513f..00000000 --- a/docs/en/memory/index.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Long-term Memory -description: CowAgent long-term memory system — file persistence, automatic writing, and hybrid retrieval ---- - -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 Architecture - -## 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. 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. - -### Dream Diary (memory/dreams/YYYY-MM-DD.md) - -A byproduct of the Deep Dream (memory distillation) process, recording discoveries, deduplication operations, and new insights from each consolidation. Stored in `~/cow/memory/dreams/` directory, named by date. - -## Automatic Writing - -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, and the discarded content is summarized by LLM into key information and written to the daily memory file. The summary is also asynchronously injected into the retained context for conversational continuity -- **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) -- [Deep Dream (memory distillation)](/en/memory/deep-dream) — Runs automatically after the daily summary, distilling daily memories into MEMORY.md and generating a dream diary -- **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. - -## Related Files - -Files related to memory in the workspace (default `~/cow`): - -| File | Description | -| --- | --- | -| `AGENT.md` | Agent personality and behavior settings | -| `USER.md` | User identity information and preferences | -| `RULE.md` | Custom rules and constraints | -| `MEMORY.md` | Core memory (long-term) | -| `memory/YYYY-MM-DD.md` | Daily memory (created on demand) | -| `memory/dreams/YYYY-MM-DD.md` | Dream diary (auto-generated by Deep Dream) | - -## Web Console - -The memory management page in the Web console allows browsing memory files and dream diaries, with tab switching support: - - - - - -## Configuration - -| Parameter | Description | Default | -| --- | --- | --- | -| `agent_workspace` | Workspace path, memory files stored under this directory | `~/cow` | -| `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/models/claude.mdx b/docs/en/models/claude.mdx deleted file mode 100644 index bb831eb8..00000000 --- a/docs/en/models/claude.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Claude -description: Anthropic Claude model configuration (Text Chat + Image Understanding) ---- - -Claude is provided by Anthropic and supports both text chat and image understanding. The mainstream Sonnet / Opus models natively support vision, so no separate Vision model needs to be specified. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "claude-opus-4-8", - "claude_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Supports `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) | -| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) | -| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy | - -### Model Selection - -| Model | Use Case | -| --- | --- | -| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks | -| `claude-opus-4-7` | Previous-generation Opus flagship | -| `claude-sonnet-4-6` | Balanced cost and speed, lower cost | -| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price | - -## Image Understanding - -Once `claude_api_key` is configured, the Agent's Vision tool automatically uses the Claude main model to recognize images, with no extra setup required. - -To manually specify a Vision model, set it explicitly in the configuration file: - -```json -{ - "tools": { - "vision": { - "model": "claude-sonnet-4-6" - } - } -} -``` diff --git a/docs/en/models/coding-plan.mdx b/docs/en/models/coding-plan.mdx deleted file mode 100644 index b09715eb..00000000 --- a/docs/en/models/coding-plan.mdx +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: Coding Plan -description: Coding Plan model configuration ---- - -> Coding Plan is a monthly subscription package offered by various providers, ideal for high-frequency Agent usage. CowAgent supports all Coding Plan providers via OpenAI-compatible mode. - - - Coding Plan API Base and API Key are usually separate from the standard pay-as-you-go ones. Please obtain them from each provider's platform. - - -## General Configuration - -All providers can be accessed via the OpenAI-compatible protocol, and can be quickly configured through the web console. Set the model provider to **OpenAI**, select a custom model and enter the model code, then fill in the corresponding provider's API Base and API Key: - - - -You can also configure directly in `config.json`: - -```json -{ - "bot_type": "openai", - "model": "MODEL_NAME", - "open_ai_api_base": "PROVIDER_CODING_PLAN_API_BASE", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `bot_type` | Must be `openai` (OpenAI-compatible mode) | -| `model` | Model name supported by the provider | -| `open_ai_api_base` | Provider's Coding Plan API Base URL | -| `open_ai_api_key` | Provider's Coding Plan API Key | - ---- - -## Alibaba Cloud - -```json -{ - "bot_type": "openai", - "model": "qwen3.5-plus", - "open_ai_api_base": "https://coding.dashscope.aliyuncs.com/v1", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | `qwen3.5-plus`, `qwen3-max-2026-01-23`, `qwen3-coder-next`, `qwen3-coder-plus`, `glm-5`, `glm-4.7`, `kimi-k2.5`, `MiniMax-M2.5` | -| `open_ai_api_base` | `https://coding.dashscope.aliyuncs.com/v1` | -| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | - -Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-quickstart?spm=a2c4g.11186623.help-menu-2400256.d_0_2_1.70115203zi5Igc), [Model List](https://help.aliyun.com/zh/model-studio/coding-plan) - ---- - -## MiniMax - -```json -{ - "bot_type": "openai", - "model": "MiniMax-M2.5", - "open_ai_api_base": "https://api.minimaxi.com/v1", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2` | -| `open_ai_api_base` | China: `https://api.minimaxi.com/v1`; Global: `https://api.minimax.io/v1` | -| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | - -Reference: [China Key](https://platform.minimaxi.com/docs/coding-plan/quickstart), [Model List](https://platform.minimaxi.com/docs/guides/pricing-coding-plan), [Global Key](https://platform.minimax.io/docs/coding-plan/quickstart) - ---- - -## GLM - -```json -{ - "bot_type": "openai", - "model": "glm-4.7", - "open_ai_api_base": "https://open.bigmodel.cn/api/coding/paas/v4", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | `glm-5`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | -| `open_ai_api_base` | China: `https://open.bigmodel.cn/api/coding/paas/v4`; Global: `https://api.z.ai/api/coding/paas/v4` | -| `open_ai_api_key` | Shared with standard API | - -Reference: [China Quick Start](https://docs.bigmodel.cn/cn/coding-plan/quick-start), [Global Quick Start](https://docs.z.ai/devpack/quick-start) - ---- - -## Kimi - -```json -{ - "bot_type": "moonshot", - "model": "kimi-for-coding", - "moonshot_base_url": "https://api.kimi.com/coding/v1", - "moonshot_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Use `kimi-for-coding` for auto-updating model, or specify a model such as `kimi-k2.6` | -| `moonshot_base_url` | `https://api.kimi.com/coding/v1` | -| `moonshot_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | - -Reference: [Key & Docs](https://www.kimi.com/code/docs/) - ---- - -## Volcengine - -```json -{ - "bot_type": "openai", - "model": "Doubao-Seed-2.0-Code", - "open_ai_api_base": "https://ark.cn-beijing.volces.com/api/coding/v3", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | `Doubao-Seed-2.0-Code`, `Doubao-Seed-2.0-pro`, `Doubao-Seed-2.0-lite`, `Doubao-Seed-Code`, `MiniMax-M2.5`, `Kimi-K2.5`, `GLM-4.7`, `DeepSeek-V3.2` | -| `open_ai_api_base` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| `open_ai_api_key` | Shared with standard API | - -Reference: [Quick Start](https://www.volcengine.com/docs/82379/1928261?lang=zh) diff --git a/docs/en/models/custom.mdx b/docs/en/models/custom.mdx deleted file mode 100644 index 45a7d2e1..00000000 --- a/docs/en/models/custom.mdx +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: Custom -description: Custom vendor configuration for third-party API proxies and local models ---- - -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: - -- **Third-party API proxies**: call multiple models through a unified API base -- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI -- **Private deployments**: model services deployed inside an enterprise - - - Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. - - -## Text Chat - -### Third-party API proxy - -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` - -| Parameter | Description | -| --- | --- | -| `bot_type` | Must be set to `custom` | -| `model` | Model name; any model name supported by the proxy service | -| `custom_api_key` | API key provided by the proxy service | -| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | - -### Local models - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: - -| Tool | Default API Base | -| --- | --- | -| [Ollama](https://ollama.com) | `http://localhost:11434/v1` | -| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | -| [LocalAI](https://localai.io) | `http://localhost:8080/v1` | - -### Switching Models - -Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: - -``` -/config model qwen3.5:27b -``` diff --git a/docs/en/models/deepseek.mdx b/docs/en/models/deepseek.mdx deleted file mode 100644 index 6de8d09b..00000000 --- a/docs/en/models/deepseek.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: DeepSeek -description: DeepSeek model configuration (Text Chat + Thinking Mode) ---- - -DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning. - -## Text Chat - -```json -{ - "model": "deepseek-v4-flash", - "deepseek_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Supports `deepseek-v4-flash` (Default), `deepseek-v4-pro` | -| `deepseek_api_key` | Create one on the [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 | - -### Model Selection - -| Model | Use Case | -| --- | --- | -| `deepseek-v4-flash` | Default recommended; fast and low cost | -| `deepseek-v4-pro` | Smarter; better for complex tasks | - -## Thinking Mode - -The V4 series (`deepseek-v4-flash` / `deepseek-v4-pro`) supports an explicit "thinking mode": before producing the final answer, the model emits a chain of thought (`reasoning_content`) to improve answer quality. - -### Toggle - -Controlled by the global `enable_thinking` config, and can also be toggled from the Web Console's configuration page: - -```json -{ - "enable_thinking": true -} -``` - -- `true`: the model thinks before answering across all channels. The Web Console displays the thinking process; IM channels (WeChat / WeCom / DingTalk / Feishu) do not show it but still get better answers. -- `false`: thinking is disabled, responses are faster, and time-to-first-token is lower. - -### Reasoning Effort - -Under thinking mode, `reasoning_effort` controls reasoning intensity: - -```json -{ - "enable_thinking": true, - "reasoning_effort": "high" -} -``` - -| Value | Use Case | -| --- | --- | -| `high` (Default) | Day-to-day Agent tasks; balanced reasoning and speed | -| `max` | Complex coding, long-horizon planning, strictly constrained tasks; deeper reasoning but more time and output tokens | - -`reasoning_effort` only takes effect when `enable_thinking` is `true`; it is ignored automatically when the model does not support thinking mode. - -### Behavior Notes - -- **Sampling parameters**: in thinking mode, `temperature`, `top_p`, `presence_penalty`, and `frequency_penalty` are ignored by the server (without errors). CowAgent automatically skips them. -- **Multi-turn tool calls**: when the history contains tool calls, DeepSeek requires every assistant message to include `reasoning_content`. CowAgent handles this automatically, so toggling thinking mode across turns will not cause errors. - - - `deepseek-v4-flash` is used by default; switch to `deepseek-v4-pro` for complex tasks; enable `enable_thinking` when deep reasoning is needed. - diff --git a/docs/en/models/doubao.mdx b/docs/en/models/doubao.mdx deleted file mode 100644 index 818275e5..00000000 --- a/docs/en/models/doubao.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Doubao -description: Doubao (Volcengine Ark) model configuration (Text / Image Understanding / Image Generation / Embedding) ---- - -Doubao (Volcengine Ark) supports text chat, image understanding, image generation (Seedream), and embedding. A single `ark_api_key` enables all capabilities. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "doubao-seed-2-0-pro-260215", - "ark_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Can be `doubao-seed-2-0-pro-260215`, `doubao-seed-2-0-code-preview-260215`, `doubao-seed-2-0-lite-260215`, etc. | -| `ark_api_key` | Create one in the [Volcengine Ark Console](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) | -| `ark_base_url` | Optional, defaults to `https://ark.cn-beijing.volces.com/api/v3` | - -## Image Understanding - -Once `ark_api_key` is configured, the Agent's Vision tool automatically uses `doubao-seed-2-0-pro-260215` to recognize images, with no extra setup required. - -To manually specify a Vision model: - -```json -{ - "tools": { - "vision": { - "model": "doubao-seed-2-0-pro-260215" - } - } -} -``` - -## Image Generation - -```json -{ - "skills": { - "image-generation": { - "model": "seedream-5.0-lite" - } - } -} -``` - -Available models: `seedream-5.0-lite`, `seedream-4.5`. - -## Embedding - -```json -{ - "embedding_provider": "doubao", - "embedding_model": "doubao-embedding-vision-251215" -} -``` - -The default model is `doubao-embedding-vision-251215` (multimodal embedding); the dimension (1024 or 2048) can be set via `embedding_dimensions` in the configuration file. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/en/models/gemini.mdx b/docs/en/models/gemini.mdx deleted file mode 100644 index b2d9520b..00000000 --- a/docs/en/models/gemini.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Gemini -description: Google Gemini model configuration (Text Chat + Image Understanding + Image Generation) ---- - -Google Gemini supports text chat, image understanding, and image generation (Nano Banana series). A single `gemini_api_key` enables all capabilities. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "gemini-3.5-flash", - "gemini_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Recommended: `gemini-3.5-flash`; also supports `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-3-pro-preview`, etc. See [official docs](https://ai.google.dev/gemini-api/docs/models) | -| `gemini_api_key` | Create one in [Google AI Studio](https://aistudio.google.com/app/apikey) | -| `gemini_api_base` | Optional, defaults to `https://generativelanguage.googleapis.com`. Can be changed to a third-party proxy | - -## Image Understanding - -All Gemini models natively support vision. Once `gemini_api_key` is configured, the Agent's Vision tool automatically uses the main model to recognize images, with no extra setup required. - -To manually specify a Vision model: - -```json -{ - "tools": { - "vision": { - "model": "gemini-3.1-flash-lite-preview" - } - } -} -``` - -## Image Generation - -```json -{ - "skills": { - "image-generation": { - "model": "gemini-3.1-flash-image-preview" - } - } -} -``` - -| Model ID | Alias | -| --- | --- | -| `gemini-3.1-flash-image-preview` | Nano Banana 2 | -| `gemini-3-pro-image-preview` | Nano Banana Pro | -| `gemini-2.5-flash-image` | Nano Banana | diff --git a/docs/en/models/glm.mdx b/docs/en/models/glm.mdx deleted file mode 100644 index 473a805c..00000000 --- a/docs/en/models/glm.mdx +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: GLM -description: Zhipu AI GLM model configuration (Text / Image Understanding / Speech-to-Text / Embedding) ---- - -Zhipu AI supports text chat, image understanding, speech-to-text (ASR), and embedding. A single `zhipu_ai_api_key` enables all capabilities. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "glm-5.1", - "zhipu_ai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Can be `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4-plus`, `glm-4-flash`, `glm-4-air`, etc. See [model codes](https://bigmodel.cn/dev/api/normal-model/glm-4) | -| `zhipu_ai_api_key` | Create one in the [Zhipu AI Console](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| `zhipu_ai_api_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` | - -## Image Understanding - -Zhipu's chat models (`glm-5.1`, `glm-5-turbo`, etc.) do not support vision; vision calls are uniformly routed to `glm-5v-turbo`. Once `zhipu_ai_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file. - -## Speech-to-Text (ASR) - -```json -{ - "voice_to_text": "zhipu", - "voice_to_text_model": "glm-asr-2512" -} -``` - -| Parameter | Description | -| --- | --- | -| `voice_to_text` | Set to `zhipu` to enable Zhipu ASR | -| `voice_to_text_model` | Optional, defaults to `glm-asr-2512` | - -Credentials are automatically reused from `zhipu_ai_api_key`. Audio files should be smaller than 25MB; oversized files may be rejected by the server. - -## Embedding - -```json -{ - "embedding_provider": "zhipu", - "embedding_model": "embedding-3" -} -``` - -Available models: `embedding-3`, `embedding-2`. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/en/models/index.mdx b/docs/en/models/index.mdx deleted file mode 100644 index 1a82d162..00000000 --- a/docs/en/models/index.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Models Overview -description: Model vendors supported by CowAgent and their capability matrix ---- - -CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow. - -## Capability Matrix - -A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power. - -| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | -| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [DeepSeek](/en/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [MiniMax](/en/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/en/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | -| [Gemini](/en/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | -| [OpenAI](/en/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [GLM](/en/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [Qwen](/en/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Doubao](/en/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | -| [Kimi](/en/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | -| [ERNIE](/en/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | -| [MiMo](/en/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | -| [LinkAI](/en/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Custom](/en/models/custom) | Local models / third-party proxies | ✅ | | | | | | - - - Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them. - - -## How to Configure - -**Option 1 (recommended):** Manage models and capabilities online via the [Web console](/en/channels/web), with no need to edit the configuration file: - - - -**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`. diff --git a/docs/en/models/kimi.mdx b/docs/en/models/kimi.mdx deleted file mode 100644 index 3292a976..00000000 --- a/docs/en/models/kimi.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Kimi -description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding) ---- - -Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "kimi-k2.6", - "moonshot_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Can be `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` | -| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) | -| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` | - -## Image Understanding - -Once `moonshot_api_key` is configured, the Agent's Vision tool automatically uses `kimi-k2.6` to recognize images, with no extra setup required. - -To manually specify a Vision model: - -```json -{ - "tools": { - "vision": { - "model": "kimi-k2.6" - } - } -} -``` diff --git a/docs/en/models/linkai.mdx b/docs/en/models/linkai.mdx deleted file mode 100644 index f60c2160..00000000 --- a/docs/en/models/linkai.mdx +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: LinkAI -description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform ---- - -A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "use_linkai": true, - "linkai_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `use_linkai` | Set to `true` to enable | -| `linkai_api_key` | Create one in the [Console](https://link-ai.tech/console/interface) | -| `model` | Can be any code from the [model list](https://link-ai.tech/console/models) | - -See [Model Service](https://link-ai.tech/console/models) for more. - -## Image Understanding - -Once configured, the Agent's Vision tool automatically calls multimodal models via the gateway, with no extra setup required. To manually specify a Vision model: - -```json -{ - "tools": { - "vision": { - "model": "gpt-5.4-mini" - } - } -} -``` - -Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.6-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc. - -## Image Generation - -```json -{ - "skills": { - "image-generation": { - "model": "gpt-image-2" - } - } -} -``` - -| Model ID | Alias | -| --- | --- | -| `gpt-image-2` | OpenAI | -| `gemini-3.1-flash-image-preview` | Nano Banana 2 | -| `gemini-3-pro-image-preview` | Nano Banana Pro | -| `seedream-5.0-lite` | ByteDance Doubao Seedream | - -## Speech-to-Text (ASR) - -```json -{ - "voice_to_text": "linkai" -} -``` - -ASR uses Whisper by default; credentials are automatically reused from `linkai_api_key`. - -## Text-to-Speech (TTS) - -The TTS gateway supports multiple underlying engines. The engine is selected by `text_to_voice_model`, and the available voices change with the engine. - -```json -{ - "text_to_voice": "linkai", - "text_to_voice_model": "doubao", - "tts_voice_id": "BV001_streaming" -} -``` - -| `text_to_voice_model` | Engine | -| --- | --- | -| `tts-1` | OpenAI · Multi-language (voices like `alloy` / `nova` / `echo`, etc.) | -| `doubao` | ByteDance Doubao · Rich Chinese voices | -| `baidu` | Baidu · Chinese broadcaster voices | - -Voices differ by engine; we recommend selecting them visually in the Web Console under "Model Management → Text-to-Speech". - -## Embedding - -```json -{ - "embedding_provider": "linkai", - "embedding_model": "text-embedding-3-small" -} -``` - -The default model is `text-embedding-3-small` (OpenAI-compatible). After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/en/models/mimo.mdx b/docs/en/models/mimo.mdx deleted file mode 100644 index 6f808b8e..00000000 --- a/docs/en/models/mimo.mdx +++ /dev/null @@ -1,136 +0,0 @@ ---- -title: MiMo -description: Xiaomi MiMo model configuration (Text Chat + Image Understanding + Text-to-Speech) ---- - -Xiaomi MiMo is a native omni-modal large model. A single `mimo_api_key` enables text chat, image understanding, and text-to-speech all at once. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console — no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "mimo-v2.5-pro", - "mimo_api_key": "YOUR_API_KEY", - "mimo_api_base": "https://api.xiaomimimo.com/v1" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Default recommendation: `mimo-v2.5-pro`; `mimo-v2.5` is also supported | -| `mimo_api_key` | Create one in the [MiMo Open Platform](https://platform.xiaomimimo.com/console/api-keys) | -| `mimo_api_base` | Optional, defaults to `https://api.xiaomimimo.com/v1` | - -### Model Selection - -| Model | Use Case | -| --- | --- | -| `mimo-v2.5-pro` | Flagship: native omni-modal + Agent capability, up to 1M tokens context | -| `mimo-v2.5` | General-purpose, native omni-modal (text / image / video / audio) | - -## Thinking Mode - -The MiMo V2.5 series enables "thinking mode" by default: the model emits `reasoning_content` (chain-of-thought) before the final answer, improving performance on complex tasks. - -Use the global `enable_thinking` flag to toggle visibility (also switchable from the Web Console settings): - -```json -{ - "enable_thinking": true -} -``` - -## Image Understanding - -Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models: - -- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup. -- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order. - -To force a specific Vision model, set it explicitly in the configuration: - -```json -{ - "tools": { - "vision": { - "provider": "mimo", - "model": "mimo-v2.5-pro" - } - } -} -``` - -## Text-to-Speech (TTS) - -```json -{ - "text_to_voice": "mimo", - "text_to_voice_model": "mimo-v2.5-tts", - "tts_voice_id": "冰糖" -} -``` - -| Parameter | Description | -| --- | --- | -| `text_to_voice_model` | Currently only `mimo-v2.5-tts` (preset voices + singing mode) | -| `tts_voice_id` | Preset voice name (Chinese voice IDs use the Chinese name directly) | - -### Preset Voices - -| Voice ID | Description | -| --- | --- | -| `Mia` | English · Female | -| `Chloe` | English · Female | -| `Milo` | English · Male | -| `Dean` | English · Male | -| `冰糖` | Chinese · Female (default) | -| `茉莉` | Chinese · Female | -| `苏打` | Chinese · Male | -| `白桦` | Chinese · Male | - - -You can also pick a voice visually from the Web Console under "Model Management → Text-to-Speech". - -### Style Control - -MiMo TTS supports embedding **audio tags** in the synthesis text to control emotion, tone, dialect, persona, and even singing. Tags must appear in the **text that will be synthesized to speech (i.e. the Agent's reply)**, with the overall style tag placed at the very beginning: - -``` -(style)content-to-synthesize -``` - -Half-width `()`, full-width `()`, and `[]` brackets are all accepted. Both Chinese and English style descriptors work — pick whichever language expresses the timbre most precisely. Common examples: - -| Category | Example tags | -| --- | --- | -| Basic emotions | `happy` `sad` `angry` `fear` `surprised` `excited` `aggrieved` `calm` `indifferent` | -| Compound emotions | `wistful` `relieved` `helpless` `guilty` `at ease` `uneasy` `touched` | -| Overall tone | `gentle` `aloof` `lively` `serious` `languid` `playful` `deep` `sharp` `cutting` | -| Voice character | `magnetic` `mellow` `bright` `ethereal` `childlike` `aged` `sweet` `husky` | -| Persona | `squeaky` `mature lady` `young boy` `uncle` `Taiwanese accent` | -| Dialect | `Northeastern` `Sichuan` `Henan` `Cantonese` | -| Role-play | `Sun Wukong` `Lin Daiyu` | -| Singing | `sing` / `singing` | - -Examples: - -- `(magnetic)The night is deep, and the city is still breathing.` -- `(gentle)Take a breath. You've got this.` -- `(serious)This is the final warning before the system reboots.` -- `(singing)Oh, when the saints go marching in…` - -You can also insert fine-grained audio tags at any position in the text to control breathing, laughter, pauses, etc. For example: - -``` -(nervous, deep breath) Phew… stay calm, stay calm. (faster pace) I've rehearsed this intro fifty times, it'll be fine. -``` - -See the [MiMo speech synthesis documentation](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5) for the full tag list. - - - When CowAgent calls TTS, the Agent's reply text (including any `(...)` tags) is forwarded directly to MiMo for synthesis. Tell the model in its persona / system prompt to "prefix replies with a `(style)` tag to control the tone", and IM channels (WeChat / Feishu / DingTalk / WeCom) will play voice replies with the corresponding emotion, dialect, or even singing. - diff --git a/docs/en/models/minimax.mdx b/docs/en/models/minimax.mdx deleted file mode 100644 index d945d2ea..00000000 --- a/docs/en/models/minimax.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: MiniMax -description: MiniMax model configuration (Text / Image Understanding / Image Generation / Text-to-Speech) ---- - -MiniMax supports text chat, image understanding, image generation, and text-to-speech. A single `minimax_api_key` enables all capabilities. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "MiniMax-M2.7", - "minimax_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Can be `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2.1-lightning`, `MiniMax-M2`, etc. | -| `minimax_api_key` | Create one in the [MiniMax Console](https://platform.minimaxi.com/user-center/basic-information/interface-key) | - -## Image Understanding - -MiniMax's M2.x chat models do not support vision natively; vision calls are uniformly routed to `MiniMax-Text-01`. Once `minimax_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file. - -## Image Generation - -```json -{ - "skills": { - "image-generation": { - "model": "image-01" - } - } -} -``` - -Available models: `image-01`. - -## Text-to-Speech (TTS) - -```json -{ - "text_to_voice": "minimax", - "text_to_voice_model": "speech-2.8-hd", - "tts_voice_id": "female-shaonv" -} -``` - -| Parameter | Description | -| --- | --- | -| `text_to_voice_model` | `speech-2.8-hd` (emotional rendering, natural sound), `speech-2.8-turbo` (ultra-fast), `speech-2.6-hd`, `speech-2.6-turbo` | -| `tts_voice_id` | Voice ID; supports Chinese / Cantonese / English / Japanese / Korean — 70+ voices in total | - -Common voice examples: - -| Voice ID | Description | -| --- | --- | -| `female-shaonv` | Chinese · Young Girl (Female) | -| `female-yujie` | Chinese · Mature Lady (Female) | -| `female-tianmei` | Chinese · Sweet Female (Female) | -| `male-qn-jingying` | Chinese · Elite Youth (Male) | -| `male-qn-badao` | Chinese · Dominant Youth (Male) | -| `Cantonese_GentleLady` | Cantonese · Gentle Female Voice | -| `English_Graceful_Lady` | English · Graceful Lady | - -For the full voice list (70+ voices across Chinese / Cantonese / English / Japanese / Korean), see the [system voice list](https://platform.minimaxi.com/docs/faq/system-voice-id), or select visually in the Web Console under "Model Management → Text-to-Speech". diff --git a/docs/en/models/openai.mdx b/docs/en/models/openai.mdx deleted file mode 100644 index f8715562..00000000 --- a/docs/en/models/openai.mdx +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: OpenAI -description: OpenAI model configuration (Text / Vision / Image / Speech / Embedding) ---- - -OpenAI offers the most complete coverage and can simultaneously serve text chat, vision understanding, image generation, speech-to-text (ASR), text-to-speech (TTS), and embedding. A single `open_ai_api_key` lets the Agent use all of these capabilities. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - - -## Text Chat - -```json -{ - "model": "gpt-5.5", - "open_ai_api_key": "YOUR_API_KEY", - "open_ai_api_base": "https://api.openai.com/v1" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency | -| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) | -| `open_ai_api_base` | Optional; change it to access a third-party proxy | -| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol | - -## Image Understanding - -OpenAI models like `gpt-5.5`, `gpt-5.4`, `gpt-4o`, and `gpt-4.1` natively support vision. Once `open_ai_api_key` is configured, the Agent's Vision tool automatically uses the main model to recognize images. If the main model does not support vision or you want to specify it explicitly, set it in the configuration file: - -```json -{ - "tools": { - "vision": { - "model": "gpt-5.4-mini" - } - } -} -``` - -Supported Vision models: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`. - -## Image Generation - -Specify the image generation model in the configuration file; the Agent automatically routes image generation skill calls to OpenAI: - -```json -{ - "skills": { - "image-generation": { - "model": "gpt-image-2" - } - } -} -``` - -Supported image generation models: `gpt-image-2`, `gpt-image-1`. - -## Speech-to-Text (ASR) - -```json -{ - "voice_to_text": "openai", - "voice_to_text_model": "gpt-4o-mini-transcribe" -} -``` - -| Parameter | Description | -| --- | --- | -| `voice_to_text` | Set to `openai` to enable OpenAI speech-to-text | -| `voice_to_text_model` | Optional, defaults to `gpt-4o-mini-transcribe`; can also be `gpt-4o-transcribe`, `whisper-1` | - -Credentials are automatically reused from `open_ai_api_key`. - -## Text-to-Speech (TTS) - -```json -{ - "text_to_voice": "openai", - "text_to_voice_model": "tts-1", - "tts_voice_id": "alloy" -} -``` - -| Parameter | Description | -| --- | --- | -| `text_to_voice_model` | `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts` | -| `tts_voice_id` | Voices: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`, `ash`, `ballad`, `coral`, `sage`, `verse` | - -## Embedding - -```json -{ - "embedding_provider": "openai", - "embedding_model": "text-embedding-3-small" -} -``` - -Available models: `text-embedding-3-small`, `text-embedding-3-large`, `text-embedding-ada-002`. After changing the embedding, run `/memory rebuild-index` to rebuild the index. - diff --git a/docs/en/models/qianfan.mdx b/docs/en/models/qianfan.mdx deleted file mode 100644 index 13525967..00000000 --- a/docs/en/models/qianfan.mdx +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: ERNIE -description: ERNIE model configuration (Baidu Qianfan) ---- - -Option 1: Native integration (recommended): - -```json -{ - "model": "ernie-5.1", - "qianfan_api_key": "", - "qianfan_api_base": "https://qianfan.baidubce.com/v2" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Default recommendation: `ernie-5.1`; also supports `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-128k`, `ernie-4.5-turbo-32k` | -| `qianfan_api_key` | Qianfan API key, usually starting with `bce-v3/` | -| `qianfan_api_base` | Optional, defaults to `https://qianfan.baidubce.com/v2` | - -## Model Selection - -| Model | Use Case | -| --- | --- | -| `ernie-5.1` | Default recommendation; latest ERNIE flagship with the strongest overall capability | -| `ernie-5.0` | Previous-generation flagship with excellent overall capability | -| `ernie-x1.1` | Deep-thinking reasoning model with lower hallucination and stronger instruction following / tool calling | -| `ernie-4.5-turbo-128k` | Long-context and general chat | -| `ernie-4.5-turbo-32k` | General chat with a balanced context window and cost | - -## Vision tool - -Once `qianfan_api_key` is configured, Agent mode can auto-discover Qianfan for the Vision tool: - -- When the main model itself is multimodal (e.g. `ernie-5.1`, `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-vl`), images are handled directly by the main model with no extra setup. -- When the main model is text-only (e.g. `ernie-4.5-turbo-128k`), the Vision tool automatically falls back to `ernie-4.5-turbo-vl`. - -To force a specific Vision model, set it explicitly in `config.json`: - -```json -{ - "tools": { - "vision": { - "model": "ernie-4.5-turbo-vl" - } - } -} -``` - -Option 2: OpenAI-compatible configuration: - -```json -{ - "model": "ernie-5.1", - "bot_type": "openai", - "open_ai_api_key": "", - "open_ai_api_base": "https://qianfan.baidubce.com/v2" -} -``` - - - Prefer `qianfan_api_key` for new configurations. Existing `wenxin`, `wenxin-4`, `baidu_wenxin_api_key`, and `baidu_wenxin_secret_key` configurations remain supported. - diff --git a/docs/en/models/qwen.mdx b/docs/en/models/qwen.mdx deleted file mode 100644 index 8e27269c..00000000 --- a/docs/en/models/qwen.mdx +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: Qwen -description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding) ---- - -Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`. - - - All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. - - -## Text Chat - -```json -{ - "model": "qwen3.6-plus", - "dashscope_api_key": "YOUR_API_KEY" -} -``` - -| Parameter | Description | -| --- | --- | -| `model` | Can be `qwen3.6-plus`, `qwen3.7-max`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. | -| `dashscope_api_key` | Create one in the [Bailian Console](https://bailian.console.aliyun.com/?tab=model#/api-key); see the [official docs](https://bailian.console.aliyun.com/?tab=api#/api) | - -## Image Understanding - -Once `dashscope_api_key` is configured, the Agent's Vision tool automatically calls Qwen's vision models to recognize images. Models like `qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` are already multimodal; if the main model is text-only (e.g. `qwen-turbo`), it automatically falls back to `qwen-vl-max`. - -To manually specify a Vision model: - -```json -{ - "tools": { - "vision": { - "model": "qwen3.6-plus" - } - } -} -``` - -Supported models: `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`. - -## Image Generation - -```json -{ - "skills": { - "image-generation": { - "model": "qwen-image-2.0" - } - } -} -``` - -Available models: `qwen-image-2.0`, `qwen-image-2.0-pro`. - -## Speech-to-Text (ASR) - -```json -{ - "voice_to_text": "dashscope", - "voice_to_text_model": "qwen3-asr-flash" -} -``` - -| Parameter | Description | -| --- | --- | -| `voice_to_text` | Set to `dashscope` to enable Qwen ASR | -| `voice_to_text_model` | Optional, defaults to `qwen3-asr-flash` | - -Credentials are automatically reused from `dashscope_api_key`. A single audio segment should be smaller than 10MB and no longer than 300 seconds. - -## Text-to-Speech (TTS) - -```json -{ - "text_to_voice": "dashscope", - "text_to_voice_model": "qwen3-tts-flash", - "tts_voice_id": "Cherry" -} -``` - -| Parameter | Description | -| --- | --- | -| `text_to_voice_model` | Optional, defaults to `qwen3-tts-flash`; covers Mandarin, dialects, and major foreign languages | -| `tts_voice_id` | Voice ID; see the common list below | - -Common voice examples: - -| Voice ID | Description | -| --- | --- | -| `Cherry` | Qianyue · Sunny Female Voice | -| `Serena` | Suyao · Gentle Female Voice | -| `Ethan` | Chenxu · Sunny Male Voice | -| `Chelsie` | Qianxue · Anime Girl | -| `Dylan` | Beijing Dialect · Xiaodong | -| `Rocky` | Cantonese · Aqiang | -| `Sunny` | Sichuan Dialect · Qing'er | - -The full voice list (Mandarin / regional dialects / bilingual, etc.) can be selected visually in the Web Console under "Model Management → Text-to-Speech". - -## Embedding - -```json -{ - "embedding_provider": "dashscope", - "embedding_model": "text-embedding-v4" -} -``` - -The default model is `text-embedding-v4`. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/en/releases/overview.mdx b/docs/en/releases/overview.mdx deleted file mode 100644 index ce932884..00000000 --- a/docs/en/releases/overview.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Changelog -description: CowAgent version history ---- - -| Version | Date | Description | -| --- | --- | --- | -| [2.0.9](/en/releases/v2.0.9) | 2026.05.22 | Model management console, MCP protocol support, browser persistent login, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max, etc.), deployment hardening | -| [2.0.8](/en/releases/v2.0.8) | 2026.05.06 | Major Feishu channel upgrade (voice, streaming and Markdown, one-click QR-scan setup), DeepSeek V4 and Baidu models, scheduler tool enhancements | -| [2.0.7](/en/releases/v2.0.7) | 2026.04.22 | Image Generation Skill (6-provider auto-routing), new models (Kimi K2.6, Claude Opus 4.7, GLM 5.1), knowledge base and Web Console improvements | -| [2.0.6](/en/releases/v2.0.6) | 2026.04.14 | Project rename, Knowledge Base system, Deep Dream Memory Distillation, Smart Context Compression, Web Console multi-session and various improvements | -| [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.3](/en/releases/v2.0.3) | 2026.03.18 | WeCom Smart Bot and QQ channels, Coding Plan support, multiple new models, Web file processing, memory system upgrade | -| [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.13 | 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 | -| 1.7.6 | 2025.05.23 | Web Channel optimization, AgentMesh plugin | -| 1.7.5 | 2025.04.11 | DeepSeek model | -| 1.7.4 | 2024.12.13 | Gemini 2.0 model, Web Channel | -| 1.7.3 | 2024.10.31 | Stability improvements, database features | -| 1.7.2 | 2024.09.26 | One-click install script, o1 model | -| 1.7.0 | 2024.08.02 | iFlytek 4.0 model, knowledge base references | -| 1.6.9 | 2024.07.19 | gpt-4o-mini, Alibaba voice recognition | -| 1.6.8 | 2024.07.05 | Claude 3.5, Gemini 1.5 Pro | -| 1.6.0 | 2024.04.26 | Kimi integration, gpt-4-turbo upgrade | -| 1.5.8 | 2024.03.26 | GLM-4, Claude-3, edge-tts | -| 1.5.2 | 2023.11.10 | Feishu channel, image recognition chat | -| 1.5.0 | 2023.11.10 | gpt-4-turbo, dall-e-3, tts multimodal | -| 1.0.0 | 2022.12.12 | Project created, first ChatGPT integration | - -See [GitHub Releases](https://github.com/zhayujie/CowAgent/releases) for full history. diff --git a/docs/en/releases/v2.0.0.mdx b/docs/en/releases/v2.0.0.mdx deleted file mode 100644 index e6c37533..00000000 --- a/docs/en/releases/v2.0.0.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: v2.0.0 -description: CowAgent 2.0 - Full upgrade from chatbot to AI super assistant ---- - -CowAgent 2.0 is a comprehensive upgrade from a chatbot to an **AI super assistant** — capable of autonomous thinking and task planning, long-term memory, operating computers, and creating and executing skills. - -**Release Date**: 2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) - -## Key Updates - -### Agent Core - -- **Complex Task Planning**: Autonomous planning with multi-turn reasoning -- **Long-term Memory**: Persistent memory with keyword and vector search -- **Built-in Tools**: 10+ tools including file ops, Bash, browser, scheduler -- **Web search**: Built-in `web_search` tool, supports multiple search engines, configure corresponding API key to use -- **Skills System**: Skill engine with built-in and custom skill support -- **Security & Cost**: Secret management, prompt controls, token limits - -### Other - -- **Channels**: Feishu/DingTalk WebSocket support, image/file messages -- **Models**: claude-sonnet-4-5, gemini-3-pro-preview, glm-4.7, MiniMax-M2.1, qwen3-max -- **Deployment**: One-click install, configure, run, and management script - -## Long-term Memory - - - - - -## Task Planning & Tools - - - - - - - - - - - - - -## Skills System - - - - - - - - - - - - - -## Contributing - -Welcome to [submit feedback](https://github.com/zhayujie/CowAgent/issues) and [contribute code](https://github.com/zhayujie/CowAgent/pulls). diff --git a/docs/en/releases/v2.0.1.mdx b/docs/en/releases/v2.0.1.mdx deleted file mode 100644 index 7de5746a..00000000 --- a/docs/en/releases/v2.0.1.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: v2.0.1 -description: CowAgent 2.0.1 - Built-in Web Search, smart context management, multiple fixes ---- - -**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) - -## New Features - -- **Built-in Web Search tool**: Integrated web search as a built-in Agent tool, reducing decision cost ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) -- **Claude Opus 4.6 model support**: Added support for Claude Opus 4.6 model ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) -- **WeCom image recognition**: Support image message recognition in WeCom channel ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) - -## Improvements - -- **Smart context management**: Resolved chat context overflow with intelligent context trimming strategy to prevent token limits ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) -- **Runtime info dynamic update**: Automatic update of timestamps and other runtime info in system prompts via dynamic functions ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) -- **Skill prompt optimization**: Improved Skill system prompt generation, simplified tool descriptions for better Agent performance ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) -- **GLM custom API Base URL**: Support custom API Base URL for GLM models ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) -- **Startup script optimization**: Improved `run.sh` script interaction and configuration flow ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) -- **Decision step logging**: Added Agent decision step logging for debugging ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) - -## Bug Fixes - -- **Scheduler memory loss**: Fixed memory loss caused by Scheduler dispatcher ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) -- **Empty tool calls & long results**: Fixed handling of empty tool calls and excessively long tool results ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) -- **OpenAI Function Call**: Fixed function call compatibility with OpenAI models ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) -- **Claude tool name field**: Removed extraneous tool name field from Claude model responses ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) -- **MiniMax reasoning**: Optimized MiniMax model reasoning content handling, hidden thinking process output ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **GLM thinking process**: Hidden GLM model thinking process display ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **Feishu connection & SSL**: Fixed Feishu channel SSL certificate errors and connection issues ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) -- **model_type validation**: Fixed `AttributeError` caused by non-string `model_type` ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) - -## Platform Compatibility - -- **Windows compatibility**: Fixed path handling, file encoding, and `os.getuid()` unavailability on Windows across multiple tool modules ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) diff --git a/docs/en/releases/v2.0.2.mdx b/docs/en/releases/v2.0.2.mdx deleted file mode 100644 index 80e9f6d8..00000000 --- a/docs/en/releases/v2.0.2.mdx +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: v2.0.2 -description: CowAgent 2.0.2 - Web Console upgrade, multi-channel concurrency, session persistence ---- - -**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) - -## Highlights - -### 🖥️ Web Console Upgrade - -The Web Console has been fully upgraded with streaming conversation output, visual display of tool execution and reasoning processes, and online management of **models, skills, memory, channels, and Agent configuration**. - -#### Chat Interface - -Supports streaming output with real-time display of the Agent's reasoning process and tool calls, providing intuitive observation of the Agent's decision-making: - - - -#### Model Management - -Manage model configurations online without manually editing config files: - - - -#### Skill Management - -View and manage Agent skills (Skills) online: - - - -#### Memory Management - -View and manage Agent memory online: - - - -#### Channel Management - -Manage connected channels online with real-time connect/disconnect operations: - - - -#### Scheduled Tasks - -View and manage scheduled tasks online, including one-time tasks, fixed intervals, and Cron expressions: - - - -#### Logs - -View Agent runtime logs in real-time for monitoring and troubleshooting: - - - -Related commits: [f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) - -### 🔀 Multi-Channel Concurrency - -Multiple channels (e.g., Feishu, DingTalk, WeCom, Web) can now run simultaneously, each in an independent thread without interference. - -Configuration: Set multiple channels in `config.json` via `channel_type` separated by commas, or connect/disconnect channels in real-time from the Web Console's channel management page. - -```json -{ - "channel_type": "web,feishu,dingtalk" -} -``` - -Related commits: [4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) - -### 💾 Session Persistence - -Session history is now persisted to a local SQLite database. Conversation context is automatically restored after service restarts. Historical conversations in the Web Console are also restored. - -Related commits: [29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) - -## New Models - -- **Gemini 3.1 Pro Preview**: Added `gemini-3.1-pro-preview` model support ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) -- **Claude 4.6 Sonnet**: Added `claude-4.6-sonnet` model support ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) -- **Qwen3.5 Plus**: Added `qwen3.5-plus` model support ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) -- **MiniMax M2.5**: Added `Minimax-M2.5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) -- **GLM-5**: Added `glm-5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) -- **Kimi K2.5**: Added `kimi-k2.5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) -- **Doubao 2.0 Code**: Added `doubao-2.0-code` coding-specialized model ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) -- **DashScope Models**: Added Alibaba Cloud DashScope model name support ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) - -## Website & Documentation - -- **Official Website**: [cowagent.ai](https://cowagent.ai/) -- **Documentation**: [docs.cowagent.ai](https://docs.cowagent.ai/) - -## Bug Fixes - -- **Gemini DingTalk image recognition**: Fixed Gemini unable to process image markers in DingTalk channel ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) -- **Startup script dependencies**: Fixed dependency installation issue in `run.sh` script ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) -- **Bare except cleanup**: Replaced `bare except` with `except Exception` for better exception handling ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) diff --git a/docs/en/releases/v2.0.3.mdx b/docs/en/releases/v2.0.3.mdx deleted file mode 100644 index 5f9a837d..00000000 --- a/docs/en/releases/v2.0.3.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: v2.0.3 -description: CowAgent 2.0.3 - WeCom Smart Bot and QQ channels, Web Console file handling, memory system upgrade ---- - -## 🔌 New Channels - -### WeCom Smart Bot - -Added the WeCom Smart Bot (`wecom_bot`) channel with streaming card output, support for receiving and replying to text and image messages, and full configuration through the Web Console. - -Documentation: [WeCom Smart Bot](https://docs.cowagent.ai/en/channels/wecom-bot). - -Related commits: [d4480b6](https://github.com/zhayujie/CowAgent/commit/d4480b6), [a42f31f](https://github.com/zhayujie/CowAgent/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/CowAgent/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/CowAgent/commit/8b45d6c) - -### QQ Channel - -Added the QQ official bot (`qq`) channel with support for text and image messages in both private chats and group chats. - -Documentation: [QQ Bot](https://docs.cowagent.ai/en/channels/qq). - -Related commits: [005a0e1](https://github.com/zhayujie/CowAgent/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/CowAgent/commit/a4d54f5) - -## 🖥️ Web Console File Input and Processing - -The Web Console chat UI now supports file and image uploads — files can be sent directly to the agent for processing. The Read tool gains parsing support for Office documents (Word, Excel, PPT). - -Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) - -## 🤖 New Models - -- **GPT-5.4 Series**: Added `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano` ([1623deb](https://github.com/zhayujie/CowAgent/commit/1623deb)) -- **Gemini 3.1 Flash Lite Preview**: Added `gemini-3.1-flash-lite-preview` ([ba915f2](https://github.com/zhayujie/CowAgent/commit/ba915f2)) - -## 💰 Coding Plan Support - -Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine. - -See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration. - -## 🧠 Memory System Upgrade - -Memory flush improvements: - -- Use the LLM to summarize out-of-window conversations into compact daily memory entries -- Summarization runs asynchronously on a background thread, never blocking replies -- Smarter batch trimming policy reduces flush frequency -- Daily scheduled flush as a safety net for low-activity scenarios -- Fixed context-memory loss issues - -Related commits: [022c13f](https://github.com/zhayujie/CowAgent/commit/022c13f), [c116235](https://github.com/zhayujie/CowAgent/commit/c116235) - -## 🔧 Tool Refactoring - -- **Image Vision**: Image recognition (Vision) is refactored from a Skill into a built-in Tool with a dedicated Vision Provider configuration, improving stability and maintainability ([a50fafa](https://github.com/zhayujie/CowAgent/commit/a50fafa), [3b8b562](https://github.com/zhayujie/CowAgent/commit/3b8b562)) -- **Web Fetch**: Web fetch is refactored from a Skill into a built-in Tool with support for downloading and parsing remote documents (PDF, Word, Excel, PPT) ([ccb9030](https://github.com/zhayujie/CowAgent/commit/ccb9030), [fa61744](https://github.com/zhayujie/CowAgent/commit/fa61744)) - -## 🐳 Docker Deployment Improvements - -- **Config Template Alignment**: `docker-compose.yml` env vars aligned with `config-template.json`, covering full model API key and Agent settings -- **Web Console Port Mapping**: Added `9899` port mapping so the Web Console is reachable in browser after Docker deployment -- **Hot Config Reload**: Bot API key and API base are now read at request time — changes from the Web Console take effect without restart -- **Workspace Persistence**: Added a `./cow` volume mount so agent workspace data (memories, persona, skills, etc.) persists across container rebuilds and upgrades - -## ⚡ Performance Improvements - -- **Faster Startup**: The Feishu channel imports its dependencies lazily, avoiding a 4–10s startup delay ([924dc79](https://github.com/zhayujie/CowAgent/commit/924dc79)) -- **Channel Stability**: Improved channel connection stability and added env-var support for channel configuration ([f1c04bc](https://github.com/zhayujie/CowAgent/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/CowAgent/commit/46d97fd)) - -## 🐛 Bug Fixes - -- **bot_type Propagation**: Fixed `bot_type` propagation under Agent mode ([#2691](https://github.com/zhayujie/CowAgent/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) -- **bot_type Resolution Priority**: Adjusted `bot_type` resolution priority under Agent mode ([#2692](https://github.com/zhayujie/CowAgent/pull/2692)) Thanks [@6vision](https://github.com/6vision) -- **Zhipu Config**: Fixed Zhipu `bot_type` naming, Web Console persistence, and regex escaping ([#2693](https://github.com/zhayujie/CowAgent/pull/2693)) Thanks [@6vision](https://github.com/6vision) -- **OpenAI-Compat Layer**: Unified error handling via the `openai_compat` layer ([#2688](https://github.com/zhayujie/CowAgent/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) -- **OpenAI-Compat Migration**: Completed the `openai_compat` migration across all model bots ([#2689](https://github.com/zhayujie/CowAgent/pull/2689)) -- **Gemini Tool Calling**: Fixed tool-call matching for Gemini ([eda82ba](https://github.com/zhayujie/CowAgent/commit/eda82ba)) -- **Session Concurrency**: Fixed race conditions in concurrent session scenarios ([9879878](https://github.com/zhayujie/CowAgent/commit/9879878)) -- **History Recovery**: Fixed incomplete history recovery — only user/assistant text messages are restored, tool calls are stripped ([b788a3d](https://github.com/zhayujie/CowAgent/commit/b788a3d), [a33ce97](https://github.com/zhayujie/CowAgent/commit/a33ce97)) -- **Feishu Group Chat**: Removed the `bot_name` dependency for Feishu group chats ([b641bff](https://github.com/zhayujie/CowAgent/commit/b641bff)) -- **Safari Compatibility**: Fixed an IME Enter key issue that mistakenly sent messages on Safari ([0687916](https://github.com/zhayujie/CowAgent/commit/0687916)) -- **Windows Compatibility**: Fixed bash-style `$VAR` to `%VAR%` env-var conversion on Windows ([7c67513](https://github.com/zhayujie/CowAgent/commit/7c67513)) -- **MiniMax Params**: Added a `max_tokens` cap for MiniMax models ([1767413](https://github.com/zhayujie/CowAgent/commit/1767413)) -- **.gitignore**: Added Python directory ignore rules ([#2683](https://github.com/zhayujie/CowAgent/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) -- **AGENT.md Proactive Evolution**: Improved the system prompt guidance around AGENT.md — instead of waiting for explicit user edits, the agent now proactively detects persona/style shifts in the conversation and updates AGENT.md accordingly - -## 📦 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/en/guide/upgrade) for details. - -**Release Date**: 2026.03.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.2...2.0.3) diff --git a/docs/en/releases/v2.0.4.mdx b/docs/en/releases/v2.0.4.mdx deleted file mode 100644 index fc976fa0..00000000 --- a/docs/en/releases/v2.0.4.mdx +++ /dev/null @@ -1,55 +0,0 @@ ---- -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/CowAgent/commit/ce89869), [a483ec0](https://github.com/zhayujie/CowAgent/commit/a483ec0), [c1421e0](https://github.com/zhayujie/CowAgent/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/CowAgent/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/CowAgent/commit/49d8707)) -- **Executable Permission**: Fixed `run.sh` file permission issue ([652156e](https://github.com/zhayujie/CowAgent/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/CowAgent/commit/b4e711f)) -- **Auto-Repair Messages**: Enhanced message protocol fault tolerance with automatic repair of malformed message sequences ([b8b57e3](https://github.com/zhayujie/CowAgent/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/CowAgent/commit/5487c0b) - -## 🐛 Bug Fixes - -- **WeCom Bot Compatibility**: Fixed compatibility with older `websocket-client` versions, added unified WebSocket compatibility layer ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) -- **run.sh PID**: Fixed process PID retrieval error in `run.sh` ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) -- **Feishu Encoding**: Fixed message and log encoding issue in Feishu channel ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) -- **Feishu Config**: Removed redundant `feishu_bot_name` dependency in `run.sh` ([1b5be1b](https://github.com/zhayujie/CowAgent/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/CowAgent/compare/2.0.3...master) diff --git a/docs/en/releases/v2.0.5.mdx b/docs/en/releases/v2.0.5.mdx deleted file mode 100644 index ad45398e..00000000 --- a/docs/en/releases/v2.0.5.mdx +++ /dev/null @@ -1,77 +0,0 @@ ---- -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/cli) - - - -## 🧩 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/CowAgent/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/CowAgent/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/CowAgent/pull/2731)). Thanks [@zkjqd](https://github.com/zkjqd) -- **Context loss**: Fix context loss after trimming ([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) -- **System prompt**: Fix system prompt not rebuilding on every turn ([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) -- **Gemini**: Fix missing model attribute in GoogleGeminiBot ([#2716](https://github.com/zhayujie/CowAgent/pull/2716)). Thanks [@cowagent](https://github.com/cowagent) -- **WeChat channel**: Fix file send failures and filename loss ([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba), [45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) -- **Docker**: Fix volume permissions, reduce image size ([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348), [4470d4c](https://github.com/zhayujie/CowAgent/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/CowAgent/compare/2.0.4...master) diff --git a/docs/en/releases/v2.0.6.mdx b/docs/en/releases/v2.0.6.mdx deleted file mode 100644 index a41f75aa..00000000 --- a/docs/en/releases/v2.0.6.mdx +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: v2.0.6 -description: CowAgent 2.0.6 - Knowledge Base, Deep Dream Memory Distillation, Smart Context Compression, Web Console Multi-Session and More ---- - -## Project Renamed to CowAgent - -The repository has been officially renamed from `chatgpt-on-wechat` to **CowAgent**, evolving into a full-featured AI Agent assistant. - -- New URL: [github.com/zhayujie/CowAgent](https://github.com/zhayujie/CowAgent) — GitHub auto-redirects the old URL -- CLI commands, config files, and documentation links remain compatible — no extra steps needed - -## 📚 Knowledge Base - -New personal knowledge base system — Agent can autonomously build and maintain structured knowledge, retrieving it on demand during conversations: - -- **Index-driven self-organizing structure**: Knowledge is stored in `knowledge/` directory, auto-organized by category, with each knowledge page as an independent Markdown file -- **Auto-write**: Send files, links, or other knowledge to the Agent, or it will automatically create/update knowledge pages when valuable information is identified in conversation -- **Hybrid retrieval**: Supports keyword full-text search and vector semantic retrieval, loading relevant knowledge on demand during conversations -- **Visualization**: File tree browsing and knowledge graph visualization, with in-document links for direct navigation -- **Command management**: `/knowledge` for stats, `/knowledge list` for directory structure, `/knowledge on|off` to toggle - - - - -Docs: [Knowledge Base](https://docs.cowagent.ai/en/knowledge) - -## 🌙 Deep Dream Memory Distillation - -A new memory consolidation mechanism that automatically distills scattered conversation memories into refined long-term memory daily: - -- **Three-tier memory flow**: Conversation context (short-term) → Daily memory (mid-term) → MEMORY.md (long-term), forming a complete memory lifecycle -- **Auto-distillation**: Runs daily at 23:55, reads the day's daily memory and MEMORY.md, performs deduplication, merging, and pruning via LLM, outputting a refined MEMORY.md -- **Dream diary**: Each distillation generates a narrative-style dream diary recording discoveries and insights, stored in `memory/dreams/` -- **Manual trigger**: `/memory dream [N]` to manually trigger with configurable lookback days (default 3, max 30), with chat notification on completion -- **Web console**: Memory management page now includes a "Dream Diary" tab for browsing all dream diaries - -Docs: [Deep Dream](https://docs.cowagent.ai/en/memory/deep-dream) - - - -## 🧠 Smart Context Compression - -When context exceeds limits, trimmed portions are summarized by LLM and asynchronously injected to maintain conversation continuity: - -- **Async LLM summary**: Trimmed messages are summarized into key information by LLM, written to daily memory files and injected into retained context -- **Multi-model compatible**: Uses the primary model for summarization, compatible with Claude, OpenAI, MiniMax and other model message format requirements - -Docs: [Short-term Memory](https://docs.cowagent.ai/en/memory/context) - -## 💬 Web Console Upgrades - -Multiple enhancements to the Web console: - -- **Multi-session management**: Create and switch between independent sessions, sidebar session list with auto-generated and manually editable titles -- **Password protection**: Set a login password via `web_console_password` config option -- **Deep thinking**: Display model thinking process in Web console, controlled by `enable_thinking` config option -- **Scheduled push**: Scheduled task results can be pushed to Web console -- **Message copy**: One-click copy of raw Markdown content from AI reply bubbles -- **Language toggle**: Top language switch button now shows current language for more intuitive interaction - -## 🤖 Model Updates - -- **Vision optimization**: Image recognition tool prefers the primary model with automatic multi-provider fallback. Docs: [Vision Tool](https://docs.cowagent.ai/en/tools/vision) -- **MiniMax new model**: Added MiniMax-M2.7-highspeed model and MiniMax TTS voice synthesis support. Thanks @octo-patch -- **Qwen**: Added qwen3.6-plus model support - -## 🐛 Other Improvements & Fixes - -- **Memory prompts**: `MEMORY.md` injected into system prompt by default, with refined memory retrieval and write trigger conditions for enhanced proactive writing -- **System prompt**: Optimized system prompt style and tone guidance -- **Browser tool**: Enhanced implicit interactive element detection -- **File send**: Fixed common file types (tar.gz, zip, etc.) not being sent correctly. Thanks @6vision -- **macOS compatibility**: Fixed network pre-check timeout compatibility issue. Thanks @Moliang Zhou -- **Windows compatibility**: Fixed PowerShell compatibility, process updates, terminal encoding and other issues on Windows -- **Python 3.13+**: Fixed missing `legacy-cgi` dependency for Python 3.13+ -- **WeChat channel**: Updated personal WeChat channel version - -## 📦 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.14 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.5...master) diff --git a/docs/en/releases/v2.0.7.mdx b/docs/en/releases/v2.0.7.mdx deleted file mode 100644 index 522e5339..00000000 --- a/docs/en/releases/v2.0.7.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: v2.0.7 -description: CowAgent 2.0.7 - Image Generation Skill (6-provider auto-routing), new models, knowledge base enhancements, Web Console improvements and bug fixes ---- - -## 🎨 Image Generation Skill - -New built-in `image-generation` skill supporting text-to-image, image-to-image, and multi-image fusion across six major providers: - -- **6-provider auto-routing**: OpenAI (GPT-Image-2) → Gemini (Nano Banana) → Seedream (Volcengine Ark) → Qwen (DashScope) → MiniMax → LinkAI — automatically selects from configured providers in fixed priority order, with automatic fallback on failure -- **Zero model selection**: Just configure an API key and it works — no need to manually specify a model. You can also name a specific model in conversation (e.g. "draw a cat with seedream") -- **Flexible control**: Supports `quality`, `size` (512/1K–4K), and `aspect_ratio` parameters, with each provider automatically mapping to its supported values -- **Image editing**: Pass existing images for editing, style transfer, or multi-image fusion (Seedream supports up to 14 reference images) -- **Skill-level config**: Pin a default model via `skills.image-generation.model` in `config.json` -- **Image lightbox**: All images in the Web console now support click-to-enlarge preview - -Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generation) - -## 🤖 New Model Support - -- **Kimi K2.6**: Added `kimi-k2.6` model support -- **Claude Opus 4.7**: Added `claude-opus-4-7` model support -- **GLM 5.1**: Added `glm-5.1` model support -- **Kimi Coding Plan**: Support for Kimi Coding Plan mode -- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors - -## 💬 Web Console Improvements - -- **Smart auto-scroll**: Improved chat scroll behaviour — no longer forces scroll to bottom while the user is reading earlier messages -- **Reasoning content cap**: Deep thinking content capped at 4 KB to prevent frontend lag -- **Mobile optimisation**: Session sidebar hidden by default on mobile, with overlay dismiss support -- **Session title fix**: Fixed title auto-generation fallback logic and Bridge reset on config change -- **Image preview dedup**: Fixed duplicate image rendering within the same message - -## 📚 Knowledge Base Enhancements - -- **Nested directory support**: Knowledge base listing and display now support multi-level nested directories -- **Root-level file display**: Show `index.md`, `log.md` and other root-level files in the knowledge tree -- **Empty state stats fix**: Root-level files no longer interfere with empty-state detection - -## 🌙 Dream Memory Improvements - -- **Structured organisation**: Dream memory files are now auto-archived by date with a cleaner directory structure -- **Schedule jitter**: Daily dream trigger includes random jitter to avoid concurrency conflicts in cluster deployments - -## 🛠 Skill System Improvements - -- **Skill manager refresh**: `/skill` commands now automatically refresh the skill manager to keep state in sync -- **Installation sources**: Skill installation supports multiple source formats (URL, zip, local file, etc.) with automatic target directory handling - -## 🐛 Other Fixes - -- **Gemini fix**: Fixed Gemini tool calls not returning results -- **Agent retry**: Empty-response retries no longer drop `tool_calls` -- **Docker env sync**: Fixed environment variables not syncing after config update in Docker environments -- **Python 3.7 compat**: Deferred `Literal` import for Python 3.7 compatibility -- **Model switch notification**: Fixed bot_type change notification not showing after model switch. Thanks @6vision -- **Config command**: `/config` now supports setting `enable_thinking` -- **Thinking display**: Deep thinking display disabled by default - -## 📦 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.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.6...master) diff --git a/docs/en/releases/v2.0.8.mdx b/docs/en/releases/v2.0.8.mdx deleted file mode 100644 index 3fcc29da..00000000 --- a/docs/en/releases/v2.0.8.mdx +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: v2.0.8 -description: CowAgent 2.0.8 - Major Feishu channel upgrade (voice, streaming typewriter, one-click QR app creation), DeepSeek V4 / ERNIE 5.0 support, scheduler memory enhancements and multiple fixes ---- - -## 🪶 Major Feishu Channel Upgrade - -### 1. One-click QR-scan App Creation - -No more manual app setup, permission scopes and event subscriptions in the Feishu Open Platform. When `feishu_app_id` is not configured, both the Web Console and CLI startup flow now show a QR-scan entry — scan with Feishu, authorize, and the bot is created and config is filled back automatically. Out-of-the-box. - -Documentation: [Feishu Channel](https://docs.cowagent.ai/en/channels/feishu) - -### 2. Voice Messages - -Receive Feishu voice messages with automatic speech-to-text, and reply in voice via TTS. Recognition accuracy for short Chinese voice messages has been improved. - -### 3. Streaming Typewriter Replies - -Integrated with Feishu CardKit streaming cards, **enabled by default**, matching the Web Console experience: - -- Multi-turn agent flows render intermediate updates and the final reply on separate cards -- Tuned for high-throughput models like DeepSeek to keep pace with the Web Console -- Falls back to plain text replies automatically when not supported, no manual config needed -- Requires Feishu client ≥ 7.20 - -The voice and streaming building blocks come from a community contribution #2791. Thanks [@yangluxin613](https://github.com/yangluxin613) - -## 🤖 New Model Support - -- **DeepSeek V4 series**: Added `deepseek-v4-pro` / `deepseek-v4-flash`, with `deepseek-v4-flash` set as the new default -- **Unified thinking-mode toggle**: DeepSeek V4, Qwen3 and other thinking-capable models now share the same `enable_thinking` switch -- **ERNIE first-class integration**: New `qianfan` provider supporting `ernie-5.0` (default recommendation), `ernie-x1.1`, `ernie-4.5-turbo-128k`, `ernie-4.5-turbo-32k`. Dedicated `qianfan_api_key` / `qianfan_api_base` settings keep OpenAI config clean; legacy `wenxin` / `wenxin-4` paths are fully preserved. #2790 Thanks [@jimmyzhuu](https://github.com/jimmyzhuu) - - Documentation: [ERNIE](https://docs.cowagent.ai/en/models/qianfan) - -## 🌐 Translation Provider - -- **Youdao translator**: Added a Youdao provider to the `translate/` module using the v3 SHA-256 signing scheme, with automatic ISO 639-1 language-code mapping (`zh`, `zh-TW`, etc.) #2797 Thanks [@Zmjjeff7](https://github.com/Zmjjeff7) - -## 🛠 OpenAI Client Refactor - -- **Drop SDK dependency**: The OpenAI bot is reimplemented on a native HTTP client — leaner startup, fewer dependency conflicts -- **Web Console hint**: API base inputs in the model config UI now include version-path placeholder hints - -## ⏰ Scheduler Memory Enhancements - -- **Follow-up on task results**: Scheduled task results are automatically injected into the receiver's session history — the next turn can ask follow-up questions without re-stating context. Thanks [@huangrichao2020](https://github.com/huangrichao2020) -- **No long-term memory pollution**: Scheduler-injected pairs are excluded from the daily memory flush so high-frequency tasks don't drown the memory store -- **Bounded scheduler context**: The scheduler's own session context is automatically capped, so long-running periodic tasks don't accumulate state and slow down replies - -## 🔧 Tools and Safety - -- **Vision model selection**: `tools.vision.model` config now actually takes effect, with automatic fallback when unconfigured #2792 -- **Bash safety prompt**: The destructive-deletion confirm prompt is now scoped to paths outside the workspace — routine in-workspace operations are no longer interrupted - -## 🐛 Other Fixes - -- Fixed Deep Dream firing duplicate runs in multi-instance setups -- Fixed missing `reasoning_content` on some history turns in DeepSeek multi-turn conversations - -## 📦 Upgrade - -Source-code deployments can run `cow update` or `./run.sh update` for a one-click upgrade, or pull the latest code and restart manually. See [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade) for details. - -> ⚠️ One-click Feishu app creation requires `lark-oapi>=1.5.5`. `cow update` pulls it automatically; manual deployments must update dependencies. - -**Release Date**: 2026.05.05 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.7...2.0.8) diff --git a/docs/en/releases/v2.0.9.mdx b/docs/en/releases/v2.0.9.mdx deleted file mode 100644 index ccae36fc..00000000 --- a/docs/en/releases/v2.0.9.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: v2.0.9 -description: CowAgent 2.0.9 - Web Console model management, MCP protocol support, browser persistent login, new models and deployment hardening ---- - -## 🖥️ Model Management Console - -The Web Console adds a new **Models** page that organizes everything by **provider × capability**, covering chat, image, voice, embedding and search models in one place: - -- **Per-provider configuration**: Each provider's API Key / API Base is configured once at the top, and every capability below picks it up automatically — no more re-entering credentials -- **Image models**: Image understanding and image generation can each pick their own provider and model independently; falls back to the main model when unspecified -- **Voice models**: ASR (speech-to-text) and TTS (text-to-speech) can be configured independently, with new Qwen and Zhipu ASR/TTS models added -- **Embedding models**: Configurable embedding models (used for memory and knowledge-base retrieval), with new support for OpenAI, Tongyi, Doubao, Zhipu and others; run `/memory rebuild-index` after switching to rebuild the index online -- **Search capability**: Web search has been upgraded to support Bocha, Baidu, Zhipu and more providers — in auto mode the agent can synthesize results from multiple sources for deeper research - -Documentation: [Models Overview](https://docs.cowagent.ai/en/models) - -20260522113305 - - -## 🧩 MCP Protocol Support - -Adds support for **MCP (Model Context Protocol)**, expanding from a fixed built-in toolset to an open, pluggable tool ecosystem — any MCP-compatible service can be plugged in directly as an agent tool. - -- Native JSON-RPC implementation, zero extra dependencies, supports both `stdio` and `sse` transports -- Compatible with the `mcpServers` configuration style used by Claude Desktop / Cursor, reads `~/cow/mcp.json` by default - -Documentation: [MCP Tools](https://docs.cowagent.ai/en/tools/mcp). Thanks [@yangluxin613](https://github.com/yangluxin613) (#2801) - -## 🌐 Browser Persistent Login - -For sites that require login or have anti-bot protection, the browser tool can now persist a login session for long-term reuse, and supports attaching to your real Chrome browser to bypass fingerprint detection: - -- **Persistent user profile (default)**: Uses `~/.cow/browser_profile` as the browser user data dir by default; once logged in, sessions are reused automatically on subsequent runs -- **CDP mode**: Configure `tools.browser.cdp_endpoint` to take over a real Chrome instance with full browser permissions - -Documentation: [Browser Tool](https://docs.cowagent.ai/en/tools/browser). Thanks [@leafmove](https://github.com/leafmove) (#2809) - -## 🤖 New Models and Improvements - -- **New models**: `gpt-5.5`, `gemini-3.5-flash`, `qwen3.7-max`, `ernie-5.1` -- **Improvements**: DeepSeek V4 supports the `reasoning_effort` thinking-depth parameter; fixed thinking models like MiMo failing to connect via the OpenAI-compatible protocol - -## 🔒 Deployment & Security - -- **Bind to localhost by default**: The Web Console `web_host` now defaults to `127.0.0.1`; for server deployments, set it to `0.0.0.0` and configure a password manually. Thanks @August829, @yidaozhongqing, @YLChen-007, @icysun -- **Fully bundled frontend assets**: All third-party CSS / JS are now served locally — the console works offline and on intranet deployments. Thanks [@gitlayzer](https://github.com/gitlayzer) (#2816) - -## 🛠 UX Improvements & Fixes - -- **TTS rolls out to more channels**: Web Console, Personal WeChat, Feishu, DingTalk and WeCom Smart Bot all support voice replies — see the [Channels Overview](https://docs.cowagent.ai/en/channels) -- **Log panel enhancements**: Differentiated highlighting by log level, with level-based filtering. Thanks [@yangluxin613](https://github.com/yangluxin613) (#2807) -- **Auto-launch Web Console**: The Web Console now opens automatically on startup. Thanks [@yangluxin613](https://github.com/yangluxin613) (#2804) -- **Clean Ctrl+C exit**: No more long `KeyboardInterrupt` stack traces. Thanks [@yangluxin613](https://github.com/yangluxin613) (#2806) -- **Folder upload**: Web Console supports directory uploads, with path validation adapted for Windows. Thanks [@TryToMakeUsBetter](https://github.com/TryToMakeUsBetter) (#2814) -- Fixed scheduled tasks executing duplicates under certain conditions. Thanks [@CNXudiandian](https://github.com/CNXudiandian) (#2820) -- Fixed one-shot scheduled tasks with timezone not firing. Thanks @AethericSpace -- Fixed failed tool calls not being displayed after page refresh. Thanks [@a1094174619](https://github.com/a1094174619) (#2822) -- Fixed WeCom bot messages with illegal control characters failing to be delivered. Thanks [@Jacques-Zhao](https://github.com/Jacques-Zhao) (#2810) - -## 📦 Upgrade - -Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade) for details. - -**Release Date**: 2026.05.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.8...2.0.9) diff --git a/docs/en/skills/create.mdx b/docs/en/skills/create.mdx deleted file mode 100644 index cfdbde02..00000000 --- a/docs/en/skills/create.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -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/CowAgent/blob/master/skills/skill-creator/SKILL.md) for details. - diff --git a/docs/en/skills/hub.mdx b/docs/en/skills/hub.mdx deleted file mode 100644 index 0a9e73e1..00000000 --- a/docs/en/skills/hub.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Skill Hub -description: Browse, search, and install AI Agent skills ---- - -[Cow Skill Hub](https://skills.cowagent.ai/) is an open-source skill marketplace for AI Agents, aggregating official picks, community contributions, and third-party skills from GitHub, ClawHub, and beyond. - -Source code: [github.com/zhayujie/cow-skill-hub](https://github.com/zhayujie/cow-skill-hub) - - - -## Features - -- **Browse skills** — filter by category (Featured / Community / Third-party) and tags -- **Search skills** — find skills by name or description -- **View details** — read the skill manifest, file contents, install command, and required environment variables -- **One-click install** — copy the install command and run it in CowAgent - -## Installing a skill - -Run the install command in chat or in your terminal: - - -```text Chat -/skill install -``` - -```bash Terminal -cow skill install -``` - - -You can also browse the marketplace directly from chat: - -```text -/skill list --remote -/skill search -``` - -Beyond the curated list, you can install third-party skills from **GitHub, ClawHub, LinkAI, or any URL** via the CLI. See [Installing skills](/en/skills/install) for details. - -## Contributing a skill - -To submit your own skill: - -1. Visit [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) -2. Sign in with GitHub or Google -3. Upload a folder or zip file containing `SKILL.md` -4. Skill name, display name, and description are auto-detected — adjust as needed -5. Submit for review; skills go live after security and quality checks - - - -Skill file layout: - -``` -your-skill/ -├── SKILL.md # required, in the root -├── scripts/ # optional, runtime scripts -└── resources/ # optional, additional assets -``` - - - Skills are built around the `SKILL.md` manifest. You can also download `SKILL.md` from a skill's detail page and use it with any Agent that supports custom instructions (OpenClaw, Cursor, Claude Code, and more). - diff --git a/docs/en/skills/image-generation.mdx b/docs/en/skills/image-generation.mdx deleted file mode 100644 index 608fa3bc..00000000 --- a/docs/en/skills/image-generation.mdx +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: image-generation -description: Text-to-image / image-to-image / multi-image fusion with automatic multi-provider routing and fallback ---- - -A general-purpose image generation and editing skill supporting six providers: OpenAI, Gemini, Seedream (Volcengine Ark), Qwen (DashScope), MiniMax, and LinkAI. Configure any one provider's key to start using it; configure multiple to enable automatic fallback. - -## Supported Models - -| Provider | Models / Aliases | Notes | -| --- | --- | --- | -| OpenAI | `gpt-image-2`, `gpt-image-1` | General-purpose, high quality, supports `quality` parameter | -| Gemini Nano Banana | `nano-banana-2`, `nano-banana-pro`, `nano-banana` | Corresponds to the image variants of `gemini-3.1-flash`, `gemini-3-pro`, `gemini-2.5-flash` | -| Seedream (Volcengine Ark) | `seedream-5.0-lite`, `seedream-4.5` | Native 2K–4K, up to 14 reference images for fusion | -| Qwen (DashScope) | `qwen-image-2.0`, `qwen-image-2.0-pro` | Strong with Chinese text rendering and text-image layouts | -| MiniMax | `image-01` | Fast and simple | -| LinkAI | Any model | Universal gateway, used as fallback | - -## Model Selection - -By default, "auto routing + automatic fallback" is used: - -1. Pick the first configured provider in the order `OpenAI → Gemini → Seedream → Qwen → MiniMax → LinkAI` -2. On errors such as 401, model not enabled, or network issues, automatically switch to the next provider -3. If the user specifies a model in the conversation (e.g. "use seedream to draw a cat"), the corresponding provider is promoted to the front - -To pin a specific model: - -```json -{ - "skills": { - "image-generation": { - "model": "seedream-5.0-lite" - } - } -} -``` - -## Configuring API Keys - - - It is recommended to configure providers from the "Model Management" page in the [Web console](/en/channels/web). Chat model keys configured there are automatically reused by the image generation skill — no need to set them twice. You can also edit the configuration file manually or temporarily set keys in a conversation using the `env_config` tool. - - -Credentials are shared with the main model providers: - -| Field | Provider | -| --- | --- | -| `openai_api_key` | OpenAI | -| `gemini_api_key` | Gemini | -| `ark_api_key` | Volcengine Ark (Seedream) | -| `dashscope_api_key` | Alibaba DashScope (Qwen) | -| `minimax_api_key` | MiniMax | -| `linkai_api_key` | LinkAI | - - -## Enabling and Disabling - -The skill automatically adjusts its status based on API keys: - -- **Key configured**: the Agent calls the skill directly when it receives a drawing request -- **Key not configured**: the skill still appears in context (marked as "needs configuration") — the Agent will guide the user to set up a key - -To control it manually: - -```text -/skill disable image-generation # Disable -/skill enable image-generation # Re-enable -``` - -Equivalent terminal commands: `cow skill disable image-generation` / `cow skill enable image-generation`. - -## Parameters - -| Parameter | Type | Required | Default | Description | -| --- | --- | --- | --- | --- | -| `prompt` | string | Yes | — | Image description | -| `image_url` | string / list | No | null | Input image for editing — local path or URL; pass a list for multi-image fusion | -| `quality` | string | No | auto | `low` / `medium` / `high`, supported only by some providers | -| `size` | string | No | auto | `512` / `1K` / `2K` / `3K` / `4K`, or pixel value like `1024x1024` | -| `aspect_ratio` | string | No | null | `1:1` / `3:2` / `2:3` / `16:9` / `9:16` / `21:9`; Gemini also supports `1:4` / `4:1` / `1:8` / `8:1` | - - - **Higher quality and larger size cost more and take longer.** For everyday conversations, use the defaults (`auto`) or `quality=low` + `size=1K` — about 20 seconds per image. For posters or when high resolution is explicitly requested, use `quality=high` + `size=2K/4K` — may take 1–5 minutes. - - -## Common Use Cases - -- **Text-to-image**: generate illustrations, posters, icons, avatars, storyboards, etc. from a description -- **Image-to-image**: change styles, swap elements, add decorations or text on an existing image -- **Multi-image fusion**: combine multiple reference images into one (outfit swaps, character group photos, etc.) - - -- Bash timeout should be set to 600 seconds: each provider has a 300-second HTTP timeout, and the script may try multiple providers sequentially -- Input images are automatically compressed to ≤ 4 MB with the longest edge ≤ 4096 px -- Gemini / Seedream / Qwen / MiniMax do not support the `quality` parameter -- Seedream defaults to 2K; `seedream-5.0-lite` supports up to 3K; `seedream-4.5` supports up to 4K - diff --git a/docs/en/skills/index.mdx b/docs/en/skills/index.mdx deleted file mode 100644 index de57d94a..00000000 --- a/docs/en/skills/index.mdx +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Skills Overview -description: CowAgent skills system introduction ---- - -Skills provide infinite extensibility for the Agent. Each Skill consists of a description file (`SKILL.md`), execution scripts (optional), and resources (optional), describing how to accomplish specific types of tasks. - -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. - -## Getting Skills - -CowAgent offers multiple ways to acquire skills: - -- **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 - -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 - -1. **Workspace skills** (highest): `~/cow/skills/` -2. **Project built-in skills** (lowest): `skills/` - -Skills with the same name are overridden by priority. - -## Skill File Structure - -``` -skills/ -├── my-skill/ -│ ├── SKILL.md # Skill description (frontmatter + instructions) -│ ├── scripts/ # Execution scripts (optional) -│ └── resources/ # Additional resources (optional) -``` - -### 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) | diff --git a/docs/en/skills/install.mdx b/docs/en/skills/install.mdx deleted file mode 100644 index 7a70205f..00000000 --- a/docs/en/skills/install.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Install Skills -description: Install skills from multiple sources with a single command ---- - -CowAgent supports installing skills from [Cow Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub, LinkAI, and any URL via a unified `install` command. Use `/skill install` in chat or `cow skill install` in the terminal. - -## From the Skill Hub - -Browse all available skills at [skills.cowagent.ai](https://skills.cowagent.ai/) and install by name: - -```text -/skill list --remote -/skill install pptx -``` - -## From GitHub - -Any GitHub-hosted skill can be installed directly. Supports both repository-level batch install and subdirectory-level single install: - -```text -/skill install larksuite/cli -/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im -``` - -## From ClawHub - -All [ClawHub](https://clawhub.ai/) skills (40k+) can be installed with a single command: - -```text -/skill install clawhub: -``` - -## From LinkAI - -All public resources on [LinkAI](https://link-ai.tech/console) (10k+ apps / workflows / plugins), as well as your own resources (apps, workflows, knowledge bases, databases, plugins), can be installed via: - -```text -/skill install linkai: -``` - -> Every resource created on the LinkAI platform has a unique `code`. Find it on each resource's page in the [console](https://link-ai.tech/console). - -## 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/cli/skill) for full documentation. - diff --git a/docs/en/skills/knowledge-wiki.mdx b/docs/en/skills/knowledge-wiki.mdx deleted file mode 100644 index 14ae9c90..00000000 --- a/docs/en/skills/knowledge-wiki.mdx +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: knowledge-wiki -description: Maintain a local structured knowledge base with automatic archiving, categorisation, and cross-referencing ---- - -Organises notes, insights, and reference materials from your conversations into a structured local knowledge base, automatically maintaining an index and cross-references between pages. - -`knowledge-wiki` maintains a `knowledge/` directory in your workspace — essentially the Agent's "second brain". The skill is marked `always: true`, so it is **always loaded** and requires no external dependencies. - -## When It Triggers - -- You share an article, document, or URL that you want to keep for future reference -- A conversation produces conclusions worth retaining long-term -- You want to look up something you accumulated earlier - -## Directory Structure - -``` -knowledge/ -├── index.md # Global index (must be maintained) -├── log.md # Operation log (append-only) -└── / # Category subdirectories (grouped by content) - └── .md # Knowledge page (lowercase-hyphenated filename) -``` - -## Three Core Operations - -### 1. Ingest - -When you share some material, the Agent will: - -1. Read and understand the original content, extracting key information -2. Decide which category it belongs to — check `index.md` first; create a new category if none fits -3. Generate a knowledge page at `knowledge//.md` -4. Update the index `index.md` and the log `log.md` - -### 2. Synthesise - -When a conversation produces new conclusions or insights: - -1. Create a new knowledge page under an appropriate category -2. Add cross-links to and from related existing pages -3. Update the index and log - -### 3. Query - -When you ask about previously accumulated knowledge: - -1. Search `index.md` for potentially relevant pages -2. Open specific pages with the `read` tool -3. Supplement with `memory_search` if needed -4. Include links to knowledge pages in the answer so you can click through to the source - -## Page Format - -```markdown -# Page Title - -> Source: - -Body content. Link between pages using relative paths: -[Related Page](../category/related-page.md) - -## Key Points - -- ... - -## Related Pages - -- [Page A](../category/page-a.md) — why it's related -``` - - -- `> Source:` records where this knowledge came from. Always include it when there is a clear source -- Cross-references are important: when creating or updating a page, remember to add back-links in the related pages too -- **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` uses a flat list grouped by category, one knowledge page per line: - -```markdown -# Knowledge Index - -## Category A -- [Page Title](category-a/page-slug.md) — one-line summary - -## Category B -- [Page Title](category-b/page-slug.md) — one-line summary -``` - -No tables, no emojis. Category names and organisation can be adjusted freely. - -## Log Format - -`knowledge/log.md` is append-only — newest entries go at the bottom: - -```markdown -## [YYYY-MM-DD] ingest | Page Title -## [YYYY-MM-DD] synthesize | Page Title -``` - -## Writing Guidelines - -- **Filenames**: lowercase with hyphens, e.g. `machine-learning.md` -- **One topic per page** — link related content across pages -- **Update, don't duplicate** — if a page already exists, update it rather than creating a new one -- **Always update the index** `knowledge/index.md` after any change -- **Distill, don't copy** — capture the key points, not the entire source -- **Use full paths when referencing knowledge pages in conversations**, e.g. `[Title](knowledge//.md)`. Use relative paths only for inter-page links -- **Include links when answering questions based on knowledge pages** so users can dig deeper diff --git a/docs/en/skills/skill-creator.mdx b/docs/en/skills/skill-creator.mdx deleted file mode 100644 index 58853f52..00000000 --- a/docs/en/skills/skill-creator.mdx +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: skill-creator -description: Create, install, and update skills — standardises SKILL.md format and directory structure ---- - -`skill-creator` is a "meta-skill" that helps the Agent create, install, and update other skills, ensuring every skill follows a consistent `SKILL.md` format and directory layout. - -## When It Triggers - -- The user wants to install a skill from a URL or remote repository -- The user wants to create a brand-new skill from scratch -- An existing skill needs upgrading or restructuring - -## What Is a Skill? - -A skill is a reusable instruction set plus optional scripts and assets. It injects domain expertise into the Agent so it can handle specific tasks like a specialist. - -A skill typically contains: - -1. **Specialised workflow** — step-by-step instructions for a category of tasks -2. **Tool usage** — how to call a particular API or process a particular file format -3. **Domain knowledge** — team conventions, business rules, data schemas, etc. -4. **Attached resources** — scripts, reference docs, templates, etc. - - -**Core principle: less is more.** Only write what the Agent wouldn't figure out on its own. For every line you add, ask yourself: is it worth the tokens? - - -## Directory Structure - -``` -skill-name/ -├── SKILL.md # Required: skill definition -│ ├── YAML frontmatter (name / description are mandatory) -│ └── Markdown body (instructions + examples) -└── Optional resources - ├── scripts/ # Executable scripts (Python / Bash, etc.) - ├── references/ # Large reference docs the Agent reads on demand - └── assets/ # Templates, icons, etc. used directly in output -``` - -## SKILL.md Specification - -Frontmatter fields in the SKILL.md header: - -| Field | Description | -| --- | --- | -| `name` | Skill name — lowercase with hyphens, must match the directory name | -| `description` | **The most important field.** Clearly state what the skill does and when to use it. The Agent reads this to decide whether to invoke it. All trigger-related descriptions go here, not in the body | -| `metadata.cowagent.requires.bins` | System CLI tools that must be installed | -| `metadata.cowagent.requires.env` | Required environment variables (all must be present) | -| `metadata.cowagent.requires.anyEnv` | Multiple API keys — at least one must be set | -| `metadata.cowagent.requires.anyBins` | Multiple tools — at least one must be installed | -| `metadata.cowagent.always` | Set to `true` to always load, skipping dependency checks | -| `metadata.cowagent.emoji` | Display emoji (optional) | -| `metadata.cowagent.os` | OS restriction, e.g. `["darwin", "linux"]` | - - -The `category` field does not need to be set manually — the system automatically sets it to `skill`. - - -Two ways to declare API key dependencies: - -```yaml -metadata: - cowagent: - requires: - env: ["MYAPI_KEY"] # Must be present -``` - -```yaml -metadata: - cowagent: - requires: - anyEnv: ["OPENAI_API_KEY", "LINKAI_API_KEY"] # At least one -``` - -**Skills are auto-enabled/disabled based on dependencies**: they activate when all required environment variables are present and deactivate when any are missing — no need for manual `/skill enable`. - -## Resource Directories - -| Directory | What goes here | What does NOT go here | -| --- | --- | --- | -| `scripts/` | Code that needs to run repeatedly, or scripts that produce deterministic results | Demo-only code snippets | -| `references/` | Documents **over 500 lines** that genuinely won't fit in SKILL.md (e.g. a full DB schema) | General API docs, tutorials, examples | -| `assets/` | Files that appear in the final output (templates, icons, boilerplate, etc.) | Explanatory documentation | - - -**In principle, everything goes in `SKILL.md`** — only split into resource directories when it truly won't fit. - -Do not add `README.md`, `CHANGELOG.md`, or `INSTALLATION_GUIDE.md` to a skill — put everything in `SKILL.md`. Resource directories should only contain scripts that actually run or assets that are actually used. - - -## Installing External Skills - -After installation, the skill lands in `/skills//`. - -| Source | How to install | -| --- | --- | -| URL (single file) | curl / web_fetch | -| URL (zip archive) | Download and extract | -| Local SKILL.md | Read directly | -| Local zip archive | Extract | - -Installation steps: - -1. Locate the `SKILL.md` (may be at the root or in a subdirectory of the archive) -2. Read the `name` from the frontmatter -3. Copy the **entire skill directory** (including `SKILL.md`, `scripts/`, `assets/`, etc.) to `/skills//` -4. If the archive contains an `INSTALL.md` or similar setup script, run it — but the final result must still reside under `/skills//` - -## Creating a Skill from Scratch - -Recommended order: - -1. **Clarify requirements** — ask the user for a few concrete use cases (don't ask too many at once) -2. **Plan the structure** — does this skill need scripts? Reference docs? Template assets? -3. **Scaffold** — use the init script: - - ```bash - scripts/init_skill.py --path /skills [--resources scripts,references,assets] [--examples] - ``` - -4. **Fill in content** — write SKILL.md, add scripts and resources. Always test scripts after writing them -5. **Validate** (optional): - - ```bash - scripts/quick_validate.py /skills/ - ``` - -6. **Iterate** — keep improving based on real-world usage feedback - -## Naming Conventions - -- Use only lowercase letters, digits, and hyphens. Normalise user-given names, e.g. `Plan Mode` → `plan-mode` -- Maximum 64 characters -- Keep it short, start with a verb, make it self-explanatory -- Use tool names as prefixes when appropriate, e.g. `gh-address-comments`, `linear-address-issue` -- The directory name and the `name` field must match exactly - -## Three-Level Loading - -Skills are not loaded into context all at once — they use a three-level progressive loading mechanism: - -1. **Metadata** (`name` + `description`) — always in context (~100 words). The Agent uses this to decide whether to invoke the skill -2. **SKILL.md body** — loaded only when the skill is activated; keep it under 500 lines -3. **Resource files** — read on demand by the Agent - -For skills with multiple variants (e.g. multi-cloud deployment), organise like this: - -``` -cloud-deploy/ -├── SKILL.md # Main workflow and provider selection logic -└── references/ - ├── aws.md - ├── gcp.md - └── azure.md -``` - -When the user picks AWS, the Agent only reads `aws.md` — no need to load all three providers. - -## Common Design Patterns - -**Step-by-step**: numbered steps with corresponding scripts. - -```markdown -1. Analyse form structure (run analyze_form.py) -2. Generate field mappings (edit fields.json) -3. Auto-fill the form (run fill_form.py) -``` - -**Branching**: different flows based on user intent. - -```markdown -1. Determine operation type: - **Creating new content?** → follow the "Create" workflow - **Editing existing content?** → follow the "Edit" workflow -``` - -**Template-based**: when output format has strict requirements, include a template in SKILL.md for the Agent to follow. diff --git a/docs/en/tools/bash.mdx b/docs/en/tools/bash.mdx deleted file mode 100644 index 60b20918..00000000 --- a/docs/en/tools/bash.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: bash - Terminal -description: Execute system commands ---- - -Execute Bash commands in the current working directory, returns stdout and stderr. API keys configured via `env_config` are automatically injected into the environment. - -## Dependencies - -No extra dependencies, available by default. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `command` | string | Yes | Command to execute | -| `timeout` | integer | No | Timeout in seconds | - -## Use Cases - -- Install packages and dependencies -- Run code and tests -- Deploy applications and services (Nginx config, process management, etc.) -- System administration and troubleshooting - - - - diff --git a/docs/en/tools/browser.mdx b/docs/en/tools/browser.mdx deleted file mode 100644 index 4c6fda82..00000000 --- a/docs/en/tools/browser.mdx +++ /dev/null @@ -1,172 +0,0 @@ ---- -title: browser - Browser -description: Control a browser to access and interact with web pages ---- - -Control a Chromium browser for web navigation, element interaction and content extraction. Supports JavaScript-rendered pages and uses a compact DOM snapshot so the Agent can efficiently understand page structure. - -## Installation - - - - ```bash - cow install-browser - ``` - - This command will: - - Install the `playwright` Python package (with auto-fallback for older systems) - - Install system dependencies on Linux - - Download the Chromium browser (Linux servers automatically use the headless build) - - Detect China-mainland networks and use mirror acceleration - - - ```bash - pip install playwright - playwright install chromium - ``` - - On Linux servers, install system dependencies as well: - ```bash - sudo playwright install-deps chromium - ``` - - On older systems (e.g. Ubuntu 18.04, glibc < 2.28), install a compatible version: - ```bash - pip install playwright==1.28.0 - python -m playwright install chromium - ``` - - To accelerate the Chromium download from China: - ```bash - export PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright - python -m playwright install chromium - ``` - - - - - 1. Supported on Ubuntu 20.04+, Debian 10+, macOS and Windows. Older systems such as Ubuntu 18.04 will fall back to a compatible version automatically. - 2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool. - - -## Workflow - -A typical browser workflow for the Agent: - -1. **`navigate`** — Open the target URL -2. **`snapshot`** — Get a compact DOM with auto-numbered interactive elements (`ref`) -3. **`click` / `fill` / `select`** — Operate elements by `ref` -4. **`snapshot`** — Snapshot again to verify the result - -## Supported Actions - -| Action | Description | Key parameters | -| --- | --- | --- | -| `navigate` | Open URL | `url` | -| `snapshot` | Get structured page text (primary way) | `selector` (optional) | -| `click` | Click an element | `ref` or `selector` | -| `fill` | Fill text into an input | `ref` or `selector`, `text` | -| `select` | Select a dropdown option | `ref` or `selector`, `value` | -| `scroll` | Scroll the page | `direction` (up/down/left/right) | -| `screenshot` | Save a screenshot to the workspace | `full_page` | -| `wait` | Wait for an element or timeout | `selector`, `timeout` | -| `press` | Press a key (Enter, Tab, etc.) | `key` | -| `back` / `forward` | Browser back / forward | - | -| `get_text` | Get an element's text content | `selector` | -| `evaluate` | Run JavaScript | `script` | - -## Use Cases - -- Access a URL to retrieve dynamic page content -- Fill in forms and log in -- Operate web elements (click buttons, select options, etc.) -- Verify the result of a deployed web page -- Scrape content that requires JS rendering - -## Run Mode - -The browser picks a mode based on the runtime environment: - -| Environment | Mode | -| --- | --- | -| macOS / Windows | Headed (browser window visible) | -| Linux desktop (with DISPLAY) | Headed | -| Linux server (no DISPLAY) | Headless | - -You can override it in `config.json`: - -```json -{ - "tools": { - "browser": { - "headless": true - } - } -} -``` - -## Persistent Login - -**Log in to a target site once and the Agent can keep using it.** Two ways are supported: - -### Option 1: Persistent mode (default) - -Works out of the box. Login state is saved under `~/.cow/browser_profile`. No configuration needed. - -To disable persistence and start with a clean environment every time: - -```json -{ - "tools": { - "browser": { - "persistent": false - } - } -} -``` - -### Option 2: CDP mode (attach to real Chrome) - -Have the Agent connect to a separately launched real Chrome (instead of the Chromium bundled with Playwright) for full browser fingerprints. Useful for sites with strict bot detection. - -Launch Chrome with a debugging port and a dedicated user data directory: - - - - ```bash - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ - --remote-debugging-port=9222 \ - --user-data-dir="$HOME/.cow/chrome-cdp" - ``` - - - ```bash - google-chrome \ - --remote-debugging-port=9222 \ - --user-data-dir="$HOME/.cow/chrome-cdp" - ``` - - - ```powershell - & "C:\Program Files\Google\Chrome\Application\chrome.exe" ` - --remote-debugging-port=9222 ` - --user-data-dir="$env:USERPROFILE\.cow\chrome-cdp" - ``` - - - -Then point the Agent at the endpoint in `config.json`: - -```json -{ - "tools": { - "browser": { - "cdp_endpoint": "http://localhost:9222" - } - } -} -``` - - - Chrome 137+ requires `--remote-debugging-port` to be paired with a dedicated `--user-data-dir`. As a result, the CDP-launched Chrome **cannot directly reuse the login state of your daily Chrome**; you'll need to log in once inside this dedicated profile. - diff --git a/docs/en/tools/edit.mdx b/docs/en/tools/edit.mdx deleted file mode 100644 index f231c6b9..00000000 --- a/docs/en/tools/edit.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: edit - File Edit -description: Edit files via precise text replacement ---- - -Edit files via precise text replacement. If `oldText` is empty, appends to the end of the file. - -## Dependencies - -No extra dependencies, available by default. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `path` | string | Yes | File path | -| `oldText` | string | Yes | Original text to replace (empty to append) | -| `newText` | string | Yes | Replacement text | - -## Use Cases - -- Modify specific parameters in configuration files -- Fix bugs in code -- Insert content at specific positions in files diff --git a/docs/en/tools/env-config.mdx b/docs/en/tools/env-config.mdx deleted file mode 100644 index 23f75bf8..00000000 --- a/docs/en/tools/env-config.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: env_config - Environment -description: Manage API keys and secrets ---- - -Manage environment variables (API keys and secrets) in the workspace `.env` file, with secure conversational updates. Built-in security protection and desensitization. - -## Dependencies - -| Dependency | Install Command | -| --- | --- | -| `python-dotenv` ≥ 1.0.0 | `pip install python-dotenv>=1.0.0` | - -Included when installing optional dependencies: `pip3 install -r requirements-optional.txt` - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `action` | string | Yes | Operation type: `get`, `set`, `list`, `delete` | -| `key` | string | No | Environment variable name | -| `value` | string | No | Environment variable value (only for `set`) | - -## Usage - -Tell the Agent what key you need to configure, and it will automatically invoke this tool: - -- "Configure my BOCHA_API_KEY" -- "Set OPENAI_API_KEY to sk-xxx" -- "Show configured environment variables" - -Configured keys are automatically injected into the `bash` tool's execution environment. - - - - diff --git a/docs/en/tools/index.mdx b/docs/en/tools/index.mdx deleted file mode 100644 index b85930e8..00000000 --- a/docs/en/tools/index.mdx +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: Tools Overview -description: CowAgent built-in tools system ---- - -Tools are the core capability for Agent to access operating system resources. The Agent intelligently selects and invokes tools based on task requirements, performing file operations, command execution, web search, scheduled tasks, and more. Tools are implemented in the `agent/tools/` directory. - -## Built-in Tools - -The following tools are available by default with no extra configuration: - - - - Read file content, supports text, images, PDF - - - Create or overwrite files - - - Edit files via precise text replacement - - - List directory contents - - - Execute system commands - - - Send files or images to user - - - Search and read long-term memory - - - -## Optional Tools - -The following tools require additional dependencies or API key configuration: - - - - Manage API keys and secrets - - - Create and manage scheduled tasks - - - Search the internet for real-time information - - - -## MCP Tools - -Integrate thousands of community tools (maps, GitHub, Notion, etc.) via the [Model Context Protocol](https://modelcontextprotocol.io). Configure `mcp.json` once, ready to use: - - - - Supports standard stdio / SSE transports. Hot-reload, zero code changes. - - diff --git a/docs/en/tools/ls.mdx b/docs/en/tools/ls.mdx deleted file mode 100644 index e9a5f656..00000000 --- a/docs/en/tools/ls.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: ls - Directory List -description: List directory contents ---- - -List directory contents, sorted alphabetically, directories suffixed with `/`, includes hidden files. - -## Dependencies - -No extra dependencies, available by default. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `path` | string | Yes | Directory path, relative paths are based on workspace directory | -| `limit` | integer | No | Maximum entries to return, default 500 | - -## Use Cases - -- Browse project structure -- Find specific files -- Check if a directory exists diff --git a/docs/en/tools/mcp.mdx b/docs/en/tools/mcp.mdx deleted file mode 100644 index fc320fe0..00000000 --- a/docs/en/tools/mcp.mdx +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: MCP Tools -description: Integrate external tool ecosystems via the Model Context Protocol ---- - -CowAgent supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), allowing the Agent to directly invoke tens of thousands of community MCP tools. Configure `mcp.json` once and the tools are exposed to the LLM in exactly the same way as built-in tools — automatically selected and invoked. - -## Configuration File - -CowAgent reads `~/cow/mcp.json`. If the file does not exist, no MCP tools are loaded — and no error is raised. - -For Docker deployments, the official `docker-compose.yml` already mounts the host's `./cow` directory to `/home/agent/cow` inside the container (i.e. the container user's `~/cow`). Just drop `mcp.json` into the host's `./cow/` directory and it will take effect. - -### Standard Format - -Fully compatible with the MCP community standard, identical to Claude Desktop / Cursor: - -```json -{ - "mcpServers": { - "": { - "command": "npx", - "args": ["-y", "some-mcp-package"], - "env": { - "API_KEY": "your-key-here" - } - } - } -} -``` - -| Field | Required | Description | -| --- | --- | --- | -| `command` | stdio | Executable to launch the server (e.g. `npx`, `python`, `uvx`) | -| `args` | No | Arguments passed to `command` | -| `env` | No | Environment variables for the subprocess, commonly used for API keys | -| `url` | SSE / Streamable HTTP | Remote endpoint URL (alternative to `command`) | -| `type` | Remote | Remote transport type: `sse` or `streamable-http` (defaults to `sse`) | -| `headers` | No | Extra HTTP headers for remote requests (e.g. `Authorization`); Streamable HTTP only | -| `disabled` | No | When `true`, this server is skipped — handy for temporary disabling | - -### Full Example - -```json -{ - "mcpServers": { - "fetch": { - "command": "uvx", - "args": ["mcp-server-fetch"] - }, - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "" - } - } - } -} -``` - -- **fetch**: Generic web page fetcher that returns page text content. No API key required. -- **github**: Access GitHub repos, issues, PRs, etc. Requires a Personal Access Token. - -## Let the Agent Configure It for You - -CowAgent ships with `read` / `write` / `edit` tools, so **you can simply send the MCP config to the Agent and ask it to write the file**: - -For example: - -```markdown -Add this MCP to ~/cow/mcp.json: - -{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}} -``` - -The Agent will: - -1. Read the existing MCP config and merge the new server entry, preserving existing ones -2. Hot-reload the new MCP server, so the corresponding tools become available on the next message - -## How It Works - -- **Async loading at startup**: All servers configured in `mcp.json` are loaded asynchronously in the background, never blocking the main loop — chat is usable immediately. -- **Hot reload**: When you or the Agent modifies `mcp.json`, changed servers are automatically reloaded after the current message — no need to restart cow. -- **Flat exposure**: Each method exposed by an MCP server appears as an individual tool. The LLM picks one directly without a second-stage decision. - -## Supported Transports - -| Transport | Description | Config Field | -| --- | --- | --- | -| **stdio** | Subprocess communication. The most common option, with the richest community ecosystem. | `command` + `args` | -| **SSE** | HTTP Server-Sent Events. Legacy remote transport. | `url` (default) | -| **Streamable HTTP** | New unified remote transport, gradually replacing SSE. | `type: "streamable-http"` + `url` | - -## Troubleshooting - -| Symptom | What to Check | -| --- | --- | -| Agent has no MCP tools after startup | Verify that `~/cow/mcp.json` exists and contains valid JSON | -| A specific server fails to load | Look for `[MCP] Server 'xxx' load failed` in startup logs — usually missing dependencies or API keys | -| Changes to `mcp.json` aren't applied | Changes take effect on **the next message**. If the server config didn't actually change (e.g. only comments edited), no restart is triggered | -| Docker deployment | Make sure host's `./cow` is mounted to `/home/agent/cow` in the container, then just drop `mcp.json` into host's `./cow/`. Or just ask the Agent to do it | - -## Recommended MCP Marketplaces - -You can browse third-party MCP marketplaces and copy a JSON config to use directly, for example: - -- [mcp.so](https://mcp.so) — Global MCP service index -- [ModelScope MCP Hub](https://modelscope.cn/mcp) — ModelScope's MCP hub, more reliable from mainland China - -Any MCP server that follows the standard protocol (stdio / SSE / Streamable HTTP) integrates with CowAgent out of the box. diff --git a/docs/en/tools/memory.mdx b/docs/en/tools/memory.mdx deleted file mode 100644 index a1874eef..00000000 --- a/docs/en/tools/memory.mdx +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: memory - Memory & Knowledge -description: Search and read long-term memory and knowledge base files ---- - -The memory tool contains two sub-tools: `memory_search` (search memory) and `memory_get` (read memory or knowledge files). - -When the [knowledge base](/en/knowledge) feature is enabled, both tools also support accessing files under the `knowledge/` directory. - -## Dependencies - -No extra dependencies, available by default. Managed by the Agent Core memory system. - -## memory_search - -Search historical memory and knowledge base content with hybrid keyword and vector retrieval. - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `query` | string | Yes | Search query | - -## memory_get - -Read the content of a specific memory or knowledge file. - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `path` | string | Yes | Relative path to the file (e.g. `MEMORY.md`, `memory/2026-01-01.md`, `knowledge/concepts/rag.md`) | -| `start_line` | integer | No | Start line number | -| `end_line` | integer | No | End line number | - -## How It Works - -The Agent automatically invokes memory tools in these scenarios: - -- When the user shares important information → stores to memory -- When historical context is needed → searches relevant memory -- When conversation reaches a certain length → extracts summary for storage -- When discussing domain knowledge → retrieves relevant pages from the knowledge base - - - When `knowledge` is set to `false` in config, the tool descriptions and search scope automatically adjust to include only memory files. - diff --git a/docs/en/tools/read.mdx b/docs/en/tools/read.mdx deleted file mode 100644 index 56b56570..00000000 --- a/docs/en/tools/read.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: read - File Read -description: Read file content ---- - -Read file content. Supports text files, PDF files, images (returns metadata), and more. - -## Dependencies - -No extra dependencies, available by default. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `path` | string | Yes | File path, relative paths are based on workspace directory | -| `offset` | integer | No | Start line number (1-indexed), negative values read from the end | -| `limit` | integer | No | Number of lines to read | - -## Use Cases - -- View configuration files, log files -- Read code files for analysis -- Check image/video file info diff --git a/docs/en/tools/scheduler.mdx b/docs/en/tools/scheduler.mdx deleted file mode 100644 index 18c211bf..00000000 --- a/docs/en/tools/scheduler.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: scheduler - Scheduler -description: Create and manage scheduled tasks ---- - -Create and manage dynamic scheduled tasks with flexible scheduling and execution modes. - -## Dependencies - -| Dependency | Install Command | -| --- | --- | -| `croniter` ≥ 2.0.0 | `pip install croniter>=2.0.0` | - -Included in core dependencies: `pip3 install -r requirements.txt` - -## Scheduling Modes - -| Mode | Description | -| --- | --- | -| One-time | Execute once at a specified time | -| Fixed interval | Repeat at fixed time intervals | -| Cron expression | Define complex schedules using Cron syntax | - -## Execution Modes - -- **Fixed message**: Send a preset message when triggered -- **Agent dynamic task**: Agent intelligently executes the task when triggered - -## Usage - -Create and manage scheduled tasks with natural language: - -- "Send me a weather report every morning at 9 AM" -- "Check server status every 2 hours" -- "Remind me about the meeting tomorrow at 3 PM" -- "Show all scheduled tasks" - - - - - -## Results injected into the conversation - -Scheduled tasks run inside an isolated session (so internal planning and tool calls do not pollute the user's chat), but the **final output** is written back to the user's real session as a message pair. You can directly follow up — e.g. "expand on point 2 from earlier". - -**Default policy** - -- Output of Agent dynamic tasks is injected into the conversation -- Fixed-message tasks are not injected by default (configurable) -- Each session keeps the most recent **3 pairs** of scheduler messages; older pairs are pruned automatically. Regular user messages are unaffected - -**Configuration** - -| Key | Default | Description | -| --- | --- | --- | -| `scheduler_inject_to_session` | `true` | Master switch | -| `scheduler_inject_max_per_session` | `3` | Max scheduler message pairs kept per session | -| `scheduler_inject_send_message` | `false` | Whether to also inject fixed-message tasks | - -```json -{ - "scheduler_inject_to_session": true, - "scheduler_inject_max_per_session": 3, - "scheduler_inject_send_message": false -} -``` - -## Context inside scheduled task execution - -The isolated session for scheduled tasks retains a few recent runs of conversation history, so you can naturally do "compare with last time" or "continue from previous conclusion". To prevent prompts from growing unbounded for high-frequency tasks (e.g. a 5-minute monitor), history is auto-trimmed: - -``` -scheduler_keep_turns = max(1, agent_max_context_turns / 5) -``` - -`agent_max_context_turns` defaults to `20`, so each scheduled run keeps the most recent **4 turns** of history by default. Increase `agent_max_context_turns` if you need longer memory. - - -For group-chat scenarios (Feishu / WeCom group bots / DingTalk, etc.), the user's real `session_id` looks like `user_id:group_id` — different from `receiver`. Scheduler records the correct `session_id` when a task is created. For older `tasks.json` entries missing this field, the runtime falls back to `receiver`, matching legacy behavior. - diff --git a/docs/en/tools/send.mdx b/docs/en/tools/send.mdx deleted file mode 100644 index 1cf089ac..00000000 --- a/docs/en/tools/send.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: send - File Send -description: Send files to user ---- - -Send files to the user (images, videos, audio, documents, etc.), used when the user explicitly requests to send/share a file. - -## Dependencies - -No extra dependencies, available by default. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `path` | string | Yes | File path, can be absolute or relative to workspace | -| `message` | string | No | Accompanying message | - -## Use Cases - -- Send generated code or documents to the user -- Send screenshots, charts -- Share downloaded files diff --git a/docs/en/tools/vision.mdx b/docs/en/tools/vision.mdx deleted file mode 100644 index 4db6bec0..00000000 --- a/docs/en/tools/vision.mdx +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: vision - Image Understanding -description: Analyze image content (recognition, description, OCR, etc.) ---- - -Analyze local images or image URLs using Vision API. Supports content description, text extraction (OCR), object recognition, and more. - -## Model Selection - -The vision tool uses a multi-level auto-selection strategy with automatic fallback — no manual configuration required: - -1. **Main model** — uses the currently configured main model for image recognition (must be a multimodal model) -2. **Other configured models** — auto-discovers other multimodal models with configured API keys as alternatives - -If the current provider fails, the tool automatically tries the next one until it succeeds or all fail. - -### Supported Models - -| Provider | Vision Model | Notes | -| --- | --- | --- | -| OpenAI / Compatible | Main model | All OpenAI-protocol-compatible multimodal models | -| Qwen (DashScope) | Main model | e.g. qwen3.6-plus, etc. | -| Claude | Main model | Anthropic native image format | -| Gemini | Main model | inlineData format | -| Doubao | Main model | doubao-seed-2-0 series natively supported | -| Kimi (Moonshot) | Main model | kimi-k2.6, kimi-k2.5 natively supported | -| ERNIE | Main model | Defaults to the multimodal main model (e.g. `ernie-5.1`); falls back to `ernie-4.5-turbo-vl` when the main model is not multimodal | -| ZhipuAI | glm-5v-turbo | Always uses the dedicated vision model | -| MiniMax | MiniMax-Text-01 | Always uses the dedicated vision model | - - - ZhipuAI and MiniMax text models do not support image understanding, so their dedicated vision models are always used automatically. - - -> When `use_linkai=true`, LinkAI's multimodal model is used by default. - -## Custom Configuration - -To specify the model used by Vision, configure it in `config.json`, for example: - -```json -{ - "tools": { - "vision": { - "model": "gpt-4.1" - } - } -} -``` - -The specified model is **used first**, and the tool automatically routes to the corresponding provider based on the model name; on failure, it falls back to other configured providers. - -In most cases no configuration is needed — the tool works automatically as long as the main model supports multimodal input or any vision-capable API key is configured. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `image` | string | Yes | Local file path or HTTP(S) image URL | -| `question` | string | Yes | Question to ask about the image | - -Supported image formats: jpg, jpeg, png, gif, webp - - - -## Use Cases - -- Describe image content -- Extract text from images (OCR) -- Identify objects, colors, scenes -- Analyze screenshots and scanned documents - - - Images larger than 1MB are automatically compressed before upload. All images (including remote URLs) are converted to base64 for transmission to ensure compatibility with all model backends. - diff --git a/docs/en/tools/web-fetch.mdx b/docs/en/tools/web-fetch.mdx deleted file mode 100644 index 0a0349b9..00000000 --- a/docs/en/tools/web-fetch.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: web_fetch - Web Fetch -description: Fetch web pages and document content ---- - -Fetch the content of an HTTP/HTTPS URL. Web pages are extracted as readable text; document files (PDF, Word, Excel, etc.) are downloaded and parsed automatically. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `url` | string | Yes | HTTP/HTTPS URL (web page or document) | - -## Supported file types - -| Type | Formats | -| --- | --- | -| PDF | `.pdf` | -| Word | `.docx` | -| Text | `.txt`, `.md`, `.csv`, `.log` | -| Spreadsheet | `.xls`, `.xlsx` | -| Presentation | `.ppt`, `.pptx` | - -## Use cases - -- Extract readable text from a web page -- Download and parse remote documents -- Inspect API response bodies - - - `web_fetch` only retrieves static HTML. For pages that require JavaScript rendering (such as SPAs), use the `browser` tool instead. - diff --git a/docs/en/tools/web-search.mdx b/docs/en/tools/web-search.mdx deleted file mode 100644 index 80c1eac1..00000000 --- a/docs/en/tools/web-search.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: web_search - Web Search -description: Search the internet for real-time information, with support for multiple search providers ---- - -Search the internet for real-time information, news, research, and more. Supports four backends — Bocha, ERNIE, GLM, and LinkAI — and works once any one of them is configured. - - - It is recommended to configure providers and routing strategy visually from the "Model Management → Search" panel in the [Web console](/en/channels/web), without manually editing the configuration file. - - -## Providers - -| Provider | Credential | Apply | -| --- | --- | --- | -| Bocha | `tools.web_search.bocha_api_key` | [Bocha Open Platform](https://open.bochaai.com/) | -| ERNIE | Reuses `qianfan_api_key` | [Qianfan Console](https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy) | -| Zhipu | Reuses `zhipu_ai_api_key` | [Zhipu Open Platform](https://docs.bigmodel.cn/cn/guide/tools/web-search) | -| LinkAI | Reuses `linkai_api_key` | [LinkAI Console](https://link-ai.tech/console/interface) | - -Except for Bocha which requires a dedicated `bocha_api_key`, the other three reuse the corresponding model's API key — configuring the model automatically grants search capability. - -## Routing Strategy - -```json -{ - "tools": { - "web_search": { - "strategy": "auto", - "provider": "" - } - } -} -``` - -- `auto` (default): the Agent intelligently picks among configured providers and may call multiple providers in a single task to gather more comprehensive results; when none is specified, falls back through `bocha → qianfan → zhipu → linkai`. -- `fixed`: always use the provider specified in `provider`; falls back to the auto order if that provider's credentials are missing. - -## Tool Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `query` | string | Yes | Search keywords | -| `count` | integer | No | Number of results (1–50, default 10) | -| `freshness` | string | No | Time range: `noLimit` (default), `oneDay`, `oneWeek`, `oneMonth`, `oneYear`, or date range like `2025-01-01..2025-02-01` | -| `summary` | boolean | No | Whether to return page summaries (default false) | -| `provider` | string | No | Available when multiple providers are configured under the `auto` strategy; used to switch provider for a single call | - - - If none of the four credentials are configured, this tool is not registered with the Agent. - diff --git a/docs/en/tools/write.mdx b/docs/en/tools/write.mdx deleted file mode 100644 index 2c0a10f0..00000000 --- a/docs/en/tools/write.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: write - File Write -description: Create or overwrite files ---- - -Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories. - -## Dependencies - -No extra dependencies, available by default. - -## Parameters - -| Parameter | Type | Required | Description | -| --- | --- | --- | --- | -| `path` | string | Yes | File path | -| `content` | string | Yes | Content to write | - -## Use Cases - -- Create new code files or scripts -- Generate configuration files -- Save processing results - - - Single writes should not exceed 10KB. For large files, create a skeleton first, then use the edit tool to add content in chunks. - diff --git a/docs/guide/manual-install.mdx b/docs/guide/manual-install.mdx index 799a1191..154f99a8 100644 --- a/docs/guide/manual-install.mdx +++ b/docs/guide/manual-install.mdx @@ -1,11 +1,11 @@ --- -title: 手动安装 -description: 手动部署 CowAgent(源码 / Docker) +title: Manual Install +description: Deploy CowAgent manually (source code / Docker) --- -## 源码部署 +## Source Code Deployment -### 1. 克隆项目代码 +### 1. Clone the project ```bash git clone https://github.com/zhayujie/CowAgent @@ -13,170 +13,136 @@ cd CowAgent/ ``` - 若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/CowAgent + For network issues, use the mirror: https://gitee.com/zhayujie/CowAgent -### 2. 安装依赖 +### 2. Install dependencies -核心依赖(必选): +Core dependencies (required): ```bash pip3 install -r requirements.txt ``` -扩展依赖(可选,建议安装): +Optional dependencies (recommended): ```bash pip3 install -r requirements-optional.txt ``` -> 国内网络可使用镜像源加速:`pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple` +### 3. Install Cow CLI -### 3. 安装 Cow CLI - -安装命令行工具,用于管理服务和技能: +Install the command-line tool for managing services and skills: ```bash pip3 install -e . ``` -安装后即可使用 `cow` 命令: +Then use the `cow` command: ```bash cow help ``` - 此步骤为推荐操作。安装后可以使用 `cow start`、`cow stop`、`cow update` 等命令管理服务,也可以使用 `cow skill` 管理技能。如果不安装 CLI,可以使用 `./run.sh` 或 `python3 app.py` 运行。 + 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. -### 3.1 安装浏览器工具(可选) +### 4. Configure -如需使用浏览器工具(控制浏览器访问网页、填写表单等),运行: - -```bash -cow install-browser -``` - -该命令会自动安装 Playwright 和 Chromium 浏览器。详细说明参考 [浏览器工具文档](/tools/browser)。 - - - 浏览器工具依赖较重(~300MB),如不需要可跳过,不影响其他功能正常使用。 - - -### 4. 配置 - -复制配置文件模板并编辑: +Copy the config template and edit: ```bash cp config-template.json config.json ``` -在 `config.json` 中填写模型 API Key 和通道类型等配置,详细说明参考各 [模型文档](/models/minimax)。 +Fill in model API keys, channel type, and other settings in `config.json`. See the [model docs](/models/index) for details. -### 5. 运行 +### 5. Run -**使用 Cow CLI 运行(推荐):** +**Using Cow CLI (recommended):** ```bash cow start ``` -**或者本地前台运行:** +**Or run locally in foreground:** ```bash python3 app.py ``` -运行后默认启动 Web 控制台,访问 `http://localhost:9899` 开始对话和管理Agent。 +By default, the Web console starts. Access `http://localhost:9899` to chat. -**服务器后台运行(不使用 CLI 时):** +**Background run on server (without CLI):** ```bash nohup python3 app.py & tail -f nohup.out ``` - **服务器公网访问 Web 控制台**:默认 `web_host` 仅监听 `127.0.0.1`(本机访问),需公网访问时请在 `config.json` 中将 `web_host` 设为 `0.0.0.0`,同时强烈建议设置 `web_password` 启用鉴权。此外还需在防火墙/安全组中放行 `9899` 端口,建议仅对指定 IP 开放以保证安全。 + **Deploying on a server?** By default `web_host` only listens on `127.0.0.1` (local access). Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group — ideally restricted to specific IPs. -## Docker 部署 +## Docker Deployment -使用 Docker 部署无需下载源码和安装依赖。Agent模式下更推荐使用源码部署以获得更多系统访问能力。 +Docker deployment does not require cloning source code or installing dependencies. For Agent mode, source deployment is recommended for broader system access. - 需要安装 [Docker](https://docs.docker.com/engine/install/) 和 docker-compose。 + Requires [Docker](https://docs.docker.com/engine/install/) and docker-compose. -**1. 下载配置文件** +**1. Download config** ```bash curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml ``` -打开 `docker-compose.yml` 填写所需配置。 +Edit `docker-compose.yml` with your configuration. -**2. 启动容器** +**2. Start container** ```bash sudo docker compose up -d ``` -**3. 查看日志** +**3. View logs** ```bash sudo docker logs -f chatgpt-on-wechat ``` - **Docker 公网访问 Web 控制台**:在 `docker-compose.yml` 中将 `WEB_HOST` 设为 `0.0.0.0`(容器内默认绑定 `127.0.0.1` 无法从宿主机外访问),同时强烈建议设置 `WEB_PASSWORD` 启用鉴权。此外需确保 `9899` 端口正确映射到宿主机,并在防火墙/安全组放行该端口。 + **Running in Docker?** Set `WEB_HOST` to `0.0.0.0` in `docker-compose.yml` so the console is reachable from outside the container, and set `WEB_PASSWORD` to protect it. Make sure port `9899` is mapped to the host and open in your firewall or security group. -## 核心配置项 +## Core Configuration - - - ```json - { - "channel_type": "web", - "model": "deepseek-v4-flash", - "deepseek_api_key": "", - "agent": true, - "agent_workspace": "~/cow", - "agent_max_context_tokens": 40000, - "agent_max_context_turns": 30, - "agent_max_steps": 15, - "cow_lang": "auto" - } - ``` - - - ```yaml - environment: - CHANNEL_TYPE: 'web' - MODEL: 'deepseek-v4-flash' - DEEPSEEK_API_KEY: 'your-api-key' - DEEPSEEK_API_BASE: 'https://api.deepseek.com/v1' - AGENT: 'True' - AGENT_MAX_CONTEXT_TOKENS: 40000 - AGENT_MAX_CONTEXT_TURNS: 30 - AGENT_MAX_STEPS: 15 - COW_LANG: 'auto' - ``` - - +```json +{ + "channel_type": "web", + "model": "deepseek-v4-flash", + "deepseek_api_key": "", + "agent": true, + "agent_workspace": "~/cow", + "agent_max_context_tokens": 40000, + "agent_max_context_turns": 30, + "agent_max_steps": 15, + "cow_lang": "auto" +} +``` -| 参数 | 环境变量 | 说明 | 默认值 | -| --- | --- | --- | --- | -| `channel_type` | `CHANNEL_TYPE` | 接入渠道类型 | `web` | -| `model` | `MODEL` | 模型名称 | `deepseek-v4-flash` | -| `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` | -| `cow_lang` | `COW_LANG` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | +| Parameter | Description | Default | +| --- | --- | --- | +| `channel_type` | Channel type | `web` | +| `model` | Model name | `deepseek-v4-flash` | +| `agent` | Enable Agent mode | `true` | +| `agent_workspace` | Agent workspace path | `~/cow` | +| `agent_max_context_tokens` | Max context tokens | `40000` | +| `agent_max_context_turns` | Max context turns | `30` | +| `agent_max_steps` | Max decision steps per task | `15` | +| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | - 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) 文件中查看。Docker 部署时,配置项名称需转为大写环境变量格式。 + Full configuration options are in the project [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py). diff --git a/docs/guide/quick-start.mdx b/docs/guide/quick-start.mdx index dd71ee88..ed5ec3f1 100644 --- a/docs/guide/quick-start.mdx +++ b/docs/guide/quick-start.mdx @@ -1,13 +1,13 @@ --- -title: 一键安装 -description: 使用脚本一键安装和管理 CowAgent +title: One-click Install +description: One-click install and manage CowAgent with scripts --- -项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行。 +The project provides scripts for one-click install, configuration, startup, and management. Script-based deployment is recommended for quick setup. -支持 Linux、macOS、Windows 操作系统,需安装 Python 3.7 ~ 3.12(推荐 3.9)。 +Supports Linux, macOS, and Windows. Requires Python 3.7-3.12 (3.9 recommended). -## 安装命令 +## Install Command @@ -22,37 +22,37 @@ description: 使用脚本一键安装和管理 CowAgent -脚本自动执行以下流程: +The script automatically performs these steps: -1. 检查 Python 环境(需要 Python 3.7+) -2. 安装必要工具(git、curl 等) -3. 克隆项目代码到 `~/CowAgent` -4. 安装 Python 依赖和 Cow CLI -5. 引导配置 AI 模型和通信渠道 -6. 启动服务 +1. Check Python environment (requires Python 3.7+) +2. Install required tools (git, curl, etc.) +3. Clone project to `~/CowAgent` +4. Install Python dependencies and Cow CLI +5. Guided configuration for AI model and channel +6. Start service -运行后默认启动 Web 控制台,访问 `http://localhost:9899` 开始对话和管理Agent。 +By default, the Web console starts after installation. Access `http://localhost:9899` to begin chatting. - **服务器部署需要公网访问控制台时**,请在 `config.json` 中将 `web_host` 设为 `0.0.0.0`(默认仅监听 `127.0.0.1` 本机访问),同时强烈建议设置 `web_password` 启用鉴权。然后通过 `http://:9899` 访问,并确保防火墙/安全组放行 `9899` 端口。 + **Deploying on a server?** By default `web_host` only listens on `127.0.0.1` (local access only). Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group — ideally restricted to specific IPs. -## 管理命令 +## Management Commands -安装完成后,使用 `cow` CLI 管理服务: +After installation, use the `cow` command to manage the service: -| 命令 | 说明 | +| Command | Description | | --- | --- | -| `cow start` | 启动服务 | -| `cow stop` | 停止服务 | -| `cow restart` | 重启服务 | -| `cow status` | 查看运行状态 | -| `cow logs` | 查看实时日志 | -| `cow update` | 更新代码并重启 | -| `cow install-browser` | 安装浏览器工具依赖 | +| `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 | +| `cow install-browser` | Install browser tool dependencies | -更多命令和用法参考 [命令文档](/cli/index)。 +See the [Commands documentation](/cli/index) for more details. - 如果 `cow` 命令不可用,也可以使用 `./run.sh <命令>`(Linux/macOS)或 `.\scripts\run.ps1 <命令>`(Windows)作为替代,功能等效。 + 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/guide/upgrade.mdx b/docs/guide/upgrade.mdx index 7a36d706..d1cd5df6 100644 --- a/docs/guide/upgrade.mdx +++ b/docs/guide/upgrade.mdx @@ -1,31 +1,31 @@ --- -title: 更新升级 -description: CowAgent 的升级方式说明 +title: Upgrade +description: How to upgrade CowAgent --- -## 命令升级(推荐) +## Recommended: One-line upgrade -使用 `cow update` 一键完成代码更新和服务重启: +Use `cow update` to pull the latest code and restart the service in one step: ```bash cow update ``` -该命令会自动完成以下流程: +The command runs the following automatically: -1. 拉取最新代码(`git pull`) -2. 停止当前服务 -3. 更新 Python 依赖 -4. 重新安装 CLI -5. 启动服务 +1. Pull the latest code (`git pull`) +2. Stop the running service +3. Update Python dependencies +4. Reinstall the CLI +5. Start the service - 如果未安装 Cow CLI,也可以使用 `./run.sh update` 完成相同操作。 + If the Cow CLI is not installed, `./run.sh update` performs the same operations. -## 手动升级 +## Manual upgrade -在项目根目录下执行: +Run the following inside the project root: ```bash git pull @@ -33,23 +33,23 @@ pip3 install -r requirements.txt pip3 install -e . ``` -更新完成后重启服务: +Then restart the service: ```bash -# 使用 Cow CLI (推荐) +# Using Cow CLI (recommended) cow restart -# 或使用 run.sh +# Or using run.sh ./run.sh restart -# 或使用 nohup 直接运行 +# Or restart manually with nohup kill $(ps -ef | grep app.py | grep -v grep | awk '{print $2}') nohup python3 app.py & tail -f nohup.out ``` -## Docker 升级 +## Docker upgrade -在 `docker-compose.yml` 所在目录下执行: +Run the following in the directory containing `docker-compose.yml`: ```bash sudo docker compose pull @@ -57,5 +57,5 @@ sudo docker compose up -d ``` - 升级前建议备份 `config.json` 配置文件。Docker 环境下如需保留数据,可通过 volume 挂载持久化工作空间目录。 + Back up `config.json` before upgrading. For Docker deployments, mount the workspace directory as a volume to persist data across upgrades. diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index 9d8c3da2..98084b48 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -1,41 +1,41 @@ --- -title: 项目架构 -description: CowAgent 2.0 的系统架构和核心设计 +title: Architecture +description: CowAgent 2.0 system architecture and core design --- -CowAgent 2.0 从简单的聊天机器人全面升级为超级智能助理,采用 Agent 架构设计,具备自主思考、规划任务、长期记忆和技能扩展等能力。 +CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistant with Agent architecture, featuring autonomous thinking, task planning, long-term memory, and skill extensibility. -## 系统架构 +## System Architecture -CowAgent 的整体架构由以下核心模块组成: +CowAgent's architecture consists of the following core modules: -CowAgent Architecture +CowAgent Architecture -| 模块 | 说明 | +| Module | Description | | --- | --- | -| **Plan** | 理解用户意图,将复杂任务分解为多步骤计划,循环调用工具直到完成目标 | -| **Memory** | 自动将重要信息持久化为核心记忆和日级记忆,支持关键词和向量混合检索,跨会话保持上下文连续性 | -| **Knowledge** | 以主题维度组织结构化知识,Agent 自主整理有价值信息为 Markdown 页面,维护索引和交叉引用,构建持续增长的知识网络 | -| **Tools** | Agent 访问操作系统资源的核心能力,内置文件读写、终端执行、浏览器操作、定时调度、记忆检索、联网搜索等 10+ 种工具 | -| **Skills** | 加载和管理 Skills,支持从 Skill Hub、GitHub 等一键安装,或通过对话创建自定义技能 | -| **Models** | 模型层,统一接入 OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen 等国内外主流大语言模型 | -| **Channels** | 消息通道层,负责接收和发送消息,支持 Web 控制台、微信、飞书、钉钉、企微、公众号等,统一消息协议 | -| **CLI** | 命令行系统,提供终端命令(`cow`)和对话命令(`/`),支持进程管理、技能安装、配置修改、知识库管理等操作 | +| **Plan** | Understands user intent, decomposes complex tasks into multi-step plans, and iteratively invokes tools until the goal is achieved | +| **Memory** | Automatically persists important information as core memory and daily memory, with hybrid keyword and vector retrieval for cross-session context continuity | +| **Knowledge** | Organizes structured knowledge by topic. The Agent autonomously distills valuable information into Markdown pages, maintaining indexes and cross-references to build a growing knowledge network | +| **Tools** | Core capability for Agent to access OS resources. 10+ built-in tools including file read/write, terminal, browser, scheduler, memory search, web search, and more | +| **Skills** | Loads and manages Skills. Supports one-click installation from Skill Hub, GitHub, and more, or custom skill creation through conversation | +| **Models** | Model layer with unified access to OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, and other mainstream LLMs | +| **Channels** | Message channel layer for receiving and sending messages. Supports Web console, WeChat, Feishu, DingTalk, WeCom, WeChat Official Account, and more with a unified protocol | +| **CLI** | Command-line system providing terminal commands (`cow`) and chat commands (`/`) for process management, skill installation, configuration, knowledge base management, and more | -## Agent 模式 +## Agent Mode Workflow -启用 Agent 模式后,CowAgent 会以自主智能体的方式运行,核心工作流如下: +When Agent mode is enabled, CowAgent runs as an autonomous agent with the following workflow: -1. **接收消息** — 通过通道接收用户输入 -2. **理解意图** — 分析任务需求和上下文 -3. **规划任务** — 将复杂任务分解为多个步骤 -4. **调用工具** — 选择合适的工具执行每个步骤 -5. **记忆与知识更新** — 将重要信息存入长期记忆,将结构化知识整理至知识库 -6. **返回结果** — 将执行结果发送回用户 +1. **Receive Message** — Receive user input through channels +2. **Understand Intent** — Analyze task requirements and context +3. **Plan Task** — Break complex tasks into multiple steps +4. **Invoke Tools** — Select and execute appropriate tools for each step +5. **Update Memory & Knowledge** — Store important information in long-term memory and organize structured knowledge into the knowledge base +6. **Return Result** — Send execution results back to the user -## 工作空间 +## Workspace Directory Structure -Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词、记忆文件、技能文件等: +The Agent workspace is located at `~/cow` by default and stores system prompts, memory files, and skill files: ``` ~/cow/ @@ -52,36 +52,36 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 └── skill-2/ ``` -秘钥文件单独存储在 `~/.cow` 目录(出于安全考虑): +Secret keys are stored separately in `~/.cow` directory for security: ``` ~/.cow/ └── .env # Secret keys for skills ``` -## 核心配置 +## Core Configuration -在 `config.json` 中配置 Agent 模式的核心参数: +Configure Agent mode parameters in `config.json`: ```json { "agent": true, "agent_workspace": "~/cow", - "agent_max_context_tokens": 40000, - "agent_max_context_turns": 30, - "agent_max_steps": 15, + "agent_max_context_tokens": 50000, + "agent_max_context_turns": 20, + "agent_max_steps": 20, "enable_thinking": false, "cow_lang": "auto" } ``` -| 参数 | 说明 | 默认值 | +| Parameter | Description | Default | | --- | --- | --- | -| `agent` | 是否启用 Agent 模式 | `true` | -| `agent_workspace` | 工作空间路径 | `~/cow` | -| `agent_max_context_tokens` | 最大上下文 token 数 | `50000` | -| `agent_max_context_turns` | 最大上下文记忆轮次 | `20` | -| `agent_max_steps` | 单次任务最大决策步数 | `20` | -| `enable_thinking` | 是否启用深度思考模式 | `false` | -| `knowledge` | 是否启用个人知识库 | `true` | -| `cow_lang` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | +| `agent` | Enable Agent mode | `true` | +| `agent_workspace` | Workspace path | `~/cow` | +| `agent_max_context_tokens` | Max context tokens | `50000` | +| `agent_max_context_turns` | Max context turns | `20` | +| `agent_max_steps` | Max decision steps per task | `20` | +| `enable_thinking` | Enable deep-thinking mode | `false` | +| `knowledge` | Enable personal knowledge base | `true` | +| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | diff --git a/docs/intro/features.mdx b/docs/intro/features.mdx index ae0820a3..3f27012a 100644 --- a/docs/intro/features.mdx +++ b/docs/intro/features.mdx @@ -1,142 +1,139 @@ --- -title: 功能介绍 -description: CowAgent 长期记忆、个人知识库、任务规划、技能系统、CLI 命令、浏览器工具详细说明 +title: Features +description: CowAgent long-term memory, task planning, skills system, CLI commands, and browser tool in detail --- -## 1. 长期记忆 +## 1. Long-term Memory -> 记忆系统让 Agent 能够长期记住重要信息,采用三层记忆流转架构:对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期),形成完整的记忆生命周期。 +The memory system enables the Agent to remember important information over time, using a three-tier memory flow: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term), forming a complete memory lifecycle. -第一次启动 Agent 时,Agent 会主动询问关键信息,并记录至工作空间(默认 `~/cow`)中的智能体设定、用户身份、记忆文件中。 +On first launch, the Agent proactively asks the user for key information and records it in the workspace (default `~/cow`) — including agent settings, user identity, and memory files. -在后续的长期对话中,Agent 会在需要时智能记录或检索记忆,并对自身设定、用户偏好、记忆文件等进行不断更新。每日自动执行 **梦境蒸馏(Deep Dream)**,将分散的天级记忆整合为精炼的长期记忆,同时生成叙事风格的梦境日记。 +In subsequent long-term conversations, the Agent intelligently stores or retrieves memory as needed, continuously updating its own settings, user preferences, and memory files. **Deep Dream** distillation runs daily, consolidating scattered daily memories into refined long-term memory and generating a narrative-style dream diary. -详细说明请参考 [长期记忆](/memory) 和 [梦境蒸馏](/memory/deep-dream)。 +See [Long-term Memory](/memory) and [Deep Dream](/memory/deep-dream) for details. -## 2. 个人知识库 +## 2. Personal Knowledge Base -> 知识库系统让 Agent 能够持续积累和组织结构化知识。与按时间线记录的记忆不同,知识库以主题为维度,将文章、对话洞察、学习材料等整理为互相关联的 Markdown 页面,形成持续增长的知识网络。 +> The knowledge base system enables the Agent to continuously accumulate and organize structured knowledge. Unlike memory which records along a timeline, the knowledge base is organized by topics, transforming articles, conversation insights, and learning materials into interconnected Markdown pages that form a continuously growing knowledge network. -Agent 会在对话中自动将有价值的信息整理为知识页面,维护交叉引用和索引,通过 Web 控制台可浏览文档和查看知识图谱。知识库存储在工作空间的 `~/cow/knowledge/` 目录下。 +The Agent automatically organizes valuable information from conversations into knowledge pages, maintaining cross-references and indexes. The Web console provides document browsing and knowledge graph visualization. Knowledge is stored in `~/cow/knowledge/` within the workspace. -- **自动整理**:Agent 在对话中自主提取和整理结构化知识,维护索引和交叉引用 -- **知识图谱**:基于页面间的交叉引用自动构建知识图谱,Web 控制台提供可视化关系图浏览 -- **对话联动**:Agent 回复中引用的知识文档链接可在 Web 控制台中直接点击跳转查看 -- **CLI 管理**:通过 `/knowledge` 命令查看统计、浏览目录,通过 `/knowledge on|off` 开关功能 +- **Auto-organization**: The Agent autonomously extracts and organizes structured knowledge during conversations, maintaining indexes and cross-references +- **Knowledge graph**: Automatically builds a knowledge graph from cross-references between pages, with interactive graph visualization in the Web console +- **Chat integration**: Knowledge document links referenced in Agent replies can be clicked directly in the Web console for viewing +- **CLI management**: Use `/knowledge` commands to view stats, browse directory, and toggle the feature with `/knowledge on|off` -详细说明请参考 [个人知识库](/knowledge)。 +See [Personal Knowledge Base](/knowledge) for details. -## 3. 任务规划和工具调用 +## 3. Task Planning and Tool Use -工具是 Agent 访问操作系统资源的核心,Agent 会根据任务需求智能选择和调用工具,完成文件读写、命令执行、定时任务等各类操作。内置工具的实现在项目的 `agent/tools/` 目录下。 +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. -**主要工具:** 文件读写编辑、Bash 终端、浏览器操作、文件发送、定时调度、记忆搜索、联网搜索、环境配置等。 +**Key tools:** file read/write/edit, Bash terminal, browser, file send, scheduler, memory search, web search, environment config, and more. -### 3.1 终端和文件访问 +### 3.1 Terminal and File Access -针对操作系统的终端和文件的访问能力,是最基础和核心的工具,其他很多工具或技能都是基于此进行扩展。用户可通过手机端与 Agent 交互,操作个人电脑或服务器上的资源: +Access to the OS terminal and file system is the most fundamental and core capability. Many other tools and skills build on top of this. Users can interact with the Agent from a mobile device to operate resources on their personal computer or server: -### 3.2 编程能力 +### 3.2 Programming Capability -基于编程能力和系统访问能力,Agent 可以实现从信息搜索、图片等素材生成、编码、测试、部署、Nginx 配置修改、发布的 **Vibecoding 全流程**,通过手机端简单的一句命令完成应用的快速 demo: +Combining programming and system access, the Agent can execute the complete **Vibecoding workflow** — from information search, asset generation, coding, testing, deployment, Nginx configuration, to publishing — all triggered by a single command from your phone: -### 3.3 定时任务 +### 3.3 Scheduled Tasks -基于 `scheduler` 工具实现动态定时任务,支持**一次性任务、固定时间间隔、Cron 表达式**三种形式,任务触发可选择**固定消息发送**或 **Agent 动态任务**执行两种模式: +The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time tasks, fixed intervals, and Cron expressions**. Tasks can be triggered as either a **fixed message send** or an **Agent dynamic task** execution: -### 3.4 浏览器操作 +### 3.4 Browser -内置 `browser` 工具,Agent 可控制浏览器访问网页、填写表单、点击元素、截图,支持动态 JS 渲染页面。运行 `cow install-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: - + -### 3.5 环境变量管理 +### 3.5 Environment Variable Management -技能所需的秘钥存储在环境变量文件中,由 `env_config` 工具进行管理,你可以通过对话的方式更新秘钥,工具内置安全保护和脱敏策略: +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: -## 4. 技能系统 +## 4. Skills System -技能系统为 Agent 提供无限的扩展性,每个 Skill 由说明文件、运行脚本(可选)、资源(可选)组成,描述如何完成特定类型的任务。通过 Skill 可以让 Agent 遵循说明完成复杂流程、调用各类工具或对接第三方系统。 +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/):开放的技能广场,汇集官方推荐、社区贡献和第三方技能,支持一键安装。 -- **内置技能:** 在项目的 `skills/` 目录下,包含技能创造器、图像识别、LinkAI 智能体、网页抓取等。内置 Skill 根据依赖条件(API Key、系统命令等)自动判断是否启用。 -- **自定义技能:** 由用户通过对话创建,存放在工作空间中(`~/cow/skills/`),可实现任何复杂的业务流程和第三方系统对接。 +- [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. -安装技能:`/skill install <名称>` 或 `cow skill install <名称>`,支持从 Skill Hub、GitHub、ClawHub、URL 等来源安装。 +Install skills: `/skill install ` or `cow skill install `, supporting Skill Hub, GitHub, ClawHub, URL, and more. -### 4.1 创建技能 +### 4.1 Creating Skills -通过 `skill-creator` 技能可以通过对话的方式快速创建技能。你可以让 Agent 将某个工作流程固化为技能,或者把任意接口文档和示例发送给 Agent,让他直接完成对接: +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: -### 4.2 搜索和图像识别 +### 4.2 Web Search and Image Recognition -- **联网搜索:** 内置 `web_search` 工具,支持多种搜索引擎,配置 `BOCHA_API_KEY` 或 `LINKAI_API_KEY` 后启用。 -- **图像识别:** 内置 `openai-image-vision` 技能,可使用 `gpt-4.1-mini`、`gpt-4.1` 等模型,依赖 `OPENAI_API_KEY`。 +- **Web search:** Built-in `web_search` tool, supports multiple search engines. Configure `BOCHA_API_KEY` or `LINKAI_API_KEY` to enable. +- **Image recognition:** Built-in `openai-image-vision` skill, supports `gpt-4.1-mini`, `gpt-4.1`, and other models. Requires `OPENAI_API_KEY`. -### 4.3 技能广场 +### 4.3 Skill Hub -访问 [skills.cowagent.ai](https://skills.cowagent.ai/) 浏览所有可用技能,或在对话中执行: +Visit [skills.cowagent.ai](https://skills.cowagent.ai/) to browse all available skills, or use commands in conversation: ```text -/skill list --remote # 浏览技能广场 -/skill search <关键词> # 搜索技能 -/skill install <名称> # 一键安装 +/skill list --remote # Browse Skill Hub +/skill search # Search skills +/skill install # Install with one command ``` -同时还支持安装Github、ClawHub、LinkAI等第三方平台上的所有技能,详情查看 [技能安装](/skills/install) +Also supports installing skills from GitHub, ClawHub, LinkAI, and other third-party platforms. See [Install Skills](/skills/install) for details. +## 5. CLI Command System -## 5. CLI 命令系统 +CowAgent provides two command interaction methods, covering service management, skill installation, configuration, and more: -CowAgent 提供两种命令交互方式,覆盖服务管理、技能安装、配置调整等日常运维操作: - -- **终端 CLI:** 在系统终端执行 `cow <命令>`,支持 `start`、`stop`、`restart`、`update`、`status`、`logs`、`skill` 等 -- **对话命令:** 在对话中输入 `/<命令>`,Web 控制台输入 `/` 可弹出指令菜单快速选择 +- **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 # 启动服务 -cow stop # 停止服务 -cow update # 更新并重启 -cow skill install pptx # 安装技能 -cow install-browser # 安装浏览器工具 +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 ``` -详细命令参考 [命令总览](https://docs.cowagent.ai/cli)。 - - +See [Command Overview](https://docs.cowagent.ai/en/cli) for details. diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx index 10e64813..508d1362 100644 --- a/docs/intro/index.mdx +++ b/docs/intro/index.mdx @@ -1,57 +1,60 @@ --- -title: 项目介绍 -description: CowAgent - 基于大模型的超级AI助理 +title: Introduction +description: CowAgent - Open-source super AI assistant and Agent Harness ---
CowAgent
-**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆和知识库并不断成长。 +**CowAgent** is an open-source super AI assistant and Agent Harness. It proactively plans tasks, runs tools and skills, and autonomously grows with memory and knowledge. -CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企业微信应用、微信公众号、网页中使用,7×24小时运行于你的个人电脑或服务器中。 +CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider, run it across Web and major IM platforms, 24/7 on a personal computer or server. - 开源代码仓库,欢迎 Star 和贡献 + Open-source repository — Star and contribute - - 无需安装,立即在线体验 CowAgent + + No setup required — experience CowAgent instantly -## 核心能力 +## Core Capabilities - - 能够理解复杂任务并自主规划执行,持续思考和调用各类工具和技能直到完成目标。 + + Decomposes complex tasks and executes them step by step, looping over tools and skills until the goal is reached. - - 三层记忆流转(上下文→天级记忆→全局记忆),每日梦境蒸馏整理,支持关键词及向量检索。 + + Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval. - - 自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。 + + Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing. - - 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发。 + + A complete skill creation and execution engine. Install from Skill Hub or generate custom skills via natural-language conversation. - - 内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 可自主调用工具完成复杂任务。 + + First-class support for text, images, voice, and files — recognition, generation, and delivery. - - 提供终端 CLI 和对话中的命令,支持进程管理、技能安装、配置修改、上下文查看等常用操作。 + + Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and more — with native MCP integration. - - 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao 等国内外主流模型厂商。 + + Terminal CLI and in-chat commands for process management, skill installation, configuration, and context inspection. - - 支持运行在本地计算机或服务器,可集成到微信、网页、飞书、钉钉、微信公众号、企业微信应用中使用。 + + Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click. + + + A single Agent simultaneously serves Web, WeChat, Feishu, DingTalk, WeCom, QQ, and Official Accounts. -## 快速体验 +## Quick Start -在终端执行以下命令,即可一键安装、配置、启动 CowAgent: +Run one of the commands below to install, configure, and start CowAgent in a single step: @@ -66,19 +69,25 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、 -运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。 +Once started, open `http://localhost:9899` to access the **Web console** — the unified place to chat, configure providers, connect channels, and install skills. - - 查看完整的安装和运行指南 + + Complete installation and run guide - - 了解 CowAgent 的系统架构设计 + + CowAgent system architecture -## 社区 +## Disclaimer -添加小助手微信加入开源项目交流群: +1. This project is licensed under the [MIT License](https://github.com/zhayujie/CowAgent/blob/master/LICENSE) and is intended for technical research and learning. You are responsible for complying with applicable laws and regulations in your jurisdiction; the maintainers assume no liability for any consequences arising from use of this project. +2. **Cost & safety:** Agent mode consumes substantially more tokens than plain chat — pick models that balance quality and cost. The Agent has access to your local operating system; deploy only in trusted environments. +3. CowAgent is a pure open-source project and does not participate in, authorize, or issue any cryptocurrency. + +## Community + +Scan the WeChat QR code to join the open-source community group: diff --git a/docs/knowledge/index.mdx b/docs/knowledge/index.mdx index 4f9aa797..f1610dc9 100644 --- a/docs/knowledge/index.mdx +++ b/docs/knowledge/index.mdx @@ -1,96 +1,93 @@ --- -title: 个人知识库 -description: CowAgent 的个人知识库系统 — 结构化知识沉淀、自动整理与知识图谱 +title: Personal Knowledge Base +description: CowAgent personal knowledge base — structured knowledge accumulation, automatic organization, and knowledge graph --- -个人知识库是 Agent 的长期结构化知识存储,保存在工作空间的 `knowledge/` 目录下。与按时间线组织的记忆不同,知识库以主题为维度,将用户分享的文章、对话中的洞察、学习材料等整理为互相关联的 Markdown 页面,形成可持续增长的知识网络。 +The personal knowledge base is the Agent's long-term structured knowledge store, saved in the `knowledge/` directory within the workspace. Unlike memory, which is organized by timeline, the knowledge base organizes content by topic — articles, conversation insights, and learning materials are structured into interlinked Markdown pages, forming a continuously growing knowledge network. -## 核心概念 +## Core Concepts -### 知识 vs 记忆 +### Knowledge vs Memory -| 维度 | 知识库(knowledge/) | 长期记忆(memory/) | +| Dimension | Knowledge Base (knowledge/) | Long-term Memory (memory/) | | --- | --- | --- | -| 组织方式 | 按主题分类、互相关联 | 按时间线、日期文件 | -| 写入方式 | Agent 主动整理结构化内容 | 上下文裁剪时自动摘要 | -| 内容特点 | 提炼后的结构化知识 | 原始对话摘要 | -| 典型用途 | 学习笔记、技术文档、项目知识 | 对话历史、事件记录 | +| Organization | By topic, interlinked | By timeline, dated files | +| Writing | Agent actively structures content | Auto-summarized on context trimming | +| Content | Refined, structured knowledge | Raw conversation summaries | +| Use cases | Study notes, tech docs, project knowledge | Conversation history, event records | -### 目录结构 +### Directory Structure ``` ~/cow/knowledge/ -├── index.md # 知识索引,所有页面的入口 -├── log.md # 变更日志,记录每次写入 -├── concepts/ # 概念类知识 +├── index.md # Knowledge index, entry point for all pages +├── log.md # Change log, records each write +├── concepts/ # Conceptual knowledge │ └── machine-learning.md -├── entities/ # 实体类知识(人物、组织、工具) +├── entities/ # Entity knowledge (people, orgs, tools) │ └── openai.md -└── sources/ # 来源类知识(文章、论文) +└── sources/ # Source knowledge (articles, papers) └── llm-wiki.md ``` -目录结构是灵活的 — Agent 会根据实际内容自动创建合适的分类目录。用户也可以通过对话的方式自定义目录组织方式。 +The directory structure is flexible — the Agent automatically creates appropriate category directories based on actual content. Users can also customize the organization. +## Automatic Organization + +Knowledge writing is an autonomous Agent behavior, triggered in these scenarios: + +- **User shares an article or document** — The Agent automatically extracts key information and creates a structured knowledge page +- **Conversation produces valuable conclusions** — The Agent organizes insights into knowledge pages and links them to existing knowledge +- **User explicitly requests organization** — Users can guide the Agent to organize and update knowledge through conversation + +Each knowledge page includes cross-reference links to related pages, gradually building a knowledge graph. -## 自动整理 +## Knowledge Retrieval -知识库的写入是 Agent 的自主行为,在以下场景中触发: +The Agent can retrieve knowledge during conversation through: -- **用户分享文章或文档** — Agent 自动提取关键信息,创建结构化知识页面 -- **对话产生有价值的结论** — Agent 将洞察整理为知识页面,并与已有知识建立关联 -- **用户主动要求整理** — 用户可以通过对话指导 Agent 组织和更新知识 +- **Index lookup** — Quickly locate relevant pages via `knowledge/index.md` +- **Semantic search** — Search knowledge content via the `memory_search` tool +- **Direct read** — Read specific knowledge files via the `memory_get` tool + +## Web Console + +The web console provides a dedicated "Knowledge" module with: + +- **Document browsing** — Tree-style directory structure, searchable and collapsible, click to view content +- **Knowledge graph** — Interactive graph visualizing relationships between knowledge pages +- **Chat integration** — Knowledge document links referenced in Agent replies are clickable for direct navigation - -每个知识页面都包含与其他页面的交叉引用链接,逐步构建起一个知识图谱。 - -## 知识检索 - -Agent 在对话中可以通过以下方式检索知识: - -- **索引查阅** — 通过 `knowledge/index.md` 快速定位相关知识页面 -- **语义搜索** — 通过 `memory_search` 工具对知识库内容进行语义检索 -- **直接读取** — 通过 `memory_get` 工具读取特定知识文件 - -## Web 控制台 - -Web 控制台提供了专用的「知识」模块,支持: - -- **文档浏览** — 树状目录结构,可搜索、可折叠,点击查看文档内容 -- **知识图谱** — 可视化展示知识之间的关联关系,节点可直接跳转至文档 -- **对话联动** — Agent 回复中引用的知识文档链接可直接点击跳转查看 - +## CLI Commands -## CLI 命令 +Manage the knowledge base with the `/knowledge` command: -通过 `/knowledge` 命令管理知识库: - -| 命令 | 说明 | +| Command | Description | | --- | --- | -| `/knowledge` | 显示知识库统计信息 | -| `/knowledge list` | 以树状结构显示文件目录 | -| `/knowledge on` | 开启知识库功能 | -| `/knowledge off` | 关闭知识库功能 | +| `/knowledge` | Show knowledge base statistics | +| `/knowledge list` | Display file directory as a tree | +| `/knowledge on` | Enable the knowledge base feature | +| `/knowledge off` | Disable the knowledge base feature | -## 相关配置 +## Configuration -| 参数 | 说明 | 默认值 | +| Parameter | Description | Default | | --- | --- | --- | -| `knowledge` | 是否启用个人知识库功能 | `true` | -| `agent_workspace` | 工作空间路径,知识库存储在此目录的 `knowledge/` 子目录下 | `~/cow` | +| `knowledge` | Whether to enable the personal knowledge base | `true` | +| `agent_workspace` | Workspace path; knowledge is stored under the `knowledge/` subdirectory | `~/cow` | diff --git a/docs/memory/context.mdx b/docs/memory/context.mdx index 3d358f9d..18fbdc8c 100644 --- a/docs/memory/context.mdx +++ b/docs/memory/context.mdx @@ -1,81 +1,81 @@ --- -title: 短期记忆 -description: 对话上下文 — 消息管理、压缩策略和上下文操作 +title: Short-term Memory +description: Conversation context — message management, compression strategies, and context operations --- -对话上下文是 Agent 的短期记忆,包含当前会话中的所有消息(用户输入、Agent 回复、工具调用及结果)。合理管理上下文对于 Agent 的推理质量和成本控制至关重要。 +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: ``` -用户消息 → Agent 思考 → 工具调用 → 工具结果 → ... → Agent 最终回复 +User message → Agent thinking → Tool call → Tool result → ... → Agent final reply ``` -一轮中可能包含多次工具调用(Agent 的决策步数由 `agent_max_steps` 控制),所有工具调用和结果都会保留在上下文中,直到被压缩或裁剪。 +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` | 上下文最大 token 预算 | `50000` | -| `agent_max_context_turns` | 上下文最大对话轮次 | `20` | -| `agent_max_steps` | 单轮对话最大决策步数(工具调用次数) | `15` | +| `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` | -可通过 `config.json` 或对话中的 `/config` 命令修改。 +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. 工具结果截断 +### 1. Tool Result Truncation -在每次决策循环开始前,系统会检查历史轮次中的工具调用结果。超过 **20000 字符** 的工具结果会被截断,仅保留首尾内容和截断说明。当前轮次的工具结果不受影响。 +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. 轮次裁剪 +### 2. Turn Trimming -当对话轮次超过 `agent_max_context_turns` 时: +When conversation turns exceed `agent_max_context_turns`: -- 裁剪 **最早一半** 的完整轮次(保证工具调用链的完整性) -- 被裁剪的消息会通过 LLM 总结后**写入当天的日级记忆文件** -- LLM 摘要完成后,同时将摘要**注入到保留消息的第一条用户消息开头**,帮助模型在后续对话中保持上下文连贯性 -- 摘要注入在后台异步完成,不阻塞当前回复;注入的摘要在下一轮对话时生效 +- 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** +- Once the LLM summary is ready, it is also **injected into the first user message** of the retained context, helping the model maintain conversational continuity +- Summary injection runs asynchronously in the background and takes effect from the next turn onward -### 3. Token 预算裁剪 +### 3. Token Budget Trimming -裁剪轮次后,如果 token 数仍超出预算: +After turn trimming, if tokens still exceed the budget: -- **轮次 < 5 时**:对所有轮次进行**文本压缩** — 每轮只保留第一条用户文本和最后一条 Agent 回复,去掉中间的工具调用链 -- **轮次 ≥ 5 时**:再次裁剪**前半轮次**,被丢弃内容同样写入记忆并注入上下文摘要 +- **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 written to memory and a context summary injected -### 4. 溢出应急处理 +### 4. Overflow Emergency Handling -当模型 API 返回上下文溢出错误时: +When the model API returns a context overflow error: -1. 先将当前所有消息总结写入记忆 -2. 执行激进裁剪(工具结果限制 10K 字符、用户文本限制 10K、最多保留 5 轮) -3. 如果仍然溢出,清空整个对话上下文 +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: -- 恢复最近的 **`max(3, max_context_turns / 6)`** 轮对话 -- 只保留每轮的**用户文本和 Agent 最终回复**,不恢复中间工具调用链 -- 超过 **30 天**的历史会话自动清理 +- 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` | 查看当前上下文统计(消息数、角色分布、总字符数) | -| `/context clear` | 清空当前会话上下文 | -| `/config agent_max_context_tokens 80000` | 调整上下文 token 预算 | -| `/config agent_max_context_turns 30` | 调整上下文轮次上限 | +| `/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 | - 清空上下文后,Agent 会"忘记"之前的对话内容。被裁剪和清空的内容如果已经写入长期记忆,仍可通过记忆检索找回。 + 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/memory/deep-dream.mdx b/docs/memory/deep-dream.mdx index 726ec5e2..d0dd9e2d 100644 --- a/docs/memory/deep-dream.mdx +++ b/docs/memory/deep-dream.mdx @@ -1,94 +1,90 @@ --- -title: 梦境蒸馏 -description: Deep Dream — 从对话到永久记忆的自动蒸馏机制 +title: Deep Dream +description: Deep Dream — automatic distillation from conversations to permanent memory --- -梦境蒸馏(Deep Dream)是 CowAgent 记忆系统的核心整理机制,负责将分散的天级记忆蒸馏为精炼的长期记忆,并生成梦境日记。 +Deep Dream is the core consolidation mechanism of CowAgent's memory system, responsible for distilling scattered daily memories into refined long-term memory and generating dream diaries. -## 记忆流转 +## Memory Flow -CowAgent 的记忆从短期到长期经历三个阶段: +CowAgent's memory progresses through three stages from short-term to long-term: ``` -对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期) +Conversation context (short-term) → Daily memory (mid-term) → MEMORY.md (long-term) ``` -### 1. 对话 → 天级记忆 +### 1. Conversation → Daily Memory -当对话上下文被裁剪或每日定时总结时,系统使用 LLM 将对话内容摘要为关键事件,写入当天的天级记忆文件 `memory/YYYY-MM-DD.md`。 +When conversation context is trimmed or during the daily scheduled summary, the system uses LLM to summarize conversation content into key events, writing them to the daily memory file `memory/YYYY-MM-DD.md`. -触发时机: -- **上下文裁剪** — 轮次或 token 超限时,裁剪的内容被总结写入 -- **每日定时** — 23:55 自动触发全量总结 -- **API 溢出** — 紧急保存当前对话摘要 +Triggers: +- **Context trimming** — Trimmed content is summarized when turn or token limits are exceeded +- **Daily schedule** — Automatically triggered at 23:55 +- **API overflow** — Emergency save of current conversation summary -### 2. 天级记忆 → MEMORY.md(蒸馏) +### 2. Daily Memory → MEMORY.md (Distillation) -每日总结完成后,Deep Dream 自动执行蒸馏: +After the daily summary completes, Deep Dream automatically runs distillation: -1. **读取材料** — 当前 `MEMORY.md` + 当天的天级记忆 -2. **LLM 蒸馏** — 去重、合并、修剪、提取新信息 -3. **覆写 MEMORY.md** — 输出精炼后的长期记忆 -4. **生成梦境日记** — 记录整理过程的发现和洞察 +1. **Read materials** — Current `MEMORY.md` + today's daily memory +2. **LLM distillation** — Deduplicate, merge, prune, extract new information +3. **Overwrite MEMORY.md** — Output the refined long-term memory +4. **Generate dream diary** — Record discoveries and insights from the consolidation -### 3. MEMORY.md 的作用 +### 3. Role of MEMORY.md -`MEMORY.md` 会被注入到每次对话的系统提示词中,让 Agent 始终了解用户的偏好、决策和关键事实。因此它必须保持精炼——Deep Dream 会控制在约 30 条以内。 +`MEMORY.md` is injected into the system prompt for every conversation, keeping the Agent aware of user preferences, decisions, and key facts. Therefore it must stay concise — Deep Dream targets approximately 30 entries or fewer. -## 蒸馏规则 +## Distillation Rules -Deep Dream 遵循以下整理规则: +Deep Dream follows these consolidation rules: -| 操作 | 说明 | +| Operation | Description | | --- | --- | -| **合并提炼** | 含义相近的多条合并为一条高密度表述 | -| **新增萃取** | 从天级记忆中提取偏好、决策、人物、经验等 | -| **冲突更新** | 新信息与旧条目矛盾时,以新信息为准 | -| **清理无效** | 删除临时性记录、空白条目、格式残留 | -| **删除冗余** | 已被更精炼表述涵盖的旧条目删除 | +| **Merge & refine** | Combine similar entries into single high-density statements | +| **Extract new** | Pull preferences, decisions, people, experiences from daily memory | +| **Conflict update** | When new info contradicts old entries, newer info takes precedence | +| **Clean invalid** | Remove temporary records, blank entries, formatting artifacts | +| **Remove redundancy** | Delete old entries already covered by more refined statements | -## 梦境日记 +## Dream Diary -每次蒸馏会生成一篇梦境日记,保存在 `memory/dreams/YYYY-MM-DD.md`,用叙事风格记录: +Each distillation generates a dream diary saved at `memory/dreams/YYYY-MM-DD.md`, written in a narrative style recording: -- 发现了哪些重复或矛盾 -- 从天级记忆中提取了什么新洞察 -- 做了哪些清理和优化 -- 整体感受和观察 +- Duplications or contradictions found +- New insights extracted from daily memory +- Cleanups and optimizations performed +- Overall observations -梦境日记可在 Web 控制台的「记忆管理 → 梦境日记」tab 中查看。 +Dream diaries can be viewed in the Web console under "Memory → Dream Diary" tab. -## 手动触发 +## Manual Trigger -除了每日自动执行外,也可以在对话中手动触发: +In addition to the automatic daily run, you can manually trigger distillation in chat: ```text /memory dream [N] ``` -- `N`:整理近 N 天的记忆(默认 3 天,最大 30 天) -- 蒸馏在后台异步执行,完成后在对话中通知结果 -- Web 端通知包含可点击链接,直接跳转查看 MEMORY.md 和梦境日记 -- 无需 Agent 初始化,首次对话前即可使用 - - - - +- `N`: Consolidate the last N days of memory (default 3, max 30) +- Runs asynchronously in the background; you'll be notified in chat when complete +- Web notifications include clickable links to view MEMORY.md and dream diary +- Works without Agent initialization — can be used before the first conversation - 首次部署后可以手动执行一次 `/memory dream 30`,将历史天级记忆全量蒸馏到 MEMORY.md。 + After first deployment, it's recommended to run `/memory dream 30` once to distill all historical daily memories into MEMORY.md. -## 安全机制 +## Safety Mechanisms -| 机制 | 说明 | +| Mechanism | Description | | --- | --- | -| **无新内容跳过** | 没有天级记忆时不执行蒸馏,避免空覆写 | -| **输入去重** | 定时任务中,输入材料未变化时自动跳过 | -| **异步执行** | 蒸馏在后台线程运行,不阻塞对话 | -| **顺序保证** | 定时任务中,天级 flush 全部完成后才启动蒸馏 | -| **禁止编造** | 提示词明确约束只能基于已有材料整理,不得推测或添加 | +| **Skip on no content** | Distillation skipped when no daily memory exists, avoiding empty overwrites | +| **Input dedup** | In scheduled tasks, automatically skipped when input materials haven't changed | +| **Async execution** | Distillation runs in a background thread, never blocking conversation | +| **Sequential guarantee** | In scheduled tasks, daily flush completes before distillation starts | +| **No fabrication** | Prompt explicitly constrains consolidation to existing materials only | diff --git a/docs/memory/index.mdx b/docs/memory/index.mdx index c6dc0e65..069b6eab 100644 --- a/docs/memory/index.mdx +++ b/docs/memory/index.mdx @@ -1,71 +1,71 @@ --- -title: 长期记忆 -description: CowAgent 的长期记忆系统 — 文件持久化、自动写入与混合检索 +title: Long-term Memory +description: CowAgent long-term memory system — file persistence, automatic writing, and hybrid retrieval --- -长期记忆保存在工作空间文件中,跨会话持久存在。Agent 在对话中通过检索工具按需加载历史记忆,也会在上下文裁剪时自动将对话摘要写入长期记忆。 +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 Architecture +Memory Architecture -## 记忆类型 +## Memory Types -### 核心记忆(MEMORY.md) +### Core Memory (MEMORY.md) -存储在 `~/cow/MEMORY.md` 中,包含用户的长期偏好、重要决策、关键事实等不会随时间淡化的信息。Agent 可通过工具读写此文件来维护长期知识。 +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. -### 日级记忆(memory/YYYY-MM-DD.md) +### Daily Memory (memory/YYYY-MM-DD.md) -存储在 `~/cow/memory/` 目录下,按日期命名(如 `2026-03-08.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. -### 梦境日记(memory/dreams/YYYY-MM-DD.md) +### Dream Diary (memory/dreams/YYYY-MM-DD.md) -Deep Dream(记忆蒸馏)过程的副产物,记录每次整理的发现、去重合并操作和新洞察。存储在 `~/cow/memory/dreams/` 目录下,按日期命名。 +A byproduct of the Deep Dream (memory distillation) process, recording discoveries, deduplication operations, and new insights from each consolidation. Stored in `~/cow/memory/dreams/` directory, named by date. -## 自动写入 +## Automatic Writing -Agent 通过以下机制自动将对话内容持久化为长期记忆: +The Agent automatically persists conversation content to long-term memory through the following mechanisms: -- **上下文裁剪时** — 当对话轮次或 token 超出配置上限时,裁剪最早一半的上下文,使用 LLM 将被裁剪的内容总结为关键信息写入当天记忆文件,并将摘要异步注入到保留的上下文中,帮助模型保持对话连贯性 -- **每日定时总结** — 每天 23:55 自动触发一次全量总结,防止低活跃日无记忆留存(内容无变化时自动跳过) -- [梦境蒸馏(Deep Dream)](/memory/deep-dream) — 每日总结完成后自动执行,将天级记忆蒸馏合并到 MEMORY.md,并生成梦境日记 -- **API 上下文溢出时** — 当模型 API 返回上下文溢出错误时,紧急保存当前对话摘要 +- **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. The summary is also asynchronously injected into the retained context for conversational continuity +- **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) +- [Deep Dream (memory distillation)](/memory/deep-dream) — Runs automatically after the daily summary, distilling daily memories into MEMORY.md and generating a dream diary +- **On API context overflow** — When the model API returns a context overflow error, the current conversation summary is saved as an emergency measure -所有记忆写入均在后台异步执行(LLM 总结 + 文件写入),不阻塞正常对话回复。 +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: -- **关键词检索** — 基于 FTS5 全文索引匹配历史记忆,支持 BM25 排序 -- **向量检索** — 基于 embedding 语义相似度搜索,即使表述不同也能找到相关记忆 +- **Keyword retrieval** — FTS5 full-text index matching with BM25 ranking +- **Vector retrieval** — Embedding-based semantic similarity search, finds relevant memory even with different wording -Agent 会在对话中根据需要自动触发记忆检索,将相关历史信息纳入上下文。检索结果按混合评分排序(默认向量权重 0.7、关键词权重 0.3),日级记忆会随时间衰减(半衰期 30 天),核心记忆不衰减。 +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. -## 相关文件 +## Related Files -工作空间(默认 `~/cow`)中与记忆相关的文件: +Files related to memory in the workspace (default `~/cow`): -| 文件 | 说明 | +| File | Description | | --- | --- | -| `AGENT.md` | Agent 的人格和行为设定 | -| `USER.md` | 用户身份信息和偏好 | -| `RULE.md` | 自定义规则和约束 | -| `MEMORY.md` | 核心记忆(长期) | -| `memory/YYYY-MM-DD.md` | 日级记忆(按需创建) | -| `memory/dreams/YYYY-MM-DD.md` | 梦境日记(Deep Dream 自动生成) | +| `AGENT.md` | Agent personality and behavior settings | +| `USER.md` | User identity information and preferences | +| `RULE.md` | Custom rules and constraints | +| `MEMORY.md` | Core memory (long-term) | +| `memory/YYYY-MM-DD.md` | Daily memory (created on demand) | +| `memory/dreams/YYYY-MM-DD.md` | Dream diary (auto-generated by Deep Dream) | -## Web 控制台 +## Web Console -在 Web 控制台的记忆管理页面中,可浏览记忆文件和梦境日记,支持通过 Tab 切换查看: +The memory management page in the Web console allows browsing memory files and dream diaries, with tab switching support: -## 相关配置 +## Configuration -| 参数 | 说明 | 默认值 | +| Parameter | Description | Default | | --- | --- | --- | -| `agent_workspace` | 工作空间路径,记忆文件存储在此目录下 | `~/cow` | -| `agent_max_context_tokens` | 最大上下文 token 数,超出时裁剪并总结写入记忆 | `50000` | -| `agent_max_context_turns` | 最大上下文轮次,超出时裁剪并总结写入记忆 | `20` | +| `agent_workspace` | Workspace path, memory files stored under this directory | `~/cow` | +| `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/models/claude.mdx b/docs/models/claude.mdx index ee1809d6..bb831eb8 100644 --- a/docs/models/claude.mdx +++ b/docs/models/claude.mdx @@ -1,15 +1,15 @@ --- title: Claude -description: Anthropic Claude 模型配置(文本对话 + 图像理解) +description: Anthropic Claude model configuration (Text Chat + Image Understanding) --- -Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet / Opus 模型均原生支持视觉,无需额外指定 Vision 模型。 +Claude is provided by Anthropic and supports both text chat and image understanding. The mainstream Sonnet / Opus models natively support vision, so no separate Vision model needs to be specified. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,26 +18,26 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 支持 `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) | -| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 | -| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 | +| `model` | Supports `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) | +| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) | +| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy | -### 模型选择 +### Model Selection -| 模型 | 适用场景 | +| Model | Use Case | | --- | --- | -| `claude-opus-4-8` | 默认推荐,最新旗舰,复杂推理与长链路任务效果最佳 | -| `claude-opus-4-7` | 上一代 Opus 旗舰 | -| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 | -| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 | +| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks | +| `claude-opus-4-7` | Previous-generation Opus flagship | +| `claude-sonnet-4-6` | Balanced cost and speed, lower cost | +| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price | -## 图像理解 +## Image Understanding -配置 `claude_api_key` 后 Agent 的 Vision 工具会自动使用 Claude 主模型识别图像,无需额外配置。 +Once `claude_api_key` is configured, the Agent's Vision tool automatically uses the Claude main model to recognize images, with no extra setup required. -如需手动指定 Vision 模型,可在配置文件中显式配置: +To manually specify a Vision model, set it explicitly in the configuration file: ```json { diff --git a/docs/models/coding-plan.mdx b/docs/models/coding-plan.mdx index a8341638..b09715eb 100644 --- a/docs/models/coding-plan.mdx +++ b/docs/models/coding-plan.mdx @@ -1,41 +1,41 @@ --- title: Coding Plan -description: Coding Plan 模式模型配置 +description: Coding Plan model configuration --- -> Coding Plan 是各厂商推出的编程包月套餐,适合高频使用 Agent 的场景。CowAgent 支持通过 OpenAI 兼容方式接入各厂商的 Coding Plan 接口。 +> Coding Plan is a monthly subscription package offered by various providers, ideal for high-frequency Agent usage. CowAgent supports all Coding Plan providers via OpenAI-compatible mode. - Coding Plan 的 API Base 和 API Key 通常与普通按量计费接口不通用,请在各厂商平台单独获取。 + Coding Plan API Base and API Key are usually separate from the standard pay-as-you-go ones. Please obtain them from each provider's platform. -## 通用配置格式 +## General Configuration -所有厂商均可使用 OpenAI 兼容协议接入,可在web控制台快速配置。设置模型厂商为**OpenAI**,选择自定义模型并填入模型编码,最后填写对应厂商的API Base 和 API Key: +All providers can be accessed via the OpenAI-compatible protocol, and can be quickly configured through the web console. Set the model provider to **OpenAI**, select a custom model and enter the model code, then fill in the corresponding provider's API Base and API Key: -也可通过 `config.json` 配置文件直接修改: +You can also configure directly in `config.json`: ```json { "bot_type": "openai", - "model": "模型名称", - "open_ai_api_base": "厂商 Coding Plan API Base", + "model": "MODEL_NAME", + "open_ai_api_base": "PROVIDER_CODING_PLAN_API_BASE", "open_ai_api_key": "YOUR_API_KEY" } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `bot_type` | 固定为 `openai`(OpenAI 兼容方式) | -| `model` | 各厂商支持的模型名称 | -| `open_ai_api_base` | 各厂商 Coding Plan 专用 API Base | -| `open_ai_api_key` | 各厂商 Coding Plan 专用 API Key | +| `bot_type` | Must be `openai` (OpenAI-compatible mode) | +| `model` | Model name supported by the provider | +| `open_ai_api_base` | Provider's Coding Plan API Base URL | +| `open_ai_api_key` | Provider's Coding Plan API Key | --- -## 阿里云 +## Alibaba Cloud ```json { @@ -46,13 +46,13 @@ description: Coding Plan 模式模型配置 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | `qwen3.5-plus`、`qwen3-max-2026-01-23`、`qwen3-coder-next`、`qwen3-coder-plus`、`glm-5`、`glm-4.7`、`kimi-k2.5`、`MiniMax-M2.5` | +| `model` | `qwen3.5-plus`, `qwen3-max-2026-01-23`, `qwen3-coder-next`, `qwen3-coder-plus`, `glm-5`, `glm-4.7`, `kimi-k2.5`, `MiniMax-M2.5` | | `open_ai_api_base` | `https://coding.dashscope.aliyuncs.com/v1` | -| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | +| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | -官方文档:[快速开始](https://help.aliyun.com/zh/model-studio/coding-plan-quickstart?spm=a2c4g.11186623.help-menu-2400256.d_0_2_1.70115203zi5Igc)、[模型列表](https://help.aliyun.com/zh/model-studio/coding-plan) +Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-quickstart?spm=a2c4g.11186623.help-menu-2400256.d_0_2_1.70115203zi5Igc), [Model List](https://help.aliyun.com/zh/model-studio/coding-plan) --- @@ -67,18 +67,17 @@ description: Coding Plan 模式模型配置 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` | -| `open_ai_api_base` | 国内:`https://api.minimaxi.com/v1`;海外:`https://api.minimax.io/v1` | -| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | +| `model` | `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2` | +| `open_ai_api_base` | China: `https://api.minimaxi.com/v1`; Global: `https://api.minimax.io/v1` | +| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | -官方文档:[国内 Key 获取](https://platform.minimaxi.com/docs/coding-plan/quickstart)、[模型列表](https://platform.minimaxi.com/docs/guides/pricing-coding-plan)、[国际 Key 获取](https://platform.minimax.io/docs/coding-plan/quickstart) +Reference: [China Key](https://platform.minimaxi.com/docs/coding-plan/quickstart), [Model List](https://platform.minimaxi.com/docs/guides/pricing-coding-plan), [Global Key](https://platform.minimax.io/docs/coding-plan/quickstart) --- - -## 智谱 GLM +## GLM ```json { @@ -89,34 +88,13 @@ description: Coding Plan 模式模型配置 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | `glm-5`、`glm-4.7`、`glm-4.6`、`glm-4.5`、`glm-4.5-air` | -| `open_ai_api_base` | 中国区:`https://open.bigmodel.cn/api/coding/paas/v4`;全球区:`https://api.z.ai/api/coding/paas/v4` | -| `open_ai_api_key` | API Key 与普通接口通用 | +| `model` | `glm-5`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | +| `open_ai_api_base` | China: `https://open.bigmodel.cn/api/coding/paas/v4`; Global: `https://api.z.ai/api/coding/paas/v4` | +| `open_ai_api_key` | Shared with standard API | -官方文档:[国内版快速开始](https://docs.bigmodel.cn/cn/coding-plan/quick-start)、[国际版快速开始](https://docs.z.ai/devpack/quick-start) - ---- - -## 火山引擎 - -```json -{ - "bot_type": "openai", - "model": "Doubao-Seed-2.0-Code", - "open_ai_api_base": "https://ark.cn-beijing.volces.com/api/coding/v3", - "open_ai_api_key": "YOUR_API_KEY" -} -``` - -| 参数 | 说明 | -| --- | --- | -| `model` | `Doubao-Seed-2.0-Code`、`Doubao-Seed-2.0-pro`、`Doubao-Seed-2.0-lite`、`Doubao-Seed-Code`、`MiniMax-M2.5`、`Kimi-K2.5`、`GLM-4.7`、`DeepSeek-V3.2` | -| `open_ai_api_base` | `https://ark.cn-beijing.volces.com/api/coding/v3` | -| `open_ai_api_key` | API Key 与普通接口通用 | - -官方文档:[快速开始](https://www.volcengine.com/docs/82379/1928261?lang=zh) +Reference: [China Quick Start](https://docs.bigmodel.cn/cn/coding-plan/quick-start), [Global Quick Start](https://docs.z.ai/devpack/quick-start) --- @@ -131,10 +109,31 @@ description: Coding Plan 模式模型配置 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 填写 `kimi-for-coding` 会自动更新模型,或指定模型例如 `kimi-k2.6` | +| `model` | Use `kimi-for-coding` for auto-updating model, or specify a model such as `kimi-k2.6` | | `moonshot_base_url` | `https://api.kimi.com/coding/v1` | -| `moonshot_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | +| `moonshot_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | -官方文档:[Key 获取](https://www.kimi.com/code/docs/) +Reference: [Key & Docs](https://www.kimi.com/code/docs/) + +--- + +## Volcengine + +```json +{ + "bot_type": "openai", + "model": "Doubao-Seed-2.0-Code", + "open_ai_api_base": "https://ark.cn-beijing.volces.com/api/coding/v3", + "open_ai_api_key": "YOUR_API_KEY" +} +``` + +| Parameter | Description | +| --- | --- | +| `model` | `Doubao-Seed-2.0-Code`, `Doubao-Seed-2.0-pro`, `Doubao-Seed-2.0-lite`, `Doubao-Seed-Code`, `MiniMax-M2.5`, `Kimi-K2.5`, `GLM-4.7`, `DeepSeek-V3.2` | +| `open_ai_api_base` | `https://ark.cn-beijing.volces.com/api/coding/v3` | +| `open_ai_api_key` | Shared with standard API | + +Reference: [Quick Start](https://www.volcengine.com/docs/82379/1928261?lang=zh) diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index 2673a8de..45a7d2e1 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -1,21 +1,21 @@ --- -title: 自定义 -description: 自定义厂商配置,适用于第三方 API 代理和本地模型 +title: Custom +description: Custom vendor configuration for third-party API proxies and local models --- -适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如: +For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: -- **第三方 API 代理**:使用统一的 API Base 调用多种模型 -- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型 -- **私有化部署**:企业内部部署的模型服务 +- **Third-party API proxies**: call multiple models through a unified API base +- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI +- **Private deployments**: model services deployed inside an enterprise - 与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。 + Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. -## 文本对话 +## Text Chat -### 第三方 API 代理 +### Third-party API proxy ```json { @@ -26,16 +26,16 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `bot_type` | 必须设为 `custom` | -| `model` | 模型名称,填写代理服务支持的任意模型名 | -| `custom_api_key` | API 密钥,由代理服务提供 | -| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 | +| `bot_type` | Must be set to `custom` | +| `model` | Model name; any model name supported by the proxy service | +| `custom_api_key` | API key provided by the proxy service | +| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | -### 本地模型 +### Local models -本地模型通常不需要 API Key,只需填写 API Base: +Local models usually do not require an API key — only the API base needs to be filled in: ```json { @@ -45,17 +45,17 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -常见的本地部署工具及默认地址: +Common local deployment tools and their default endpoints: -| 工具 | 默认 API Base | +| Tool | Default API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### 切换模型 +### Switching Models -自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址: +Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: ``` /config model qwen3.5:27b diff --git a/docs/models/deepseek.mdx b/docs/models/deepseek.mdx index 57b96d55..6de8d09b 100644 --- a/docs/models/deepseek.mdx +++ b/docs/models/deepseek.mdx @@ -1,11 +1,11 @@ --- title: DeepSeek -description: DeepSeek 模型配置(文本对话 + 思考模式) +description: DeepSeek model configuration (Text Chat + Thinking Mode) --- -DeepSeek 是当前 Agent 模式默认推荐的厂商之一,主打高性价比的文本对话和任务规划能力。 +DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning. -## 文本对话 +## Text Chat ```json { @@ -14,26 +14,26 @@ DeepSeek 是当前 Agent 模式默认推荐的厂商之一,主打高性价比 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 支持 `deepseek-v4-flash`(默认)、`deepseek-v4-pro` | -| `deepseek_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 | -| `deepseek_api_base` | 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址 | +| `model` | Supports `deepseek-v4-flash` (Default), `deepseek-v4-pro` | +| `deepseek_api_key` | Create one on the [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 | -### 模型选择 +### Model Selection -| 模型 | 适用场景 | +| Model | Use Case | | --- | --- | -| `deepseek-v4-flash` | 默认推荐,速度快、成本低 | -| `deepseek-v4-pro` | 更智能,复杂任务效果更强 | +| `deepseek-v4-flash` | Default recommended; fast and low cost | +| `deepseek-v4-pro` | Smarter; better for complex tasks | -## 思考模式 +## Thinking Mode -V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的「思考模式」:模型在输出最终回答前,先输出一段思维链(`reasoning_content`),从而提升答案质量。 +The V4 series (`deepseek-v4-flash` / `deepseek-v4-pro`) supports an explicit "thinking mode": before producing the final answer, the model emits a chain of thought (`reasoning_content`) to improve answer quality. -### 开关 +### Toggle -通过全局配置 `enable_thinking` 控制,也可在 web控制台 - 配置页面中进行切换: +Controlled by the global `enable_thinking` config, and can also be toggled from the Web Console's configuration page: ```json { @@ -41,12 +41,12 @@ V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的「思考 } ``` -- `true`:所有渠道下模型都会先思考再作答。Web 控制台会展示思考过程,IM 渠道(微信 / 企微 / 钉钉 / 飞书)虽不展示但同样获得更好答案。 -- `false`:关闭思考,响应更快,首字延迟更低。 +- `true`: the model thinks before answering across all channels. The Web Console displays the thinking process; IM channels (WeChat / WeCom / DingTalk / Feishu) do not show it but still get better answers. +- `false`: thinking is disabled, responses are faster, and time-to-first-token is lower. -### 推理强度 +### Reasoning Effort -思考模式下可通过 `reasoning_effort` 控制推理强度: +Under thinking mode, `reasoning_effort` controls reasoning intensity: ```json { @@ -55,18 +55,18 @@ V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的「思考 } ``` -| 取值 | 适用场景 | +| Value | Use Case | | --- | --- | -| `high`(默认) | 日常 Agent 任务,思考与速度的平衡 | -| `max` | 复杂编码、长链路规划、严格约束的任务,推理更深但耗时与输出 token 更多 | +| `high` (Default) | Day-to-day Agent tasks; balanced reasoning and speed | +| `max` | Complex coding, long-horizon planning, strictly constrained tasks; deeper reasoning but more time and output tokens | -`reasoning_effort` 仅在 `enable_thinking` 为 `true` 时生效;模型不支持思考模式时该字段自动忽略。 +`reasoning_effort` only takes effect when `enable_thinking` is `true`; it is ignored automatically when the model does not support thinking mode. -### 行为说明 +### Behavior Notes -- **采样参数**:思考模式下 `temperature`、`top_p`、`presence_penalty`、`frequency_penalty` 会被服务端忽略(不会报错),CowAgent 会自动跳过传入。 -- **多轮工具调用**:当历史中包含工具调用时,DeepSeek 要求所有 assistant 消息必须回传 `reasoning_content`。CowAgent 会自动处理回传逻辑,跨轮次切换思考开关也不会出错。 +- **Sampling parameters**: in thinking mode, `temperature`, `top_p`, `presence_penalty`, and `frequency_penalty` are ignored by the server (without errors). CowAgent automatically skips them. +- **Multi-turn tool calls**: when the history contains tool calls, DeepSeek requires every assistant message to include `reasoning_content`. CowAgent handles this automatically, so toggling thinking mode across turns will not cause errors. - 默认使用 `deepseek-v4-flash`;复杂任务可使用 `deepseek-v4-pro`;需要深度推理可开启 `enable_thinking`。 + `deepseek-v4-flash` is used by default; switch to `deepseek-v4-pro` for complex tasks; enable `enable_thinking` when deep reasoning is needed. diff --git a/docs/models/doubao.mdx b/docs/models/doubao.mdx index cfdc5670..818275e5 100644 --- a/docs/models/doubao.mdx +++ b/docs/models/doubao.mdx @@ -1,15 +1,15 @@ --- -title: 豆包 Doubao -description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图像生成 / 向量) +title: Doubao +description: Doubao (Volcengine Ark) model configuration (Text / Image Understanding / Image Generation / Embedding) --- -豆包(火山方舟)支持文本对话、图像理解、图像生成(Seedream)和向量能力,一份 `ark_api_key` 即可启用全部能力。 +Doubao (Volcengine Ark) supports text chat, image understanding, image generation (Seedream), and embedding. A single `ark_api_key` enables all capabilities. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,17 +18,17 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 可填 `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` 等 | -| `ark_api_key` | 在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 | -| `ark_base_url` | 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` | +| `model` | Can be `doubao-seed-2-0-pro-260215`, `doubao-seed-2-0-code-preview-260215`, `doubao-seed-2-0-lite-260215`, etc. | +| `ark_api_key` | Create one in the [Volcengine Ark Console](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) | +| `ark_base_url` | Optional, defaults to `https://ark.cn-beijing.volces.com/api/v3` | -## 图像理解 +## Image Understanding -配置 `ark_api_key` 后 Agent 的 Vision 工具会自动使用 `doubao-seed-2-0-pro-260215` 识别图像,无需额外配置。 +Once `ark_api_key` is configured, the Agent's Vision tool automatically uses `doubao-seed-2-0-pro-260215` to recognize images, with no extra setup required. -如需手动指定 Vision 模型: +To manually specify a Vision model: ```json { @@ -40,7 +40,7 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图 } ``` -## 图像生成 +## Image Generation ```json { @@ -52,9 +52,9 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图 } ``` -可选模型:`seedream-5.0-lite`、`seedream-4.5`。 +Available models: `seedream-5.0-lite`, `seedream-4.5`. -## 向量 +## Embedding ```json { @@ -63,4 +63,4 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图 } ``` -默认模型 `doubao-embedding-vision-251215`(多模态 embedding),可在配置文件中通过 `embedding_dimensions` 指定 1024 或 2048 维。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 +The default model is `doubao-embedding-vision-251215` (multimodal embedding); the dimension (1024 or 2048) can be set via `embedding_dimensions` in the configuration file. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/models/gemini.mdx b/docs/models/gemini.mdx index f1c8991a..b2d9520b 100644 --- a/docs/models/gemini.mdx +++ b/docs/models/gemini.mdx @@ -1,15 +1,15 @@ --- title: Gemini -description: Google Gemini 模型配置(文本对话 + 图像理解 + 图像生成) +description: Google Gemini model configuration (Text Chat + Image Understanding + Image Generation) --- -Google Gemini 支持文本对话、图像理解和图像生成(Nano Banana 系列),一个 `gemini_api_key` 即可启用全部能力。 +Google Gemini supports text chat, image understanding, and image generation (Nano Banana series). A single `gemini_api_key` enables all capabilities. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,17 +18,17 @@ Google Gemini 支持文本对话、图像理解和图像生成(Nano Banana 系 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 推荐 `gemini-3.5-flash`,亦支持 `gemini-3.1-pro-preview`、`gemini-3.1-flash-lite-preview`、`gemini-3-flash-preview`、`gemini-3-pro-preview` 等,参考 [官方文档](https://ai.google.dev/gemini-api/docs/models) | -| `gemini_api_key` | 在 [Google AI Studio](https://aistudio.google.com/app/apikey) 创建 | -| `gemini_api_base` | 可选,默认为 `https://generativelanguage.googleapis.com`,可改为第三方代理 | +| `model` | Recommended: `gemini-3.5-flash`; also supports `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-3-pro-preview`, etc. See [official docs](https://ai.google.dev/gemini-api/docs/models) | +| `gemini_api_key` | Create one in [Google AI Studio](https://aistudio.google.com/app/apikey) | +| `gemini_api_base` | Optional, defaults to `https://generativelanguage.googleapis.com`. Can be changed to a third-party proxy | -## 图像理解 +## Image Understanding -Gemini 全系列模型均原生支持视觉,配置 `gemini_api_key` 后 Agent 的 Vision 工具会自动使用主模型识别图像,无需额外配置。 +All Gemini models natively support vision. Once `gemini_api_key` is configured, the Agent's Vision tool automatically uses the main model to recognize images, with no extra setup required. -如需手动指定 Vision 模型: +To manually specify a Vision model: ```json { @@ -40,7 +40,7 @@ Gemini 全系列模型均原生支持视觉,配置 `gemini_api_key` 后 Agent } ``` -## 图像生成 +## Image Generation ```json { @@ -52,7 +52,7 @@ Gemini 全系列模型均原生支持视觉,配置 `gemini_api_key` 后 Agent } ``` -| 模型 ID | 别名 | +| Model ID | Alias | | --- | --- | | `gemini-3.1-flash-image-preview` | Nano Banana 2 | | `gemini-3-pro-image-preview` | Nano Banana Pro | diff --git a/docs/models/glm.mdx b/docs/models/glm.mdx index ad5f8fd3..473a805c 100644 --- a/docs/models/glm.mdx +++ b/docs/models/glm.mdx @@ -1,15 +1,15 @@ --- -title: 智谱 GLM -description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 / 向量) +title: GLM +description: Zhipu AI GLM model configuration (Text / Image Understanding / Speech-to-Text / Embedding) --- -智谱 AI 支持文本对话、图像理解、语音识别(ASR)和向量(Embedding),一份 `zhipu_ai_api_key` 即可启用全部能力。 +Zhipu AI supports text chat, image understanding, speech-to-text (ASR), and embedding. A single `zhipu_ai_api_key` enables all capabilities. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,17 +18,17 @@ description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 / } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 可填 `glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` 等,参考 [模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4) | -| `zhipu_ai_api_key` | 在 [智谱 AI 控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建 | -| `zhipu_ai_api_base` | 可选,默认为 `https://open.bigmodel.cn/api/paas/v4` | +| `model` | Can be `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4-plus`, `glm-4-flash`, `glm-4-air`, etc. See [model codes](https://bigmodel.cn/dev/api/normal-model/glm-4) | +| `zhipu_ai_api_key` | Create one in the [Zhipu AI Console](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| `zhipu_ai_api_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` | -## 图像理解 +## Image Understanding -智谱 chat 系列模型(`glm-5.1`、`glm-5-turbo` 等)不支持视觉,视觉调用统一路由到 `glm-5v-turbo`。配置 `zhipu_ai_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。 +Zhipu's chat models (`glm-5.1`, `glm-5-turbo`, etc.) do not support vision; vision calls are uniformly routed to `glm-5v-turbo`. Once `zhipu_ai_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file. -## 语音识别 +## Speech-to-Text (ASR) ```json { @@ -37,14 +37,14 @@ description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 / } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `voice_to_text` | 设为 `zhipu` 启用智谱 ASR | -| `voice_to_text_model` | 可选,默认 `glm-asr-2512` | +| `voice_to_text` | Set to `zhipu` to enable Zhipu ASR | +| `voice_to_text_model` | Optional, defaults to `glm-asr-2512` | -凭证自动复用 `zhipu_ai_api_key`。语音文件建议小于 25MB,超大文件可能被服务端拒绝。 +Credentials are automatically reused from `zhipu_ai_api_key`. Audio files should be smaller than 25MB; oversized files may be rejected by the server. -## 向量 +## Embedding ```json { @@ -53,4 +53,4 @@ description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 / } ``` -可选模型:`embedding-3`、`embedding-2`。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 +Available models: `embedding-3`, `embedding-2`. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 02402b6a..c3744234 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -1,40 +1,38 @@ --- -title: 模型概览 -description: CowAgent 支持的模型厂商及能力矩阵 +title: Models Overview +description: Model vendors supported by CowAgent and their capability matrix --- -CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在项目的 `models/` 目录下。除文本对话外,部分厂商还提供视觉理解、图像生成、语音识别、语音合成、向量等能力,可在 Agent 流程中按需调用。 +CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow. +## Capability Matrix -## 模型能力总览 +A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power. -各厂商提供的能力一览。「文本」指主对话模型,其余列表示该厂商可承担对应 Agent 能力。 - -| 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 | +| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | | [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | -| [OpenAI](/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [智谱 GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [通义千问](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [豆包 Doubao](/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | +| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [GLM](/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | +| [Qwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | -| [百度千帆](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | -| [小米 MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | -| [LinkAI](/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [自定义](/models/custom) |本地模型 / 三方代理 | ✅ | | | | | | +| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | +| [LinkAI](/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | | - Web 控制台中各项能力(视觉 / 图像 / 语音识别 / 语音合成 / 向量 / 网络搜索)均可独立配置厂商与模型,互相之间不强制绑定。 + Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them. +## How to Configure -## 配置方式 +**Option 1 (recommended):** Manage models and capabilities online via the [Web console](/channels/web), with no need to edit the configuration file: -**方式一(推荐):** 通过 [Web 控制台](/channels/web) 在线管理模型与各项能力,无需手动编辑配置文件: + - - -**方式二:** 手动编辑 `config.json`,根据所选模型填写对应的模型名称和 API Key。每个模型也支持 OpenAI 兼容方式接入,将 `bot_type` 设为 `openai`,配置 `open_ai_api_base` 和 `open_ai_api_key` 即可。 +**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`. diff --git a/docs/models/kimi.mdx b/docs/models/kimi.mdx index beb5beaf..3292a976 100644 --- a/docs/models/kimi.mdx +++ b/docs/models/kimi.mdx @@ -1,15 +1,15 @@ --- title: Kimi -description: Kimi(Moonshot)模型配置(文本对话 + 图像理解) +description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding) --- -Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。 +Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,17 +18,17 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 可填 `kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` | -| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 | -| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` | +| `model` | Can be `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` | +| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) | +| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` | -## 图像理解 +## Image Understanding -配置 `moonshot_api_key` 后 Agent 的 Vision 工具会自动使用 `kimi-k2.6` 识别图像,无需额外配置。 +Once `moonshot_api_key` is configured, the Agent's Vision tool automatically uses `kimi-k2.6` to recognize images, with no extra setup required. -如需手动指定 Vision 模型: +To manually specify a Vision model: ```json { diff --git a/docs/models/linkai.mdx b/docs/models/linkai.mdx index 68647ebc..f60c2160 100644 --- a/docs/models/linkai.mdx +++ b/docs/models/linkai.mdx @@ -1,15 +1,15 @@ --- title: LinkAI -description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音与向量能力 +description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform --- -通过一份 `linkai_api_key` 即可访问 OpenAI、Claude、Gemini、DeepSeek、MiniMax、Qwen、Kimi、豆包 等主流厂商的全部能力。 +A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,17 +18,17 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `use_linkai` | 设为 `true` 启用 | -| `linkai_api_key` | 在 [控制台](https://link-ai.tech/console/interface) 创建 | -| `model` | 可填写 [模型列表](https://link-ai.tech/console/models) 中任意编码 | +| `use_linkai` | Set to `true` to enable | +| `linkai_api_key` | Create one in the [Console](https://link-ai.tech/console/interface) | +| `model` | Can be any code from the [model list](https://link-ai.tech/console/models) | -前往 [模型服务](https://link-ai.tech/console/models) 了解更多。 +See [Model Service](https://link-ai.tech/console/models) for more. -## 图像理解 +## Image Understanding -配置完成后 Agent 的 Vision 工具会自动调用网关上的多模态模型,无需额外配置。如需手动指定 Vision 模型: +Once configured, the Agent's Vision tool automatically calls multimodal models via the gateway, with no extra setup required. To manually specify a Vision model: ```json { @@ -40,9 +40,9 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音 } ``` -可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.6-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。 +Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.6-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc. -## 图像生成 +## Image Generation ```json { @@ -54,14 +54,14 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音 } ``` -| 模型 ID | 别名 | +| Model ID | Alias | | --- | --- | | `gpt-image-2` | OpenAI | | `gemini-3.1-flash-image-preview` | Nano Banana 2 | | `gemini-3-pro-image-preview` | Nano Banana Pro | -| `seedream-5.0-lite` | 字节豆包 Seedream | +| `seedream-5.0-lite` | ByteDance Doubao Seedream | -## 语音识别 +## Speech-to-Text (ASR) ```json { @@ -69,11 +69,11 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音 } ``` -ASR 固定使用 Whisper,凭证自动复用 `linkai_api_key`。 +ASR uses Whisper by default; credentials are automatically reused from `linkai_api_key`. -## 语音合成 +## Text-to-Speech (TTS) -语音合成网关下支持多个底层 TTS 引擎,按 `text_to_voice_model` 选择引擎,音色随引擎切换。 +The TTS gateway supports multiple underlying engines. The engine is selected by `text_to_voice_model`, and the available voices change with the engine. ```json { @@ -83,15 +83,15 @@ ASR 固定使用 Whisper,凭证自动复用 `linkai_api_key`。 } ``` -| `text_to_voice_model` | 引擎说明 | +| `text_to_voice_model` | Engine | | --- | --- | -| `tts-1` | OpenAI · 多语种通用(音色 `alloy` / `nova` / `echo` 等) | -| `doubao` | 字节豆包 · 中文音色丰富 | -| `baidu` | 百度 · 中文主播音色 | +| `tts-1` | OpenAI · Multi-language (voices like `alloy` / `nova` / `echo`, etc.) | +| `doubao` | ByteDance Doubao · Rich Chinese voices | +| `baidu` | Baidu · Chinese broadcaster voices | -不同引擎对应的音色不同,建议在 Web 控制台「模型管理 → 语音合成」中可视化选择。 +Voices differ by engine; we recommend selecting them visually in the Web Console under "Model Management → Text-to-Speech". -## 向量 +## Embedding ```json { @@ -100,4 +100,4 @@ ASR 固定使用 Whisper,凭证自动复用 `linkai_api_key`。 } ``` -默认模型 `text-embedding-3-small`(OpenAI 兼容)。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 +The default model is `text-embedding-3-small` (OpenAI-compatible). After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/models/mimo.mdx b/docs/models/mimo.mdx index ea445df9..6f808b8e 100644 --- a/docs/models/mimo.mdx +++ b/docs/models/mimo.mdx @@ -1,15 +1,15 @@ --- -title: 小米 MiMo -description: 小米 MiMo 模型配置(文本对话 + 图像理解 + 语音合成) +title: MiMo +description: Xiaomi MiMo model configuration (Text Chat + Image Understanding + Text-to-Speech) --- -小米 MiMo 是原生全模态大模型,单 `mimo_api_key` 即可同时启用文本对话、图像理解与语音合成。 +Xiaomi MiMo is a native omni-modal large model. A single `mimo_api_key` enables text chat, image understanding, and text-to-speech all at once. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console — no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -19,24 +19,24 @@ description: 小米 MiMo 模型配置(文本对话 + 图像理解 + 语音合 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 默认推荐 `mimo-v2.5-pro`,也可使用 `mimo-v2.5` | -| `mimo_api_key` | 在 [MiMo 开放平台](https://platform.xiaomimimo.com/console/api-keys) 创建 | -| `mimo_api_base` | 可选,默认为 `https://api.xiaomimimo.com/v1` | +| `model` | Default recommendation: `mimo-v2.5-pro`; `mimo-v2.5` is also supported | +| `mimo_api_key` | Create one in the [MiMo Open Platform](https://platform.xiaomimimo.com/console/api-keys) | +| `mimo_api_base` | Optional, defaults to `https://api.xiaomimimo.com/v1` | -### 模型选择 +### Model Selection -| 模型 | 适用场景 | +| Model | Use Case | | --- | --- | -| `mimo-v2.5-pro` | 旗舰,原生全模态 + Agent 能力,最高 100 万 tokens 上下文 | -| `mimo-v2.5` | 综合版,原生全模态(文本 / 图像 / 视频 / 音频) | +| `mimo-v2.5-pro` | Flagship: native omni-modal + Agent capability, up to 1M tokens context | +| `mimo-v2.5` | General-purpose, native omni-modal (text / image / video / audio) | -## 思考模式 +## Thinking Mode -MiMo V2.5 系列默认开启「思考模式」:模型在输出最终回答前会先输出 `reasoning_content`(思维链),提升复杂任务表现。 +The MiMo V2.5 series enables "thinking mode" by default: the model emits `reasoning_content` (chain-of-thought) before the final answer, improving performance on complex tasks. -通过全局配置 `enable_thinking` 控制是否展示(也可在 Web 控制台 - 配置页面切换): +Use the global `enable_thinking` flag to toggle visibility (also switchable from the Web Console settings): ```json { @@ -44,14 +44,14 @@ MiMo V2.5 系列默认开启「思考模式」:模型在输出最终回答前 } ``` -## 图像理解 +## Image Understanding -配置 `mimo_api_key` 后,Agent 的 Vision 工具可以自动使用 MiMo 视觉模型: +Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models: -- 当主模型本身是多模态时(`mimo-v2.5-pro` / `mimo-v2.5`),直接由主模型识别图像,无需额外配置 -- 当主模型是其他厂商时,Vision 工具会根据顺序自动 fallback 到 `mimo-v2.5-pro` +- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup. +- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order. -如需手动指定 Vision 模型,可在配置文件中显式配置: +To force a specific Vision model, set it explicitly in the configuration: ```json { @@ -64,7 +64,7 @@ MiMo V2.5 系列默认开启「思考模式」:模型在输出最终回答前 } ``` -## 语音合成 +## Text-to-Speech (TTS) ```json { @@ -74,62 +74,63 @@ MiMo V2.5 系列默认开启「思考模式」:模型在输出最终回答前 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `text_to_voice_model` | 当前仅支持 `mimo-v2.5-tts`(预置音色 + 唱歌模式) | -| `tts_voice_id` | 预置音色名(中文音色直接使用中文名作为 ID) | +| `text_to_voice_model` | Currently only `mimo-v2.5-tts` (preset voices + singing mode) | +| `tts_voice_id` | Preset voice name (Chinese voice IDs use the Chinese name directly) | -### 预置音色 +### Preset Voices -| 音色 ID | 说明 | +| Voice ID | Description | | --- | --- | -| `冰糖` | 中文 · 女声(默认) | -| `茉莉` | 中文 · 女声 | -| `苏打` | 中文 · 男声 | -| `白桦` | 中文 · 男声 | -| `Mia` | 英文 · 女声 | -| `Chloe` | 英文 · 女声 | -| `Milo` | 英文 · 男声 | -| `Dean` | 英文 · 男声 | +| `Mia` | English · Female | +| `Chloe` | English · Female | +| `Milo` | English · Male | +| `Dean` | English · Male | +| `冰糖` | Chinese · Female (default) | +| `茉莉` | Chinese · Female | +| `苏打` | Chinese · Male | +| `白桦` | Chinese · Male | -也可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 -### 风格控制 +You can also pick a voice visually from the Web Console under "Model Management → Text-to-Speech". -MiMo TTS 支持在合成文本中嵌入 **音频标签** 来控制情绪、语调、方言、角色甚至唱歌。标签需出现在 **最终被合成为语音的文本(即 Agent 回复内容)** 中,整体风格标签写在开头: +### Style Control + +MiMo TTS supports embedding **audio tags** in the synthesis text to control emotion, tone, dialect, persona, and even singing. Tags must appear in the **text that will be synthesized to speech (i.e. the Agent's reply)**, with the overall style tag placed at the very beginning: ``` -(风格)待合成内容 +(style)content-to-synthesize ``` -支持半角 `()`、全角 `()` 或 `[]` 三种括号。常见风格示例: +Half-width `()`, full-width `()`, and `[]` brackets are all accepted. Both Chinese and English style descriptors work — pick whichever language expresses the timbre most precisely. Common examples: -| 类型 | 示例标签 | +| Category | Example tags | | --- | --- | -| 基础情绪 | `开心` `悲伤` `愤怒` `恐惧` `惊讶` `兴奋` `委屈` `平静` `冷漠` | -| 复合情绪 | `怅然` `欣慰` `无奈` `愧疚` `释然` `忐忑` `动情` | -| 整体语调 | `温柔` `高冷` `活泼` `严肃` `慵懒` `俏皮` `深沉` `干练` `凌厉` | -| 音色定位 | `磁性` `醇厚` `清亮` `空灵` `稚嫩` `苍老` `甜美` `沙哑` | -| 人设腔调 | `夹子音` `御姐音` `正太音` `大叔音` `台湾腔` | -| 方言 | `东北话` `四川话` `河南话` `粤语` | -| 角色扮演 | `孙悟空` `林黛玉` | -| 唱歌 | `唱歌`(等价于 `sing` / `singing`) | +| Basic emotions | `happy` `sad` `angry` `fear` `surprised` `excited` `aggrieved` `calm` `indifferent` | +| Compound emotions | `wistful` `relieved` `helpless` `guilty` `at ease` `uneasy` `touched` | +| Overall tone | `gentle` `aloof` `lively` `serious` `languid` `playful` `deep` `sharp` `cutting` | +| Voice character | `magnetic` `mellow` `bright` `ethereal` `childlike` `aged` `sweet` `husky` | +| Persona | `squeaky` `mature lady` `young boy` `uncle` `Taiwanese accent` | +| Dialect | `Northeastern` `Sichuan` `Henan` `Cantonese` | +| Role-play | `Sun Wukong` `Lin Daiyu` | +| Singing | `sing` / `singing` | -示例: +Examples: -- (磁性)夜已经深了,城市还在呼吸。 -- (东北话)哎呀妈呀,这天儿也忒冷了吧! -- (粤语)呢个真係好正啊! -- (唱歌)原谅我这一生不羁放纵爱自由… +- `(magnetic)The night is deep, and the city is still breathing.` +- `(gentle)Take a breath. You've got this.` +- `(serious)This is the final warning before the system reboots.` +- `(singing)Oh, when the saints go marching in…` -也可以在文本任意位置插入细粒度音频标签来控制呼吸、笑声、停顿等,例如: +You can also insert fine-grained audio tags at any position in the text to control breathing, laughter, pauses, etc. For example: ``` -(紧张,深呼吸)呼……冷静,冷静。(语速加快)自我介绍我背了五十遍了,应该没问题。 +(nervous, deep breath) Phew… stay calm, stay calm. (faster pace) I've rehearsed this intro fifty times, it'll be fine. ``` -完整标签列表参见 [MiMo 语音合成文档](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5)。 +See the [MiMo speech synthesis documentation](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5) for the full tag list. - CowAgent 在调用 TTS 时会将 Agent 的回复原文(含 `(...)` 标签)直接送入 MiMo 合成。你可以在人设 / 系统提示词里要求模型「在回复开头用 `(风格)` 标签控制语气」,即可让 IM 渠道(微信 / 飞书 / 钉钉 / 企微)的语音回复带上情绪、方言、唱歌等效果。 + When CowAgent calls TTS, the Agent's reply text (including any `(...)` tags) is forwarded directly to MiMo for synthesis. Tell the model in its persona / system prompt to "prefix replies with a `(style)` tag to control the tone", and IM channels (WeChat / Feishu / DingTalk / WeCom) will play voice replies with the corresponding emotion, dialect, or even singing. diff --git a/docs/models/minimax.mdx b/docs/models/minimax.mdx index 8282f88b..d945d2ea 100644 --- a/docs/models/minimax.mdx +++ b/docs/models/minimax.mdx @@ -1,15 +1,15 @@ --- title: MiniMax -description: MiniMax 模型配置(文本 / 图像理解 / 图像生成 / 语音合成) +description: MiniMax model configuration (Text / Image Understanding / Image Generation / Text-to-Speech) --- -MiniMax 支持文本对话、图像理解、图像生成与语音合成,一份 `minimax_api_key` 即可启用全部能力。 +MiniMax supports text chat, image understanding, image generation, and text-to-speech. A single `minimax_api_key` enables all capabilities. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,16 +18,16 @@ MiniMax 支持文本对话、图像理解、图像生成与语音合成,一份 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 可填 `MiniMax-M2.7`、`MiniMax-M2.7-highspeed`、`MiniMax-M2.5`、`MiniMax-M2.1`、`MiniMax-M2.1-lightning`、`MiniMax-M2` 等 | -| `minimax_api_key` | 在 [MiniMax 控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建 | +| `model` | Can be `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2.1-lightning`, `MiniMax-M2`, etc. | +| `minimax_api_key` | Create one in the [MiniMax Console](https://platform.minimaxi.com/user-center/basic-information/interface-key) | -## 图像理解 +## Image Understanding -MiniMax 的 M2.x 系列 chat 模型本身不支持视觉,视觉调用统一路由到 `MiniMax-Text-01`。配置 `minimax_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。 +MiniMax's M2.x chat models do not support vision natively; vision calls are uniformly routed to `MiniMax-Text-01`. Once `minimax_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file. -## 图像生成 +## Image Generation ```json { @@ -39,9 +39,9 @@ MiniMax 的 M2.x 系列 chat 模型本身不支持视觉,视觉调用统一路 } ``` -可选模型:`image-01`。 +Available models: `image-01`. -## 语音合成 +## Text-to-Speech (TTS) ```json { @@ -51,21 +51,21 @@ MiniMax 的 M2.x 系列 chat 模型本身不支持视觉,视觉调用统一路 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `text_to_voice_model` | `speech-2.8-hd`(情绪渲染、自然听感)、`speech-2.8-turbo`(极速)、`speech-2.6-hd`、`speech-2.6-turbo` | -| `tts_voice_id` | 音色 ID,支持中文 / 粤语 / 英 / 日 / 韩,共 70+ 种 | +| `text_to_voice_model` | `speech-2.8-hd` (emotional rendering, natural sound), `speech-2.8-turbo` (ultra-fast), `speech-2.6-hd`, `speech-2.6-turbo` | +| `tts_voice_id` | Voice ID; supports Chinese / Cantonese / English / Japanese / Korean — 70+ voices in total | -常用音色示例: +Common voice examples: -| 音色 ID | 说明 | +| Voice ID | Description | | --- | --- | -| `female-shaonv` | 中文 · 少女(女) | -| `female-yujie` | 中文 · 御姐(女) | -| `female-tianmei` | 中文 · 甜美女性(女) | -| `male-qn-jingying` | 中文 · 精英青年(男) | -| `male-qn-badao` | 中文 · 霸道青年(男) | -| `Cantonese_GentleLady` | 粤语 · 温柔女声 | -| `English_Graceful_Lady` | 英文 · Graceful Lady | +| `female-shaonv` | Chinese · Young Girl (Female) | +| `female-yujie` | Chinese · Mature Lady (Female) | +| `female-tianmei` | Chinese · Sweet Female (Female) | +| `male-qn-jingying` | Chinese · Elite Youth (Male) | +| `male-qn-badao` | Chinese · Dominant Youth (Male) | +| `Cantonese_GentleLady` | Cantonese · Gentle Female Voice | +| `English_Graceful_Lady` | English · Graceful Lady | -完整音色(中文 / 粤语 / 英 / 日 / 韩共 70+ 种)可参考 [系统音色列表](https://platform.minimaxi.com/docs/faq/system-voice-id),也可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 +For the full voice list (70+ voices across Chinese / Cantonese / English / Japanese / Korean), see the [system voice list](https://platform.minimaxi.com/docs/faq/system-voice-id), or select visually in the Web Console under "Model Management → Text-to-Speech". diff --git a/docs/models/openai.mdx b/docs/models/openai.mdx index aad83c8f..f8715562 100644 --- a/docs/models/openai.mdx +++ b/docs/models/openai.mdx @@ -1,16 +1,16 @@ --- title: OpenAI -description: OpenAI 模型配置(文本 / 视觉 / 图像 / 语音 / 向量) +description: OpenAI model configuration (Text / Vision / Image / Speech / Embedding) --- -OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解、图像生成、语音识别(ASR)、语音合成(TTS)和向量(Embedding)能力。一份 `open_ai_api_key` 即可让 Agent 用到全部能力。 +OpenAI offers the most complete coverage and can simultaneously serve text chat, vision understanding, image generation, speech-to-text (ASR), text-to-speech (TTS), and embedding. A single `open_ai_api_key` lets the Agent use all of these capabilities. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -20,16 +20,16 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1`、o 系列等;Agent 模式默认 `gpt-5.5`,追求性价比可改为 `gpt-5.4` | -| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 | -| `open_ai_api_base` | 可选,修改可接入第三方代理 | -| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` | +| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency | +| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) | +| `open_ai_api_base` | Optional; change it to access a third-party proxy | +| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol | -## 图像理解 +## Image Understanding -`gpt-5.5`、`gpt-5.4`、`gpt-4o`、`gpt-4.1` 等 OpenAI 模型均原生支持视觉,配置 `open_ai_api_key` 后 Agent 的 Vision 工具会自动使用主模型识别图像。若主模型不支持视觉或希望显式指定,可在配置文件中配置: +OpenAI models like `gpt-5.5`, `gpt-5.4`, `gpt-4o`, and `gpt-4.1` natively support vision. Once `open_ai_api_key` is configured, the Agent's Vision tool automatically uses the main model to recognize images. If the main model does not support vision or you want to specify it explicitly, set it in the configuration file: ```json { @@ -41,11 +41,11 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解 } ``` -支持的 Vision 模型:`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5`、`gpt-4.1`、`gpt-4.1-mini`、`gpt-4o`。 +Supported Vision models: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`. -## 图像生成 +## Image Generation -在配置文件中指定图像生成模型,Agent 调用图像生成技能时会自动路由到 OpenAI: +Specify the image generation model in the configuration file; the Agent automatically routes image generation skill calls to OpenAI: ```json { @@ -57,9 +57,9 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解 } ``` -支持的图像生成模型:`gpt-image-2`、`gpt-image-1`。 +Supported image generation models: `gpt-image-2`, `gpt-image-1`. -## 语音识别 +## Speech-to-Text (ASR) ```json { @@ -68,14 +68,14 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `voice_to_text` | 设为 `openai` 启用 OpenAI 语音识别 | -| `voice_to_text_model` | 可选,默认 `gpt-4o-mini-transcribe`;也可填 `gpt-4o-transcribe`、`whisper-1` | +| `voice_to_text` | Set to `openai` to enable OpenAI speech-to-text | +| `voice_to_text_model` | Optional, defaults to `gpt-4o-mini-transcribe`; can also be `gpt-4o-transcribe`, `whisper-1` | -凭证自动复用 `open_ai_api_key`。 +Credentials are automatically reused from `open_ai_api_key`. -## 语音合成 +## Text-to-Speech (TTS) ```json { @@ -85,12 +85,12 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `text_to_voice_model` | `tts-1`、`tts-1-hd`、`gpt-4o-mini-tts` | -| `tts_voice_id` | 音色:`alloy`、`echo`、`fable`、`onyx`、`nova`、`shimmer`、`ash`、`ballad`、`coral`、`sage`、`verse` | +| `text_to_voice_model` | `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts` | +| `tts_voice_id` | Voices: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`, `ash`, `ballad`, `coral`, `sage`, `verse` | -## 向量 +## Embedding ```json { @@ -99,5 +99,5 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解 } ``` -可选模型:`text-embedding-3-small`、`text-embedding-3-large`、`text-embedding-ada-002`。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 +Available models: `text-embedding-3-small`, `text-embedding-3-large`, `text-embedding-ada-002`. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/models/qianfan.mdx b/docs/models/qianfan.mdx index bdd87214..13525967 100644 --- a/docs/models/qianfan.mdx +++ b/docs/models/qianfan.mdx @@ -1,48 +1,42 @@ --- -title: 百度千帆 -description: 百度千帆 ERNIE 模型配置(文本对话 + 图像理解) +title: ERNIE +description: ERNIE model configuration (Baidu Qianfan) --- -百度千帆提供 ERNIE 系列模型,支持文本对话与图像理解。 - - - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 - - -## 文本对话 +Option 1: Native integration (recommended): ```json { "model": "ernie-5.1", - "qianfan_api_key": "YOUR_API_KEY", + "qianfan_api_key": "", "qianfan_api_base": "https://qianfan.baidubce.com/v2" } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 默认推荐使用 `ernie-5.1`;也可使用 `ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-128k`、`ernie-4.5-turbo-32k` | -| `qianfan_api_key` | 千帆 API Key,格式通常以 `bce-v3/` 开头 | -| `qianfan_api_base` | 可选,默认为 `https://qianfan.baidubce.com/v2` | +| `model` | Default recommendation: `ernie-5.1`; also supports `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-128k`, `ernie-4.5-turbo-32k` | +| `qianfan_api_key` | Qianfan API key, usually starting with `bce-v3/` | +| `qianfan_api_base` | Optional, defaults to `https://qianfan.baidubce.com/v2` | -### 模型选择 +## Model Selection -| 模型 | 适用场景 | +| Model | Use Case | | --- | --- | -| `ernie-5.1` | 默认推荐,文心新一代旗舰模型,综合能力最强 | -| `ernie-5.0` | 上一代旗舰模型,综合能力优异 | -| `ernie-x1.1` | 深度思考推理模型,幻觉更低、指令遵循与工具调用更强 | -| `ernie-4.5-turbo-128k` | 长上下文和通用对话 | -| `ernie-4.5-turbo-32k` | 通用对话,成本和上下文更均衡 | +| `ernie-5.1` | Default recommendation; latest ERNIE flagship with the strongest overall capability | +| `ernie-5.0` | Previous-generation flagship with excellent overall capability | +| `ernie-x1.1` | Deep-thinking reasoning model with lower hallucination and stronger instruction following / tool calling | +| `ernie-4.5-turbo-128k` | Long-context and general chat | +| `ernie-4.5-turbo-32k` | General chat with a balanced context window and cost | -## 图像理解 +## Vision tool -配置 `qianfan_api_key` 后,Agent 的 Vision 工具可以自动使用千帆视觉模型: +Once `qianfan_api_key` is configured, Agent mode can auto-discover Qianfan for the Vision tool: -- 当主模型本身是多模态时(如 `ernie-5.1`、`ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-vl`),直接由主模型识别图像,无需额外配置 -- 当主模型是纯文本时(如 `ernie-4.5-turbo-128k`),Vision 工具会自动 fallback 到 `ernie-4.5-turbo-vl` +- When the main model itself is multimodal (e.g. `ernie-5.1`, `ernie-5.0`, `ernie-x1.1`, `ernie-4.5-turbo-vl`), images are handled directly by the main model with no extra setup. +- When the main model is text-only (e.g. `ernie-4.5-turbo-128k`), the Vision tool automatically falls back to `ernie-4.5-turbo-vl`. -如需手动指定 Vision 模型,可在配置文件中显式配置: +To force a specific Vision model, set it explicitly in `config.json`: ```json { @@ -54,6 +48,17 @@ description: 百度千帆 ERNIE 模型配置(文本对话 + 图像理解) } ``` +Option 2: OpenAI-compatible configuration: + +```json +{ + "model": "ernie-5.1", + "bot_type": "openai", + "open_ai_api_key": "", + "open_ai_api_base": "https://qianfan.baidubce.com/v2" +} +``` + - 新配置推荐使用 `qianfan_api_key`。旧的 `wenxin`、`wenxin-4`、`baidu_wenxin_api_key`、`baidu_wenxin_secret_key` 配置仍保持兼容。 + Prefer `qianfan_api_key` for new configurations. Existing `wenxin`, `wenxin-4`, `baidu_wenxin_api_key`, and `baidu_wenxin_secret_key` configurations remain supported. diff --git a/docs/models/qwen.mdx b/docs/models/qwen.mdx index 765bae64..8e27269c 100644 --- a/docs/models/qwen.mdx +++ b/docs/models/qwen.mdx @@ -1,15 +1,15 @@ --- -title: 通义千问 Qwen -description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / 语音识别 / 语音合成 / 向量) +title: Qwen +description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding) --- -通义千问(DashScope / 百炼)是国内覆盖最完整的厂商之一,文本、图像理解、图像生成、语音识别、语音合成与向量能力均可用一份 `dashscope_api_key` 启用。 +Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`. - 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. -## 文本对话 +## Text Chat ```json { @@ -18,16 +18,16 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `model` | 可填 `qwen3.6-plus`、`qwen3.7-max`、`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) | +| `model` | Can be `qwen3.6-plus`, `qwen3.7-max`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. | +| `dashscope_api_key` | Create one in the [Bailian Console](https://bailian.console.aliyun.com/?tab=model#/api-key); see the [official docs](https://bailian.console.aliyun.com/?tab=api#/api) | -## 图像理解 +## Image Understanding -配置 `dashscope_api_key` 后 Agent 的 Vision 工具会自动调用千问的视觉模型识别图像。`qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` 等模型本身就是多模态;若主模型是纯文本(如 `qwen-turbo`),会自动回落到 `qwen-vl-max`。 +Once `dashscope_api_key` is configured, the Agent's Vision tool automatically calls Qwen's vision models to recognize images. Models like `qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` are already multimodal; if the main model is text-only (e.g. `qwen-turbo`), it automatically falls back to `qwen-vl-max`. -如需手动指定 Vision 模型: +To manually specify a Vision model: ```json { @@ -39,9 +39,9 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / } ``` -支持模型:`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。 +Supported models: `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`. -## 图像生成 +## Image Generation ```json { @@ -53,9 +53,9 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / } ``` -可选模型:`qwen-image-2.0`、`qwen-image-2.0-pro`。 +Available models: `qwen-image-2.0`, `qwen-image-2.0-pro`. -## 语音识别 +## Speech-to-Text (ASR) ```json { @@ -64,14 +64,14 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `voice_to_text` | 设为 `dashscope` 启用通义千问 ASR | -| `voice_to_text_model` | 可选,默认 `qwen3-asr-flash` | +| `voice_to_text` | Set to `dashscope` to enable Qwen ASR | +| `voice_to_text_model` | Optional, defaults to `qwen3-asr-flash` | -凭证自动复用 `dashscope_api_key`。单段音频建议小于 10MB、时长不超过 300 秒。 +Credentials are automatically reused from `dashscope_api_key`. A single audio segment should be smaller than 10MB and no longer than 300 seconds. -## 语音合成 +## Text-to-Speech (TTS) ```json { @@ -81,26 +81,26 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `text_to_voice_model` | 可选,默认 `qwen3-tts-flash`,覆盖普通话、方言与主流外语 | -| `tts_voice_id` | 音色 ID,详见下方常用列表 | +| `text_to_voice_model` | Optional, defaults to `qwen3-tts-flash`; covers Mandarin, dialects, and major foreign languages | +| `tts_voice_id` | Voice ID; see the common list below | -常用音色示例: +Common voice examples: -| 音色 ID | 说明 | +| Voice ID | Description | | --- | --- | -| `Cherry` | 芊悦 · 阳光女声 | -| `Serena` | 苏瑶 · 温柔女声 | -| `Ethan` | 晨煦 · 阳光男声 | -| `Chelsie` | 千雪 · 二次元少女 | -| `Dylan` | 北京话 · 晓东 | -| `Rocky` | 粤语 · 阿强 | -| `Sunny` | 四川话 · 晴儿 | +| `Cherry` | Qianyue · Sunny Female Voice | +| `Serena` | Suyao · Gentle Female Voice | +| `Ethan` | Chenxu · Sunny Male Voice | +| `Chelsie` | Qianxue · Anime Girl | +| `Dylan` | Beijing Dialect · Xiaodong | +| `Rocky` | Cantonese · Aqiang | +| `Sunny` | Sichuan Dialect · Qing'er | -完整音色(普通话 / 各地方言 / 双语等)可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 +The full voice list (Mandarin / regional dialects / bilingual, etc.) can be selected visually in the Web Console under "Model Management → Text-to-Speech". -## 向量 +## Embedding ```json { @@ -109,4 +109,4 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / } ``` -默认模型 `text-embedding-v4`。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 +The default model is `text-embedding-v4`. After changing the embedding, run `/memory rebuild-index` to rebuild the index. diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx index 020265e6..aa68112c 100644 --- a/docs/releases/overview.mdx +++ b/docs/releases/overview.mdx @@ -1,32 +1,32 @@ --- -title: 更新日志 -description: CowAgent 版本更新历史 +title: Changelog +description: CowAgent version history --- -| 版本 | 日期 | 说明 | +| Version | Date | Description | | --- | --- | --- | -| [2.0.9](/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | -| [2.0.8](/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | -| [2.0.7](/releases/v2.0.7) | 2026.04.22 | 图像生成技能(六厂商自动路由)、新模型支持(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、知识库增强、Web 控制台优化 | -| [2.0.6](/releases/v2.0.6) | 2026.04.14 | 项目更名、知识库系统、梦境记忆蒸馏、上下文智能压缩、Web 控制台多会话及多项优化 | -| [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 控制台升级、多通道同时运行、会话持久化 | -| [2.0.1](/releases/v2.0.1) | 2026.02.13 | 内置 Web Search 工具、智能上下文管理、多项修复 | -| [2.0.0](/releases/v2.0.0) | 2026.02.03 | 全面升级为超级 Agent 助理 | -| 1.7.6 | 2025.05.23 | Web Channel 优化、AgentMesh 多智能体插件 | -| 1.7.5 | 2025.04.11 | DeepSeek 模型 | -| 1.7.4 | 2024.12.13 | Gemini 2.0 模型、Web Channel | -| 1.7.3 | 2024.10.31 | 稳定性提升、数据库功能 | -| 1.7.2 | 2024.09.26 | 一键安装脚本、o1 模型 | -| 1.7.0 | 2024.08.02 | 讯飞 4.0 模型、知识库引用 | -| 1.6.9 | 2024.07.19 | gpt-4o-mini、阿里语音识别 | -| 1.6.8 | 2024.07.05 | Claude 3.5、Gemini 1.5 Pro | -| 1.6.0 | 2024.04.26 | Kimi 接入、gpt-4-turbo 升级 | -| 1.5.8 | 2024.03.26 | GLM-4、Claude-3、edge-tts | -| 1.5.2 | 2023.11.10 | 飞书通道、图像识别对话 | -| 1.5.0 | 2023.11.10 | gpt-4-turbo、dall-e-3、tts 多模态 | -| 1.0.0 | 2022.12.12 | 项目创建,首次接入 ChatGPT 模型 | +| [2.0.9](/releases/v2.0.9) | 2026.05.22 | Model management console, MCP protocol support, browser persistent login, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max, etc.), deployment hardening | +| [2.0.8](/releases/v2.0.8) | 2026.05.06 | Major Feishu channel upgrade (voice, streaming and Markdown, one-click QR-scan setup), DeepSeek V4 and Baidu models, scheduler tool enhancements | +| [2.0.7](/releases/v2.0.7) | 2026.04.22 | Image Generation Skill (6-provider auto-routing), new models (Kimi K2.6, Claude Opus 4.7, GLM 5.1), knowledge base and Web Console improvements | +| [2.0.6](/releases/v2.0.6) | 2026.04.14 | Project rename, Knowledge Base system, Deep Dream Memory Distillation, Smart Context Compression, Web Console multi-session and various improvements | +| [2.0.5](/releases/v2.0.5) | 2026.04.01 | Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more | +| [2.0.4](/releases/v2.0.4) | 2026.03.22 | Personal WeChat channel, new model support, Japanese docs, script refactoring and bug fixes | +| [2.0.3](/releases/v2.0.3) | 2026.03.18 | WeCom Smart Bot and QQ channels, Coding Plan support, multiple new models, Web file processing, memory system upgrade | +| [2.0.2](/releases/v2.0.2) | 2026.02.27 | Web Console upgrade, multi-channel concurrency, session persistence | +| [2.0.1](/releases/v2.0.1) | 2026.02.13 | Built-in Web Search tool, smart context management, multiple fixes | +| [2.0.0](/releases/v2.0.0) | 2026.02.03 | Full upgrade to AI super assistant | +| 1.7.6 | 2025.05.23 | Web Channel optimization, AgentMesh plugin | +| 1.7.5 | 2025.04.11 | DeepSeek model | +| 1.7.4 | 2024.12.13 | Gemini 2.0 model, Web Channel | +| 1.7.3 | 2024.10.31 | Stability improvements, database features | +| 1.7.2 | 2024.09.26 | One-click install script, o1 model | +| 1.7.0 | 2024.08.02 | iFlytek 4.0 model, knowledge base references | +| 1.6.9 | 2024.07.19 | gpt-4o-mini, Alibaba voice recognition | +| 1.6.8 | 2024.07.05 | Claude 3.5, Gemini 1.5 Pro | +| 1.6.0 | 2024.04.26 | Kimi integration, gpt-4-turbo upgrade | +| 1.5.8 | 2024.03.26 | GLM-4, Claude-3, edge-tts | +| 1.5.2 | 2023.11.10 | Feishu channel, image recognition chat | +| 1.5.0 | 2023.11.10 | gpt-4-turbo, dall-e-3, tts multimodal | +| 1.0.0 | 2022.12.12 | Project created, first ChatGPT integration | -更多历史版本请查看 [GitHub Releases](https://github.com/zhayujie/CowAgent/releases)。 +See [GitHub Releases](https://github.com/zhayujie/CowAgent/releases) for full history. diff --git a/docs/releases/v2.0.0.mdx b/docs/releases/v2.0.0.mdx index 3436bc19..e6c37533 100644 --- a/docs/releases/v2.0.0.mdx +++ b/docs/releases/v2.0.0.mdx @@ -1,105 +1,63 @@ --- title: v2.0.0 -description: CowAgent 2.0 - 从聊天机器人到超级智能助理的全面升级 +description: CowAgent 2.0 - Full upgrade from chatbot to AI super assistant --- -CowAgent 2.0 实现了从聊天机器人到**超级智能助理**的全面升级!现在它能够主动思考和规划任务、拥有长期记忆、操作计算机和外部资源、创造和执行技能,真正理解你并和你一起成长。 +CowAgent 2.0 is a comprehensive upgrade from a chatbot to an **AI super assistant** — capable of autonomous thinking and task planning, long-term memory, operating computers, and creating and executing skills. -**发布日期**:2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) +**Release Date**: 2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) -## 重点更新 +## Key Updates -### Agent 核心能力 +### Agent Core -- **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持多轮推理和上下文理解 -- **长期记忆**:自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索 -- **内置系统工具**:内置实现 10+ 种工具,包括文件操作、Bash 终端、浏览器、文件发送、定时任务、记忆管理等 -- **Skills**:新增 Skill 运行引擎,内置多种技能,并支持通过自然语言对话完成自定义 Skills 开发 -- **安全和成本**:通过秘钥管理工具、提示词控制、系统权限等手段控制 Agent 的访问安全;通过最大记忆轮次、最大上下文 token、工具执行步数对 token 成本进行限制 +- **Complex Task Planning**: Autonomous planning with multi-turn reasoning +- **Long-term Memory**: Persistent memory with keyword and vector search +- **Built-in Tools**: 10+ tools including file ops, Bash, browser, scheduler +- **Web search**: Built-in `web_search` tool, supports multiple search engines, configure corresponding API key to use +- **Skills System**: Skill engine with built-in and custom skill support +- **Security & Cost**: Secret management, prompt controls, token limits -### 其他更新 +### Other -- **渠道优化**:飞书及钉钉接入渠道支持长连接接入(无需公网 IP)、支持图片/文件消息的接收和发送 -- **模型更新**:新增 claude-sonnet-4-5、gemini-3-pro-preview、glm-4.7、MiniMax-M2.1、qwen3-max 等最新模型 -- **部署优化**:增加一键安装、配置、运行、管理的脚本,简化部署流程 +- **Channels**: Feishu/DingTalk WebSocket support, image/file messages +- **Models**: claude-sonnet-4-5, gemini-3-pro-preview, glm-4.7, MiniMax-M2.1, qwen3-max +- **Deployment**: One-click install, configure, run, and management script -## 长期记忆系统 - -Agent 会在用户分享重要信息时主动存储,也会在对话达到一定长度时自动提取摘要。支持语义搜索和向量检索的混合检索模式。 - -**首次启动**时,Agent 会主动询问关键信息,并记录至工作空间(默认 `~/cow`)中的智能体设定、用户身份、记忆文件中。 - -**长期对话**中,Agent 会智能记录或检索记忆,不断更新自身设定、用户偏好,总结经验和教训,真正实现自主思考和持续成长。 +## Long-term Memory -## 任务规划与工具调用 - -Agent 根据任务需求智能选择和调用工具,完成各类复杂操作。 - -### 终端和文件访问 - -最基础和核心的工具能力,用户可通过手机端与 Agent 交互,操作个人电脑或服务器上的资源: +## Task Planning & Tools -### 应用编程能力 - -基于编程能力和系统访问能力,Agent 可实现从信息搜索、素材生成、编码、测试、部署、Nginx 配置、发布的 **Vibecoding 全流程**,通过手机端一句命令完成应用快速 demo。 - -### 定时任务 - -支持 **一次性任务、固定时间间隔、Cron 表达式** 三种形式,任务触发可选择 **固定消息发送** 或 **Agent 动态任务执行** 两种模式: - -### 环境变量管理 - -通过 `env_config` 工具管理技能所需秘钥,支持对话式更新,内置安全保护和脱敏策略: - - - - - -## 技能系统 - -每个 Skill 由说明文件、运行脚本(可选)、资源(可选)组成,为 Agent 提供无限扩展性。 - -### 技能创造器 - -通过对话方式快速创建技能,将工作流程固化或对接任意第三方接口: +## Skills System -### 网页搜索和图像识别 - -- **网页搜索**:内置 `web_search` 工具,支持多种搜索引擎,配置对应 API Key 即可使用 -- **图像识别**:支持 `gpt-4.1-mini`、`gpt-4.1` 等模型,配置 `OPENAI_API_KEY` 即可使用 - -### 三方知识库和插件 - -`linkai-agent` 技能可将 [LinkAI](https://link-ai.tech/) 上的所有智能体作为 Skill 使用,实现多智能体决策: - -## 参与共建 +## Contributing -2.0 版本后,项目将持续升级 Agent 能力、拓展接入渠道、内置工具、技能系统,降低模型成本和提升安全性。欢迎 [提出反馈](https://github.com/zhayujie/CowAgent/issues) 和 [贡献代码](https://github.com/zhayujie/CowAgent/pulls)。 +Welcome to [submit feedback](https://github.com/zhayujie/CowAgent/issues) and [contribute code](https://github.com/zhayujie/CowAgent/pulls). diff --git a/docs/releases/v2.0.1.mdx b/docs/releases/v2.0.1.mdx index da7b6745..7de5746a 100644 --- a/docs/releases/v2.0.1.mdx +++ b/docs/releases/v2.0.1.mdx @@ -1,36 +1,36 @@ --- title: v2.0.1 -description: CowAgent 2.0.1 - 内置 Web Search、智能上下文管理、多项修复 +description: CowAgent 2.0.1 - Built-in Web Search, smart context management, multiple fixes --- -**发布日期**:2026.02 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.1) | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) +**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) -## 新特性 +## New Features -- **内置 Web Search 工具**:将网络搜索作为 Agent 内置工具集成,降低决策成本 ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) -- **Claude Opus 4.6 模型支持**:新增对 Claude Opus 4.6 模型的支持 ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) -- **企业微信图片消息识别**:支持企业微信渠道的图片消息识别功能 ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) +- **Built-in Web Search tool**: Integrated web search as a built-in Agent tool, reducing decision cost ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) +- **Claude Opus 4.6 model support**: Added support for Claude Opus 4.6 model ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) +- **WeCom image recognition**: Support image message recognition in WeCom channel ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) -## 优化 +## Improvements -- **智能上下文管理**:解决聊天上下文溢出问题,新增智能上下文裁剪策略,防止 token 超限 ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) -- **运行时信息动态更新**:通过动态函数方案实现系统提示词中时间戳等运行时信息的自动更新 ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) -- **Skill 提示词优化**:改进 Skill 系统提示词生成逻辑,简化工具描述,提升 Agent 表现 ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) -- **智谱 AI 自定义 API Base URL**:支持智谱 AI 配置自定义 API Base URL ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) -- **启动脚本优化**:改进 `run.sh` 脚本的交互体验和配置流程 ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) -- **决策轮次日志**:新增 Agent 决策轮次的日志记录,便于调试 ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) +- **Smart context management**: Resolved chat context overflow with intelligent context trimming strategy to prevent token limits ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) +- **Runtime info dynamic update**: Automatic update of timestamps and other runtime info in system prompts via dynamic functions ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) +- **Skill prompt optimization**: Improved Skill system prompt generation, simplified tool descriptions for better Agent performance ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) +- **GLM custom API Base URL**: Support custom API Base URL for GLM models ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) +- **Startup script optimization**: Improved `run.sh` script interaction and configuration flow ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) +- **Decision step logging**: Added Agent decision step logging for debugging ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) -## 问题修复 +## Bug Fixes -- **定时任务记忆丢失**:修复 Scheduler 调度器导致的记忆丢失问题 ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) -- **空工具调用与超长结果**:修复空 tool calls 及过长工具返回结果的异常处理 ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) -- **OpenAI Function Call**:修复 OpenAI 模型的 function call 调用兼容性问题 ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) -- **Claude 工具名字段**:移除 Claude 模型响应中多余的 tool name 字段 ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) -- **MiniMax 推理优化**:优化 MiniMax 模型 reasoning content 处理,隐藏思考过程输出 ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **智谱 AI 思考过程**:隐藏智谱 AI 模型的思考过程展示 ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) -- **飞书连接与证书**:修复飞书渠道的 SSL 证书错误和连接异常问题 ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) -- **model_type 类型校验**:修复非字符串 `model_type` 导致的 `AttributeError` ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) +- **Scheduler memory loss**: Fixed memory loss caused by Scheduler dispatcher ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) +- **Empty tool calls & long results**: Fixed handling of empty tool calls and excessively long tool results ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) +- **OpenAI Function Call**: Fixed function call compatibility with OpenAI models ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) +- **Claude tool name field**: Removed extraneous tool name field from Claude model responses ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) +- **MiniMax reasoning**: Optimized MiniMax model reasoning content handling, hidden thinking process output ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **GLM thinking process**: Hidden GLM model thinking process display ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **Feishu connection & SSL**: Fixed Feishu channel SSL certificate errors and connection issues ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) +- **model_type validation**: Fixed `AttributeError` caused by non-string `model_type` ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) -## 平台兼容 +## Platform Compatibility -- **Windows 兼容性适配**:修复 Windows 平台下路径处理、文件编码及 `os.getuid()` 不可用等问题,涉及多个工具模块 ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) +- **Windows compatibility**: Fixed path handling, file encoding, and `os.getuid()` unavailability on Windows across multiple tool modules ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) diff --git a/docs/releases/v2.0.2.mdx b/docs/releases/v2.0.2.mdx index ad17bcba..80e9f6d8 100644 --- a/docs/releases/v2.0.2.mdx +++ b/docs/releases/v2.0.2.mdx @@ -1,63 +1,65 @@ --- title: v2.0.2 -description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会话持久化 +description: CowAgent 2.0.2 - Web Console upgrade, multi-channel concurrency, session persistence --- -## ✨ 重点更新 +**Release Date**: 2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) -### 🖥️ Web 控制台升级 +## Highlights -本次对 Web 控制台进行了全面升级,支持流式对话输出、工具执行过程和思考过程的可视化展示,并支持对模型、技能、记忆、通道、Agent 配置的在线查看和管理。 +### 🖥️ Web Console Upgrade -#### 对话界面 +The Web Console has been fully upgraded with streaming conversation output, visual display of tool execution and reasoning processes, and online management of **models, skills, memory, channels, and Agent configuration**. -支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程: +#### Chat Interface + +Supports streaming output with real-time display of the Agent's reasoning process and tool calls, providing intuitive observation of the Agent's decision-making: -#### 模型管理 +#### Model Management -支持在线管理模型配置,无需手动编辑配置文件: +Manage model configurations online without manually editing config files: -#### 技能管理 +#### Skill Management -支持在线查看和管理 Agent 技能(Skills): +View and manage Agent skills (Skills) online: -#### 记忆管理 +#### Memory Management -支持在线查看和管理 Agent 记忆: +View and manage Agent memory online: -#### 通道管理 +#### Channel Management -支持在线管理接入通道,支持实时连接/断开操作: +Manage connected channels online with real-time connect/disconnect operations: -#### 定时任务 +#### Scheduled Tasks -支持在线查看和管理定时任务,包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理: +View and manage scheduled tasks online, including one-time tasks, fixed intervals, and Cron expressions: -#### 日志 +#### Logs -支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题: +View Agent runtime logs in real-time for monitoring and troubleshooting: -相关提交:[f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) +Related commits: [f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) -### 🔀 多通道同时运行 +### 🔀 Multi-Channel Concurrency -支持多个接入通道(如飞书、钉钉、企微应用、Web 等)同时运行,每个通道在独立子线程中启动,互不干扰。 +Multiple channels (e.g., Feishu, DingTalk, WeCom, Web) can now run simultaneously, each in an independent thread without interference. -配置方式:在 `config.json` 中通过 `channel_type` 配置多个通道,以逗号分隔,也可在 Web 控制台的通道管理页面中实时连接或断开各通道。 +Configuration: Set multiple channels in `config.json` via `channel_type` separated by commas, or connect/disconnect channels in real-time from the Web Console's channel management page. ```json { @@ -65,34 +67,32 @@ description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会 } ``` -相关提交:[4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) +Related commits: [4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) -### 💾 会话持久化 +### 💾 Session Persistence -会话历史支持持久化存储至本地 SQLite 数据库,服务重启后会话上下文自动恢复,不再丢失。Web 控制台中的历史对话记录也会同步恢复展示。 +Session history is now persisted to a local SQLite database. Conversation context is automatically restored after service restarts. Historical conversations in the Web Console are also restored. -相关提交:[29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) +Related commits: [29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) -### 🤖 新增模型 +## New Models -- **Gemini 3.1 Pro Preview**:新增 `gemini-3.1-pro-preview` 模型支持 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) -- **Claude 4.6 Sonnet**:新增 `claude-4.6-sonnet` 模型支持 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) -- **Qwen3.5 Plus**:新增 `qwen3.5-plus` 模型支持 ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) -- **MiniMax M2.5**:新增 `Minimax-M2.5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) -- **GLM-5**:新增 `glm-5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) -- **Kimi K2.5**:新增 `kimi-k2.5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) -- **Doubao 2.0 Code**:新增 `doubao-2.0-code` 编程专用模型 ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) -- **DashScope 模型**:新增阿里云 DashScope 模型名称支持 ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) +- **Gemini 3.1 Pro Preview**: Added `gemini-3.1-pro-preview` model support ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Claude 4.6 Sonnet**: Added `claude-4.6-sonnet` model support ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Qwen3.5 Plus**: Added `qwen3.5-plus` model support ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) +- **MiniMax M2.5**: Added `Minimax-M2.5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **GLM-5**: Added `glm-5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Kimi K2.5**: Added `kimi-k2.5` model support ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Doubao 2.0 Code**: Added `doubao-2.0-code` coding-specialized model ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) +- **DashScope Models**: Added Alibaba Cloud DashScope model name support ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) -### 🌐 新增官网和文档中心 +## Website & Documentation -- **官网上线**:[cowagent.ai](https://cowagent.ai/) -- **文档中心上线**:[docs.cowagent.ai](https://docs.cowagent.ai/) +- **Official Website**: [cowagent.ai](https://cowagent.ai/) +- **Documentation**: [docs.cowagent.ai](https://docs.cowagent.ai/) -### 🐛 问题修复 +## Bug Fixes -- **Gemini 钉钉图片识别**:修复 Gemini 在钉钉通道中无法处理图片标记的问题 ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) -- **启动脚本依赖**:修复 `run.sh` 脚本的依赖安装问题 ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) -- **裸异常捕获**:将代码中的 `bare except` 替换为 `except Exception`,提升异常处理规范性 ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) - -**发布日期**:2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) +- **Gemini DingTalk image recognition**: Fixed Gemini unable to process image markers in DingTalk channel ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) +- **Startup script dependencies**: Fixed dependency installation issue in `run.sh` script ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) +- **Bare except cleanup**: Replaced `bare except` with `except Exception` for better exception handling ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) diff --git a/docs/releases/v2.0.3.mdx b/docs/releases/v2.0.3.mdx index 22d2d1f6..5f9a837d 100644 --- a/docs/releases/v2.0.3.mdx +++ b/docs/releases/v2.0.3.mdx @@ -1,91 +1,91 @@ --- title: v2.0.3 -description: CowAgent 2.0.3 - 新增企微智能机器人和 QQ 通道、Web 控制台文件处理、记忆系统升级 +description: CowAgent 2.0.3 - WeCom Smart Bot and QQ channels, Web Console file handling, memory system upgrade --- -## 🔌 新增接入通道 +## 🔌 New Channels -### 企业微信智能机器人 +### WeCom Smart Bot -新增企业微信智能机器人(`wecom_bot`)通道,支持流式卡片消息输出,支持文本和图片消息的接收与回复,可在 Web 控制台中进行通道配置和管理。 +Added the WeCom Smart Bot (`wecom_bot`) channel with streaming card output, support for receiving and replying to text and image messages, and full configuration through the Web Console. -接入文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。 +Documentation: [WeCom Smart Bot](https://docs.cowagent.ai/en/channels/wecom-bot). -相关提交:[d4480b6](https://github.com/zhayujie/CowAgent/commit/d4480b6), [a42f31f](https://github.com/zhayujie/CowAgent/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/CowAgent/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/CowAgent/commit/8b45d6c) +Related commits: [d4480b6](https://github.com/zhayujie/CowAgent/commit/d4480b6), [a42f31f](https://github.com/zhayujie/CowAgent/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/CowAgent/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/CowAgent/commit/8b45d6c) -### QQ 通道 +### QQ Channel -新增 QQ 官方机器人(`qq`)通道,支持文本和图片消息的接收与回复,支持私聊和群聊场景。 +Added the QQ official bot (`qq`) channel with support for text and image messages in both private chats and group chats. -接入文档参考:[QQ机器人接入](https://docs.cowagent.ai/channels/qq)。 +Documentation: [QQ Bot](https://docs.cowagent.ai/en/channels/qq). -相关提交:[005a0e1](https://github.com/zhayujie/CowAgent/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/CowAgent/commit/a4d54f5) +Related commits: [005a0e1](https://github.com/zhayujie/CowAgent/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/CowAgent/commit/a4d54f5) -## 🖥️ Web 控制台支持文件输入和处理 +## 🖥️ Web Console File Input and Processing -Web 控制台对话界面支持文件和图片上传,可直接发送文件给 Agent 进行处理。同时 Read 工具新增对 Office 文档(Word、Excel、PPT)的解析能力。 +The Web Console chat UI now supports file and image uploads — files can be sent directly to the agent for processing. The Read tool gains parsing support for Office documents (Word, Excel, PPT). -相关提交:[30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) +Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) -## 🤖 新增模型 +## 🤖 New Models -- **GPT-5.4 系列**:新增 `gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` 模型支持 ([1623deb](https://github.com/zhayujie/CowAgent/commit/1623deb)) -- **Gemini 3.1 Flash Lite Preview**:新增 `gemini-3.1-flash-lite-preview` 模型支持 ([ba915f2](https://github.com/zhayujie/CowAgent/commit/ba915f2)) +- **GPT-5.4 Series**: Added `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano` ([1623deb](https://github.com/zhayujie/CowAgent/commit/1623deb)) +- **Gemini 3.1 Flash Lite Preview**: Added `gemini-3.1-flash-lite-preview` ([ba915f2](https://github.com/zhayujie/CowAgent/commit/ba915f2)) -## 💰 Coding Plan 支持 +## 💰 Coding Plan Support -新增各厂商 Coding Plan(编程包月套餐)的接入支持,通过 OpenAI 兼容方式统一接入。目前已支持阿里云、MiniMax、智谱 GLM、Kimi、火山引擎等厂商。 +Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine. -详细配置参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。 +See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration. -## 🧠 记忆系统升级 +## 🧠 Memory System Upgrade -记忆写入(Memory Flush)升级: +Memory flush improvements: -- 使用 LLM 对超出上下文窗口的对话内容进行智能摘要,生成精炼的每日记忆条目 -- 摘要在后台线程异步执行,不阻塞回复 -- 优化上下文批量裁剪策略,降低冲刷频率 -- 新增每日定时冲刷兜底机制,避免低活跃场景下记忆丢失 -- 修复上下文记忆丢失问题 +- Use the LLM to summarize out-of-window conversations into compact daily memory entries +- Summarization runs asynchronously on a background thread, never blocking replies +- Smarter batch trimming policy reduces flush frequency +- Daily scheduled flush as a safety net for low-activity scenarios +- Fixed context-memory loss issues -相关提交:[022c13f](https://github.com/zhayujie/CowAgent/commit/022c13f), [c116235](https://github.com/zhayujie/CowAgent/commit/c116235) +Related commits: [022c13f](https://github.com/zhayujie/CowAgent/commit/022c13f), [c116235](https://github.com/zhayujie/CowAgent/commit/c116235) -## 🔧 工具重构 +## 🔧 Tool Refactoring -- **图片识别**:将图片识别(Image Vision)从 Skill 重构为内置 Tool,新增独立的图片视觉提供方(Vision Provider)配置,提升稳定性和可维护性 ([a50fafa](https://github.com/zhayujie/CowAgent/commit/a50fafa), [3b8b562](https://github.com/zhayujie/CowAgent/commit/3b8b562)) -- **网页抓取**:将网页抓取(Web Fetch)从 Skill 重构为内置 Tool,支持远程文档文件(PDF、Word、Excel、PPT)的下载和解析 ([ccb9030](https://github.com/zhayujie/CowAgent/commit/ccb9030), [fa61744](https://github.com/zhayujie/CowAgent/commit/fa61744)) +- **Image Vision**: Image recognition (Vision) is refactored from a Skill into a built-in Tool with a dedicated Vision Provider configuration, improving stability and maintainability ([a50fafa](https://github.com/zhayujie/CowAgent/commit/a50fafa), [3b8b562](https://github.com/zhayujie/CowAgent/commit/3b8b562)) +- **Web Fetch**: Web fetch is refactored from a Skill into a built-in Tool with support for downloading and parsing remote documents (PDF, Word, Excel, PPT) ([ccb9030](https://github.com/zhayujie/CowAgent/commit/ccb9030), [fa61744](https://github.com/zhayujie/CowAgent/commit/fa61744)) -## 🐳 Docker 部署优化 +## 🐳 Docker Deployment Improvements -- **配置模板对齐**:`docker-compose.yml` 环境变量与 `config-template.json` 对齐,补充完整的模型 API Key 和 Agent 等配置项 -- **Web 控制台端口映射**:新增 `9899` 端口映射,Docker 部署后可通过浏览器访问 Web 控制台 -- **配置热更新**:各模型 Bot 的 API Key 和 API Base 改为实时读取,通过 Web 控制台修改配置后无需重启即可生效 -- **工作空间持久化**:新增 `./cow` Volume 挂载,Agent 工作空间数据(记忆、人格、技能等)持久化到宿主机,容器重建或升级不丢失 +- **Config Template Alignment**: `docker-compose.yml` env vars aligned with `config-template.json`, covering full model API key and Agent settings +- **Web Console Port Mapping**: Added `9899` port mapping so the Web Console is reachable in browser after Docker deployment +- **Hot Config Reload**: Bot API key and API base are now read at request time — changes from the Web Console take effect without restart +- **Workspace Persistence**: Added a `./cow` volume mount so agent workspace data (memories, persona, skills, etc.) persists across container rebuilds and upgrades -## ⚡ 性能优化 +## ⚡ Performance Improvements -- **启动加速**:飞书通道采用懒加载方式导入依赖,避免 4-10 秒的启动延迟 ([924dc79](https://github.com/zhayujie/CowAgent/commit/924dc79)) -- **通道稳定性**:优化通道连接稳定性,支持通道配置通过环境变量设置 ([f1c04bc](https://github.com/zhayujie/CowAgent/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/CowAgent/commit/46d97fd)) +- **Faster Startup**: The Feishu channel imports its dependencies lazily, avoiding a 4–10s startup delay ([924dc79](https://github.com/zhayujie/CowAgent/commit/924dc79)) +- **Channel Stability**: Improved channel connection stability and added env-var support for channel configuration ([f1c04bc](https://github.com/zhayujie/CowAgent/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/CowAgent/commit/46d97fd)) -## 🐛 问题修复 +## 🐛 Bug Fixes -- **bot_type 配置**:修复 Agent 模式下 `bot_type` 配置传递问题 ([#2691](https://github.com/zhayujie/CowAgent/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) -- **bot_type 优先级**:调整 Agent 模式下 `bot_type` 的解析优先级 ([#2692](https://github.com/zhayujie/CowAgent/pull/2692)) Thanks [@6vision](https://github.com/6vision) -- **智谱模型配置**:修复智谱 `bot_type` 命名、Web 控制台持久化及正则转义问题 ([#2693](https://github.com/zhayujie/CowAgent/pull/2693)) Thanks [@6vision](https://github.com/6vision) -- **OpenAI 兼容层**:使用 `openai_compat` 层统一错误处理 ([#2688](https://github.com/zhayujie/CowAgent/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) -- **OpenAI 兼容迁移**:完成所有模型 Bot 的 `openai_compat` 迁移 ([#2689](https://github.com/zhayujie/CowAgent/pull/2689)) -- **Gemini 工具调用**:修复 Gemini 模型的工具调用匹配问题 ([eda82ba](https://github.com/zhayujie/CowAgent/commit/eda82ba)) -- **会话并发**:修复会话并发场景下的竞态条件问题 ([9879878](https://github.com/zhayujie/CowAgent/commit/9879878)) -- **历史消息恢复**:修复历史会话消息不完整问题,仅恢复 user/assistant 文本消息,剥离工具调用 ([b788a3d](https://github.com/zhayujie/CowAgent/commit/b788a3d), [a33ce97](https://github.com/zhayujie/CowAgent/commit/a33ce97)) -- **飞书群聊**:移除飞书群聊场景下对 `bot_name` 的依赖 ([b641bff](https://github.com/zhayujie/CowAgent/commit/b641bff)) -- **Safari 兼容**:修复 Safari 浏览器 IME 回车键误触发消息发送问题 ([0687916](https://github.com/zhayujie/CowAgent/commit/0687916)) -- **Windows 兼容**:修复 Windows 下 bash 风格 `$VAR` 环境变量转换为 `%VAR%` 的问题 ([7c67513](https://github.com/zhayujie/CowAgent/commit/7c67513)) -- **MiniMax 参数**:增加 MiniMax 模型的 `max_tokens` 限制 ([1767413](https://github.com/zhayujie/CowAgent/commit/1767413)) -- **.gitignore 更新**:添加 Python 目录忽略规则 ([#2683](https://github.com/zhayujie/CowAgent/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) -- **AGENT.md 主动演进**:优化系统提示词中对 AGENT.md 的更新引导,从被动的"用户修改时更新"改为主动识别对话中的性格、风格变化并自动更新 +- **bot_type Propagation**: Fixed `bot_type` propagation under Agent mode ([#2691](https://github.com/zhayujie/CowAgent/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) +- **bot_type Resolution Priority**: Adjusted `bot_type` resolution priority under Agent mode ([#2692](https://github.com/zhayujie/CowAgent/pull/2692)) Thanks [@6vision](https://github.com/6vision) +- **Zhipu Config**: Fixed Zhipu `bot_type` naming, Web Console persistence, and regex escaping ([#2693](https://github.com/zhayujie/CowAgent/pull/2693)) Thanks [@6vision](https://github.com/6vision) +- **OpenAI-Compat Layer**: Unified error handling via the `openai_compat` layer ([#2688](https://github.com/zhayujie/CowAgent/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) +- **OpenAI-Compat Migration**: Completed the `openai_compat` migration across all model bots ([#2689](https://github.com/zhayujie/CowAgent/pull/2689)) +- **Gemini Tool Calling**: Fixed tool-call matching for Gemini ([eda82ba](https://github.com/zhayujie/CowAgent/commit/eda82ba)) +- **Session Concurrency**: Fixed race conditions in concurrent session scenarios ([9879878](https://github.com/zhayujie/CowAgent/commit/9879878)) +- **History Recovery**: Fixed incomplete history recovery — only user/assistant text messages are restored, tool calls are stripped ([b788a3d](https://github.com/zhayujie/CowAgent/commit/b788a3d), [a33ce97](https://github.com/zhayujie/CowAgent/commit/a33ce97)) +- **Feishu Group Chat**: Removed the `bot_name` dependency for Feishu group chats ([b641bff](https://github.com/zhayujie/CowAgent/commit/b641bff)) +- **Safari Compatibility**: Fixed an IME Enter key issue that mistakenly sent messages on Safari ([0687916](https://github.com/zhayujie/CowAgent/commit/0687916)) +- **Windows Compatibility**: Fixed bash-style `$VAR` to `%VAR%` env-var conversion on Windows ([7c67513](https://github.com/zhayujie/CowAgent/commit/7c67513)) +- **MiniMax Params**: Added a `max_tokens` cap for MiniMax models ([1767413](https://github.com/zhayujie/CowAgent/commit/1767413)) +- **.gitignore**: Added Python directory ignore rules ([#2683](https://github.com/zhayujie/CowAgent/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) +- **AGENT.md Proactive Evolution**: Improved the system prompt guidance around AGENT.md — instead of waiting for explicit user edits, the agent now proactively detects persona/style shifts in the conversation and updates AGENT.md accordingly -## 📦 升级方式 +## 📦 Upgrade -源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/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/en/guide/upgrade) for details. -**发布日期**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.2...master) +**Release Date**: 2026.03.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.2...2.0.3) diff --git a/docs/releases/v2.0.4.mdx b/docs/releases/v2.0.4.mdx index 55264a4b..fc976fa0 100644 --- a/docs/releases/v2.0.4.mdx +++ b/docs/releases/v2.0.4.mdx @@ -1,51 +1,55 @@ --- title: v2.0.4 -description: CowAgent 2.0.4 - 新增个人微信通道、新模型支持、日文文档、脚本重构及多项修复 +description: CowAgent 2.0.4 - Personal WeChat channel, new model support, Japanese docs, script refactoring and bug fixes --- -## 🔌 新增个人微信通道 +## 🔌 Personal WeChat Channel -新增个人微信(`weixin`)通道,微信扫描二维码即可将 CowAgent 接入个人微信,支持以下功能: +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: -- **消息收发**:支持文本、图片、文件、视频消息的接收与回复,支持语音消息接收和识别 -- **扫码登录**:终端显示二维码,微信扫码确认即可登录,二维码过期自动刷新 -- **凭证持久化**:登录凭证自动保存至 `~/.weixin_cow_credentials.json`,重启无需重新扫码 -- **Session 自动重连**:Session 过期后自动清除旧凭证并重新发起扫码登录 -- **Web 控制台接入**:支持在 Web 控制台中添加微信通道,扫码登录流程同步展示 -- **Docker 和脚本支持**:`run.sh` 和 `docker-compose.yml` 均已适配微信通道 +- **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 -接入文档:[微信接入](https://docs.cowagent.ai/channels/weixin)。 +Documentation: [WeChat Channel](https://docs.cowagent.ai/channels/weixin). -相关提交:[ce89869](https://github.com/zhayujie/CowAgent/commit/ce89869) +Related commits: [ce89869](https://github.com/zhayujie/CowAgent/commit/ce89869), [a483ec0](https://github.com/zhayujie/CowAgent/commit/a483ec0), [c1421e0](https://github.com/zhayujie/CowAgent/commit/c1421e0) -## 🤖 新增模型 +## 🤖 New Models -- **MiniMax-M2.7**:新增 MiniMax-M2.7 模型支持 -- **GLM-5-Turbo**:新增智谱 glm-5-turbo 模型支持 +- **MiniMax-M2.7**: Added MiniMax-M2.7 model support +- **GLM-5-Turbo**: Added Zhipu glm-5-turbo model support -相关提交:[9192f6f](https://github.com/zhayujie/CowAgent/commit/9192f6f) +Related commits: [9192f6f](https://github.com/zhayujie/CowAgent/commit/9192f6f) -## 🔧 脚本重构 +## 🔧 Script Refactoring -- **run.sh 重构**:提取公共逻辑,精简脚本代码([49d8707](https://github.com/zhayujie/CowAgent/commit/49d8707)) -- **可执行权限**:修复 `run.sh` 文件权限问题 ([652156e](https://github.com/zhayujie/CowAgent/commit/652156e)) -- **PID 获取**:修复 `run.sh` 中进程 PID 获取错误的问题 ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) +- **run.sh Refactoring**: Extracted shared logic and eliminated duplication, reducing from 600+ lines to 177 lines ([49d8707](https://github.com/zhayujie/CowAgent/commit/49d8707)) +- **Executable Permission**: Fixed `run.sh` file permission issue ([652156e](https://github.com/zhayujie/CowAgent/commit/652156e)) -## 🌍 文档更新 +## ⚡ Improvements -新增完整的日文文档,覆盖入门指南、通道接入、模型配置等主要章节。Thanks [@Ikko Ashimine](https://github.com/ikoamu) +- **Unified Request Headers**: Added identification headers to external requests across Agent services (Chat, Embedding, Vision, WebSearch, etc.) ([b4e711f](https://github.com/zhayujie/CowAgent/commit/b4e711f)) +- **Auto-Repair Messages**: Enhanced message protocol fault tolerance with automatic repair of malformed message sequences ([b8b57e3](https://github.com/zhayujie/CowAgent/commit/b8b57e3)) -相关提交:[5487c0b](https://github.com/zhayujie/CowAgent/commit/5487c0b) +## 🌍 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) -- **企微机器人兼容**:修复旧版 `websocket-client` 的兼容性问题,新增统一的 WebSocket 兼容层 ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) -- **消息自动修复**:增强消息协议的容错能力,自动修复格式异常的消息序列 ([b8b57e3](https://github.com/zhayujie/CowAgent/commit/b8b57e3)) -- **飞书编码**:修复飞书通道消息和日志的编码问题 ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) -- **飞书配置**:移除 `run.sh` 中对 `feishu_bot_name` 的冗余依赖 ([1b5be1b](https://github.com/zhayujie/CowAgent/commit/1b5be1b)) +Related commits: [5487c0b](https://github.com/zhayujie/CowAgent/commit/5487c0b) -## 📦 升级方式 +## 🐛 Bug Fixes -源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 +- **WeCom Bot Compatibility**: Fixed compatibility with older `websocket-client` versions, added unified WebSocket compatibility layer ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) +- **run.sh PID**: Fixed process PID retrieval error in `run.sh` ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) +- **Feishu Encoding**: Fixed message and log encoding issue in Feishu channel ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) +- **Feishu Config**: Removed redundant `feishu_bot_name` dependency in `run.sh` ([1b5be1b](https://github.com/zhayujie/CowAgent/commit/1b5be1b)) -**发布日期**:2026.03.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.3...master) +## 📦 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/CowAgent/compare/2.0.3...master) diff --git a/docs/releases/v2.0.5.mdx b/docs/releases/v2.0.5.mdx index bf0c0e53..ad45398e 100644 --- a/docs/releases/v2.0.5.mdx +++ b/docs/releases/v2.0.5.mdx @@ -1,84 +1,77 @@ --- title: v2.0.5 -description: CowAgent 2.0.5 - Cow CLI、Skill Hub 开源、浏览器工具、企微扫码创建、DeepSeek 独立模块及多项优化 +description: CowAgent 2.0.5 - Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more --- -## 🖥️ Cow CLI 命令系统 +## 🖥️ Cow CLI -新增 Cow CLI 命令系统,支持在终端和对话中执行命令,实现对 CowAgent 的全方位管理: +New CLI command system for managing CowAgent from terminal and chat: -- **终端命令**:在系统终端中执行 `cow <命令>`,支持 `start`、`stop`、`restart`、`update`、`status`、`logs` 等服务管理操作 -- **对话命令**:在对话中输入 `/<命令>` 或 `cow <命令>`,支持 `/help`、`/status`、`/config`、`/skill`、`/context`、`/logs`、`/version` 等 -- **web控制台**:Web 控制台输入框输入 `/` 即可弹出指令菜单,支持方向键回溯历史输入 -- **Windows 支持**:新增 PowerShell 一键安装脚本 `scripts/run.ps1`,同时支持 `cow` 命令 +- **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 -相关文档:[命令总览](https://docs.cowagent.ai/cli) +Docs: [Command Overview](https://docs.cowagent.ai/en/cli) -## 🧩 Cow Skill Hub 开源 +## 🧩 Cow Skill Hub Open Source -[Cow Skill Hub](https://skills.cowagent.ai)(技能广场)正式开源并上线,提供 AI Agent 技能的浏览、搜索、安装和发布,汇集精选技能、社区贡献技能、三方技能: +[Cow Skill Hub](https://skills.cowagent.ai) is now open source and live — browse, search, install, and publish AI Agent skills: -- **一键安装**:在对话中 `/skill install <名称>` 或终端 `cow skill install <名称>` 一键安装 -- **多来源支持**:支持安装 Skill Hub、GitHub、ClawHub、LinkAI 上的全部技能,支持 GitHub 批量安装和子目录指定 -- **技能搜索**:`/skill search` 和 `/skill list --remote` 浏览和搜索技能广场 -- **技能发布**:通过 [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) 提交自己的技能 -- **镜像加速**:支持 Skill Hub 镜像加速,国内环境下载更流畅 +- **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 -Skill Hub 开源仓库:[cow-skill-hub](https://github.com/zhayujie/cow-skill-hub)。 +Open source repo: [cow-skill-hub](https://github.com/zhayujie/cow-skill-hub) -相关文档:[技能广场](https://docs.cowagent.ai/skills/hub)、[安装技能](https://docs.cowagent.ai/skills/install) +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: -新增 Browser 工具,Agent 可控制浏览器访问和操作网页: +- **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 -- **网页导航与交互**:支持 `navigate`、`click`、`fill`、`select`、`scroll`、`press` 等操作 -- **页面快照**:使用精简 DOM 快照技术,让 Agent 高效理解页面结构,导航后自动快照 -- **截图能力**:支持页面截图保存到工作区 -- **JavaScript 执行**:支持在页面中执行自定义脚本 -- **CLI 安装**:通过 `cow install-browser` 一键安装浏览器及依赖,自动适配系统环境 -- **Docker 支持**:Docker 镜像已内置浏览器安装支持 - -相关文档:[浏览器工具](https://docs.cowagent.ai/tools/browser)。 +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 -- **Web 控制台扫码**:在 Web 控制台通道页面,选择「扫码接入」模式,使用企业微信扫码即可自动创建并接入智能机器人,无需手动到企业微信后台配置 -- **手动模式保留**:同时保留「手动填写」模式,可输入已有的 Bot ID 和 Secret 接入 -- **流式推送优化**:增加推送节流,避免 WebSocket 拥塞 +Docs: [WeCom Bot](https://docs.cowagent.ai/en/channels/wecom-bot) -相关文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。 +PR: [#2735](https://github.com/zhayujie/CowAgent/pull/2735). Thanks [@WecomTeam](https://github.com/WecomTeam) -相关提交:[#2735](https://github.com/zhayujie/CowAgent/pull/2735) +## 🐛 Other Improvements & Fixes -Thanks [@WecomTeam](https://github.com/WecomTeam) +- **DeepSeek module**: Independent DeepSeek Bot with dedicated `deepseek_api_key` config ([#2719](https://github.com/zhayujie/CowAgent/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/CowAgent/pull/2731)). Thanks [@zkjqd](https://github.com/zkjqd) +- **Context loss**: Fix context loss after trimming ([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) +- **System prompt**: Fix system prompt not rebuilding on every turn ([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) +- **Gemini**: Fix missing model attribute in GoogleGeminiBot ([#2716](https://github.com/zhayujie/CowAgent/pull/2716)). Thanks [@cowagent](https://github.com/cowagent) +- **WeChat channel**: Fix file send failures and filename loss ([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba), [45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) +- **Docker**: Fix volume permissions, reduce image size ([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348), [4470d4c](https://github.com/zhayujie/CowAgent/commit/4470d4c)) +- **Security**: Fix Memory Content path traversal risk. Thanks [@August829](https://github.com/August829) -## 🐛 其他优化与修复 +## 📦 Upgrade -- **DeepSeek 独立模块**:新增独立的 DeepSeek Bot 模块,支持 `deepseek_api_key` 专属配置,无需再通过 OpenAI 兼容方式接入([#2719](https://github.com/zhayujie/CowAgent/pull/2719))。Thanks [@6vision](https://github.com/6vision) -- **Web 控制台优化**:新增斜杠指令菜单和输入历史回溯,新增模型选项,优化移动端适配([#2731](https://github.com/zhayujie/CowAgent/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd) -- **上下文丢失**:修复上下文裁剪后丢失的问题 ([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) -- **系统提示词**:修复系统提示词未在每轮重建的问题 ([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) -- **Agent 响应**:去除 Agent 响应首尾空白字符 ([f890318](https://github.com/zhayujie/CowAgent/commit/f890318)) -- **视觉压缩**:优化视觉图片压缩策略 ([22b8ca0](https://github.com/zhayujie/CowAgent/commit/22b8ca0)) -- **Gemini 模型**:修复 GoogleGeminiBot 缺少 model 属性的问题([#2716](https://github.com/zhayujie/CowAgent/pull/2716))。Thanks [@cowagent](https://github.com/cowagent) -- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba)、[baf66a1](https://github.com/zhayujie/CowAgent/commit/baf66a1)、[45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) -- **Docker 优化**:修复卷权限问题,精简镜像体积 ([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/CowAgent/commit/4470d4c)) -- **README 排版**:优化中英文排版空格([#2723](https://github.com/zhayujie/CowAgent/pull/2723))。Thanks [@Xiaozhou345](https://github.com/Xiaozhou345) -- **安全修复**:修复 Memory Content路径遍历风险,Thanks [@August829](https://github.com/August829) +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). -## 📦 升级方式 - -源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 - -**发布日期**:2026.04.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.4...master) +**Release Date**: 2026.04.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.4...master) diff --git a/docs/releases/v2.0.6.mdx b/docs/releases/v2.0.6.mdx index 299be80b..a41f75aa 100644 --- a/docs/releases/v2.0.6.mdx +++ b/docs/releases/v2.0.6.mdx @@ -1,83 +1,83 @@ --- title: v2.0.6 -description: CowAgent 2.0.6 - 知识库系统、梦境记忆蒸馏、上下文智能压缩、Web 控制台多会话及多项优化 +description: CowAgent 2.0.6 - Knowledge Base, Deep Dream Memory Distillation, Smart Context Compression, Web Console Multi-Session and More --- -## 项目正式更名为 CowAgent +## Project Renamed to CowAgent -项目仓库正式从 `chatgpt-on-wechat` 更名为 **CowAgent**,演进为功能完备的 AI Agent 助理。 +The repository has been officially renamed from `chatgpt-on-wechat` to **CowAgent**, evolving into a full-featured AI Agent assistant. -- 新地址:[github.com/zhayujie/CowAgent](https://github.com/zhayujie/CowAgent),旧地址 GitHub 会自动重定向 -- CLI 命令、配置文件、文档链接均保持兼容,无需额外操作 +- New URL: [github.com/zhayujie/CowAgent](https://github.com/zhayujie/CowAgent) — GitHub auto-redirects the old URL +- CLI commands, config files, and documentation links remain compatible — no extra steps needed -## 📚 知识库系统 +## 📚 Knowledge Base -新增个人知识库系统,Agent 可自主构建和维护结构化知识,并在对话中按需检索引用。 +New personal knowledge base system — Agent can autonomously build and maintain structured knowledge, retrieving it on demand during conversations: -- **索引驱动的自组织结构**:知识库采用 `knowledge/` 目录,按分类自动组织,每个知识页面为独立的 Markdown 文件 -- **自动写入**:向 Agent 发送文件、链接等知识,或在讨论中识别到有价值的知识时,自动创建或更新知识页面 -- **混合检索**:支持关键词全文搜索和向量语义检索,在对话中按需加载相关知识 -- **可视化**:支持文件树浏览和知识图谱可视化,文档内链接可直接跳转查看 -- **命令管理**:`/knowledge` 查看统计、`/knowledge list` 查看目录结构、`/knowledge on|off` 开关知识库 +- **Index-driven self-organizing structure**: Knowledge is stored in `knowledge/` directory, auto-organized by category, with each knowledge page as an independent Markdown file +- **Auto-write**: Send files, links, or other knowledge to the Agent, or it will automatically create/update knowledge pages when valuable information is identified in conversation +- **Hybrid retrieval**: Supports keyword full-text search and vector semantic retrieval, loading relevant knowledge on demand during conversations +- **Visualization**: File tree browsing and knowledge graph visualization, with in-document links for direct navigation +- **Command management**: `/knowledge` for stats, `/knowledge list` for directory structure, `/knowledge on|off` to toggle -相关文档:[知识库](https://docs.cowagent.ai/knowledge) -Inspired by Karpathy's [LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). +Docs: [Knowledge Base](https://docs.cowagent.ai/en/knowledge) -## 🌙 梦境记忆蒸馏(Deep Dream) +## 🌙 Deep Dream Memory Distillation -全新的记忆整理机制,每日自动将分散的对话记忆蒸馏为精炼的长期记忆: +A new memory consolidation mechanism that automatically distills scattered conversation memories into refined long-term memory daily: -- **三层记忆流转**:对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期),形成完整的记忆生命周期 -- **自动蒸馏**:每日 23:55 定时执行,读取当天天级记忆和 MEMORY.md,通过 LLM 进行去重、合并、修剪,输出精炼的新版 MEMORY.md -- **梦境日记**:每次蒸馏生成一篇叙事风格的梦境日记,记录整理过程的发现和洞察,存储在 `memory/dreams/` 目录 -- **手动触发**:支持 `/memory dream [N]` 手动触发,可指定整理天数(默认 3 天,最大 30 天),完成后在对话中通知结果 -- **Web 控制台**:记忆管理页面新增「梦境日记」tab,可浏览和查看所有梦境日记 +- **Three-tier memory flow**: Conversation context (short-term) → Daily memory (mid-term) → MEMORY.md (long-term), forming a complete memory lifecycle +- **Auto-distillation**: Runs daily at 23:55, reads the day's daily memory and MEMORY.md, performs deduplication, merging, and pruning via LLM, outputting a refined MEMORY.md +- **Dream diary**: Each distillation generates a narrative-style dream diary recording discoveries and insights, stored in `memory/dreams/` +- **Manual trigger**: `/memory dream [N]` to manually trigger with configurable lookback days (default 3, max 30), with chat notification on completion +- **Web console**: Memory management page now includes a "Dream Diary" tab for browsing all dream diaries -相关文档:[梦境蒸馏](https://docs.cowagent.ai/memory/deep-dream) +Docs: [Deep Dream](https://docs.cowagent.ai/en/memory/deep-dream) -## 🧠 上下文智能压缩 +## 🧠 Smart Context Compression -上下文超出限制时将裁剪的部分通过 LLM 总结后异步注入,保持对话连贯性: +When context exceeds limits, trimmed portions are summarized by LLM and asynchronously injected to maintain conversation continuity: -- **LLM 异步摘要**:裁剪的消息由 LLM 总结为关键信息,同时写入天级记忆文件和注入保留的上下文 -- **多模型兼容**:优先使用主模型进行摘要,兼容 Claude、OpenAI、MiniMax 等不同模型的消息格式要求 +- **Async LLM summary**: Trimmed messages are summarized into key information by LLM, written to daily memory files and injected into retained context +- **Multi-model compatible**: Uses the primary model for summarization, compatible with Claude, OpenAI, MiniMax and other model message format requirements -相关文档:[短期记忆](https://docs.cowagent.ai/memory/context) +Docs: [Short-term Memory](https://docs.cowagent.ai/en/memory/context) -## 💬 Web 控制台升级 +## 💬 Web Console Upgrades -Web 控制台多项功能增强: +Multiple enhancements to the Web console: -- **多会话管理**:支持创建和切换多个独立会话,侧边栏展示会话列表,支持会话标题自动生成和手动编辑 -- **密码保护**:支持为控制台设置登录密码,可通过 `web_console_password` 配置项控制 -- **深度思考**:支持在 Web 端展示模型的思考过程,可通过`enable_thinking` 配置项控制 -- **定时推送**:支持定时任务结果推送到 Web 控制台 -- **消息复制**:AI 回复支持一键复制原始 Markdown 内容 +- **Multi-session management**: Create and switch between independent sessions, sidebar session list with auto-generated and manually editable titles +- **Password protection**: Set a login password via `web_console_password` config option +- **Deep thinking**: Display model thinking process in Web console, controlled by `enable_thinking` config option +- **Scheduled push**: Scheduled task results can be pushed to Web console +- **Message copy**: One-click copy of raw Markdown content from AI reply bubbles +- **Language toggle**: Top language switch button now shows current language for more intuitive interaction -## 🤖 模型相关 +## 🤖 Model Updates -- **视觉识别优化**:图片识别工具优先使用主模型,支持多模型厂商自动降级。相关文档:[视觉工具](https://docs.cowagent.ai/tools/vision) -- **MiniMax 新模型**:新增 MiniMax-M2.7-highspeed 模型和 MiniMax TTS 语音合成支持。Thanks @octo-patch -- **通义千问**:新增 qwen3.6-plus 模型支持 +- **Vision optimization**: Image recognition tool prefers the primary model with automatic multi-provider fallback. Docs: [Vision Tool](https://docs.cowagent.ai/en/tools/vision) +- **MiniMax new model**: Added MiniMax-M2.7-highspeed model and MiniMax TTS voice synthesis support. Thanks @octo-patch +- **Qwen**: Added qwen3.6-plus model support -## 🐛 其他优化与修复 +## 🐛 Other Improvements & Fixes -- **记忆提示词优化**:`MEMORY.md` 默认注入系统提示词,精细化记忆检索和写入的触发条件,增强主动写入能力 -- **系统提示词**:优化系统提示词的风格和语气引导 -- **浏览器工具**:增强隐式交互元素检测 -- **文件发送**:修复通用文件类型(tar.gz、zip 等)未能正确发送的问题。Thanks @6vision -- **macOS 兼容**:修复网络预检超时兼容性问题。Thanks @Moliang Zhou -- **Windows 兼容**:修复 Windows 下 PowerShell 兼容性、进程更新、终端编码等多项问题 -- **Python 3.13+**:修复 Python 3.13 及以上版本缺少 `legacy-cgi` 依赖的问题 -- **个人微信**:更新个人微信通道版本 +- **Memory prompts**: `MEMORY.md` injected into system prompt by default, with refined memory retrieval and write trigger conditions for enhanced proactive writing +- **System prompt**: Optimized system prompt style and tone guidance +- **Browser tool**: Enhanced implicit interactive element detection +- **File send**: Fixed common file types (tar.gz, zip, etc.) not being sent correctly. Thanks @6vision +- **macOS compatibility**: Fixed network pre-check timeout compatibility issue. Thanks @Moliang Zhou +- **Windows compatibility**: Fixed PowerShell compatibility, process updates, terminal encoding and other issues on Windows +- **Python 3.13+**: Fixed missing `legacy-cgi` dependency for Python 3.13+ +- **WeChat channel**: Updated personal WeChat channel version -## 📦 升级方式 +## 📦 Upgrade -源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/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). -**发布日期**:2026.04.14 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.5...master) +**Release Date**: 2026.04.14 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.5...master) diff --git a/docs/releases/v2.0.7.mdx b/docs/releases/v2.0.7.mdx index b4b6e27b..522e5339 100644 --- a/docs/releases/v2.0.7.mdx +++ b/docs/releases/v2.0.7.mdx @@ -1,64 +1,65 @@ --- title: v2.0.7 -description: CowAgent 2.0.7 - 图像生成技能(六厂商自动路由)、新模型支持、知识库增强、Web 控制台优化及多项修复 +description: CowAgent 2.0.7 - Image Generation Skill (6-provider auto-routing), new models, knowledge base enhancements, Web Console improvements and bug fixes --- -## 🎨 图像生成技能 +## 🎨 Image Generation Skill -新增图像生成内置技能,支持文生图、图生图、多图融合,支持 `GPT-Image-2`、`Nano Banana` 等多种模型: +New built-in `image-generation` skill supporting text-to-image, image-to-image, and multi-image fusion across six major providers: -- **自动路由**:支持六种模型厂商自动切换,OpenAI (GPT-Image-2) → Gemini (Nano Banana) → Seedream (火山方舟) → Qwen (百炼) → MiniMax → LinkAI -- **开箱即用**:配置 API Key 即可使用,无需手动指定模型。也支持在对话中指定特定模型 -- **灵活控制**:支持 `quality`(画质)、`size`(分辨率,512/1K~4K)、`aspect_ratio`(宽高比)等参数,各厂商自动适配有效值 -- **图片编辑**:传入已有图片即可进行编辑、风格迁移、多图融合 -- **Skill 级配置**:支持通过 `config.json` 中的 `skills.image-generation.model` 固定默认模型 +- **6-provider auto-routing**: OpenAI (GPT-Image-2) → Gemini (Nano Banana) → Seedream (Volcengine Ark) → Qwen (DashScope) → MiniMax → LinkAI — automatically selects from configured providers in fixed priority order, with automatic fallback on failure +- **Zero model selection**: Just configure an API key and it works — no need to manually specify a model. You can also name a specific model in conversation (e.g. "draw a cat with seedream") +- **Flexible control**: Supports `quality`, `size` (512/1K–4K), and `aspect_ratio` parameters, with each provider automatically mapping to its supported values +- **Image editing**: Pass existing images for editing, style transfer, or multi-image fusion (Seedream supports up to 14 reference images) +- **Skill-level config**: Pin a default model via `skills.image-generation.model` in `config.json` +- **Image lightbox**: All images in the Web console now support click-to-enlarge preview -相关文档:[图像生成技能](https://docs.cowagent.ai/skills/image-generation) +Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generation) -## 🤖 新模型支持 +## 🤖 New Model Support -- **Kimi K2.6**:新增 `kimi-k2.6` 模型支持 -- **Claude Opus 4.7**:新增 `claude-opus-4-7` 模型支持 -- **GLM 5.1**:新增 `glm-5.1` 模型支持 -- **Kimi Coding Plan**:支持 Kimi Coding Plan 模式 -- **自定义模型厂商**:新增[自定义模型](https://docs.cowagent.ai/models/custom)提供方配置,方便接入本地模型及更多厂商 +- **Kimi K2.6**: Added `kimi-k2.6` model support +- **Claude Opus 4.7**: Added `claude-opus-4-7` model support +- **GLM 5.1**: Added `glm-5.1` model support +- **Kimi Coding Plan**: Support for Kimi Coding Plan mode +- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors -## 📚 知识库增强 +## 💬 Web Console Improvements -- **嵌套目录支持**:知识库列表和展示支持多级嵌套目录 -- **根级文件展示**:知识树中显示根目录下的 `index.md`、`log.md` 等文件 -- **空状态统计修复**:排除根级文件对知识库统计的干扰,正确保持空状态 +- **Smart auto-scroll**: Improved chat scroll behaviour — no longer forces scroll to bottom while the user is reading earlier messages +- **Reasoning content cap**: Deep thinking content capped at 4 KB to prevent frontend lag +- **Mobile optimisation**: Session sidebar hidden by default on mobile, with overlay dismiss support +- **Session title fix**: Fixed title auto-generation fallback logic and Bridge reset on config change +- **Image preview dedup**: Fixed duplicate image rendering within the same message -## 🌙 梦境记忆优化 +## 📚 Knowledge Base Enhancements -- **结构化组织**:梦境记忆文件按日期自动归档,目录结构更清晰 -- **定时抖动**:每日定时触发增加随机抖动,避免集群场景下的并发冲突 +- **Nested directory support**: Knowledge base listing and display now support multi-level nested directories +- **Root-level file display**: Show `index.md`, `log.md` and other root-level files in the knowledge tree +- **Empty state stats fix**: Root-level files no longer interfere with empty-state detection -## 🛠 技能系统改进 +## 🌙 Dream Memory Improvements -- **技能管理刷新**:`/skill` 命令执行后自动加载最新技能,确保状态同步 -- **安装来源扩展**:技能安装支持多种来源格式(URL、zip、本地文件等) +- **Structured organisation**: Dream memory files are now auto-archived by date with a cleaner directory structure +- **Schedule jitter**: Daily dream trigger includes random jitter to avoid concurrency conflicts in cluster deployments -## 💬 Web 控制台优化 +## 🛠 Skill System Improvements -- **智能自动滚动**:优化聊天窗口滚动逻辑,用户手动翻阅时不再强制跳到底部 Thanks @colin2060 -- **移动端适配**:侧边栏默认隐藏,支持点击遮罩关闭 -- **图片预览去重**:修复同一消息中图片重复渲染的问题 -- **推理内容截断**:深度思考内容超出阶段,解决前端卡顿问题 -- **会话标题修复**:修复标题自动生成的回退逻辑 +- **Skill manager refresh**: `/skill` commands now automatically refresh the skill manager to keep state in sync +- **Installation sources**: Skill installation supports multiple source formats (URL, zip, local file, etc.) with automatic target directory handling +## 🐛 Other Fixes -## 🐛 其他修复 +- **Gemini fix**: Fixed Gemini tool calls not returning results +- **Agent retry**: Empty-response retries no longer drop `tool_calls` +- **Docker env sync**: Fixed environment variables not syncing after config update in Docker environments +- **Python 3.7 compat**: Deferred `Literal` import for Python 3.7 compatibility +- **Model switch notification**: Fixed bot_type change notification not showing after model switch. Thanks @6vision +- **Config command**: `/config` now supports setting `enable_thinking` +- **Thinking display**: Deep thinking display disabled by default -- **Gemini 修复**:修复 Gemini tool call 不返回结果的问题 -- **Agent 重试**:空响应重试时不再丢弃 tool_calls -- **Docker 环境变量**:修复 Docker 环境下更新配置后环境变量未同步的问题 Thanks @sunboy0523 -- **Python 3.7 兼容**:延迟导入 `Literal` 以兼容 Python 3.7 -- **模型切换通知**:修复切换模型后 bot_type 变更通知未显示的问题。Thanks @6vision -- **配置命令增强**:`/config` 支持设置 `enable_thinking` +## 📦 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). -源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 - -**发布日期**:2026.04.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.6...2.0.7) +**Release Date**: 2026.04.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.6...master) diff --git a/docs/releases/v2.0.8.mdx b/docs/releases/v2.0.8.mdx index ced1b967..3fcc29da 100644 --- a/docs/releases/v2.0.8.mdx +++ b/docs/releases/v2.0.8.mdx @@ -1,63 +1,68 @@ --- title: v2.0.8 -description: CowAgent 2.0.8 - 飞书渠道全面升级(语音、流式打字机、一键扫码接入)、DeepSeek V4 / 百度千帆支持、定时任务工具优化 +description: CowAgent 2.0.8 - Major Feishu channel upgrade (voice, streaming typewriter, one-click QR app creation), DeepSeek V4 / ERNIE 5.0 support, scheduler memory enhancements and multiple fixes --- -## 🪶 飞书渠道全面升级 +## 🪶 Major Feishu Channel Upgrade -### 1. 一键扫码创建飞书应用 +### 1. One-click QR-scan App Creation -不再需要手动到飞书开放平台建应用、填权限和事件订阅。Web 控制台和命令行启动时若未配置 `feishu_app_id`,会自动展示扫码入口,飞书扫码授权后自动创建机器人并回填配置,开箱即用。 +No more manual app setup, permission scopes and event subscriptions in the Feishu Open Platform. When `feishu_app_id` is not configured, both the Web Console and CLI startup flow now show a QR-scan entry — scan with Feishu, authorize, and the bot is created and config is filled back automatically. Out-of-the-box. -相关文档:[飞书渠道](https://docs.cowagent.ai/channels/feishu) +Documentation: [Feishu Channel](https://docs.cowagent.ai/en/channels/feishu) -### 2. 语音消息收发 +### 2. Voice Messages -支持接收用户发送的飞书语音消息并自动转文本,回复也可走 TTS 以语音形式发出。同时优化了中文短语音的识别准确度。 +Receive Feishu voice messages with automatic speech-to-text, and reply in voice via TTS. Recognition accuracy for short Chinese voice messages has been improved. -### 3. 流式打字机回复 +### 3. Streaming Typewriter Replies -接入飞书 CardKit 流式卡片,**默认开启**,体验对齐 Web 端: +Integrated with Feishu CardKit streaming cards, **enabled by default**, matching the Web Console experience: -- 多轮 Agent 场景下中间过场消息与最终回复分卡呈现 -- 针对 DeepSeek 等高频输出模型做了专门优化,速度与 Web 端持平 -- 不支持时自动回退为普通文本回复,无需手动配置 -- 要求飞书客户端 ≥ 7.20 +- Multi-turn agent flows render intermediate updates and the final reply on separate cards +- Tuned for high-throughput models like DeepSeek to keep pace with the Web Console +- Falls back to plain text replies automatically when not supported, no manual config needed +- Requires Feishu client ≥ 7.20 -飞书语音消息收发与流式打字机的基础能力来自社区贡献 #2791 Thanks @yangluxin613 +The voice and streaming building blocks come from a community contribution #2791. Thanks [@yangluxin613](https://github.com/yangluxin613) -## 🤖 新模型支持 +## 🤖 New Model Support -- **DeepSeek V4 系列**:新增 `deepseek-v4-pro` / `deepseek-v4-flash`,并将默认模型切换为 `deepseek-v4-flash` -- **思考模型开关统一**:DeepSeek V4、Qwen3 等思考模型的开关行为对齐到 `enable_thinking` -- **百度千帆模型接入**:新增百度千帆厂商,支持 `ernie-5.0`、`ernie-4.5-turbo-128k` 等模型,并支持图像识别工具,相关文档查看 [百度千帆](https://docs.cowagent.ai/models/qianfan)。#2790 Thanks @jimmyzhuu -- **新增有道翻译**:`translate` 模块新增有道翻译支持 #2797 Thanks @Zmjjeff7 +- **DeepSeek V4 series**: Added `deepseek-v4-pro` / `deepseek-v4-flash`, with `deepseek-v4-flash` set as the new default +- **Unified thinking-mode toggle**: DeepSeek V4, Qwen3 and other thinking-capable models now share the same `enable_thinking` switch +- **ERNIE first-class integration**: New `qianfan` provider supporting `ernie-5.0` (default recommendation), `ernie-x1.1`, `ernie-4.5-turbo-128k`, `ernie-4.5-turbo-32k`. Dedicated `qianfan_api_key` / `qianfan_api_base` settings keep OpenAI config clean; legacy `wenxin` / `wenxin-4` paths are fully preserved. #2790 Thanks [@jimmyzhuu](https://github.com/jimmyzhuu) -## 🛠 OpenAI 客户端重构 + Documentation: [ERNIE](https://docs.cowagent.ai/en/models/qianfan) -- **去 SDK 依赖**:OpenAI Bot 改为原生 HTTP 实现,启动更轻、依赖冲突更少 -- **Web 控制台提示**:模型配置 API Base 输入框加入版本路径占位提示 +## 🌐 Translation Provider -## ⏰ 定时任务记忆增强 +- **Youdao translator**: Added a Youdao provider to the `translate/` module using the v3 SHA-256 signing scheme, with automatic ISO 639-1 language-code mapping (`zh`, `zh-TW`, etc.) #2797 Thanks [@Zmjjeff7](https://github.com/Zmjjeff7) -- **任务结果可被追问**:定时任务的执行结果自动注入到接收方的会话历史中,下一轮对话可直接追问,无需重新交代上下文 Thanks @huangrichao2020 -- **不污染长期记忆**:注入的调度对话不会被纳入每日梦境记忆汇总,避免高频任务把记忆刷满 -- **避免越跑越慢**:调度任务自己的上下文长度自动控制在合理范围内,长期反复执行也不会越积越大、拖慢响应 +## 🛠 OpenAI Client Refactor -## 🔧 工具与安全 +- **Drop SDK dependency**: The OpenAI bot is reimplemented on a native HTTP client — leaner startup, fewer dependency conflicts +- **Web Console hint**: API base inputs in the model config UI now include version-path placeholder hints -- **图像识别模型**:让 `tools.vision.model` 配置真正生效,未配置时自动 fallback #2792 Thanks CNXudiandian -- **Bash 安全确认**:仅对工作区外的破坏性删除做二次确认,工作区内常规操作不再打扰 +## ⏰ Scheduler Memory Enhancements -## 🐛 其他修复 +- **Follow-up on task results**: Scheduled task results are automatically injected into the receiver's session history — the next turn can ask follow-up questions without re-stating context. Thanks [@huangrichao2020](https://github.com/huangrichao2020) +- **No long-term memory pollution**: Scheduler-injected pairs are excluded from the daily memory flush so high-frequency tasks don't drown the memory store +- **Bounded scheduler context**: The scheduler's own session context is automatically capped, so long-running periodic tasks don't accumulate state and slow down replies -- 修复 Deep Dream 在多实例场景下重复触发 -- 修复 DeepSeek 多轮对话中部分历史轮次缺失 `reasoning_content` +## 🔧 Tools and Safety -## 📦 升级方式 +- **Vision model selection**: `tools.vision.model` config now actually takes effect, with automatic fallback when unconfigured #2792 +- **Bash safety prompt**: The destructive-deletion confirm prompt is now scoped to paths outside the workspace — routine in-workspace operations are no longer interrupted -源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 +## 🐛 Other Fixes -> ⚠️ 飞书一键创建应用依赖 `lark-oapi>=1.5.5`,`cow update` 会自动拉取;手动部署请确保依赖已更新。 +- Fixed Deep Dream firing duplicate runs in multi-instance setups +- Fixed missing `reasoning_content` on some history turns in DeepSeek multi-turn conversations -**发布日期**:2026.05.06 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.7...2.0.8) +## 📦 Upgrade + +Source-code deployments can run `cow update` or `./run.sh update` for a one-click upgrade, or pull the latest code and restart manually. See [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade) for details. + +> ⚠️ One-click Feishu app creation requires `lark-oapi>=1.5.5`. `cow update` pulls it automatically; manual deployments must update dependencies. + +**Release Date**: 2026.05.05 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.7...2.0.8) diff --git a/docs/releases/v2.0.9.mdx b/docs/releases/v2.0.9.mdx index 957e0ced..ccae36fc 100644 --- a/docs/releases/v2.0.9.mdx +++ b/docs/releases/v2.0.9.mdx @@ -1,65 +1,65 @@ --- title: v2.0.9 -description: CowAgent 2.0.9 - 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入 +description: CowAgent 2.0.9 - Web Console model management, MCP protocol support, browser persistent login, new models and deployment hardening --- -## 🖥️ 新增模型管理 +## 🖥️ Model Management Console -Web 控制台新增「模型」页面,按 **模型厂商 + 模型能力** 进行管理,支持对话、图像、语音、向量模型和搜索能力的配置: +The Web Console adds a new **Models** page that organizes everything by **provider × capability**, covering chat, image, voice, embedding and search models in one place: -- **多厂商配置**:所有厂商的 API Key / API Base 在顶部统一维护,下方所有能力立即生效,无需重复填写 -- **图像模型**:图像理解与图像生成均可独立选择厂商和模型,未指定时跟随主模型自动选择 -- **语音模型**:语音识别和合成可独立配置,新增千问、智谱 ASR/TTS 模型 -- **向量模型**:支持配置 Embedding 模型(用于记忆及知识库检索),新增支持 OpenAI、通义、豆包、智谱等;切换模型后需执行 `/memory rebuild-index` 在线重建索引 -- **搜索能力**:联网搜索能力升级,支持博查、百度、智谱等多个厂商,自动模式下 Agent 可综合多来源搜索结果进行深度研究 +- **Per-provider configuration**: Each provider's API Key / API Base is configured once at the top, and every capability below picks it up automatically — no more re-entering credentials +- **Image models**: Image understanding and image generation can each pick their own provider and model independently; falls back to the main model when unspecified +- **Voice models**: ASR (speech-to-text) and TTS (text-to-speech) can be configured independently, with new Qwen and Zhipu ASR/TTS models added +- **Embedding models**: Configurable embedding models (used for memory and knowledge-base retrieval), with new support for OpenAI, Tongyi, Doubao, Zhipu and others; run `/memory rebuild-index` after switching to rebuild the index online +- **Search capability**: Web search has been upgraded to support Bocha, Baidu, Zhipu and more providers — in auto mode the agent can synthesize results from multiple sources for deeper research -相关文档:[模型概览](https://docs.cowagent.ai/models) +Documentation: [Models Overview](https://docs.cowagent.ai/en/models) -20260522113305 +20260522113305 -## 🧩 MCP 协议支持 +## 🧩 MCP Protocol Support -支持 **MCP(Model Context Protocol)** 协议,从固定工具集扩展为开放可插拔的工具生态,任何兼容 MCP 协议的服务均可作为工具直接接入 Agent。 +Adds support for **MCP (Model Context Protocol)**, expanding from a fixed built-in toolset to an open, pluggable tool ecosystem — any MCP-compatible service can be plugged in directly as an agent tool. -- 原生 JSON-RPC 实现,零额外依赖,同时支持 `stdio` 和 `sse` 两种传输 -- 兼容 Claude Desktop / Cursor 等主流风格的 `mcpServers` 配置,优先读取 `~/cow/mcp.json` +- Native JSON-RPC implementation, zero extra dependencies, supports both `stdio` and `sse` transports +- Compatible with the `mcpServers` configuration style used by Claude Desktop / Cursor, reads `~/cow/mcp.json` by default -相关文档:[MCP 工具](https://docs.cowagent.ai/tools/mcp)。Thanks @yangluxin613 (#2801) +Documentation: [MCP Tools](https://docs.cowagent.ai/en/tools/mcp). Thanks [@yangluxin613](https://github.com/yangluxin613) (#2801) -## 🌐 浏览器登录态持久化 +## 🌐 Browser Persistent Login -针对需要登录、有反爬机制的网站,浏览器工具支持登录一次后长期复用登录态,并允许接入用户自己的真实 Chrome 以通过指纹检测: +For sites that require login or have anti-bot protection, the browser tool can now persist a login session for long-term reuse, and supports attaching to your real Chrome browser to bypass fingerprint detection: -- **持久化用户配置(默认)**:默认使用 `~/.cow/browser_profile` 作为浏览器用户目录,登录一次后下次自动复用登录态 -- **CDP 模式**:通过 `tools.browser.cdp_endpoint` 接管真实 Chrome 浏览器,享有完整浏览器权限 +- **Persistent user profile (default)**: Uses `~/.cow/browser_profile` as the browser user data dir by default; once logged in, sessions are reused automatically on subsequent runs +- **CDP mode**: Configure `tools.browser.cdp_endpoint` to take over a real Chrome instance with full browser permissions -相关文档:[浏览器工具](https://docs.cowagent.ai/tools/browser)。Thanks @leafmove (#2809) +Documentation: [Browser Tool](https://docs.cowagent.ai/en/tools/browser). Thanks [@leafmove](https://github.com/leafmove) (#2809) -## 🤖 模型新增与优化 +## 🤖 New Models and Improvements -- **模型新增**:`gpt-5.5`、`gemini-3.5-flash`、`qwen3.7-max`、`ernie-5.1` -- **模型优化**:DeepSeek V4 支持 `reasoning_effort` 思考深度参数;修复 MiMo 等思考模型通过 OpenAI 兼容协议接入的问题 +- **New models**: `gpt-5.5`, `gemini-3.5-flash`, `qwen3.7-max`, `ernie-5.1` +- **Improvements**: DeepSeek V4 supports the `reasoning_effort` thinking-depth parameter; fixed thinking models like MiMo failing to connect via the OpenAI-compatible protocol -## 🔒 部署与安全 +## 🔒 Deployment & Security -- **默认本机访问**:Web 控制台 `web_host` 配置默认绑定 `127.0.0.1`,服务器部署时可手动设置为 `0.0.0.0` 并设置密码。Thanks @August829、@yidaozhongqing、@YLChen-007、@icysun -- **前端资源完全本地化**:第三方 CSS / JS 全部本地分发,离线 / 内网环境也能正常加载控制台。Thanks @gitlayzer (#2816) +- **Bind to localhost by default**: The Web Console `web_host` now defaults to `127.0.0.1`; for server deployments, set it to `0.0.0.0` and configure a password manually. Thanks @August829, @yidaozhongqing, @YLChen-007, @icysun +- **Fully bundled frontend assets**: All third-party CSS / JS are now served locally — the console works offline and on intranet deployments. Thanks [@gitlayzer](https://github.com/gitlayzer) (#2816) -## 🛠 体验优化与修复 +## 🛠 UX Improvements & Fixes -- **TTS 适配更多通道**:Web对话、个人微信、飞书、钉钉、企微智能机器人均已支持回复语音,详情查看 [通道概览](https://docs.cowagent.ai/channels) -- **日志面板增强**:根据日志等级差异化高亮展示、支持根据等级筛选。Thanks @yangluxin613 (#2807) -- **Web 控制台自动启动**:程序启动后自动打开 Web 控制台。Thanks @yangluxin613 (#2804) -- **Ctrl+C 干净退出**:不再打印一长串 `KeyboardInterrupt` 堆栈。Thanks @yangluxin613 (#2806) -- **文件夹上传**:Web 端支持目录上传,路径校验适配 Windows。Thanks @TryToMakeUsBetter (#2814) -- 修复定时任务在某些情况下重复执行的问题。Thanks @CNXudiandian (#2820) -- 修复定时任务带时区时单次任务不触发的问题。Thanks @AethericSpace -- 修复执行失败的工具调用在页面刷新后不显示的问题。Thanks @a1094174619 (#2822) -- 修复企微机器人消息中包含非法控制字符导致投递失败的问题。Thanks @Jacques-Zhao (#2810) +- **TTS rolls out to more channels**: Web Console, Personal WeChat, Feishu, DingTalk and WeCom Smart Bot all support voice replies — see the [Channels Overview](https://docs.cowagent.ai/en/channels) +- **Log panel enhancements**: Differentiated highlighting by log level, with level-based filtering. Thanks [@yangluxin613](https://github.com/yangluxin613) (#2807) +- **Auto-launch Web Console**: The Web Console now opens automatically on startup. Thanks [@yangluxin613](https://github.com/yangluxin613) (#2804) +- **Clean Ctrl+C exit**: No more long `KeyboardInterrupt` stack traces. Thanks [@yangluxin613](https://github.com/yangluxin613) (#2806) +- **Folder upload**: Web Console supports directory uploads, with path validation adapted for Windows. Thanks [@TryToMakeUsBetter](https://github.com/TryToMakeUsBetter) (#2814) +- Fixed scheduled tasks executing duplicates under certain conditions. Thanks [@CNXudiandian](https://github.com/CNXudiandian) (#2820) +- Fixed one-shot scheduled tasks with timezone not firing. Thanks @AethericSpace +- Fixed failed tool calls not being displayed after page refresh. Thanks [@a1094174619](https://github.com/a1094174619) (#2822) +- Fixed WeCom bot messages with illegal control characters failing to be delivered. Thanks [@Jacques-Zhao](https://github.com/Jacques-Zhao) (#2810) -## 📦 升级方式 +## 📦 Upgrade -源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 +Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade) for details. -**发布日期**:2026.05.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.8...2.0.9) +**Release Date**: 2026.05.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.8...2.0.9) diff --git a/docs/skills/create.mdx b/docs/skills/create.mdx index 45cef149..cfdbde02 100644 --- a/docs/skills/create.mdx +++ b/docs/skills/create.mdx @@ -1,32 +1,32 @@ --- -title: 创造技能 -description: 通过对话创建自定义技能 +title: Create Skills +description: Create custom skills through conversation --- -CowAgent 内置了 Skill Creator,可以通过自然语言对话快速创建、安装或更新技能。 +CowAgent includes a built-in Skill Creator that lets you quickly create, install, or update skills through natural language conversation. -## 使用方式 +## Usage -直接在对话中描述你想要的技能,Agent 会自动完成创建: +Simply describe the skill you want in a conversation, and the Agent will handle the creation: -- 将工作流程固化为技能:"帮我把这个部署流程创建为一个技能" -- 对接第三方 API:"根据这个接口文档创建一个技能" -- 安装远程技能:"帮我安装 xxx 技能" +- 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. 告诉 Agent 你想创建的技能功能 -2. Agent 自动生成 `SKILL.md` 说明文件和运行脚本 -3. 技能保存到工作空间的 `~/cow/skills/` 目录 -4. 后续对话中 Agent 会自动识别并使用该技能 +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 格式 +## SKILL.md Format -创建的技能遵循标准的 SKILL.md 格式: +Created skills follow the standard SKILL.md format: ```markdown --- @@ -45,14 +45,14 @@ metadata: Detailed instructions... ``` -| 字段 | 说明 | +| Field | Description | | --- | --- | -| `name` | 技能名称,需与目录名一致 | -| `description` | 技能描述,Agent 据此决定是否调用 | -| `metadata.requires.bins` | 依赖的系统命令 | -| `metadata.requires.env` | 依赖的环境变量 | -| `metadata.always` | 是否始终加载(默认 false) | +| `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) | - 详细开发文档可参考 [Skill Creator 说明](https://github.com/zhayujie/CowAgent/blob/master/skills/skill-creator/SKILL.md)。 + See the [Skill Creator documentation](https://github.com/zhayujie/CowAgent/blob/master/skills/skill-creator/SKILL.md) for details. diff --git a/docs/skills/hub.mdx b/docs/skills/hub.mdx index 843dd4d4..e88b4b2f 100644 --- a/docs/skills/hub.mdx +++ b/docs/skills/hub.mdx @@ -1,65 +1,65 @@ --- -title: 技能广场 -description: 浏览、搜索和安装 AI Agent 技能 +title: Skill Hub +description: Browse, search, and install AI Agent skills --- -[Cow Skill Hub](https://skills.cowagent.ai/) 是开源的 AI Agent 技能广场,汇集了官方推荐、社区贡献和第三方平台(GitHub、ClawHub 等)的技能。 +[Cow Skill Hub](https://skills.cowagent.ai/) is an open-source skill marketplace for AI Agents, aggregating official picks, community contributions, and third-party skills from GitHub, ClawHub, and beyond. -开源仓库:[github.com/zhayujie/cow-skill-hub](https://github.com/zhayujie/cow-skill-hub) +Source code: [github.com/zhayujie/cow-skill-hub](https://github.com/zhayujie/cow-skill-hub) -## 功能 +## Features -- **浏览技能**:按类别(推荐 / 社区 / 第三方)和标签筛选 -- **搜索技能**:按名称或描述搜索 -- **查看详情**:查看技能文档、文件内容、安装命令和依赖的环境变量 -- **一键安装**:复制安装命令即可在 CowAgent 中使用 +- **Browse skills** — filter by category (Featured / Community / Third-party) and tags +- **Search skills** — find skills by name or description +- **View details** — read the skill manifest, file contents, install command, and required environment variables +- **One-click install** — copy the install command and run it in CowAgent -## 安装技能 +## Installing a skill -在对话中或终端中执行安装命令: +Run the install command in chat or in your terminal: -```text 对话 +```text Chat /skill install ``` -```bash 终端 +```bash Terminal cow skill install ``` -也可以在对话中浏览技能广场: +You can also browse the marketplace directly from chat: ```text /skill list --remote -/skill search <关键词> +/skill search ``` -除了在列表中展示的精选技能,还可以通过 **CLI命令 + Skill Hub** 安装各种第三方技能(**GitHub、ClawHub、LinkAI、URL** 等)参考 [安装技能](/skills/install)。 +Beyond the curated list, you can install third-party skills from **GitHub, ClawHub, LinkAI, or any URL** via the CLI. See [Installing skills](/skills/install) for details. -## 贡献技能 +## Contributing a skill -欢迎向技能广场提交你的技能: +To submit your own skill: -1. 访问 [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) -2. 使用 GitHub 或 Google 账号登录 -3. 上传包含 `SKILL.md` 的文件夹或 zip 包 -4. 自动解析技能名称、显示名称和描述,可按需修改 -5. 提交后将经过安全检查和审核后发布 +1. Visit [skills.cowagent.ai/submit](https://skills.cowagent.ai/submit) +2. Sign in with GitHub or Google +3. Upload a folder or zip file containing `SKILL.md` +4. Skill name, display name, and description are auto-detected — adjust as needed +5. Submit for review; skills go live after security and quality checks -技能文件结构: +Skill file layout: ``` your-skill/ -├── SKILL.md # 必须,放在根目录 -├── scripts/ # 可选,运行脚本 -└── resources/ # 可选,其他资源 +├── SKILL.md # required, in the root +├── scripts/ # optional, runtime scripts +└── resources/ # optional, additional assets ``` - 技能基于 `SKILL.md` 文件构建,你也可以在技能详情页下载 SKILL.md,用于任何支持自定义指令的 Agent(如 OpenClaw、Cursor、Claude Code 等)。 + Skills are built around the `SKILL.md` manifest. You can also download `SKILL.md` from a skill's detail page and use it with any Agent that supports custom instructions (OpenClaw, Cursor, Claude Code, and more). diff --git a/docs/skills/image-generation.mdx b/docs/skills/image-generation.mdx index 288fd656..e8de3e0f 100644 --- a/docs/skills/image-generation.mdx +++ b/docs/skills/image-generation.mdx @@ -1,30 +1,30 @@ --- -title: image-generation - 图像生成 -description: 文生图 / 图生图 / 多图融合,支持多家厂商自动路由与回退 +title: image-generation +description: Text-to-image / image-to-image / multi-image fusion with automatic multi-provider routing and fallback --- -通用的图像生成与编辑技能,支持 OpenAI、Gemini、Seedream(火山方舟)、Qwen(百炼)、MiniMax、LinkAI 共六家厂商。配好任意一家的 Key 即可使用,配多家可享受自动回退。 +A general-purpose image generation and editing skill supporting six providers: OpenAI, Gemini, Seedream (Volcengine Ark), Qwen (DashScope), MiniMax, and LinkAI. Configure any one provider's key to start using it; configure multiple to enable automatic fallback. -## 支持的模型 +## Supported Models -| 厂商 | 模型 / 别名 | 特点 | +| Provider | Models / Aliases | Notes | | --- | --- | --- | -| OpenAI | `gpt-image-2`、`gpt-image-1` | 通用文生图,高质量,支持 `quality` 控制画质 | -| Gemini Nano Banana | `nano-banana-2`、`nano-banana-pro`、`nano-banana` | 对应 `gemini-3.1-flash`、`gemini-3-pro`、`gemini-2.5-flash` 的图像版本 | -| Seedream(火山方舟) | `seedream-5.0-lite`、`seedream-4.5` | 原生 2K–4K,最多 14 张图融合 | -| Qwen(百炼) | `qwen-image-2.0`、`qwen-image-2.0-pro` | 擅长中文排版和图文融合 | -| MiniMax | `image-01` | 简单快速 | -| LinkAI | 任意模型 | 统一网关,作为兜底 | +| OpenAI | `gpt-image-2`, `gpt-image-1` | General-purpose, high quality, supports `quality` parameter | +| Gemini Nano Banana | `nano-banana-2`, `nano-banana-pro`, `nano-banana` | Corresponds to the image variants of `gemini-3.1-flash`, `gemini-3-pro`, `gemini-2.5-flash` | +| Seedream (Volcengine Ark) | `seedream-5.0-lite`, `seedream-4.5` | Native 2K–4K, up to 14 reference images for fusion | +| Qwen (DashScope) | `qwen-image-2.0`, `qwen-image-2.0-pro` | Strong with Chinese text rendering and text-image layouts | +| MiniMax | `image-01` | Fast and simple | +| LinkAI | Any model | Universal gateway, used as fallback | -## 模型选择 +## Model Selection -默认走「自动路由 + 失败回退」: +By default, "auto routing + automatic fallback" is used: -1. 按 `OpenAI → Gemini → Seedream → Qwen → MiniMax → LinkAI` 顺序选第一个已配置的厂商 -2. 遇到 401、模型未开通、网络异常等错误时,自动切到下一家 -3. 用户在对话里指定模型时(如「用 seedream 画一只猫」),对应厂商会被提到最前优先尝试 +1. Pick the first configured provider in the order `OpenAI → Gemini → Seedream → Qwen → MiniMax → LinkAI` +2. On errors such as 401, model not enabled, or network issues, automatically switch to the next provider +3. If the user specifies a model in the conversation (e.g. "use seedream to draw a cat"), the corresponding provider is promoted to the front -如需固定使用某个模型: +To pin a specific model: ```json { @@ -36,63 +36,63 @@ description: 文生图 / 图生图 / 多图融合,支持多家厂商自动路 } ``` -## 配置 API Key +## Configuring API Keys - 推荐通过 [Web 控制台](/channels/web) 的「模型管理」页面配置,配好的对话模型 Key 会被图像生成技能自动复用,无需重复配置。也可手动编辑配置文件或在对话中通过 `env_config` 工具临时设置。 + It is recommended to configure providers from the "Model Management" page in the [Web console](/channels/web). Chat model keys configured there are automatically reused by the image generation skill — no need to set them twice. You can also edit the configuration file manually or temporarily set keys in a conversation using the `env_config` tool. -凭证统一复用主模型厂商的 Key: +Credentials are shared with the main model providers: -| 字段 | 对应厂商 | +| Field | Provider | | --- | --- | | `openai_api_key` | OpenAI | | `gemini_api_key` | Gemini | -| `ark_api_key` | 火山方舟(Seedream) | -| `dashscope_api_key` | 阿里百炼(Qwen) | +| `ark_api_key` | Volcengine Ark (Seedream) | +| `dashscope_api_key` | Alibaba DashScope (Qwen) | | `minimax_api_key` | MiniMax | | `linkai_api_key` | LinkAI | -## 开启和关闭 +## Enabling and Disabling -技能会根据 API Key 自动调整状态: +The skill automatically adjusts its status based on API keys: -- **已配置 Key**:Agent 收到画图请求时直接调用 -- **未配置 Key**:技能仍会出现在上下文中(标记为「需要配置」),Agent 会引导用户去配 Key +- **Key configured**: the Agent calls the skill directly when it receives a drawing request +- **Key not configured**: the skill still appears in context (marked as "needs configuration") — the Agent will guide the user to set up a key -如需手动控制: +To control it manually: ```text -/skill disable image-generation # 关闭 -/skill enable image-generation # 重新开启 +/skill disable image-generation # Disable +/skill enable image-generation # Re-enable ``` -终端等价命令:`cow skill disable image-generation` / `cow skill enable image-generation`。 +Equivalent terminal commands: `cow skill disable image-generation` / `cow skill enable image-generation`. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 默认 | 说明 | +| Parameter | Type | Required | Default | Description | | --- | --- | --- | --- | --- | -| `prompt` | string | 是 | — | 图像描述 | -| `image_url` | string / list | 否 | null | 编辑用的输入图,本地路径或 URL;传列表为多图融合 | -| `quality` | string | 否 | auto | `low` / `medium` / `high`,仅部分厂商支持 | -| `size` | string | 否 | auto | `512` / `1K` / `2K` / `3K` / `4K`,或像素值如 `1024x1024` | -| `aspect_ratio` | string | 否 | null | `1:1` / `3:2` / `2:3` / `16:9` / `9:16` / `21:9`;Gemini 还支持 `1:4` / `4:1` / `1:8` / `8:1` | +| `prompt` | string | Yes | — | Image description | +| `image_url` | string / list | No | null | Input image for editing — local path or URL; pass a list for multi-image fusion | +| `quality` | string | No | auto | `low` / `medium` / `high`, supported only by some providers | +| `size` | string | No | auto | `512` / `1K` / `2K` / `3K` / `4K`, or pixel value like `1024x1024` | +| `aspect_ratio` | string | No | null | `1:1` / `3:2` / `2:3` / `16:9` / `9:16` / `21:9`; Gemini also supports `1:4` / `4:1` / `1:8` / `8:1` | - **质量越高、分辨率越大,耗时和成本越高。** 日常对话用默认(`auto`)或 `quality=low` + `size=1K` 即可,约 20 秒出图;做海报或明确要高清时再上 `high` + `2K/4K`,可能需要 1–5 分钟。 + **Higher quality and larger size cost more and take longer.** For everyday conversations, use the defaults (`auto`) or `quality=low` + `size=1K` — about 20 seconds per image. For posters or when high resolution is explicitly requested, use `quality=high` + `size=2K/4K` — may take 1–5 minutes. -## 常见用法 +## Common Use Cases -- **文生图**:根据描述生成插画、海报、图标、头像、分镜图等 -- **图生图**:在已有图片上改风格、换元素、加装饰、加文字等 -- **多图融合**:把多张参考图合成一张(换装、角色合影等) +- **Text-to-image**: generate illustrations, posters, icons, avatars, storyboards, etc. from a description +- **Image-to-image**: change styles, swap elements, add decorations or text on an existing image +- **Multi-image fusion**: combine multiple reference images into one (outfit swaps, character group photos, etc.) -- bash 超时建议设 600 秒:单厂商 HTTP 超时 300 秒,脚本可能依次尝试多家 -- 输入图片自动压缩到 4MB 以内、最长边不超过 4096px -- Gemini / Seedream / Qwen / MiniMax 不支持 `quality` 参数 -- Seedream 默认出 2K 图;`seedream-5.0-lite` 支持到 3K,`seedream-4.5` 支持到 4K +- Bash timeout should be set to 600 seconds: each provider has a 300-second HTTP timeout, and the script may try multiple providers sequentially +- Input images are automatically compressed to ≤ 4 MB with the longest edge ≤ 4096 px +- Gemini / Seedream / Qwen / MiniMax do not support the `quality` parameter +- Seedream defaults to 2K; `seedream-5.0-lite` supports up to 3K; `seedream-4.5` supports up to 4K diff --git a/docs/skills/index.mdx b/docs/skills/index.mdx index 795cebc0..a7058c98 100644 --- a/docs/skills/index.mdx +++ b/docs/skills/index.mdx @@ -1,33 +1,32 @@ --- -title: 技能概览 -description: CowAgent 技能系统介绍 +title: Skills Overview +description: CowAgent skills system introduction --- -技能(Skill)为 Agent 提供无限的扩展性。每个 Skill 由说明文件(`SKILL.md`)、运行脚本(可选)、资源(可选)组成,描述如何完成特定类型的任务。 +Skills provide infinite extensibility for the Agent. Each Skill consists of a description file (`SKILL.md`), execution scripts (optional), and resources (optional), describing how to accomplish specific types of tasks. -Skill 与 Tool 的区别:Tool 是由代码实现的原子操作(如读写文件、执行命令),Skill 则是基于说明文件的高级工作流,可以组合调用多个 Tool 来完成复杂任务。 +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. -## 获取技能 +## Getting Skills -CowAgent 提供多种方式获取技能: +CowAgent offers multiple ways to acquire skills: -- [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 自动创建技能 +- **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 -详细安装方式参考 [安装技能](/skills/install) 和 [技能管理命令](/cli/skill)。也可以通过对话 [创建技能](/skills/create),或向 [Skill Hub](https://skills.cowagent.ai/submit) 贡献你的技能。 +See [Install Skills](/skills/install) and [Skill Management Commands](/cli/skill) for details. You can also [create skills](/skills/create) through conversation. -## 技能加载优先级 +## Skill Loading Priority -1. **工作空间技能**(最高):`~/cow/skills/` -2. **项目内置技能**(最低):`skills/` +1. **Workspace skills** (highest): `~/cow/skills/` +2. **Project built-in skills** (lowest): `skills/` -同名技能按优先级覆盖。 +Skills with the same name are overridden by priority. -## 技能文件结构 +## Skill File Structure ``` skills/ @@ -37,7 +36,7 @@ skills/ │ └── resources/ # Additional resources (optional) ``` -### SKILL.md 格式 +### SKILL.md Format ```markdown --- @@ -56,10 +55,10 @@ metadata: Detailed instructions... ``` -| 字段 | 说明 | +| Field | Description | | --- | --- | -| `name` | 技能名称,需与目录名一致 | -| `description` | 技能描述,Agent 据此决定是否调用 | -| `metadata.requires.bins` | 依赖的系统命令 | -| `metadata.requires.env` | 依赖的环境变量 | -| `metadata.always` | 是否始终加载(默认 false) | +| `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) | diff --git a/docs/skills/install.mdx b/docs/skills/install.mdx index 84395d95..0457f7c3 100644 --- a/docs/skills/install.mdx +++ b/docs/skills/install.mdx @@ -1,66 +1,65 @@ --- -title: 安装技能 -description: 通过命令一键安装来自多种来源的技能 +title: Install Skills +description: Install skills from multiple sources with a single command --- -CowAgent 支持通过统一的 `install` 命令安装来自 [Cow 技能广场](https://skills.cowagent.ai/)、GitHub、ClawHub、LinkAI 以及任意 URL 上的技能。在对话中使用 `/skill install`,在终端中使用 `cow skill install`。 +CowAgent supports installing skills from [Cow Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub, LinkAI, and any URL via a unified `install` command. Use `/skill install` in chat or `cow skill install` in the terminal. -## 从Cow技能广场安装 +## From the Skill Hub -访问 [skills.cowagent.ai](https://skills.cowagent.ai/) 浏览所有可用技能,找到想要的技能后直接安装,例如: +Browse all available skills at [skills.cowagent.ai](https://skills.cowagent.ai/) and install by name: ```text /skill list --remote /skill install pptx ``` -## 从 GitHub 安装 +## From GitHub -> Github上的所有技能都可以直接安装,支持仓库级批量安装和指定子目录安装,例如: +Any GitHub-hosted skill can be installed directly. Supports both repository-level batch install and subdirectory-level single install: ```text /skill install larksuite/cli /skill install https://github.com/larksuite/cli/tree/main/skills/lark-im ``` -## 从 ClawHub 安装 - -[ClawHub](https://clawhub.ai/) 上的所有技能 (4w+个) 都可以一键安装,例如: +## From ClawHub +All [ClawHub](https://clawhub.ai/) skills (40k+) can be installed with a single command: ```text /skill install clawhub: ``` -## 从 LinkAI 安装 +## From LinkAI -[LinkAI](https://link-ai.tech/console) 上的所有公开资源 (1w+个应用/工作流/插件) ,以及自己创建的资源 (应用/工作流/知识库/数据库/插件) 都可以通过命令一键安装: +All public resources on [LinkAI](https://link-ai.tech/console) (10k+ apps / workflows / plugins), as well as your own resources (apps, workflows, knowledge bases, databases, plugins), can be installed via: ```text /skill install linkai: ``` -> LinkAI平台上创建的所有应用、工作流、知识库、数据库、插件都有唯一的code,可在[控制台](https://link-ai.tech/console)各资源页面中进行获取并填写到命令中 +> Every resource created on the LinkAI platform has a unique `code`. Find it on each resource's page in the [console](https://link-ai.tech/console). -## 从 URL 安装 +## From URL -支持 zip 压缩包和 SKILL.md 文件链接: +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 # 查看已安装技能 -/skill info pptx # 查看技能详情 -/skill enable pptx # 启用技能 -/skill disable pptx # 禁用技能 -/skill uninstall pptx # 卸载技能 +/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 ``` - 以上所有命令在终端中使用时,将 `/skill` 替换为 `cow skill` 即可。完整命令说明参考 [技能管理命令](/cli/skill)。 + All commands above work in the terminal by replacing `/skill` with `cow skill`. See [Skill Management Commands](/cli/skill) for full documentation. diff --git a/docs/skills/knowledge-wiki.mdx b/docs/skills/knowledge-wiki.mdx index 40b4d298..14ae9c90 100644 --- a/docs/skills/knowledge-wiki.mdx +++ b/docs/skills/knowledge-wiki.mdx @@ -1,112 +1,112 @@ --- -title: knowledge-wiki - 知识库 -description: 维护本地结构化知识库,自动归档、分类和交叉引用 +title: knowledge-wiki +description: Maintain a local structured knowledge base with automatic archiving, categorisation, and cross-referencing --- -帮你把对话中产生的资料、灵感和零散笔记整理成结构化的本地知识库,自动维护索引和页面之间的交叉引用。 +Organises notes, insights, and reference materials from your conversations into a structured local knowledge base, automatically maintaining an index and cross-references between pages. -`knowledge-wiki` 在工作空间下维护一个 `knowledge/` 目录,相当于 Agent 的「外脑」。技能设置了 `always: true`,会**常驻上下文**,不需要任何外部依赖。 +`knowledge-wiki` maintains a `knowledge/` directory in your workspace — essentially the Agent's "second brain". The skill is marked `always: true`, so it is **always loaded** and requires no external dependencies. -## 什么时候会触发 +## When It Triggers -- 你分享了一篇文章、一份文档或一个 URL,想要沉淀下来 -- 聊天过程中聊出了值得长期保留的结论 -- 你想查一下之前积累过的知识 +- You share an article, document, or URL that you want to keep for future reference +- A conversation produces conclusions worth retaining long-term +- You want to look up something you accumulated earlier -## 目录结构 +## Directory Structure ``` knowledge/ -├── index.md # 全局索引(必须维护) -├── log.md # 操作日志(只追加) -└── / # 分类子目录(按内容自由分组) - └── .md # 知识页(文件名用小写加中划线) +├── index.md # Global index (must be maintained) +├── log.md # Operation log (append-only) +└── / # Category subdirectories (grouped by content) + └── .md # Knowledge page (lowercase-hyphenated filename) ``` -## 三个核心操作 +## Three Core Operations -### 1. 收录(Ingest) +### 1. Ingest -你分享了一段资料时,Agent 会: +When you share some material, the Agent will: -1. 读懂原文,提取关键信息 -2. 按内容决定放到哪个分类下——先看 `index.md` 里有没有合适的分类,没有就新建一个 -3. 生成知识页 `knowledge//.md` -4. 更新索引 `index.md` 和日志 `log.md` +1. Read and understand the original content, extracting key information +2. Decide which category it belongs to — check `index.md` first; create a new category if none fits +3. Generate a knowledge page at `knowledge//.md` +4. Update the index `index.md` and the log `log.md` -### 2. 综合(Synthesize) +### 2. Synthesise -聊天中产生了新的结论或洞见时: +When a conversation produces new conclusions or insights: -1. 在合适的分类下创建新知识页 -2. 给相关的已有页面加上互相指向的链接 -3. 更新索引和日志 +1. Create a new knowledge page under an appropriate category +2. Add cross-links to and from related existing pages +3. Update the index and log -### 3. 查询(Query) +### 3. Query -你问到以前积累的知识时: +When you ask about previously accumulated knowledge: -1. 先从 `index.md` 里找可能相关的页面 -2. 用 `read` 工具打开具体页面 -3. 需要时再用 `memory_search` 补充检索 -4. 回答里会带上知识页的链接,方便你点过去看原文 +1. Search `index.md` for potentially relevant pages +2. Open specific pages with the `read` tool +3. Supplement with `memory_search` if needed +4. Include links to knowledge pages in the answer so you can click through to the source -## 知识页怎么写 +## Page Format ```markdown -# 页面标题 +# Page Title -> Source: <来源 URL 或简要说明> +> Source: -正文内容。页面之间用相对路径链接: -[相关页](../category/related-page.md) +Body content. Link between pages using relative paths: +[Related Page](../category/related-page.md) -## 要点 +## Key Points - ... -## 相关页面 +## Related Pages -- [页面 A](../category/page-a.md) — 为什么相关 +- [Page A](../category/page-a.md) — why it's related ``` -- `> Source:` 用来记录这条知识的来源。有明确来源时一定要写 -- 交叉引用很重要:创建或更新某页时,记得也去关联页面里补上反向链接 -- **只链接已经存在的页面**。如果某个概念值得单独成页,先建好再加链接 +- `> Source:` records where this knowledge came from. Always include it when there is a clear source +- Cross-references are important: when creating or updating a page, remember to add back-links in the related pages too +- **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` 采用扁平列表,按分类分组,每个知识页占一行: +`knowledge/index.md` uses a flat list grouped by category, one knowledge page per line: ```markdown # Knowledge Index -## 分类 A -- [页面标题](category-a/page-slug.md) — 一句话摘要 +## Category A +- [Page Title](category-a/page-slug.md) — one-line summary -## 分类 B -- [页面标题](category-b/page-slug.md) — 一句话摘要 +## Category B +- [Page Title](category-b/page-slug.md) — one-line summary ``` -不用表格,不加 emoji。分类怎么起名、怎么组织都可以灵活调整。 +No tables, no emojis. Category names and organisation can be adjusted freely. -## 日志格式 +## Log Format -`knowledge/log.md` 只追加、不修改,最新的写在最下面: +`knowledge/log.md` is append-only — newest entries go at the bottom: ```markdown -## [YYYY-MM-DD] ingest | 页面标题 -## [YYYY-MM-DD] synthesize | 页面标题 +## [YYYY-MM-DD] ingest | Page Title +## [YYYY-MM-DD] synthesize | Page Title ``` -## 写作约定 +## Writing Guidelines -- **文件名**用小写加中划线,比如 `machine-learning.md` -- **一页只讲一件事**,需要关联的内容通过链接串起来 -- **有了就更新,不要重复建页** -- **每次改完都要更新索引** `knowledge/index.md` -- **写精华别抄全文**,抓住要点就行 -- **对话里引用知识页时用完整路径**,比如 `[标题](knowledge//.md)`。页面之间互相链接才用相对路径 -- **基于知识页回答问题时附上链接**,方便深入查阅 +- **Filenames**: lowercase with hyphens, e.g. `machine-learning.md` +- **One topic per page** — link related content across pages +- **Update, don't duplicate** — if a page already exists, update it rather than creating a new one +- **Always update the index** `knowledge/index.md` after any change +- **Distill, don't copy** — capture the key points, not the entire source +- **Use full paths when referencing knowledge pages in conversations**, e.g. `[Title](knowledge//.md)`. Use relative paths only for inter-page links +- **Include links when answering questions based on knowledge pages** so users can dig deeper diff --git a/docs/skills/skill-creator.mdx b/docs/skills/skill-creator.mdx index 623a74f6..58853f52 100644 --- a/docs/skills/skill-creator.mdx +++ b/docs/skills/skill-creator.mdx @@ -1,180 +1,180 @@ --- -title: skill-creator - 技能创建 -description: 创建、安装、更新技能,规范 SKILL.md 写法与目录结构 +title: skill-creator +description: Create, install, and update skills — standardises SKILL.md format and directory structure --- -`skill-creator` 是一个「元技能」,专门用来帮助 Agent 创建、安装和更新其他技能,确保所有技能的 `SKILL.md` 写法和目录结构保持一致。 +`skill-creator` is a "meta-skill" that helps the Agent create, install, and update other skills, ensuring every skill follows a consistent `SKILL.md` format and directory layout. -## 什么时候会触发 +## When It Triggers -- 用户想从 URL 或远程仓库安装一个技能 -- 用户想从头创建一个全新的技能 -- 需要升级或重构已有技能 +- The user wants to install a skill from a URL or remote repository +- The user wants to create a brand-new skill from scratch +- An existing skill needs upgrading or restructuring -## 技能是什么 +## What Is a Skill? -简单来说,技能就是一份「可复用的说明书」加上可选的脚本和资源。它给 Agent 注入了某个领域的专业知识,让 Agent 在遇到对应任务时能像专家一样处理。 +A skill is a reusable instruction set plus optional scripts and assets. It injects domain expertise into the Agent so it can handle specific tasks like a specialist. -一个技能通常包含以下内容: +A skill typically contains: -1. **专项工作流** — 某类任务的完整步骤 -2. **工具用法** — 怎么调某种 API 或处理某种文件 -3. **领域知识** — 团队约定、业务规则、数据结构之类 -4. **附带资源** — 脚本、参考文档、模板等 +1. **Specialised workflow** — step-by-step instructions for a category of tasks +2. **Tool usage** — how to call a particular API or process a particular file format +3. **Domain knowledge** — team conventions, business rules, data schemas, etc. +4. **Attached resources** — scripts, reference docs, templates, etc. -**核心原则:能省则省**。只写 Agent 自己想不到的内容,每加一行都要问自己:值不值得占这些 token? +**Core principle: less is more.** Only write what the Agent wouldn't figure out on its own. For every line you add, ask yourself: is it worth the tokens? -## 目录结构 +## Directory Structure ``` skill-name/ -├── SKILL.md # 必需:技能定义 -│ ├── YAML frontmatter(必填 name / description) -│ └── Markdown 正文(说明 + 示例) -└── 可选资源 - ├── scripts/ # 可执行脚本(Python / Bash 等) - ├── references/ # 内容较多的参考文档,Agent 按需读取 - └── assets/ # 模板、图标等,会直接用在输出里 +├── SKILL.md # Required: skill definition +│ ├── YAML frontmatter (name / description are mandatory) +│ └── Markdown body (instructions + examples) +└── Optional resources + ├── scripts/ # Executable scripts (Python / Bash, etc.) + ├── references/ # Large reference docs the Agent reads on demand + └── assets/ # Templates, icons, etc. used directly in output ``` -## SKILL.md 规范定义 +## SKILL.md Specification -SKILL.md 文件头部的 `frontmatter` 字段: +Frontmatter fields in the SKILL.md header: -| 字段 | 说明 | +| Field | Description | | --- | --- | -| `name` | 技能名,小写加中划线,必须和目录名一致 | -| `description` | **最关键的字段**。写清楚「这个技能干什么」和「什么情况下该用它」,Agent 看到这段来决定要不要调它。注意:所有触发相关的描述都放在这里,不要写到正文里 | -| `metadata.cowagent.requires.bins` | 系统里必须装了哪些命令行工具 | -| `metadata.cowagent.requires.env` | 需要哪些环境变量(全部满足才行) | -| `metadata.cowagent.requires.anyEnv` | 多个 API Key 满足一个就行 | -| `metadata.cowagent.requires.anyBins` | 多个工具满足一个就行 | -| `metadata.cowagent.always` | 设为 `true` 会始终加载,不检查依赖 | -| `metadata.cowagent.emoji` | 展示用的 emoji(可选) | -| `metadata.cowagent.os` | 限定系统,如 `["darwin", "linux"]` | +| `name` | Skill name — lowercase with hyphens, must match the directory name | +| `description` | **The most important field.** Clearly state what the skill does and when to use it. The Agent reads this to decide whether to invoke it. All trigger-related descriptions go here, not in the body | +| `metadata.cowagent.requires.bins` | System CLI tools that must be installed | +| `metadata.cowagent.requires.env` | Required environment variables (all must be present) | +| `metadata.cowagent.requires.anyEnv` | Multiple API keys — at least one must be set | +| `metadata.cowagent.requires.anyBins` | Multiple tools — at least one must be installed | +| `metadata.cowagent.always` | Set to `true` to always load, skipping dependency checks | +| `metadata.cowagent.emoji` | Display emoji (optional) | +| `metadata.cowagent.os` | OS restriction, e.g. `["darwin", "linux"]` | -`category` 字段不需要手写,系统会自动设成 `skill`。 +The `category` field does not need to be set manually — the system automatically sets it to `skill`. -声明 API Key 依赖有两种写法: +Two ways to declare API key dependencies: ```yaml metadata: cowagent: requires: - env: ["MYAPI_KEY"] # 必须有 + env: ["MYAPI_KEY"] # Must be present ``` ```yaml metadata: cowagent: requires: - anyEnv: ["OPENAI_API_KEY", "LINKAI_API_KEY"] # 有一个就行 + anyEnv: ["OPENAI_API_KEY", "LINKAI_API_KEY"] # At least one ``` -**技能会自动按依赖启禁用**:环境变量齐了就自动启用,缺了就自动禁用,不需要手动 `/skill enable`。 +**Skills are auto-enabled/disabled based on dependencies**: they activate when all required environment variables are present and deactivate when any are missing — no need for manual `/skill enable`. -## 资源目录怎么用 +## Resource Directories -| 目录 | 放什么 | 不要放 | +| Directory | What goes here | What does NOT go here | | --- | --- | --- | -| `scripts/` | 需要反复执行的代码,或需要确定性结果的脚本 | 纯演示用的代码片段 | -| `references/` | **超过 500 行**、SKILL.md 实在塞不下的大文档(比如完整的数据库 Schema) | 普通 API 文档、示例、教程 | -| `assets/` | 会出现在最终产物里的文件(模板、图标、样板代码等) | 说明性文档 | +| `scripts/` | Code that needs to run repeatedly, or scripts that produce deterministic results | Demo-only code snippets | +| `references/` | Documents **over 500 lines** that genuinely won't fit in SKILL.md (e.g. a full DB schema) | General API docs, tutorials, examples | +| `assets/` | Files that appear in the final output (templates, icons, boilerplate, etc.) | Explanatory documentation | -**原则上所有内容都写在 `SKILL.md` 里**,只有确实放不下才拆到资源目录。 +**In principle, everything goes in `SKILL.md`** — only split into resource directories when it truly won't fit. -不要给技能加 `README.md`、`CHANGELOG.md`、`INSTALLATION_GUIDE.md` 之类的文件——全部放进 `SKILL.md`。资源目录里只放真正要跑的脚本或真正要用的素材。 +Do not add `README.md`, `CHANGELOG.md`, or `INSTALLATION_GUIDE.md` to a skill — put everything in `SKILL.md`. Resource directories should only contain scripts that actually run or assets that are actually used. -## 安装外部技能 +## Installing External Skills -安装后最终落在 `/skills//` 目录。 +After installation, the skill lands in `/skills//`. -| 来源 | 怎么装 | +| Source | How to install | | --- | --- | -| URL(单文件) | curl / web_fetch 直接拉 | -| URL(zip 包) | 下载解压 | -| 本地 SKILL.md | 直接读 | -| 本地 zip 包 | 解压 | +| URL (single file) | curl / web_fetch | +| URL (zip archive) | Download and extract | +| Local SKILL.md | Read directly | +| Local zip archive | Extract | -安装步骤: +Installation steps: -1. 找到 `SKILL.md`(可能在包的根目录或某个子目录里) -2. 从 frontmatter 里读出 `name` -3. 把**整个技能目录**(包括 `SKILL.md`、`scripts/`、`assets/` 等)复制到 `/skills//` -4. 如果包里有 `INSTALL.md` 之类的安装脚本,照着跑一遍,但最终结果仍然要落在 `/skills//` 下 +1. Locate the `SKILL.md` (may be at the root or in a subdirectory of the archive) +2. Read the `name` from the frontmatter +3. Copy the **entire skill directory** (including `SKILL.md`, `scripts/`, `assets/`, etc.) to `/skills//` +4. If the archive contains an `INSTALL.md` or similar setup script, run it — but the final result must still reside under `/skills//` -## 从头创建技能 +## Creating a Skill from Scratch -推荐按这个顺序来: +Recommended order: -1. **搞清楚需求** — 让用户举几个具体的使用场景,一次别问太多 -2. **想好结构** — 这个技能需要脚本吗?需要参考文档吗?需要模板素材吗? -3. **生成骨架** — 用初始化脚本: +1. **Clarify requirements** — ask the user for a few concrete use cases (don't ask too many at once) +2. **Plan the structure** — does this skill need scripts? Reference docs? Template assets? +3. **Scaffold** — use the init script: ```bash scripts/init_skill.py --path /skills [--resources scripts,references,assets] [--examples] ``` -4. **填充内容** — 写好 SKILL.md、补上脚本和资源。脚本写完一定要实际跑一遍 -5. **格式校验**(可选): +4. **Fill in content** — write SKILL.md, add scripts and resources. Always test scripts after writing them +5. **Validate** (optional): ```bash scripts/quick_validate.py /skills/ ``` -6. **迭代完善** — 实际用起来之后根据反馈持续改进 +6. **Iterate** — keep improving based on real-world usage feedback -## 命名规则 +## Naming Conventions -- 只用小写字母、数字和中划线。用户给的名字需要做标准化处理,比如 `Plan Mode` → `plan-mode` -- 长度别超过 64 个字符 -- 尽量短、用动词开头、一看就知道干什么 -- 必要时用工具名做前缀,比如 `gh-address-comments`、`linear-address-issue` -- 目录名和 `name` 字段必须完全一致 +- Use only lowercase letters, digits, and hyphens. Normalise user-given names, e.g. `Plan Mode` → `plan-mode` +- Maximum 64 characters +- Keep it short, start with a verb, make it self-explanatory +- Use tool names as prefixes when appropriate, e.g. `gh-address-comments`, `linear-address-issue` +- The directory name and the `name` field must match exactly -## 三级加载机制 +## Three-Level Loading -技能不会一次性全部塞进上下文,而是分三级按需加载: +Skills are not loaded into context all at once — they use a three-level progressive loading mechanism: -1. **元信息**(`name` + `description`)— 常驻上下文,约 100 词。Agent 靠它判断「要不要用这个技能」 -2. **SKILL.md 正文** — 确定要用了才加载,建议控制在 500 行以内 -3. **资源文件** — Agent 需要的时候再读 +1. **Metadata** (`name` + `description`) — always in context (~100 words). The Agent uses this to decide whether to invoke the skill +2. **SKILL.md body** — loaded only when the skill is activated; keep it under 500 lines +3. **Resource files** — read on demand by the Agent -如果一个技能涉及多个变体(比如多云厂商部署),建议这样组织: +For skills with multiple variants (e.g. multi-cloud deployment), organise like this: ``` cloud-deploy/ -├── SKILL.md # 主流程和厂商选择逻辑 +├── SKILL.md # Main workflow and provider selection logic └── references/ ├── aws.md ├── gcp.md └── azure.md ``` -用户选了 AWS,Agent 只需要读 `aws.md`,不用把三家的文档全加载进来。 +When the user picks AWS, the Agent only reads `aws.md` — no need to load all three providers. -## 常见设计模式 +## Common Design Patterns -**步骤式**:按编号列出操作步骤和对应脚本。 +**Step-by-step**: numbered steps with corresponding scripts. ```markdown -1. 分析表单结构(运行 analyze_form.py) -2. 生成字段映射(编辑 fields.json) -3. 自动填充表单(运行 fill_form.py) +1. Analyse form structure (run analyze_form.py) +2. Generate field mappings (edit fields.json) +3. Auto-fill the form (run fill_form.py) ``` -**分支式**:根据用户意图走不同流程。 +**Branching**: different flows based on user intent. ```markdown -1. 判断操作类型: - **新建内容?** → 走「创建流程」 - **编辑已有内容?** → 走「编辑流程」 +1. Determine operation type: + **Creating new content?** → follow the "Create" workflow + **Editing existing content?** → follow the "Edit" workflow ``` -**模板式**:输出格式有严格要求时,在 SKILL.md 里直接给一个样板,让 Agent 照着写。 +**Template-based**: when output format has strict requirements, include a template in SKILL.md for the Agent to follow. diff --git a/docs/tools/bash.mdx b/docs/tools/bash.mdx index 0090fe14..60b20918 100644 --- a/docs/tools/bash.mdx +++ b/docs/tools/bash.mdx @@ -1,27 +1,27 @@ --- -title: bash - 终端 -description: 执行系统命令 +title: bash - Terminal +description: Execute system commands --- -在当前工作目录执行 Bash 命令,返回 stdout 和 stderr。`env_config` 中配置的 API Key 会自动注入到环境变量中。 +Execute Bash commands in the current working directory, returns stdout and stderr. API keys configured via `env_config` are automatically injected into the environment. -## 依赖 +## Dependencies -无额外依赖,默认可用。 +No extra dependencies, available by default. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `command` | string | 是 | 要执行的命令 | -| `timeout` | integer | 否 | 超时时间(秒) | +| `command` | string | Yes | Command to execute | +| `timeout` | integer | No | Timeout in seconds | -## 使用场景 +## Use Cases -- 安装软件包和依赖 -- 运行代码和测试 -- 部署应用和服务(Nginx 配置、进程管理等) -- 系统运维和排查 +- Install packages and dependencies +- Run code and tests +- Deploy applications and services (Nginx config, process management, etc.) +- System administration and troubleshooting diff --git a/docs/tools/browser.mdx b/docs/tools/browser.mdx index 0d3c9197..4c6fda82 100644 --- a/docs/tools/browser.mdx +++ b/docs/tools/browser.mdx @@ -1,42 +1,42 @@ --- -title: browser - 浏览器 -description: 控制浏览器访问和操作网页 +title: browser - Browser +description: Control a browser to access and interact with web pages --- -控制 Chromium 浏览器进行网页导航、元素交互和内容提取。支持 JavaScript 渲染的动态页面,使用精简 DOM 快照让 Agent 高效理解页面结构。 +Control a Chromium browser for web navigation, element interaction and content extraction. Supports JavaScript-rendered pages and uses a compact DOM snapshot so the Agent can efficiently understand page structure. -## 安装 +## Installation - + ```bash cow install-browser ``` - 该命令会自动完成: - - 安装 `playwright` Python 包(旧系统自动降级兼容版本) - - 在 Linux 上安装系统依赖 - - 下载 Chromium 浏览器(Linux 服务器自动使用无头精简版) - - 自动检测国内网络并使用镜像加速 + This command will: + - Install the `playwright` Python package (with auto-fallback for older systems) + - Install system dependencies on Linux + - Download the Chromium browser (Linux servers automatically use the headless build) + - Detect China-mainland networks and use mirror acceleration - + ```bash pip install playwright playwright install chromium ``` - Linux 服务器还需安装系统依赖: + On Linux servers, install system dependencies as well: ```bash sudo playwright install-deps chromium ``` - 如果系统较旧(如 Ubuntu 18.04,glibc < 2.28),需安装兼容版本: + On older systems (e.g. Ubuntu 18.04, glibc < 2.28), install a compatible version: ```bash pip install playwright==1.28.0 python -m playwright install chromium ``` - 国内网络下载 Chromium 较慢,可设置镜像加速: + To accelerate the Chromium download from China: ```bash export PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright python -m playwright install chromium @@ -45,55 +45,55 @@ description: 控制浏览器访问和操作网页 - 1. 支持 Ubuntu 20.04+、Debian 10+、macOS、Windows。Ubuntu 18.04 等旧系统会自动降级安装兼容版本。 - 2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。 + 1. Supported on Ubuntu 20.04+, Debian 10+, macOS and Windows. Older systems such as Ubuntu 18.04 will fall back to a compatible version automatically. + 2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool. -## 工作流程 +## Workflow -Agent 使用浏览器的典型流程: +A typical browser workflow for the Agent: -1. **`navigate`** — 打开目标 URL -2. **`snapshot`** — 获取页面精简 DOM,交互元素自动编号(ref) -3. **`click` / `fill` / `select`** — 通过 ref 编号操作元素 -4. **`snapshot`** — 再次快照验证操作结果 +1. **`navigate`** — Open the target URL +2. **`snapshot`** — Get a compact DOM with auto-numbered interactive elements (`ref`) +3. **`click` / `fill` / `select`** — Operate elements by `ref` +4. **`snapshot`** — Snapshot again to verify the result -## 支持的操作 +## Supported Actions -| 操作 | 说明 | 关键参数 | +| Action | Description | Key parameters | | --- | --- | --- | -| `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` | +| `navigate` | Open URL | `url` | +| `snapshot` | Get structured page text (primary way) | `selector` (optional) | +| `click` | Click an element | `ref` or `selector` | +| `fill` | Fill text into an input | `ref` or `selector`, `text` | +| `select` | Select a dropdown option | `ref` or `selector`, `value` | +| `scroll` | Scroll the page | `direction` (up/down/left/right) | +| `screenshot` | Save a screenshot to the workspace | `full_page` | +| `wait` | Wait for an element or timeout | `selector`, `timeout` | +| `press` | Press a key (Enter, Tab, etc.) | `key` | +| `back` / `forward` | Browser back / forward | - | +| `get_text` | Get an element's text content | `selector` | +| `evaluate` | Run JavaScript | `script` | -## 使用场景 +## Use Cases -- 访问指定 URL 获取动态页面内容 -- 填写表单、登录操作 -- 操作网页元素(点击按钮、选择选项等) -- 验证部署后的网页效果 -- 抓取需要 JS 渲染的动态内容 +- Access a URL to retrieve dynamic page content +- Fill in forms and log in +- Operate web elements (click buttons, select options, etc.) +- Verify the result of a deployed web page +- Scrape content that requires JS rendering -## 运行模式 +## Run Mode -浏览器会根据运行环境自动选择模式: +The browser picks a mode based on the runtime environment: -| 环境 | 模式 | +| Environment | Mode | | --- | --- | -| macOS / Windows | 有头模式(显示浏览器窗口) | -| Linux 桌面(有 DISPLAY) | 有头模式 | -| Linux 服务器(无 DISPLAY) | 无头模式(headless) | +| macOS / Windows | Headed (browser window visible) | +| Linux desktop (with DISPLAY) | Headed | +| Linux server (no DISPLAY) | Headless | -可在 `config.json` 中手动覆盖: +You can override it in `config.json`: ```json { @@ -105,15 +105,15 @@ Agent 使用浏览器的典型流程: } ``` -## 登录态持久化 +## Persistent Login -**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式: +**Log in to a target site once and the Agent can keep using it.** Two ways are supported: -### 方式一:Persistent 模式(默认) +### Option 1: Persistent mode (default) -开箱即用,登录信息保存在 `~/.cow/browser_profile`。无需任何配置。 +Works out of the box. Login state is saved under `~/.cow/browser_profile`. No configuration needed. -如需关闭持久化模式,每次都用纯净环境: +To disable persistence and start with a clean environment every time: ```json { @@ -125,11 +125,11 @@ Agent 使用浏览器的典型流程: } ``` -### 方式二:CDP 模式(接管真实 Chrome) +### Option 2: CDP mode (attach to real Chrome) -让 Agent 连接独立启动的真实 Chrome(而非 Playwright 自带的 Chromium),获得完整浏览器指纹,适合反爬严格的网站。 +Have the Agent connect to a separately launched real Chrome (instead of the Chromium bundled with Playwright) for full browser fingerprints. Useful for sites with strict bot detection. -启动 Chrome 时加上调试端口和独立用户目录: +Launch Chrome with a debugging port and a dedicated user data directory: @@ -155,7 +155,7 @@ Agent 使用浏览器的典型流程: -在 `config.json` 中配置端点: +Then point the Agent at the endpoint in `config.json`: ```json { @@ -168,5 +168,5 @@ Agent 使用浏览器的典型流程: ``` - Chrome 137+ 限制 `--remote-debugging-port` 必须搭配独立 `--user-data-dir`,因此 CDP 启动的 Chrome **无法直接复用你日常 Chrome 的登录态**,需要在独立目录中重新登录一次。 + Chrome 137+ requires `--remote-debugging-port` to be paired with a dedicated `--user-data-dir`. As a result, the CDP-launched Chrome **cannot directly reuse the login state of your daily Chrome**; you'll need to log in once inside this dedicated profile. diff --git a/docs/tools/edit.mdx b/docs/tools/edit.mdx index 717af2ba..f231c6b9 100644 --- a/docs/tools/edit.mdx +++ b/docs/tools/edit.mdx @@ -1,24 +1,24 @@ --- -title: edit - 文件编辑 -description: 通过精确文本替换编辑文件 +title: edit - File Edit +description: Edit files via precise text replacement --- -通过精确文本替换编辑文件。如果 `oldText` 为空则追加到文件末尾。 +Edit files via precise text replacement. If `oldText` is empty, appends to the end of the file. -## 依赖 +## Dependencies -无额外依赖,默认可用。 +No extra dependencies, available by default. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | 是 | 文件路径 | -| `oldText` | string | 是 | 要替换的原始文本(为空时追加到末尾) | -| `newText` | string | 是 | 替换后的文本 | +| `path` | string | Yes | File path | +| `oldText` | string | Yes | Original text to replace (empty to append) | +| `newText` | string | Yes | Replacement text | -## 使用场景 +## Use Cases -- 修改配置文件中的特定参数 -- 修复代码中的 bug -- 在文件指定位置插入内容 +- Modify specific parameters in configuration files +- Fix bugs in code +- Insert content at specific positions in files diff --git a/docs/tools/env-config.mdx b/docs/tools/env-config.mdx index d5d52c68..23f75bf8 100644 --- a/docs/tools/env-config.mdx +++ b/docs/tools/env-config.mdx @@ -1,35 +1,35 @@ --- -title: env_config - 环境变量 -description: 管理 API Key 等秘钥配置 +title: env_config - Environment +description: Manage API keys and secrets --- -管理工作空间 `.env` 文件中的环境变量(API Key 等秘钥),支持通过对话安全地添加和更新。内置安全保护和脱敏策略。 +Manage environment variables (API keys and secrets) in the workspace `.env` file, with secure conversational updates. Built-in security protection and desensitization. -## 依赖 +## Dependencies -| 依赖 | 安装命令 | +| Dependency | Install Command | | --- | --- | | `python-dotenv` ≥ 1.0.0 | `pip install python-dotenv>=1.0.0` | -安装扩展依赖时已包含:`pip3 install -r requirements-optional.txt` +Included when installing optional dependencies: `pip3 install -r requirements-optional.txt` -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `action` | string | 是 | 操作类型:`get`、`set`、`list`、`delete` | -| `key` | string | 否 | 环境变量名称 | -| `value` | string | 否 | 环境变量值(仅 `set` 时需要) | +| `action` | string | Yes | Operation type: `get`, `set`, `list`, `delete` | +| `key` | string | No | Environment variable name | +| `value` | string | No | Environment variable value (only for `set`) | -## 使用方式 +## Usage -直接告诉 Agent 需要配置的秘钥,Agent 会自动调用该工具: +Tell the Agent what key you need to configure, and it will automatically invoke this tool: -- "帮我配置 BOCHA_API_KEY" -- "设置 OPENAI_API_KEY 为 sk-xxx" -- "查看已配置的环境变量" +- "Configure my BOCHA_API_KEY" +- "Set OPENAI_API_KEY to sk-xxx" +- "Show configured environment variables" -配置的秘钥会自动注入到 `bash` 工具的执行环境中。 +Configured keys are automatically injected into the `bash` tool's execution environment. diff --git a/docs/tools/index.mdx b/docs/tools/index.mdx index 0d96923a..fd2a21f3 100644 --- a/docs/tools/index.mdx +++ b/docs/tools/index.mdx @@ -1,69 +1,60 @@ --- -title: 工具概览 -description: CowAgent 内置工具系统 +title: Tools Overview +description: CowAgent built-in tools system --- -工具是 Agent 访问操作系统资源的核心能力。Agent 会根据任务需求智能选择和调用工具,完成文件操作、命令执行、联网搜索、定时任务等各类操作。工具实现在项目的 `agent/tools/` 目录下。 +Tools are the core capability for Agent to access operating system resources. The Agent intelligently selects and invokes tools based on task requirements, performing file operations, command execution, web search, scheduled tasks, and more. Tools are implemented in the `agent/tools/` directory. -## 内置工具 +## Built-in Tools -以下工具默认可用,无需额外配置: +The following tools are available by default with no extra configuration: - - 读取文件内容,支持文本、图片、PDF + + Read file content, supports text, images, PDF - - 创建或覆盖写入文件 + + Create or overwrite files - - 通过精确文本替换编辑文件 + + Edit files via precise text replacement - - 列出目录内容 + + List directory contents - - 执行系统命令 + + Execute system commands - - 向用户发送文件或图片 + + Send files or images to user - - 搜索和读取长期记忆 - - - 管理 API Key 等秘钥配置 - - - 获取网页或文档内容 - - - 创建和管理定时任务 + + Search and read long-term memory -## 可选工具 +## Optional Tools -以下工具需要安装额外依赖或配置 API Key 后启用: +The following tools require additional dependencies or API key configuration: - - 搜索互联网获取实时信息 + + Manage API keys and secrets - - 分析图片内容(识别、描述、OCR 文字提取等) + + Create and manage scheduled tasks - - 控制浏览器访问和操作网页 + + Search the internet for real-time information -## MCP 工具 +## MCP Tools -通过 [Model Context Protocol](https://modelcontextprotocol.io) 接入社区生态中的各种MCP工具,配置一次 `mcp.json` 即用即得: +Integrate thousands of community tools (maps, GitHub, Notion, etc.) via the [Model Context Protocol](https://modelcontextprotocol.io). Configure `mcp.json` once, ready to use: - - 支持 stdio / SSE 标准协议,热更新,零代码接入 + + Supports standard stdio / SSE transports. Hot-reload, zero code changes. diff --git a/docs/tools/ls.mdx b/docs/tools/ls.mdx index e4d25fc5..e9a5f656 100644 --- a/docs/tools/ls.mdx +++ b/docs/tools/ls.mdx @@ -1,23 +1,23 @@ --- -title: ls - 目录列表 -description: 列出目录内容 +title: ls - Directory List +description: List directory contents --- -列出目录内容,按字母排序,目录名带 `/` 后缀,包含隐藏文件。 +List directory contents, sorted alphabetically, directories suffixed with `/`, includes hidden files. -## 依赖 +## Dependencies -无额外依赖,默认可用。 +No extra dependencies, available by default. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | 是 | 目录路径,相对路径基于工作空间目录 | -| `limit` | integer | 否 | 最大返回条目数,默认 500 | +| `path` | string | Yes | Directory path, relative paths are based on workspace directory | +| `limit` | integer | No | Maximum entries to return, default 500 | -## 使用场景 +## Use Cases -- 浏览项目结构 -- 查找特定文件 -- 检查目录是否存在 +- Browse project structure +- Find specific files +- Check if a directory exists diff --git a/docs/tools/mcp.mdx b/docs/tools/mcp.mdx index 8b7670c1..fc320fe0 100644 --- a/docs/tools/mcp.mdx +++ b/docs/tools/mcp.mdx @@ -1,19 +1,19 @@ --- -title: MCP 工具 -description: 通过 Model Context Protocol 接入外部工具生态 +title: MCP Tools +description: Integrate external tool ecosystems via the Model Context Protocol --- -CowAgent 支持 [Model Context Protocol (MCP)](https://modelcontextprotocol.io),让 Agent 能够直接调用社区中数以万计的 MCP 工具。配置一次 `mcp.json`,工具就会以与内置工具完全相同的方式呈现给 LLM,可被自动选择和调用。 +CowAgent supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), allowing the Agent to directly invoke tens of thousands of community MCP tools. Configure `mcp.json` once and the tools are exposed to the LLM in exactly the same way as built-in tools — automatically selected and invoked. -## 配置文件 +## Configuration File -CowAgent 读取 `~/cow/mcp.json`。文件不存在时不会启用任何 MCP 工具,也不会报错。 +CowAgent reads `~/cow/mcp.json`. If the file does not exist, no MCP tools are loaded — and no error is raised. -Docker 部署时,官方 `docker-compose.yml` 已经把宿主机 `./cow` 挂载到容器内 `/home/agent/cow`(即容器用户的 `~/cow`),把 `mcp.json` 放进宿主机 `./cow/` 目录即可生效。 +For Docker deployments, the official `docker-compose.yml` already mounts the host's `./cow` directory to `/home/agent/cow` inside the container (i.e. the container user's `~/cow`). Just drop `mcp.json` into the host's `./cow/` directory and it will take effect. -### 标准格式 +### Standard Format -完全兼容 MCP 社区标准,同 Claude Desktop / Cursor 一致: +Fully compatible with the MCP community standard, identical to Claude Desktop / Cursor: ```json { @@ -29,17 +29,17 @@ Docker 部署时,官方 `docker-compose.yml` 已经把宿主机 `./cow` 挂载 } ``` -| 字段 | 必填 | 说明 | +| Field | Required | Description | | --- | --- | --- | -| `command` | stdio | 启动 server 的可执行命令(如 `npx`、`python`、`uvx`) | -| `args` | 否 | 传给 command 的参数列表 | -| `env` | 否 | 子进程的环境变量,常用于 API Key | -| `url` | SSE / Streamable HTTP | 远程端点 URL(与 `command` 二选一) | -| `type` | 远程 | 远程传输类型,可选 `sse` 或 `streamable-http`,默认 `sse` | -| `headers` | 否 | 远程请求附加 HTTP 头(如 `Authorization`),仅 Streamable HTTP 使用 | -| `disabled` | 否 | `true` 时跳过该 server,便于临时关闭 | +| `command` | stdio | Executable to launch the server (e.g. `npx`, `python`, `uvx`) | +| `args` | No | Arguments passed to `command` | +| `env` | No | Environment variables for the subprocess, commonly used for API keys | +| `url` | SSE / Streamable HTTP | Remote endpoint URL (alternative to `command`) | +| `type` | Remote | Remote transport type: `sse` or `streamable-http` (defaults to `sse`) | +| `headers` | No | Extra HTTP headers for remote requests (e.g. `Authorization`); Streamable HTTP only | +| `disabled` | No | When `true`, this server is skipped — handy for temporary disabling | -### 完整示例 +### Full Example ```json { @@ -59,54 +59,54 @@ Docker 部署时,官方 `docker-compose.yml` 已经把宿主机 `./cow` 挂载 } ``` -- **fetch**:通用网页抓取,返回页面文本内容,无需 API Key -- **github**:访问 GitHub 仓库、Issue、PR 等,需要 Personal Access Token +- **fetch**: Generic web page fetcher that returns page text content. No API key required. +- **github**: Access GitHub repos, issues, PRs, etc. Requires a Personal Access Token. -## 让 Agent 帮你配置 +## Let the Agent Configure It for You -CowAgent 自带 `read` / `write` / `edit` 工具,**直接把要装的 MCP 配置发给 Agent,让它写到配置文件中: +CowAgent ships with `read` / `write` / `edit` tools, so **you can simply send the MCP config to the Agent and ask it to write the file**: -例如: +For example: ```markdown -帮我把这个 MCP 加到 ~/cow/mcp.json 里: +Add this MCP to ~/cow/mcp.json: {"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}} ``` -Agent 会: +The Agent will: -1. 访问 MCP 配置文件,合并新 server 配置,保留已有项 -2. 自动重载增量的 MCP Server,下一次对话即可使用相应 Tools +1. Read the existing MCP config and merge the new server entry, preserving existing ones +2. Hot-reload the new MCP server, so the corresponding tools become available on the next message -## 工作方式 +## How It Works -- 启动时**异步加载**:`mcp.json` 中配置的所有 server 会在后台异步加载,不阻塞主流程,对话可以立刻使用 -- **热更新**:用户或 Agent 修改 `mcp.json` 后,消息处理完成时会自动重载变更的 server,无需重启 cow -- **平铺呈现**:每个 MCP server 暴露的多个方法会平铺为独立的工具,LLM 直接选择调用,不需要二次决策 +- **Async loading at startup**: All servers configured in `mcp.json` are loaded asynchronously in the background, never blocking the main loop — chat is usable immediately. +- **Hot reload**: When you or the Agent modifies `mcp.json`, changed servers are automatically reloaded after the current message — no need to restart cow. +- **Flat exposure**: Each method exposed by an MCP server appears as an individual tool. The LLM picks one directly without a second-stage decision. -## 支持的传输协议 +## Supported Transports -| 协议 | 说明 | 配置字段 | +| Transport | Description | Config Field | | --- | --- | --- | -| **stdio** | 子进程通信,最常见,社区生态最丰富 | `command` + `args` | -| **SSE** | HTTP Server-Sent Events,旧版远程协议 | `url`(默认) | -| **Streamable HTTP** | 新版远程协议,单端点收发,逐步取代 SSE | `type: "streamable-http"` + `url` | +| **stdio** | Subprocess communication. The most common option, with the richest community ecosystem. | `command` + `args` | +| **SSE** | HTTP Server-Sent Events. Legacy remote transport. | `url` (default) | +| **Streamable HTTP** | New unified remote transport, gradually replacing SSE. | `type: "streamable-http"` + `url` | -## 排错 +## Troubleshooting -| 现象 | 排查方向 | +| Symptom | What to Check | | --- | --- | -| 启动后 Agent 没有 MCP 工具 | 检查 `~/cow/mcp.json` 是否存在、JSON 格式是否合法 | -| 某个 server 加载失败 | 查看启动日志中的 `[MCP] Server 'xxx' load failed`,常见为依赖未装、API Key 缺失 | -| 修改 `mcp.json` 没有生效 | 改动会在**下一条消息**生效;若 server 配置不变(如只改注释),不会触发重启 | -| Docker 部署 | 确认宿主机 `./cow` 已挂载到容器内 `/home/agent/cow`,`mcp.json` 直接放进宿主机 `./cow/` 目录即可,或者直接对话 Agent 安装 | +| Agent has no MCP tools after startup | Verify that `~/cow/mcp.json` exists and contains valid JSON | +| A specific server fails to load | Look for `[MCP] Server 'xxx' load failed` in startup logs — usually missing dependencies or API keys | +| Changes to `mcp.json` aren't applied | Changes take effect on **the next message**. If the server config didn't actually change (e.g. only comments edited), no restart is triggered | +| Docker deployment | Make sure host's `./cow` is mounted to `/home/agent/cow` in the container, then just drop `mcp.json` into host's `./cow/`. Or just ask the Agent to do it | -## MCP 市场推荐 +## Recommended MCP Marketplaces -可以从各个第三方广场寻找现成的 MCP server,复制 JSON 配置即可使用,例如: +You can browse third-party MCP marketplaces and copy a JSON config to use directly, for example: -- [mcp.so](https://mcp.so) — 全球 MCP 服务索引 -- [ModelScope MCP 广场](https://modelscope.cn/mcp) — 魔搭社区 MCP 广场,国内访问更稳定 +- [mcp.so](https://mcp.so) — Global MCP service index +- [ModelScope MCP Hub](https://modelscope.cn/mcp) — ModelScope's MCP hub, more reliable from mainland China -只要遵循 MCP 标准协议(stdio / SSE / Streamable HTTP),都可以直接接入 CowAgent。 +Any MCP server that follows the standard protocol (stdio / SSE / Streamable HTTP) integrates with CowAgent out of the box. diff --git a/docs/tools/memory.mdx b/docs/tools/memory.mdx index c3cc6fe4..d272bf9b 100644 --- a/docs/tools/memory.mdx +++ b/docs/tools/memory.mdx @@ -1,43 +1,43 @@ --- -title: memory - 记忆与知识 -description: 搜索和读取长期记忆及知识库文件 +title: memory - Memory & Knowledge +description: Search and read long-term memory and knowledge base files --- -记忆工具包含两个子工具:`memory_search`(搜索记忆)和 `memory_get`(读取记忆或知识文件)。 +The memory tool contains two sub-tools: `memory_search` (search memory) and `memory_get` (read memory or knowledge files). -当 [知识库](/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。 +When the [knowledge base](/knowledge) feature is enabled, both tools also support accessing files under the `knowledge/` directory. -## 依赖 +## Dependencies -无额外依赖,默认可用。由 Agent Core 的记忆系统管理。 +No extra dependencies, available by default. Managed by the Agent Core memory system. ## memory_search -搜索历史记忆和知识库内容,支持关键词和向量混合检索。 +Search historical memory and knowledge base content with hybrid keyword and vector retrieval. -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `query` | string | 是 | 搜索查询 | +| `query` | string | Yes | Search query | ## memory_get -读取特定记忆文件或知识库文件的内容。 +Read the content of a specific memory or knowledge file. -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | 是 | 文件的相对路径(如 `MEMORY.md`、`memory/2026-01-01.md`、`knowledge/concepts/rag.md`) | -| `start_line` | integer | 否 | 起始行号 | -| `end_line` | integer | 否 | 结束行号 | +| `path` | string | Yes | Relative path to the file (e.g. `MEMORY.md`, `memory/2026-01-01.md`, `knowledge/concepts/rag.md`) | +| `start_line` | integer | No | Start line number | +| `end_line` | integer | No | End line number | -## 工作方式 +## How It Works -Agent 会在以下场景自动调用记忆工具: +The Agent automatically invokes memory tools in these scenarios: -- 用户分享重要信息时 → 存储到记忆 -- 需要参考历史信息时 → 搜索相关记忆 -- 对话达到一定长度时 → 提取摘要存储 -- 讨论到专业知识时 → 检索知识库中的相关页面 +- When the user shares important information → stores to memory +- When historical context is needed → searches relevant memory +- When conversation reaches a certain length → extracts summary for storage +- When discussing domain knowledge → retrieves relevant pages from the knowledge base - 当 `knowledge` 配置为 `false` 时,工具的描述和搜索范围会自动调整为仅包含记忆文件。 + When `knowledge` is set to `false` in config, the tool descriptions and search scope automatically adjust to include only memory files. diff --git a/docs/tools/read.mdx b/docs/tools/read.mdx index 07e08b88..56b56570 100644 --- a/docs/tools/read.mdx +++ b/docs/tools/read.mdx @@ -1,24 +1,24 @@ --- -title: read - 文件读取 -description: 读取文件内容 +title: read - File Read +description: Read file content --- -读取文件内容。支持文本文件、PDF 文件、图片(返回元数据)等格式。 +Read file content. Supports text files, PDF files, images (returns metadata), and more. -## 依赖 +## Dependencies -无额外依赖,默认可用。 +No extra dependencies, available by default. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | 是 | 文件路径,相对路径基于工作空间目录 | -| `offset` | integer | 否 | 起始行号(1-indexed),负值表示从末尾读取 | -| `limit` | integer | 否 | 读取行数 | +| `path` | string | Yes | File path, relative paths are based on workspace directory | +| `offset` | integer | No | Start line number (1-indexed), negative values read from the end | +| `limit` | integer | No | Number of lines to read | -## 使用场景 +## Use Cases -- 查看配置文件、日志文件 -- 读取代码文件进行分析 -- 检查图片/视频的文件信息 +- View configuration files, log files +- Read code files for analysis +- Check image/video file info diff --git a/docs/tools/scheduler.mdx b/docs/tools/scheduler.mdx index 2648116f..18c211bf 100644 --- a/docs/tools/scheduler.mdx +++ b/docs/tools/scheduler.mdx @@ -1,61 +1,61 @@ --- -title: scheduler - 定时任务 -description: 创建和管理定时任务 +title: scheduler - Scheduler +description: Create and manage scheduled tasks --- -创建和管理动态定时任务,支持灵活的调度方式和执行模式。 +Create and manage dynamic scheduled tasks with flexible scheduling and execution modes. -## 依赖 +## Dependencies -| 依赖 | 安装命令 | +| Dependency | Install Command | | --- | --- | | `croniter` ≥ 2.0.0 | `pip install croniter>=2.0.0` | -安装核心依赖时已包含:`pip3 install -r requirements.txt` +Included in core dependencies: `pip3 install -r requirements.txt` -## 调度方式 +## Scheduling Modes -| 方式 | 说明 | +| Mode | Description | | --- | --- | -| 一次性任务 | 在指定时间执行一次 | -| 固定间隔 | 按固定时间间隔重复执行 | -| Cron 表达式 | 使用 Cron 语法定义复杂调度规则 | +| One-time | Execute once at a specified time | +| Fixed interval | Repeat at fixed time intervals | +| Cron expression | Define complex schedules using Cron syntax | -## 执行模式 +## Execution Modes -- **固定消息发送**:到达触发时间时发送预设消息 -- **Agent 动态任务**:到达触发时间时由 Agent 智能执行任务 +- **Fixed message**: Send a preset message when triggered +- **Agent dynamic task**: Agent intelligently executes the task when triggered -## 使用方式 +## Usage -通过自然语言即可创建和管理定时任务: +Create and manage scheduled tasks with natural language: -- "每天早上 9 点给我发天气预报" -- "每隔 2 小时检查一下服务器状态" -- "明天下午 3 点提醒我开会" -- "查看所有定时任务" +- "Send me a weather report every morning at 9 AM" +- "Check server status every 2 hours" +- "Remind me about the meeting tomorrow at 3 PM" +- "Show all scheduled tasks" -## 结果进入会话上下文 +## Results injected into the conversation -定时任务在隔离 session 中执行(内部规划与 tool 调用不污染用户会话),但**最终输出**会作为一对消息回写到接收者的真实会话,用户可以直接追问"刚才那条第二点展开说说"。 +Scheduled tasks run inside an isolated session (so internal planning and tool calls do not pollute the user's chat), but the **final output** is written back to the user's real session as a message pair. You can directly follow up — e.g. "expand on point 2 from earlier". -**默认策略** +**Default policy** -- Agent 动态任务的输出进入上下文 -- 固定消息类任务默认不进入上下文(可通过配置打开) -- 每个会话最多保留最近 **3 对** scheduler 消息,更早的自动清理;普通用户消息不受影响 +- Output of Agent dynamic tasks is injected into the conversation +- Fixed-message tasks are not injected by default (configurable) +- Each session keeps the most recent **3 pairs** of scheduler messages; older pairs are pruned automatically. Regular user messages are unaffected -**配置项** +**Configuration** -| 配置项 | 默认值 | 说明 | +| Key | Default | Description | | --- | --- | --- | -| `scheduler_inject_to_session` | `true` | 总开关 | -| `scheduler_inject_max_per_session` | `3` | 每会话保留 scheduler 消息对数上限 | -| `scheduler_inject_send_message` | `false` | 是否同时注入固定消息类任务 | +| `scheduler_inject_to_session` | `true` | Master switch | +| `scheduler_inject_max_per_session` | `3` | Max scheduler message pairs kept per session | +| `scheduler_inject_send_message` | `false` | Whether to also inject fixed-message tasks | ```json { @@ -65,16 +65,16 @@ description: 创建和管理定时任务 } ``` -## 任务执行时的上下文 +## Context inside scheduled task execution -定时任务的隔离 session 会保留最近几次执行的对话历史,便于做"对比上次"、"延续之前结论"等操作;但为了避免高频任务(如每 5 分钟监控)prompt 越积越长,会按公式自动裁剪: +The isolated session for scheduled tasks retains a few recent runs of conversation history, so you can naturally do "compare with last time" or "continue from previous conclusion". To prevent prompts from growing unbounded for high-frequency tasks (e.g. a 5-minute monitor), history is auto-trimmed: ``` scheduler_keep_turns = max(1, agent_max_context_turns / 5) ``` -`agent_max_context_turns` 默认为 `20`,所以定时任务每次执行默认带最近 **4 轮**历史。需要更长记忆可调大 `agent_max_context_turns`。 +`agent_max_context_turns` defaults to `20`, so each scheduled run keeps the most recent **4 turns** of history by default. Increase `agent_max_context_turns` if you need longer memory. -群聊场景(飞书 / 企微群机器人 / 钉钉等)下用户的真实 session_id 形如 `user_id:group_id`,与 receiver 不同。创建任务时会自动记录正确的 session_id;老的 `tasks.json` 缺该字段时回落到 receiver,行为与历史版本一致。 +For group-chat scenarios (Feishu / WeCom group bots / DingTalk, etc.), the user's real `session_id` looks like `user_id:group_id` — different from `receiver`. Scheduler records the correct `session_id` when a task is created. For older `tasks.json` entries missing this field, the runtime falls back to `receiver`, matching legacy behavior. diff --git a/docs/tools/send.mdx b/docs/tools/send.mdx index 05f73a6b..1cf089ac 100644 --- a/docs/tools/send.mdx +++ b/docs/tools/send.mdx @@ -1,23 +1,23 @@ --- -title: send - 文件发送 -description: 向用户发送文件 +title: send - File Send +description: Send files to user --- -向用户发送文件(图片、视频、音频、文档等),当用户明确要求发送/分享文件时使用。 +Send files to the user (images, videos, audio, documents, etc.), used when the user explicitly requests to send/share a file. -## 依赖 +## Dependencies -无额外依赖,默认可用。 +No extra dependencies, available by default. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | 是 | 文件路径,可以是绝对路径或相对于工作空间的路径 | -| `message` | string | 否 | 附带的消息说明 | +| `path` | string | Yes | File path, can be absolute or relative to workspace | +| `message` | string | No | Accompanying message | -## 使用场景 +## Use Cases -- 将生成的代码或文档发送给用户 -- 发送截图、图表 -- 分享下载的文件 +- Send generated code or documents to the user +- Send screenshots, charts +- Share downloaded files diff --git a/docs/tools/vision.mdx b/docs/tools/vision.mdx index 675afe41..4db6bec0 100644 --- a/docs/tools/vision.mdx +++ b/docs/tools/vision.mdx @@ -1,42 +1,42 @@ --- -title: vision - 图片理解 -description: 分析图片内容(识别、描述、OCR 等) +title: vision - Image Understanding +description: Analyze image content (recognition, description, OCR, etc.) --- -使用 Vision API 分析本地图片或图片 URL,支持内容描述、文字提取(OCR)、物体识别等。 +Analyze local images or image URLs using Vision API. Supports content description, text extraction (OCR), object recognition, and more. -## 模型选择 +## Model Selection -Vision 工具采用多级自动选择 + 自动兜底策略,无需手动配置即可使用: +The vision tool uses a multi-level auto-selection strategy with automatic fallback — no manual configuration required: -1. **主模型** — 优先使用当前配置的主模型进行图像识别(需要是多模态模型) -2. **其他已配置模型** — 自动发现已配置 API Key 的其他多模态模型作为备选 +1. **Main model** — uses the currently configured main model for image recognition (must be a multimodal model) +2. **Other configured models** — auto-discovers other multimodal models with configured API keys as alternatives -如果当前 provider 调用失败,会自动尝试下一个,直到成功或全部失败。 +If the current provider fails, the tool automatically tries the next one until it succeeds or all fail. -### 支持的模型 +### Supported Models -| 厂商 | 视觉模型 | 说明 | +| Provider | Vision Model | Notes | | --- | --- | --- | -| OpenAI / 兼容协议 | 使用主模型 | 支持所有 OpenAI 协议兼容的多模态模型 | -| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.6-plus 等 | -| Claude | 使用主模型 | Anthropic 原生图像格式 | -| Gemini | 使用主模型 | inlineData 格式 | -| 豆包 (Doubao) | 使用主模型 | doubao-seed-2-0 系列原生支持 | -| Kimi (Moonshot) | 使用主模型 | kimi-k2.6、kimi-k2.5 原生支持 | -| 百度千帆 (Qianfan) | 使用主模型 | 默认使用多模态主模型 (如 ernie-5.1),主模型不支持时兜底使用 ernie-4.5-turbo-vl | -| 智谱 AI | glm-5v-turbo | 固定使用视觉专用模型 | -| MiniMax | MiniMax-Text-01 | 固定使用视觉专用模型 | +| OpenAI / Compatible | Main model | All OpenAI-protocol-compatible multimodal models | +| Qwen (DashScope) | Main model | e.g. qwen3.6-plus, etc. | +| Claude | Main model | Anthropic native image format | +| Gemini | Main model | inlineData format | +| Doubao | Main model | doubao-seed-2-0 series natively supported | +| Kimi (Moonshot) | Main model | kimi-k2.6, kimi-k2.5 natively supported | +| ERNIE | Main model | Defaults to the multimodal main model (e.g. `ernie-5.1`); falls back to `ernie-4.5-turbo-vl` when the main model is not multimodal | +| ZhipuAI | glm-5v-turbo | Always uses the dedicated vision model | +| MiniMax | MiniMax-Text-01 | Always uses the dedicated vision model | - 智谱和 MiniMax 的文本模型不支持图像理解,因此始终使用对应的视觉专用模型,无需手动指定。 + ZhipuAI and MiniMax text models do not support image understanding, so their dedicated vision models are always used automatically. -> 当 `use_linkai=true` 时,默认使用 LinkAI 的多模态模型进行 +> When `use_linkai=true`, LinkAI's multimodal model is used by default. -## 自定义配置 +## Custom Configuration -如果希望指定 Vision 使用的模型,可在 `config.json` 中配置,例如: +To specify the model used by Vision, configure it in `config.json`, for example: ```json { @@ -48,28 +48,28 @@ Vision 工具采用多级自动选择 + 自动兜底策略,无需手动配置 } ``` -指定的模型会被**优先使用**,工具会根据模型名自动路由到对应的 provider;若调用失败,会自动 fallback 到其他已配置的 provider。 +The specified model is **used first**, and the tool automatically routes to the corresponding provider based on the model name; on failure, it falls back to other configured providers. -大多数情况下无需配置,主模型支持多模态或配置任意一个支持视觉的 API Key 即可自动工作。 +In most cases no configuration is needed — the tool works automatically as long as the main model supports multimodal input or any vision-capable API key is configured. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `image` | string | 是 | 本地文件路径或 HTTP(S) 图片 URL | -| `question` | string | 是 | 对图片提出的问题 | +| `image` | string | Yes | Local file path or HTTP(S) image URL | +| `question` | string | Yes | Question to ask about the image | -支持的图片格式:jpg、jpeg、png、gif、webp +Supported image formats: jpg, jpeg, png, gif, webp -## 使用场景 +## Use Cases -- 描述图片中的内容 -- 提取图片中的文字(OCR) -- 识别物体、颜色、场景 -- 分析截图、文档扫描图片等 +- Describe image content +- Extract text from images (OCR) +- Identify objects, colors, scenes +- Analyze screenshots and scanned documents - 超过 1MB 的图片会自动压缩后上传,所有图片(包括远程 URL)会统一转为 base64 传输,确保兼容所有模型后端。 + Images larger than 1MB are automatically compressed before upload. All images (including remote URLs) are converted to base64 for transmission to ensure compatibility with all model backends. diff --git a/docs/tools/web-fetch.mdx b/docs/tools/web-fetch.mdx index 12f85953..0a0349b9 100644 --- a/docs/tools/web-fetch.mdx +++ b/docs/tools/web-fetch.mdx @@ -1,32 +1,32 @@ --- -title: web_fetch - 网页获取 -description: 获取网页或文档内容 +title: web_fetch - Web Fetch +description: Fetch web pages and document content --- -获取 HTTP/HTTPS URL 的内容。对网页提取可读文本,对文档文件(PDF、Word、Excel 等)自动下载并解析内容。 +Fetch the content of an HTTP/HTTPS URL. Web pages are extracted as readable text; document files (PDF, Word, Excel, etc.) are downloaded and parsed automatically. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `url` | string | 是 | HTTP/HTTPS URL(网页或文档链接) | +| `url` | string | Yes | HTTP/HTTPS URL (web page or document) | -## 支持的文件类型 +## Supported file types -| 类型 | 格式 | +| Type | Formats | | --- | --- | | PDF | `.pdf` | | Word | `.docx` | -| 文本 | `.txt`、`.md`、`.csv`、`.log` | -| 表格 | `.xls`、`.xlsx` | -| 演示文稿 | `.ppt`、`.pptx` | +| Text | `.txt`, `.md`, `.csv`, `.log` | +| Spreadsheet | `.xls`, `.xlsx` | +| Presentation | `.ppt`, `.pptx` | -## 使用场景 +## Use cases -- 获取网页的文本内容 -- 下载并解析远程文档 -- 获取 API 响应内容 +- Extract readable text from a web page +- Download and parse remote documents +- Inspect API response bodies - `web_fetch` 只能获取静态 HTML 内容。如果页面需要 JavaScript 渲染(如 SPA 单页应用),请使用 `browser` 工具。 + `web_fetch` only retrieves static HTML. For pages that require JavaScript rendering (such as SPAs), use the `browser` tool instead. diff --git a/docs/tools/web-search.mdx b/docs/tools/web-search.mdx index 928eb633..09dff68e 100644 --- a/docs/tools/web-search.mdx +++ b/docs/tools/web-search.mdx @@ -1,26 +1,26 @@ --- -title: web_search - 联网搜索 -description: 搜索互联网获取实时信息,支持多个搜索厂商 +title: web_search - Web Search +description: Search the internet for real-time information, with support for multiple search providers --- -搜索互联网获取实时信息、新闻、研究等内容。支持博查、百度千帆、智谱、LinkAI 四个后端,配置任意一家即可使用。 +Search the internet for real-time information, news, research, and more. Supports four backends — Bocha, ERNIE, GLM, and LinkAI — and works once any one of them is configured. - 推荐通过 [Web 控制台](/channels/web) 的「模型管理 → 搜索」面板可视化配置厂商与策略,无需手动编辑配置文件。 + It is recommended to configure providers and routing strategy visually from the "Model Management → Search" panel in the [Web console](/channels/web), without manually editing the configuration file. -## 厂商 +## Providers -| 厂商 | 凭证 | 申请入口 | +| Provider | Credential | Apply | | --- | --- | --- | -| 博查 Bocha | `tools.web_search.bocha_api_key` | [博查开放平台](https://open.bochaai.com/) | -| 百度千帆 | 复用 `qianfan_api_key` | [千帆控制台](https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy) | -| 智谱 Zhipu | 复用 `zhipu_ai_api_key` | [智谱开放平台](https://docs.bigmodel.cn/cn/guide/tools/web-search) | -| LinkAI | 复用 `linkai_api_key` | [LinkAI 控制台](https://link-ai.tech/console/interface) | +| Bocha | `tools.web_search.bocha_api_key` | [Bocha Open Platform](https://open.bochaai.com/) | +| ERNIE | Reuses `qianfan_api_key` | [Qianfan Console](https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy) | +| Zhipu | Reuses `zhipu_ai_api_key` | [Zhipu Open Platform](https://docs.bigmodel.cn/cn/guide/tools/web-search) | +| LinkAI | Reuses `linkai_api_key` | [LinkAI Console](https://link-ai.tech/console/interface) | -除博查需要单独的 `bocha_api_key` 外,其他三家直接复用对应模型的 API Key,配好模型即同时获得搜索能力。 +Except for Bocha which requires a dedicated `bocha_api_key`, the other three reuse the corresponding model's API key — configuring the model automatically grants search capability. -## 路由策略 +## Routing Strategy ```json { @@ -33,19 +33,19 @@ description: 搜索互联网获取实时信息,支持多个搜索厂商 } ``` -- `auto`(默认):由 Agent 在已配置的厂商中智能选择,并可在一次任务中多次调用、切换不同厂商以获取更全面的结果;未指定时按 `bocha → qianfan → zhipu → linkai` 顺序兜底。 -- `fixed`:固定使用 `provider` 指定的厂商;该厂商凭证缺失时自动回落到 auto 顺序。 +- `auto` (default): the Agent intelligently picks among configured providers and may call multiple providers in a single task to gather more comprehensive results; when none is specified, falls back through `bocha → qianfan → zhipu → linkai`. +- `fixed`: always use the provider specified in `provider`; falls back to the auto order if that provider's credentials are missing. -## 工具参数 +## Tool Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `query` | string | 是 | 搜索关键词 | -| `count` | integer | 否 | 返回结果数量(1–50,默认 10) | -| `freshness` | string | 否 | 时间范围:`noLimit`(默认)、`oneDay`、`oneWeek`、`oneMonth`、`oneYear`,或日期范围如 `2025-01-01..2025-02-01` | -| `summary` | boolean | 否 | 是否返回页面摘要(默认 false) | -| `provider` | string | 否 | `auto` 策略下配置了多个厂商时可见,用于单次切换厂商 | +| `query` | string | Yes | Search keywords | +| `count` | integer | No | Number of results (1–50, default 10) | +| `freshness` | string | No | Time range: `noLimit` (default), `oneDay`, `oneWeek`, `oneMonth`, `oneYear`, or date range like `2025-01-01..2025-02-01` | +| `summary` | boolean | No | Whether to return page summaries (default false) | +| `provider` | string | No | Available when multiple providers are configured under the `auto` strategy; used to switch provider for a single call | - 四家凭证均未配置时,该工具不会注册到 Agent。 + If none of the four credentials are configured, this tool is not registered with the Agent. diff --git a/docs/tools/write.mdx b/docs/tools/write.mdx index 51cf66f1..2c0a10f0 100644 --- a/docs/tools/write.mdx +++ b/docs/tools/write.mdx @@ -1,27 +1,27 @@ --- -title: write - 文件写入 -description: 创建或覆盖写入文件 +title: write - File Write +description: Create or overwrite files --- -写入内容到文件。文件不存在则自动创建,已存在则覆盖。自动创建父目录。 +Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories. -## 依赖 +## Dependencies -无额外依赖,默认可用。 +No extra dependencies, available by default. -## 参数 +## Parameters -| 参数 | 类型 | 必填 | 说明 | +| Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `path` | string | 是 | 文件路径 | -| `content` | string | 是 | 要写入的内容 | +| `path` | string | Yes | File path | +| `content` | string | Yes | Content to write | -## 使用场景 +## Use Cases -- 创建新的代码文件或脚本 -- 生成配置文件 -- 保存处理结果 +- Create new code files or scripts +- Generate configuration files +- Save processing results - 单次写入不应超过 10KB。对于大文件,建议先创建骨架,再使用 edit 工具分块添加内容。 + Single writes should not exceed 10KB. For large files, create a skeleton first, then use the edit tool to add content in chunks. diff --git a/docs/zh/README.md b/docs/zh/README.md index fafc2f1e..d37f09ce 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -13,8 +13,8 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖

🌐 官网  ·  - 📖 文档中心  ·  - 🚀 快速开始  ·  + 📖 文档中心  ·  + 🚀 快速开始  ·  🧩 技能广场  ·  ☁️ 在线体验

@@ -25,15 +25,15 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖 | 能力 | 说明 | | :--- | :--- | -| [任务规划](https://docs.cowagent.ai/intro/architecture) | 理解复杂任务并自主分解执行,循环调用工具直到完成目标 | -| [长期记忆](https://docs.cowagent.ai/memory) | 三层记忆架构(上下文 → 天级 → 核心),梦境蒸馏自动整理,支持关键词与向量混合检索 | -| [知识库](https://docs.cowagent.ai/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 | -| [技能](https://docs.cowagent.ai/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 | -| [工具](https://docs.cowagent.ai/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 | -| [通道](https://docs.cowagent.ai/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 | +| [任务规划](https://docs.cowagent.ai/zh/intro/architecture) | 理解复杂任务并自主分解执行,循环调用工具直到完成目标 | +| [长期记忆](https://docs.cowagent.ai/zh/memory) | 三层记忆架构(上下文 → 天级 → 核心),梦境蒸馏自动整理,支持关键词与向量混合检索 | +| [知识库](https://docs.cowagent.ai/zh/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 | +| [技能](https://docs.cowagent.ai/zh/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 | +| [工具](https://docs.cowagent.ai/zh/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 | +| [通道](https://docs.cowagent.ai/zh/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 | | 多模态 | 文本、图片、语音、文件全消息类型支持,覆盖识别、生成、收发 | -| [模型](https://docs.cowagent.ai/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流厂商,配置一行切换 | -| [部署](https://docs.cowagent.ai/guide/quick-start) | 一键脚本安装,Web 控制台统一管理;本地、Docker、服务器多种部署方式 | +| [模型](https://docs.cowagent.ai/zh/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流厂商,配置一行切换 | +| [部署](https://docs.cowagent.ai/zh/guide/quick-start) | 一键脚本安装,Web 控制台统一管理;本地、Docker、服务器多种部署方式 |
@@ -43,7 +43,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖 CowAgent 是一个完整的 **Agent Harness**:消息从各类**通道**进入,**Agent Core** 结合记忆、知识库与可用工具/技能进行任务规划与决策,调用**模型**生成结果,再回传至原通道。各模块解耦清晰,按需扩展。 -详见 [项目架构](https://docs.cowagent.ai/intro/architecture)。 +详见 [项目架构](https://docs.cowagent.ai/zh/intro/architecture)。
@@ -74,9 +74,9 @@ docker compose up -d > 服务器部署且需要公网访问控制台时,请在 `config.json` 中将 `web_host` 设为 `0.0.0.0`(同时强烈建议设置 `web_password` 启用鉴权),然后访问 `http://:9899`,并确保防火墙/安全组放行 `9899` 端口。 -> 📖 详细安装指南:[快速开始](https://docs.cowagent.ai/guide/quick-start) · [源码安装](https://docs.cowagent.ai/guide/manual-install) · [升级](https://docs.cowagent.ai/guide/upgrade) +> 📖 详细安装指南:[快速开始](https://docs.cowagent.ai/zh/guide/quick-start) · [源码安装](https://docs.cowagent.ai/zh/guide/manual-install) · [升级](https://docs.cowagent.ai/zh/guide/upgrade) -安装后可使用 `cow` [CLI 命令](https://docs.cowagent.ai/cli) 管理服务: +安装后可使用 `cow` [CLI 命令](https://docs.cowagent.ai/zh/cli) 管理服务: ```bash cow start | stop | restart # 服务管理 @@ -94,21 +94,21 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | -| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | -| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [智谱 GLM](https://docs.cowagent.ai/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [通义千问](https://docs.cowagent.ai/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [豆包 Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | -| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | -| [百度ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | -| [小米 MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | -| [LinkAI](https://docs.cowagent.ai/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| [自定义](https://docs.cowagent.ai/models/custom) | 本地模型 / 三方代理 | ✅ | | | | | | +| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | +| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | +| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | +| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | +| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | +| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | +| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [自定义](https://docs.cowagent.ai/zh/models/custom) | 本地模型 / 三方代理 | ✅ | | | | | | -> 推荐通过 Web 控制台在线配置,无需手动编辑文件。手动配置请参考各厂商文档,详见 [模型概览](https://docs.cowagent.ai/models)。 +> 推荐通过 Web 控制台在线配置,无需手动编辑文件。手动配置请参考各厂商文档,详见 [模型概览](https://docs.cowagent.ai/zh/models)。
@@ -118,20 +118,20 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | 通道 | 文本 | 图片 | 文件 | 语音 | 群聊 | | --- | :-: | :-: | :-: | :-: | :-: | -| [Web 控制台](https://docs.cowagent.ai/channels/web)(默认) | ✅ | ✅ | ✅ | ✅ | | -| [微信](https://docs.cowagent.ai/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | -| [飞书](https://docs.cowagent.ai/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [钉钉](https://docs.cowagent.ai/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [企微智能机器人](https://docs.cowagent.ai/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | -| [企业微信应用](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | -| [微信客服](https://docs.cowagent.ai/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | -| [微信公众号](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | -| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | -| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | -| [Discord](https://docs.cowagent.ai/channels/discord) | ✅ | ✅ | ✅ | | ✅ | +| [Web 控制台](https://docs.cowagent.ai/zh/channels/web)(默认) | ✅ | ✅ | ✅ | ✅ | | +| [微信](https://docs.cowagent.ai/zh/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | +| [飞书](https://docs.cowagent.ai/zh/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [钉钉](https://docs.cowagent.ai/zh/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [企微智能机器人](https://docs.cowagent.ai/zh/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [QQ](https://docs.cowagent.ai/zh/channels/qq) | ✅ | ✅ | ✅ | | ✅ | +| [企业微信应用](https://docs.cowagent.ai/zh/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [微信客服](https://docs.cowagent.ai/zh/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | +| [微信公众号](https://docs.cowagent.ai/zh/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](https://docs.cowagent.ai/zh/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/zh/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/zh/channels/discord) | ✅ | ✅ | ✅ | | ✅ | -> 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/channels)。 +> 飞书、企微智能机器人支持在 Web 控制台内**扫码一键接入**,无需公网 IP。详见 [通道概览](https://docs.cowagent.ai/zh/channels)。 CowAgent Web 控制台 @@ -141,9 +141,9 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 ## 🧠 记忆与知识库 -**长期记忆**采用三层架构:对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期)。每日自动执行**梦境蒸馏(Deep Dream)**,将分散记忆整合为精炼的长期记忆并生成叙事日记。详见 [长期记忆](https://docs.cowagent.ai/memory) · [梦境蒸馏](https://docs.cowagent.ai/memory/deep-dream)。 +**长期记忆**采用三层架构:对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期)。每日自动执行**梦境蒸馏(Deep Dream)**,将分散记忆整合为精炼的长期记忆并生成叙事日记。详见 [长期记忆](https://docs.cowagent.ai/zh/memory) · [梦境蒸馏](https://docs.cowagent.ai/zh/memory/deep-dream)。 -**个人知识库** 与按时间记录的记忆不同,以**主题为维度**组织结构化知识。Agent 在对话中自动整理有价值信息,维护交叉引用与索引,Web 控制台可可视化浏览知识图谱。详见 [个人知识库](https://docs.cowagent.ai/knowledge)。 +**个人知识库** 与按时间记录的记忆不同,以**主题为维度**组织结构化知识。Agent 在对话中自动整理有价值信息,维护交叉引用与索引,Web 控制台可可视化浏览知识图谱。详见 [个人知识库](https://docs.cowagent.ai/zh/knowledge)。
@@ -171,7 +171,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 **MCP 协议** 通过 [Model Context Protocol](https://modelcontextprotocol.io) 接入开放生态中的各种 MCP 服务,配置一次 `mcp.json` 即用即得,支持 stdio / SSE 协议、热更新、零代码接入。 -详见 [工具概览](https://docs.cowagent.ai/tools) · [MCP 集成](https://docs.cowagent.ai/tools/mcp)。 +详见 [工具概览](https://docs.cowagent.ai/zh/tools) · [MCP 集成](https://docs.cowagent.ai/zh/tools/mcp)。 ### 技能系统 @@ -185,7 +185,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 /skill install <名称> # 一键安装 ``` -详见 [技能概览](https://docs.cowagent.ai/skills) · [创建技能](https://docs.cowagent.ai/skills/create)。 +详见 [技能概览](https://docs.cowagent.ai/zh/skills) · [创建技能](https://docs.cowagent.ai/zh/skills/create)。
@@ -205,7 +205,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 > **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — 正式升级为超级 Agent 助理,支持多轮任务决策、长期记忆、Skills 框架 -完整更新历史:[Release Notes](https://docs.cowagent.ai/releases) +完整更新历史:[Release Notes](https://docs.cowagent.ai/zh/releases)
diff --git a/docs/zh/channels/dingtalk.mdx b/docs/zh/channels/dingtalk.mdx new file mode 100644 index 00000000..df11019e --- /dev/null +++ b/docs/zh/channels/dingtalk.mdx @@ -0,0 +1,56 @@ +--- +title: 钉钉 +description: 将 CowAgent 接入钉钉应用 +--- + +通过钉钉开放平台创建智能机器人应用,将 CowAgent 接入钉钉。 + +## 一、创建应用 + +1. 进入 [钉钉开发者后台](https://open-dev.dingtalk.com/fe/app#/corp/app),登录后点击 **创建应用**,填写应用相关信息: + + + +2. 点击添加应用能力,选择 **机器人** 能力,点击 **添加**: + + + +3. 配置机器人信息后点击 **发布**。发布后,点击 "**点击调试**",会自动创建测试群聊,可在客户端查看: + + + +4. 点击 **版本管理与发布**,创建新版本发布: + + + +## 二、项目配置 + +1. 点击 **凭证与基础信息**,获取 `Client ID` 和 `Client Secret`: + + + +2. 将以下配置加入项目根目录的 `config.json` 文件: + +```json +{ + "channel_type": "dingtalk", + "dingtalk_client_id": "YOUR_CLIENT_ID", + "dingtalk_client_secret": "YOUR_CLIENT_SECRET" +} +``` + +3. 安装依赖: + +```bash +pip3 install dingtalk_stream +``` + +4. 启动项目后,在钉钉开发者后台点击 **事件订阅**,点击 **已完成接入,验证连接通道**,显示 **连接接入成功** 即表示配置完成: + + + +## 三、使用 + +与机器人私聊或将机器人拉入企业群中均可开启对话: + + diff --git a/docs/zh/channels/discord.mdx b/docs/zh/channels/discord.mdx new file mode 100644 index 00000000..0563ab4c --- /dev/null +++ b/docs/zh/channels/discord.mdx @@ -0,0 +1,93 @@ +--- +title: Discord +description: 将 CowAgent 接入 Discord Bot +--- + +> 通过 Discord Bot 的 **Gateway 长连接** 接入 CowAgent,支持私聊(DM)与服务器频道(@机器人 / 回复机器人触发)。Gateway 基于 WebSocket 长连接,无需公网 IP 与回调地址,开箱即用。 + +## 一、接入步骤 + +### 步骤一:创建 Discord 应用与 Bot + +1. 打开 [Discord 开发者后台](https://discord.com/developers/applications),点击 **New Application**,填写名称(如 `CowAgent`)并创建。 +2. 左侧菜单进入 **Bot** 页面,点击 **Reset Token** 生成 Bot Token,复制并妥善保存(仅显示一次)。 + + + 这个 Token 等同于 Bot 的密码,请勿泄露。若意外泄漏,在 Bot 页面再次点击 **Reset Token** 重置即可。 + + +### 步骤二:开启 Message Content Intent + +私聊与频道读取文本均依赖该权限。 + +1. 在 **Bot** 页面下方找到 **Privileged Gateway Intents**。 +2. 打开 **Message Content Intent** 开关并保存。 + + + 未开启该开关时,机器人收到的消息内容会为空,导致无响应。 + + +### 步骤三:邀请 Bot 进入服务器 + +1. 左侧菜单进入 **OAuth2 → URL Generator**。 +2. **Scopes** 勾选 `bot`。 +3. **Bot Permissions** 至少勾选:`Send Messages`、`Read Message History`、`Attach Files`、`View Channels`。 +4. 复制底部生成的授权链接,在浏览器打开,选择目标服务器完成授权。 + + + 仅需私聊(DM)可跳过此步,但仍需先在任意共同服务器中与机器人建立 DM 通道,或由用户主动私聊机器人。 + + +### 步骤四:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Discord**,填入 Bot Token,点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "discord", + "discord_token": "your-discord-bot-token", + "discord_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `discord_token` | 开发者后台 Bot 页面生成的 Bot Token | - | + | `discord_group_trigger` | 频道触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Discord] Bot logged in as CowAgent#1234 (id=123456789) +[Discord] ✅ Discord bot ready, listening for messages +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 私聊(DM) | ✅ | +| 服务器频道(@机器人 / 回复机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | + + + Discord 单条消息上限为 2000 字符,超长回复会自动按换行拆分为多条发送。 + + +## 三、使用 + +完成接入后: + +- **私聊(DM)**:在服务器成员列表中找到你的机器人,点击头像直接发消息对话。 +- **频道**:在已邀请机器人的频道中,使用 `@你的机器人 你好` 或 **回复机器人的某条消息** 触发对话。 + +发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/zh/channels/feishu.mdx b/docs/zh/channels/feishu.mdx new file mode 100644 index 00000000..5cb8fe80 --- /dev/null +++ b/docs/zh/channels/feishu.mdx @@ -0,0 +1,111 @@ +--- +title: 飞书 +description: 将 CowAgent 接入飞书应用 +--- + +> 通过飞书自建应用接入 CowAgent,支持单聊与群聊(@机器人),使用 WebSocket 长连接模式,无需公网 IP,支持流式打字机回复、语音消息收发。 + + + 接入需要是飞书企业用户且具有企业管理权限。 + + +## 一、接入方式 + +### 方式一:扫码一键接入(推荐) + +启动 Cow 项目后在终端中即可完成扫码创建。或打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,点击 **一键创建飞书应用**,使用 **飞书 App** 扫描二维码即可自动完成应用创建并接入: + + + + + + + 1. `lark-oapi` 依赖版本需要 >=1.5.5 + 2. 扫码创建出的应用会自动预置全部所需权限(消息收发、卡片读写、群聊事件等)和事件订阅,无需到开发者后台手动配置。 + + + +### 方式二:手动创建接入 + +需要先在飞书开放平台创建自建应用并配置权限,再通过 Web 控制台或配置文件接入。 + +**步骤一:创建应用** + +1. 进入 [飞书开发平台](https://open.feishu.cn/app/),点击 **创建企业自建应用**: + + + +2. 在 **添加应用能力** 中,为应用添加 **机器人** 能力: + + + +3. 在 **权限管理** 中,将以下权限粘贴到输入框,全选并 **批量开通**: + +``` +im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write +``` + + + +4. 在 **凭证与基础信息** 中获取 `App ID` 和 `App Secret`: + + + +**步骤二:接入 CowAgent** + + + + 打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,切换到「手动填写」Tab,输入 App ID 和 App Secret,点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动程序: + + ```json + { + "channel_type": "feishu", + "feishu_app_id": "YOUR_APP_ID", + "feishu_app_secret": "YOUR_APP_SECRET", + "feishu_stream_reply": true + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `feishu_app_id` | 飞书应用 App ID | - | + | `feishu_app_secret` | 飞书应用 App Secret | - | + | `feishu_stream_reply` | 是否开启流式打字机回复 | `true` | + + + +**步骤三:发布应用** + +1. 启动 Cow 项目后,在飞书开放平台点击 **事件与回调**,选择 **长连接** 模式并保存: + + + +2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。 + +3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过: + + + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 单聊 | ✅ | +| 群聊(@机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 语音消息 | ✅ 收发 | +| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) | + + + 流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。 + + +## 三、使用 + +完成接入后,在飞书中搜索机器人名称即可开始单聊对话。 + +如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。 diff --git a/docs/zh/channels/index.mdx b/docs/zh/channels/index.mdx new file mode 100644 index 00000000..3a930ce1 --- /dev/null +++ b/docs/zh/channels/index.mdx @@ -0,0 +1,45 @@ +--- +title: 通道概览 +description: CowAgent 支持的通道及能力矩阵 +--- + +CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换。Web 控制台默认开启,可与其他接入通道并行运行。 + +## 能力矩阵 + +下表汇总各通道支持的入站消息类型、机器人回复类型与群聊能力,方便按场景选择。 + +| 通道 | 文本 | 图片 | 文件 | 语音 | 群聊 | +| --- | :-: | :-: | :-: | :-: | :-: | +| [微信](/zh/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | +| [Web 控制台](/zh/channels/web) | ✅ | ✅ | ✅ | ✅ | | +| [飞书](/zh/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [钉钉](/zh/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [企微智能机器人](/zh/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [QQ](/zh/channels/qq) | ✅ | ✅ | ✅ | | ✅ | +| [企业微信应用](/zh/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [公众号](/zh/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [Telegram](/zh/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](/zh/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](/zh/channels/discord) | ✅ | ✅ | ✅ | | ✅ | + +- **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档 +- **群聊**列指可识别并响应群消息 + + + 每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/models)。 + + +## 通道一览 + +- [Web 控制台](/zh/channels/web) — 内置浏览器对话和管理面板,默认开启 +- [微信](/zh/channels/weixin) — 通过个人微信扫码登录 +- [飞书](/zh/channels/feishu) — 飞书自建机器人 +- [钉钉](/zh/channels/dingtalk) — 钉钉自建机器人 +- [企微智能机器人](/zh/channels/wecom-bot) — 企业微信智能机器人 +- [QQ](/zh/channels/qq) — QQ 官方机器人开放平台 +- [企业微信应用](/zh/channels/wecom) — 企业微信自建应用接入 +- [公众号](/zh/channels/wechatmp) — 微信公众号(订阅号 / 服务号) +- [Telegram](/zh/channels/telegram) — 海外 IM,5 分钟接入,无需公网 IP +- [Slack](/zh/channels/slack) — 团队协作 IM,Socket Mode 接入,无需公网 IP +- [Discord](/zh/channels/discord) — 社区 IM,Gateway 长连接接入,无需公网 IP diff --git a/docs/zh/channels/qq.mdx b/docs/zh/channels/qq.mdx new file mode 100644 index 00000000..3b7554a9 --- /dev/null +++ b/docs/zh/channels/qq.mdx @@ -0,0 +1,88 @@ +--- +title: QQ 机器人 +description: 将 CowAgent 接入 QQ 机器人(WebSocket 长连接模式) +--- + +> 通过 QQ 开放平台的机器人接口接入 CowAgent,支持 QQ 单聊、QQ 群聊(@机器人)、频道消息和频道私信,无需公网 IP,使用 WebSocket 长连接模式。 + + + QQ 机器人通过 QQ 开放平台创建,使用 WebSocket 长连接接收消息,通过 OpenAPI 发送消息,无需公网 IP 和域名。 + + +## 一、创建 QQ 机器人 + +> 进入[QQ 开放平台](https://q.qq.com),QQ扫码登录,如果未注册开放平台账号,请先完成[账号注册](https://q.qq.com/#/register)。 + +1.在 [QQ开放平台-机器人列表页](https://q.qq.com/#/apps),点击创建机器人: + + + +2.填写机器人名称、头像等基本信息,完成创建: + + + +3.点击进入机器人配置页面,选择**开发管理**菜单,完成以下步骤: + + - 复制并记录 **AppID**(机器人ID) + - 生成并记录 **AppSecret**(机器人秘钥) + + + +## 二、配置和运行 + +### 方式一:Web 控制台接入 + +启动 Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **QQ 机器人**,填写上一步保存的 AppID 和 AppSecret,点击接入即可。 + + + +### 方式二:配置文件接入 + +在 `config.json` 中添加以下配置: + +```json +{ + "channel_type": "qq", + "qq_app_id": "YOUR_APP_ID", + "qq_app_secret": "YOUR_APP_SECRET" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `qq_app_id` | QQ 机器人的 AppID,在开放平台开发管理中获取 | +| `qq_app_secret` | QQ 机器人的 AppSecret,在开放平台开发管理中获取 | + +配置完成后启动程序,日志显示 `[QQ] ✅ Connected successfully` 即表示连接成功。 + + +## 三、使用 + +在 QQ开放平台 - 管理 - **使用范围和人员** 菜单中,使用QQ客户端扫描 "添加到群和消息列表" 的二维码,即可开始与QQ机器人的聊天: + + + +对话效果: + + +## 四、功能说明 + +> 注意:若需在群聊及频道中使用QQ机器人,需完成发布上架审核并在使用范围配置权限使用范围。 + +| 功能 | 支持情况 | +| --- | --- | +| QQ 单聊 | ✅ | +| QQ 群聊(@机器人) | ✅ | +| 频道消息(@机器人) | ✅ | +| 频道私信 | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发(群聊和单聊) | +| 文件消息 | ✅ 发送(群聊和单聊) | +| 定时任务 | ✅ 主动推送(每月每用户限 4 条) | + + +## 五、注意事项 + +- **被动消息限制**:QQ 单聊被动消息有效期为 60 分钟,每条消息最多回复 5 次;QQ 群聊被动消息有效期为 5 分钟。 +- **主动消息限制**:单聊和群聊每月主动消息上限为 4 条,在使用定时任务功能时需要注意这个限制 +- **事件权限**:默认订阅 `GROUP_AND_C2C_EVENT`(QQ群/单聊)和 `PUBLIC_GUILD_MESSAGES`(频道公域消息),如需其他事件类型请在开放平台申请权限。 diff --git a/docs/zh/channels/slack.mdx b/docs/zh/channels/slack.mdx new file mode 100644 index 00000000..1103f1c0 --- /dev/null +++ b/docs/zh/channels/slack.mdx @@ -0,0 +1,118 @@ +--- +title: Slack +description: 将 CowAgent 接入 Slack App +--- + +> 通过 Slack App 的 **Socket Mode** 接入 CowAgent,支持私聊(DM)与频道(@机器人 / 线程内回复触发)。Socket Mode 基于长连接,无需公网 IP 与回调地址,开箱即用。 + +## 一、接入步骤 + +### 步骤一:创建 Slack App + +1. 打开 [Slack API 应用管理页](https://api.slack.com/apps),点击 **Create New App** → **From scratch**。 +2. 填写 **App Name**(如 `CowAgent`),选择要安装的 **Workspace**,点击创建。 + +### 步骤二:开启 Socket Mode 并获取 App Token + +1. 左侧菜单进入 **Settings → Socket Mode**,打开 **Enable Socket Mode**。 +2. 系统会提示生成一个 **App-Level Token**,作用域勾选 `connections:write`,生成后保存这串以 `xapp-` 开头的 Token。 + + + Socket Mode 通过 WebSocket 长连接接收事件,无需在公网暴露回调 URL,适合本地或内网部署。 + + +### 步骤三:配置 Bot 权限并安装 + +1. 进入 **Features → OAuth & Permissions**,在 **Bot Token Scopes** 中点击 **Add an OAuth Scope**,逐项添加以下权限: + + ``` + app_mentions:read + channels:history + chat:write + commands + files:read + files:write + groups:history + im:history + mpim:history + users:read + ``` + + + `files:read` / `files:write` 用于图片、文件的收发;若仅需文本对话可省略。 + + +2. 进入 **Features → Event Subscriptions**,打开 **Enable Events**,在 **Subscribe to bot events** 中点击 **Add Bot User Event** 添加以下事件: + + ``` + app_mention + message.im + message.channels + ``` + + + 如需在私有频道使用,再添加 `message.groups`。 + +3. 进入 **Features → App Home**,在 **Show Tabs** 区域勾选 **Messages Tab**,并勾选下方的 **Allow users to send Slash commands and messages from the messages tab**(允许用户从消息标签页发送消息),否则私聊输入框会被关闭、无法给机器人发消息。 +4. 回到 **OAuth & Permissions**,点击 **Install to Workspace** 完成安装,安装后获取以 `xoxb-` 开头的 **Bot User OAuth Token**。 + + + 若 Slack 客户端仍提示「向此应用发送消息的功能已关闭」,请确认已完成上一步的 App Home 设置,并刷新或重启 Slack 客户端(必要时把 App 从对话列表移除后重新打开)。 + + +### 步骤四:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Slack**,分别填入 Bot Token(`xoxb-`)和 App Token(`xapp-`),点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "slack", + "slack_bot_token": "xoxb-xxxxxxxxxxxx", + "slack_app_token": "xapp-xxxxxxxxxxxx", + "slack_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `slack_bot_token` | Bot User OAuth Token,形如 `xoxb-...` | - | + | `slack_app_token` | App-Level Token(开启 Socket Mode 后生成),形如 `xapp-...` | - | + | `slack_group_trigger` | 频道触发方式:`mention_or_reply`(@或线程内回复)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx +[Slack] ✅ Slack bot ready, listening for events +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 私聊(DM) | ✅ | +| 频道(@机器人 / 线程内回复) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| 线程回复 | ✅ 回复发送至触发消息所在线程 | + + + Slack 通过线程(Thread)组织对话。机器人会把回复发送到触发消息所在的线程,频道内更整洁。 + + +## 三、使用 + +完成接入后: + +- **私聊(DM)**:在 Slack 左侧 **Apps** 中找到你的 App,直接发消息对话。 +- **频道**:把 App 邀请进频道(`/invite @你的App`),使用 `@你的App 你好` 触发对话;后续在同一线程内直接回复即可继续对话。 + +发送图片或文件时,可以在附件的输入框中 **添加文字说明**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/zh/channels/telegram.mdx b/docs/zh/channels/telegram.mdx new file mode 100644 index 00000000..d7ab7a44 --- /dev/null +++ b/docs/zh/channels/telegram.mdx @@ -0,0 +1,112 @@ +--- +title: Telegram +description: 将 CowAgent 接入 Telegram Bot +--- + +> 通过 Telegram Bot API 接入 CowAgent,支持单聊与群聊(@机器人 / 回复机器人触发),使用 Long Polling 模式无需公网 IP,开箱即用。 + + +## 一、接入步骤 + +### 步骤一:通过 BotFather 创建 Bot + +1. 在 Telegram 中搜索并打开官方账号 [@BotFather](https://t.me/BotFather)。 +2. 发送 `/newbot` 命令,按提示输入: + - **Bot 名称**(显示名,可中文,例如 `My CowAgent Bot`) + - **Bot 用户名**(必须以 `bot` 结尾,例如 `my_cowagent_bot`) +3. 创建成功后,BotFather 会返回一段 **HTTP API Token**(形如 `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`),妥善保存。 + + + 这个 Token 等同于 Bot 的密码,请勿泄露。如果意外泄漏可向 `@BotFather` 发送 `/revoke` 重置。 + + +### 步骤二:(群聊使用)关闭 Privacy Mode + +仅使用单聊可跳过此步。Telegram Bot 默认开启 **Privacy Mode**,群聊中只能收到带 `@bot` 的命令(如 `/start@your_bot`)以及对 bot 消息的 reply;**普通的 `@bot 你好` 文字消息收不到**,会导致群聊无响应。 + +向 `@BotFather` 发送: + +1. `/setprivacy` +2. 选择刚才创建的 bot +3. 选择 `Disable` + + + 若设置后群聊仍无响应,可尝试把 Bot 从群里移除并重新拉入。 + + +### 步骤三:接入 CowAgent + + + + 打开 Web 控制台(本地链接:http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **Telegram**,填入 Bot Token,点击接入即可。 + + + 在 `config.json` 中添加以下配置后启动: + + ```json + { + "channel_type": "telegram", + "telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "telegram_group_trigger": "mention_or_reply" + } + ``` + + | 参数 | 说明 | 默认值 | + | --- | --- | --- | + | `telegram_token` | BotFather 返回的 HTTP API Token | - | + | `telegram_group_trigger` | 群聊触发方式:`mention_or_reply`(@或回复机器人)/ `mention_only`(仅@) / `all`(所有消息) | `mention_or_reply` | + | `telegram_register_commands` | 启动时是否自动向 BotFather 注册命令菜单 | `true` | + | `telegram_proxy` | (可选)代理地址,如 `http://127.0.0.1:7890`、`socks5://127.0.0.1:1080`;运行环境无法直连 `api.telegram.org` 时配置,留空则使用环境变量 `HTTPS_PROXY` | `""` | + + + +启动 Cow 后,日志中出现以下输出即表示接入成功: + +``` +[Telegram] Bot logged in as @my_cowagent_bot (id=123456789) +[Telegram] Registered 10 bot commands +[Telegram] ✅ Telegram bot ready, polling for updates +``` + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 单聊 | ✅ | +| 群聊(@机器人 / 回复机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 语音消息 | ✅ 收发(接收 OGG/Opus,发送 OGG/Opus) | +| 视频消息 | ✅ 收发 | +| 文件消息 | ✅ 收发(PDF / Word / Excel 等) | +| 命令菜单 | ✅ 与 Web 控制台 slash 命令一致 | + +### 命令菜单 + +启动时会自动向 BotFather 注册命令菜单,用户在 Telegram 输入框输入 `/` 会出现下拉提示: + +| 命令 | 说明 | +| --- | --- | +| `/help` | 显示命令帮助 | +| `/status` | 查看运行状态 | +| `/context` | 查看对话上下文(`/context clear` 清除) | +| `/skill` | 技能管理(`/skill list`、`/skill install` 等) | +| `/memory` | 记忆管理(`/memory dream`) | +| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) | +| `/config` | 查看当前配置 | +| `/cancel` | 中止当前正在运行的 Agent 任务 | +| `/logs` | 查看最近日志 | +| `/version` | 查看版本 | + + + Telegram 命令菜单只能展示一级命令,子命令通过空格输入即可,例如 `/skill list`、`/context clear`。 + + +## 三、使用 + +完成接入后: + +- **单聊**:在 Telegram 中搜索你创建的 Bot 用户名(如 `@my_cowagent_bot`),点击 `Start` 即可开始对话。 +- **群聊**:把 Bot 拉进群,使用 `@bot 你好` 或 **回复 Bot 的某条消息** 触发对话。若群聊无响应,请检查 Privacy Mode 是否已按 [步骤二](#步骤二-群聊使用-关闭-privacy-mode) 关闭。 + +发送图片或文件时,可以直接在附件上方的输入框中 **添加 Caption**(描述/问题)一并发送,机器人会结合附件回答。也支持先发附件再发问题,两条消息会自动合并提问。 diff --git a/docs/zh/channels/web.mdx b/docs/zh/channels/web.mdx new file mode 100644 index 00000000..810da3da --- /dev/null +++ b/docs/zh/channels/web.mdx @@ -0,0 +1,95 @@ +--- +title: Web 控制台 +description: 通过 Web 控制台使用 CowAgent +--- + +Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏览器即可与 Agent 对话,并支持在线管理模型、技能、记忆、通道等配置。 + +## 配置 + +```json +{ + "channel_type": "web", + "web_host": "0.0.0.0", + "web_port": 9899, + "web_password": "", + "enable_thinking": false +} +``` + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `channel_type` | 设为 `web` | `web` | +| `web_host` | Web 服务监听地址,默认监听 `127.0.0.1`(仅本机),如需公网访问请改为 `0.0.0.0` 并设置密码 | `""` | +| `web_port` | Web 服务监听端口 | `9899` | +| `web_password` | 访问密码,留空表示不启用密码保护;监听 `0.0.0.0` 时建议设置 | `""` | +| `web_session_expire_days` | 登录会话有效天数 | `30` | +| `web_file_serve_root` | web 端可直接读取/发送的文件根目录,默认仅限用户主目录及 agent 工作空间;设为 `/` 可放开整个文件系统 | `"~"` | +| `enable_thinking` | 是否启用深度思考模式 | `false` | + +配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。密码也支持在控制台的「配置」页面中在线修改。 + +## 访问地址 + +启动项目后访问: + +- 本地运行:`http://localhost:9899` +- 服务器运行:`http://:9899` + + + 请确保服务器防火墙和安全组已放行对应端口。 + + +## 功能介绍 + +### 对话界面 + +支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程。深度思考功能可通过配置或控制台的「Agent 配置」开关控制。 + + + +#### 多会话管理 + +对话界面支持多会话(Session)管理,所有会话记录持久化存储在数据库中: + +- **会话列表**:点击左侧历史会话图标可展开/收起会话列表面板,支持滚动加载全部历史会话 +- **AI 生成标题**:新会话在首轮对话完成后,自动调用模型生成简短的会话摘要标题 +- **新建会话**:点击会话列表顶部的「新对话」按钮或输入区的 `+` 按钮创建新会话 +- **删除会话**:点击会话项的删除按钮,确认后永久删除该会话及其所有消息 +- **清除上下文**:点击输入区的清除按钮,在当前会话中插入一条分隔线,分隔线以上的消息仍然展示但不再作为模型的上下文输入 + +### 模型管理 + +支持在线管理不同模型厂商的文本、图像、语音、向量模型配置,无需手动编辑配置文件: + + + +### 技能管理 + +支持在线查看和管理 Agent 技能(Skills): + + + +### 记忆管理 + +支持在线查看和管理 Agent 记忆: + + + +### 通道管理 + +支持在线管理接入通道,支持实时连接/断开操作: + + + +### 定时任务 + +支持在线查看和管理定时任务,包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理: + + + +### 日志 + +支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题: + + diff --git a/docs/zh/channels/wechat-kf.mdx b/docs/zh/channels/wechat-kf.mdx new file mode 100644 index 00000000..ca83aaed --- /dev/null +++ b/docs/zh/channels/wechat-kf.mdx @@ -0,0 +1,130 @@ +--- +title: 微信客服 +description: 将 CowAgent 接入微信客服(WeCom Customer Service) +--- + +通过把企业微信自建应用绑定到「微信客服」账号,CowAgent 可以接管来自外部微信用户的客服咨询,并可在小程序、公众号、视频号及视频号小店等场景中通过链接或二维码触达微信用户。 + + + 微信客服只能使用 Docker 部署或服务器 Python 部署,需要公网可达的回调地址,不支持本地运行模式。 + + +## 一、准备 + +需要的资源: + +1. 一台服务器(有公网 IP) +2. 注册并已认证的企业微信 +3. 已开通「微信客服」能力 + + + 建议**单独再创建一个企微自建应用**用于微信客服,不要复用已有的 `wechatcom_app` 应用,否则两个通道会争抢同一个回调地址。 + + +## 二、创建企业微信自建应用 + +1. 在 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#apps),点击 **应用管理 → 创建应用**: + + + +2. 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechat_kf_corp_id`): + + + +3. 进入上一步创建的应用,点击 Secret 旁的「**查看**」,Secret 会推送到管理员手机端的企业微信里查看: + + + +4. 进入应用 **接收消息 → 设置API接收**,点击右侧「**随机获取**」生成 **Token** 和 **EncodingAESKey** 并保存: + + + + + 此时保存 API 接收配置会失败,因为程序还未启动,等项目运行后再回来保存。 + + +## 三、配置和运行 + +填入上一步收集到的 4 个字段(Corp ID / Secret / Token / EncodingAESKey): + + + + 启动 Cow 项目后打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **微信客服**,依次填入 Corp ID / Secret / Token / AES Key(端口默认 9888,可改),点击接入即可。 + + + + + 在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图): + + ```json + { + "channel_type": "wechat_kf", + "wechat_kf_corp_id": "YOUR_CORP_ID", + "wechat_kf_secret": "YOUR_SECRET", + "wechat_kf_token": "YOUR_TOKEN", + "wechat_kf_aes_key": "YOUR_AES_KEY", + "wechat_kf_port": 9888 + } + ``` + + | 参数 | 说明 | + | --- | --- | + | `wechat_kf_corp_id` | 企业 ID | + | `wechat_kf_secret` | 绑定到微信客服的那个企微自建应用的 Secret | + | `wechat_kf_token` | API 接收配置中的 Token | + | `wechat_kf_aes_key` | API 接收配置中的 EncodingAESKey | + | `wechat_kf_port` | 监听端口,默认 9888 | + + + +接入完成后启动程序(Web 控制台方式会自动重启),日志中出现 `Listening on http://0.0.0.0:9888/wxkf/` 表示运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。 + +接着回到企业微信「接收消息 → 设置API接收」,将回调 URL 填为 `http://:9888/wxkf/`,点击保存。保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息: + + + + + + + 如遇到 URL 配置回调不通过或配置失败: + 1. 确保服务器防火墙关闭且安全组放行监听端口(默认 9888) + 2. 仔细检查 Token、Secret、EncodingAESKey 等参数配置是否一致,URL 格式是否正确 + 3. 认证企业微信需要配置与主体一致的备案域名 + + +## 四、绑定微信客服账号 + +进入企业微信管理后台 **微信客服** 页面,创建客服账号并与上一步的企微自建应用绑定: + + + + + + + +绑定完成后,进入 **微信客服 → 微信客服账号详情**,在「**接入链接**」一栏: + +- 点击「**复制链接**」可获得形如 `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` 的访问链接 +- 点击「**生成二维码**」可获得对应的二维码 + +把链接或二维码推给微信客户使用即可: + + + +## 五、使用 + +微信用户通过链接或二维码进入客服对话后,即可与 AI 进行多轮对话,支持文本、图片、语音等消息: + + + +除此之外,基于微信生态官方能力,还可将微信客服应用在公众号、小程序、视频号及视频号小店等场景,详情可查看企业微信管理后台 [微信客服 → 接入场景](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) 的相关说明: + + + +## 常见问题 + +需要确保已安装以下依赖: + +```bash +pip install websocket-client pycryptodome +``` diff --git a/docs/zh/channels/wechatmp.mdx b/docs/zh/channels/wechatmp.mdx new file mode 100644 index 00000000..917df41d --- /dev/null +++ b/docs/zh/channels/wechatmp.mdx @@ -0,0 +1,72 @@ +--- +title: 微信公众号 +description: 将 CowAgent 接入微信公众号 +--- + +CowAgent 支持接入个人订阅号和企业服务号两种公众号类型。 + +| 类型 | 要求 | 特点 | +| --- | --- | --- | +| **个人订阅号** | 个人可申请 | 收到消息时会回复一条提示,回复生成后需用户主动发消息获取 | +| **企业服务号** | 企业申请,需通过微信认证开通客服接口 | 回复生成后可主动推送给用户 | + + + 公众号仅支持服务器和 Docker 部署,不支持本地运行。需额外安装扩展依赖:`pip3 install -r requirements-optional.txt` + + +## 一、个人订阅号 + +在 `config.json` 中添加以下配置: + +```json +{ + "channel_type": "wechatmp", + "single_chat_prefix": [""], + "wechatmp_app_id": "wx73f9******d1e48", + "wechatmp_app_secret": "YOUR_APP_SECRET", + "wechatmp_aes_key": "", + "wechatmp_token": "YOUR_TOKEN", + "wechatmp_port": 80 +} +``` + +### 配置步骤 + +这些配置需要和 [微信公众号后台](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev) 中的保持一致,进入页面后,在左侧菜单选择 **设置与开发 → 基本配置 → 服务器配置**,按下图进行配置: + + + +1. 在公众平台启用开发者密码(对应配置 `wechatmp_app_secret`),并将服务器 IP 填入白名单 +2. 按上图填写 `config.json` 中与公众号相关的配置,要与公众号后台的配置一致 +3. 启动程序,启动后会监听 80 端口(若无权限监听,则在启动命令前加上 `sudo`;若 80 端口已被占用,则关闭该占用进程) +4. 在公众号后台 **启用服务器配置** 并提交,保存成功则表示已成功配置。注意 **"服务器地址(URL)"** 需要配置为 `http://{HOST}/wx` 的格式,其中 `{HOST}` 可以是服务器的 IP 或域名 + +随后关注公众号并发送消息即可看到以下效果: + + + +由于受订阅号限制,回复内容较短的情况下(15s 内),可以立即完成回复,但耗时较长的回复则会先回复一句 "正在思考中",后续需要用户输入任意文字主动获取答案,而服务号则可以通过客服接口解决这一问题。 + + + **语音识别**:可利用微信自带的语音识别功能,需要在公众号管理页面的 "设置与开发 → 接口权限" 页面开启 "接收语音识别结果"。 + + +## 二、企业服务号 + +企业服务号与上述个人订阅号的接入过程基本相同,差异如下: + +1. 在公众平台申请企业服务号并完成微信认证,在接口权限中确认已获得 **客服接口** 的权限 +2. 在 `config.json` 中设置 `"channel_type": "wechatmp_service"`,其他配置与上述订阅号相同 +3. 交互效果上,即使是较长耗时的回复,也可以主动推送给用户,无需用户手动获取 + +```json +{ + "channel_type": "wechatmp_service", + "single_chat_prefix": [""], + "wechatmp_app_id": "YOUR_APP_ID", + "wechatmp_app_secret": "YOUR_APP_SECRET", + "wechatmp_aes_key": "", + "wechatmp_token": "YOUR_TOKEN", + "wechatmp_port": 80 +} +``` diff --git a/docs/zh/channels/wecom-bot.mdx b/docs/zh/channels/wecom-bot.mdx new file mode 100644 index 00000000..7275639f --- /dev/null +++ b/docs/zh/channels/wecom-bot.mdx @@ -0,0 +1,90 @@ +--- +title: 企微智能机器人 +description: 将 CowAgent 接入企业微信智能机器人(长连接模式) +--- + +> 通过企业微信智能机器人接入CowAgent,支持企业内部单聊和内部群聊,无需公网 IP,使用 WebSocket 长连接模式,支持Markdown渲染和流式输出。 + + + 智能机器人与企业微信自建应用是两种不同的接入方式。智能机器人使用 WebSocket 长连接,无需服务器公网 IP 和域名,配置更简单。 + + +## 一、接入方式 + +### 方式一:扫码一键接入(推荐) + +无需提前创建机器人,启动 Cow 项目后打开 Web 控制台(本地链接:http://127.0.0.1:9899/),选择 **通道** 菜单,点击**接入通道**,选择**企微智能机器人**,切换到「扫码接入」模式,使用**企业微信**扫码即可自动完成机器人创建和接入。 + + + + + 扫码成功后,可在企业微信工作台 - **智能机器人**页面对机器人进行进一步配置,包括修改名称、头像、可见范围等。 + + +### 方式二:手动创建接入 + +需要先在企业微信中创建智能机器人并获取 Bot ID 和 Secret,再通过 Web 控制台或配置文件接入。 + +**步骤一:创建智能机器人** + +1. 打开企业微信客户端,进入工作台,点击**智能机器人**: + + + +2. 点击创建机器人 - 手动创建: + + + +3. 右侧窗口拖到最下方,选择**API模式创建**: + + + +4. 设置机器人名称、头像、可见范围,并选择**长连接模式**,记录下 **Bot ID** 和 **Secret** 信息后点击保存。 + +**步骤二:接入 CowAgent** + + + + 打开 Web 控制台,选择**通道**菜单,点击**接入通道**,选择**企微智能机器人**,切换到「手动填写」模式,输入 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 | + + + +日志显示 `[WecomBot] Subscribe success` 即表示连接成功。 + +## 二、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 单聊 | ✅ | +| 群聊(@机器人) | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发 | +| 流式回复 | ✅ | +| 定时任务主动推送 | ✅ | + +## 三、使用 + +在企业微信中搜索创建的机器人名称,即可开始单聊对话。 + +如需在企微内部群聊中使用,将机器人添加到群中,@机器人发送消息即可。 + + diff --git a/docs/zh/channels/wecom.mdx b/docs/zh/channels/wecom.mdx new file mode 100644 index 00000000..e0ed6fbc --- /dev/null +++ b/docs/zh/channels/wecom.mdx @@ -0,0 +1,98 @@ +--- +title: 企微自建应用 +description: 将 CowAgent 接入企业微信自建应用 +--- + +通过企业微信自建应用接入 CowAgent,支持企业内部人员单聊使用。 + + + 企业微信只能使用 Docker 部署或服务器 Python 部署,不支持本地运行模式。 + + +## 一、准备 + +需要的资源: + +1. 一台服务器(有公网 IP) +2. 注册一个企业微信(个人也可注册,但无法认证) +3. 认证企业微信还需要对应主体备案的域名 + +## 二、创建企业微信应用 + +1. 在 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#profile) 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechatcom_corp_id` 字段中)。 + +2. 切换到 **应用管理**,点击创建应用: + + + +3. 进入应用创建页面,记录 `AgentId` 和 `Secret`: + + + +4. 点击 **设置API接收**,配置应用接口: + + + +- URL 格式为 `http://ip:port/wxcomapp`(认证企业需使用备案域名) +- 随机获取 `Token` 和 `EncodingAESKey` 并保存 + + + 此时保存 API 接收配置会失败,因为程序还未启动,等项目运行后再回来保存。 + + +## 三、配置和运行 + +在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图): + +```json +{ + "channel_type": "wechatcom_app", + "single_chat_prefix": [""], + "wechatcom_corp_id": "YOUR_CORP_ID", + "wechatcomapp_token": "YOUR_TOKEN", + "wechatcomapp_secret": "YOUR_SECRET", + "wechatcomapp_agent_id": "YOUR_AGENT_ID", + "wechatcomapp_aes_key": "YOUR_AES_KEY", + "wechatcomapp_port": 9898 +} +``` + +| 参数 | 说明 | +| --- | --- | +| `wechatcom_corp_id` | 企业 ID | +| `wechatcomapp_token` | API 接收配置中的 Token | +| `wechatcomapp_secret` | 应用的 Secret | +| `wechatcomapp_agent_id` | 应用的 AgentId | +| `wechatcomapp_aes_key` | API 接收配置中的 EncodingAESKey | +| `wechatcomapp_port` | 监听端口,默认 9898 | + +配置完成后启动程序。当后台日志显示 `http://0.0.0.0:9898/` 时说明程序运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。 + +程序启动后,回到企业微信后台保存 **消息服务器配置**,保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息: + + + + + 如遇到 URL 配置回调不通过或配置失败: + 1. 确保服务器防火墙关闭且安全组放行监听端口 + 2. 仔细检查 Token、Secret Key 等参数配置是否一致,URL 格式是否正确 + 3. 认证企业微信需要配置与主体一致的备案域名 + + +## 四、使用 + +在企业微信中搜索刚创建的应用名称,即可直接对话: + + + +如需让外部个人微信用户使用,可在 **我的企业 → 微信插件** 中分享邀请关注二维码,个人微信扫码关注后即可与应用对话: + + + +## 常见问题 + +需要确保已安装以下依赖: + +```bash +pip install websocket-client pycryptodome +``` diff --git a/docs/zh/channels/weixin.mdx b/docs/zh/channels/weixin.mdx new file mode 100644 index 00000000..c19974a4 --- /dev/null +++ b/docs/zh/channels/weixin.mdx @@ -0,0 +1,74 @@ +--- +title: 微信 +description: 将 CowAgent 接入个人微信(基于官方接口) +--- + +> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的私聊收发。通过微信官方API进行接入,无安全风险,接入后会在会话中新增一个机器人助手,不影响当前账号的使用。 + +## 一、配置和运行 + +### 方式一:Web 控制台接入 + +启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。 + + + +### 方式二:配置文件接入 + +在 `config.json` 中设置 `channel_type` 为 `weixin`: + +```json +{ + "channel_type": "weixin" +} +``` + +启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。 + + + + + + 1. 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。 + 2. 注意微信客户端需要更新至 8.0.69 版本或以上 + + +## 二、使用说明 + +微信扫码并进行授权确认后,即可完成接入并开始对话。接入微信后会在对话中创建出一个机器人助理,不会对已有账号的正常使用有任何影响。 + +> 你可以通过搜索"微信ClawBot"随时找到这个机器人,还可以修改这个机器人的头像、备注等信息,将机器人置顶在消息列表等。 + + + + + +## 三、登录说明 + +### 扫码登录 + +首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。 + +- 二维码过期后会自动刷新并重新显示 +- `requirements.txt` 中已默认包含 `qrcode` 依赖,安装后可在终端直接渲染二维码图案 + +### 凭证保存 + +登录成功后,凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动时无需重新扫码。 + +如需重新登录,删除该凭证文件后重启程序即可。 + +### Session 过期 + +当微信 session 过期时(errcode -14),程序会自动清除旧凭证并重新发起扫码登录,无需手动干预。 + +## 四、功能说明 + +| 功能 | 支持情况 | +| --- | --- | +| 单聊 | ✅ | +| 文本消息 | ✅ 收发 | +| 图片消息 | ✅ 收发 | +| 文件消息 | ✅ 收发 | +| 视频消息 | ✅ 收发 | +| 语音消息 | ✅ 接收 (自带语音识别) | diff --git a/docs/zh/cli/general.mdx b/docs/zh/cli/general.mdx new file mode 100644 index 00000000..36af1783 --- /dev/null +++ b/docs/zh/cli/general.mdx @@ -0,0 +1,124 @@ +--- +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 +``` + +## cancel + +中止当前会话正在运行的 Agent 任务。在 Agent 执行长时间任务(例如多轮工具调用、长流式输出)时,可随时发送 `/cancel`,Agent 会在下一次工具执行前停止。Web 端、微信、企业微信、飞书等各通道均可使用。 + +```text +/cancel +``` + +## config + +查看或修改运行时配置。修改后立即生效,无需重启服务。 + +**查看所有可配置项:** + +```text +/config +``` + +**查看单个配置项:** + +```text +/config model +``` + +**修改配置项:** + +```text +/config model deepseek-v4-flash +``` + +**支持修改的配置项:** + +| 配置项 | 说明 | 示例值 | +| --- | --- | --- | +| `model` | AI 模型名称 | `deepseek-v4-flash` | +| `agent_max_context_tokens` | 最大上下文 tokens | `40000` | +| `agent_max_context_turns` | 最大上下文记忆轮次 | `30` | +| `agent_max_steps` | 单次任务最大决策步数 | `15` | +| `enable_thinking` | 是否启用深度思考模式 | `true` / `false` | + + + 修改 `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/zh/cli/index.mdx b/docs/zh/cli/index.mdx new file mode 100644 index 00000000..f6462ecb --- /dev/null +++ b/docs/zh/cli/index.mdx @@ -0,0 +1,98 @@ +--- +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 ...) + +Memory & Knowledge: + memory Memory distillation (dream) + knowledge View knowledge base stats and structure + +Others: + help Show this help message + version Show version +``` + +## 对话命令 + +在 Web 控制台或任意接入渠道的对话中,支持输入以 `/` 开头的命令: + +| 命令 | 说明 | +| --- | --- | +| `/help` | 显示命令帮助 | +| `/status` | 查看服务状态和配置 | +| `/cancel` | 中止当前正在运行的 Agent 任务 | +| `/config` | 查看或修改运行时配置 | +| `/skill` | 管理技能(安装、卸载、启用、禁用等) | +| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) | +| `/knowledge` | 查看知识库统计信息 | +| `/knowledge list` | 查看知识库目录结构 | +| `/knowledge on\|off` | 开启或关闭知识库 | +| `/context` | 查看当前会话上下文信息 | +| `/context clear` | 清空当前会话上下文 | +| `/logs` | 查看最近日志 | +| `/version` | 显示版本号 | + + + 对话命令中 `/start`、`/stop`、`/restart` 等服务管理命令会提示到终端中执行,因为它们涉及进程操作。 + + +## 命令对照表 + +以下是各命令在终端和对话中的可用性: + +| 命令 | 终端 (`cow`) | 对话 (`/`) | +| --- | :---: | :---: | +| help | ✓ | ✓ | +| version | ✓ | ✓ | +| status | ✓ | ✓ | +| logs | ✓ | ✓ | +| cancel | ✗ | ✓ | +| config | ✗ | ✓ | +| context | — | ✓ | +| memory (子命令) | ✗ | ✓ | +| knowledge (子命令) | ✓ | ✓ | +| skill (子命令) | ✓ | ✓ | +| start / stop / restart | ✓ | ✗ | +| update | ✓ | ✗ | +| install-browser | ✓ | ✗ | + + + `context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。 + diff --git a/docs/zh/cli/memory-knowledge.mdx b/docs/zh/cli/memory-knowledge.mdx new file mode 100644 index 00000000..3dc6713d --- /dev/null +++ b/docs/zh/cli/memory-knowledge.mdx @@ -0,0 +1,77 @@ +--- +title: 记忆与知识库 +description: 记忆蒸馏和知识库管理命令 +--- + +## memory + +管理 Agent 的长期记忆系统。 + +### memory dream + +手动触发记忆蒸馏(Deep Dream),整理近期的天级记忆,蒸馏合并到 MEMORY.md,并生成梦境日记。 + +```text +/memory dream [N] +``` + +- `N`:整理近 N 天的记忆,默认 3 天,最大 30 天 +- 蒸馏在后台异步执行,完成后会在对话中通知结果 +- 无需等待 Agent 初始化,首次对话前即可使用 + +**示例:** + +```text +/memory dream # 整理近 3 天 +/memory dream 7 # 整理近 7 天 +/memory dream 30 # 整理近 30 天(全量) +``` + +蒸馏完成后,Web 端会收到带有跳转链接的通知,可直接查看更新后的 MEMORY.md 和梦境日记。 + + + 系统每天 23:55 会自动执行一次蒸馏(lookback 1 天)。手动触发适用于首次部署后的历史整理,或需要立即更新记忆时使用。 + + +## knowledge + +查看和管理个人知识库。默认显示知识库统计信息。 + +```text +/knowledge +``` + +输出示例: + +``` +📚 知识库 + +- 状态:已开启 +- 页面数:12 +- 总大小:45.2 KB +- 分类明细: + - concepts/: 5 篇 + - entities/: 4 篇 + - sources/: 3 篇 +``` + +### knowledge list + +查看知识库目录树结构。 + +```text +/knowledge list +``` + +### knowledge on / off + +开启或关闭知识库。关闭后不再注入知识提示词和索引知识文件。 + +```text +/knowledge on +/knowledge off +``` + + + 终端 CLI 中 `cow knowledge` 和 `cow knowledge list` 可用,但 `on|off` 仅支持在对话中使用(需实时生效)。 + diff --git a/docs/zh/cli/process.mdx b/docs/zh/cli/process.mdx new file mode 100644 index 00000000..0fc38539 --- /dev/null +++ b/docs/zh/cli/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 浏览器,用于启用 [浏览器工具](/zh/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/zh/cli/skill.mdx b/docs/zh/cli/skill.mdx new file mode 100644 index 00000000..3b4a8aee --- /dev/null +++ b/docs/zh/cli/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/zh/guide/manual-install.mdx b/docs/zh/guide/manual-install.mdx new file mode 100644 index 00000000..e6b81b42 --- /dev/null +++ b/docs/zh/guide/manual-install.mdx @@ -0,0 +1,182 @@ +--- +title: 手动安装 +description: 手动部署 CowAgent(源码 / Docker) +--- + +## 源码部署 + +### 1. 克隆项目代码 + +```bash +git clone https://github.com/zhayujie/CowAgent +cd CowAgent/ +``` + + + 若遇到网络问题可使用国内仓库地址:https://gitee.com/zhayujie/CowAgent + + +### 2. 安装依赖 + +核心依赖(必选): + +```bash +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` + +### 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 浏览器。详细说明参考 [浏览器工具文档](/zh/tools/browser)。 + + + 浏览器工具依赖较重(~300MB),如不需要可跳过,不影响其他功能正常使用。 + + +### 4. 配置 + +复制配置文件模板并编辑: + +```bash +cp config-template.json config.json +``` + +在 `config.json` 中填写模型 API Key 和通道类型等配置,详细说明参考各 [模型文档](/zh/models/minimax)。 + +### 5. 运行 + +**使用 Cow CLI 运行(推荐):** + +```bash +cow start +``` + +**或者本地前台运行:** + +```bash +python3 app.py +``` + +运行后默认启动 Web 控制台,访问 `http://localhost:9899` 开始对话和管理Agent。 + +**服务器后台运行(不使用 CLI 时):** + +```bash +nohup python3 app.py & tail -f nohup.out +``` + + + **服务器公网访问 Web 控制台**:默认 `web_host` 仅监听 `127.0.0.1`(本机访问),需公网访问时请在 `config.json` 中将 `web_host` 设为 `0.0.0.0`,同时强烈建议设置 `web_password` 启用鉴权。此外还需在防火墙/安全组中放行 `9899` 端口,建议仅对指定 IP 开放以保证安全。 + + +## Docker 部署 + +使用 Docker 部署无需下载源码和安装依赖。Agent模式下更推荐使用源码部署以获得更多系统访问能力。 + + + 需要安装 [Docker](https://docs.docker.com/engine/install/) 和 docker-compose。 + + +**1. 下载配置文件** + +```bash +curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml +``` + +打开 `docker-compose.yml` 填写所需配置。 + +**2. 启动容器** + +```bash +sudo docker compose up -d +``` + +**3. 查看日志** + +```bash +sudo docker logs -f chatgpt-on-wechat +``` + + + **Docker 公网访问 Web 控制台**:在 `docker-compose.yml` 中将 `WEB_HOST` 设为 `0.0.0.0`(容器内默认绑定 `127.0.0.1` 无法从宿主机外访问),同时强烈建议设置 `WEB_PASSWORD` 启用鉴权。此外需确保 `9899` 端口正确映射到宿主机,并在防火墙/安全组放行该端口。 + + +## 核心配置项 + + + + ```json + { + "channel_type": "web", + "model": "deepseek-v4-flash", + "deepseek_api_key": "", + "agent": true, + "agent_workspace": "~/cow", + "agent_max_context_tokens": 40000, + "agent_max_context_turns": 30, + "agent_max_steps": 15, + "cow_lang": "auto" + } + ``` + + + ```yaml + environment: + CHANNEL_TYPE: 'web' + MODEL: 'deepseek-v4-flash' + DEEPSEEK_API_KEY: 'your-api-key' + DEEPSEEK_API_BASE: 'https://api.deepseek.com/v1' + AGENT: 'True' + AGENT_MAX_CONTEXT_TOKENS: 40000 + AGENT_MAX_CONTEXT_TURNS: 30 + AGENT_MAX_STEPS: 15 + COW_LANG: 'auto' + ``` + + + +| 参数 | 环境变量 | 说明 | 默认值 | +| --- | --- | --- | --- | +| `channel_type` | `CHANNEL_TYPE` | 接入渠道类型 | `web` | +| `model` | `MODEL` | 模型名称 | `deepseek-v4-flash` | +| `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` | +| `cow_lang` | `COW_LANG` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | + + + 全部配置项可在项目 [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py) 文件中查看。Docker 部署时,配置项名称需转为大写环境变量格式。 + diff --git a/docs/zh/guide/quick-start.mdx b/docs/zh/guide/quick-start.mdx new file mode 100644 index 00000000..964ffe74 --- /dev/null +++ b/docs/zh/guide/quick-start.mdx @@ -0,0 +1,58 @@ +--- +title: 一键安装 +description: 使用脚本一键安装和管理 CowAgent +--- + +项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行。 + +支持 Linux、macOS、Windows 操作系统,需安装 Python 3.7 ~ 3.12(推荐 3.9)。 + +## 安装命令 + + + + ```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 + ``` + + + +脚本自动执行以下流程: + +1. 检查 Python 环境(需要 Python 3.7+) +2. 安装必要工具(git、curl 等) +3. 克隆项目代码到 `~/CowAgent` +4. 安装 Python 依赖和 Cow CLI +5. 引导配置 AI 模型和通信渠道 +6. 启动服务 + +运行后默认启动 Web 控制台,访问 `http://localhost:9899` 开始对话和管理Agent。 + + + **服务器部署需要公网访问控制台时**,请在 `config.json` 中将 `web_host` 设为 `0.0.0.0`(默认仅监听 `127.0.0.1` 本机访问),同时强烈建议设置 `web_password` 启用鉴权。然后通过 `http://:9899` 访问,并确保防火墙/安全组放行 `9899` 端口。 + + +## 管理命令 + +安装完成后,使用 `cow` CLI 管理服务: + +| 命令 | 说明 | +| --- | --- | +| `cow start` | 启动服务 | +| `cow stop` | 停止服务 | +| `cow restart` | 重启服务 | +| `cow status` | 查看运行状态 | +| `cow logs` | 查看实时日志 | +| `cow update` | 更新代码并重启 | +| `cow install-browser` | 安装浏览器工具依赖 | + +更多命令和用法参考 [命令文档](/zh/cli/index)。 + + + 如果 `cow` 命令不可用,也可以使用 `./run.sh <命令>`(Linux/macOS)或 `.\scripts\run.ps1 <命令>`(Windows)作为替代,功能等效。 + diff --git a/docs/zh/guide/upgrade.mdx b/docs/zh/guide/upgrade.mdx new file mode 100644 index 00000000..7a36d706 --- /dev/null +++ b/docs/zh/guide/upgrade.mdx @@ -0,0 +1,61 @@ +--- +title: 更新升级 +description: CowAgent 的升级方式说明 +--- + +## 命令升级(推荐) + +使用 `cow update` 一键完成代码更新和服务重启: + +```bash +cow update +``` + +该命令会自动完成以下流程: + +1. 拉取最新代码(`git pull`) +2. 停止当前服务 +3. 更新 Python 依赖 +4. 重新安装 CLI +5. 启动服务 + + + 如果未安装 Cow CLI,也可以使用 `./run.sh update` 完成相同操作。 + + +## 手动升级 + +在项目根目录下执行: + +```bash +git pull +pip3 install -r requirements.txt +pip3 install -e . +``` + +更新完成后重启服务: + +```bash +# 使用 Cow CLI (推荐) +cow restart + +# 或使用 run.sh +./run.sh restart + +# 或使用 nohup 直接运行 +kill $(ps -ef | grep app.py | grep -v grep | awk '{print $2}') +nohup python3 app.py & tail -f nohup.out +``` + +## Docker 升级 + +在 `docker-compose.yml` 所在目录下执行: + +```bash +sudo docker compose pull +sudo docker compose up -d +``` + + + 升级前建议备份 `config.json` 配置文件。Docker 环境下如需保留数据,可通过 volume 挂载持久化工作空间目录。 + diff --git a/docs/zh/intro/architecture.mdx b/docs/zh/intro/architecture.mdx new file mode 100644 index 00000000..9d8c3da2 --- /dev/null +++ b/docs/zh/intro/architecture.mdx @@ -0,0 +1,87 @@ +--- +title: 项目架构 +description: CowAgent 2.0 的系统架构和核心设计 +--- + +CowAgent 2.0 从简单的聊天机器人全面升级为超级智能助理,采用 Agent 架构设计,具备自主思考、规划任务、长期记忆和技能扩展等能力。 + +## 系统架构 + +CowAgent 的整体架构由以下核心模块组成: + +CowAgent Architecture + +| 模块 | 说明 | +| --- | --- | +| **Plan** | 理解用户意图,将复杂任务分解为多步骤计划,循环调用工具直到完成目标 | +| **Memory** | 自动将重要信息持久化为核心记忆和日级记忆,支持关键词和向量混合检索,跨会话保持上下文连续性 | +| **Knowledge** | 以主题维度组织结构化知识,Agent 自主整理有价值信息为 Markdown 页面,维护索引和交叉引用,构建持续增长的知识网络 | +| **Tools** | Agent 访问操作系统资源的核心能力,内置文件读写、终端执行、浏览器操作、定时调度、记忆检索、联网搜索等 10+ 种工具 | +| **Skills** | 加载和管理 Skills,支持从 Skill Hub、GitHub 等一键安装,或通过对话创建自定义技能 | +| **Models** | 模型层,统一接入 OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen 等国内外主流大语言模型 | +| **Channels** | 消息通道层,负责接收和发送消息,支持 Web 控制台、微信、飞书、钉钉、企微、公众号等,统一消息协议 | +| **CLI** | 命令行系统,提供终端命令(`cow`)和对话命令(`/`),支持进程管理、技能安装、配置修改、知识库管理等操作 | + +## Agent 模式 + +启用 Agent 模式后,CowAgent 会以自主智能体的方式运行,核心工作流如下: + +1. **接收消息** — 通过通道接收用户输入 +2. **理解意图** — 分析任务需求和上下文 +3. **规划任务** — 将复杂任务分解为多个步骤 +4. **调用工具** — 选择合适的工具执行每个步骤 +5. **记忆与知识更新** — 将重要信息存入长期记忆,将结构化知识整理至知识库 +6. **返回结果** — 将执行结果发送回用户 + +## 工作空间 + +Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词、记忆文件、技能文件等: + +``` +~/cow/ +├── SYSTEM.md # Agent system prompt +├── USER.md # User profile +├── MEMORY.md # Core memory +├── memory/ # Long-term memory storage +│ └── YYYY-MM-DD.md # Daily memory +├── knowledge/ # Personal knowledge base +│ ├── index.md # Knowledge index +│ └── / # Topic-based pages +└── skills/ # Custom skills + ├── skill-1/ + └── skill-2/ +``` + +秘钥文件单独存储在 `~/.cow` 目录(出于安全考虑): + +``` +~/.cow/ +└── .env # Secret keys for skills +``` + +## 核心配置 + +在 `config.json` 中配置 Agent 模式的核心参数: + +```json +{ + "agent": true, + "agent_workspace": "~/cow", + "agent_max_context_tokens": 40000, + "agent_max_context_turns": 30, + "agent_max_steps": 15, + "enable_thinking": false, + "cow_lang": "auto" +} +``` + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `agent` | 是否启用 Agent 模式 | `true` | +| `agent_workspace` | 工作空间路径 | `~/cow` | +| `agent_max_context_tokens` | 最大上下文 token 数 | `50000` | +| `agent_max_context_turns` | 最大上下文记忆轮次 | `20` | +| `agent_max_steps` | 单次任务最大决策步数 | `20` | +| `enable_thinking` | 是否启用深度思考模式 | `false` | +| `knowledge` | 是否启用个人知识库 | `true` | +| `cow_lang` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | diff --git a/docs/zh/intro/features.mdx b/docs/zh/intro/features.mdx new file mode 100644 index 00000000..ca54b39e --- /dev/null +++ b/docs/zh/intro/features.mdx @@ -0,0 +1,142 @@ +--- +title: 功能介绍 +description: CowAgent 长期记忆、个人知识库、任务规划、技能系统、CLI 命令、浏览器工具详细说明 +--- + +## 1. 长期记忆 + +> 记忆系统让 Agent 能够长期记住重要信息,采用三层记忆流转架构:对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期),形成完整的记忆生命周期。 + +第一次启动 Agent 时,Agent 会主动询问关键信息,并记录至工作空间(默认 `~/cow`)中的智能体设定、用户身份、记忆文件中。 + +在后续的长期对话中,Agent 会在需要时智能记录或检索记忆,并对自身设定、用户偏好、记忆文件等进行不断更新。每日自动执行 **梦境蒸馏(Deep Dream)**,将分散的天级记忆整合为精炼的长期记忆,同时生成叙事风格的梦境日记。 + + + + + +详细说明请参考 [长期记忆](/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)。 + +## 2. 个人知识库 + +> 知识库系统让 Agent 能够持续积累和组织结构化知识。与按时间线记录的记忆不同,知识库以主题为维度,将文章、对话洞察、学习材料等整理为互相关联的 Markdown 页面,形成持续增长的知识网络。 + +Agent 会在对话中自动将有价值的信息整理为知识页面,维护交叉引用和索引,通过 Web 控制台可浏览文档和查看知识图谱。知识库存储在工作空间的 `~/cow/knowledge/` 目录下。 + +- **自动整理**:Agent 在对话中自主提取和整理结构化知识,维护索引和交叉引用 +- **知识图谱**:基于页面间的交叉引用自动构建知识图谱,Web 控制台提供可视化关系图浏览 +- **对话联动**:Agent 回复中引用的知识文档链接可在 Web 控制台中直接点击跳转查看 +- **CLI 管理**:通过 `/knowledge` 命令查看统计、浏览目录,通过 `/knowledge on|off` 开关功能 + + + + + +详细说明请参考 [个人知识库](/knowledge)。 + +## 3. 任务规划和工具调用 + +工具是 Agent 访问操作系统资源的核心,Agent 会根据任务需求智能选择和调用工具,完成文件读写、命令执行、定时任务等各类操作。内置工具的实现在项目的 `agent/tools/` 目录下。 + +**主要工具:** 文件读写编辑、Bash 终端、浏览器操作、文件发送、定时调度、记忆搜索、联网搜索、环境配置等。 + +### 3.1 终端和文件访问 + +针对操作系统的终端和文件的访问能力,是最基础和核心的工具,其他很多工具或技能都是基于此进行扩展。用户可通过手机端与 Agent 交互,操作个人电脑或服务器上的资源: + + + + + +### 3.2 编程能力 + +基于编程能力和系统访问能力,Agent 可以实现从信息搜索、图片等素材生成、编码、测试、部署、Nginx 配置修改、发布的 **Vibecoding 全流程**,通过手机端简单的一句命令完成应用的快速 demo: + + + + + +### 3.3 定时任务 + +基于 `scheduler` 工具实现动态定时任务,支持**一次性任务、固定时间间隔、Cron 表达式**三种形式,任务触发可选择**固定消息发送**或 **Agent 动态任务**执行两种模式: + + + + + +### 3.4 浏览器操作 + +内置 `browser` 工具,Agent 可控制浏览器访问网页、填写表单、点击元素、截图,支持动态 JS 渲染页面。运行 `cow install-browser` 一键安装,自动适配服务器(无头模式)和桌面环境: + + + + + +### 3.5 环境变量管理 + +技能所需的秘钥存储在环境变量文件中,由 `env_config` 工具进行管理,你可以通过对话的方式更新秘钥,工具内置安全保护和脱敏策略: + + + + + +## 4. 技能系统 + +技能系统为 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 等来源安装。 + +### 4.1 创建技能 + +通过 `skill-creator` 技能可以通过对话的方式快速创建技能。你可以让 Agent 将某个工作流程固化为技能,或者把任意接口文档和示例发送给 Agent,让他直接完成对接: + + + + + +### 4.2 搜索和图像识别 + +- **联网搜索:** 内置 `web_search` 工具,支持多种搜索引擎,配置 `BOCHA_API_KEY` 或 `LINKAI_API_KEY` 后启用。 +- **图像识别:** 内置 `openai-image-vision` 技能,可使用 `gpt-4.1-mini`、`gpt-4.1` 等模型,依赖 `OPENAI_API_KEY`。 + + + + + +### 4.3 技能广场 + +访问 [skills.cowagent.ai](https://skills.cowagent.ai/) 浏览所有可用技能,或在对话中执行: + +```text +/skill list --remote # 浏览技能广场 +/skill search <关键词> # 搜索技能 +/skill install <名称> # 一键安装 +``` + +同时还支持安装Github、ClawHub、LinkAI等第三方平台上的所有技能,详情查看 [技能安装](/zh/skills/install) + + + + +## 5. 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/cli)。 + + diff --git a/docs/zh/intro/index.mdx b/docs/zh/intro/index.mdx new file mode 100644 index 00000000..4701b754 --- /dev/null +++ b/docs/zh/intro/index.mdx @@ -0,0 +1,84 @@ +--- +title: 项目介绍 +description: CowAgent - 基于大模型的超级AI助理 +--- + +
+ CowAgent +
+ +**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆和知识库并不断成长。 + +CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企业微信应用、微信公众号、网页中使用,7×24小时运行于你的个人电脑或服务器中。 + + + + 开源代码仓库,欢迎 Star 和贡献 + + + 无需安装,立即在线体验 CowAgent + + + +## 核心能力 + + + + 能够理解复杂任务并自主规划执行,持续思考和调用各类工具和技能直到完成目标。 + + + 三层记忆流转(上下文→天级记忆→全局记忆),每日梦境蒸馏整理,支持关键词及向量检索。 + + + 自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。 + + + 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发。 + + + 内置文件读写、终端执行、浏览器操作、定时任务、消息发送等工具,Agent 可自主调用工具完成复杂任务。 + + + 提供终端 CLI 和对话中的命令,支持进程管理、技能安装、配置修改、上下文查看等常用操作。 + + + 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao 等国内外主流模型厂商。 + + + 支持运行在本地计算机或服务器,可集成到微信、网页、飞书、钉钉、微信公众号、企业微信应用中使用。 + + + +## 快速体验 + +在终端执行以下命令,即可一键安装、配置、启动 CowAgent: + + + + ```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` 可以在网页端进行对话、配置、应用通道接入等操作。 + + + + 查看完整的安装和运行指南 + + + 了解 CowAgent 的系统架构设计 + + + +## 社区 + +添加小助手微信加入开源项目交流群: + + diff --git a/docs/zh/knowledge/index.mdx b/docs/zh/knowledge/index.mdx new file mode 100644 index 00000000..4f9aa797 --- /dev/null +++ b/docs/zh/knowledge/index.mdx @@ -0,0 +1,96 @@ +--- +title: 个人知识库 +description: CowAgent 的个人知识库系统 — 结构化知识沉淀、自动整理与知识图谱 +--- + +个人知识库是 Agent 的长期结构化知识存储,保存在工作空间的 `knowledge/` 目录下。与按时间线组织的记忆不同,知识库以主题为维度,将用户分享的文章、对话中的洞察、学习材料等整理为互相关联的 Markdown 页面,形成可持续增长的知识网络。 + + + + + +## 核心概念 + +### 知识 vs 记忆 + +| 维度 | 知识库(knowledge/) | 长期记忆(memory/) | +| --- | --- | --- | +| 组织方式 | 按主题分类、互相关联 | 按时间线、日期文件 | +| 写入方式 | Agent 主动整理结构化内容 | 上下文裁剪时自动摘要 | +| 内容特点 | 提炼后的结构化知识 | 原始对话摘要 | +| 典型用途 | 学习笔记、技术文档、项目知识 | 对话历史、事件记录 | + +### 目录结构 + +``` +~/cow/knowledge/ +├── index.md # 知识索引,所有页面的入口 +├── log.md # 变更日志,记录每次写入 +├── concepts/ # 概念类知识 +│ └── machine-learning.md +├── entities/ # 实体类知识(人物、组织、工具) +│ └── openai.md +└── sources/ # 来源类知识(文章、论文) + └── llm-wiki.md +``` + +目录结构是灵活的 — Agent 会根据实际内容自动创建合适的分类目录。用户也可以通过对话的方式自定义目录组织方式。 + + + + + + +## 自动整理 + +知识库的写入是 Agent 的自主行为,在以下场景中触发: + +- **用户分享文章或文档** — Agent 自动提取关键信息,创建结构化知识页面 +- **对话产生有价值的结论** — Agent 将洞察整理为知识页面,并与已有知识建立关联 +- **用户主动要求整理** — 用户可以通过对话指导 Agent 组织和更新知识 + + + + + + +每个知识页面都包含与其他页面的交叉引用链接,逐步构建起一个知识图谱。 + +## 知识检索 + +Agent 在对话中可以通过以下方式检索知识: + +- **索引查阅** — 通过 `knowledge/index.md` 快速定位相关知识页面 +- **语义搜索** — 通过 `memory_search` 工具对知识库内容进行语义检索 +- **直接读取** — 通过 `memory_get` 工具读取特定知识文件 + +## Web 控制台 + +Web 控制台提供了专用的「知识」模块,支持: + +- **文档浏览** — 树状目录结构,可搜索、可折叠,点击查看文档内容 +- **知识图谱** — 可视化展示知识之间的关联关系,节点可直接跳转至文档 +- **对话联动** — Agent 回复中引用的知识文档链接可直接点击跳转查看 + + + + + + +## CLI 命令 + +通过 `/knowledge` 命令管理知识库: + +| 命令 | 说明 | +| --- | --- | +| `/knowledge` | 显示知识库统计信息 | +| `/knowledge list` | 以树状结构显示文件目录 | +| `/knowledge on` | 开启知识库功能 | +| `/knowledge off` | 关闭知识库功能 | + +## 相关配置 + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `knowledge` | 是否启用个人知识库功能 | `true` | +| `agent_workspace` | 工作空间路径,知识库存储在此目录的 `knowledge/` 子目录下 | `~/cow` | diff --git a/docs/zh/memory/context.mdx b/docs/zh/memory/context.mdx new file mode 100644 index 00000000..3d358f9d --- /dev/null +++ b/docs/zh/memory/context.mdx @@ -0,0 +1,81 @@ +--- +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 总结后**写入当天的日级记忆文件** +- 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/zh/memory/deep-dream.mdx b/docs/zh/memory/deep-dream.mdx new file mode 100644 index 00000000..726ec5e2 --- /dev/null +++ b/docs/zh/memory/deep-dream.mdx @@ -0,0 +1,94 @@ +--- +title: 梦境蒸馏 +description: Deep Dream — 从对话到永久记忆的自动蒸馏机制 +--- + +梦境蒸馏(Deep Dream)是 CowAgent 记忆系统的核心整理机制,负责将分散的天级记忆蒸馏为精炼的长期记忆,并生成梦境日记。 + +## 记忆流转 + +CowAgent 的记忆从短期到长期经历三个阶段: + +``` +对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期) +``` + +### 1. 对话 → 天级记忆 + +当对话上下文被裁剪或每日定时总结时,系统使用 LLM 将对话内容摘要为关键事件,写入当天的天级记忆文件 `memory/YYYY-MM-DD.md`。 + +触发时机: +- **上下文裁剪** — 轮次或 token 超限时,裁剪的内容被总结写入 +- **每日定时** — 23:55 自动触发全量总结 +- **API 溢出** — 紧急保存当前对话摘要 + +### 2. 天级记忆 → MEMORY.md(蒸馏) + +每日总结完成后,Deep Dream 自动执行蒸馏: + +1. **读取材料** — 当前 `MEMORY.md` + 当天的天级记忆 +2. **LLM 蒸馏** — 去重、合并、修剪、提取新信息 +3. **覆写 MEMORY.md** — 输出精炼后的长期记忆 +4. **生成梦境日记** — 记录整理过程的发现和洞察 + +### 3. MEMORY.md 的作用 + +`MEMORY.md` 会被注入到每次对话的系统提示词中,让 Agent 始终了解用户的偏好、决策和关键事实。因此它必须保持精炼——Deep Dream 会控制在约 30 条以内。 + +## 蒸馏规则 + +Deep Dream 遵循以下整理规则: + +| 操作 | 说明 | +| --- | --- | +| **合并提炼** | 含义相近的多条合并为一条高密度表述 | +| **新增萃取** | 从天级记忆中提取偏好、决策、人物、经验等 | +| **冲突更新** | 新信息与旧条目矛盾时,以新信息为准 | +| **清理无效** | 删除临时性记录、空白条目、格式残留 | +| **删除冗余** | 已被更精炼表述涵盖的旧条目删除 | + +## 梦境日记 + +每次蒸馏会生成一篇梦境日记,保存在 `memory/dreams/YYYY-MM-DD.md`,用叙事风格记录: + +- 发现了哪些重复或矛盾 +- 从天级记忆中提取了什么新洞察 +- 做了哪些清理和优化 +- 整体感受和观察 + +梦境日记可在 Web 控制台的「记忆管理 → 梦境日记」tab 中查看。 + + + + + +## 手动触发 + +除了每日自动执行外,也可以在对话中手动触发: + +```text +/memory dream [N] +``` + +- `N`:整理近 N 天的记忆(默认 3 天,最大 30 天) +- 蒸馏在后台异步执行,完成后在对话中通知结果 +- Web 端通知包含可点击链接,直接跳转查看 MEMORY.md 和梦境日记 +- 无需 Agent 初始化,首次对话前即可使用 + + + + + + + 首次部署后可以手动执行一次 `/memory dream 30`,将历史天级记忆全量蒸馏到 MEMORY.md。 + + +## 安全机制 + +| 机制 | 说明 | +| --- | --- | +| **无新内容跳过** | 没有天级记忆时不执行蒸馏,避免空覆写 | +| **输入去重** | 定时任务中,输入材料未变化时自动跳过 | +| **异步执行** | 蒸馏在后台线程运行,不阻塞对话 | +| **顺序保证** | 定时任务中,天级 flush 全部完成后才启动蒸馏 | +| **禁止编造** | 提示词明确约束只能基于已有材料整理,不得推测或添加 | diff --git a/docs/zh/memory/index.mdx b/docs/zh/memory/index.mdx new file mode 100644 index 00000000..c547aab0 --- /dev/null +++ b/docs/zh/memory/index.mdx @@ -0,0 +1,71 @@ +--- +title: 长期记忆 +description: CowAgent 的长期记忆系统 — 文件持久化、自动写入与混合检索 +--- + +长期记忆保存在工作空间文件中,跨会话持久存在。Agent 在对话中通过检索工具按需加载历史记忆,也会在上下文裁剪时自动将对话摘要写入长期记忆。 + +Memory Architecture + +## 记忆类型 + +### 核心记忆(MEMORY.md) + +存储在 `~/cow/MEMORY.md` 中,包含用户的长期偏好、重要决策、关键事实等不会随时间淡化的信息。Agent 可通过工具读写此文件来维护长期知识。 + +### 日级记忆(memory/YYYY-MM-DD.md) + +存储在 `~/cow/memory/` 目录下,按日期命名(如 `2026-03-08.md`),记录每天的对话摘要和关键事件。仅在首次写入时创建,避免生成空文件。 + +### 梦境日记(memory/dreams/YYYY-MM-DD.md) + +Deep Dream(记忆蒸馏)过程的副产物,记录每次整理的发现、去重合并操作和新洞察。存储在 `~/cow/memory/dreams/` 目录下,按日期命名。 + +## 自动写入 + +Agent 通过以下机制自动将对话内容持久化为长期记忆: + +- **上下文裁剪时** — 当对话轮次或 token 超出配置上限时,裁剪最早一半的上下文,使用 LLM 将被裁剪的内容总结为关键信息写入当天记忆文件,并将摘要异步注入到保留的上下文中,帮助模型保持对话连贯性 +- **每日定时总结** — 每天 23:55 自动触发一次全量总结,防止低活跃日无记忆留存(内容无变化时自动跳过) +- [梦境蒸馏(Deep Dream)](/zh/memory/deep-dream) — 每日总结完成后自动执行,将天级记忆蒸馏合并到 MEMORY.md,并生成梦境日记 +- **API 上下文溢出时** — 当模型 API 返回上下文溢出错误时,紧急保存当前对话摘要 + +所有记忆写入均在后台异步执行(LLM 总结 + 文件写入),不阻塞正常对话回复。 + +## 记忆检索 + +记忆系统支持混合检索模式: + +- **关键词检索** — 基于 FTS5 全文索引匹配历史记忆,支持 BM25 排序 +- **向量检索** — 基于 embedding 语义相似度搜索,即使表述不同也能找到相关记忆 + +Agent 会在对话中根据需要自动触发记忆检索,将相关历史信息纳入上下文。检索结果按混合评分排序(默认向量权重 0.7、关键词权重 0.3),日级记忆会随时间衰减(半衰期 30 天),核心记忆不衰减。 + +## 相关文件 + +工作空间(默认 `~/cow`)中与记忆相关的文件: + +| 文件 | 说明 | +| --- | --- | +| `AGENT.md` | Agent 的人格和行为设定 | +| `USER.md` | 用户身份信息和偏好 | +| `RULE.md` | 自定义规则和约束 | +| `MEMORY.md` | 核心记忆(长期) | +| `memory/YYYY-MM-DD.md` | 日级记忆(按需创建) | +| `memory/dreams/YYYY-MM-DD.md` | 梦境日记(Deep Dream 自动生成) | + +## Web 控制台 + +在 Web 控制台的记忆管理页面中,可浏览记忆文件和梦境日记,支持通过 Tab 切换查看: + + + + + +## 相关配置 + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `agent_workspace` | 工作空间路径,记忆文件存储在此目录下 | `~/cow` | +| `agent_max_context_tokens` | 最大上下文 token 数,超出时裁剪并总结写入记忆 | `50000` | +| `agent_max_context_turns` | 最大上下文轮次,超出时裁剪并总结写入记忆 | `20` | diff --git a/docs/zh/models/claude.mdx b/docs/zh/models/claude.mdx new file mode 100644 index 00000000..ee1809d6 --- /dev/null +++ b/docs/zh/models/claude.mdx @@ -0,0 +1,50 @@ +--- +title: Claude +description: Anthropic Claude 模型配置(文本对话 + 图像理解) +--- + +Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet / Opus 模型均原生支持视觉,无需额外指定 Vision 模型。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "claude-opus-4-8", + "claude_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 支持 `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) | +| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 | +| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 | + +### 模型选择 + +| 模型 | 适用场景 | +| --- | --- | +| `claude-opus-4-8` | 默认推荐,最新旗舰,复杂推理与长链路任务效果最佳 | +| `claude-opus-4-7` | 上一代 Opus 旗舰 | +| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 | +| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 | + +## 图像理解 + +配置 `claude_api_key` 后 Agent 的 Vision 工具会自动使用 Claude 主模型识别图像,无需额外配置。 + +如需手动指定 Vision 模型,可在配置文件中显式配置: + +```json +{ + "tools": { + "vision": { + "model": "claude-sonnet-4-6" + } + } +} +``` diff --git a/docs/zh/models/coding-plan.mdx b/docs/zh/models/coding-plan.mdx new file mode 100644 index 00000000..a8341638 --- /dev/null +++ b/docs/zh/models/coding-plan.mdx @@ -0,0 +1,140 @@ +--- +title: Coding Plan +description: Coding Plan 模式模型配置 +--- + +> Coding Plan 是各厂商推出的编程包月套餐,适合高频使用 Agent 的场景。CowAgent 支持通过 OpenAI 兼容方式接入各厂商的 Coding Plan 接口。 + + + Coding Plan 的 API Base 和 API Key 通常与普通按量计费接口不通用,请在各厂商平台单独获取。 + + +## 通用配置格式 + +所有厂商均可使用 OpenAI 兼容协议接入,可在web控制台快速配置。设置模型厂商为**OpenAI**,选择自定义模型并填入模型编码,最后填写对应厂商的API Base 和 API Key: + + + +也可通过 `config.json` 配置文件直接修改: + +```json +{ + "bot_type": "openai", + "model": "模型名称", + "open_ai_api_base": "厂商 Coding Plan API Base", + "open_ai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `bot_type` | 固定为 `openai`(OpenAI 兼容方式) | +| `model` | 各厂商支持的模型名称 | +| `open_ai_api_base` | 各厂商 Coding Plan 专用 API Base | +| `open_ai_api_key` | 各厂商 Coding Plan 专用 API Key | + +--- + +## 阿里云 + +```json +{ + "bot_type": "openai", + "model": "qwen3.5-plus", + "open_ai_api_base": "https://coding.dashscope.aliyuncs.com/v1", + "open_ai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | `qwen3.5-plus`、`qwen3-max-2026-01-23`、`qwen3-coder-next`、`qwen3-coder-plus`、`glm-5`、`glm-4.7`、`kimi-k2.5`、`MiniMax-M2.5` | +| `open_ai_api_base` | `https://coding.dashscope.aliyuncs.com/v1` | +| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | + +官方文档:[快速开始](https://help.aliyun.com/zh/model-studio/coding-plan-quickstart?spm=a2c4g.11186623.help-menu-2400256.d_0_2_1.70115203zi5Igc)、[模型列表](https://help.aliyun.com/zh/model-studio/coding-plan) + +--- + +## MiniMax + +```json +{ + "bot_type": "openai", + "model": "MiniMax-M2.5", + "open_ai_api_base": "https://api.minimaxi.com/v1", + "open_ai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` | +| `open_ai_api_base` | 国内:`https://api.minimaxi.com/v1`;海外:`https://api.minimax.io/v1` | +| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | + +官方文档:[国内 Key 获取](https://platform.minimaxi.com/docs/coding-plan/quickstart)、[模型列表](https://platform.minimaxi.com/docs/guides/pricing-coding-plan)、[国际 Key 获取](https://platform.minimax.io/docs/coding-plan/quickstart) + +--- + + +## 智谱 GLM + +```json +{ + "bot_type": "openai", + "model": "glm-4.7", + "open_ai_api_base": "https://open.bigmodel.cn/api/coding/paas/v4", + "open_ai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | `glm-5`、`glm-4.7`、`glm-4.6`、`glm-4.5`、`glm-4.5-air` | +| `open_ai_api_base` | 中国区:`https://open.bigmodel.cn/api/coding/paas/v4`;全球区:`https://api.z.ai/api/coding/paas/v4` | +| `open_ai_api_key` | API Key 与普通接口通用 | + +官方文档:[国内版快速开始](https://docs.bigmodel.cn/cn/coding-plan/quick-start)、[国际版快速开始](https://docs.z.ai/devpack/quick-start) + +--- + +## 火山引擎 + +```json +{ + "bot_type": "openai", + "model": "Doubao-Seed-2.0-Code", + "open_ai_api_base": "https://ark.cn-beijing.volces.com/api/coding/v3", + "open_ai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | `Doubao-Seed-2.0-Code`、`Doubao-Seed-2.0-pro`、`Doubao-Seed-2.0-lite`、`Doubao-Seed-Code`、`MiniMax-M2.5`、`Kimi-K2.5`、`GLM-4.7`、`DeepSeek-V3.2` | +| `open_ai_api_base` | `https://ark.cn-beijing.volces.com/api/coding/v3` | +| `open_ai_api_key` | API Key 与普通接口通用 | + +官方文档:[快速开始](https://www.volcengine.com/docs/82379/1928261?lang=zh) + +--- + +## Kimi + +```json +{ + "bot_type": "moonshot", + "model": "kimi-for-coding", + "moonshot_base_url": "https://api.kimi.com/coding/v1", + "moonshot_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 填写 `kimi-for-coding` 会自动更新模型,或指定模型例如 `kimi-k2.6` | +| `moonshot_base_url` | `https://api.kimi.com/coding/v1` | +| `moonshot_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | + +官方文档:[Key 获取](https://www.kimi.com/code/docs/) diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx new file mode 100644 index 00000000..2673a8de --- /dev/null +++ b/docs/zh/models/custom.mdx @@ -0,0 +1,62 @@ +--- +title: 自定义 +description: 自定义厂商配置,适用于第三方 API 代理和本地模型 +--- + +适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如: + +- **第三方 API 代理**:使用统一的 API Base 调用多种模型 +- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型 +- **私有化部署**:企业内部部署的模型服务 + + + 与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。 + + +## 文本对话 + +### 第三方 API 代理 + +```json +{ + "bot_type": "custom", + "model": "", + "custom_api_key": "YOUR_API_KEY", + "custom_api_base": "https://{your-proxy.com}/v1" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `bot_type` | 必须设为 `custom` | +| `model` | 模型名称,填写代理服务支持的任意模型名 | +| `custom_api_key` | API 密钥,由代理服务提供 | +| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 | + +### 本地模型 + +本地模型通常不需要 API Key,只需填写 API Base: + +```json +{ + "bot_type": "custom", + "model": "qwen3.5:27b", + "custom_api_base": "http://localhost:11434/v1" +} +``` + +常见的本地部署工具及默认地址: + +| 工具 | 默认 API Base | +| --- | --- | +| [Ollama](https://ollama.com) | `http://localhost:11434/v1` | +| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | +| [LocalAI](https://localai.io) | `http://localhost:8080/v1` | + +### 切换模型 + +自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址: + +``` +/config model qwen3.5:27b +``` diff --git a/docs/zh/models/deepseek.mdx b/docs/zh/models/deepseek.mdx new file mode 100644 index 00000000..57b96d55 --- /dev/null +++ b/docs/zh/models/deepseek.mdx @@ -0,0 +1,72 @@ +--- +title: DeepSeek +description: DeepSeek 模型配置(文本对话 + 思考模式) +--- + +DeepSeek 是当前 Agent 模式默认推荐的厂商之一,主打高性价比的文本对话和任务规划能力。 + +## 文本对话 + +```json +{ + "model": "deepseek-v4-flash", + "deepseek_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 支持 `deepseek-v4-flash`(默认)、`deepseek-v4-pro` | +| `deepseek_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 | +| `deepseek_api_base` | 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址 | + +### 模型选择 + +| 模型 | 适用场景 | +| --- | --- | +| `deepseek-v4-flash` | 默认推荐,速度快、成本低 | +| `deepseek-v4-pro` | 更智能,复杂任务效果更强 | + +## 思考模式 + +V4 系列(`deepseek-v4-flash` / `deepseek-v4-pro`)支持显式的「思考模式」:模型在输出最终回答前,先输出一段思维链(`reasoning_content`),从而提升答案质量。 + +### 开关 + +通过全局配置 `enable_thinking` 控制,也可在 web控制台 - 配置页面中进行切换: + +```json +{ + "enable_thinking": true +} +``` + +- `true`:所有渠道下模型都会先思考再作答。Web 控制台会展示思考过程,IM 渠道(微信 / 企微 / 钉钉 / 飞书)虽不展示但同样获得更好答案。 +- `false`:关闭思考,响应更快,首字延迟更低。 + +### 推理强度 + +思考模式下可通过 `reasoning_effort` 控制推理强度: + +```json +{ + "enable_thinking": true, + "reasoning_effort": "high" +} +``` + +| 取值 | 适用场景 | +| --- | --- | +| `high`(默认) | 日常 Agent 任务,思考与速度的平衡 | +| `max` | 复杂编码、长链路规划、严格约束的任务,推理更深但耗时与输出 token 更多 | + +`reasoning_effort` 仅在 `enable_thinking` 为 `true` 时生效;模型不支持思考模式时该字段自动忽略。 + +### 行为说明 + +- **采样参数**:思考模式下 `temperature`、`top_p`、`presence_penalty`、`frequency_penalty` 会被服务端忽略(不会报错),CowAgent 会自动跳过传入。 +- **多轮工具调用**:当历史中包含工具调用时,DeepSeek 要求所有 assistant 消息必须回传 `reasoning_content`。CowAgent 会自动处理回传逻辑,跨轮次切换思考开关也不会出错。 + + + 默认使用 `deepseek-v4-flash`;复杂任务可使用 `deepseek-v4-pro`;需要深度推理可开启 `enable_thinking`。 + diff --git a/docs/zh/models/doubao.mdx b/docs/zh/models/doubao.mdx new file mode 100644 index 00000000..cfdc5670 --- /dev/null +++ b/docs/zh/models/doubao.mdx @@ -0,0 +1,66 @@ +--- +title: 豆包 Doubao +description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图像生成 / 向量) +--- + +豆包(火山方舟)支持文本对话、图像理解、图像生成(Seedream)和向量能力,一份 `ark_api_key` 即可启用全部能力。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "doubao-seed-2-0-pro-260215", + "ark_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 可填 `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` 等 | +| `ark_api_key` | 在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 | +| `ark_base_url` | 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` | + +## 图像理解 + +配置 `ark_api_key` 后 Agent 的 Vision 工具会自动使用 `doubao-seed-2-0-pro-260215` 识别图像,无需额外配置。 + +如需手动指定 Vision 模型: + +```json +{ + "tools": { + "vision": { + "model": "doubao-seed-2-0-pro-260215" + } + } +} +``` + +## 图像生成 + +```json +{ + "skills": { + "image-generation": { + "model": "seedream-5.0-lite" + } + } +} +``` + +可选模型:`seedream-5.0-lite`、`seedream-4.5`。 + +## 向量 + +```json +{ + "embedding_provider": "doubao", + "embedding_model": "doubao-embedding-vision-251215" +} +``` + +默认模型 `doubao-embedding-vision-251215`(多模态 embedding),可在配置文件中通过 `embedding_dimensions` 指定 1024 或 2048 维。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 diff --git a/docs/zh/models/gemini.mdx b/docs/zh/models/gemini.mdx new file mode 100644 index 00000000..f1c8991a --- /dev/null +++ b/docs/zh/models/gemini.mdx @@ -0,0 +1,59 @@ +--- +title: Gemini +description: Google Gemini 模型配置(文本对话 + 图像理解 + 图像生成) +--- + +Google Gemini 支持文本对话、图像理解和图像生成(Nano Banana 系列),一个 `gemini_api_key` 即可启用全部能力。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "gemini-3.5-flash", + "gemini_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 推荐 `gemini-3.5-flash`,亦支持 `gemini-3.1-pro-preview`、`gemini-3.1-flash-lite-preview`、`gemini-3-flash-preview`、`gemini-3-pro-preview` 等,参考 [官方文档](https://ai.google.dev/gemini-api/docs/models) | +| `gemini_api_key` | 在 [Google AI Studio](https://aistudio.google.com/app/apikey) 创建 | +| `gemini_api_base` | 可选,默认为 `https://generativelanguage.googleapis.com`,可改为第三方代理 | + +## 图像理解 + +Gemini 全系列模型均原生支持视觉,配置 `gemini_api_key` 后 Agent 的 Vision 工具会自动使用主模型识别图像,无需额外配置。 + +如需手动指定 Vision 模型: + +```json +{ + "tools": { + "vision": { + "model": "gemini-3.1-flash-lite-preview" + } + } +} +``` + +## 图像生成 + +```json +{ + "skills": { + "image-generation": { + "model": "gemini-3.1-flash-image-preview" + } + } +} +``` + +| 模型 ID | 别名 | +| --- | --- | +| `gemini-3.1-flash-image-preview` | Nano Banana 2 | +| `gemini-3-pro-image-preview` | Nano Banana Pro | +| `gemini-2.5-flash-image` | Nano Banana | diff --git a/docs/zh/models/glm.mdx b/docs/zh/models/glm.mdx new file mode 100644 index 00000000..ad5f8fd3 --- /dev/null +++ b/docs/zh/models/glm.mdx @@ -0,0 +1,56 @@ +--- +title: 智谱 GLM +description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 / 向量) +--- + +智谱 AI 支持文本对话、图像理解、语音识别(ASR)和向量(Embedding),一份 `zhipu_ai_api_key` 即可启用全部能力。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "glm-5.1", + "zhipu_ai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 可填 `glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` 等,参考 [模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4) | +| `zhipu_ai_api_key` | 在 [智谱 AI 控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建 | +| `zhipu_ai_api_base` | 可选,默认为 `https://open.bigmodel.cn/api/paas/v4` | + +## 图像理解 + +智谱 chat 系列模型(`glm-5.1`、`glm-5-turbo` 等)不支持视觉,视觉调用统一路由到 `glm-5v-turbo`。配置 `zhipu_ai_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。 + +## 语音识别 + +```json +{ + "voice_to_text": "zhipu", + "voice_to_text_model": "glm-asr-2512" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `voice_to_text` | 设为 `zhipu` 启用智谱 ASR | +| `voice_to_text_model` | 可选,默认 `glm-asr-2512` | + +凭证自动复用 `zhipu_ai_api_key`。语音文件建议小于 25MB,超大文件可能被服务端拒绝。 + +## 向量 + +```json +{ + "embedding_provider": "zhipu", + "embedding_model": "embedding-3" +} +``` + +可选模型:`embedding-3`、`embedding-2`。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 diff --git a/docs/zh/models/index.mdx b/docs/zh/models/index.mdx new file mode 100644 index 00000000..4169cd4a --- /dev/null +++ b/docs/zh/models/index.mdx @@ -0,0 +1,40 @@ +--- +title: 模型概览 +description: CowAgent 支持的模型厂商及能力矩阵 +--- + +CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在项目的 `models/` 目录下。除文本对话外,部分厂商还提供视觉理解、图像生成、语音识别、语音合成、向量等能力,可在 Agent 流程中按需调用。 + + +## 模型能力总览 + +各厂商提供的能力一览。「文本」指主对话模型,其余列表示该厂商可承担对应 Agent 能力。 + +| 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 | +| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | +| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | +| [MiniMax](/zh/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [Claude](/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | +| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [智谱 GLM](/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | +| [通义千问](/zh/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | +| [Kimi](/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | +| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | +| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [自定义](/zh/models/custom) |本地模型 / 三方代理 | ✅ | | | | | | + + + Web 控制台中各项能力(视觉 / 图像 / 语音识别 / 语音合成 / 向量 / 网络搜索)均可独立配置厂商与模型,互相之间不强制绑定。 + + + +## 配置方式 + +**方式一(推荐):** 通过 [Web 控制台](/zh/channels/web) 在线管理模型与各项能力,无需手动编辑配置文件: + + + +**方式二:** 手动编辑 `config.json`,根据所选模型填写对应的模型名称和 API Key。每个模型也支持 OpenAI 兼容方式接入,将 `bot_type` 设为 `openai`,配置 `open_ai_api_base` 和 `open_ai_api_key` 即可。 diff --git a/docs/zh/models/kimi.mdx b/docs/zh/models/kimi.mdx new file mode 100644 index 00000000..beb5beaf --- /dev/null +++ b/docs/zh/models/kimi.mdx @@ -0,0 +1,41 @@ +--- +title: Kimi +description: Kimi(Moonshot)模型配置(文本对话 + 图像理解) +--- + +Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "kimi-k2.6", + "moonshot_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 可填 `kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` | +| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 | +| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` | + +## 图像理解 + +配置 `moonshot_api_key` 后 Agent 的 Vision 工具会自动使用 `kimi-k2.6` 识别图像,无需额外配置。 + +如需手动指定 Vision 模型: + +```json +{ + "tools": { + "vision": { + "model": "kimi-k2.6" + } + } +} +``` diff --git a/docs/zh/models/linkai.mdx b/docs/zh/models/linkai.mdx new file mode 100644 index 00000000..68647ebc --- /dev/null +++ b/docs/zh/models/linkai.mdx @@ -0,0 +1,103 @@ +--- +title: LinkAI +description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音与向量能力 +--- + +通过一份 `linkai_api_key` 即可访问 OpenAI、Claude、Gemini、DeepSeek、MiniMax、Qwen、Kimi、豆包 等主流厂商的全部能力。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "use_linkai": true, + "linkai_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `use_linkai` | 设为 `true` 启用 | +| `linkai_api_key` | 在 [控制台](https://link-ai.tech/console/interface) 创建 | +| `model` | 可填写 [模型列表](https://link-ai.tech/console/models) 中任意编码 | + +前往 [模型服务](https://link-ai.tech/console/models) 了解更多。 + +## 图像理解 + +配置完成后 Agent 的 Vision 工具会自动调用网关上的多模态模型,无需额外配置。如需手动指定 Vision 模型: + +```json +{ + "tools": { + "vision": { + "model": "gpt-5.4-mini" + } + } +} +``` + +可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.6-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。 + +## 图像生成 + +```json +{ + "skills": { + "image-generation": { + "model": "gpt-image-2" + } + } +} +``` + +| 模型 ID | 别名 | +| --- | --- | +| `gpt-image-2` | OpenAI | +| `gemini-3.1-flash-image-preview` | Nano Banana 2 | +| `gemini-3-pro-image-preview` | Nano Banana Pro | +| `seedream-5.0-lite` | 字节豆包 Seedream | + +## 语音识别 + +```json +{ + "voice_to_text": "linkai" +} +``` + +ASR 固定使用 Whisper,凭证自动复用 `linkai_api_key`。 + +## 语音合成 + +语音合成网关下支持多个底层 TTS 引擎,按 `text_to_voice_model` 选择引擎,音色随引擎切换。 + +```json +{ + "text_to_voice": "linkai", + "text_to_voice_model": "doubao", + "tts_voice_id": "BV001_streaming" +} +``` + +| `text_to_voice_model` | 引擎说明 | +| --- | --- | +| `tts-1` | OpenAI · 多语种通用(音色 `alloy` / `nova` / `echo` 等) | +| `doubao` | 字节豆包 · 中文音色丰富 | +| `baidu` | 百度 · 中文主播音色 | + +不同引擎对应的音色不同,建议在 Web 控制台「模型管理 → 语音合成」中可视化选择。 + +## 向量 + +```json +{ + "embedding_provider": "linkai", + "embedding_model": "text-embedding-3-small" +} +``` + +默认模型 `text-embedding-3-small`(OpenAI 兼容)。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 diff --git a/docs/zh/models/mimo.mdx b/docs/zh/models/mimo.mdx new file mode 100644 index 00000000..ea445df9 --- /dev/null +++ b/docs/zh/models/mimo.mdx @@ -0,0 +1,135 @@ +--- +title: 小米 MiMo +description: 小米 MiMo 模型配置(文本对话 + 图像理解 + 语音合成) +--- + +小米 MiMo 是原生全模态大模型,单 `mimo_api_key` 即可同时启用文本对话、图像理解与语音合成。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "mimo-v2.5-pro", + "mimo_api_key": "YOUR_API_KEY", + "mimo_api_base": "https://api.xiaomimimo.com/v1" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 默认推荐 `mimo-v2.5-pro`,也可使用 `mimo-v2.5` | +| `mimo_api_key` | 在 [MiMo 开放平台](https://platform.xiaomimimo.com/console/api-keys) 创建 | +| `mimo_api_base` | 可选,默认为 `https://api.xiaomimimo.com/v1` | + +### 模型选择 + +| 模型 | 适用场景 | +| --- | --- | +| `mimo-v2.5-pro` | 旗舰,原生全模态 + Agent 能力,最高 100 万 tokens 上下文 | +| `mimo-v2.5` | 综合版,原生全模态(文本 / 图像 / 视频 / 音频) | + +## 思考模式 + +MiMo V2.5 系列默认开启「思考模式」:模型在输出最终回答前会先输出 `reasoning_content`(思维链),提升复杂任务表现。 + +通过全局配置 `enable_thinking` 控制是否展示(也可在 Web 控制台 - 配置页面切换): + +```json +{ + "enable_thinking": true +} +``` + +## 图像理解 + +配置 `mimo_api_key` 后,Agent 的 Vision 工具可以自动使用 MiMo 视觉模型: + +- 当主模型本身是多模态时(`mimo-v2.5-pro` / `mimo-v2.5`),直接由主模型识别图像,无需额外配置 +- 当主模型是其他厂商时,Vision 工具会根据顺序自动 fallback 到 `mimo-v2.5-pro` + +如需手动指定 Vision 模型,可在配置文件中显式配置: + +```json +{ + "tools": { + "vision": { + "provider": "mimo", + "model": "mimo-v2.5-pro" + } + } +} +``` + +## 语音合成 + +```json +{ + "text_to_voice": "mimo", + "text_to_voice_model": "mimo-v2.5-tts", + "tts_voice_id": "冰糖" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `text_to_voice_model` | 当前仅支持 `mimo-v2.5-tts`(预置音色 + 唱歌模式) | +| `tts_voice_id` | 预置音色名(中文音色直接使用中文名作为 ID) | + +### 预置音色 + +| 音色 ID | 说明 | +| --- | --- | +| `冰糖` | 中文 · 女声(默认) | +| `茉莉` | 中文 · 女声 | +| `苏打` | 中文 · 男声 | +| `白桦` | 中文 · 男声 | +| `Mia` | 英文 · 女声 | +| `Chloe` | 英文 · 女声 | +| `Milo` | 英文 · 男声 | +| `Dean` | 英文 · 男声 | + +也可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 + +### 风格控制 + +MiMo TTS 支持在合成文本中嵌入 **音频标签** 来控制情绪、语调、方言、角色甚至唱歌。标签需出现在 **最终被合成为语音的文本(即 Agent 回复内容)** 中,整体风格标签写在开头: + +``` +(风格)待合成内容 +``` + +支持半角 `()`、全角 `()` 或 `[]` 三种括号。常见风格示例: + +| 类型 | 示例标签 | +| --- | --- | +| 基础情绪 | `开心` `悲伤` `愤怒` `恐惧` `惊讶` `兴奋` `委屈` `平静` `冷漠` | +| 复合情绪 | `怅然` `欣慰` `无奈` `愧疚` `释然` `忐忑` `动情` | +| 整体语调 | `温柔` `高冷` `活泼` `严肃` `慵懒` `俏皮` `深沉` `干练` `凌厉` | +| 音色定位 | `磁性` `醇厚` `清亮` `空灵` `稚嫩` `苍老` `甜美` `沙哑` | +| 人设腔调 | `夹子音` `御姐音` `正太音` `大叔音` `台湾腔` | +| 方言 | `东北话` `四川话` `河南话` `粤语` | +| 角色扮演 | `孙悟空` `林黛玉` | +| 唱歌 | `唱歌`(等价于 `sing` / `singing`) | + +示例: + +- (磁性)夜已经深了,城市还在呼吸。 +- (东北话)哎呀妈呀,这天儿也忒冷了吧! +- (粤语)呢个真係好正啊! +- (唱歌)原谅我这一生不羁放纵爱自由… + +也可以在文本任意位置插入细粒度音频标签来控制呼吸、笑声、停顿等,例如: + +``` +(紧张,深呼吸)呼……冷静,冷静。(语速加快)自我介绍我背了五十遍了,应该没问题。 +``` + +完整标签列表参见 [MiMo 语音合成文档](https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5)。 + + + CowAgent 在调用 TTS 时会将 Agent 的回复原文(含 `(...)` 标签)直接送入 MiMo 合成。你可以在人设 / 系统提示词里要求模型「在回复开头用 `(风格)` 标签控制语气」,即可让 IM 渠道(微信 / 飞书 / 钉钉 / 企微)的语音回复带上情绪、方言、唱歌等效果。 + diff --git a/docs/zh/models/minimax.mdx b/docs/zh/models/minimax.mdx new file mode 100644 index 00000000..8282f88b --- /dev/null +++ b/docs/zh/models/minimax.mdx @@ -0,0 +1,71 @@ +--- +title: MiniMax +description: MiniMax 模型配置(文本 / 图像理解 / 图像生成 / 语音合成) +--- + +MiniMax 支持文本对话、图像理解、图像生成与语音合成,一份 `minimax_api_key` 即可启用全部能力。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "MiniMax-M2.7", + "minimax_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 可填 `MiniMax-M2.7`、`MiniMax-M2.7-highspeed`、`MiniMax-M2.5`、`MiniMax-M2.1`、`MiniMax-M2.1-lightning`、`MiniMax-M2` 等 | +| `minimax_api_key` | 在 [MiniMax 控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建 | + +## 图像理解 + +MiniMax 的 M2.x 系列 chat 模型本身不支持视觉,视觉调用统一路由到 `MiniMax-Text-01`。配置 `minimax_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。 + +## 图像生成 + +```json +{ + "skills": { + "image-generation": { + "model": "image-01" + } + } +} +``` + +可选模型:`image-01`。 + +## 语音合成 + +```json +{ + "text_to_voice": "minimax", + "text_to_voice_model": "speech-2.8-hd", + "tts_voice_id": "female-shaonv" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `text_to_voice_model` | `speech-2.8-hd`(情绪渲染、自然听感)、`speech-2.8-turbo`(极速)、`speech-2.6-hd`、`speech-2.6-turbo` | +| `tts_voice_id` | 音色 ID,支持中文 / 粤语 / 英 / 日 / 韩,共 70+ 种 | + +常用音色示例: + +| 音色 ID | 说明 | +| --- | --- | +| `female-shaonv` | 中文 · 少女(女) | +| `female-yujie` | 中文 · 御姐(女) | +| `female-tianmei` | 中文 · 甜美女性(女) | +| `male-qn-jingying` | 中文 · 精英青年(男) | +| `male-qn-badao` | 中文 · 霸道青年(男) | +| `Cantonese_GentleLady` | 粤语 · 温柔女声 | +| `English_Graceful_Lady` | 英文 · Graceful Lady | + +完整音色(中文 / 粤语 / 英 / 日 / 韩共 70+ 种)可参考 [系统音色列表](https://platform.minimaxi.com/docs/faq/system-voice-id),也可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 diff --git a/docs/zh/models/openai.mdx b/docs/zh/models/openai.mdx new file mode 100644 index 00000000..aad83c8f --- /dev/null +++ b/docs/zh/models/openai.mdx @@ -0,0 +1,103 @@ +--- +title: OpenAI +description: OpenAI 模型配置(文本 / 视觉 / 图像 / 语音 / 向量) +--- + +OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解、图像生成、语音识别(ASR)、语音合成(TTS)和向量(Embedding)能力。一份 `open_ai_api_key` 即可让 Agent 用到全部能力。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + + +## 文本对话 + +```json +{ + "model": "gpt-5.5", + "open_ai_api_key": "YOUR_API_KEY", + "open_ai_api_base": "https://api.openai.com/v1" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1`、o 系列等;Agent 模式默认 `gpt-5.5`,追求性价比可改为 `gpt-5.4` | +| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 | +| `open_ai_api_base` | 可选,修改可接入第三方代理 | +| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` | + +## 图像理解 + +`gpt-5.5`、`gpt-5.4`、`gpt-4o`、`gpt-4.1` 等 OpenAI 模型均原生支持视觉,配置 `open_ai_api_key` 后 Agent 的 Vision 工具会自动使用主模型识别图像。若主模型不支持视觉或希望显式指定,可在配置文件中配置: + +```json +{ + "tools": { + "vision": { + "model": "gpt-5.4-mini" + } + } +} +``` + +支持的 Vision 模型:`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5`、`gpt-4.1`、`gpt-4.1-mini`、`gpt-4o`。 + +## 图像生成 + +在配置文件中指定图像生成模型,Agent 调用图像生成技能时会自动路由到 OpenAI: + +```json +{ + "skills": { + "image-generation": { + "model": "gpt-image-2" + } + } +} +``` + +支持的图像生成模型:`gpt-image-2`、`gpt-image-1`。 + +## 语音识别 + +```json +{ + "voice_to_text": "openai", + "voice_to_text_model": "gpt-4o-mini-transcribe" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `voice_to_text` | 设为 `openai` 启用 OpenAI 语音识别 | +| `voice_to_text_model` | 可选,默认 `gpt-4o-mini-transcribe`;也可填 `gpt-4o-transcribe`、`whisper-1` | + +凭证自动复用 `open_ai_api_key`。 + +## 语音合成 + +```json +{ + "text_to_voice": "openai", + "text_to_voice_model": "tts-1", + "tts_voice_id": "alloy" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `text_to_voice_model` | `tts-1`、`tts-1-hd`、`gpt-4o-mini-tts` | +| `tts_voice_id` | 音色:`alloy`、`echo`、`fable`、`onyx`、`nova`、`shimmer`、`ash`、`ballad`、`coral`、`sage`、`verse` | + +## 向量 + +```json +{ + "embedding_provider": "openai", + "embedding_model": "text-embedding-3-small" +} +``` + +可选模型:`text-embedding-3-small`、`text-embedding-3-large`、`text-embedding-ada-002`。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 + diff --git a/docs/zh/models/qianfan.mdx b/docs/zh/models/qianfan.mdx new file mode 100644 index 00000000..bdd87214 --- /dev/null +++ b/docs/zh/models/qianfan.mdx @@ -0,0 +1,59 @@ +--- +title: 百度千帆 +description: 百度千帆 ERNIE 模型配置(文本对话 + 图像理解) +--- + +百度千帆提供 ERNIE 系列模型,支持文本对话与图像理解。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "ernie-5.1", + "qianfan_api_key": "YOUR_API_KEY", + "qianfan_api_base": "https://qianfan.baidubce.com/v2" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 默认推荐使用 `ernie-5.1`;也可使用 `ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-128k`、`ernie-4.5-turbo-32k` | +| `qianfan_api_key` | 千帆 API Key,格式通常以 `bce-v3/` 开头 | +| `qianfan_api_base` | 可选,默认为 `https://qianfan.baidubce.com/v2` | + +### 模型选择 + +| 模型 | 适用场景 | +| --- | --- | +| `ernie-5.1` | 默认推荐,文心新一代旗舰模型,综合能力最强 | +| `ernie-5.0` | 上一代旗舰模型,综合能力优异 | +| `ernie-x1.1` | 深度思考推理模型,幻觉更低、指令遵循与工具调用更强 | +| `ernie-4.5-turbo-128k` | 长上下文和通用对话 | +| `ernie-4.5-turbo-32k` | 通用对话,成本和上下文更均衡 | + +## 图像理解 + +配置 `qianfan_api_key` 后,Agent 的 Vision 工具可以自动使用千帆视觉模型: + +- 当主模型本身是多模态时(如 `ernie-5.1`、`ernie-5.0`、`ernie-x1.1`、`ernie-4.5-turbo-vl`),直接由主模型识别图像,无需额外配置 +- 当主模型是纯文本时(如 `ernie-4.5-turbo-128k`),Vision 工具会自动 fallback 到 `ernie-4.5-turbo-vl` + +如需手动指定 Vision 模型,可在配置文件中显式配置: + +```json +{ + "tools": { + "vision": { + "model": "ernie-4.5-turbo-vl" + } + } +} +``` + + + 新配置推荐使用 `qianfan_api_key`。旧的 `wenxin`、`wenxin-4`、`baidu_wenxin_api_key`、`baidu_wenxin_secret_key` 配置仍保持兼容。 + diff --git a/docs/zh/models/qwen.mdx b/docs/zh/models/qwen.mdx new file mode 100644 index 00000000..765bae64 --- /dev/null +++ b/docs/zh/models/qwen.mdx @@ -0,0 +1,112 @@ +--- +title: 通义千问 Qwen +description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / 语音识别 / 语音合成 / 向量) +--- + +通义千问(DashScope / 百炼)是国内覆盖最完整的厂商之一,文本、图像理解、图像生成、语音识别、语音合成与向量能力均可用一份 `dashscope_api_key` 启用。 + + + 通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。 + + +## 文本对话 + +```json +{ + "model": "qwen3.6-plus", + "dashscope_api_key": "YOUR_API_KEY" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 可填 `qwen3.6-plus`、`qwen3.7-max`、`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) | + +## 图像理解 + +配置 `dashscope_api_key` 后 Agent 的 Vision 工具会自动调用千问的视觉模型识别图像。`qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` 等模型本身就是多模态;若主模型是纯文本(如 `qwen-turbo`),会自动回落到 `qwen-vl-max`。 + +如需手动指定 Vision 模型: + +```json +{ + "tools": { + "vision": { + "model": "qwen3.6-plus" + } + } +} +``` + +支持模型:`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。 + +## 图像生成 + +```json +{ + "skills": { + "image-generation": { + "model": "qwen-image-2.0" + } + } +} +``` + +可选模型:`qwen-image-2.0`、`qwen-image-2.0-pro`。 + +## 语音识别 + +```json +{ + "voice_to_text": "dashscope", + "voice_to_text_model": "qwen3-asr-flash" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `voice_to_text` | 设为 `dashscope` 启用通义千问 ASR | +| `voice_to_text_model` | 可选,默认 `qwen3-asr-flash` | + +凭证自动复用 `dashscope_api_key`。单段音频建议小于 10MB、时长不超过 300 秒。 + +## 语音合成 + +```json +{ + "text_to_voice": "dashscope", + "text_to_voice_model": "qwen3-tts-flash", + "tts_voice_id": "Cherry" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `text_to_voice_model` | 可选,默认 `qwen3-tts-flash`,覆盖普通话、方言与主流外语 | +| `tts_voice_id` | 音色 ID,详见下方常用列表 | + +常用音色示例: + +| 音色 ID | 说明 | +| --- | --- | +| `Cherry` | 芊悦 · 阳光女声 | +| `Serena` | 苏瑶 · 温柔女声 | +| `Ethan` | 晨煦 · 阳光男声 | +| `Chelsie` | 千雪 · 二次元少女 | +| `Dylan` | 北京话 · 晓东 | +| `Rocky` | 粤语 · 阿强 | +| `Sunny` | 四川话 · 晴儿 | + +完整音色(普通话 / 各地方言 / 双语等)可在 Web 控制台的「模型管理 → 语音合成」下拉框中可视化选择。 + +## 向量 + +```json +{ + "embedding_provider": "dashscope", + "embedding_model": "text-embedding-v4" +} +``` + +默认模型 `text-embedding-v4`。修改 embedding 后需执行 `/memory rebuild-index` 命令重建索引。 diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx new file mode 100644 index 00000000..9a426982 --- /dev/null +++ b/docs/zh/releases/overview.mdx @@ -0,0 +1,32 @@ +--- +title: 更新日志 +description: CowAgent 版本更新历史 +--- + +| 版本 | 日期 | 说明 | +| --- | --- | --- | +| [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | +| [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | +| [2.0.7](/zh/releases/v2.0.7) | 2026.04.22 | 图像生成技能(六厂商自动路由)、新模型支持(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、知识库增强、Web 控制台优化 | +| [2.0.6](/zh/releases/v2.0.6) | 2026.04.14 | 项目更名、知识库系统、梦境记忆蒸馏、上下文智能压缩、Web 控制台多会话及多项优化 | +| [2.0.5](/zh/releases/v2.0.5) | 2026.04.01 | Cow CLI、Skill Hub 开源、浏览器工具、企微扫码创建、多项优化和修复 | +| [2.0.4](/zh/releases/v2.0.4) | 2026.03.22 | 新增个人微信通道、新模型支持、日文文档、脚本重构及多项修复 | +| [2.0.3](/zh/releases/v2.0.3) | 2026.03.18 | 新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级 | +| [2.0.2](/zh/releases/v2.0.2) | 2026.02.27 | Web 控制台升级、多通道同时运行、会话持久化 | +| [2.0.1](/zh/releases/v2.0.1) | 2026.02.13 | 内置 Web Search 工具、智能上下文管理、多项修复 | +| [2.0.0](/zh/releases/v2.0.0) | 2026.02.03 | 全面升级为超级 Agent 助理 | +| 1.7.6 | 2025.05.23 | Web Channel 优化、AgentMesh 多智能体插件 | +| 1.7.5 | 2025.04.11 | DeepSeek 模型 | +| 1.7.4 | 2024.12.13 | Gemini 2.0 模型、Web Channel | +| 1.7.3 | 2024.10.31 | 稳定性提升、数据库功能 | +| 1.7.2 | 2024.09.26 | 一键安装脚本、o1 模型 | +| 1.7.0 | 2024.08.02 | 讯飞 4.0 模型、知识库引用 | +| 1.6.9 | 2024.07.19 | gpt-4o-mini、阿里语音识别 | +| 1.6.8 | 2024.07.05 | Claude 3.5、Gemini 1.5 Pro | +| 1.6.0 | 2024.04.26 | Kimi 接入、gpt-4-turbo 升级 | +| 1.5.8 | 2024.03.26 | GLM-4、Claude-3、edge-tts | +| 1.5.2 | 2023.11.10 | 飞书通道、图像识别对话 | +| 1.5.0 | 2023.11.10 | gpt-4-turbo、dall-e-3、tts 多模态 | +| 1.0.0 | 2022.12.12 | 项目创建,首次接入 ChatGPT 模型 | + +更多历史版本请查看 [GitHub Releases](https://github.com/zhayujie/CowAgent/releases)。 diff --git a/docs/zh/releases/v2.0.0.mdx b/docs/zh/releases/v2.0.0.mdx new file mode 100644 index 00000000..3436bc19 --- /dev/null +++ b/docs/zh/releases/v2.0.0.mdx @@ -0,0 +1,105 @@ +--- +title: v2.0.0 +description: CowAgent 2.0 - 从聊天机器人到超级智能助理的全面升级 +--- + +CowAgent 2.0 实现了从聊天机器人到**超级智能助理**的全面升级!现在它能够主动思考和规划任务、拥有长期记忆、操作计算机和外部资源、创造和执行技能,真正理解你并和你一起成长。 + +**发布日期**:2026.02.03 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) + +## 重点更新 + +### Agent 核心能力 + +- **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持多轮推理和上下文理解 +- **长期记忆**:自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索 +- **内置系统工具**:内置实现 10+ 种工具,包括文件操作、Bash 终端、浏览器、文件发送、定时任务、记忆管理等 +- **Skills**:新增 Skill 运行引擎,内置多种技能,并支持通过自然语言对话完成自定义 Skills 开发 +- **安全和成本**:通过秘钥管理工具、提示词控制、系统权限等手段控制 Agent 的访问安全;通过最大记忆轮次、最大上下文 token、工具执行步数对 token 成本进行限制 + +### 其他更新 + +- **渠道优化**:飞书及钉钉接入渠道支持长连接接入(无需公网 IP)、支持图片/文件消息的接收和发送 +- **模型更新**:新增 claude-sonnet-4-5、gemini-3-pro-preview、glm-4.7、MiniMax-M2.1、qwen3-max 等最新模型 +- **部署优化**:增加一键安装、配置、运行、管理的脚本,简化部署流程 + +## 长期记忆系统 + +Agent 会在用户分享重要信息时主动存储,也会在对话达到一定长度时自动提取摘要。支持语义搜索和向量检索的混合检索模式。 + +**首次启动**时,Agent 会主动询问关键信息,并记录至工作空间(默认 `~/cow`)中的智能体设定、用户身份、记忆文件中。 + +**长期对话**中,Agent 会智能记录或检索记忆,不断更新自身设定、用户偏好,总结经验和教训,真正实现自主思考和持续成长。 + + + + + +## 任务规划与工具调用 + +Agent 根据任务需求智能选择和调用工具,完成各类复杂操作。 + +### 终端和文件访问 + +最基础和核心的工具能力,用户可通过手机端与 Agent 交互,操作个人电脑或服务器上的资源: + + + + + +### 应用编程能力 + +基于编程能力和系统访问能力,Agent 可实现从信息搜索、素材生成、编码、测试、部署、Nginx 配置、发布的 **Vibecoding 全流程**,通过手机端一句命令完成应用快速 demo。 + + + + + +### 定时任务 + +支持 **一次性任务、固定时间间隔、Cron 表达式** 三种形式,任务触发可选择 **固定消息发送** 或 **Agent 动态任务执行** 两种模式: + + + + + +### 环境变量管理 + +通过 `env_config` 工具管理技能所需秘钥,支持对话式更新,内置安全保护和脱敏策略: + + + + + +## 技能系统 + +每个 Skill 由说明文件、运行脚本(可选)、资源(可选)组成,为 Agent 提供无限扩展性。 + +### 技能创造器 + +通过对话方式快速创建技能,将工作流程固化或对接任意第三方接口: + + + + + +### 网页搜索和图像识别 + +- **网页搜索**:内置 `web_search` 工具,支持多种搜索引擎,配置对应 API Key 即可使用 +- **图像识别**:支持 `gpt-4.1-mini`、`gpt-4.1` 等模型,配置 `OPENAI_API_KEY` 即可使用 + + + + + +### 三方知识库和插件 + +`linkai-agent` 技能可将 [LinkAI](https://link-ai.tech/) 上的所有智能体作为 Skill 使用,实现多智能体决策: + + + + + +## 参与共建 + +2.0 版本后,项目将持续升级 Agent 能力、拓展接入渠道、内置工具、技能系统,降低模型成本和提升安全性。欢迎 [提出反馈](https://github.com/zhayujie/CowAgent/issues) 和 [贡献代码](https://github.com/zhayujie/CowAgent/pulls)。 diff --git a/docs/zh/releases/v2.0.1.mdx b/docs/zh/releases/v2.0.1.mdx new file mode 100644 index 00000000..da7b6745 --- /dev/null +++ b/docs/zh/releases/v2.0.1.mdx @@ -0,0 +1,36 @@ +--- +title: v2.0.1 +description: CowAgent 2.0.1 - 内置 Web Search、智能上下文管理、多项修复 +--- + +**发布日期**:2026.02 | [GitHub Release](https://github.com/zhayujie/CowAgent/releases/tag/2.0.1) | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.0..2.0.1) + +## 新特性 + +- **内置 Web Search 工具**:将网络搜索作为 Agent 内置工具集成,降低决策成本 ([4f0ea5d](https://github.com/zhayujie/CowAgent/commit/4f0ea5d7568d61db91ff69c91c429e785fd1b1c2)) +- **Claude Opus 4.6 模型支持**:新增对 Claude Opus 4.6 模型的支持 ([#2661](https://github.com/zhayujie/CowAgent/pull/2661)) +- **企业微信图片消息识别**:支持企业微信渠道的图片消息识别功能 ([#2667](https://github.com/zhayujie/CowAgent/pull/2667)) + +## 优化 + +- **智能上下文管理**:解决聊天上下文溢出问题,新增智能上下文裁剪策略,防止 token 超限 ([cea7fb7](https://github.com/zhayujie/CowAgent/commit/cea7fb7490c53454602bf05955a0e9f059bcf0fd), [8acf2db](https://github.com/zhayujie/CowAgent/commit/8acf2dbdfe713b84ad74b761b7f86674b1c1904d)) [#2663](https://github.com/zhayujie/CowAgent/issues/2663) +- **运行时信息动态更新**:通过动态函数方案实现系统提示词中时间戳等运行时信息的自动更新 ([#2655](https://github.com/zhayujie/CowAgent/pull/2655), [#2657](https://github.com/zhayujie/CowAgent/pull/2657)) +- **Skill 提示词优化**:改进 Skill 系统提示词生成逻辑,简化工具描述,提升 Agent 表现 ([6c21833](https://github.com/zhayujie/CowAgent/commit/6c218331b1f1208ea8be6bf226936d3b556ade3e)) +- **智谱 AI 自定义 API Base URL**:支持智谱 AI 配置自定义 API Base URL ([#2660](https://github.com/zhayujie/CowAgent/pull/2660)) +- **启动脚本优化**:改进 `run.sh` 脚本的交互体验和配置流程 ([#2656](https://github.com/zhayujie/CowAgent/pull/2656)) +- **决策轮次日志**:新增 Agent 决策轮次的日志记录,便于调试 ([cb303e6](https://github.com/zhayujie/CowAgent/commit/cb303e6109c50c8dfef1f5e6c1ec47223bf3cd11)) + +## 问题修复 + +- **定时任务记忆丢失**:修复 Scheduler 调度器导致的记忆丢失问题 ([a77a874](https://github.com/zhayujie/CowAgent/commit/a77a8741b500a408c6f5c8868856fb4b018fe9db)) +- **空工具调用与超长结果**:修复空 tool calls 及过长工具返回结果的异常处理 ([0542700](https://github.com/zhayujie/CowAgent/commit/0542700f9091ebb08c1a56103b0f0f45f24aa621)) +- **OpenAI Function Call**:修复 OpenAI 模型的 function call 调用兼容性问题 ([158c87a](https://github.com/zhayujie/CowAgent/commit/158c87ab8b05bae054cc1b4eacdbb64fc1062ba9)) +- **Claude 工具名字段**:移除 Claude 模型响应中多余的 tool name 字段 ([eec10cb](https://github.com/zhayujie/CowAgent/commit/eec10cb5db6a3d5bc12ef606606532237d2c5f6e)) +- **MiniMax 推理优化**:优化 MiniMax 模型 reasoning content 处理,隐藏思考过程输出 ([c72cda3](https://github.com/zhayujie/CowAgent/commit/c72cda33864bd1542012ee6e0a8bd8c6c88cb5ed), [72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **智谱 AI 思考过程**:隐藏智谱 AI 模型的思考过程展示 ([72b1cac](https://github.com/zhayujie/CowAgent/commit/72b1cacea1ba0d1f3dedacbab2e088e98fd7e172)) +- **飞书连接与证书**:修复飞书渠道的 SSL 证书错误和连接异常问题 ([229b14b](https://github.com/zhayujie/CowAgent/commit/229b14b6fcabe7123d53cab1dea39f38dab26d6d), [8674421](https://github.com/zhayujie/CowAgent/commit/867442155e7f095b4f38b0856f8c1d8312b5fcf7)) +- **model_type 类型校验**:修复非字符串 `model_type` 导致的 `AttributeError` ([#2666](https://github.com/zhayujie/CowAgent/pull/2666)) + +## 平台兼容 + +- **Windows 兼容性适配**:修复 Windows 平台下路径处理、文件编码及 `os.getuid()` 不可用等问题,涉及多个工具模块 ([051ffd7](https://github.com/zhayujie/CowAgent/commit/051ffd78a372f71a967fd3259e37fe19131f83cf), [5264f7c](https://github.com/zhayujie/CowAgent/commit/5264f7ce18360ee4db5dcb4ebe67307977d40014)) diff --git a/docs/zh/releases/v2.0.2.mdx b/docs/zh/releases/v2.0.2.mdx new file mode 100644 index 00000000..ad17bcba --- /dev/null +++ b/docs/zh/releases/v2.0.2.mdx @@ -0,0 +1,98 @@ +--- +title: v2.0.2 +description: CowAgent 2.0.2 - Web 控制台升级、多通道同时运行、会话持久化 +--- + +## ✨ 重点更新 + +### 🖥️ Web 控制台升级 + +本次对 Web 控制台进行了全面升级,支持流式对话输出、工具执行过程和思考过程的可视化展示,并支持对模型、技能、记忆、通道、Agent 配置的在线查看和管理。 + +#### 对话界面 + +支持流式输出,可实时展示 Agent 的思考过程(Reasoning)和工具调用过程(Tool Calls),更直观地观察 Agent 的决策过程: + + + +#### 模型管理 + +支持在线管理模型配置,无需手动编辑配置文件: + + + +#### 技能管理 + +支持在线查看和管理 Agent 技能(Skills): + + + +#### 记忆管理 + +支持在线查看和管理 Agent 记忆: + + + +#### 通道管理 + +支持在线管理接入通道,支持实时连接/断开操作: + + + +#### 定时任务 + +支持在线查看和管理定时任务,包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理: + + + +#### 日志 + +支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题: + + + +相关提交:[f1a1413](https://github.com/zhayujie/CowAgent/commit/f1a1413), [c0702c8](https://github.com/zhayujie/CowAgent/commit/c0702c8), [394853c](https://github.com/zhayujie/CowAgent/commit/394853c), [1c71c4e](https://github.com/zhayujie/CowAgent/commit/1c71c4e), [5e3eccb](https://github.com/zhayujie/CowAgent/commit/5e3eccb), [e1dc037](https://github.com/zhayujie/CowAgent/commit/e1dc037), [5edbf4c](https://github.com/zhayujie/CowAgent/commit/5edbf4c), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5) + +### 🔀 多通道同时运行 + +支持多个接入通道(如飞书、钉钉、企微应用、Web 等)同时运行,每个通道在独立子线程中启动,互不干扰。 + +配置方式:在 `config.json` 中通过 `channel_type` 配置多个通道,以逗号分隔,也可在 Web 控制台的通道管理页面中实时连接或断开各通道。 + +```json +{ + "channel_type": "web,feishu,dingtalk" +} +``` + +相关提交:[4694594](https://github.com/zhayujie/CowAgent/commit/4694594), [7cce224](https://github.com/zhayujie/CowAgent/commit/7cce224), [7d258b5](https://github.com/zhayujie/CowAgent/commit/7d258b5), [c9adddb](https://github.com/zhayujie/CowAgent/commit/c9adddb) + +### 💾 会话持久化 + +会话历史支持持久化存储至本地 SQLite 数据库,服务重启后会话上下文自动恢复,不再丢失。Web 控制台中的历史对话记录也会同步恢复展示。 + +相关提交:[29bfbec](https://github.com/zhayujie/CowAgent/commit/29bfbec), [9917552](https://github.com/zhayujie/CowAgent/commit/9917552), [925d728](https://github.com/zhayujie/CowAgent/commit/925d728) + +### 🤖 新增模型 + +- **Gemini 3.1 Pro Preview**:新增 `gemini-3.1-pro-preview` 模型支持 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Claude 4.6 Sonnet**:新增 `claude-4.6-sonnet` 模型支持 ([52d7cad](https://github.com/zhayujie/CowAgent/commit/52d7cad)) +- **Qwen3.5 Plus**:新增 `qwen3.5-plus` 模型支持 ([e59a289](https://github.com/zhayujie/CowAgent/commit/e59a289)) +- **MiniMax M2.5**:新增 `Minimax-M2.5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **GLM-5**:新增 `glm-5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Kimi K2.5**:新增 `kimi-k2.5` 模型支持 ([48db538](https://github.com/zhayujie/CowAgent/commit/48db538)) +- **Doubao 2.0 Code**:新增 `doubao-2.0-code` 编程专用模型 ([ab28ee5](https://github.com/zhayujie/CowAgent/commit/ab28ee5)) +- **DashScope 模型**:新增阿里云 DashScope 模型名称支持 ([ce58f23](https://github.com/zhayujie/CowAgent/commit/ce58f23)) + +### 🌐 新增官网和文档中心 + +- **官网上线**:[cowagent.ai](https://cowagent.ai/) +- **文档中心上线**:[docs.cowagent.ai](https://docs.cowagent.ai/) + +### 🐛 问题修复 + +- **Gemini 钉钉图片识别**:修复 Gemini 在钉钉通道中无法处理图片标记的问题 ([05a3304](https://github.com/zhayujie/CowAgent/commit/05a3304)) ([#2670](https://github.com/zhayujie/CowAgent/pull/2670)) Thanks [@SgtPepper114](https://github.com/SgtPepper114) +- **启动脚本依赖**:修复 `run.sh` 脚本的依赖安装问题 ([b6fc9fa](https://github.com/zhayujie/CowAgent/commit/b6fc9fa)) +- **裸异常捕获**:将代码中的 `bare except` 替换为 `except Exception`,提升异常处理规范性 ([adca89b](https://github.com/zhayujie/CowAgent/commit/adca89b)) ([#2674](https://github.com/zhayujie/CowAgent/pull/2674)) Thanks [@haosenwang1018](https://github.com/haosenwang1018) + +**发布日期**:2026.02.27 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.1...master) diff --git a/docs/zh/releases/v2.0.3.mdx b/docs/zh/releases/v2.0.3.mdx new file mode 100644 index 00000000..22d2d1f6 --- /dev/null +++ b/docs/zh/releases/v2.0.3.mdx @@ -0,0 +1,91 @@ +--- +title: v2.0.3 +description: CowAgent 2.0.3 - 新增企微智能机器人和 QQ 通道、Web 控制台文件处理、记忆系统升级 +--- + +## 🔌 新增接入通道 + +### 企业微信智能机器人 + +新增企业微信智能机器人(`wecom_bot`)通道,支持流式卡片消息输出,支持文本和图片消息的接收与回复,可在 Web 控制台中进行通道配置和管理。 + +接入文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。 + +相关提交:[d4480b6](https://github.com/zhayujie/CowAgent/commit/d4480b6), [a42f31f](https://github.com/zhayujie/CowAgent/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/CowAgent/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/CowAgent/commit/8b45d6c) + +### QQ 通道 + +新增 QQ 官方机器人(`qq`)通道,支持文本和图片消息的接收与回复,支持私聊和群聊场景。 + +接入文档参考:[QQ机器人接入](https://docs.cowagent.ai/channels/qq)。 + +相关提交:[005a0e1](https://github.com/zhayujie/CowAgent/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/CowAgent/commit/a4d54f5) + +## 🖥️ Web 控制台支持文件输入和处理 + +Web 控制台对话界面支持文件和图片上传,可直接发送文件给 Agent 进行处理。同时 Read 工具新增对 Office 文档(Word、Excel、PPT)的解析能力。 + +相关提交:[30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) + +## 🤖 新增模型 + +- **GPT-5.4 系列**:新增 `gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` 模型支持 ([1623deb](https://github.com/zhayujie/CowAgent/commit/1623deb)) +- **Gemini 3.1 Flash Lite Preview**:新增 `gemini-3.1-flash-lite-preview` 模型支持 ([ba915f2](https://github.com/zhayujie/CowAgent/commit/ba915f2)) + +## 💰 Coding Plan 支持 + +新增各厂商 Coding Plan(编程包月套餐)的接入支持,通过 OpenAI 兼容方式统一接入。目前已支持阿里云、MiniMax、智谱 GLM、Kimi、火山引擎等厂商。 + +详细配置参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。 + +## 🧠 记忆系统升级 + +记忆写入(Memory Flush)升级: + +- 使用 LLM 对超出上下文窗口的对话内容进行智能摘要,生成精炼的每日记忆条目 +- 摘要在后台线程异步执行,不阻塞回复 +- 优化上下文批量裁剪策略,降低冲刷频率 +- 新增每日定时冲刷兜底机制,避免低活跃场景下记忆丢失 +- 修复上下文记忆丢失问题 + +相关提交:[022c13f](https://github.com/zhayujie/CowAgent/commit/022c13f), [c116235](https://github.com/zhayujie/CowAgent/commit/c116235) + +## 🔧 工具重构 + +- **图片识别**:将图片识别(Image Vision)从 Skill 重构为内置 Tool,新增独立的图片视觉提供方(Vision Provider)配置,提升稳定性和可维护性 ([a50fafa](https://github.com/zhayujie/CowAgent/commit/a50fafa), [3b8b562](https://github.com/zhayujie/CowAgent/commit/3b8b562)) +- **网页抓取**:将网页抓取(Web Fetch)从 Skill 重构为内置 Tool,支持远程文档文件(PDF、Word、Excel、PPT)的下载和解析 ([ccb9030](https://github.com/zhayujie/CowAgent/commit/ccb9030), [fa61744](https://github.com/zhayujie/CowAgent/commit/fa61744)) + +## 🐳 Docker 部署优化 + +- **配置模板对齐**:`docker-compose.yml` 环境变量与 `config-template.json` 对齐,补充完整的模型 API Key 和 Agent 等配置项 +- **Web 控制台端口映射**:新增 `9899` 端口映射,Docker 部署后可通过浏览器访问 Web 控制台 +- **配置热更新**:各模型 Bot 的 API Key 和 API Base 改为实时读取,通过 Web 控制台修改配置后无需重启即可生效 +- **工作空间持久化**:新增 `./cow` Volume 挂载,Agent 工作空间数据(记忆、人格、技能等)持久化到宿主机,容器重建或升级不丢失 + +## ⚡ 性能优化 + +- **启动加速**:飞书通道采用懒加载方式导入依赖,避免 4-10 秒的启动延迟 ([924dc79](https://github.com/zhayujie/CowAgent/commit/924dc79)) +- **通道稳定性**:优化通道连接稳定性,支持通道配置通过环境变量设置 ([f1c04bc](https://github.com/zhayujie/CowAgent/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/CowAgent/commit/46d97fd)) + +## 🐛 问题修复 + +- **bot_type 配置**:修复 Agent 模式下 `bot_type` 配置传递问题 ([#2691](https://github.com/zhayujie/CowAgent/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss) +- **bot_type 优先级**:调整 Agent 模式下 `bot_type` 的解析优先级 ([#2692](https://github.com/zhayujie/CowAgent/pull/2692)) Thanks [@6vision](https://github.com/6vision) +- **智谱模型配置**:修复智谱 `bot_type` 命名、Web 控制台持久化及正则转义问题 ([#2693](https://github.com/zhayujie/CowAgent/pull/2693)) Thanks [@6vision](https://github.com/6vision) +- **OpenAI 兼容层**:使用 `openai_compat` 层统一错误处理 ([#2688](https://github.com/zhayujie/CowAgent/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888) +- **OpenAI 兼容迁移**:完成所有模型 Bot 的 `openai_compat` 迁移 ([#2689](https://github.com/zhayujie/CowAgent/pull/2689)) +- **Gemini 工具调用**:修复 Gemini 模型的工具调用匹配问题 ([eda82ba](https://github.com/zhayujie/CowAgent/commit/eda82ba)) +- **会话并发**:修复会话并发场景下的竞态条件问题 ([9879878](https://github.com/zhayujie/CowAgent/commit/9879878)) +- **历史消息恢复**:修复历史会话消息不完整问题,仅恢复 user/assistant 文本消息,剥离工具调用 ([b788a3d](https://github.com/zhayujie/CowAgent/commit/b788a3d), [a33ce97](https://github.com/zhayujie/CowAgent/commit/a33ce97)) +- **飞书群聊**:移除飞书群聊场景下对 `bot_name` 的依赖 ([b641bff](https://github.com/zhayujie/CowAgent/commit/b641bff)) +- **Safari 兼容**:修复 Safari 浏览器 IME 回车键误触发消息发送问题 ([0687916](https://github.com/zhayujie/CowAgent/commit/0687916)) +- **Windows 兼容**:修复 Windows 下 bash 风格 `$VAR` 环境变量转换为 `%VAR%` 的问题 ([7c67513](https://github.com/zhayujie/CowAgent/commit/7c67513)) +- **MiniMax 参数**:增加 MiniMax 模型的 `max_tokens` 限制 ([1767413](https://github.com/zhayujie/CowAgent/commit/1767413)) +- **.gitignore 更新**:添加 Python 目录忽略规则 ([#2683](https://github.com/zhayujie/CowAgent/pull/2683)) Thanks [@pelioo](https://github.com/pelioo) +- **AGENT.md 主动演进**:优化系统提示词中对 AGENT.md 的更新引导,从被动的"用户修改时更新"改为主动识别对话中的性格、风格变化并自动更新 + +## 📦 升级方式 + +源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 + +**发布日期**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.2...master) diff --git a/docs/zh/releases/v2.0.4.mdx b/docs/zh/releases/v2.0.4.mdx new file mode 100644 index 00000000..55264a4b --- /dev/null +++ b/docs/zh/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/CowAgent/commit/ce89869) + +## 🤖 新增模型 + +- **MiniMax-M2.7**:新增 MiniMax-M2.7 模型支持 +- **GLM-5-Turbo**:新增智谱 glm-5-turbo 模型支持 + +相关提交:[9192f6f](https://github.com/zhayujie/CowAgent/commit/9192f6f) + +## 🔧 脚本重构 + +- **run.sh 重构**:提取公共逻辑,精简脚本代码([49d8707](https://github.com/zhayujie/CowAgent/commit/49d8707)) +- **可执行权限**:修复 `run.sh` 文件权限问题 ([652156e](https://github.com/zhayujie/CowAgent/commit/652156e)) +- **PID 获取**:修复 `run.sh` 中进程 PID 获取错误的问题 ([9febb07](https://github.com/zhayujie/CowAgent/commit/9febb07)) + +## 🌍 文档更新 + +新增完整的日文文档,覆盖入门指南、通道接入、模型配置等主要章节。Thanks [@Ikko Ashimine](https://github.com/ikoamu) + +相关提交:[5487c0b](https://github.com/zhayujie/CowAgent/commit/5487c0b) + +## 🐛 问题修复 + +- **企微机器人兼容**:修复旧版 `websocket-client` 的兼容性问题,新增统一的 WebSocket 兼容层 ([bc7f627](https://github.com/zhayujie/CowAgent/commit/bc7f627)) +- **消息自动修复**:增强消息协议的容错能力,自动修复格式异常的消息序列 ([b8b57e3](https://github.com/zhayujie/CowAgent/commit/b8b57e3)) +- **飞书编码**:修复飞书通道消息和日志的编码问题 ([7d0e156](https://github.com/zhayujie/CowAgent/commit/7d0e156)) +- **飞书配置**:移除 `run.sh` 中对 `feishu_bot_name` 的冗余依赖 ([1b5be1b](https://github.com/zhayujie/CowAgent/commit/1b5be1b)) + +## 📦 升级方式 + +源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 + +**发布日期**:2026.03.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.3...master) diff --git a/docs/zh/releases/v2.0.5.mdx b/docs/zh/releases/v2.0.5.mdx new file mode 100644 index 00000000..bf0c0e53 --- /dev/null +++ b/docs/zh/releases/v2.0.5.mdx @@ -0,0 +1,84 @@ +--- +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控制台**:Web 控制台输入框输入 `/` 即可弹出指令菜单,支持方向键回溯历史输入 +- **Windows 支持**:新增 PowerShell 一键安装脚本 `scripts/run.ps1`,同时支持 `cow` 命令 + +相关文档:[命令总览](https://docs.cowagent.ai/cli) + + + +## 🧩 Cow Skill Hub 开源 + +[Cow Skill Hub](https://skills.cowagent.ai)(技能广场)正式开源并上线,提供 AI Agent 技能的浏览、搜索、安装和发布,汇集精选技能、社区贡献技能、三方技能: + +- **一键安装**:在对话中 `/skill install <名称>` 或终端 `cow skill install <名称>` 一键安装 +- **多来源支持**:支持安装 Skill Hub、GitHub、ClawHub、LinkAI 上的全部技能,支持 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/cow-skill-hub)。 + +相关文档:[技能广场](https://docs.cowagent.ai/skills/hub)、[安装技能](https://docs.cowagent.ai/skills/install) + + + + +## 🌐 新增浏览器工具 + +新增 Browser 工具,Agent 可控制浏览器访问和操作网页: + +- **网页导航与交互**:支持 `navigate`、`click`、`fill`、`select`、`scroll`、`press` 等操作 +- **页面快照**:使用精简 DOM 快照技术,让 Agent 高效理解页面结构,导航后自动快照 +- **截图能力**:支持页面截图保存到工作区 +- **JavaScript 执行**:支持在页面中执行自定义脚本 +- **CLI 安装**:通过 `cow install-browser` 一键安装浏览器及依赖,自动适配系统环境 +- **Docker 支持**:Docker 镜像已内置浏览器安装支持 + +相关文档:[浏览器工具](https://docs.cowagent.ai/tools/browser)。 + + + + +## 🤖 企微智能机器人扫码创建 + +企业微信智能机器人通道新增扫码一键创建功能: + +- **Web 控制台扫码**:在 Web 控制台通道页面,选择「扫码接入」模式,使用企业微信扫码即可自动创建并接入智能机器人,无需手动到企业微信后台配置 +- **手动模式保留**:同时保留「手动填写」模式,可输入已有的 Bot ID 和 Secret 接入 +- **流式推送优化**:增加推送节流,避免 WebSocket 拥塞 + +相关文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。 + +相关提交:[#2735](https://github.com/zhayujie/CowAgent/pull/2735) + +Thanks [@WecomTeam](https://github.com/WecomTeam) + +## 🐛 其他优化与修复 + +- **DeepSeek 独立模块**:新增独立的 DeepSeek Bot 模块,支持 `deepseek_api_key` 专属配置,无需再通过 OpenAI 兼容方式接入([#2719](https://github.com/zhayujie/CowAgent/pull/2719))。Thanks [@6vision](https://github.com/6vision) +- **Web 控制台优化**:新增斜杠指令菜单和输入历史回溯,新增模型选项,优化移动端适配([#2731](https://github.com/zhayujie/CowAgent/pull/2731))。Thanks [@zkjqd](https://github.com/zkjqd) +- **上下文丢失**:修复上下文裁剪后丢失的问题 ([393f0c0](https://github.com/zhayujie/CowAgent/commit/393f0c0)) +- **系统提示词**:修复系统提示词未在每轮重建的问题 ([13f5fde](https://github.com/zhayujie/CowAgent/commit/13f5fde)) +- **Agent 响应**:去除 Agent 响应首尾空白字符 ([f890318](https://github.com/zhayujie/CowAgent/commit/f890318)) +- **视觉压缩**:优化视觉图片压缩策略 ([22b8ca0](https://github.com/zhayujie/CowAgent/commit/22b8ca0)) +- **Gemini 模型**:修复 GoogleGeminiBot 缺少 model 属性的问题([#2716](https://github.com/zhayujie/CowAgent/pull/2716))。Thanks [@cowagent](https://github.com/cowagent) +- **微信通道**:修复文件发送失败、文件名丢失等问题 ([6d9b7ba](https://github.com/zhayujie/CowAgent/commit/6d9b7ba)、[baf66a1](https://github.com/zhayujie/CowAgent/commit/baf66a1)、[45faa9c](https://github.com/zhayujie/CowAgent/commit/45faa9c)) +- **Docker 优化**:修复卷权限问题,精简镜像体积 ([3eb8348](https://github.com/zhayujie/CowAgent/commit/3eb8348)、[4470d4c](https://github.com/zhayujie/CowAgent/commit/4470d4c)) +- **README 排版**:优化中英文排版空格([#2723](https://github.com/zhayujie/CowAgent/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.04.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.4...master) diff --git a/docs/zh/releases/v2.0.6.mdx b/docs/zh/releases/v2.0.6.mdx new file mode 100644 index 00000000..299be80b --- /dev/null +++ b/docs/zh/releases/v2.0.6.mdx @@ -0,0 +1,83 @@ +--- +title: v2.0.6 +description: CowAgent 2.0.6 - 知识库系统、梦境记忆蒸馏、上下文智能压缩、Web 控制台多会话及多项优化 +--- + +## 项目正式更名为 CowAgent + +项目仓库正式从 `chatgpt-on-wechat` 更名为 **CowAgent**,演进为功能完备的 AI Agent 助理。 + +- 新地址:[github.com/zhayujie/CowAgent](https://github.com/zhayujie/CowAgent),旧地址 GitHub 会自动重定向 +- CLI 命令、配置文件、文档链接均保持兼容,无需额外操作 + +## 📚 知识库系统 + +新增个人知识库系统,Agent 可自主构建和维护结构化知识,并在对话中按需检索引用。 + +- **索引驱动的自组织结构**:知识库采用 `knowledge/` 目录,按分类自动组织,每个知识页面为独立的 Markdown 文件 +- **自动写入**:向 Agent 发送文件、链接等知识,或在讨论中识别到有价值的知识时,自动创建或更新知识页面 +- **混合检索**:支持关键词全文搜索和向量语义检索,在对话中按需加载相关知识 +- **可视化**:支持文件树浏览和知识图谱可视化,文档内链接可直接跳转查看 +- **命令管理**:`/knowledge` 查看统计、`/knowledge list` 查看目录结构、`/knowledge on|off` 开关知识库 + + + +相关文档:[知识库](https://docs.cowagent.ai/knowledge) + +Inspired by Karpathy's [LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). + +## 🌙 梦境记忆蒸馏(Deep Dream) + +全新的记忆整理机制,每日自动将分散的对话记忆蒸馏为精炼的长期记忆: + +- **三层记忆流转**:对话上下文(短期)→ 天级记忆(中期)→ MEMORY.md(长期),形成完整的记忆生命周期 +- **自动蒸馏**:每日 23:55 定时执行,读取当天天级记忆和 MEMORY.md,通过 LLM 进行去重、合并、修剪,输出精炼的新版 MEMORY.md +- **梦境日记**:每次蒸馏生成一篇叙事风格的梦境日记,记录整理过程的发现和洞察,存储在 `memory/dreams/` 目录 +- **手动触发**:支持 `/memory dream [N]` 手动触发,可指定整理天数(默认 3 天,最大 30 天),完成后在对话中通知结果 +- **Web 控制台**:记忆管理页面新增「梦境日记」tab,可浏览和查看所有梦境日记 + +相关文档:[梦境蒸馏](https://docs.cowagent.ai/memory/deep-dream) + + + +## 🧠 上下文智能压缩 + +上下文超出限制时将裁剪的部分通过 LLM 总结后异步注入,保持对话连贯性: + +- **LLM 异步摘要**:裁剪的消息由 LLM 总结为关键信息,同时写入天级记忆文件和注入保留的上下文 +- **多模型兼容**:优先使用主模型进行摘要,兼容 Claude、OpenAI、MiniMax 等不同模型的消息格式要求 + +相关文档:[短期记忆](https://docs.cowagent.ai/memory/context) + +## 💬 Web 控制台升级 + +Web 控制台多项功能增强: + +- **多会话管理**:支持创建和切换多个独立会话,侧边栏展示会话列表,支持会话标题自动生成和手动编辑 +- **密码保护**:支持为控制台设置登录密码,可通过 `web_console_password` 配置项控制 +- **深度思考**:支持在 Web 端展示模型的思考过程,可通过`enable_thinking` 配置项控制 +- **定时推送**:支持定时任务结果推送到 Web 控制台 +- **消息复制**:AI 回复支持一键复制原始 Markdown 内容 + +## 🤖 模型相关 + +- **视觉识别优化**:图片识别工具优先使用主模型,支持多模型厂商自动降级。相关文档:[视觉工具](https://docs.cowagent.ai/tools/vision) +- **MiniMax 新模型**:新增 MiniMax-M2.7-highspeed 模型和 MiniMax TTS 语音合成支持。Thanks @octo-patch +- **通义千问**:新增 qwen3.6-plus 模型支持 + +## 🐛 其他优化与修复 + +- **记忆提示词优化**:`MEMORY.md` 默认注入系统提示词,精细化记忆检索和写入的触发条件,增强主动写入能力 +- **系统提示词**:优化系统提示词的风格和语气引导 +- **浏览器工具**:增强隐式交互元素检测 +- **文件发送**:修复通用文件类型(tar.gz、zip 等)未能正确发送的问题。Thanks @6vision +- **macOS 兼容**:修复网络预检超时兼容性问题。Thanks @Moliang Zhou +- **Windows 兼容**:修复 Windows 下 PowerShell 兼容性、进程更新、终端编码等多项问题 +- **Python 3.13+**:修复 Python 3.13 及以上版本缺少 `legacy-cgi` 依赖的问题 +- **个人微信**:更新个人微信通道版本 + +## 📦 升级方式 + +源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 + +**发布日期**:2026.04.14 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.5...master) diff --git a/docs/zh/releases/v2.0.7.mdx b/docs/zh/releases/v2.0.7.mdx new file mode 100644 index 00000000..b4b6e27b --- /dev/null +++ b/docs/zh/releases/v2.0.7.mdx @@ -0,0 +1,64 @@ +--- +title: v2.0.7 +description: CowAgent 2.0.7 - 图像生成技能(六厂商自动路由)、新模型支持、知识库增强、Web 控制台优化及多项修复 +--- + +## 🎨 图像生成技能 + +新增图像生成内置技能,支持文生图、图生图、多图融合,支持 `GPT-Image-2`、`Nano Banana` 等多种模型: + +- **自动路由**:支持六种模型厂商自动切换,OpenAI (GPT-Image-2) → Gemini (Nano Banana) → Seedream (火山方舟) → Qwen (百炼) → MiniMax → LinkAI +- **开箱即用**:配置 API Key 即可使用,无需手动指定模型。也支持在对话中指定特定模型 +- **灵活控制**:支持 `quality`(画质)、`size`(分辨率,512/1K~4K)、`aspect_ratio`(宽高比)等参数,各厂商自动适配有效值 +- **图片编辑**:传入已有图片即可进行编辑、风格迁移、多图融合 +- **Skill 级配置**:支持通过 `config.json` 中的 `skills.image-generation.model` 固定默认模型 + +相关文档:[图像生成技能](https://docs.cowagent.ai/skills/image-generation) + +## 🤖 新模型支持 + +- **Kimi K2.6**:新增 `kimi-k2.6` 模型支持 +- **Claude Opus 4.7**:新增 `claude-opus-4-7` 模型支持 +- **GLM 5.1**:新增 `glm-5.1` 模型支持 +- **Kimi Coding Plan**:支持 Kimi Coding Plan 模式 +- **自定义模型厂商**:新增[自定义模型](https://docs.cowagent.ai/models/custom)提供方配置,方便接入本地模型及更多厂商 + +## 📚 知识库增强 + +- **嵌套目录支持**:知识库列表和展示支持多级嵌套目录 +- **根级文件展示**:知识树中显示根目录下的 `index.md`、`log.md` 等文件 +- **空状态统计修复**:排除根级文件对知识库统计的干扰,正确保持空状态 + +## 🌙 梦境记忆优化 + +- **结构化组织**:梦境记忆文件按日期自动归档,目录结构更清晰 +- **定时抖动**:每日定时触发增加随机抖动,避免集群场景下的并发冲突 + +## 🛠 技能系统改进 + +- **技能管理刷新**:`/skill` 命令执行后自动加载最新技能,确保状态同步 +- **安装来源扩展**:技能安装支持多种来源格式(URL、zip、本地文件等) + +## 💬 Web 控制台优化 + +- **智能自动滚动**:优化聊天窗口滚动逻辑,用户手动翻阅时不再强制跳到底部 Thanks @colin2060 +- **移动端适配**:侧边栏默认隐藏,支持点击遮罩关闭 +- **图片预览去重**:修复同一消息中图片重复渲染的问题 +- **推理内容截断**:深度思考内容超出阶段,解决前端卡顿问题 +- **会话标题修复**:修复标题自动生成的回退逻辑 + + +## 🐛 其他修复 + +- **Gemini 修复**:修复 Gemini tool call 不返回结果的问题 +- **Agent 重试**:空响应重试时不再丢弃 tool_calls +- **Docker 环境变量**:修复 Docker 环境下更新配置后环境变量未同步的问题 Thanks @sunboy0523 +- **Python 3.7 兼容**:延迟导入 `Literal` 以兼容 Python 3.7 +- **模型切换通知**:修复切换模型后 bot_type 变更通知未显示的问题。Thanks @6vision +- **配置命令增强**:`/config` 支持设置 `enable_thinking` + +## 📦 升级方式 + +源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 + +**发布日期**:2026.04.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.6...2.0.7) diff --git a/docs/zh/releases/v2.0.8.mdx b/docs/zh/releases/v2.0.8.mdx new file mode 100644 index 00000000..ced1b967 --- /dev/null +++ b/docs/zh/releases/v2.0.8.mdx @@ -0,0 +1,63 @@ +--- +title: v2.0.8 +description: CowAgent 2.0.8 - 飞书渠道全面升级(语音、流式打字机、一键扫码接入)、DeepSeek V4 / 百度千帆支持、定时任务工具优化 +--- + +## 🪶 飞书渠道全面升级 + +### 1. 一键扫码创建飞书应用 + +不再需要手动到飞书开放平台建应用、填权限和事件订阅。Web 控制台和命令行启动时若未配置 `feishu_app_id`,会自动展示扫码入口,飞书扫码授权后自动创建机器人并回填配置,开箱即用。 + +相关文档:[飞书渠道](https://docs.cowagent.ai/channels/feishu) + +### 2. 语音消息收发 + +支持接收用户发送的飞书语音消息并自动转文本,回复也可走 TTS 以语音形式发出。同时优化了中文短语音的识别准确度。 + +### 3. 流式打字机回复 + +接入飞书 CardKit 流式卡片,**默认开启**,体验对齐 Web 端: + +- 多轮 Agent 场景下中间过场消息与最终回复分卡呈现 +- 针对 DeepSeek 等高频输出模型做了专门优化,速度与 Web 端持平 +- 不支持时自动回退为普通文本回复,无需手动配置 +- 要求飞书客户端 ≥ 7.20 + +飞书语音消息收发与流式打字机的基础能力来自社区贡献 #2791 Thanks @yangluxin613 + +## 🤖 新模型支持 + +- **DeepSeek V4 系列**:新增 `deepseek-v4-pro` / `deepseek-v4-flash`,并将默认模型切换为 `deepseek-v4-flash` +- **思考模型开关统一**:DeepSeek V4、Qwen3 等思考模型的开关行为对齐到 `enable_thinking` +- **百度千帆模型接入**:新增百度千帆厂商,支持 `ernie-5.0`、`ernie-4.5-turbo-128k` 等模型,并支持图像识别工具,相关文档查看 [百度千帆](https://docs.cowagent.ai/models/qianfan)。#2790 Thanks @jimmyzhuu +- **新增有道翻译**:`translate` 模块新增有道翻译支持 #2797 Thanks @Zmjjeff7 + +## 🛠 OpenAI 客户端重构 + +- **去 SDK 依赖**:OpenAI Bot 改为原生 HTTP 实现,启动更轻、依赖冲突更少 +- **Web 控制台提示**:模型配置 API Base 输入框加入版本路径占位提示 + +## ⏰ 定时任务记忆增强 + +- **任务结果可被追问**:定时任务的执行结果自动注入到接收方的会话历史中,下一轮对话可直接追问,无需重新交代上下文 Thanks @huangrichao2020 +- **不污染长期记忆**:注入的调度对话不会被纳入每日梦境记忆汇总,避免高频任务把记忆刷满 +- **避免越跑越慢**:调度任务自己的上下文长度自动控制在合理范围内,长期反复执行也不会越积越大、拖慢响应 + +## 🔧 工具与安全 + +- **图像识别模型**:让 `tools.vision.model` 配置真正生效,未配置时自动 fallback #2792 Thanks CNXudiandian +- **Bash 安全确认**:仅对工作区外的破坏性删除做二次确认,工作区内常规操作不再打扰 + +## 🐛 其他修复 + +- 修复 Deep Dream 在多实例场景下重复触发 +- 修复 DeepSeek 多轮对话中部分历史轮次缺失 `reasoning_content` + +## 📦 升级方式 + +源码部署可执行 `cow update` 或 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 + +> ⚠️ 飞书一键创建应用依赖 `lark-oapi>=1.5.5`,`cow update` 会自动拉取;手动部署请确保依赖已更新。 + +**发布日期**:2026.05.06 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.7...2.0.8) diff --git a/docs/zh/releases/v2.0.9.mdx b/docs/zh/releases/v2.0.9.mdx new file mode 100644 index 00000000..957e0ced --- /dev/null +++ b/docs/zh/releases/v2.0.9.mdx @@ -0,0 +1,65 @@ +--- +title: v2.0.9 +description: CowAgent 2.0.9 - 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入 +--- + +## 🖥️ 新增模型管理 + +Web 控制台新增「模型」页面,按 **模型厂商 + 模型能力** 进行管理,支持对话、图像、语音、向量模型和搜索能力的配置: + +- **多厂商配置**:所有厂商的 API Key / API Base 在顶部统一维护,下方所有能力立即生效,无需重复填写 +- **图像模型**:图像理解与图像生成均可独立选择厂商和模型,未指定时跟随主模型自动选择 +- **语音模型**:语音识别和合成可独立配置,新增千问、智谱 ASR/TTS 模型 +- **向量模型**:支持配置 Embedding 模型(用于记忆及知识库检索),新增支持 OpenAI、通义、豆包、智谱等;切换模型后需执行 `/memory rebuild-index` 在线重建索引 +- **搜索能力**:联网搜索能力升级,支持博查、百度、智谱等多个厂商,自动模式下 Agent 可综合多来源搜索结果进行深度研究 + +相关文档:[模型概览](https://docs.cowagent.ai/models) + +20260522113305 + + +## 🧩 MCP 协议支持 + +支持 **MCP(Model Context Protocol)** 协议,从固定工具集扩展为开放可插拔的工具生态,任何兼容 MCP 协议的服务均可作为工具直接接入 Agent。 + +- 原生 JSON-RPC 实现,零额外依赖,同时支持 `stdio` 和 `sse` 两种传输 +- 兼容 Claude Desktop / Cursor 等主流风格的 `mcpServers` 配置,优先读取 `~/cow/mcp.json` + +相关文档:[MCP 工具](https://docs.cowagent.ai/tools/mcp)。Thanks @yangluxin613 (#2801) + +## 🌐 浏览器登录态持久化 + +针对需要登录、有反爬机制的网站,浏览器工具支持登录一次后长期复用登录态,并允许接入用户自己的真实 Chrome 以通过指纹检测: + +- **持久化用户配置(默认)**:默认使用 `~/.cow/browser_profile` 作为浏览器用户目录,登录一次后下次自动复用登录态 +- **CDP 模式**:通过 `tools.browser.cdp_endpoint` 接管真实 Chrome 浏览器,享有完整浏览器权限 + +相关文档:[浏览器工具](https://docs.cowagent.ai/tools/browser)。Thanks @leafmove (#2809) + +## 🤖 模型新增与优化 + +- **模型新增**:`gpt-5.5`、`gemini-3.5-flash`、`qwen3.7-max`、`ernie-5.1` +- **模型优化**:DeepSeek V4 支持 `reasoning_effort` 思考深度参数;修复 MiMo 等思考模型通过 OpenAI 兼容协议接入的问题 + +## 🔒 部署与安全 + +- **默认本机访问**:Web 控制台 `web_host` 配置默认绑定 `127.0.0.1`,服务器部署时可手动设置为 `0.0.0.0` 并设置密码。Thanks @August829、@yidaozhongqing、@YLChen-007、@icysun +- **前端资源完全本地化**:第三方 CSS / JS 全部本地分发,离线 / 内网环境也能正常加载控制台。Thanks @gitlayzer (#2816) + +## 🛠 体验优化与修复 + +- **TTS 适配更多通道**:Web对话、个人微信、飞书、钉钉、企微智能机器人均已支持回复语音,详情查看 [通道概览](https://docs.cowagent.ai/channels) +- **日志面板增强**:根据日志等级差异化高亮展示、支持根据等级筛选。Thanks @yangluxin613 (#2807) +- **Web 控制台自动启动**:程序启动后自动打开 Web 控制台。Thanks @yangluxin613 (#2804) +- **Ctrl+C 干净退出**:不再打印一长串 `KeyboardInterrupt` 堆栈。Thanks @yangluxin613 (#2806) +- **文件夹上传**:Web 端支持目录上传,路径校验适配 Windows。Thanks @TryToMakeUsBetter (#2814) +- 修复定时任务在某些情况下重复执行的问题。Thanks @CNXudiandian (#2820) +- 修复定时任务带时区时单次任务不触发的问题。Thanks @AethericSpace +- 修复执行失败的工具调用在页面刷新后不显示的问题。Thanks @a1094174619 (#2822) +- 修复企微机器人消息中包含非法控制字符导致投递失败的问题。Thanks @Jacques-Zhao (#2810) + +## 📦 升级方式 + +源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。 + +**发布日期**:2026.05.22 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.8...2.0.9) diff --git a/docs/zh/skills/create.mdx b/docs/zh/skills/create.mdx new file mode 100644 index 00000000..45cef149 --- /dev/null +++ b/docs/zh/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/CowAgent/blob/master/skills/skill-creator/SKILL.md)。 + diff --git a/docs/zh/skills/hub.mdx b/docs/zh/skills/hub.mdx new file mode 100644 index 00000000..65c8a216 --- /dev/null +++ b/docs/zh/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** 等)参考 [安装技能](/zh/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/zh/skills/image-generation.mdx b/docs/zh/skills/image-generation.mdx new file mode 100644 index 00000000..5a174a8a --- /dev/null +++ b/docs/zh/skills/image-generation.mdx @@ -0,0 +1,98 @@ +--- +title: image-generation - 图像生成 +description: 文生图 / 图生图 / 多图融合,支持多家厂商自动路由与回退 +--- + +通用的图像生成与编辑技能,支持 OpenAI、Gemini、Seedream(火山方舟)、Qwen(百炼)、MiniMax、LinkAI 共六家厂商。配好任意一家的 Key 即可使用,配多家可享受自动回退。 + +## 支持的模型 + +| 厂商 | 模型 / 别名 | 特点 | +| --- | --- | --- | +| OpenAI | `gpt-image-2`、`gpt-image-1` | 通用文生图,高质量,支持 `quality` 控制画质 | +| Gemini Nano Banana | `nano-banana-2`、`nano-banana-pro`、`nano-banana` | 对应 `gemini-3.1-flash`、`gemini-3-pro`、`gemini-2.5-flash` 的图像版本 | +| Seedream(火山方舟) | `seedream-5.0-lite`、`seedream-4.5` | 原生 2K–4K,最多 14 张图融合 | +| Qwen(百炼) | `qwen-image-2.0`、`qwen-image-2.0-pro` | 擅长中文排版和图文融合 | +| MiniMax | `image-01` | 简单快速 | +| LinkAI | 任意模型 | 统一网关,作为兜底 | + +## 模型选择 + +默认走「自动路由 + 失败回退」: + +1. 按 `OpenAI → Gemini → Seedream → Qwen → MiniMax → LinkAI` 顺序选第一个已配置的厂商 +2. 遇到 401、模型未开通、网络异常等错误时,自动切到下一家 +3. 用户在对话里指定模型时(如「用 seedream 画一只猫」),对应厂商会被提到最前优先尝试 + +如需固定使用某个模型: + +```json +{ + "skills": { + "image-generation": { + "model": "seedream-5.0-lite" + } + } +} +``` + +## 配置 API Key + + + 推荐通过 [Web 控制台](/zh/channels/web) 的「模型管理」页面配置,配好的对话模型 Key 会被图像生成技能自动复用,无需重复配置。也可手动编辑配置文件或在对话中通过 `env_config` 工具临时设置。 + + +凭证统一复用主模型厂商的 Key: + +| 字段 | 对应厂商 | +| --- | --- | +| `openai_api_key` | OpenAI | +| `gemini_api_key` | Gemini | +| `ark_api_key` | 火山方舟(Seedream) | +| `dashscope_api_key` | 阿里百炼(Qwen) | +| `minimax_api_key` | MiniMax | +| `linkai_api_key` | LinkAI | + + +## 开启和关闭 + +技能会根据 API Key 自动调整状态: + +- **已配置 Key**:Agent 收到画图请求时直接调用 +- **未配置 Key**:技能仍会出现在上下文中(标记为「需要配置」),Agent 会引导用户去配 Key + +如需手动控制: + +```text +/skill disable image-generation # 关闭 +/skill enable image-generation # 重新开启 +``` + +终端等价命令:`cow skill disable image-generation` / `cow skill enable image-generation`。 + +## 参数 + +| 参数 | 类型 | 必填 | 默认 | 说明 | +| --- | --- | --- | --- | --- | +| `prompt` | string | 是 | — | 图像描述 | +| `image_url` | string / list | 否 | null | 编辑用的输入图,本地路径或 URL;传列表为多图融合 | +| `quality` | string | 否 | auto | `low` / `medium` / `high`,仅部分厂商支持 | +| `size` | string | 否 | auto | `512` / `1K` / `2K` / `3K` / `4K`,或像素值如 `1024x1024` | +| `aspect_ratio` | string | 否 | null | `1:1` / `3:2` / `2:3` / `16:9` / `9:16` / `21:9`;Gemini 还支持 `1:4` / `4:1` / `1:8` / `8:1` | + + + **质量越高、分辨率越大,耗时和成本越高。** 日常对话用默认(`auto`)或 `quality=low` + `size=1K` 即可,约 20 秒出图;做海报或明确要高清时再上 `high` + `2K/4K`,可能需要 1–5 分钟。 + + +## 常见用法 + +- **文生图**:根据描述生成插画、海报、图标、头像、分镜图等 +- **图生图**:在已有图片上改风格、换元素、加装饰、加文字等 +- **多图融合**:把多张参考图合成一张(换装、角色合影等) + + +- bash 超时建议设 600 秒:单厂商 HTTP 超时 300 秒,脚本可能依次尝试多家 +- 输入图片自动压缩到 4MB 以内、最长边不超过 4096px +- Gemini / Seedream / Qwen / MiniMax 不支持 `quality` 参数 +- Seedream 默认出 2K 图;`seedream-5.0-lite` 支持到 3K,`seedream-4.5` 支持到 4K + diff --git a/docs/zh/skills/index.mdx b/docs/zh/skills/index.mdx new file mode 100644 index 00000000..d30a2efa --- /dev/null +++ b/docs/zh/skills/index.mdx @@ -0,0 +1,65 @@ +--- +title: 技能概览 +description: CowAgent 技能系统介绍 +--- + +技能(Skill)为 Agent 提供无限的扩展性。每个 Skill 由说明文件(`SKILL.md`)、运行脚本(可选)、资源(可选)组成,描述如何完成特定类型的任务。 + +Skill 与 Tool 的区别:Tool 是由代码实现的原子操作(如读写文件、执行命令),Skill 则是基于说明文件的高级工作流,可以组合调用多个 Tool 来完成复杂任务。 + +## 获取技能 + +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 自动创建技能 + +详细安装方式参考 [安装技能](/zh/skills/install) 和 [技能管理命令](/zh/cli/skill)。也可以通过对话 [创建技能](/zh/skills/create),或向 [Skill Hub](https://skills.cowagent.ai/submit) 贡献你的技能。 + +## 技能加载优先级 + +1. **工作空间技能**(最高):`~/cow/skills/` +2. **项目内置技能**(最低):`skills/` + +同名技能按优先级覆盖。 + +## 技能文件结构 + +``` +skills/ +├── my-skill/ +│ ├── SKILL.md # Skill description (frontmatter + instructions) +│ ├── scripts/ # Execution scripts (optional) +│ └── resources/ # Additional resources (optional) +``` + +### 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) | diff --git a/docs/zh/skills/install.mdx b/docs/zh/skills/install.mdx new file mode 100644 index 00000000..bbec9d99 --- /dev/null +++ b/docs/zh/skills/install.mdx @@ -0,0 +1,66 @@ +--- +title: 安装技能 +description: 通过命令一键安装来自多种来源的技能 +--- + +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 +/skill install pptx +``` + +## 从 GitHub 安装 + +> Github上的所有技能都可以直接安装,支持仓库级批量安装和指定子目录安装,例如: + +```text +/skill install larksuite/cli +/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im +``` + +## 从 ClawHub 安装 + +[ClawHub](https://clawhub.ai/) 上的所有技能 (4w+个) 都可以一键安装,例如: + + +```text +/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 文件链接: + +```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` 即可。完整命令说明参考 [技能管理命令](/zh/cli/skill)。 + diff --git a/docs/zh/skills/knowledge-wiki.mdx b/docs/zh/skills/knowledge-wiki.mdx new file mode 100644 index 00000000..40b4d298 --- /dev/null +++ b/docs/zh/skills/knowledge-wiki.mdx @@ -0,0 +1,112 @@ +--- +title: knowledge-wiki - 知识库 +description: 维护本地结构化知识库,自动归档、分类和交叉引用 +--- + +帮你把对话中产生的资料、灵感和零散笔记整理成结构化的本地知识库,自动维护索引和页面之间的交叉引用。 + +`knowledge-wiki` 在工作空间下维护一个 `knowledge/` 目录,相当于 Agent 的「外脑」。技能设置了 `always: true`,会**常驻上下文**,不需要任何外部依赖。 + +## 什么时候会触发 + +- 你分享了一篇文章、一份文档或一个 URL,想要沉淀下来 +- 聊天过程中聊出了值得长期保留的结论 +- 你想查一下之前积累过的知识 + +## 目录结构 + +``` +knowledge/ +├── index.md # 全局索引(必须维护) +├── log.md # 操作日志(只追加) +└── / # 分类子目录(按内容自由分组) + └── .md # 知识页(文件名用小写加中划线) +``` + +## 三个核心操作 + +### 1. 收录(Ingest) + +你分享了一段资料时,Agent 会: + +1. 读懂原文,提取关键信息 +2. 按内容决定放到哪个分类下——先看 `index.md` 里有没有合适的分类,没有就新建一个 +3. 生成知识页 `knowledge//.md` +4. 更新索引 `index.md` 和日志 `log.md` + +### 2. 综合(Synthesize) + +聊天中产生了新的结论或洞见时: + +1. 在合适的分类下创建新知识页 +2. 给相关的已有页面加上互相指向的链接 +3. 更新索引和日志 + +### 3. 查询(Query) + +你问到以前积累的知识时: + +1. 先从 `index.md` 里找可能相关的页面 +2. 用 `read` 工具打开具体页面 +3. 需要时再用 `memory_search` 补充检索 +4. 回答里会带上知识页的链接,方便你点过去看原文 + +## 知识页怎么写 + +```markdown +# 页面标题 + +> Source: <来源 URL 或简要说明> + +正文内容。页面之间用相对路径链接: +[相关页](../category/related-page.md) + +## 要点 + +- ... + +## 相关页面 + +- [页面 A](../category/page-a.md) — 为什么相关 +``` + + +- `> Source:` 用来记录这条知识的来源。有明确来源时一定要写 +- 交叉引用很重要:创建或更新某页时,记得也去关联页面里补上反向链接 +- **只链接已经存在的页面**。如果某个概念值得单独成页,先建好再加链接 + + +## 索引格式 + +`knowledge/index.md` 采用扁平列表,按分类分组,每个知识页占一行: + +```markdown +# Knowledge Index + +## 分类 A +- [页面标题](category-a/page-slug.md) — 一句话摘要 + +## 分类 B +- [页面标题](category-b/page-slug.md) — 一句话摘要 +``` + +不用表格,不加 emoji。分类怎么起名、怎么组织都可以灵活调整。 + +## 日志格式 + +`knowledge/log.md` 只追加、不修改,最新的写在最下面: + +```markdown +## [YYYY-MM-DD] ingest | 页面标题 +## [YYYY-MM-DD] synthesize | 页面标题 +``` + +## 写作约定 + +- **文件名**用小写加中划线,比如 `machine-learning.md` +- **一页只讲一件事**,需要关联的内容通过链接串起来 +- **有了就更新,不要重复建页** +- **每次改完都要更新索引** `knowledge/index.md` +- **写精华别抄全文**,抓住要点就行 +- **对话里引用知识页时用完整路径**,比如 `[标题](knowledge//.md)`。页面之间互相链接才用相对路径 +- **基于知识页回答问题时附上链接**,方便深入查阅 diff --git a/docs/zh/skills/skill-creator.mdx b/docs/zh/skills/skill-creator.mdx new file mode 100644 index 00000000..623a74f6 --- /dev/null +++ b/docs/zh/skills/skill-creator.mdx @@ -0,0 +1,180 @@ +--- +title: skill-creator - 技能创建 +description: 创建、安装、更新技能,规范 SKILL.md 写法与目录结构 +--- + +`skill-creator` 是一个「元技能」,专门用来帮助 Agent 创建、安装和更新其他技能,确保所有技能的 `SKILL.md` 写法和目录结构保持一致。 + +## 什么时候会触发 + +- 用户想从 URL 或远程仓库安装一个技能 +- 用户想从头创建一个全新的技能 +- 需要升级或重构已有技能 + +## 技能是什么 + +简单来说,技能就是一份「可复用的说明书」加上可选的脚本和资源。它给 Agent 注入了某个领域的专业知识,让 Agent 在遇到对应任务时能像专家一样处理。 + +一个技能通常包含以下内容: + +1. **专项工作流** — 某类任务的完整步骤 +2. **工具用法** — 怎么调某种 API 或处理某种文件 +3. **领域知识** — 团队约定、业务规则、数据结构之类 +4. **附带资源** — 脚本、参考文档、模板等 + + +**核心原则:能省则省**。只写 Agent 自己想不到的内容,每加一行都要问自己:值不值得占这些 token? + + +## 目录结构 + +``` +skill-name/ +├── SKILL.md # 必需:技能定义 +│ ├── YAML frontmatter(必填 name / description) +│ └── Markdown 正文(说明 + 示例) +└── 可选资源 + ├── scripts/ # 可执行脚本(Python / Bash 等) + ├── references/ # 内容较多的参考文档,Agent 按需读取 + └── assets/ # 模板、图标等,会直接用在输出里 +``` + +## SKILL.md 规范定义 + +SKILL.md 文件头部的 `frontmatter` 字段: + +| 字段 | 说明 | +| --- | --- | +| `name` | 技能名,小写加中划线,必须和目录名一致 | +| `description` | **最关键的字段**。写清楚「这个技能干什么」和「什么情况下该用它」,Agent 看到这段来决定要不要调它。注意:所有触发相关的描述都放在这里,不要写到正文里 | +| `metadata.cowagent.requires.bins` | 系统里必须装了哪些命令行工具 | +| `metadata.cowagent.requires.env` | 需要哪些环境变量(全部满足才行) | +| `metadata.cowagent.requires.anyEnv` | 多个 API Key 满足一个就行 | +| `metadata.cowagent.requires.anyBins` | 多个工具满足一个就行 | +| `metadata.cowagent.always` | 设为 `true` 会始终加载,不检查依赖 | +| `metadata.cowagent.emoji` | 展示用的 emoji(可选) | +| `metadata.cowagent.os` | 限定系统,如 `["darwin", "linux"]` | + + +`category` 字段不需要手写,系统会自动设成 `skill`。 + + +声明 API Key 依赖有两种写法: + +```yaml +metadata: + cowagent: + requires: + env: ["MYAPI_KEY"] # 必须有 +``` + +```yaml +metadata: + cowagent: + requires: + anyEnv: ["OPENAI_API_KEY", "LINKAI_API_KEY"] # 有一个就行 +``` + +**技能会自动按依赖启禁用**:环境变量齐了就自动启用,缺了就自动禁用,不需要手动 `/skill enable`。 + +## 资源目录怎么用 + +| 目录 | 放什么 | 不要放 | +| --- | --- | --- | +| `scripts/` | 需要反复执行的代码,或需要确定性结果的脚本 | 纯演示用的代码片段 | +| `references/` | **超过 500 行**、SKILL.md 实在塞不下的大文档(比如完整的数据库 Schema) | 普通 API 文档、示例、教程 | +| `assets/` | 会出现在最终产物里的文件(模板、图标、样板代码等) | 说明性文档 | + + +**原则上所有内容都写在 `SKILL.md` 里**,只有确实放不下才拆到资源目录。 + +不要给技能加 `README.md`、`CHANGELOG.md`、`INSTALLATION_GUIDE.md` 之类的文件——全部放进 `SKILL.md`。资源目录里只放真正要跑的脚本或真正要用的素材。 + + +## 安装外部技能 + +安装后最终落在 `/skills//` 目录。 + +| 来源 | 怎么装 | +| --- | --- | +| URL(单文件) | curl / web_fetch 直接拉 | +| URL(zip 包) | 下载解压 | +| 本地 SKILL.md | 直接读 | +| 本地 zip 包 | 解压 | + +安装步骤: + +1. 找到 `SKILL.md`(可能在包的根目录或某个子目录里) +2. 从 frontmatter 里读出 `name` +3. 把**整个技能目录**(包括 `SKILL.md`、`scripts/`、`assets/` 等)复制到 `/skills//` +4. 如果包里有 `INSTALL.md` 之类的安装脚本,照着跑一遍,但最终结果仍然要落在 `/skills//` 下 + +## 从头创建技能 + +推荐按这个顺序来: + +1. **搞清楚需求** — 让用户举几个具体的使用场景,一次别问太多 +2. **想好结构** — 这个技能需要脚本吗?需要参考文档吗?需要模板素材吗? +3. **生成骨架** — 用初始化脚本: + + ```bash + scripts/init_skill.py --path /skills [--resources scripts,references,assets] [--examples] + ``` + +4. **填充内容** — 写好 SKILL.md、补上脚本和资源。脚本写完一定要实际跑一遍 +5. **格式校验**(可选): + + ```bash + scripts/quick_validate.py /skills/ + ``` + +6. **迭代完善** — 实际用起来之后根据反馈持续改进 + +## 命名规则 + +- 只用小写字母、数字和中划线。用户给的名字需要做标准化处理,比如 `Plan Mode` → `plan-mode` +- 长度别超过 64 个字符 +- 尽量短、用动词开头、一看就知道干什么 +- 必要时用工具名做前缀,比如 `gh-address-comments`、`linear-address-issue` +- 目录名和 `name` 字段必须完全一致 + +## 三级加载机制 + +技能不会一次性全部塞进上下文,而是分三级按需加载: + +1. **元信息**(`name` + `description`)— 常驻上下文,约 100 词。Agent 靠它判断「要不要用这个技能」 +2. **SKILL.md 正文** — 确定要用了才加载,建议控制在 500 行以内 +3. **资源文件** — Agent 需要的时候再读 + +如果一个技能涉及多个变体(比如多云厂商部署),建议这样组织: + +``` +cloud-deploy/ +├── SKILL.md # 主流程和厂商选择逻辑 +└── references/ + ├── aws.md + ├── gcp.md + └── azure.md +``` + +用户选了 AWS,Agent 只需要读 `aws.md`,不用把三家的文档全加载进来。 + +## 常见设计模式 + +**步骤式**:按编号列出操作步骤和对应脚本。 + +```markdown +1. 分析表单结构(运行 analyze_form.py) +2. 生成字段映射(编辑 fields.json) +3. 自动填充表单(运行 fill_form.py) +``` + +**分支式**:根据用户意图走不同流程。 + +```markdown +1. 判断操作类型: + **新建内容?** → 走「创建流程」 + **编辑已有内容?** → 走「编辑流程」 +``` + +**模板式**:输出格式有严格要求时,在 SKILL.md 里直接给一个样板,让 Agent 照着写。 diff --git a/docs/zh/tools/bash.mdx b/docs/zh/tools/bash.mdx new file mode 100644 index 00000000..0090fe14 --- /dev/null +++ b/docs/zh/tools/bash.mdx @@ -0,0 +1,28 @@ +--- +title: bash - 终端 +description: 执行系统命令 +--- + +在当前工作目录执行 Bash 命令,返回 stdout 和 stderr。`env_config` 中配置的 API Key 会自动注入到环境变量中。 + +## 依赖 + +无额外依赖,默认可用。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `command` | string | 是 | 要执行的命令 | +| `timeout` | integer | 否 | 超时时间(秒) | + +## 使用场景 + +- 安装软件包和依赖 +- 运行代码和测试 +- 部署应用和服务(Nginx 配置、进程管理等) +- 系统运维和排查 + + + + diff --git a/docs/zh/tools/browser.mdx b/docs/zh/tools/browser.mdx new file mode 100644 index 00000000..0d3c9197 --- /dev/null +++ b/docs/zh/tools/browser.mdx @@ -0,0 +1,172 @@ +--- +title: browser - 浏览器 +description: 控制浏览器访问和操作网页 +--- + +控制 Chromium 浏览器进行网页导航、元素交互和内容提取。支持 JavaScript 渲染的动态页面,使用精简 DOM 快照让 Agent 高效理解页面结构。 + +## 安装 + + + + ```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 + ``` + + + + + 1. 支持 Ubuntu 20.04+、Debian 10+、macOS、Windows。Ubuntu 18.04 等旧系统会自动降级安装兼容版本。 + 2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。 + + +## 工作流程 + +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 获取动态页面内容 +- 填写表单、登录操作 +- 操作网页元素(点击按钮、选择选项等) +- 验证部署后的网页效果 +- 抓取需要 JS 渲染的动态内容 + +## 运行模式 + +浏览器会根据运行环境自动选择模式: + +| 环境 | 模式 | +| --- | --- | +| macOS / Windows | 有头模式(显示浏览器窗口) | +| Linux 桌面(有 DISPLAY) | 有头模式 | +| Linux 服务器(无 DISPLAY) | 无头模式(headless) | + +可在 `config.json` 中手动覆盖: + +```json +{ + "tools": { + "browser": { + "headless": true + } + } +} +``` + +## 登录态持久化 + +**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式: + +### 方式一:Persistent 模式(默认) + +开箱即用,登录信息保存在 `~/.cow/browser_profile`。无需任何配置。 + +如需关闭持久化模式,每次都用纯净环境: + +```json +{ + "tools": { + "browser": { + "persistent": false + } + } +} +``` + +### 方式二:CDP 模式(接管真实 Chrome) + +让 Agent 连接独立启动的真实 Chrome(而非 Playwright 自带的 Chromium),获得完整浏览器指纹,适合反爬严格的网站。 + +启动 Chrome 时加上调试端口和独立用户目录: + + + + ```bash + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --remote-debugging-port=9222 \ + --user-data-dir="$HOME/.cow/chrome-cdp" + ``` + + + ```bash + google-chrome \ + --remote-debugging-port=9222 \ + --user-data-dir="$HOME/.cow/chrome-cdp" + ``` + + + ```powershell + & "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --user-data-dir="$env:USERPROFILE\.cow\chrome-cdp" + ``` + + + +在 `config.json` 中配置端点: + +```json +{ + "tools": { + "browser": { + "cdp_endpoint": "http://localhost:9222" + } + } +} +``` + + + Chrome 137+ 限制 `--remote-debugging-port` 必须搭配独立 `--user-data-dir`,因此 CDP 启动的 Chrome **无法直接复用你日常 Chrome 的登录态**,需要在独立目录中重新登录一次。 + diff --git a/docs/zh/tools/edit.mdx b/docs/zh/tools/edit.mdx new file mode 100644 index 00000000..717af2ba --- /dev/null +++ b/docs/zh/tools/edit.mdx @@ -0,0 +1,24 @@ +--- +title: edit - 文件编辑 +description: 通过精确文本替换编辑文件 +--- + +通过精确文本替换编辑文件。如果 `oldText` 为空则追加到文件末尾。 + +## 依赖 + +无额外依赖,默认可用。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `path` | string | 是 | 文件路径 | +| `oldText` | string | 是 | 要替换的原始文本(为空时追加到末尾) | +| `newText` | string | 是 | 替换后的文本 | + +## 使用场景 + +- 修改配置文件中的特定参数 +- 修复代码中的 bug +- 在文件指定位置插入内容 diff --git a/docs/zh/tools/env-config.mdx b/docs/zh/tools/env-config.mdx new file mode 100644 index 00000000..d5d52c68 --- /dev/null +++ b/docs/zh/tools/env-config.mdx @@ -0,0 +1,36 @@ +--- +title: env_config - 环境变量 +description: 管理 API Key 等秘钥配置 +--- + +管理工作空间 `.env` 文件中的环境变量(API Key 等秘钥),支持通过对话安全地添加和更新。内置安全保护和脱敏策略。 + +## 依赖 + +| 依赖 | 安装命令 | +| --- | --- | +| `python-dotenv` ≥ 1.0.0 | `pip install python-dotenv>=1.0.0` | + +安装扩展依赖时已包含:`pip3 install -r requirements-optional.txt` + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `action` | string | 是 | 操作类型:`get`、`set`、`list`、`delete` | +| `key` | string | 否 | 环境变量名称 | +| `value` | string | 否 | 环境变量值(仅 `set` 时需要) | + +## 使用方式 + +直接告诉 Agent 需要配置的秘钥,Agent 会自动调用该工具: + +- "帮我配置 BOCHA_API_KEY" +- "设置 OPENAI_API_KEY 为 sk-xxx" +- "查看已配置的环境变量" + +配置的秘钥会自动注入到 `bash` 工具的执行环境中。 + + + + diff --git a/docs/zh/tools/index.mdx b/docs/zh/tools/index.mdx new file mode 100644 index 00000000..088c0731 --- /dev/null +++ b/docs/zh/tools/index.mdx @@ -0,0 +1,69 @@ +--- +title: 工具概览 +description: CowAgent 内置工具系统 +--- + +工具是 Agent 访问操作系统资源的核心能力。Agent 会根据任务需求智能选择和调用工具,完成文件操作、命令执行、联网搜索、定时任务等各类操作。工具实现在项目的 `agent/tools/` 目录下。 + +## 内置工具 + +以下工具默认可用,无需额外配置: + + + + 读取文件内容,支持文本、图片、PDF + + + 创建或覆盖写入文件 + + + 通过精确文本替换编辑文件 + + + 列出目录内容 + + + 执行系统命令 + + + 向用户发送文件或图片 + + + 搜索和读取长期记忆 + + + 管理 API Key 等秘钥配置 + + + 获取网页或文档内容 + + + 创建和管理定时任务 + + + +## 可选工具 + +以下工具需要安装额外依赖或配置 API Key 后启用: + + + + 搜索互联网获取实时信息 + + + 分析图片内容(识别、描述、OCR 文字提取等) + + + 控制浏览器访问和操作网页 + + + +## MCP 工具 + +通过 [Model Context Protocol](https://modelcontextprotocol.io) 接入社区生态中的各种MCP工具,配置一次 `mcp.json` 即用即得: + + + + 支持 stdio / SSE 标准协议,热更新,零代码接入 + + diff --git a/docs/zh/tools/ls.mdx b/docs/zh/tools/ls.mdx new file mode 100644 index 00000000..e4d25fc5 --- /dev/null +++ b/docs/zh/tools/ls.mdx @@ -0,0 +1,23 @@ +--- +title: ls - 目录列表 +description: 列出目录内容 +--- + +列出目录内容,按字母排序,目录名带 `/` 后缀,包含隐藏文件。 + +## 依赖 + +无额外依赖,默认可用。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `path` | string | 是 | 目录路径,相对路径基于工作空间目录 | +| `limit` | integer | 否 | 最大返回条目数,默认 500 | + +## 使用场景 + +- 浏览项目结构 +- 查找特定文件 +- 检查目录是否存在 diff --git a/docs/zh/tools/mcp.mdx b/docs/zh/tools/mcp.mdx new file mode 100644 index 00000000..8b7670c1 --- /dev/null +++ b/docs/zh/tools/mcp.mdx @@ -0,0 +1,112 @@ +--- +title: MCP 工具 +description: 通过 Model Context Protocol 接入外部工具生态 +--- + +CowAgent 支持 [Model Context Protocol (MCP)](https://modelcontextprotocol.io),让 Agent 能够直接调用社区中数以万计的 MCP 工具。配置一次 `mcp.json`,工具就会以与内置工具完全相同的方式呈现给 LLM,可被自动选择和调用。 + +## 配置文件 + +CowAgent 读取 `~/cow/mcp.json`。文件不存在时不会启用任何 MCP 工具,也不会报错。 + +Docker 部署时,官方 `docker-compose.yml` 已经把宿主机 `./cow` 挂载到容器内 `/home/agent/cow`(即容器用户的 `~/cow`),把 `mcp.json` 放进宿主机 `./cow/` 目录即可生效。 + +### 标准格式 + +完全兼容 MCP 社区标准,同 Claude Desktop / Cursor 一致: + +```json +{ + "mcpServers": { + "": { + "command": "npx", + "args": ["-y", "some-mcp-package"], + "env": { + "API_KEY": "your-key-here" + } + } + } +} +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `command` | stdio | 启动 server 的可执行命令(如 `npx`、`python`、`uvx`) | +| `args` | 否 | 传给 command 的参数列表 | +| `env` | 否 | 子进程的环境变量,常用于 API Key | +| `url` | SSE / Streamable HTTP | 远程端点 URL(与 `command` 二选一) | +| `type` | 远程 | 远程传输类型,可选 `sse` 或 `streamable-http`,默认 `sse` | +| `headers` | 否 | 远程请求附加 HTTP 头(如 `Authorization`),仅 Streamable HTTP 使用 | +| `disabled` | 否 | `true` 时跳过该 server,便于临时关闭 | + +### 完整示例 + +```json +{ + "mcpServers": { + "fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "" + } + } + } +} +``` + +- **fetch**:通用网页抓取,返回页面文本内容,无需 API Key +- **github**:访问 GitHub 仓库、Issue、PR 等,需要 Personal Access Token + +## 让 Agent 帮你配置 + +CowAgent 自带 `read` / `write` / `edit` 工具,**直接把要装的 MCP 配置发给 Agent,让它写到配置文件中: + +例如: + +```markdown +帮我把这个 MCP 加到 ~/cow/mcp.json 里: + +{"mcpServers":{"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}} +``` + +Agent 会: + +1. 访问 MCP 配置文件,合并新 server 配置,保留已有项 +2. 自动重载增量的 MCP Server,下一次对话即可使用相应 Tools + +## 工作方式 + +- 启动时**异步加载**:`mcp.json` 中配置的所有 server 会在后台异步加载,不阻塞主流程,对话可以立刻使用 +- **热更新**:用户或 Agent 修改 `mcp.json` 后,消息处理完成时会自动重载变更的 server,无需重启 cow +- **平铺呈现**:每个 MCP server 暴露的多个方法会平铺为独立的工具,LLM 直接选择调用,不需要二次决策 + +## 支持的传输协议 + +| 协议 | 说明 | 配置字段 | +| --- | --- | --- | +| **stdio** | 子进程通信,最常见,社区生态最丰富 | `command` + `args` | +| **SSE** | HTTP Server-Sent Events,旧版远程协议 | `url`(默认) | +| **Streamable HTTP** | 新版远程协议,单端点收发,逐步取代 SSE | `type: "streamable-http"` + `url` | + +## 排错 + +| 现象 | 排查方向 | +| --- | --- | +| 启动后 Agent 没有 MCP 工具 | 检查 `~/cow/mcp.json` 是否存在、JSON 格式是否合法 | +| 某个 server 加载失败 | 查看启动日志中的 `[MCP] Server 'xxx' load failed`,常见为依赖未装、API Key 缺失 | +| 修改 `mcp.json` 没有生效 | 改动会在**下一条消息**生效;若 server 配置不变(如只改注释),不会触发重启 | +| Docker 部署 | 确认宿主机 `./cow` 已挂载到容器内 `/home/agent/cow`,`mcp.json` 直接放进宿主机 `./cow/` 目录即可,或者直接对话 Agent 安装 | + +## MCP 市场推荐 + +可以从各个第三方广场寻找现成的 MCP server,复制 JSON 配置即可使用,例如: + +- [mcp.so](https://mcp.so) — 全球 MCP 服务索引 +- [ModelScope MCP 广场](https://modelscope.cn/mcp) — 魔搭社区 MCP 广场,国内访问更稳定 + +只要遵循 MCP 标准协议(stdio / SSE / Streamable HTTP),都可以直接接入 CowAgent。 diff --git a/docs/zh/tools/memory.mdx b/docs/zh/tools/memory.mdx new file mode 100644 index 00000000..c3cc6fe4 --- /dev/null +++ b/docs/zh/tools/memory.mdx @@ -0,0 +1,43 @@ +--- +title: memory - 记忆与知识 +description: 搜索和读取长期记忆及知识库文件 +--- + +记忆工具包含两个子工具:`memory_search`(搜索记忆)和 `memory_get`(读取记忆或知识文件)。 + +当 [知识库](/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。 + +## 依赖 + +无额外依赖,默认可用。由 Agent Core 的记忆系统管理。 + +## memory_search + +搜索历史记忆和知识库内容,支持关键词和向量混合检索。 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `query` | string | 是 | 搜索查询 | + +## memory_get + +读取特定记忆文件或知识库文件的内容。 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `path` | string | 是 | 文件的相对路径(如 `MEMORY.md`、`memory/2026-01-01.md`、`knowledge/concepts/rag.md`) | +| `start_line` | integer | 否 | 起始行号 | +| `end_line` | integer | 否 | 结束行号 | + +## 工作方式 + +Agent 会在以下场景自动调用记忆工具: + +- 用户分享重要信息时 → 存储到记忆 +- 需要参考历史信息时 → 搜索相关记忆 +- 对话达到一定长度时 → 提取摘要存储 +- 讨论到专业知识时 → 检索知识库中的相关页面 + + + 当 `knowledge` 配置为 `false` 时,工具的描述和搜索范围会自动调整为仅包含记忆文件。 + diff --git a/docs/zh/tools/read.mdx b/docs/zh/tools/read.mdx new file mode 100644 index 00000000..07e08b88 --- /dev/null +++ b/docs/zh/tools/read.mdx @@ -0,0 +1,24 @@ +--- +title: read - 文件读取 +description: 读取文件内容 +--- + +读取文件内容。支持文本文件、PDF 文件、图片(返回元数据)等格式。 + +## 依赖 + +无额外依赖,默认可用。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `path` | string | 是 | 文件路径,相对路径基于工作空间目录 | +| `offset` | integer | 否 | 起始行号(1-indexed),负值表示从末尾读取 | +| `limit` | integer | 否 | 读取行数 | + +## 使用场景 + +- 查看配置文件、日志文件 +- 读取代码文件进行分析 +- 检查图片/视频的文件信息 diff --git a/docs/zh/tools/scheduler.mdx b/docs/zh/tools/scheduler.mdx new file mode 100644 index 00000000..2648116f --- /dev/null +++ b/docs/zh/tools/scheduler.mdx @@ -0,0 +1,80 @@ +--- +title: scheduler - 定时任务 +description: 创建和管理定时任务 +--- + +创建和管理动态定时任务,支持灵活的调度方式和执行模式。 + +## 依赖 + +| 依赖 | 安装命令 | +| --- | --- | +| `croniter` ≥ 2.0.0 | `pip install croniter>=2.0.0` | + +安装核心依赖时已包含:`pip3 install -r requirements.txt` + +## 调度方式 + +| 方式 | 说明 | +| --- | --- | +| 一次性任务 | 在指定时间执行一次 | +| 固定间隔 | 按固定时间间隔重复执行 | +| Cron 表达式 | 使用 Cron 语法定义复杂调度规则 | + +## 执行模式 + +- **固定消息发送**:到达触发时间时发送预设消息 +- **Agent 动态任务**:到达触发时间时由 Agent 智能执行任务 + +## 使用方式 + +通过自然语言即可创建和管理定时任务: + +- "每天早上 9 点给我发天气预报" +- "每隔 2 小时检查一下服务器状态" +- "明天下午 3 点提醒我开会" +- "查看所有定时任务" + + + + + +## 结果进入会话上下文 + +定时任务在隔离 session 中执行(内部规划与 tool 调用不污染用户会话),但**最终输出**会作为一对消息回写到接收者的真实会话,用户可以直接追问"刚才那条第二点展开说说"。 + +**默认策略** + +- Agent 动态任务的输出进入上下文 +- 固定消息类任务默认不进入上下文(可通过配置打开) +- 每个会话最多保留最近 **3 对** scheduler 消息,更早的自动清理;普通用户消息不受影响 + +**配置项** + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `scheduler_inject_to_session` | `true` | 总开关 | +| `scheduler_inject_max_per_session` | `3` | 每会话保留 scheduler 消息对数上限 | +| `scheduler_inject_send_message` | `false` | 是否同时注入固定消息类任务 | + +```json +{ + "scheduler_inject_to_session": true, + "scheduler_inject_max_per_session": 3, + "scheduler_inject_send_message": false +} +``` + +## 任务执行时的上下文 + +定时任务的隔离 session 会保留最近几次执行的对话历史,便于做"对比上次"、"延续之前结论"等操作;但为了避免高频任务(如每 5 分钟监控)prompt 越积越长,会按公式自动裁剪: + +``` +scheduler_keep_turns = max(1, agent_max_context_turns / 5) +``` + +`agent_max_context_turns` 默认为 `20`,所以定时任务每次执行默认带最近 **4 轮**历史。需要更长记忆可调大 `agent_max_context_turns`。 + + +群聊场景(飞书 / 企微群机器人 / 钉钉等)下用户的真实 session_id 形如 `user_id:group_id`,与 receiver 不同。创建任务时会自动记录正确的 session_id;老的 `tasks.json` 缺该字段时回落到 receiver,行为与历史版本一致。 + diff --git a/docs/zh/tools/send.mdx b/docs/zh/tools/send.mdx new file mode 100644 index 00000000..05f73a6b --- /dev/null +++ b/docs/zh/tools/send.mdx @@ -0,0 +1,23 @@ +--- +title: send - 文件发送 +description: 向用户发送文件 +--- + +向用户发送文件(图片、视频、音频、文档等),当用户明确要求发送/分享文件时使用。 + +## 依赖 + +无额外依赖,默认可用。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `path` | string | 是 | 文件路径,可以是绝对路径或相对于工作空间的路径 | +| `message` | string | 否 | 附带的消息说明 | + +## 使用场景 + +- 将生成的代码或文档发送给用户 +- 发送截图、图表 +- 分享下载的文件 diff --git a/docs/zh/tools/vision.mdx b/docs/zh/tools/vision.mdx new file mode 100644 index 00000000..675afe41 --- /dev/null +++ b/docs/zh/tools/vision.mdx @@ -0,0 +1,75 @@ +--- +title: vision - 图片理解 +description: 分析图片内容(识别、描述、OCR 等) +--- + +使用 Vision API 分析本地图片或图片 URL,支持内容描述、文字提取(OCR)、物体识别等。 + +## 模型选择 + +Vision 工具采用多级自动选择 + 自动兜底策略,无需手动配置即可使用: + +1. **主模型** — 优先使用当前配置的主模型进行图像识别(需要是多模态模型) +2. **其他已配置模型** — 自动发现已配置 API Key 的其他多模态模型作为备选 + +如果当前 provider 调用失败,会自动尝试下一个,直到成功或全部失败。 + +### 支持的模型 + +| 厂商 | 视觉模型 | 说明 | +| --- | --- | --- | +| OpenAI / 兼容协议 | 使用主模型 | 支持所有 OpenAI 协议兼容的多模态模型 | +| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.6-plus 等 | +| Claude | 使用主模型 | Anthropic 原生图像格式 | +| Gemini | 使用主模型 | inlineData 格式 | +| 豆包 (Doubao) | 使用主模型 | doubao-seed-2-0 系列原生支持 | +| Kimi (Moonshot) | 使用主模型 | kimi-k2.6、kimi-k2.5 原生支持 | +| 百度千帆 (Qianfan) | 使用主模型 | 默认使用多模态主模型 (如 ernie-5.1),主模型不支持时兜底使用 ernie-4.5-turbo-vl | +| 智谱 AI | glm-5v-turbo | 固定使用视觉专用模型 | +| MiniMax | MiniMax-Text-01 | 固定使用视觉专用模型 | + + + 智谱和 MiniMax 的文本模型不支持图像理解,因此始终使用对应的视觉专用模型,无需手动指定。 + + +> 当 `use_linkai=true` 时,默认使用 LinkAI 的多模态模型进行 + +## 自定义配置 + +如果希望指定 Vision 使用的模型,可在 `config.json` 中配置,例如: + +```json +{ + "tools": { + "vision": { + "model": "gpt-4.1" + } + } +} +``` + +指定的模型会被**优先使用**,工具会根据模型名自动路由到对应的 provider;若调用失败,会自动 fallback 到其他已配置的 provider。 + +大多数情况下无需配置,主模型支持多模态或配置任意一个支持视觉的 API Key 即可自动工作。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `image` | string | 是 | 本地文件路径或 HTTP(S) 图片 URL | +| `question` | string | 是 | 对图片提出的问题 | + +支持的图片格式:jpg、jpeg、png、gif、webp + + + +## 使用场景 + +- 描述图片中的内容 +- 提取图片中的文字(OCR) +- 识别物体、颜色、场景 +- 分析截图、文档扫描图片等 + + + 超过 1MB 的图片会自动压缩后上传,所有图片(包括远程 URL)会统一转为 base64 传输,确保兼容所有模型后端。 + diff --git a/docs/zh/tools/web-fetch.mdx b/docs/zh/tools/web-fetch.mdx new file mode 100644 index 00000000..12f85953 --- /dev/null +++ b/docs/zh/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/docs/zh/tools/web-search.mdx b/docs/zh/tools/web-search.mdx new file mode 100644 index 00000000..86c170c0 --- /dev/null +++ b/docs/zh/tools/web-search.mdx @@ -0,0 +1,51 @@ +--- +title: web_search - 联网搜索 +description: 搜索互联网获取实时信息,支持多个搜索厂商 +--- + +搜索互联网获取实时信息、新闻、研究等内容。支持博查、百度千帆、智谱、LinkAI 四个后端,配置任意一家即可使用。 + + + 推荐通过 [Web 控制台](/zh/channels/web) 的「模型管理 → 搜索」面板可视化配置厂商与策略,无需手动编辑配置文件。 + + +## 厂商 + +| 厂商 | 凭证 | 申请入口 | +| --- | --- | --- | +| 博查 Bocha | `tools.web_search.bocha_api_key` | [博查开放平台](https://open.bochaai.com/) | +| 百度千帆 | 复用 `qianfan_api_key` | [千帆控制台](https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy) | +| 智谱 Zhipu | 复用 `zhipu_ai_api_key` | [智谱开放平台](https://docs.bigmodel.cn/cn/guide/tools/web-search) | +| LinkAI | 复用 `linkai_api_key` | [LinkAI 控制台](https://link-ai.tech/console/interface) | + +除博查需要单独的 `bocha_api_key` 外,其他三家直接复用对应模型的 API Key,配好模型即同时获得搜索能力。 + +## 路由策略 + +```json +{ + "tools": { + "web_search": { + "strategy": "auto", + "provider": "" + } + } +} +``` + +- `auto`(默认):由 Agent 在已配置的厂商中智能选择,并可在一次任务中多次调用、切换不同厂商以获取更全面的结果;未指定时按 `bocha → qianfan → zhipu → linkai` 顺序兜底。 +- `fixed`:固定使用 `provider` 指定的厂商;该厂商凭证缺失时自动回落到 auto 顺序。 + +## 工具参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `query` | string | 是 | 搜索关键词 | +| `count` | integer | 否 | 返回结果数量(1–50,默认 10) | +| `freshness` | string | 否 | 时间范围:`noLimit`(默认)、`oneDay`、`oneWeek`、`oneMonth`、`oneYear`,或日期范围如 `2025-01-01..2025-02-01` | +| `summary` | boolean | 否 | 是否返回页面摘要(默认 false) | +| `provider` | string | 否 | `auto` 策略下配置了多个厂商时可见,用于单次切换厂商 | + + + 四家凭证均未配置时,该工具不会注册到 Agent。 + diff --git a/docs/zh/tools/write.mdx b/docs/zh/tools/write.mdx new file mode 100644 index 00000000..51cf66f1 --- /dev/null +++ b/docs/zh/tools/write.mdx @@ -0,0 +1,27 @@ +--- +title: write - 文件写入 +description: 创建或覆盖写入文件 +--- + +写入内容到文件。文件不存在则自动创建,已存在则覆盖。自动创建父目录。 + +## 依赖 + +无额外依赖,默认可用。 + +## 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `path` | string | 是 | 文件路径 | +| `content` | string | 是 | 要写入的内容 | + +## 使用场景 + +- 创建新的代码文件或脚本 +- 生成配置文件 +- 保存处理结果 + + + 单次写入不应超过 10KB。对于大文件,建议先创建骨架,再使用 edit 工具分块添加内容。 + From 9e6a2cc2c059df3c0dd90f3e91fd197361a0eba8 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 20:11:23 +0800 Subject: [PATCH 299/399] feat(installer): revamp install flow with i18n --- cli/commands/install.py | 4 +- cli/commands/process.py | 5 +- cli/commands/skill.py | 4 +- config-template.json | 2 +- run.sh | 851 +++++++++++++++++++++++++++++----------- scripts/run.ps1 | 644 ++++++++++++++++++++---------- 6 files changed, 1066 insertions(+), 444 deletions(-) diff --git a/cli/commands/install.py b/cli/commands/install.py index ed22f296..b72bcba1 100644 --- a/cli/commands/install.py +++ b/cli/commands/install.py @@ -118,9 +118,11 @@ def run_install_browser( 0 on success, 1 on fatal failure (pip or chromium install failed). """ from cli.utils import get_cli_language - from common import i18n + # Import `common` only after get_cli_language() runs ensure_sys_path(), + # so it works when `cow` is invoked from outside the project directory. get_cli_language() # resolve cow_lang so i18n.t reflects config + from common import i18n _t = i18n.t stream = stream or _default_stream diff --git a/cli/commands/process.py b/cli/commands/process.py index 9d22b67f..94e8126b 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -276,9 +276,12 @@ def status(): """Show CowAgent running status.""" from cli import __version__ from cli.utils import load_config_json, get_cli_language - from common import i18n + # get_cli_language() calls ensure_sys_path(), which adds the project root + # to sys.path. Import `common` only AFTER that, otherwise it fails with + # ModuleNotFoundError when `cow` runs from outside the project dir. get_cli_language() # resolve cow_lang so i18n.t reflects config + from common import i18n _t = i18n.t pid = _read_pid() diff --git a/cli/commands/skill.py b/cli/commands/skill.py index fa5a3167..61f92fb7 100644 --- a/cli/commands/skill.py +++ b/cli/commands/skill.py @@ -518,9 +518,11 @@ 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.""" from cli.utils import get_cli_language - from common import i18n + # Import `common` only after get_cli_language() runs ensure_sys_path(), + # so it works when `cow` is invoked from outside the project directory. get_cli_language() # resolve cow_lang so i18n.t reflects config + from common import i18n _t = i18n.t skills_dir = get_skills_dir() diff --git a/config-template.json b/config-template.json index dcd19774..8be82885 100644 --- a/config-template.json +++ b/config-template.json @@ -1,6 +1,6 @@ { "cow_lang": "auto", - "channel_type": "weixin", + "channel_type": "web", "model": "deepseek-v4-flash", "deepseek_api_key": "", "deepseek_api_base": "https://api.deepseek.com/v1", diff --git a/run.sh b/run.sh index 5dff9785..21bb5a9f 100755 --- a/run.sh +++ b/run.sh @@ -28,6 +28,261 @@ if [ -z "$BASH_VERSION" ]; then exit 1 fi +# ============================ +# i18n: install-flow language +# ============================ +# UI_LANG controls the language of install prompts/menus. Detected on first run +# (or chosen by the user), defaults to auto-detection. "zh" or "en". +UI_LANG="" + +# A terminal we can read from. When the script runs via `curl | bash`, stdin is +# the script pipe (EOF on read), so interactive prompts must read from the tty. +TTY_DEV="/dev/tty" +HAS_TTY=false +if [ -r /dev/tty ] && [ -w /dev/tty ]; then + HAS_TTY=true +fi + +# Detect default UI language from environment (best-effort, mirrors common/i18n). +detect_ui_lang() { + local loc="" + # macOS: prefer AppleLocale, which reflects the real UI language + if [ "$(uname)" = "Darwin" ] && command -v defaults &> /dev/null; then + loc=$(defaults read -g AppleLocale 2>/dev/null || true) + fi + [ -z "$loc" ] && loc="${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" + case "$loc" in + zh* | *zh_* | *_CN* | *_TW* | *_HK* | *Hans* | *Hant*) echo "zh" ;; + *) echo "en" ;; + esac +} + +# Translation helper: t +t() { + if [ "$UI_LANG" = "en" ]; then + printf '%s' "$2" + else + printf '%s' "$1" + fi +} + +# Read a line from the controlling terminal (works under `curl | bash`). +# Usage: tty_read VAR "prompt" +tty_read() { + local __var=$1 __prompt=$2 __input="" + if [ "$HAS_TTY" = true ]; then + # Ensure the tty is in normal line mode. A preceding arrow-key menu + # may have left it in cbreak/-echo mode; without this, `read` could + # return immediately or not echo typed characters. + stty sane < "$TTY_DEV" 2>/dev/null || true + # Print the prompt explicitly (not via read -p, whose prompt can be + # swallowed right after an arrow-key menu) and read from the tty. + # `|| true` so a non-zero read (EOF) does NOT trip `set -e`. + printf '%s' "$__prompt" > /dev/tty + read -r __input < "$TTY_DEV" || true + else + read -r -p "$__prompt" __input || true + fi + printf -v "$__var" '%s' "$__input" +} + +# Arrow-key selectable menu with number fallback. +# Usage: select_menu OUT_VAR "Title" "opt1" "opt2" ... +# Result: OUT_VAR is set to the selected index (1-based). +select_menu() { + # Interactive function: never let a non-zero command (read EOF, arithmetic + # evaluating to 0, etc.) abort the caller under `set -e`. + set +e + local __out=$1; shift + local title=$1; shift + local options=("$@") + local count=${#options[@]} + # Initial highlight: MENU_DEFAULT (1-based) if set, else first option. + local cur=0 + if [[ "${MENU_DEFAULT:-}" =~ ^[0-9]+$ ]] && (( MENU_DEFAULT >= 1 && MENU_DEFAULT <= count )); then + cur=$((MENU_DEFAULT - 1)) + fi + MENU_DEFAULT="" + + # Fallback to numbered input when no interactive terminal is available + # (e.g. CI, non-tty pipe). Arrow-key rendering needs a real tty. + if [ "$HAS_TTY" != true ] || [ ! -t 1 ]; then + local def=$((cur + 1)) + echo -e "${CYAN}${BOLD}${title}${NC}" + local i=1 + for opt in "${options[@]}"; do + echo -e " ${YELLOW}${i})${NC} ${opt}" + i=$((i + 1)) + done + local choice="" + while true; do + tty_read choice "$(t "请输入序号" "Enter number") [1-${count}, $(t "默认" "default") ${def}]: " + choice=${choice:-$def} + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then + break + fi + echo -e "${RED}$(t "无效选择,请输入" "Invalid choice, enter") 1-${count}${NC}" + done + printf -v "$__out" '%s' "$choice" + return + fi + + # Interactive arrow-key menu. + # Use literal escape characters (via $'...') and printf instead of + # `echo -e`, because `echo`'s backslash handling is not portable and + # leaks raw "\e[K" text on some shells/terminals. + local ESC=$'\033' + local UP="${ESC}[A" # move cursor up one line + local CLR="${ESC}[K" # clear to end of line + + # fd 3 is a long-lived (read) handle to the controlling terminal, opened + # once by menu_session_begin() before the install flow. Reusing one fd + # across all menus avoids the bash 3.2 bug where re-opening /dev/tty per + # menu makes the second menu read EOF and auto-select the default. + # Detect whether fd 3 is already open using a READ redirection (fd 3 is + # read-only; testing with `>&3` would wrongly report it as closed). + local _own_fd3=false + if ! { : <&3; } 2>/dev/null; then + exec 3<"$TTY_DEV" + _own_fd3=true + fi + + # Put the terminal into cbreak/raw input mode so single keystrokes arrive + # immediately and are not echoed. + # -echo : don't echo keystrokes (otherwise arrow keys leak as ^[[A) + # -icanon : disable line buffering + # min 1 time 0 : read returns as soon as 1 byte is available + local _restore="tput cnorm 2>/dev/null; stty echo icanon <${TTY_DEV} 2>/dev/null" + trap "$_restore" EXIT INT TERM + tput civis 2>/dev/null || true + stty -echo -icanon min 1 time 0 <&3 2>/dev/null || true + + printf '%b\n' "${CYAN}${BOLD}${title}${NC}" + printf '%b\n' "${CYAN}$(t "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select")${NC}" + + local first_draw=true + while true; do + # Move cursor up to the top of the option block to redraw it. + if [ "$first_draw" = false ]; then + local i=0 + while [ $i -lt $count ]; do + printf '%s' "$UP" + i=$((i + 1)) + done + fi + first_draw=false + + local idx=0 + for opt in "${options[@]}"; do + if [ $idx -eq $cur ]; then + printf '%s%b\n' "$CLR" " ${GREEN}${BOLD}❯ ${opt}${NC}" + else + printf '%s%b\n' "$CLR" " ${opt}" + fi + idx=$((idx + 1)) + done + + # Read one key from the shared terminal fd 3. + local key="" + IFS= read -rsn1 key <&3 + local rc=$? + if [ $rc -ne 0 ]; then + # No usable terminal: restore and fall back to numbered input. + eval "$_restore"; trap - EXIT INT TERM + [ "${_own_fd3:-}" = true ] && exec 3<&- 2>/dev/null + local choice="" + while true; do + tty_read choice "$(t "请输入序号" "Enter number") [1-${count}]: " + choice=${choice:-$((cur + 1))} + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then + break + fi + done + printf -v "$__out" '%s' "$choice" + return + fi + + # Empty key means Enter/Return (read -n1 strips the newline delimiter). + if [ -z "$key" ]; then + break + fi + + case "$key" in + "$ESC") + # Arrow key: ESC [ A/B (or ESC O A/B). Read the two trailing + # bytes one at a time, no timeout (bash 3.2 has no fractional + # read -t; in cbreak mode the bytes are already buffered). + local b2="" b3="" + IFS= read -rsn1 b2 <&3 2>/dev/null || b2="" + IFS= read -rsn1 b3 <&3 2>/dev/null || b3="" + case "${b2}${b3}" in + "[A" | "OA") cur=$(( (cur - 1 + count) % count )) ;; # up + "[B" | "OB") cur=$(( (cur + 1) % count )) ;; # down + esac + ;; + $'\n' | $'\r') + break + ;; + [0-9]) + if (( key >= 1 && key <= count )); then + cur=$((key - 1)) + break + fi + ;; + $'\003') + # Ctrl-C: restore and abort. + eval "$_restore"; trap - EXIT INT TERM + [ "${_own_fd3:-}" = true ] && exec 3<&- 2>/dev/null + printf '\n%b\n' "${RED}$(t "已取消安装" "Installation cancelled")${NC}" + exit 130 + ;; + esac + done + + eval "$_restore" + trap - EXIT INT TERM + [ "${_own_fd3:-}" = true ] && exec 3<&- 2>/dev/null + printf -v "$__out" '%s' "$((cur + 1))" +} + +# Open/close a long-lived terminal handle (fd 3) shared by all menus in an +# install/config session. Opening fd 3 once avoids per-menu re-open issues on +# bash 3.2 (second menu reading EOF). Safe no-ops when there is no tty. +menu_session_begin() { + [ "$HAS_TTY" = true ] || return 0 + exec 3<"$TTY_DEV" 2>/dev/null || true +} +menu_session_end() { + exec 3<&- 2>/dev/null || true +} + +# Ask the user to choose the install/UI language (first step of install). +select_language() { + # Order is fixed (English first, Chinese second). The default highlight + # follows detection, but conservatively: only a confident "zh" signal + # (macOS AppleLocale / Linux zh_* locale) preselects Chinese; everything + # else (English, empty/C/POSIX locale, server images) defaults to English. + local detected + detected=$(detect_ui_lang) + if [ "$detected" = "zh" ]; then + MENU_DEFAULT=2 + UI_LANG="zh" + else + MENU_DEFAULT=1 + UI_LANG="en" + fi + + local lang_choice + select_menu lang_choice "Select Language / 选择语言" "English" "中文 (Chinese)" + case "$lang_choice" in + 1) UI_LANG="en" ;; + 2) UI_LANG="zh" ;; + *) UI_LANG="en" ;; + esac + # Remember for the rest of the flow (config write happens later) + INSTALL_LANG="$UI_LANG" +} + # Cross-platform timeout: prefer GNU timeout/gtimeout, fallback to a pure-bash implementation # that uses background process + sleep to enforce a hard time limit. if command -v timeout &> /dev/null; then @@ -49,8 +304,20 @@ else } fi -# Get current script directory -export BASE_DIR=$(cd "$(dirname "$0")"; pwd) +# Get current script directory. +# When launched via process substitution (`bash <(curl ...)`) or a pipe, +# $0 points at /dev/fd/* or "bash", so dirname is meaningless. Fall back to +# the current working directory in that case (remote install will cd into +# the cloned project dir and reset BASE_DIR afterwards). +_script_src="$0" +case "$_script_src" in + /dev/fd/* | /proc/self/fd/* | bash | sh | -* | "") + export BASE_DIR="$(pwd)" + ;; + *) + export BASE_DIR=$(cd "$(dirname "$_script_src")" 2>/dev/null && pwd || pwd) + ;; +esac # Detect if in project directory IS_PROJECT_DIR=false @@ -151,28 +418,11 @@ clone_project() { echo -e "${GREEN}🔍 Cloning CowAgent project...${NC}" if [ -d "CowAgent" ]; then - echo -e "${YELLOW}⚠️ Directory 'CowAgent' already exists.${NC}" - read -p "Choose action: overwrite(o), backup(b), or quit(q)? [press Enter for default: b]: " choice - choice=${choice:-b} - case "$choice" in - o|O) - echo -e "${YELLOW}🗑️ Overwriting 'CowAgent' directory...${NC}" - rm -rf CowAgent - ;; - b|B) - backup_dir="CowAgent_backup_$(date +%s)" - echo -e "${YELLOW}🔀 Backing up to '$backup_dir'...${NC}" - mv CowAgent "$backup_dir" - ;; - q|Q) - echo -e "${RED}❌ Installation cancelled.${NC}" - exit 1 - ;; - *) - echo -e "${RED}❌ Invalid choice. Exiting.${NC}" - exit 1 - ;; - esac + # An existing directory is automatically backed up (no prompt) so the + # installer stays one-shot / hands-off. + local backup_dir="CowAgent_backup_$(date +%s)" + echo -e "${YELLOW}⚠️ $(t "目录 'CowAgent' 已存在,自动备份到" "Directory 'CowAgent' exists, backing up to") '$backup_dir'...${NC}" + mv CowAgent "$backup_dir" fi check_and_install_tool git @@ -188,9 +438,25 @@ clone_project() { echo -e "${RED}❌ Cannot download project. Please install Git, wget, or curl.${NC}" exit 1 fi - unzip CowAgent.zip - mv CowAgent-master CowAgent - rm CowAgent.zip + # Unzip: prefer `unzip`, otherwise fall back to Python's zipfile (no + # extra dependency) so minimal environments without unzip still work. + if command -v unzip &> /dev/null; then + unzip CowAgent.zip + elif command -v python3 &> /dev/null; then + python3 -m zipfile -e CowAgent.zip . + elif command -v python &> /dev/null; then + python -m zipfile -e CowAgent.zip . + else + echo -e "${RED}❌ Cannot extract archive. Please install 'unzip' or Python.${NC}" + exit 1 + fi + # Archive top-level dir name may vary (CowAgent-master, etc.); detect it. + local _extracted="CowAgent-master" + if [ ! -d "$_extracted" ]; then + _extracted=$(ls -d CowAgent-*/ 2>/dev/null | head -1 | sed 's:/*$::') + fi + [ -n "$_extracted" ] && [ -d "$_extracted" ] && mv "$_extracted" CowAgent + rm -f CowAgent.zip else local clone_ok=false # Detect and temporarily disable invalid git proxy settings @@ -240,15 +506,37 @@ clone_project() { # Install dependencies install_dependencies() { echo -e "${GREEN}📦 Installing dependencies...${NC}" + # Pick the pip index by install language, then fall back to the other if the + # preferred one is unreachable: + # - zh users: Tsinghua mirror first (fast in China), official PyPI fallback + # - others : official PyPI first, Tsinghua mirror fallback 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" + local _tuna="https://pypi.tuna.tsinghua.edu.cn/simple" + local _pypi="https://pypi.org/simple" + if [ "$UI_LANG" = "zh" ]; then + # Prefer Tsinghua; if it's down, fall back to official PyPI (pip default). + if curl -s --connect-timeout 5 "${_tuna}/" > /dev/null 2>&1; then + PIP_MIRROR="-i $_tuna" + fi + else + # Prefer official PyPI; only use Tsinghua if PyPI is unreachable. + if ! curl -s --connect-timeout 5 "${_pypi}/" > /dev/null 2>&1 \ + && curl -s --connect-timeout 5 "${_tuna}/" > /dev/null 2>&1; then + PIP_MIRROR="-i $_tuna" + fi + fi + if [ -n "$PIP_MIRROR" ]; then + echo -e "${YELLOW}Using pip mirror: ${_tuna}${NC}" fi + # Only pass --break-system-packages if this pip actually supports it + # (pip >= 23.x). Older pip versions error out with "no such option", + # which previously dumped a confusing usage message and failed the install. PIP_EXTRA_ARGS="" - if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then + if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null \ + && $PYTHON_CMD -m pip install --help 2>/dev/null | grep -q -- "--break-system-packages"; then PIP_EXTRA_ARGS="--break-system-packages" - echo -e "${YELLOW}Python 3.11+ detected, using --break-system-packages for pip installations${NC}" + echo -e "${YELLOW}Python 3.11+ with break-system-packages support detected${NC}" fi echo -e "${YELLOW}Upgrading pip and basic tools...${NC}" @@ -306,199 +594,221 @@ install_dependencies() { # Select model select_model() { echo "" - echo -e "${CYAN}${BOLD}=========================================${NC}" - echo -e "${CYAN}${BOLD} Select AI Model${NC}" - echo -e "${CYAN}${BOLD}=========================================${NC}" - echo -e "${YELLOW}1) DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)${NC}" - echo -e "${YELLOW}2) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)${NC}" - echo -e "${YELLOW}3) Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)${NC}" - echo -e "${YELLOW}4) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)${NC}" - echo -e "${YELLOW}5) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)${NC}" - echo -e "${YELLOW}6) Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)${NC}" - echo -e "${YELLOW}7) Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)${NC}" - echo -e "${YELLOW}8) Doubao (doubao-seed-2-0-code-preview-260215, etc.)${NC}" - echo -e "${YELLOW}9) Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)${NC}" - echo -e "${YELLOW}10) LinkAI (access multiple models via one API)${NC}" - echo "" - - while true; do - read -p "Enter your choice [press Enter for default: 1 - DeepSeek]: " model_choice - model_choice=${model_choice:-1} - case "$model_choice" in - 1|2|3|4|5|6|7|8|9|10) - break - ;; - *) - echo -e "${RED}Invalid choice. Please enter 1-10.${NC}" - ;; - esac - done + local title sel + title="$(t "选择 AI 模型" "Select AI Model")" + # The 11th option is "skip" -> configure later in the web console. + select_menu sel "$title" \ + "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \ + "Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)" \ + "Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" \ + "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" \ + "MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)" \ + "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)" \ + "Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" \ + "Doubao (doubao-seed-2-0-code-preview-260215, etc.)" \ + "Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)" \ + "LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \ + "$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")" + model_choice="$sel" } # Read model config: provider, default_model, key_variable_name read_model_config() { local provider=$1 default_model=$2 key_var=$3 - echo -e "${GREEN}Configuring ${provider}...${NC}" - read -p "Enter ${provider} API Key: " _api_key - read -p "Enter model name [press Enter for default: ${default_model}]: " model_name - model_name=${model_name:-$default_model} - MODEL_NAME="$model_name" - eval "${key_var}=\"\$_api_key\"" + echo -e "${GREEN}$(t "正在配置" "Configuring") ${provider}...${NC}" + # Only ask for the API key here; the model name and API base default to + # sensible values and can be changed later in the web console. + local _api_key + tty_read _api_key "$(t "请输入" "Enter") ${provider} API Key ($(t "回车跳过,稍后在 Web 控制台填写" "press Enter to skip, set later in web console")): " + MODEL_NAME="$default_model" + # printf -v (not eval) so keys containing quotes/backticks/$() are safe. + printf -v "${key_var}" '%s' "$_api_key" } -# Read optional API base URL -read_api_base() { - local base_var=$1 default_url=$2 - read -p "Enter API Base URL [press Enter for default: ${default_url}]: " api_base - api_base=${api_base:-$default_url} - eval "${base_var}=\"\$api_base\"" -} - -# Configure model +# Configure model. The "skip" choice leaves the model empty so the user can +# finish configuration in the web console after first start. configure_model() { case "$model_choice" in 1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;; - 2) read_model_config "MiniMax" "MiniMax-M2.7" "MINIMAX_KEY" ;; - 3) - read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" - read_api_base "CLAUDE_BASE" "https://api.anthropic.com/v1" - ;; - 4) - read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" - read_api_base "GEMINI_BASE" "https://generativelanguage.googleapis.com" - ;; - 5) - read_model_config "OpenAI GPT" "gpt-5.4" "OPENAI_KEY" - read_api_base "OPENAI_BASE" "https://api.openai.com/v1" - ;; + 2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;; + 3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;; + 4) read_model_config "OpenAI GPT" "gpt-5.4" "OPENAI_KEY" ;; + 5) read_model_config "MiniMax" "MiniMax-M2.7" "MINIMAX_KEY" ;; 6) read_model_config "Zhipu AI" "glm-5.1" "ZHIPU_KEY" ;; 7) read_model_config "Qwen (DashScope)" "qwen3.6-plus" "DASHSCOPE_KEY" ;; 8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;; 9) read_model_config "Kimi (Moonshot)" "kimi-k2.6" "MOONSHOT_KEY" ;; 10) + # Show where to obtain a LinkAI key (zh users -> console page). + echo -e "${CYAN}$(t "获取 LinkAI Key" "Get your LinkAI Key"): https://link-ai.tech/console/interface${NC}" read_model_config "LinkAI" "deepseek-v4-flash" "LINKAI_KEY" USE_LINKAI="true" ;; + 11) + # Skip: leave model unset, will be configured in web console + MODEL_SKIPPED="true" + MODEL_NAME="" + echo -e "${YELLOW}$(t "已跳过模型配置,稍后可在 Web 控制台填写" "Model configuration skipped, you can set it later in the web console")${NC}" + ;; esac } -# Select channel -select_channel() { - echo "" - echo -e "${CYAN}${BOLD}=========================================${NC}" - echo -e "${CYAN}${BOLD} Select Communication Channel${NC}" - echo -e "${CYAN}${BOLD}=========================================${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 - Weixin]: " channel_choice - channel_choice=${channel_choice:-1} - case "$channel_choice" in - 1|2|3|4|5|6|7) - break - ;; - *) - echo -e "${RED}Invalid choice. Please enter 1-7.${NC}" - ;; - esac - done +# Channel label by stable key (independent of menu order). +channel_label() { + case "$1" in + web) t "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)" ;; + weixin) t "微信" "WeChat (Weixin)" ;; + feishu) t "飞书" "Feishu / Lark" ;; + dingtalk) t "钉钉" "DingTalk" ;; + wecom_bot) t "企微智能机器人" "WeCom Bot" ;; + qq) printf '%s' "QQ" ;; + wechatcom_app) t "企微自建应用" "WeCom App" ;; + telegram) printf '%s' "Telegram" ;; + slack) printf '%s' "Slack" ;; + discord) printf '%s' "Discord" ;; + skip) t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)" ;; + esac } -# Configure channel +# Select channel. The display order depends on the install language: +# - English: Web first, then the global IM channels (Telegram/Discord/Slack), +# then the China-focused channels. +# - Chinese: Web first, then China-focused channels, then global ones. +# A stable key list (CHANNEL_KEYS) decouples the menu order from the config +# logic, so reordering the menu never breaks configure_channel(). +select_channel() { + echo "" + local title sel + title="$(t "选择接入渠道" "Select Communication Channel")" + if [ "$UI_LANG" = "en" ]; then + CHANNEL_KEYS=(web telegram discord slack weixin feishu dingtalk wecom_bot qq wechatcom_app skip) + else + CHANNEL_KEYS=(web weixin feishu dingtalk wecom_bot qq wechatcom_app telegram slack discord skip) + fi + local labels=() k + for k in "${CHANNEL_KEYS[@]}"; do + labels+=("$(channel_label "$k")") + done + select_menu sel "$title" "${labels[@]}" + # Map the 1-based menu position back to the stable channel key. + channel_choice="${CHANNEL_KEYS[$((sel - 1))]}" +} + +# Configure channel, dispatched by stable channel key (not menu position). configure_channel() { case "$channel_choice" in - 1) + web|skip) + # Web (also the default when skipped). Use the default port with + # no prompt; it can be changed later in the web console / config. + CHANNEL_TYPE="web" + WEB_PORT="9899" + ACCESS_INFO="$(t "Web 控制台地址" "Web console") : http://localhost:9899/chat" + ;; + weixin) # Weixin CHANNEL_TYPE="weixin" - ACCESS_INFO="Weixin channel configured. Scan QR code in terminal or web console to login." + ACCESS_INFO="$(t "微信渠道已配置,请在终端或 Web 控制台扫码登录" "Weixin channel configured. Scan QR code in terminal or web console to login.")" ;; - 2) + feishu) # Feishu (WebSocket mode) CHANNEL_TYPE="feishu" - echo -e "${GREEN}Configure Feishu (WebSocket mode)...${NC}" - read -p "Enter Feishu App ID: " fs_app_id - read -p "Enter Feishu App Secret: " fs_app_secret - + echo -e "${GREEN}$(t "配置飞书(WebSocket 模式)" "Configure Feishu (WebSocket mode)")...${NC}" + local fs_app_id fs_app_secret + tty_read fs_app_id "$(t "请输入飞书 App ID" "Enter Feishu App ID"): " + tty_read fs_app_secret "$(t "请输入飞书 App Secret" "Enter Feishu App Secret"): " FEISHU_APP_ID="$fs_app_id" FEISHU_APP_SECRET="$fs_app_secret" FEISHU_EVENT_MODE="websocket" - ACCESS_INFO="Feishu channel configured (WebSocket mode)" + ACCESS_INFO="$(t "飞书渠道已配置(WebSocket 模式)" "Feishu channel configured (WebSocket mode)")" ;; - 3) + dingtalk) # DingTalk CHANNEL_TYPE="dingtalk" - echo -e "${GREEN}Configure DingTalk...${NC}" - read -p "Enter DingTalk Client ID: " dt_client_id - read -p "Enter DingTalk Client Secret: " dt_client_secret - + echo -e "${GREEN}$(t "配置钉钉" "Configure DingTalk")...${NC}" + local dt_client_id dt_client_secret + tty_read dt_client_id "$(t "请输入钉钉 Client ID" "Enter DingTalk Client ID"): " + tty_read dt_client_secret "$(t "请输入钉钉 Client Secret" "Enter DingTalk Client Secret"): " DT_CLIENT_ID="$dt_client_id" DT_CLIENT_SECRET="$dt_client_secret" - ACCESS_INFO="DingTalk channel configured" + ACCESS_INFO="$(t "钉钉渠道已配置" "DingTalk channel configured")" ;; - 4) + wecom_bot) # WeCom Bot CHANNEL_TYPE="wecom_bot" - echo -e "${GREEN}Configure WeCom Bot...${NC}" - read -p "Enter WeCom Bot ID: " wecom_bot_id - read -p "Enter WeCom Bot Secret: " wecom_bot_secret - + echo -e "${GREEN}$(t "配置企微智能机器人" "Configure WeCom Bot")...${NC}" + local wecom_bot_id wecom_bot_secret + tty_read wecom_bot_id "$(t "请输入 WeCom Bot ID" "Enter WeCom Bot ID"): " + tty_read wecom_bot_secret "$(t "请输入 WeCom Bot Secret" "Enter WeCom Bot Secret"): " WECOM_BOT_ID="$wecom_bot_id" WECOM_BOT_SECRET="$wecom_bot_secret" - ACCESS_INFO="WeCom Bot channel configured" + ACCESS_INFO="$(t "企微智能机器人渠道已配置" "WeCom Bot channel configured")" ;; - 5) + qq) # QQ CHANNEL_TYPE="qq" - echo -e "${GREEN}Configure QQ Bot...${NC}" - read -p "Enter QQ App ID: " qq_app_id - read -p "Enter QQ App Secret: " qq_app_secret - + echo -e "${GREEN}$(t "配置 QQ 机器人" "Configure QQ Bot")...${NC}" + local qq_app_id qq_app_secret + tty_read qq_app_id "$(t "请输入 QQ App ID" "Enter QQ App ID"): " + tty_read qq_app_secret "$(t "请输入 QQ App Secret" "Enter QQ App Secret"): " QQ_APP_ID="$qq_app_id" QQ_APP_SECRET="$qq_app_secret" - ACCESS_INFO="QQ Bot channel configured" + ACCESS_INFO="$(t "QQ 机器人渠道已配置" "QQ Bot channel configured")" ;; - 6) + wechatcom_app) # WeCom App CHANNEL_TYPE="wechatcom_app" - echo -e "${GREEN}Configure WeCom App...${NC}" - read -p "Enter WeChat Corp ID: " corp_id - read -p "Enter WeChat Com App Token: " com_token - read -p "Enter WeChat Com App Secret: " com_secret - read -p "Enter WeChat Com App Agent ID: " com_agent_id - read -p "Enter WeChat Com App AES Key: " com_aes_key - read -p "Enter WeChat Com App Port [press Enter for default: 9898]: " com_port + echo -e "${GREEN}$(t "配置企微自建应用" "Configure WeCom App")...${NC}" + local corp_id com_token com_secret com_agent_id com_aes_key com_port + tty_read corp_id "$(t "请输入企业 Corp ID" "Enter WeChat Corp ID"): " + tty_read com_token "$(t "请输入应用 Token" "Enter WeChat Com App Token"): " + tty_read com_secret "$(t "请输入应用 Secret" "Enter WeChat Com App Secret"): " + tty_read com_agent_id "$(t "请输入应用 Agent ID" "Enter WeChat Com App Agent ID"): " + tty_read com_aes_key "$(t "请输入应用 AES Key" "Enter WeChat Com App AES Key"): " + tty_read com_port "$(t "请输入应用端口" "Enter WeChat Com App Port") [$(t "默认" "default"): 9898]: " com_port=${com_port:-9898} - WECHATCOM_CORP_ID="$corp_id" WECHATCOM_TOKEN="$com_token" WECHATCOM_SECRET="$com_secret" WECHATCOM_AGENT_ID="$com_agent_id" WECHATCOM_AES_KEY="$com_aes_key" WECHATCOM_PORT="$com_port" - ACCESS_INFO="WeCom App channel configured on port ${com_port}" + ACCESS_INFO="$(t "企微自建应用渠道已配置,端口" "WeCom App channel configured on port") ${com_port}" ;; - 7) - # Web - CHANNEL_TYPE="web" - read -p "Enter web port [press Enter for default: 9899]: " web_port - web_port=${web_port:-9899} - - WEB_PORT="$web_port" - ACCESS_INFO="Web interface will be available at: http://localhost:${web_port}/chat" + telegram) + # Telegram + CHANNEL_TYPE="telegram" + echo -e "${GREEN}$(t "配置 Telegram" "Configure Telegram")...${NC}" + local tg_token + tty_read tg_token "$(t "请输入 Telegram Bot Token" "Enter Telegram Bot Token"): " + TELEGRAM_TOKEN="$tg_token" + ACCESS_INFO="$(t "Telegram 渠道已配置" "Telegram channel configured")" + ;; + slack) + # Slack + CHANNEL_TYPE="slack" + echo -e "${GREEN}$(t "配置 Slack" "Configure Slack")...${NC}" + local slack_bot slack_app + tty_read slack_bot "$(t "请输入 Slack Bot Token" "Enter Slack Bot Token") (xoxb-...): " + tty_read slack_app "$(t "请输入 Slack App Token" "Enter Slack App Token") (xapp-...): " + SLACK_BOT_TOKEN="$slack_bot" + SLACK_APP_TOKEN="$slack_app" + ACCESS_INFO="$(t "Slack 渠道已配置" "Slack channel configured")" + ;; + discord) + # Discord + CHANNEL_TYPE="discord" + echo -e "${GREEN}$(t "配置 Discord" "Configure Discord")...${NC}" + local discord_token + tty_read discord_token "$(t "请输入 Discord Bot Token" "Enter Discord Bot Token"): " + DISCORD_TOKEN="$discord_token" + ACCESS_INFO="$(t "Discord 渠道已配置" "Discord channel configured")" ;; esac } # Generate config file create_config_file() { - echo -e "${GREEN}📝 Generating config.json...${NC}" + echo -e "${GREEN}📝 $(t "正在生成 config.json" "Generating config.json")...${NC}" CHANNEL_TYPE="$CHANNEL_TYPE" \ MODEL_NAME="$MODEL_NAME" \ @@ -532,12 +842,18 @@ create_config_file() { WECHATCOM_AGENT_ID="${WECHATCOM_AGENT_ID:-}" \ WECHATCOM_AES_KEY="${WECHATCOM_AES_KEY:-}" \ WECHATCOM_PORT="${WECHATCOM_PORT:-}" \ + TELEGRAM_TOKEN="${TELEGRAM_TOKEN:-}" \ + SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN:-}" \ + SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-}" \ + DISCORD_TOKEN="${DISCORD_TOKEN:-}" \ + COW_LANG="${INSTALL_LANG:-auto}" \ $PYTHON_CMD -c " import json, os e = os.environ.get base = { - 'channel_type': e('CHANNEL_TYPE'), - 'model': e('MODEL_NAME'), + 'channel_type': e('CHANNEL_TYPE') or 'web', + 'model': e('MODEL_NAME') or '', + 'cow_lang': e('COW_LANG', 'auto'), 'open_ai_api_key': e('OPENAI_KEY', ''), 'open_ai_api_base': e('OPENAI_BASE'), 'claude_api_key': e('CLAUDE_KEY', ''), @@ -571,19 +887,28 @@ channel_map = { 'wecom_bot': {'wecom_bot_id': 'WECOM_BOT_ID', 'wecom_bot_secret': 'WECOM_BOT_SECRET'}, 'qq': {'qq_app_id': 'QQ_APP_ID', 'qq_app_secret': 'QQ_APP_SECRET'}, 'wechatcom_app': {'wechatcom_corp_id': 'WECHATCOM_CORP_ID', 'wechatcomapp_token': 'WECHATCOM_TOKEN', 'wechatcomapp_secret': 'WECHATCOM_SECRET', 'wechatcomapp_agent_id': 'WECHATCOM_AGENT_ID', 'wechatcomapp_aes_key': 'WECHATCOM_AES_KEY', 'wechatcomapp_port': ('WECHATCOM_PORT', int)}, + 'telegram': {'telegram_token': 'TELEGRAM_TOKEN'}, + 'slack': {'slack_bot_token': 'SLACK_BOT_TOKEN', 'slack_app_token': 'SLACK_APP_TOKEN'}, + 'discord': {'discord_token': 'DISCORD_TOKEN'}, } -ch = e('CHANNEL_TYPE') +def _to_int(val, default): + try: + return int(val) + except (TypeError, ValueError): + return default +ch = e('CHANNEL_TYPE') or 'web' for key, spec in channel_map.get(ch, {}).items(): if isinstance(spec, tuple): env_name, conv = spec - base[key] = conv(e(env_name)) + # Guard int() against non-numeric input; fall back to a sane port. + base[key] = _to_int(e(env_name), 9899 if key == 'web_port' else 9898) if conv is int else conv(e(env_name)) else: base[key] = e(spec, '') with open('config.json', 'w') as f: json.dump(base, f, indent=2, ensure_ascii=False) " - echo -e "${GREEN}✅ Configuration file created successfully.${NC}" + echo -e "${GREEN}✅ $(t "配置文件创建成功" "Configuration file created successfully").${NC}" } # Start project @@ -622,29 +947,37 @@ start_project() { sleep 2 echo "" echo -e "${CYAN}${BOLD}=========================================${NC}" - echo -e "${GREEN}${EMOJI_CHECK} CowAgent is now running in background!${NC}" - echo -e "${GREEN}${EMOJI_CHECK} Process will continue after closing terminal.${NC}" + echo -e "${GREEN}${EMOJI_CHECK} $(t "CowAgent 已在后台运行" "CowAgent is now running in background")!${NC}" + echo -e "${GREEN}${EMOJI_CHECK} $(t "关闭终端后进程仍会继续运行" "Process will continue after closing terminal").${NC}" echo -e "${CYAN}$ACCESS_INFO${NC}" + + # If the model was skipped, guide the user to finish setup in the web console. + if [ "${MODEL_SKIPPED:-}" = "true" ]; then + local _port="${WEB_PORT:-9899}" + echo "" + echo -e "${YELLOW}${EMOJI_WARN} $(t "尚未配置模型,请在 Web 控制台完成配置" "Model not configured yet, please finish setup in the web console"):${NC}" + echo -e "${CYAN} http://localhost:${_port}/chat${NC}" + fi echo "" - echo -e "${CYAN}${BOLD}Management Commands:${NC}" + echo -e "${CYAN}${BOLD}$(t "管理命令" "Management Commands"):${NC}" 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" - echo -e " ${GREEN}cow update${NC} Update and restart" - echo -e " ${GREEN}cow install-browser${NC} Install browser tool" + echo -e " ${GREEN}cow stop${NC} $(t "停止服务" "Stop the service")" + echo -e " ${GREEN}cow restart${NC} $(t "重启服务" "Restart the service")" + echo -e " ${GREEN}cow status${NC} $(t "查看状态" "Check status")" + echo -e " ${GREEN}cow logs${NC} $(t "查看日志" "View logs")" + echo -e " ${GREEN}cow update${NC} $(t "更新并重启" "Update and restart")" + echo -e " ${GREEN}cow install-browser${NC} $(t "安装浏览器工具" "Install browser tool")" 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" - echo -e " ${GREEN}./run.sh update${NC} Update and restart" + echo -e " ${GREEN}./run.sh stop${NC} $(t "停止服务" "Stop the service")" + echo -e " ${GREEN}./run.sh restart${NC} $(t "重启服务" "Restart the service")" + echo -e " ${GREEN}./run.sh status${NC} $(t "查看状态" "Check status")" + echo -e " ${GREEN}./run.sh logs${NC} $(t "查看日志" "View logs")" + echo -e " ${GREEN}./run.sh update${NC} $(t "更新并重启" "Update and restart")" fi echo -e "${CYAN}${BOLD}=========================================${NC}" echo "" - echo -e "${YELLOW}Showing recent logs (Ctrl+C to exit, agent keeps running):${NC}" + echo -e "${YELLOW}$(t "显示最近日志(Ctrl+C 退出,Agent 继续运行)" "Showing recent logs (Ctrl+C to exit, agent keeps running)"):${NC}" sleep 2 tail -n 30 -f "${BASE_DIR}/nohup.out" } @@ -655,20 +988,20 @@ show_usage() { echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Management Script${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}" echo "" - echo -e "${YELLOW}Usage:${NC}" - echo -e " ${GREEN}./run.sh${NC} ${CYAN}# Install/Configure project${NC}" - echo -e " ${GREEN}./run.sh ${NC} ${CYAN}# Execute management command${NC}" + echo -e "${YELLOW}$(t "用法" "Usage"):${NC}" + echo -e " ${GREEN}./run.sh${NC} ${CYAN}# $(t "安装/配置项目" "Install/Configure project")${NC}" + echo -e " ${GREEN}./run.sh ${NC} ${CYAN}# $(t "执行管理命令" "Execute management command")${NC}" echo "" - echo -e "${YELLOW}Commands:${NC}" - echo -e " ${GREEN}start${NC} Start the service" - echo -e " ${GREEN}stop${NC} Stop the service" - echo -e " ${GREEN}restart${NC} Restart the service" - echo -e " ${GREEN}status${NC} Check service status" - echo -e " ${GREEN}logs${NC} View logs (tail -f)" - echo -e " ${GREEN}config${NC} Reconfigure project" - echo -e " ${GREEN}update${NC} Update and restart" + echo -e "${YELLOW}$(t "命令" "Commands"):${NC}" + echo -e " ${GREEN}start${NC} $(t "启动服务" "Start the service")" + echo -e " ${GREEN}stop${NC} $(t "停止服务" "Stop the service")" + echo -e " ${GREEN}restart${NC} $(t "重启服务" "Restart the service")" + echo -e " ${GREEN}status${NC} $(t "查看服务状态" "Check service status")" + echo -e " ${GREEN}logs${NC} $(t "查看日志 (tail -f)" "View logs (tail -f)")" + echo -e " ${GREEN}config${NC} $(t "重新配置项目" "Reconfigure project")" + echo -e " ${GREEN}update${NC} $(t "更新并重启" "Update and restart")" echo "" - echo -e "${YELLOW}Examples:${NC}" + echo -e "${YELLOW}$(t "示例" "Examples"):${NC}" echo -e " ${GREEN}./run.sh start${NC}" echo -e " ${GREEN}./run.sh logs${NC}" echo -e " ${GREEN}./run.sh status${NC}" @@ -701,8 +1034,8 @@ has_cow() { # Start service cmd_start() { 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}" + echo -e "${RED}${EMOJI_CROSS} $(t "未找到 config.json" "config.json not found")${NC}" + echo -e "${YELLOW}$(t "请先运行 './run.sh' 进行配置" "Please run './run.sh' to configure first")${NC}" exit 1 fi @@ -711,8 +1044,8 @@ cmd_start() { 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}" + echo -e "${YELLOW}${EMOJI_WARN} $(t "CowAgent 已在运行中" "CowAgent is already running") (PID: $(get_pid))${NC}" + echo -e "${YELLOW}$(t "使用 './run.sh restart' 重启" "Use './run.sh restart' to restart")${NC}" return fi check_python_version @@ -722,34 +1055,37 @@ cmd_start() { # Stop service cmd_stop() { + # Don't let kill/return non-zero (e.g. process already gone) abort the + # caller (cmd_restart) under `set -e`. + set +e if has_cow; then cd "${BASE_DIR}" cow stop else - echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}" + echo -e "${GREEN}${EMOJI_STOP} $(t "正在停止 CowAgent" "Stopping CowAgent")...${NC}" if ! is_running; then - echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}" - return + echo -e "${YELLOW}${EMOJI_WARN} $(t "CowAgent 未在运行" "CowAgent is not running")${NC}" + return 0 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 + echo -e "${RED}❌ $(t "获取有效 PID 失败" "Failed to get valid PID") (${pid})${NC}" + return 0 fi - echo -e "${GREEN}Found running process (PID: ${pid})${NC}" + echo -e "${GREEN}$(t "找到运行中的进程" "Found running process") (PID: ${pid})${NC}" - kill ${pid} + kill ${pid} 2>/dev/null || true sleep 3 if ps -p ${pid} > /dev/null 2>&1; then - echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}" - kill -9 ${pid} + echo -e "${YELLOW}⚠️ $(t "进程未停止,强制终止" "Process not stopped, forcing termination")...${NC}" + kill -9 ${pid} 2>/dev/null || true fi - echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}" + echo -e "${GREEN}${EMOJI_CHECK} $(t "CowAgent 已停止" "CowAgent stopped")${NC}" fi } @@ -777,20 +1113,21 @@ cmd_status() { if is_running; then pid=$(get_pid) - echo -e "${GREEN}Status:${NC} ✅ Running" + echo -e "${GREEN}$(t "状态" "Status"):${NC} ✅ $(t "运行中" "Running")" echo -e "${GREEN}PID:${NC} ${pid}" if [ -f "${BASE_DIR}/nohup.out" ]; then - echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out" + echo -e "${GREEN}$(t "日志" "Logs"):${NC} ${BASE_DIR}/nohup.out" fi else - echo -e "${YELLOW}Status:${NC} ⭐ Stopped" + echo -e "${YELLOW}$(t "状态" "Status"):${NC} ⭐ $(t "已停止" "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}" + # `|| true`: grep returns 1 when the key is absent (set -e safe). + model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" 2>/dev/null | cut -d'"' -f4 || true) + channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" 2>/dev/null | cut -d'"' -f4 || true) + echo -e "${GREEN}$(t "模型" "Model"):${NC} ${model:-$(t "(未配置)" "(not set)")}" + echo -e "${GREEN}$(t "渠道" "Channel"):${NC} ${channel:-$(t "(未配置)" "(not set)")}" fi echo -e "${CYAN}${BOLD}=========================================${NC}" @@ -804,22 +1141,30 @@ cmd_logs() { cow logs -f else if [ -f "${BASE_DIR}/nohup.out" ]; then - echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}" + echo -e "${YELLOW}$(t "查看日志(Ctrl+C 退出)" "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}" + echo -e "${RED}❌ $(t "日志文件未找到" "Log file not found"): ${BASE_DIR}/nohup.out${NC}" fi fi } # Reconfigure cmd_config() { - echo -e "${YELLOW}${EMOJI_WRENCH} Reconfiguring CowAgent...${NC}" + # Interactive flow: disable `set -e` (see install_mode for rationale). + set +e + # One shared terminal handle for all menus in this session. + menu_session_begin + + # Choose language first so the rest of the flow is localized. + select_language + echo "" + echo -e "${YELLOW}${EMOJI_WRENCH} $(t "正在重新配置 CowAgent" "Reconfiguring CowAgent")...${NC}" if [ -f "${BASE_DIR}/config.json" ]; then backup_file="${BASE_DIR}/config.json.backup.$(date +%s)" cp "${BASE_DIR}/config.json" "${backup_file}" - echo -e "${GREEN}✅ Backed up config to: ${backup_file}${NC}" + echo -e "${GREEN}✅ $(t "已备份配置到" "Backed up config to"): ${backup_file}${NC}" fi check_python_version @@ -828,10 +1173,12 @@ cmd_config() { configure_model select_channel configure_channel + menu_session_end create_config_file echo "" - read -p "Restart service now? [Y/n]: " restart_now + local restart_now + tty_read restart_now "$(t "现在重启服务" "Restart service now")? [Y/n]: " if [[ ! $restart_now == [Nn]* ]]; then cmd_restart fi @@ -839,27 +1186,27 @@ cmd_config() { # Update project cmd_update() { - echo -e "${GREEN}${EMOJI_WRENCH} Updating CowAgent...${NC}" + echo -e "${GREEN}${EMOJI_WRENCH} $(t "正在更新 CowAgent" "Updating CowAgent")...${NC}" cd "${BASE_DIR}" # Pull latest code first (service still running) local pull_ok=false if [ -d .git ]; then - echo -e "${GREEN}🔄 Pulling latest code...${NC}" + echo -e "${GREEN}🔄 $(t "正在拉取最新代码" "Pulling latest code")...${NC}" if git pull; then pull_ok=true else - echo -e "${YELLOW}⚠️ git pull failed, trying Gitee mirror...${NC}" + echo -e "${YELLOW}⚠️ $(t "git pull 失败,尝试 Gitee 镜像" "git pull failed, trying Gitee mirror")...${NC}" git remote set-url origin https://gitee.com/zhayujie/CowAgent.git if git pull; then pull_ok=true else - echo -e "${RED}❌ Failed to pull code. Update aborted.${NC}" + echo -e "${RED}❌ $(t "拉取代码失败,更新已中止" "Failed to pull code. Update aborted").${NC}" exit 1 fi fi else - echo -e "${YELLOW}⚠️ Not a git repository, skipping code update${NC}" + echo -e "${YELLOW}⚠️ $(t "非 git 仓库,跳过代码更新" "Not a git repository, skipping code update")${NC}" fi # Re-exec with the updated run.sh to pick up new logic @@ -885,24 +1232,38 @@ cmd_post_update() { # Installation mode install_mode() { + # Interactive flow: disable `set -e` so a single non-zero command (e.g. an + # arithmetic `(( ))` evaluating to 0, a `read` hitting EOF, or an optional + # step failing) does not silently abort the whole installer. + set +e clear echo -e "${CYAN}${BOLD}=========================================${NC}" echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Installation${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}" echo "" + + # Open one shared terminal handle for ALL menus in this session (language, + # model, channel). One long-lived fd 3 avoids per-menu re-open issues on + # bash 3.2. Closed on early return and before config generation. + menu_session_begin + + # Step 0: choose the install/UI language. Everything after this is localized. + select_language + echo "" sleep 1 if [ "$IS_PROJECT_DIR" = true ]; then - echo -e "${GREEN}✅ Detected existing project directory.${NC}" + echo -e "${GREEN}✅ $(t "检测到已有项目目录" "Detected existing project directory").${NC}" if [ -f "${BASE_DIR}/config.json" ]; then - echo -e "${GREEN}✅ Project already configured${NC}" + menu_session_end + echo -e "${GREEN}✅ $(t "项目已配置" "Project already configured")${NC}" echo "" show_usage return fi - echo -e "${YELLOW}📝 No config.json found. Let's configure your project!${NC}" + echo -e "${YELLOW}📝 $(t "未找到 config.json,开始配置项目" "No config.json found. Let's configure your project")!${NC}" echo "" # Project directory already exists, skip clone @@ -919,34 +1280,44 @@ install_mode() { configure_model select_channel configure_channel + menu_session_end create_config_file + # Auto-start after configuration for a true out-of-the-box experience. echo "" - read -p "Start CowAgent now? [Y/n]: " start_now - if [[ ! $start_now == [Nn]* ]]; then - start_project - else - echo -e "${GREEN}✅ Installation complete!${NC}" - echo "" - echo -e "${CYAN}${BOLD}To start manually:${NC}" - echo -e "${YELLOW} cd ${BASE_DIR}${NC}" - echo -e "${YELLOW} ./run.sh start${NC}" - echo "" - echo -e "${CYAN}Or use nohup directly:${NC}" - echo -e "${YELLOW} nohup $PYTHON_CMD app.py > nohup.out 2>&1 & tail -f nohup.out${NC}" - fi + start_project } # Require running inside the project directory require_project_dir() { if [ "$IS_PROJECT_DIR" = false ]; then - echo -e "${RED}${EMOJI_CROSS} Must run in project directory${NC}" + echo -e "${RED}${EMOJI_CROSS} $(t "必须在项目目录下运行" "Must run in project directory")${NC}" exit 1 fi } +# Initialize UI_LANG for management commands: prefer cow_lang from an existing +# config.json, otherwise fall back to environment detection. The install flow +# overrides this later via select_language(). +init_ui_lang() { + [ -n "$UI_LANG" ] && return + local cfg_lang="" + if [ -f "${BASE_DIR}/config.json" ]; then + # `|| true`: grep returns 1 when cow_lang is absent, which would abort + # the whole script under `set -e` at the very first management command. + cfg_lang=$(grep -o '"cow_lang"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" 2>/dev/null | cut -d'"' -f4 || true) + fi + case "$cfg_lang" in + zh) UI_LANG="zh" ;; + en) UI_LANG="en" ;; + *) UI_LANG=$(detect_ui_lang) ;; + esac +} + # Main function main() { + init_ui_lang + case "$1" in start|stop|restart|status|logs|config|update|_post_update) require_project_dir @@ -969,7 +1340,7 @@ main() { install_mode ;; *) - echo -e "${RED}${EMOJI_CROSS} Unknown command: $1${NC}" + echo -e "${RED}${EMOJI_CROSS} $(t "未知命令" "Unknown command"): $1${NC}" echo "" show_usage exit 1 diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 7c5f0b06..f2171258 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -18,10 +18,19 @@ param( $ErrorActionPreference = "Stop" -# ── ensure UTF-8 console encoding on Windows ───────────────────── -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +# ── ensure UTF-8 everywhere on Windows ─────────────────────────── +# Without this, Chinese text renders as mojibake (e.g. "éæ©") on Windows +# PowerShell 5.1, whose console defaults to the system ANSI code page (GBK on +# Chinese systems). Set the active code page AND the console encodings so both +# our output and any child process (git/python) speak UTF-8. +try { chcp 65001 | Out-Null } catch {} +try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + [Console]::InputEncoding = [System.Text.Encoding]::UTF8 +} catch {} +# $OutputEncoding controls how strings are piped to external programs. +$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 } @@ -29,6 +38,31 @@ 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 } +# ── i18n: install-flow language ────────────────────────────────── +# $UiLang controls the language of install prompts/menus ("zh" or "en"). +# Chosen by the user at the first step; defaults to environment detection +# for management commands (start/stop/...). +$script:UiLang = "" + +# Detect default UI language from the OS culture (best-effort). Checks the +# display/UI culture first (closest to the user's chosen Windows language), +# then the regional format culture as a fallback. Any zh-* signal -> "zh". +function Get-DefaultUiLang { + foreach ($getter in @({ (Get-UICulture).Name }, { (Get-Culture).Name })) { + try { + $name = & $getter + if ($name -match '^zh') { return "zh" } + } catch {} + } + return "en" +} + +# Translation helper: T -> string in the active UI language. +function T { + param([string]$Zh, [string]$En) + if ($script:UiLang -eq "en") { return $En } else { return $Zh } +} + # ── detect project directory ───────────────────────────────────── $ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } $BaseDir = Split-Path $ScriptDir -Parent @@ -39,6 +73,125 @@ if (-not $IsProjectDir) { $IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json") } +# Initialize $UiLang for management commands: prefer cow_lang from an existing +# config.json, otherwise fall back to environment detection. +function Initialize-UiLang { + if ($script:UiLang) { return } + $cfgLang = "" + if (Test-Path "$BaseDir\config.json") { + try { + $cfg = Get-Content "$BaseDir\config.json" -Raw | ConvertFrom-Json + if ($cfg.cow_lang) { $cfgLang = "$($cfg.cow_lang)" } + } catch {} + } + switch ($cfgLang) { + "zh" { $script:UiLang = "zh" } + "en" { $script:UiLang = "en" } + default { $script:UiLang = Get-DefaultUiLang } + } +} + +# ── arrow-key selectable menu with number fallback ─────────────── +# Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1] +# Returns the selected 1-based index. +function Select-Menu { + param( + [string]$Title, + [string[]]$Options, + [int]$Default = 1 + ) + $count = $Options.Count + $cur = [Math]::Max(0, [Math]::Min($Default - 1, $count - 1)) + + # Fallback to numbered input when there is no interactive console + # (e.g. piped input, redirected host). + $interactive = $true + try { + if ([Console]::IsInputRedirected) { $interactive = $false } + } catch { $interactive = $false } + + if (-not $interactive) { + Write-Info $Title + for ($i = 0; $i -lt $count; $i++) { + Write-Host (" {0}) {1}" -f ($i + 1), $Options[$i]) + } + do { + $sel = Read-Host (T "请输入序号" "Enter number") + if (-not $sel) { $sel = "$($cur + 1)" } + } while ($sel -notmatch '^\d+$' -or [int]$sel -lt 1 -or [int]$sel -gt $count) + return [int]$sel + } + + Write-Info $Title + Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan + + [Console]::CursorVisible = $false + $firstDraw = $true + try { + while ($true) { + if (-not $firstDraw) { + # Move cursor up to the top of the option block to redraw it. + $top = [Console]::CursorTop - $count + if ($top -lt 0) { $top = 0 } + [Console]::SetCursorPosition(0, $top) + } + $firstDraw = $false + + for ($i = 0; $i -lt $count; $i++) { + # Clear the line first to avoid leftover characters. + Write-Host (" " * ([Console]::WindowWidth - 1)) -NoNewline + [Console]::SetCursorPosition(0, [Console]::CursorTop) + if ($i -eq $cur) { + Write-Host (" > " + $Options[$i]) -ForegroundColor Green + } else { + Write-Host (" " + $Options[$i]) + } + } + + $key = [Console]::ReadKey($true) + switch ($key.Key) { + "UpArrow" { $cur = (($cur - 1 + $count) % $count) } + "DownArrow" { $cur = (($cur + 1) % $count) } + "Enter" { return ($cur + 1) } + default { + # Number shortcut (1-9) jumps to that option and confirms. + $ch = $key.KeyChar + if ($ch -match '^[1-9]$') { + $n = [int]"$ch" + if ($n -ge 1 -and $n -le $count) { return $n } + } + } + } + } + } finally { + [Console]::CursorVisible = $true + } +} + +# ── language selection (first step of install) ─────────────────── +function Select-Language { + # Order is fixed (English first, Chinese second). The default highlight + # follows detection, but conservatively: only a confident "zh" signal + # (a zh-* system culture) preselects Chinese; everything else defaults to + # English. The menu hint shows in the detected language for familiarity. + $detected = Get-DefaultUiLang + if ($detected -eq "zh") { + $default = 2 + $script:UiLang = "zh" + } else { + $default = 1 + $script:UiLang = "en" + } + + $idx = Select-Menu -Title "Select Language / 选择语言" -Options @("English", "中文 (Chinese)") -Default $default + switch ($idx) { + 1 { $script:UiLang = "en" } + 2 { $script:UiLang = "zh" } + default { $script:UiLang = "en" } + } + $script:InstallLang = $script:UiLang +} + # ── Python detection ───────────────────────────────────────────── function Find-Python { foreach ($cmd in @("python3", "python")) { @@ -59,45 +212,36 @@ function Find-Python { $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/" - Read-Host "Press Enter to exit" + Write-Err (T "未找到 Python 3.9-3.13,请从 https://www.python.org/downloads/ 安装" "Python 3.9-3.13 not found. Please install from https://www.python.org/downloads/") + Read-Host (T "按回车退出" "Press Enter to exit") exit 1 } - Write-Cow "Found Python: $PythonCmd" + Write-Cow ((T "找到 Python" "Found Python") + ": $PythonCmd") } # ── clone project ──────────────────────────────────────────────── function Install-Project { if (Test-Path "CowAgent") { - Write-Warn "Directory 'CowAgent' 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 "CowAgent" } - "b" { - $backup = "CowAgent_backup_$(Get-Date -Format 'yyyyMMddHHmmss')" - Rename-Item "CowAgent" $backup - Write-Cow "Backed up to '$backup'" - } - "q" { Write-Err "Installation cancelled."; exit 1 } - default { Write-Err "Invalid choice."; exit 1 } - } + # Auto-backup the existing directory without prompting. + $backup = "CowAgent_backup_$(Get-Date -Format 'yyyyMMddHHmmss')" + Rename-Item "CowAgent" $backup + Write-Warn ((T "已存在 CowAgent 目录,已自动备份为" "Existing 'CowAgent' directory backed up to") + " '$backup'") } $gitBin = Get-Command git -ErrorAction SilentlyContinue if (-not $gitBin) { - Write-Err "Git not found. Please install from https://git-scm.com/download/win" - Read-Host "Press Enter to exit" + Write-Err (T "未找到 Git,请从 https://git-scm.com/download/win 安装" "Git not found. Please install from https://git-scm.com/download/win") + Read-Host (T "按回车退出" "Press Enter to exit") exit 1 } - Write-Cow "Cloning CowAgent project..." + Write-Cow (T "正在克隆 CowAgent 项目..." "Cloning CowAgent project...") $cloneOk = $false # Test GitHub connectivity before attempting clone try { $null = Invoke-WebRequest -Uri "https://github.com" -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop - Write-Cow "GitHub is reachable, cloning from GitHub..." + Write-Cow (T "GitHub 可达,正在从 GitHub 克隆..." "GitHub is reachable, cloning from GitHub...") $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" git clone --depth 10 --progress "https://github.com/zhayujie/CowAgent.git" 2>&1 | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -eq 0) { $cloneOk = $true } @@ -108,7 +252,7 @@ function Install-Project { } catch {} if (-not $cloneOk) { - Write-Warn "GitHub clone failed or timed out, switching to Gitee mirror..." + Write-Warn (T "GitHub 克隆失败或超时,切换到 Gitee 镜像..." "GitHub clone failed or timed out, switching to Gitee mirror...") $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" git clone --depth 10 --progress "https://gitee.com/zhayujie/CowAgent.git" 2>&1 | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -eq 0) { $cloneOk = $true } @@ -119,35 +263,83 @@ function Install-Project { } if (-not $cloneOk) { - Write-Err "Clone failed from both GitHub and Gitee. Please check your network connection." - Write-Err "You can also manually clone: git clone https://gitee.com/zhayujie/CowAgent.git" - Read-Host "Press Enter to exit" + Write-Err (T "GitHub 和 Gitee 均克隆失败,请检查网络连接。" "Clone failed from both GitHub and Gitee. Please check your network connection.") + Write-Err (T "你也可以手动克隆: git clone https://gitee.com/zhayujie/CowAgent.git" "You can also manually clone: git clone https://gitee.com/zhayujie/CowAgent.git") + Read-Host (T "按回车退出" "Press Enter to exit") exit 1 } Set-Location "CowAgent" $script:BaseDir = $PWD.Path $script:IsProjectDir = $true - Write-Cow "Project cloned: $BaseDir" + Write-Cow ((T "项目已克隆" "Project cloned") + ": $BaseDir") +} + +# Test whether a URL is reachable within a short timeout. Uses a HEAD request +# and hides progress so it never blocks the UI for long. Any failure (DNS, TLS, +# timeout) just returns $false so the caller falls back gracefully. +function Test-UrlReachable { + param([string]$Url, [int]$TimeoutSec = 4) + $oldPP = $ProgressPreference; $ProgressPreference = "SilentlyContinue" + try { + $null = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop + return $true + } catch { + return $false + } finally { + $ProgressPreference = $oldPP + } +} + +# Pick the pip index by install language, with the other as fallback: +# - zh users: Tsinghua mirror first, official PyPI fallback +# - others : official PyPI first, Tsinghua mirror fallback +# Returns an args array to splat into pip (empty = pip default / official PyPI). +function Get-PipMirrorArgs { + $tuna = "https://pypi.tuna.tsinghua.edu.cn/simple" + $pypi = "https://pypi.org/simple" + if ($script:UiLang -eq "zh") { + if (Test-UrlReachable "$tuna/") { + Write-Warn ((T "使用 pip 镜像" "Using pip mirror") + ": $tuna") + return @("-i", $tuna) + } + } else { + if ((-not (Test-UrlReachable "$pypi/")) -and (Test-UrlReachable "$tuna/")) { + Write-Warn ((T "使用 pip 镜像" "Using pip mirror") + ": $tuna") + return @("-i", $tuna) + } + } + return @() } # ── install dependencies ───────────────────────────────────────── function Install-Dependencies { - Write-Cow "Installing dependencies..." + Write-Cow (T "正在安装依赖..." "Installing dependencies...") + # Probe the mirror first (with progress hidden so the slow IWR call doesn't + # leave the screen blank for too long). + $oldPP = $ProgressPreference; $ProgressPreference = "SilentlyContinue" + $pipMirror = Get-PipMirrorArgs + $ProgressPreference = $oldPP + + # Keep pip output VISIBLE (do not pipe to Out-Null): on slow networks the + # download can take minutes, and a silent step looks like a hang. $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 $_ } + Write-Info (T "正在升级 pip 等基础工具..." "Upgrading pip and basic tools...") + & $PythonCmd -m pip install --upgrade pip setuptools wheel @pipMirror + + Write-Info (T "正在安装项目依赖(可能需要几分钟)..." "Installing project dependencies (may take a few minutes)...") + & $PythonCmd -m pip install -r "$BaseDir\requirements.txt" @pipMirror $pipExit = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($pipExit -ne 0) { - Write-Warn "Some dependencies may have issues, but continuing..." + Write-Warn (T "部分依赖可能有问题,但继续安装..." "Some dependencies may have issues, but continuing...") } - Write-Cow "Registering cow CLI..." + Write-Cow (T "正在注册 cow CLI..." "Registering cow CLI...") $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" - & $PythonCmd -m pip install -e $BaseDir 2>&1 | Out-Null + & $PythonCmd -m pip install -e $BaseDir @pipMirror 2>&1 | Out-Null $ErrorActionPreference = $prevEAP # Ensure Python Scripts dir is in PATH for this session @@ -160,139 +352,206 @@ function Install-Dependencies { $cowBin = Get-Command cow -ErrorAction SilentlyContinue if ($cowBin) { - Write-Cow "cow CLI registered: $($cowBin.Source)" + Write-Cow ((T "cow CLI 注册成功" "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." + Write-Warn ((T "cow CLI 不在 PATH 中,你可以使用" "cow CLI not in PATH. You can use") + ": $PythonCmd -m cli.cli") + Write-Warn (T "如需永久修复,请将 Python Scripts 目录加入系统 PATH。" "To fix permanently, add Python Scripts directory to your system PATH.") } } # ── model selection ────────────────────────────────────────────── +# Order mirrors run.sh: DeepSeek, Claude, Gemini, OpenAI, MiniMax, Zhipu, +# Qwen, Doubao, Kimi, LinkAI, then Skip (11th option). +# Each entry: Provider / default model name / config key field / optional base. $ModelChoices = @{ - "1" = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Key = "DEEPSEEK_KEY" } - "2" = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Key = "MINIMAX_KEY" } - "3" = @{ Provider = "Zhipu AI"; Default = "glm-5.1"; Key = "ZHIPU_KEY" } - "4" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Key = "MOONSHOT_KEY" } - "5" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" } - "6" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Key = "DASHSCOPE_KEY" } - "7" = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" } - "8" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" } - "9" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" } - "10" = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Key = "LINKAI_KEY" } + 1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" } + 2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" } + 3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" } + 4 = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } + 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Field = "minimax_api_key" } + 6 = @{ Provider = "Zhipu AI"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" } + 7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Field = "dashscope_api_key" } + 8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Field = "ark_api_key" } + 9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Field = "moonshot_api_key" } + 10 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true } } function Select-Model { - Write-Info "=========================================" - Write-Info " Select AI Model" - Write-Info "=========================================" - Write-Host "1) DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" - Write-Host "2) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)" - Write-Host "3) Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)" - Write-Host "4) Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)" - Write-Host "5) Doubao (doubao-seed-2-0-code-preview-260215, etc.)" - Write-Host "6) Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" - Write-Host "7) Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)" - Write-Host "8) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" - Write-Host "9) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" - Write-Host "10) LinkAI (access multiple models via one API)" Write-Host "" + $title = T "选择 AI 模型" "Select AI Model" + $options = @( + "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)", + "Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)", + "Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)", + "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)", + "MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)", + "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)", + "Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)", + "Doubao (doubao-seed-2-0-code-preview-260215, etc.)", + "Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)", + ("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"), + (T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)") + ) + $script:ModelChoice = Select-Menu -Title $title -Options $options -Default 1 +} - do { - $choice = Read-Host "Enter your choice [default: 1 - DeepSeek]" - if (-not $choice) { $choice = "1" } - } while ($choice -notmatch '^([1-9]|10)$') +# Configure model. Only ask for the API key; model name and base default to +# sensible values and can be changed later in the web console. +function Configure-Model { + # Reset model-related state + $script:ModelName = "" + $script:ModelField = "" + $script:ApiKey = "" + $script:ApiBase = "" + $script:ApiBaseField = "" + $script:UseLinkai = $false - $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 "10") - - 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 = "" + if ($script:ModelChoice -eq 11) { + # Skip: leave model unset, will be configured in the web console. + Write-Warn (T "已跳过模型配置,稍后可在 Web 控制台填写" "Model configuration skipped, you can set it later in the web console") + return } - $script:ModelChoice = $choice + + $m = $ModelChoices[$script:ModelChoice] + Write-Cow ((T "正在配置" "Configuring") + " $($m.Provider)...") + # Show where to obtain a LinkAI key. + if ($m.Linkai) { + Write-Info ((T "获取 LinkAI Key" "Get your LinkAI Key") + ": https://link-ai.tech/console/interface") + } + $hint = T "回车跳过,稍后在 Web 控制台填写" "press Enter to skip, set later in web console" + $script:ApiKey = Read-Host ((T "请输入" "Enter") + " $($m.Provider) API Key ($hint)") + $script:ModelName = $m.Default + $script:ModelField = $m.Field + if ($m.BaseField) { $script:ApiBaseField = $m.BaseField } + if ($m.Linkai) { $script:UseLinkai = $true } } # ── channel selection ──────────────────────────────────────────── +# Channel label by stable key (independent of menu order). +function Get-ChannelLabel { + param([string]$Key) + switch ($Key) { + "web" { return (T "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)") } + "weixin" { return (T "微信 Weixin" "WeChat (Weixin)") } + "feishu" { return (T "飞书 Feishu" "Feishu / Lark") } + "dingtalk" { return (T "钉钉 DingTalk" "DingTalk") } + "wecom_bot" { return (T "企微智能机器人 WeCom Bot" "WeCom Bot") } + "qq" { return "QQ" } + "wechatcom_app" { return (T "企微自建应用 WeCom App" "WeCom App") } + "telegram" { return "Telegram" } + "slack" { return "Slack" } + "discord" { return "Discord" } + "skip" { return (T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)") } + } +} + +# Select channel. The display order depends on the install language: +# - English: Web first, then the global IM channels (Telegram/Discord/Slack), +# then the China-focused channels. +# - Chinese: Web first, then China-focused channels, then global ones. +# A stable key list decouples the menu order from the config logic. 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]$') + $title = T "选择接入渠道" "Select Communication Channel" + if ($script:UiLang -eq "en") { + $script:ChannelKeys = @("web", "telegram", "discord", "slack", "weixin", "feishu", "dingtalk", "wecom_bot", "qq", "wechatcom_app", "skip") + } else { + $script:ChannelKeys = @("web", "weixin", "feishu", "dingtalk", "wecom_bot", "qq", "wechatcom_app", "telegram", "slack", "discord", "skip") + } + $options = @($script:ChannelKeys | ForEach-Object { Get-ChannelLabel $_ }) + $idx = Select-Menu -Title $title -Options $options -Default 1 + # Map the 1-based menu position back to the stable channel key. + $script:ChannelChoice = $script:ChannelKeys[$idx - 1] +} +# Configure channel, dispatched by stable channel key (not menu position). +function Configure-Channel { $script:ChannelExtra = @{} + $script:AccessInfo = "" - 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" { + switch ($script:ChannelChoice) { + { $_ -eq "web" -or $_ -eq "skip" } { + # Web (also the default when skipped). Default port, no prompt. $script:ChannelType = "web" - $port = Read-Host "Enter web port [default: 9899]" - if (-not $port) { $port = "9899" } - $script:ChannelExtra["web_port"] = [int]$port + $script:ChannelExtra["web_port"] = 9899 + $script:AccessInfo = (T "Web 控制台地址" "Web console") + " : http://localhost:9899/chat" + } + "weixin" { + $script:ChannelType = "weixin" + $script:AccessInfo = T "微信渠道已配置,请在终端或 Web 控制台扫码登录" "Weixin channel configured. Scan QR code in terminal or web console to login." + } + "feishu" { + $script:ChannelType = "feishu" + Write-Cow (T "配置飞书(WebSocket 模式)..." "Configure Feishu (WebSocket mode)...") + $script:ChannelExtra["feishu_app_id"] = Read-Host (T "请输入飞书 App ID" "Enter Feishu App ID") + $script:ChannelExtra["feishu_app_secret"] = Read-Host (T "请输入飞书 App Secret" "Enter Feishu App Secret") + $script:ChannelExtra["feishu_event_mode"] = "websocket" + $script:AccessInfo = T "飞书渠道已配置(WebSocket 模式)" "Feishu channel configured (WebSocket mode)" + } + "dingtalk" { + $script:ChannelType = "dingtalk" + Write-Cow (T "配置钉钉..." "Configure DingTalk...") + $script:ChannelExtra["dingtalk_client_id"] = Read-Host (T "请输入钉钉 Client ID" "Enter DingTalk Client ID") + $script:ChannelExtra["dingtalk_client_secret"] = Read-Host (T "请输入钉钉 Client Secret" "Enter DingTalk Client Secret") + $script:AccessInfo = T "钉钉渠道已配置" "DingTalk channel configured" + } + "wecom_bot" { + $script:ChannelType = "wecom_bot" + Write-Cow (T "配置企微智能机器人..." "Configure WeCom Bot...") + $script:ChannelExtra["wecom_bot_id"] = Read-Host (T "请输入 WeCom Bot ID" "Enter WeCom Bot ID") + $script:ChannelExtra["wecom_bot_secret"] = Read-Host (T "请输入 WeCom Bot Secret" "Enter WeCom Bot Secret") + $script:AccessInfo = T "企微智能机器人渠道已配置" "WeCom Bot channel configured" + } + "qq" { + $script:ChannelType = "qq" + Write-Cow (T "配置 QQ 机器人..." "Configure QQ Bot...") + $script:ChannelExtra["qq_app_id"] = Read-Host (T "请输入 QQ App ID" "Enter QQ App ID") + $script:ChannelExtra["qq_app_secret"] = Read-Host (T "请输入 QQ App Secret" "Enter QQ App Secret") + $script:AccessInfo = T "QQ 机器人渠道已配置" "QQ Bot channel configured" + } + "wechatcom_app" { + $script:ChannelType = "wechatcom_app" + Write-Cow (T "配置企微自建应用..." "Configure WeCom App...") + $script:ChannelExtra["wechatcom_corp_id"] = Read-Host (T "请输入企业 Corp ID" "Enter WeChat Corp ID") + $script:ChannelExtra["wechatcomapp_token"] = Read-Host (T "请输入应用 Token" "Enter WeChat Com App Token") + $script:ChannelExtra["wechatcomapp_secret"] = Read-Host (T "请输入应用 Secret" "Enter WeChat Com App Secret") + $script:ChannelExtra["wechatcomapp_agent_id"] = Read-Host (T "请输入应用 Agent ID" "Enter WeChat Com App Agent ID") + $script:ChannelExtra["wechatcomapp_aes_key"] = Read-Host (T "请输入应用 AES Key" "Enter WeChat Com App AES Key") + $port = Read-Host ((T "请输入应用端口" "Enter port") + " [" + (T "默认" "default") + ": 9898]") + if (-not ($port -match '^\d+$')) { $port = "9898" } + $script:ChannelExtra["wechatcomapp_port"] = [int]$port + $script:AccessInfo = (T "企微自建应用渠道已配置,端口" "WeCom App channel configured on port") + " $port" + } + "telegram" { + $script:ChannelType = "telegram" + Write-Cow (T "配置 Telegram..." "Configure Telegram...") + $script:ChannelExtra["telegram_token"] = Read-Host (T "请输入 Telegram Bot Token" "Enter Telegram Bot Token") + $script:AccessInfo = T "Telegram 渠道已配置" "Telegram channel configured" + } + "slack" { + $script:ChannelType = "slack" + Write-Cow (T "配置 Slack..." "Configure Slack...") + $script:ChannelExtra["slack_bot_token"] = Read-Host ((T "请输入 Slack Bot Token" "Enter Slack Bot Token") + " (xoxb-...)") + $script:ChannelExtra["slack_app_token"] = Read-Host ((T "请输入 Slack App Token" "Enter Slack App Token") + " (xapp-...)") + $script:AccessInfo = T "Slack 渠道已配置" "Slack channel configured" + } + "discord" { + $script:ChannelType = "discord" + Write-Cow (T "配置 Discord..." "Configure Discord...") + $script:ChannelExtra["discord_token"] = Read-Host (T "请输入 Discord Bot Token" "Enter Discord Bot Token") + $script:AccessInfo = T "Discord 渠道已配置" "Discord channel configured" } } } # ── generate config.json ───────────────────────────────────────── function New-ConfigFile { - Write-Cow "Generating config.json..." + Write-Cow (T "正在生成 config.json..." "Generating config.json...") $config = [ordered]@{ - channel_type = $ChannelType - model = $ModelName + channel_type = if ($script:ChannelType) { $script:ChannelType } else { "web" } + model = if ($script:ModelName) { $script:ModelName } else { "" } + cow_lang = if ($script:InstallLang) { $script:InstallLang } else { "auto" } open_ai_api_key = "" open_ai_api_base = "https://api.openai.com/v1" claude_api_key = "" @@ -311,7 +570,7 @@ function New-ConfigFile { voice_reply_voice = $false speech_recognition = $true group_speech_recognition = $false - use_linkai = $UseLinkai + use_linkai = [bool]$script:UseLinkai linkai_api_key = "" linkai_app_code = "" agent = $true @@ -320,51 +579,33 @@ function New-ConfigFile { 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" - DEEPSEEK_KEY = "deepseek_api_key" - LINKAI_KEY = "linkai_api_key" + # Set the API key into the right field (skipped models leave it empty). + if ($script:ModelField -and $config.Contains($script:ModelField)) { + $config[$script:ModelField] = $script:ApiKey } - if ($keyMap.ContainsKey($KeyName)) { - $config[$keyMap[$KeyName]] = $ApiKey - } - - # Set API base if provided - $baseMap = @{ - "7" = "claude_api_base" - "8" = "gemini_api_base" - "9" = "open_ai_api_base" - } - if ($ApiBase -and $baseMap.ContainsKey($ModelChoice)) { - $config[$baseMap[$ModelChoice]] = $ApiBase + # Set API base if the model has a configurable base and the user changed it. + if ($script:ApiBase -and $script:ApiBaseField -and $config.Contains($script:ApiBaseField)) { + $config[$script:ApiBaseField] = $script:ApiBase } # Merge channel-specific fields - foreach ($k in $ChannelExtra.Keys) { - $config[$k] = $ChannelExtra[$k] + foreach ($k in $script:ChannelExtra.Keys) { + $config[$k] = $script:ChannelExtra[$k] } $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." + Write-Cow (T "配置文件创建成功。" "Configuration file created.") } # ── start via cow CLI ───────────────────────────────────────────── function Start-CowAgent { - Write-Cow "Starting CowAgent..." + Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...") $cowBin = Get-Command cow -ErrorAction SilentlyContinue if ($cowBin) { & cow start } else { - Write-Warn "cow CLI not found, starting directly..." + Write-Warn (T "未找到 cow CLI,直接启动..." "cow CLI not found, starting directly...") & $PythonCmd "$BaseDir\app.py" } } @@ -376,7 +617,7 @@ function Invoke-CowCommand { if ($cowBin) { & cow $Cmd } else { - Write-Err "cow CLI not found. Run this script without arguments first to install." + Write-Err (T "未找到 cow CLI,请先不带参数运行本脚本进行安装。" "cow CLI not found. Run this script without arguments first to install.") exit 1 } } @@ -387,19 +628,19 @@ function Show-Usage { 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 (T "用法:" "Usage:") + Write-Host (" .\run.ps1 # " + (T "安装 / 配置" "Install / Configure")) + Write-Host (" .\run.ps1 # " + (T "管理命令" "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 (T "命令:" "Commands:") + Write-Host (" start " + (T "启动服务" "Start the service")) + Write-Host (" stop " + (T "停止服务" "Stop the service")) + Write-Host (" restart " + (T "重启服务" "Restart the service")) + Write-Host (" status " + (T "查看状态" "Check service status")) + Write-Host (" logs " + (T "查看日志" "View logs")) + Write-Host (" config " + (T "重新配置项目" "Reconfigure project")) + Write-Host (" update " + (T "更新并重启" "Update and restart")) + Write-Host (" help " + (T "显示本帮助" "Show this message")) Write-Host "" } @@ -411,15 +652,19 @@ function Install-Mode { Write-Info "=========================================" Write-Host "" + # Step 0: choose the install/UI language. Everything after this is localized. + Select-Language + Write-Host "" + if ($IsProjectDir) { - Write-Cow "Detected existing project directory." + Write-Cow (T "检测到已有项目目录。" "Detected existing project directory.") if (Test-Path "$BaseDir\config.json") { - Write-Cow "Project already configured." + Write-Cow (T "项目已配置。" "Project already configured.") Write-Host "" Show-Usage return } - Write-Warn "No config.json found. Let's configure your project!" + Write-Warn (T "未找到 config.json,开始配置项目!" "No config.json found. Let's configure your project!") Write-Host "" Assert-Python } else { @@ -429,25 +674,20 @@ function Install-Mode { Install-Dependencies Select-Model + Configure-Model Select-Channel + Configure-Channel New-ConfigFile + # Auto-start after configuration for a true out-of-the-box experience. 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" - } + if ($script:AccessInfo) { Write-Cow $script:AccessInfo } + Start-CowAgent } # ── update ──────────────────────────────────────────────────────── function Update-Project { - Write-Cow "Updating CowAgent..." + Write-Cow (T "正在更新 CowAgent..." "Updating CowAgent...") Set-Location $BaseDir # Stop if running @@ -459,20 +699,20 @@ function Update-Project { } if (Test-Path "$BaseDir\.git") { - Write-Cow "Pulling latest code..." + Write-Cow (T "正在拉取最新代码..." "Pulling latest code...") $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue" git pull 2>&1 | Out-Null $pullExit = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($pullExit -ne 0) { - Write-Warn "GitHub failed, trying Gitee..." + Write-Warn (T "GitHub 拉取失败,尝试 Gitee..." "GitHub failed, trying Gitee...") $ErrorActionPreference = "Continue" git remote set-url origin https://gitee.com/zhayujie/CowAgent.git 2>&1 | Out-Null git pull 2>&1 | Out-Null $ErrorActionPreference = $prevEAP } } else { - Write-Warn "Not a git repository, skipping code update." + Write-Warn (T "非 git 仓库,跳过代码更新。" "Not a git repository, skipping code update.") } Assert-Python @@ -480,11 +720,13 @@ function Update-Project { # 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..." + Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...") & $PythonCmd -m cli.cli start } # ── main ────────────────────────────────────────────────────────── +Initialize-UiLang + switch ($Command.ToLower()) { "" { Install-Mode } "start" { Invoke-CowCommand "start" } @@ -496,15 +738,17 @@ switch ($Command.ToLower()) { Assert-Python Install-Dependencies Select-Model + Configure-Model Select-Channel + Configure-Channel New-ConfigFile - $r = Read-Host "Restart service now? [Y/n]" + $r = Read-Host (T "现在重启服务吗?[Y/n]" "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" + Write-Err ((T "未知命令" "Unknown command") + ": $Command") Show-Usage exit 1 } From 04d28f9d2d94ad54d53fc07a32ed0717f40f8611 Mon Sep 17 00:00:00 2001 From: Wyh-max-star <2310137@mail.nankai.edu.cn> Date: Sun, 31 May 2026 20:52:42 +0800 Subject: [PATCH 300/399] chore:add group task board plugin source --- plugins/source.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/source.json b/plugins/source.json index 34f82dbe..0e231bab 100644 --- a/plugins/source.json +++ b/plugins/source.json @@ -24,6 +24,10 @@ "url": "https://github.com/dividduang/blackroom.git", "desc": "小黑屋插件,被拉进小黑屋的人将不能使用@bot的功能的插件" }, + "group_task_board": { + "url": "https://github.com/Wyh-max-star/cowagent-plugin-group-task-board.git", + "desc": "群聊任务看板插件,支持从群聊消息中创建、查看和管理任务" + }, "midjourney": { "url": "https://github.com/baojingyu/midjourney.git", "desc": "利用midjourney实现ai绘图的的插件" From 2b2d24ed25cf23a5ba51aecee4c6c0f919d7f362 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 22:22:48 +0800 Subject: [PATCH 301/399] docs: update doc references --- docs/zh/channels/index.mdx | 2 +- docs/zh/intro/features.mdx | 4 ++-- docs/zh/intro/index.mdx | 4 ++-- docs/zh/tools/memory.mdx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/zh/channels/index.mdx b/docs/zh/channels/index.mdx index 3a930ce1..bcd28350 100644 --- a/docs/zh/channels/index.mdx +++ b/docs/zh/channels/index.mdx @@ -27,7 +27,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 - **群聊**列指可识别并响应群消息 - 每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/models)。 + 每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/zh/models)。 ## 通道一览 diff --git a/docs/zh/intro/features.mdx b/docs/zh/intro/features.mdx index ca54b39e..86aa5c95 100644 --- a/docs/zh/intro/features.mdx +++ b/docs/zh/intro/features.mdx @@ -15,7 +15,7 @@ description: CowAgent 长期记忆、个人知识库、任务规划、技能系 -详细说明请参考 [长期记忆](/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)。 +详细说明请参考 [长期记忆](/zh/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)。 ## 2. 个人知识库 @@ -32,7 +32,7 @@ Agent 会在对话中自动将有价值的信息整理为知识页面,维护 -详细说明请参考 [个人知识库](/knowledge)。 +详细说明请参考 [个人知识库](/zh/knowledge)。 ## 3. 任务规划和工具调用 diff --git a/docs/zh/intro/index.mdx b/docs/zh/intro/index.mdx index 4701b754..87076e0f 100644 --- a/docs/zh/intro/index.mdx +++ b/docs/zh/intro/index.mdx @@ -26,10 +26,10 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、 能够理解复杂任务并自主规划执行,持续思考和调用各类工具和技能直到完成目标。 - + 三层记忆流转(上下文→天级记忆→全局记忆),每日梦境蒸馏整理,支持关键词及向量检索。 - + 自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。 diff --git a/docs/zh/tools/memory.mdx b/docs/zh/tools/memory.mdx index c3cc6fe4..7f6ca9e0 100644 --- a/docs/zh/tools/memory.mdx +++ b/docs/zh/tools/memory.mdx @@ -5,7 +5,7 @@ description: 搜索和读取长期记忆及知识库文件 记忆工具包含两个子工具:`memory_search`(搜索记忆)和 `memory_get`(读取记忆或知识文件)。 -当 [知识库](/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。 +当 [知识库](/zh/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。 ## 依赖 From af2c839231b836503952a0f9648429fb5b0e5529 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 1 Jun 2026 11:01:28 +0800 Subject: [PATCH 302/399] docs: add contributing guide and issue/PR templates --- .github/ISSUE_TEMPLATE/1.bug.yml | 145 ++++++--------------------- .github/ISSUE_TEMPLATE/2.feature.yml | 31 +++--- .github/ISSUE_TEMPLATE/config.yml | 5 + .github/PULL_REQUEST_TEMPLATE.md | 21 ++++ CONTRIBUTING.md | 61 +++++++++++ 5 files changed, 135 insertions(+), 128 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/1.bug.yml b/.github/ISSUE_TEMPLATE/1.bug.yml index a4f10926..7c0ec054 100644 --- a/.github/ISSUE_TEMPLATE/1.bug.yml +++ b/.github/ISSUE_TEMPLATE/1.bug.yml @@ -1,131 +1,46 @@ name: Bug report 🐛 -description: 项目运行中遇到的Bug或问题。 +description: Report a bug or unexpected behavior. +title: "[Bug] " labels: ['status: needs check'] body: - type: markdown attributes: value: | - ### ⚠️ 前置确认 - 1. 网络能够访问openai接口 - 2. python 已安装:版本在 3.7 ~ 3.10 之间 - 3. `git pull` 拉取最新代码 - 4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足 - 5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足 - 6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题 + > 💡 English is recommended so global developers can help. 【中文用户】推荐使用英文提交,谢谢 ❤️ - type: checkboxes attributes: - label: 前置确认 + label: Self check options: - - label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。 + - label: I'm on the latest version and searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate. required: true - - type: checkboxes + - type: textarea attributes: - label: ⚠️ 搜索issues中是否已存在类似问题 - description: > - 请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索你的问题 - 或相关日志的关键词来查找是否存在类似问题。 - options: - - label: 我已经搜索过issues和disscussions,没有跟我遇到的问题相关的issue - required: true - - type: markdown - attributes: - value: | - 请在上方的`title`中填写你对你所遇到问题的简略总结,这将帮助其他人更好的找到相似问题,谢谢❤️。 - - type: dropdown - attributes: - label: 操作系统类型? - description: > - 请选择你运行程序的操作系统类型。 - options: - - Windows - - Linux - - MacOS - - Docker - - Railway - - Windows Subsystem for Linux (WSL) - - Other (请在问题中说明) - validations: - required: true - - type: dropdown - attributes: - label: 运行的python版本是? - description: | - 请选择你运行程序的`python`版本。 - 注意:在`python 3.7`中,有部分可选依赖无法安装。 - 经过长时间的观察,我们认为`python 3.8`是兼容性最好的版本。 - `python 3.7`~`python 3.10`以外版本的issue,将视情况直接关闭。 - options: - - python 3.7 - - python 3.8 - - python 3.9 - - python 3.10 - - other - validations: - required: true - - type: dropdown - attributes: - label: 使用的chatgpt-on-wechat版本是? - description: | - 请确保你使用的是 [releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) 中的最新版本。 - 如果你使用git, 请使用`git branch`命令来查看分支。 - options: - - Latest Release - - Master (branch) - validations: - required: true - - type: dropdown - attributes: - label: 运行的`channel`类型是? - description: | - 请确保你正确配置了该`channel`所需的配置项,所有可选的配置项都写在了[该文件中](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py),请将所需配置项填写在根目录下的`config.json`文件中。 - options: - - wechatmp(公众号, 订阅号) - - wechatmp_service(公众号, 服务号) - - terminal - - other + label: Environment + description: "Version (`cow status`), OS, Python version, install method, model & channel." + placeholder: | + Version: v1.2.0 + OS: macOS / Linux / Windows / Docker + Python: 3.11 + Install: installer / Docker / source + Model & channel: deepseek-v4-flash, web validations: required: true - type: textarea attributes: - label: 复现步骤 🕹 - description: | - **⚠️ 不能复现将会关闭issue.** - - type: textarea - attributes: - label: 问题描述 😯 - description: 详细描述出现的问题,或提供有关截图。 - - type: textarea - attributes: - label: 终端日志 📒 - description: | - 在此处粘贴终端日志,可在主目录下`run.log`文件中找到,这会帮助我们更好的分析问题,注意隐去你的API key。 - 如果在配置文件中加入`"debug": true`,打印出的日志会更有帮助。 + label: What happened? + description: "Steps to reproduce, what you expected, and what happened instead. Screenshots welcome." + placeholder: | + 1. ... + 2. ... -
- 示例 - ```log - [DEBUG][2023-04-16 00:23:22][plugin_manager.py:157] - Plugin SUMMARY triggered by event Event.ON_HANDLE_CONTEXT - [DEBUG][2023-04-16 00:23:22][main.py:221] - [Summary] on_handle_context. content: $总结前100条消息 - [DEBUG][2023-04-16 00:23:24][main.py:240] - [Summary] limit: 100, duration: -1 seconds - [ERROR][2023-04-16 00:23:24][chat_channel.py:244] - Worker return exception: name 'start_date' is not defined - Traceback (most recent call last): - File "C:\ProgramData\Anaconda3\lib\concurrent\futures\thread.py", line 57, in run - result = self.fn(*self.args, **self.kwargs) - File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 132, in _handle - reply = self._generate_reply(context) - File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 142, in _generate_reply - e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, { - File "D:\project\chatgpt-on-wechat\plugins\plugin_manager.py", line 159, in emit_event - instance.handlers[e_context.event](e_context, *args, **kwargs) - File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 255, in on_handle_context - records = self._get_records(session_id, start_time, limit) - File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 96, in _get_records - c.execute("SELECT * FROM chat_records WHERE sessionid=? and timestamp>? ORDER BY timestamp DESC LIMIT ?", (session_id, start_date, limit)) - NameError: name 'start_date' is not defined - [INFO][2023-04-16 00:23:36][app.py:14] - signal 2 received, exiting... - ``` -
- value: | - ```log - <此处粘贴终端日志> - ``` \ No newline at end of file + Expected: ... + Actual: ... + validations: + required: true + - type: textarea + attributes: + label: Logs + description: "Relevant logs from `nohup.out` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys." + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature.yml b/.github/ISSUE_TEMPLATE/2.feature.yml index bbf0888a..4bf3f9d1 100644 --- a/.github/ISSUE_TEMPLATE/2.feature.yml +++ b/.github/ISSUE_TEMPLATE/2.feature.yml @@ -1,28 +1,33 @@ name: Feature request 🚀 -description: 提出你对项目的新想法或建议。 +description: Suggest a new idea or improvement. +title: "[Feature] " labels: ['status: needs check'] body: - type: markdown attributes: value: | - 请在上方的`title`中填写简略总结,谢谢❤️。 + > 💡 English is recommended so global developers can help. 【中文用户】推荐使用英文提交,谢谢 ❤️ - type: checkboxes attributes: - label: ⚠️ 搜索是否存在类似issue - description: > - 请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索关键词查找是否存在相似issue。 + label: Self check options: - - label: 我已经搜索过issues和disscussions,没有发现相似issue + - label: I searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate. required: true - type: textarea attributes: - label: 总结 - description: 描述feature的功能。 + label: What problem does this solve? + description: "The use case or pain point — what are you trying to do, and what gets in the way?" + validations: + required: true - type: textarea attributes: - label: 举例 - description: 提供聊天示例,草图或相关网址。 - - type: textarea + label: Proposed solution + description: "What you'd like. Examples, sketches, or links welcome." + validations: + required: false + - type: checkboxes attributes: - label: 动机 - description: 描述你提出该feature的动机,比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。 \ No newline at end of file + label: Contribution + options: + - label: I'd be interested in helping implement this. + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a37a397e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 📖 Documentation + url: https://docs.cowagent.ai + about: Setup guides, configuration, and FAQ. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..74c15bc7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ + + +## What does this PR do? + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Docs +- [ ] Refactor / chore + +## Checklist + +- [ ] I tested this change locally +- [ ] Code comments and docs are in English +- [ ] Linked related issue (if any): closes # diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..46f3fc3e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to CowAgent + +Thanks for taking the time to contribute! 🎉 CowAgent is built by a global +community, and contributions of all sizes are welcome — from typo fixes to new +features. + +## Language policy + +To keep the project accessible to a global community, **please write issues, +pull requests, code comments, and commit messages in English.** + +> 【致中文开发者】为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与 +> commit message。不必担心英文不完美——表达清楚即可,工具翻译也完全没问题。感谢理解 ❤️ + +## Reporting issues + +Found a bug or have an idea? [Open an issue](https://github.com/zhayujie/CowAgent/issues/new/choose). + +Before opening one, please search existing issues (including closed ones) to +avoid duplicates, and make sure you're on the latest version. + +## Submitting a pull request + +1. **Fork** the repo and create a branch from `master` + (e.g. `feat/web-search`, `fix/telegram-reconnect`). +2. Make your change. Keep it focused — one logical change per PR. +3. Follow the existing code style. Write comments and docstrings in English. +4. Run the app locally to confirm your change works. +5. Open a PR with a clear title and a short description of **what** and **why**. + +We keep the bar friendly: clear, focused, and working is enough. Maintainers are +happy to help polish details during review. + +### Commit & PR titles + +Use a short, imperative summary. The [Conventional Commits](https://www.conventionalcommits.org/) +style is preferred but not required: + +``` +feat: add web search tool +fix: reconnect Telegram websocket on timeout +docs: clarify Docker setup +``` + +## Development setup + +See the [Install from Source](https://docs.cowagent.ai/guide/manual-install) +guide. In short: + +```bash +git clone https://github.com/zhayujie/CowAgent.git +cd CowAgent +pip install -r requirements.txt +pip install -e . +cow start +``` + +## Code of conduct + +Be respectful and constructive. We want CowAgent to be a welcoming place for +everyone. From ce0249706e8a74fbcaeb6ad3f637722822a8dc4b Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 1 Jun 2026 11:10:12 +0800 Subject: [PATCH 303/399] docs: update issue/pr templates --- .github/ISSUE_TEMPLATE/1.bug.yml | 4 ++-- .github/ISSUE_TEMPLATE/2.feature.yml | 10 +++++----- CONTRIBUTING.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1.bug.yml b/.github/ISSUE_TEMPLATE/1.bug.yml index 7c0ec054..c3ef17fa 100644 --- a/.github/ISSUE_TEMPLATE/1.bug.yml +++ b/.github/ISSUE_TEMPLATE/1.bug.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - > 💡 English is recommended so global developers can help. 【中文用户】推荐使用英文提交,谢谢 ❤️ + > 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️ - type: checkboxes attributes: label: Self check @@ -40,7 +40,7 @@ body: - type: textarea attributes: label: Logs - description: "Relevant logs from `nohup.out` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys." + description: "Relevant logs from `run.log` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys." render: shell validations: required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature.yml b/.github/ISSUE_TEMPLATE/2.feature.yml index 4bf3f9d1..8b53ff92 100644 --- a/.github/ISSUE_TEMPLATE/2.feature.yml +++ b/.github/ISSUE_TEMPLATE/2.feature.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - > 💡 English is recommended so global developers can help. 【中文用户】推荐使用英文提交,谢谢 ❤️ + > 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️ - type: checkboxes attributes: label: Self check @@ -15,14 +15,14 @@ body: required: true - type: textarea attributes: - label: What problem does this solve? - description: "The use case or pain point — what are you trying to do, and what gets in the way?" + label: What's the problem? + description: "The pain point or what's not working for you right now." validations: required: true - type: textarea attributes: - label: Proposed solution - description: "What you'd like. Examples, sketches, or links welcome." + label: What would you like? + description: "How you'd expect it to work. Examples, sketches, or links welcome." validations: required: false - type: checkboxes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46f3fc3e..4a9d7d8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ features. To keep the project accessible to a global community, **please write issues, pull requests, code comments, and commit messages in English.** -> 【致中文开发者】为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与 +> 为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与 > commit message。不必担心英文不完美——表达清楚即可,工具翻译也完全没问题。感谢理解 ❤️ ## Reporting issues From feaa9076b0d5eb60cab5ada6aec20f09d4d774fb Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 1 Jun 2026 16:02:55 +0800 Subject: [PATCH 304/399] feat: release 2.1.0 --- README.md | 2 + channel/web/web_channel.py | 2 +- channel/wechat_kf/README.md | 2 +- channel/wechat_kf/wechat_kf_channel.py | 2 +- config.py | 14 +++--- docs/channels/index.mdx | 1 + docs/channels/wechat-kf.mdx | 20 ++++---- docs/docs.json | 3 ++ docs/ja/README.md | 2 + docs/ja/channels/index.mdx | 1 + docs/ja/channels/wechat-kf.mdx | 20 ++++---- docs/ja/releases/overview.mdx | 1 + docs/ja/releases/v2.1.0.mdx | 69 ++++++++++++++++++++++++++ docs/releases/overview.mdx | 1 + docs/releases/v2.1.0.mdx | 69 ++++++++++++++++++++++++++ docs/zh/README.md | 2 + docs/zh/channels/index.mdx | 1 + docs/zh/channels/wechat-kf.mdx | 2 +- docs/zh/releases/overview.mdx | 1 + docs/zh/releases/v2.1.0.mdx | 68 +++++++++++++++++++++++++ 20 files changed, 252 insertions(+), 31 deletions(-) create mode 100644 docs/ja/releases/v2.1.0.mdx create mode 100644 docs/releases/v2.1.0.mdx create mode 100644 docs/zh/releases/v2.1.0.mdx diff --git a/README.md b/README.md index 6f91d563..6066f433 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin ## 🏷 Changelog +> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo). + > **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening. > **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu channel overhaul (voice, streaming, QR onboarding), DeepSeek V4 and Baidu Qianfan support, scheduler tool upgrades. diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 88471dfa..b217b56e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2934,7 +2934,7 @@ class ChannelsHandler: ], }), ("wechat_kf", { - "label": {"zh": "微信客服", "en": "WeCom Customer Service"}, + "label": {"zh": "微信客服", "en": "WeChat Customer Service"}, "icon": "fa-headset", "color": "emerald", "fields": [ diff --git a/channel/wechat_kf/README.md b/channel/wechat_kf/README.md index c7352f70..ab139f48 100644 --- a/channel/wechat_kf/README.md +++ b/channel/wechat_kf/README.md @@ -1,4 +1,4 @@ -# 微信客服(WeCom Customer Service)通道 +# 微信客服(WeChat Customer Service)通道 > 与 `channel/wechatcom/`(企微自建应用)是两个**独立的 CoW 通道**: > diff --git a/channel/wechat_kf/wechat_kf_channel.py b/channel/wechat_kf/wechat_kf_channel.py index 3a21f595..797d6475 100644 --- a/channel/wechat_kf/wechat_kf_channel.py +++ b/channel/wechat_kf/wechat_kf_channel.py @@ -1,6 +1,6 @@ # -*- coding=utf-8 -*- """ -WeCom Customer Service (微信客服) channel for CoW. +WeChat Customer Service (微信客服) channel for CoW. Differences from `channel/wechatcom/` (企微自建应用): 1. Audience: external WeChat users (not internal members). diff --git a/config.py b/config.py index ba7936ca..da1935d5 100644 --- a/config.py +++ b/config.py @@ -158,13 +158,13 @@ available_setting = { "wechatcomapp_secret": "", # WeCom app secret "wechatcomapp_agent_id": "", # WeCom app agent_id "wechatcomapp_aes_key": "", # WeCom app aes_key - # WeCom Customer Service (wechat_kf) config - "wechat_kf_corp_id": "", # corp_id of the company the WeCom Customer Service belongs to - "wechat_kf_token": "", # WeCom Customer Service callback token - "wechat_kf_port": 9888, # WeCom Customer Service callback service port - "wechat_kf_secret": "", # WeCom Customer Service app secret - "wechat_kf_aes_key": "", # WeCom Customer Service callback aes_key - "wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # path for persisting the WeCom Customer Service sync_msg cursor + # WeChat Customer Service (wechat_kf) config + "wechat_kf_corp_id": "", # corp_id of the company the WeChat Customer Service belongs to + "wechat_kf_token": "", # WeChat Customer Service callback token + "wechat_kf_port": 9888, # WeChat Customer Service callback service port + "wechat_kf_secret": "", # WeChat Customer Service app secret + "wechat_kf_aes_key": "", # WeChat Customer Service callback aes_key + "wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # path for persisting the WeChat Customer Service sync_msg cursor # Feishu config "feishu_port": 80, # Feishu bot listening port; only needed in webhook mode "feishu_app_id": "", # Feishu bot app id diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 70bec878..1b0fbba4 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -19,6 +19,7 @@ The table below summarizes the inbound message types, bot reply types, and group | [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom App](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [Official Account](/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [WeChat Customer Service](/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | | [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ | | [Discord](/channels/discord) | ✅ | ✅ | ✅ | | ✅ | diff --git a/docs/channels/wechat-kf.mdx b/docs/channels/wechat-kf.mdx index f0711d51..bb1ac16c 100644 --- a/docs/channels/wechat-kf.mdx +++ b/docs/channels/wechat-kf.mdx @@ -1,12 +1,12 @@ --- -title: WeCom Customer Service -description: Integrate CowAgent into WeCom Customer Service (微信客服) +title: WeChat Customer Service +description: Integrate CowAgent into WeChat Customer Service --- -By binding a WeCom custom enterprise app to a WeCom Customer Service (微信客服) account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores. +By binding a WeCom custom enterprise app to a WeChat Customer Service account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores. - WeCom Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported. + WeChat Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported. ## 1. Prerequisites @@ -15,7 +15,7 @@ Required resources: 1. A server with a public IP 2. A registered and verified WeCom account -3. WeCom Customer Service capability enabled +3. WeChat Customer Service capability enabled It is recommended to create a **dedicated** WeCom custom app for Customer Service rather than reusing the existing `wechatcom_app` one — otherwise the two channels will compete for the same callback URL. @@ -49,7 +49,7 @@ Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token - Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeCom Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect. + Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeChat Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect. @@ -92,9 +92,9 @@ Then go back to **Receive Messages → Set API Reception** in the WeCom console 3. Verified WeCom accounts must use a filed domain matching the entity -## 4. Bind a WeCom Customer Service Account +## 4. Bind a WeChat Customer Service Account -In the WeCom Admin Console, go to **WeCom Customer Service**, create a customer service account, and bind it to the custom app you created above: +In the WeCom Admin Console, go to **WeChat Customer Service**, create a customer service account, and bind it to the custom app you created above: @@ -102,7 +102,7 @@ In the WeCom Admin Console, go to **WeCom Customer Service**, create a customer -After binding, go to **WeCom Customer Service → Account Details**, and under **"Access Link"**: +After binding, go to **WeChat Customer Service → Account Details**, and under **"Access Link"**: - Click **"Copy Link"** to get an access link like `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` - Click **"Generate QR Code"** to get the corresponding QR code @@ -117,7 +117,7 @@ After WeChat users enter the customer service conversation via the link or QR co -Beyond that, leveraging the official WeChat ecosystem, WeCom Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeCom Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details: +Beyond that, leveraging the official WeChat ecosystem, WeChat Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeChat Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details: diff --git a/docs/docs.json b/docs/docs.json index 593142cc..460727a7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -240,6 +240,7 @@ "group": "Release Notes", "pages": [ "releases/overview", + "releases/v2.1.0", "releases/v2.0.9", "releases/v2.0.8", "releases/v2.0.7", @@ -453,6 +454,7 @@ "group": "发布记录", "pages": [ "zh/releases/overview", + "zh/releases/v2.1.0", "zh/releases/v2.0.9", "zh/releases/v2.0.8", "zh/releases/v2.0.7", @@ -666,6 +668,7 @@ "group": "リリースノート", "pages": [ "ja/releases/overview", + "ja/releases/v2.1.0", "ja/releases/v2.0.9", "ja/releases/v2.0.8", "ja/releases/v2.0.7", diff --git a/docs/ja/README.md b/docs/ja/README.md index 2c8bbb48..9f607bcd 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -190,6 +190,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 ## 🏷 更新履歴 +> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国際化対応、新チャネル(Telegram、Discord、Slack、WeChat カスタマーサービス)、CLI インタラクション強化、ワンライナーインストールの最適化、MCP Streamable HTTP 対応、新モデル(claude-opus-4-8、MiMo)。 + > **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — モデル管理、MCP プロトコル対応、ブラウザセッション永続化、新モデル(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、デプロイのセキュリティ強化。 > **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu チャネル全面アップグレード(音声、ストリーミング、QR 接続)、DeepSeek V4 と Baidu Qianfan 対応、スケジューラツール強化。 diff --git a/docs/ja/channels/index.mdx b/docs/ja/channels/index.mdx index fe300645..c86d198d 100644 --- a/docs/ja/channels/index.mdx +++ b/docs/ja/channels/index.mdx @@ -22,6 +22,7 @@ CowAgent は複数のチャットチャネルへの接続に対応しており | [QQ](/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [WeChat カスタマーサービス](/ja/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | - **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください - **グループチャット**列はグループメッセージを認識して応答できることを示します diff --git a/docs/ja/channels/wechat-kf.mdx b/docs/ja/channels/wechat-kf.mdx index 078f1b5a..e48fa716 100644 --- a/docs/ja/channels/wechat-kf.mdx +++ b/docs/ja/channels/wechat-kf.mdx @@ -1,12 +1,12 @@ --- -title: WeChat カスタマーサービス -description: CowAgent を 微信客服(WeCom Customer Service)に統合する +title: WeChat Customer Service +description: CowAgent を WeChat Customer Service に統合する --- -WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカウントにバインドすることで、CowAgent は外部 WeChat ユーザーからの問い合わせを引き受けることができます。WeChat ミニプログラム、公式アカウント、ビデオチャンネル、ビデオチャンネルストアなどから、リンクや QR コードで WeChat ユーザーに到達できます。 +WeCom の自建アプリを WeChat Customer Service アカウントにバインドすることで、CowAgent は外部 WeChat ユーザーからの問い合わせを引き受けることができます。WeChat ミニプログラム、公式アカウント、ビデオチャンネル、ビデオチャンネルストアなどから、リンクや QR コードで WeChat ユーザーに到達できます。 - WeChat カスタマーサービスは Docker デプロイまたはサーバー Python デプロイのみサポートしており、外部からアクセス可能なコールバック URL が必要です。ローカル実行モードには対応していません。 + WeChat Customer Service は Docker デプロイまたはサーバー Python デプロイのみサポートしており、外部からアクセス可能なコールバック URL が必要です。ローカル実行モードには対応していません。 ## 1. 前提条件 @@ -15,7 +15,7 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ 1. パブリック IP を持つサーバー 2. 登録済みかつ認証済みの WeCom アカウント -3. 「微信客服」機能が有効になっていること +3. WeChat Customer Service 機能が有効になっていること カスタマーサービス専用に **新たな** 企業微信自建アプリを作成することを推奨します。既存の `wechatcom_app` アプリを流用すると、2 つのチャネルが同じコールバック URL を奪い合うことになります。 @@ -49,7 +49,7 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ - Cow プロジェクトを起動した後、Web コンソールを開きます。**チャネル** メニューを選択し、**接入チャネル** をクリックし、**微信客服** を選択して、Corp ID / Secret / Token / AES Key を入力し(ポートはデフォルト 9888、変更可能)、接入をクリックします。 + Cow プロジェクトを起動した後、Web コンソールを開きます。**チャネル** メニューを選択し、**接入チャネル** をクリックし、**WeChat Customer Service** を選択して、Corp ID / Secret / Token / AES Key を入力し(ポートはデフォルト 9888、変更可能)、接入をクリックします。 @@ -92,9 +92,9 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ 3. 認証済みの WeCom アカウントは、法人に対応する届け出済みドメインを設定する必要があります -## 4. 微信客服アカウントとのバインド +## 4. WeChat Customer Service アカウントとのバインド -WeCom 管理コンソールの **微信客服** ページに入り、カスタマーサービスアカウントを作成し、上で作成した企業微信自建アプリとバインドします: +WeCom 管理コンソールの **WeChat Customer Service** ページに入り、カスタマーサービスアカウントを作成し、上で作成した WeCom 自建アプリとバインドします: @@ -102,7 +102,7 @@ WeCom 管理コンソールの **微信客服** ページに入り、カスタ -バインド完了後、**微信客服 → 微信客服アカウント詳細** に入り、「**接入リンク**」の項目で: +バインド完了後、**WeChat Customer Service → WeChat Customer Service アカウント詳細** に入り、「**接入リンク**」の項目で: - 「**リンクをコピー**」をクリックすると、`https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` のような接入リンクが取得できます - 「**QR コード生成**」をクリックすると、対応する QR コードが取得できます @@ -117,7 +117,7 @@ WeChat ユーザーがリンクや QR コードからカスタマーサービス -これに加え、WeChat 公式エコシステムの機能に基づき、微信客服を公式アカウント、ミニプログラム、ビデオチャンネルなどの場面でも使用できます。詳細は [WeCom 管理コンソール](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) の **微信客服 → 接入シナリオ** を参照してください: +これに加え、WeChat 公式エコシステムの機能に基づき、WeChat Customer Service を公式アカウント、ミニプログラム、ビデオチャンネルなどの場面でも使用できます。詳細は [WeCom 管理コンソール](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) の **WeChat Customer Service → 接入シナリオ** を参照してください: diff --git a/docs/ja/releases/overview.mdx b/docs/ja/releases/overview.mdx index cc51eeb4..9f0d80e4 100644 --- a/docs/ja/releases/overview.mdx +++ b/docs/ja/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴 | バージョン | 日付 | 説明 | | --- | --- | --- | +| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化(ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル)、MCP Streamable HTTP、新モデル追加 | | [2.0.9](/ja/releases/v2.0.9) | 2026.05.22 | モデル管理機能の追加、MCP プロトコル対応、ブラウザログイン状態の永続化、新モデル追加(gpt-5.5、gemini-3.5-flash、qwen3.7-max など)、デプロイ・セキュリティ強化 | | [2.0.8](/ja/releases/v2.0.8) | 2026.05.06 | Feishu チャネル全面アップグレード(音声、ストリーミング出力と Markdown、QR コードによるワンクリック接続)、DeepSeek V4 と百度モデルの追加、スケジュールタスクツールの強化 | | [2.0.7](/ja/releases/v2.0.7) | 2026.04.22 | 画像生成スキル(6 プロバイダー自動ルーティング)、新モデル対応(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、ナレッジベース強化、Web コンソール最適化 | diff --git a/docs/ja/releases/v2.1.0.mdx b/docs/ja/releases/v2.1.0.mdx new file mode 100644 index 00000000..db9b26f4 --- /dev/null +++ b/docs/ja/releases/v2.1.0.mdx @@ -0,0 +1,69 @@ +--- +title: v2.1.0 +description: CowAgent 2.1.0 - 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化、MCP プロトコル強化、新モデル追加 +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.0) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.0) + +## 📱 接続チャネルの追加 + +今回、主要プラットフォームのチャネルを複数追加。設定すればすぐ使える、すぐ接続可能です: + +- **Telegram Bot**:Telegram ボットに接続、テキストとマルチメディアメッセージに対応 +- **Discord Bot**:Discord ボットに接続、チャンネルおよび DM で対話可能 +- **Slack Bot**:Slack ボットに接続、チームのワークフローに統合 +- **WeChat カスタマーサービス**:WeChat カスタマーサービスチャネルを新設。画像・ファイルの受信に対応し、自動で次のターンに統合。マルチメディアのコンテキスト体験が他チャネルと同等に。Thanks [@6vision](https://github.com/6vision) (#2840) + +ドキュメント:[チャネル概要](https://docs.cowagent.ai/ja/channels) + +## 🌍 国際化対応 + +CowAgent は全世界の開発者に向けたエンドツーエンドの国際化フレームワークを導入。システム言語に応じて自動で適応します: + +- **エンドツーエンドのローカライズ**:インストールガイド、CLI、ログとエラー、Agent システムプロンプトなどがローカライズに対応 +- **言語の自動判定**:デフォルトの `auto` モードはシステムロケールから判定。`config.json` の `cow_lang` で明示的に指定も可能。まずは英語と中国語に対応し、今後さらに多くの言語を拡充予定 +- **コンソールでワンクリック切り替え**:Web コンソールでシステム言語をオンライン切り替え可能、リアルタイムで反映 + +## ⌨️ CLI インタラクション強化 + +- **ワンライナーインストールの最適化**:インストールスクリプトを簡素化し、対話的なセットアップに対応——言語を選択でき、ガイドに沿ってモデルとチャンネルも任意で選択可能。数分でデプロイして利用開始できます +- **ストリーミング出力**:Terminal チャネルが Agent モードの推論過程、ツール呼び出し、ストリーミング返信をリアルタイムに表示 +- **コマンドのあいまいマッチング**:コマンドの省略形や近似タイポの自動サジェストに対応。よく使うショートカットを内蔵し、設定ファイルでカスタムエイリアスも定義可能。Thanks [@lyteen](https://github.com/lyteen) (#2850) +- **タスクのキャンセル対応**:実行中の Agent タスクを能動的に中断可能。Web 端に中止ボタンを追加、その他のチャネルでは `/cancel` を送信して中止 + +ドキュメント:[CLI ガイド](https://docs.cowagent.ai/ja/cli/general) + +## 🧩 MCP プロトコル強化 + +MCP ツールが **Streamable HTTP** 伝送に新対応。既存の `stdio`・`sse` に加え、より多くの MCP サービスに互換となり、ストリーミング HTTP プロトコルを用いるリモートツールへ直接接続できます。 + +ドキュメント:[MCP ツール](https://docs.cowagent.ai/ja/tools/mcp) + +## 🤖 モデル追加と最適化 + +- **モデル新規追加**:`claude-opus-4-8`、Xiaomi `MiMo` +- **モデル最適化**:一部モデルが返すツール呼び出し引数の JSON 解析に失敗する問題を修正 (#2823) + +ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models) + +## 🧠 記憶と検索の最適化 + +- **キーワード検索の最適化**:中国語キーワード検索のヒット率が低い問題、純英語キーワードでクエリ結果が空になる問題を修正 +- **ベクトル検索の強化**:ベクトル検索フローを最適化し、Python バージョン互換性を向上 + +Thanks [@yangluxin613](https://github.com/yangluxin613) (#2832) + +## 🛠 体験改善と修正 + +- **ファイルアクセスの限定**:Web 端のファイル読み取り・送信をデフォルトでユーザーホームディレクトリと Agent ワークスペース内に限定し、任意ファイル読み取りを防止。`web_file_serve_root` で範囲を拡張可能 +- **スケジュールタスクの安定化**:個人 WeChat チャネルで再起動後にスケジュールタスクの配信が失効する問題を修正 +- **ブラウザツール**:ナビゲーション URL の非 HTTP スキームが破棄される問題を修正、ブラウザのメモリ使用量を最適化 +- **WeChat 公式アカウント**:パッシブ返信でキャッシュ済みテキストセグメントの結合、タスク実行中の準備完了セグメントの先行配信、ローカル `file://` 画像の送信に対応。Thanks [@6vision](https://github.com/6vision) (#2848) +- **WeCom ボットの応答高速化**:コールバックを非同期ディスパッチに変更し、WeCom の 5 秒タイムアウトによるメッセージ欠落を回避 +- **ログインの堅牢化**:`web_password` が文字列でない場合にログインエラーになる問題を修正 + +## 📦 アップグレード方法 + +ソースコードデプロイは `cow update` でワンクリックアップグレード、または最新コードを手動で pull して再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照。 + +**リリース日**:2026.06.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.9...2.1.0) diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx index aa68112c..c47237ed 100644 --- a/docs/releases/overview.mdx +++ b/docs/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent version history | Version | Date | Description | | --- | --- | --- | +| [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models | | [2.0.9](/releases/v2.0.9) | 2026.05.22 | Model management console, MCP protocol support, browser persistent login, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max, etc.), deployment hardening | | [2.0.8](/releases/v2.0.8) | 2026.05.06 | Major Feishu channel upgrade (voice, streaming and Markdown, one-click QR-scan setup), DeepSeek V4 and Baidu models, scheduler tool enhancements | | [2.0.7](/releases/v2.0.7) | 2026.04.22 | Image Generation Skill (6-provider auto-routing), new models (Kimi K2.6, Claude Opus 4.7, GLM 5.1), knowledge base and Web Console improvements | diff --git a/docs/releases/v2.1.0.mdx b/docs/releases/v2.1.0.mdx new file mode 100644 index 00000000..e3aefe29 --- /dev/null +++ b/docs/releases/v2.1.0.mdx @@ -0,0 +1,69 @@ +--- +title: v2.1.0 +description: CowAgent 2.1.0 - Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades, MCP protocol enhancements and new models +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.0) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.0) + +## 📱 New Channels + +This release adds several mainstream platform channels — configure and go, ready out of the box: + +- **Telegram Bot**: Connect a Telegram bot with support for text and multimedia messages +- **Discord Bot**: Connect a Discord bot to chat in channels and direct messages +- **Slack Bot**: Connect a Slack bot and bring CowAgent into your team's workflow +- **WeChat Customer Service**: New WeChat Customer Service channel that receives images and files and automatically merges them into the next turn, bringing its multimedia context experience in line with other channels. Thanks [@6vision](https://github.com/6vision) (#2840) + +Documentation: [Channels Overview](https://docs.cowagent.ai/en/channels) + +## 🌍 Internationalization + +CowAgent introduces an end-to-end internationalization framework built for developers worldwide, adapting automatically based on the system language: + +- **End-to-end localization**: The install flow, CLI, logs and error messages, agent system prompts and more are all localized +- **Automatic language detection**: The default `auto` mode infers the language from the system locale, or you can set `cow_lang` explicitly in `config.json`. English and Chinese ship first, with more languages to follow +- **One-click switch in the console**: The Web Console supports switching the system language online, taking effect in real time + +## ⌨️ CLI Interaction Upgrades + +- **Streamlined one-line install**: The install script is simplified with an interactive setup — pick your language, and optionally choose a model and channel right from the prompts, getting you up and running in minutes +- **Streaming output**: The Terminal channel now renders the agent's reasoning, tool calls and streaming replies in real time +- **Fuzzy command matching**: Supports command abbreviations and near-miss typo suggestions, ships with built-in shortcuts, and lets you define custom aliases in the config file. Thanks [@lyteen](https://github.com/lyteen) (#2850) +- **Task cancellation**: In-flight agent runs can be interrupted on demand — the Web Console adds a stop button, and other channels can send `/cancel` to abort + +Documentation: [CLI Guide](https://docs.cowagent.ai/en/cli/general) + +## 🧩 MCP Protocol Enhancements + +MCP tools now support the **Streamable HTTP** transport. On top of the existing `stdio` and `sse` options, this makes more MCP services compatible, letting you connect directly to remote tools that use the streamable HTTP protocol. + +Documentation: [MCP Tools](https://docs.cowagent.ai/en/tools/mcp) + +## 🤖 New Models & Improvements + +- **New models**: `claude-opus-4-8`, Xiaomi `MiMo` +- **Improvements**: Fixed JSON parsing failures for tool-call arguments returned by some models (#2823) + +Documentation: [Models Overview](https://docs.cowagent.ai/en/models) + +## 🧠 Memory & Retrieval Improvements + +- **Keyword search**: Fixed low hit rates for Chinese keyword search and empty results for pure-English keyword queries +- **Vector retrieval**: Optimized the vector retrieval flow and improved Python version compatibility + +Thanks [@yangluxin613](https://github.com/yangluxin613) (#2832) + +## 🛠 UX Improvements & Fixes + +- **Confined file access**: Web file reads and sends are now limited to the user home directory and agent workspace by default to prevent arbitrary file reads; the scope can be widened via `web_file_serve_root` +- **More stable scheduled tasks**: Fixed scheduled-task pushes failing after a restart on the Personal WeChat channel +- **Browser tool**: Fixed non-HTTP schemes being dropped from navigation URLs; reduced browser memory usage +- **WeChat Official Account**: Passive replies now merge cached text segments, flush ready segments while a task is still running, and support sending local `file://` images. Thanks [@6vision](https://github.com/6vision) (#2848) +- **Faster WeCom bot responses**: Callbacks are now dispatched asynchronously to avoid message loss from WeCom's 5-second timeout +- **More robust login**: Fixed a login error when `web_password` was not a string + +## 📦 Upgrade + +Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade) for details. + +**Release Date**: 2026.06.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.9...2.1.0) diff --git a/docs/zh/README.md b/docs/zh/README.md index d37f09ce..0765adf8 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -191,6 +191,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 ## 🏷 更新日志 +> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国际化支持、新增通道(Telegram、Discord、Slack、微信客服)、命令行交互升级、一键安装脚本优化、MCP Streamable HTTP 支持、新模型接入(claude-opus-4-8、MiMo) + > **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — 模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、部署安全加固 > **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — 飞书渠道全面升级(语音、流式输出、扫码接入)、新模型支持(DeepSeek V4、百度千帆)、定时任务工具增强 diff --git a/docs/zh/channels/index.mdx b/docs/zh/channels/index.mdx index bcd28350..55ad07ae 100644 --- a/docs/zh/channels/index.mdx +++ b/docs/zh/channels/index.mdx @@ -19,6 +19,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换 | [QQ](/zh/channels/qq) | ✅ | ✅ | ✅ | | ✅ | | [企业微信应用](/zh/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | | [公众号](/zh/channels/wechatmp) | ✅ | ✅ | | ✅ | | +| [微信客服](/zh/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | | [Telegram](/zh/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | | [Slack](/zh/channels/slack) | ✅ | ✅ | ✅ | | ✅ | | [Discord](/zh/channels/discord) | ✅ | ✅ | ✅ | | ✅ | diff --git a/docs/zh/channels/wechat-kf.mdx b/docs/zh/channels/wechat-kf.mdx index ca83aaed..1967d99f 100644 --- a/docs/zh/channels/wechat-kf.mdx +++ b/docs/zh/channels/wechat-kf.mdx @@ -1,6 +1,6 @@ --- title: 微信客服 -description: 将 CowAgent 接入微信客服(WeCom Customer Service) +description: 将 CowAgent 接入微信客服(WeChat Customer Service) --- 通过把企业微信自建应用绑定到「微信客服」账号,CowAgent 可以接管来自外部微信用户的客服咨询,并可在小程序、公众号、视频号及视频号小店等场景中通过链接或二维码触达微信用户。 diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx index 9a426982..35409fee 100644 --- a/docs/zh/releases/overview.mdx +++ b/docs/zh/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent 版本更新历史 | 版本 | 日期 | 说明 | | --- | --- | --- | +| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 | | [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | | [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | | [2.0.7](/zh/releases/v2.0.7) | 2026.04.22 | 图像生成技能(六厂商自动路由)、新模型支持(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、知识库增强、Web 控制台优化 | diff --git a/docs/zh/releases/v2.1.0.mdx b/docs/zh/releases/v2.1.0.mdx new file mode 100644 index 00000000..2df19c00 --- /dev/null +++ b/docs/zh/releases/v2.1.0.mdx @@ -0,0 +1,68 @@ +--- +title: v2.1.0 +description: CowAgent 2.1.0 - 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级、MCP协议增强、新模型接入 +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.0) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.0) + +## 📱 新增接入通道 + +本次新增多个主流平台通道,配置即用,开箱接入: + +- **Telegram Bot**:接入 Telegram 机器人,支持文本与多媒体消息 +- **Discord Bot**:接入 Discord 机器人,可在频道与私信中对话 +- **Slack Bot**:接入 Slack 机器人,融入团队协作场景 +- **微信客服**:新增微信客服通道,支持接收图片与文件并自动并入下一轮对话,多媒体上下文体验对齐其它通道。Thanks @6vision (#2840) + +相关文档:[通道概览](https://docs.cowagent.ai/zh/channels) + +## 🌍 国际化支持 + +CowAgent 引入全链路国际化框架,面向全球开发者,支持根据系统语言自动适配: + +- **全链路本地化**:安装引导、命令行、日志和报错、Agent 系统提示词等均已支持本地化语言适配 +- **自动识别语言**:默认 `auto` 模式按系统语言自动判断,也可在 `config.json` 中通过 `cow_lang` 显式配置。首批提供英文与中文,后续将持续扩充更多语言。 +- **控制台一键切换**:Web 控制台支持在线切换系统语言,实时生效 +## ⌨️ 命令行交互升级 + +- **一键安装脚本优化**:安装脚本简化流程并支持交互式配置——可选择语言,并在引导中按需选择模型与通道,几分钟即可完成部署 +- **流式输出体验**:Terminal 通道支持 Agent 模式的推理过程、工具调用与流式回复 +- **命令模糊匹配**:支持命令缩写及近似拼写自动提示,内置常用快捷指令,并可在配置文件中自定义别名。Thanks @lyteen (#2850) +- **支持任务取消**:Agent 任务执行中可主动中断,Web 端新增中止按钮,其他通道可发送 `/cancel` 进行中止 + +相关文档:[命令行指南](https://docs.cowagent.ai/zh/cli/general) + +## 🧩 MCP 协议增强 + +MCP 工具新增对 **Streamable HTTP** 传输的支持,在原有 `stdio` 和 `sse` 基础上进一步兼容更多 MCP 服务,可直接接入采用流式 HTTP 协议的远程工具。 + +相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp) + +## 🤖 模型新增与优化 + +- **模型新增**:`claude-opus-4-8`、小米 `MiMo` +- **模型优化**:修复部分模型的工具调用参数 JSON 解析失败问题 (#2823) + +相关文档:[模型概览](https://docs.cowagent.ai/zh/models) + +## 🧠 记忆与检索优化 + +- **关键词检索优化**:修复中文关键词搜索命中率低、纯英文关键词出现查询为空的问题 +- **向量检索增强**:优化向量检索流程,并提升 Python 版本兼容性 + +Thanks @yangluxin613 (#2832) + +## 🛠 体验优化与修复 + +- **文件访问收敛**:Web 端文件读取与发送默认限制在用户主目录及 Agent 工作空间内,防止任意文件读取,可通过 `web_file_serve_root` 配置放开范围 +- **定时任务更稳定**:修复定时任务推送在个人微信通道重启后失效的问题 +- **浏览器工具**:修复导航 URL 中非 http 协议被丢弃的问题;优化浏览器内存占用 +- **微信公众号**:被动回复支持合并缓存文本片段、任务执行中先行下发已就绪片段、支持发送本地 `file://` 图片。Thanks @6vision (#2848) +- **企微机器人响应提速**:回调改为异步分发,避免企微 5 秒超时导致的消息丢失 +- **登录鲁棒性**:修复 `web_password` 为非字符串时登录报错的问题 + +## 📦 升级方式 + +源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。 + +**发布日期**:2026.06.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.9...2.1.0) From 285373547249f7f1dd717cf1141a15686384399d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 1 Jun 2026 16:46:16 +0800 Subject: [PATCH 305/399] docs: update README.md --- README.md | 4 ++-- docs/ja/README.md | 4 ++-- docs/zh/README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6066f433..ad7823d4 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,9 @@ For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https:/ ## 🛠️ Development & Contributing -Contributions are welcome — add a new channel by following the [Feishu channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit). +Contributions are welcome — add a new channel by following the [Telegram channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/telegram/telegram_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit). -⭐ Star the project to follow updates, and feel free to open PRs and Issues. +⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome. ## 🌟 Contributors diff --git a/docs/ja/README.md b/docs/ja/README.md index 9f607bcd..9c99bf7a 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -238,9 +238,9 @@ GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) する ## 🛠️ 開発とコントリビューション -新しいチャネルの追加を歓迎します — [Feishu チャネル](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py) を参考にカスタムチャネルを実装できます。新しい Skill のコントリビューションも [Skill Hub](https://skills.cowagent.ai/submit) で受け付けています。 +新しいチャネルの追加を歓迎します — [Telegram チャネル](https://github.com/zhayujie/CowAgent/blob/master/channel/telegram/telegram_channel.py) を参考にカスタムチャネルを実装できます。新しい Skill のコントリビューションも [Skill Hub](https://skills.cowagent.ai/submit) で受け付けています。 -⭐ Star でプロジェクトの更新をフォローしてください。PR や Issue の提出も歓迎します。 +⭐ Star でプロジェクトを応援し、Watch → Custom → Releases で新バージョンの通知を受け取れます。PR や Issue の提出も歓迎します。 ## 🌟 コントリビューター diff --git a/docs/zh/README.md b/docs/zh/README.md index 0765adf8..b2a2877b 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -252,7 +252,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 欢迎接入更多应用通道,参考 [飞书通道实现](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道;同时欢迎贡献新技能,向 [Skill Hub](https://skills.cowagent.ai/submit) 提交。 -通过 ⭐ Star 关注项目更新,欢迎提交 PR、Issue 进行反馈。 +欢迎 ⭐ Star 支持项目,并通过 Watch → Custom → Releases 订阅新版本通知。也欢迎提交 PR、Issue 进行反馈。 ## 🌟 贡献者 From 1f6859d78f83b69111a4c537d4381d454f48d6ce Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 1 Jun 2026 16:59:19 +0800 Subject: [PATCH 306/399] feat: update CLI version to 2.1.0 --- cli/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/VERSION b/cli/VERSION index 09843e3b..7ec1d6db 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.0.9 +2.1.0 From 7fa743f01ab350c0b21ada2f069825c37b8f8b65 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Mon, 1 Jun 2026 21:30:38 +0800 Subject: [PATCH 307/399] feat(minimax): add MiniMax-M3, set as default, drop M2.5/M2.1/M2 - Add MINIMAX_M3 = "MiniMax-M3" constant and put it first in MODEL_LIST - Default MinimaxBot model: MiniMax-M2.7 -> MiniMax-M3 - Keep MiniMax-M2.7 and MiniMax-M2.7-highspeed as legacy options - Drop MINIMAX_M2_5 / MINIMAX_M2_1 / MINIMAX_M2_1_LIGHTNING / MINIMAX_M2 - Update web console recommended/provider model lists - Update README capability table and docs/models index (en/zh/ja) - Update docs/models/minimax.mdx and coding-plan.mdx MiniMax section - Update run.sh / run.ps1 installer default and menu hint - Update zh CLI status sample output - Update unit tests to assert new M3 default and constant TTS (speech-2.*) and API base URL remain unchanged. --- README.md | 2 +- channel/web/web_channel.py | 4 ++-- common/const.py | 11 ++++------- docs/ja/README.md | 2 +- docs/ja/models/coding-plan.mdx | 4 ++-- docs/ja/models/index.mdx | 4 ++-- docs/ja/models/minimax.mdx | 4 ++-- docs/models/coding-plan.mdx | 4 ++-- docs/models/index.mdx | 2 +- docs/models/minimax.mdx | 4 ++-- docs/zh/README.md | 2 +- docs/zh/cli/general.mdx | 2 +- docs/zh/cli/process.mdx | 2 +- docs/zh/models/coding-plan.mdx | 4 ++-- docs/zh/models/index.mdx | 2 +- docs/zh/models/minimax.mdx | 4 ++-- models/minimax/minimax_bot.py | 2 +- run.sh | 4 ++-- scripts/run.ps1 | 4 ++-- tests/test_minimax_provider.py | 27 ++++++++++++++++++--------- 20 files changed, 50 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ad7823d4..0310d5d6 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation | [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | | [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | -| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | | [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | | [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index b217b56e..bfc038cb 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1404,7 +1404,7 @@ class ConfigHandler: _RECOMMENDED_MODELS = [ const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER, - const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, + const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, @@ -1442,7 +1442,7 @@ class ConfigHandler: "api_base_key": None, "api_base_default": None, "api_base_placeholder": "", - "models": [const.MINIMAX_M2_7, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING], + "models": [const.MINIMAX_M3, const.MINIMAX_M2_7, const.MINIMAX_M2_7_HIGHSPEED], }), ("claudeAPI", { "label": "Claude", diff --git a/common/const.py b/common/const.py index ba4edd17..fa56d273 100644 --- a/common/const.py +++ b/common/const.py @@ -112,13 +112,10 @@ QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型 QWQ_PLUS = "qwq-plus" # MiniMax -MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest -MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision) +MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default) +MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 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 极速版 -MINIMAX_M2 = "MiniMax-M2" # MiniMax M2 +MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision) MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5 # GLM (智谱AI) @@ -189,7 +186,7 @@ MODEL_LIST = [ ERNIE_45_TURBO_VL, ERNIE_45_TURBO_VL_32K, # MiniMax - MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5, + MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_ABAB6_5, # 小米 MiMo MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, diff --git a/docs/ja/README.md b/docs/ja/README.md index 9c99bf7a..5f848a15 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -102,7 +102,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | | [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | -| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | | [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | | [LinkAI](https://docs.cowagent.ai/ja/models/linkai) | 1 つの Key で 100+ モデルに接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/ja/models/coding-plan.mdx b/docs/ja/models/coding-plan.mdx index 448373d6..24477029 100644 --- a/docs/ja/models/coding-plan.mdx +++ b/docs/ja/models/coding-plan.mdx @@ -61,7 +61,7 @@ description: Coding Planモデルの設定 ```json { "bot_type": "openai", - "model": "MiniMax-M2.5", + "model": "MiniMax-M3", "open_ai_api_base": "https://api.minimaxi.com/v1", "open_ai_api_key": "YOUR_API_KEY" } @@ -69,7 +69,7 @@ description: Coding Planモデルの設定 | パラメータ | 説明 | | --- | --- | -| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` | +| `model` | `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` | | `open_ai_api_base` | 中国: `https://api.minimaxi.com/v1`、グローバル: `https://api.minimax.io/v1` | | `open_ai_api_key` | Coding Plan専用キー(従量課金とは共有不可) | diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 0f3916a3..33e42c89 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。 - Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-5.1。 + Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-5.1。 同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。 @@ -19,7 +19,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ | ベンダー | 代表モデル | テキスト | 画像理解 | 画像生成 | 音声認識 | 音声合成 | ベクトル | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/ja/models/minimax.mdx b/docs/ja/models/minimax.mdx index 66d0024a..411619c2 100644 --- a/docs/ja/models/minimax.mdx +++ b/docs/ja/models/minimax.mdx @@ -13,14 +13,14 @@ MiniMax はテキスト対話、画像理解、画像生成、音声合成をサ ```json { - "model": "MiniMax-M2.7", + "model": "MiniMax-M3", "minimax_api_key": "YOUR_API_KEY" } ``` | パラメータ | 説明 | | --- | --- | -| `model` | `MiniMax-M2.7`、`MiniMax-M2.7-highspeed`、`MiniMax-M2.5`、`MiniMax-M2.1`、`MiniMax-M2.1-lightning`、`MiniMax-M2` などを指定可能 | +| `model` | `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` などを指定可能 | | `minimax_api_key` | [MiniMax コンソール](https://platform.minimaxi.com/user-center/basic-information/interface-key) で作成 | ## 画像理解 diff --git a/docs/models/coding-plan.mdx b/docs/models/coding-plan.mdx index b09715eb..5d523617 100644 --- a/docs/models/coding-plan.mdx +++ b/docs/models/coding-plan.mdx @@ -61,7 +61,7 @@ Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-qui ```json { "bot_type": "openai", - "model": "MiniMax-M2.5", + "model": "MiniMax-M3", "open_ai_api_base": "https://api.minimaxi.com/v1", "open_ai_api_key": "YOUR_API_KEY" } @@ -69,7 +69,7 @@ Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-qui | Parameter | Description | | --- | --- | -| `model` | `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2` | +| `model` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` | | `open_ai_api_base` | China: `https://api.minimaxi.com/v1`; Global: `https://api.minimax.io/v1` | | `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) | diff --git a/docs/models/index.mdx b/docs/models/index.mdx index c3744234..0d065e58 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -12,7 +12,7 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/models/minimax.mdx b/docs/models/minimax.mdx index d945d2ea..8a9c8084 100644 --- a/docs/models/minimax.mdx +++ b/docs/models/minimax.mdx @@ -13,14 +13,14 @@ MiniMax supports text chat, image understanding, image generation, and text-to-s ```json { - "model": "MiniMax-M2.7", + "model": "MiniMax-M3", "minimax_api_key": "YOUR_API_KEY" } ``` | Parameter | Description | | --- | --- | -| `model` | Can be `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2.1-lightning`, `MiniMax-M2`, etc. | +| `model` | Can be `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, etc. | | `minimax_api_key` | Create one in the [MiniMax Console](https://platform.minimaxi.com/user-center/basic-information/interface-key) | ## Image Understanding diff --git a/docs/zh/README.md b/docs/zh/README.md index b2a2877b..b276191a 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -95,7 +95,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/zh/cli/general.mdx b/docs/zh/cli/general.mdx index 36af1783..eb0aea37 100644 --- a/docs/zh/cli/general.mdx +++ b/docs/zh/cli/general.mdx @@ -33,7 +33,7 @@ description: 查看状态、管理配置和上下文等常用命令 Process: PID 12345 | Running 2h 15m Version: 2.0.4 Channel: web -Model: MiniMax-M2.5 +Model: MiniMax-M3 Mode: agent Session: 12 messages | 8 skills loaded diff --git a/docs/zh/cli/process.mdx b/docs/zh/cli/process.mdx index 0fc38539..80b10d6c 100644 --- a/docs/zh/cli/process.mdx +++ b/docs/zh/cli/process.mdx @@ -75,7 +75,7 @@ cow status Status: ● Running (PID: 12345) Version: 2.0.4 Channel: web - Model: MiniMax-M2.5 + Model: MiniMax-M3 Mode: agent ``` diff --git a/docs/zh/models/coding-plan.mdx b/docs/zh/models/coding-plan.mdx index a8341638..6193c34b 100644 --- a/docs/zh/models/coding-plan.mdx +++ b/docs/zh/models/coding-plan.mdx @@ -61,7 +61,7 @@ description: Coding Plan 模式模型配置 ```json { "bot_type": "openai", - "model": "MiniMax-M2.5", + "model": "MiniMax-M3", "open_ai_api_base": "https://api.minimaxi.com/v1", "open_ai_api_key": "YOUR_API_KEY" } @@ -69,7 +69,7 @@ description: Coding Plan 模式模型配置 | 参数 | 说明 | | --- | --- | -| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` | +| `model` | `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` | | `open_ai_api_base` | 国内:`https://api.minimaxi.com/v1`;海外:`https://api.minimax.io/v1` | | `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) | diff --git a/docs/zh/models/index.mdx b/docs/zh/models/index.mdx index 4169cd4a..e0d4a35c 100644 --- a/docs/zh/models/index.mdx +++ b/docs/zh/models/index.mdx @@ -13,7 +13,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 | 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [MiniMax](/zh/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | | +| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [Claude](/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | | [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/zh/models/minimax.mdx b/docs/zh/models/minimax.mdx index 8282f88b..af7913b3 100644 --- a/docs/zh/models/minimax.mdx +++ b/docs/zh/models/minimax.mdx @@ -13,14 +13,14 @@ MiniMax 支持文本对话、图像理解、图像生成与语音合成,一份 ```json { - "model": "MiniMax-M2.7", + "model": "MiniMax-M3", "minimax_api_key": "YOUR_API_KEY" } ``` | 参数 | 说明 | | --- | --- | -| `model` | 可填 `MiniMax-M2.7`、`MiniMax-M2.7-highspeed`、`MiniMax-M2.5`、`MiniMax-M2.1`、`MiniMax-M2.1-lightning`、`MiniMax-M2` 等 | +| `model` | 可填 `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` 等 | | `minimax_api_key` | 在 [MiniMax 控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建 | ## 图像理解 diff --git a/models/minimax/minimax_bot.py b/models/minimax/minimax_bot.py index 612f54dc..c010371d 100644 --- a/models/minimax/minimax_bot.py +++ b/models/minimax/minimax_bot.py @@ -22,7 +22,7 @@ class MinimaxBot(Bot): def __init__(self): super().__init__() self.args = { - "model": conf().get("model") or "MiniMax-M2.7", + "model": conf().get("model") or "MiniMax-M3", "temperature": conf().get("temperature", 0.3), "top_p": conf().get("top_p", 0.95), } diff --git a/run.sh b/run.sh index 21bb5a9f..4672133f 100755 --- a/run.sh +++ b/run.sh @@ -602,7 +602,7 @@ select_model() { "Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)" \ "Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" \ "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" \ - "MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)" \ + "MiniMax (MiniMax-M3, MiniMax-M2.7, etc.)" \ "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)" \ "Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" \ "Doubao (doubao-seed-2-0-code-preview-260215, etc.)" \ @@ -633,7 +633,7 @@ configure_model() { 2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;; 3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;; 4) read_model_config "OpenAI GPT" "gpt-5.4" "OPENAI_KEY" ;; - 5) read_model_config "MiniMax" "MiniMax-M2.7" "MINIMAX_KEY" ;; + 5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;; 6) read_model_config "Zhipu AI" "glm-5.1" "ZHIPU_KEY" ;; 7) read_model_config "Qwen (DashScope)" "qwen3.6-plus" "DASHSCOPE_KEY" ;; 8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;; diff --git a/scripts/run.ps1 b/scripts/run.ps1 index f2171258..e69786a3 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -368,7 +368,7 @@ $ModelChoices = @{ 2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" } 3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" } 4 = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } - 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Field = "minimax_api_key" } + 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" } 6 = @{ Provider = "Zhipu AI"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" } 7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Field = "dashscope_api_key" } 8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Field = "ark_api_key" } @@ -384,7 +384,7 @@ function Select-Model { "Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)", "Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)", "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)", - "MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)", + "MiniMax (MiniMax-M3, MiniMax-M2.7, etc.)", "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)", "Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)", "Doubao (doubao-seed-2-0-code-preview-260215, etc.)", diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py index cfad7fd7..bd4cc572 100644 --- a/tests/test_minimax_provider.py +++ b/tests/test_minimax_provider.py @@ -1,7 +1,7 @@ # encoding:utf-8 """ Unit tests for MiniMax provider additions: - - MiniMax-M2.7-highspeed constant in const.py + - MiniMax-M3 / M2.7 / M2.7-highspeed constants in const.py - Default model update in MinimaxBot - MinimaxVoice TTS provider """ @@ -16,7 +16,12 @@ 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.""" + """Test that MiniMax M3 / M2.7 constants are properly registered in const.py.""" + + def test_m3_constant_defined(self): + from common import const + self.assertTrue(hasattr(const, "MINIMAX_M3")) + self.assertEqual(const.MINIMAX_M3, "MiniMax-M3") def test_m2_7_highspeed_constant_defined(self): from common import const @@ -27,6 +32,10 @@ class TestMinimaxConst(unittest.TestCase): from common import const self.assertEqual(const.MINIMAX_M2_7, "MiniMax-M2.7") + def test_m3_in_model_list(self): + from common import const + self.assertIn("MiniMax-M3", const.MODEL_LIST) + def test_m2_7_highspeed_in_model_list(self): from common import const self.assertIn("MiniMax-M2.7-highspeed", const.MODEL_LIST) @@ -41,9 +50,9 @@ class TestMinimaxConst(unittest.TestCase): class TestMinimaxBotDefaultModel(unittest.TestCase): - """Test that MinimaxBot defaults to MiniMax-M2.7.""" + """Test that MinimaxBot defaults to MiniMax-M3.""" - def test_default_model_is_m2_7(self): + def test_default_model_is_m3(self): # Patch conf() to return empty config mock_conf = MagicMock() mock_conf.get = MagicMock(side_effect=lambda key, default=None: default) @@ -57,18 +66,18 @@ class TestMinimaxBotDefaultModel(unittest.TestCase): 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", + "model": mock_conf.get("model") or "MiniMax-M3", } - self.assertEqual(bot.args["model"], "MiniMax-M2.7") + self.assertEqual(bot.args["model"], "MiniMax-M3") def test_default_model_string(self): - """Verify the fallback string literal in minimax_bot.py is MiniMax-M2.7.""" + """Verify the fallback string literal in minimax_bot.py is MiniMax-M3.""" 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) + # Verify MiniMax-M3 is in the source (not the older default) + self.assertIn("MiniMax-M3", source) self.assertNotIn('"MiniMax-M2.1"', source) From cd88b23b5d8d1a858f5cc890386d5f289ed84aba Mon Sep 17 00:00:00 2001 From: nightwhite Date: Tue, 2 Jun 2026 13:01:20 +0800 Subject: [PATCH 308/399] fix: persist ASR model in models API --- channel/web/web_channel.py | 20 +++++++++++- tests/test_models_handler.py | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/test_models_handler.py diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index bfc038cb..6000d8a1 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2613,7 +2613,7 @@ class ModelsHandler: if capability == "vision": return self._set_vision(provider_id, model) if capability == "asr": - return self._set_simple("voice_to_text", provider_id) + return self._set_asr(provider_id, model) if capability == "tts": return self._set_tts(provider_id, model, (data.get("voice") or "").strip()) if capability == "embedding": @@ -2773,6 +2773,24 @@ class ModelsHandler: self._refresh_voice_routing() return json.dumps({"status": "success", key: value}) + def _set_asr(self, provider_id: str, model: str) -> str: + local_config = conf() + file_cfg = self._read_file_config() + local_config["voice_to_text"] = provider_id + file_cfg["voice_to_text"] = provider_id + local_config["voice_to_text_model"] = model + file_cfg["voice_to_text_model"] = model + self._write_file_config(file_cfg) + logger.info( + f"[ModelsHandler] asr updated: provider={provider_id!r} " + f"model={model!r}" + ) + self._refresh_voice_routing() + return json.dumps({ + "status": "success", + "provider": provider_id, "model": model, + }) + def _set_tts(self, provider_id: str, model: str, voice: str = "") -> str: local_config = conf() file_cfg = self._read_file_config() diff --git a/tests/test_models_handler.py b/tests/test_models_handler.py new file mode 100644 index 00000000..cd36c1c9 --- /dev/null +++ b/tests/test_models_handler.py @@ -0,0 +1,59 @@ +# encoding:utf-8 +import json +import os +import sys +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +if "web" not in sys.modules: + web_stub = types.ModuleType("web") + web_stub.HTTPError = type("HTTPError", (Exception,), {}) + web_stub.cookies = lambda: {} + web_stub.header = lambda *args, **kwargs: None + web_stub.data = lambda: b"{}" + web_stub.input = lambda **kwargs: types.SimpleNamespace(**kwargs) + web_stub.setcookie = lambda *args, **kwargs: None + web_stub.seeother = lambda *args, **kwargs: Exception("seeother") + web_stub.notfound = lambda *args, **kwargs: Exception("notfound") + web_stub.badrequest = lambda *args, **kwargs: Exception("badrequest") + web_stub.application = lambda *args, **kwargs: types.SimpleNamespace(wsgifunc=lambda: None) + web_stub.httpserver = types.SimpleNamespace( + LogMiddleware=type("LogMiddleware", (), {"log": lambda *args, **kwargs: None}), + StaticMiddleware=lambda app: app, + WSGIServer=lambda *args, **kwargs: types.SimpleNamespace(serve_forever=lambda: None), + ) + sys.modules["web"] = web_stub + + +class TestModelsHandler(unittest.TestCase): + def test_set_asr_capability_persists_provider_and_model(self): + from channel.web.web_channel import ModelsHandler + + local_config = {} + file_config = {} + handler = ModelsHandler() + + with patch("channel.web.web_channel.conf", return_value=local_config): + with patch.object(ModelsHandler, "_read_file_config", return_value=file_config): + with patch.object(ModelsHandler, "_write_file_config") as write_file: + with patch.object(ModelsHandler, "_refresh_voice_routing") as refresh_voice: + result = json.loads(handler._handle_set_capability({ + "capability": "asr", + "provider_id": "dashscope", + "model": "qwen3-asr-flash", + })) + + self.assertEqual(result["status"], "success") + self.assertEqual(local_config["voice_to_text"], "dashscope") + self.assertEqual(local_config["voice_to_text_model"], "qwen3-asr-flash") + self.assertEqual(file_config["voice_to_text"], "dashscope") + self.assertEqual(file_config["voice_to_text_model"], "qwen3-asr-flash") + write_file.assert_called_once_with(file_config) + refresh_voice.assert_called_once() + + +if __name__ == "__main__": + unittest.main() From e861d98007e3138d1b69fedbe431bc4b3247f72d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 2 Jun 2026 15:05:35 +0800 Subject: [PATCH 309/399] feat(models): support ASR model selection in web console --- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- README.md | 2 +- channel/web/static/js/console.js | 2 +- channel/web/web_channel.py | 37 +++++++++++++++++++++++++---- docs/ja/README.md | 2 +- docs/zh/README.md | 2 +- tests/test_models_handler.py | 40 ++++++++++++++++++++++++++++++++ 7 files changed, 79 insertions(+), 9 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 74c15bc7..d8a2741d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ ## What does this PR do? @@ -16,6 +16,7 @@ Thanks for your contribution! Please write this PR in English. ## Checklist +- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md) - [ ] I tested this change locally - [ ] Code comments and docs are in English - [ ] Linked related issue (if any): closes # diff --git a/README.md b/README.md index 0310d5d6..c273637b 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https:/ ## 🛠️ Development & Contributing -Contributions are welcome — add a new channel by following the [Telegram channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/telegram/telegram_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit). +All kinds of contributions are welcome — new features, bug fixes, performance improvements, docs, or sharing your own skills on the [Skill Hub](https://skills.cowagent.ai/submit). See [CONTRIBUTING.md](/CONTRIBUTING.md) to get started, then open an Issue to discuss or send a PR directly. ⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome. diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index fcd25b95..e4d84387 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -4025,7 +4025,7 @@ const MODELS_CAPABILITY_DEFS = [ iconChip: 'bg-blue-50 dark:bg-blue-900/30', iconGlyph: 'text-blue-500' }, { id: 'image', icon: 'fa-image', editable: true, needsModel: true, titleKey: 'models_capability_image', descKey: 'models_capability_image_desc', iconChip: 'bg-blue-50 dark:bg-blue-900/30', iconGlyph: 'text-blue-500' }, - { id: 'asr', icon: 'fa-microphone', editable: true, needsModel: false, titleKey: 'models_capability_asr', descKey: 'models_capability_asr_desc', + { id: 'asr', icon: 'fa-microphone', editable: true, needsModel: true, titleKey: 'models_capability_asr', descKey: 'models_capability_asr_desc', iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' }, { id: 'tts', icon: 'fa-volume-high', editable: true, needsModel: true, titleKey: 'models_capability_tts', descKey: 'models_capability_tts_desc', iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' }, diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 6000d8a1..8eb9cbd2 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1720,6 +1720,28 @@ class ModelsHandler: ], } + # ASR engine catalog per provider. The first entry of each list is the + # runtime default (mirrors DEFAULT_ASR_MODEL in voice/*). Users can still + # pick "custom" in the UI to send any other model id. + _ASR_PROVIDER_MODELS = { + "openai": [ + {"value": "gpt-4o-mini-transcribe", "hint": "默认 · 速度快"}, + {"value": "gpt-4o-transcribe", "hint": "更高准确率"}, + {"value": "whisper-1", "hint": "经典 Whisper"}, + ], + "dashscope": [ + {"value": "qwen3-asr-flash", "hint": "覆盖普通话、方言与主流外语"}, + ], + "zhipu": [ + {"value": "glm-asr-2512", "hint": "智谱语音识别"}, + ], + # LinkAI gateway pins whisper-1 for ASR and ignores any other id, + # so expose only that to avoid misleading the user. + "linkai": [ + {"value": "whisper-1", "hint": "网关固定使用"}, + ], + } + # Per-provider voice timbres. Entries can be a bare code string # (label = code) or {value, hint?} when a friendly secondary label # helps recognition. We keep `value` as the raw API code so power @@ -2240,8 +2262,9 @@ class ModelsHandler: "editable": True, "current_provider": explicit, "suggested_provider": suggested, - "current_model": "", + "current_model": (local_config.get("voice_to_text_model") or "") if explicit else "", "providers": cls._ASR_PROVIDERS, + "provider_models": cls._ASR_PROVIDER_MODELS, } @classmethod @@ -2778,8 +2801,13 @@ class ModelsHandler: file_cfg = self._read_file_config() local_config["voice_to_text"] = provider_id file_cfg["voice_to_text"] = provider_id - local_config["voice_to_text_model"] = model - file_cfg["voice_to_text_model"] = model + # Only overwrite the model when one is supplied. An empty model means + # "keep whatever is configured" so switching provider from the console + # never wipes a user's hand-set voice_to_text_model (runtime falls back + # to the engine default via `or DEFAULT_ASR_MODEL` regardless). + if model: + local_config["voice_to_text_model"] = model + file_cfg["voice_to_text_model"] = model self._write_file_config(file_cfg) logger.info( f"[ModelsHandler] asr updated: provider={provider_id!r} " @@ -2788,7 +2816,8 @@ class ModelsHandler: self._refresh_voice_routing() return json.dumps({ "status": "success", - "provider": provider_id, "model": model, + "provider": provider_id, + "model": local_config.get("voice_to_text_model", ""), }) def _set_tts(self, provider_id: str, model: str, voice: str = "") -> str: diff --git a/docs/ja/README.md b/docs/ja/README.md index 5f848a15..32722fcc 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -238,7 +238,7 @@ GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) する ## 🛠️ 開発とコントリビューション -新しいチャネルの追加を歓迎します — [Telegram チャネル](https://github.com/zhayujie/CowAgent/blob/master/channel/telegram/telegram_channel.py) を参考にカスタムチャネルを実装できます。新しい Skill のコントリビューションも [Skill Hub](https://skills.cowagent.ai/submit) で受け付けています。 +あらゆる形のコントリビューションを歓迎します —— 新機能、バグ修正、パフォーマンス改善、ドキュメント、あるいは [Skill Hub](https://skills.cowagent.ai/submit) への Skill の共有など。まずは [CONTRIBUTING.md](/CONTRIBUTING.md) をご覧いただき、Issue で相談するか、直接 PR を送ってください。 ⭐ Star でプロジェクトを応援し、Watch → Custom → Releases で新バージョンの通知を受け取れます。PR や Issue の提出も歓迎します。 diff --git a/docs/zh/README.md b/docs/zh/README.md index b276191a..77cd9d5f 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -250,7 +250,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 ## 🛠️ 开发与贡献 -欢迎接入更多应用通道,参考 [飞书通道实现](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道;同时欢迎贡献新技能,向 [Skill Hub](https://skills.cowagent.ai/submit) 提交。 +欢迎各种形式的贡献:新功能、Bug 修复、性能优化、文档完善,或向 [Skill Hub](https://skills.cowagent.ai/submit) 分享你的技能。请先阅读 [CONTRIBUTING.md](/CONTRIBUTING.md) 了解如何开始,然后提交 Issue 讨论或直接发起 PR。 欢迎 ⭐ Star 支持项目,并通过 Watch → Custom → Releases 订阅新版本通知。也欢迎提交 PR、Issue 进行反馈。 diff --git a/tests/test_models_handler.py b/tests/test_models_handler.py index cd36c1c9..9d41b2b6 100644 --- a/tests/test_models_handler.py +++ b/tests/test_models_handler.py @@ -54,6 +54,46 @@ class TestModelsHandler(unittest.TestCase): write_file.assert_called_once_with(file_config) refresh_voice.assert_called_once() + def test_set_asr_empty_model_keeps_existing(self): + # Switching provider with an empty model must not wipe a user's + # hand-configured voice_to_text_model. + from channel.web.web_channel import ModelsHandler + + local_config = {"voice_to_text_model": "qwen3-asr-flash"} + file_config = {"voice_to_text_model": "qwen3-asr-flash"} + handler = ModelsHandler() + + with patch("channel.web.web_channel.conf", return_value=local_config): + with patch.object(ModelsHandler, "_read_file_config", return_value=file_config): + with patch.object(ModelsHandler, "_write_file_config"): + with patch.object(ModelsHandler, "_refresh_voice_routing"): + result = json.loads(handler._handle_set_capability({ + "capability": "asr", + "provider_id": "zhipu", + "model": "", + })) + + self.assertEqual(result["status"], "success") + self.assertEqual(local_config["voice_to_text"], "zhipu") + # Existing model preserved, not overwritten with "". + self.assertEqual(local_config["voice_to_text_model"], "qwen3-asr-flash") + self.assertEqual(file_config["voice_to_text_model"], "qwen3-asr-flash") + self.assertEqual(result["model"], "qwen3-asr-flash") + + def test_asr_capability_exposes_provider_models(self): + from channel.web.web_channel import ModelsHandler + + cap = ModelsHandler._asr_capability({ + "voice_to_text": "dashscope", + "voice_to_text_model": "qwen3-asr-flash", + }) + + self.assertTrue(cap["editable"]) + self.assertEqual(cap["current_provider"], "dashscope") + self.assertEqual(cap["current_model"], "qwen3-asr-flash") + self.assertIn("provider_models", cap) + self.assertIn("dashscope", cap["provider_models"]) + if __name__ == "__main__": unittest.main() From 92ec9653e54ccb9f05514c176a14f06092650a90 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 2 Jun 2026 16:38:17 +0800 Subject: [PATCH 310/399] feat(models): support qwen3.7-plus multi-modal model --- README.md | 2 +- agent/tools/vision/vision.py | 4 +- channel/web/web_channel.py | 10 ++--- common/const.py | 3 +- docs/ja/README.md | 2 +- docs/ja/models/index.mdx | 4 +- docs/ja/models/linkai.mdx | 2 +- docs/ja/models/qwen.mdx | 10 ++--- docs/ja/tools/vision.mdx | 2 +- docs/models/index.mdx | 2 +- docs/models/linkai.mdx | 2 +- docs/models/qwen.mdx | 10 ++--- docs/tools/vision.mdx | 2 +- docs/zh/README.md | 2 +- docs/zh/models/index.mdx | 2 +- docs/zh/models/linkai.mdx | 2 +- docs/zh/models/qwen.mdx | 10 ++--- docs/zh/tools/vision.mdx | 2 +- models/dashscope/dashscope_bot.py | 6 +-- run.sh | 4 +- scripts/run.ps1 | 4 +- tests/test_dashscope_provider.py | 63 +++++++++++++++++++++++++++++++ 22 files changed, 107 insertions(+), 43 deletions(-) create mode 100644 tests/test_dashscope_provider.py diff --git a/README.md b/README.md index c273637b..95ca70d2 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation | [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | | [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 498f3cd8..e022b4f3 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -51,7 +51,7 @@ _MAIN_MODEL_PROVIDER_NAME = "MainModel" _DISCOVERABLE_MODELS = [ ("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"), ("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"), - ("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN36_PLUS, "DashScope"), + ("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"), ("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"), ("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"), ("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"), @@ -161,7 +161,7 @@ class Vision(BaseTool): "Error: No model available for Vision.\n" "The main model does not support vision and no other API keys are configured.\n" "Options:\n" - " 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.6-plus, claude-sonnet-4-6, gemini-2.0-flash)\n" + " 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.7-plus, claude-sonnet-4-6, gemini-2.0-flash)\n" " 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n" " 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")" ) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 8eb9cbd2..92cd9fcf 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1409,7 +1409,7 @@ class ConfigHandler: const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, - const.QWEN36_PLUS, const.QWEN37_MAX, const.QWEN35_PLUS, const.QWEN3_MAX, + const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS, const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2, const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K, @@ -1482,7 +1482,7 @@ class ConfigHandler: "api_base_key": None, "api_base_default": None, "api_base_placeholder": "", - "models": [const.QWEN36_PLUS, const.QWEN37_MAX, const.QWEN35_PLUS, const.QWEN3_MAX], + "models": [const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS], }), ("doubao", { "label": {"zh": "豆包", "en": "Doubao"}, @@ -1986,7 +1986,7 @@ class ModelsHandler: ], "doubao": [const.DOUBAO_SEED_2_PRO], "moonshot": [const.KIMI_K2_6], - "dashscope": [const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX], + "dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS], "claudeAPI": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], "gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE], "qianfan": [const.ERNIE_45_TURBO_VL], @@ -2007,7 +2007,7 @@ class ModelsHandler: "linkai": [ const.GPT_41_MINI, const.GPT_54_MINI, - const.QWEN36_PLUS, + const.QWEN37_PLUS, const.DOUBAO_SEED_2_PRO, const.KIMI_K2_6, const.CLAUDE_4_6_SONNET, @@ -2124,7 +2124,7 @@ class ModelsHandler: _VISION_AUTO_ORDER = [ ("moonshot", "moonshot_api_key", const.KIMI_K2_6), ("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO), - ("dashscope", "dashscope_api_key", const.QWEN36_PLUS), + ("dashscope", "dashscope_api_key", const.QWEN37_PLUS), ("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET), ("gemini", "gemini_api_key", const.GEMINI_35_FLASH), ("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL), diff --git a/common/const.py b/common/const.py index fa56d273..53db4984 100644 --- a/common/const.py +++ b/common/const.py @@ -108,6 +108,7 @@ 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) +QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation) QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型 QWQ_PLUS = "qwq-plus" @@ -215,7 +216,7 @@ MODEL_LIST = [ GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7, # Qwen (通义千问) - QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, + QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, # Doubao (豆包) DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI, diff --git a/docs/ja/README.md b/docs/ja/README.md index 32722fcc..99a1e6c3 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -98,7 +98,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | -| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | | [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 33e42c89..916ac469 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。 - Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-5.1。 + Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.7-plus、kimi-k2.6、ernie-5.1。 同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。 @@ -24,7 +24,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Zhipu GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [Tongyi Qianwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | diff --git a/docs/ja/models/linkai.mdx b/docs/ja/models/linkai.mdx index a19c943c..a1152a2c 100644 --- a/docs/ja/models/linkai.mdx +++ b/docs/ja/models/linkai.mdx @@ -40,7 +40,7 @@ description: LinkAI プラットフォーム経由でテキスト、ビジョン } ``` -選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.6-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` など。 +選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` など。 ## 画像生成 diff --git a/docs/ja/models/qwen.mdx b/docs/ja/models/qwen.mdx index a9e03ad5..5ccc1f12 100644 --- a/docs/ja/models/qwen.mdx +++ b/docs/ja/models/qwen.mdx @@ -13,19 +13,19 @@ Tongyi Qianwen(DashScope / Bailian)は国内で最も広範な機能をカ ```json { - "model": "qwen3.6-plus", + "model": "qwen3.7-plus", "dashscope_api_key": "YOUR_API_KEY" } ``` | パラメータ | 説明 | | --- | --- | -| `model` | `qwen3.6-plus`、`qwen3.7-max`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` などを指定可能 | +| `model` | `qwen3.7-plus`、`qwen3.7-max`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` などを指定可能 | | `dashscope_api_key` | [Bailian コンソール](https://bailian.console.aliyun.com/?tab=model#/api-key) で作成。詳細は [公式ドキュメント](https://bailian.console.aliyun.com/?tab=api#/api) を参照 | ## 画像理解 -`dashscope_api_key` を設定すると、Agent の Vision ツールは自動的に Qwen のビジョンモデルを呼び出して画像を認識します。`qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` などのモデルはそのままマルチモーダルです。メインモデルがテキスト専用(`qwen-turbo` など)の場合は、自動的に `qwen-vl-max` にフォールバックします。 +`dashscope_api_key` を設定すると、Agent の Vision ツールは自動的に Qwen のビジョンモデルを呼び出して画像を認識します。`qwen3.7-plus` / `qwen3.6-plus` / `qwen3.5-plus` / `qwen3-max` などのモデルはそのままマルチモーダルです。メインモデルがテキスト専用(`qwen-turbo` など)の場合は、自動的に `qwen-vl-max` にフォールバックします。 Vision モデルを手動で指定したい場合: @@ -33,13 +33,13 @@ Vision モデルを手動で指定したい場合: { "tools": { "vision": { - "model": "qwen3.6-plus" + "model": "qwen3.7-plus" } } } ``` -サポートするモデル:`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。 +サポートするモデル:`qwen3.7-plus`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。 ## 画像生成 diff --git a/docs/ja/tools/vision.mdx b/docs/ja/tools/vision.mdx index 06eb287d..4621347d 100644 --- a/docs/ja/tools/vision.mdx +++ b/docs/ja/tools/vision.mdx @@ -19,7 +19,7 @@ Vision ツールは多段階の自動選択 + 自動フォールバック戦略 | プロバイダー | ビジョンモデル | 説明 | | --- | --- | --- | | OpenAI / 互換プロトコル | メインモデルを使用 | すべての OpenAI 互換マルチモーダルモデルに対応 | -| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.6-plus など | +| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.7-plus など | | Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 | | Gemini | メインモデルを使用 | inlineData 形式 | | 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-0 シリーズがネイティブ対応 | diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 0d065e58..2492270b 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -17,7 +17,7 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [GLM](/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [Qwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | diff --git a/docs/models/linkai.mdx b/docs/models/linkai.mdx index f60c2160..e078a945 100644 --- a/docs/models/linkai.mdx +++ b/docs/models/linkai.mdx @@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v } ``` -Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.6-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc. +Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc. ## Image Generation diff --git a/docs/models/qwen.mdx b/docs/models/qwen.mdx index 8e27269c..76a419fe 100644 --- a/docs/models/qwen.mdx +++ b/docs/models/qwen.mdx @@ -13,19 +13,19 @@ Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Te ```json { - "model": "qwen3.6-plus", + "model": "qwen3.7-plus", "dashscope_api_key": "YOUR_API_KEY" } ``` | Parameter | Description | | --- | --- | -| `model` | Can be `qwen3.6-plus`, `qwen3.7-max`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. | +| `model` | Can be `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. | | `dashscope_api_key` | Create one in the [Bailian Console](https://bailian.console.aliyun.com/?tab=model#/api-key); see the [official docs](https://bailian.console.aliyun.com/?tab=api#/api) | ## Image Understanding -Once `dashscope_api_key` is configured, the Agent's Vision tool automatically calls Qwen's vision models to recognize images. Models like `qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` are already multimodal; if the main model is text-only (e.g. `qwen-turbo`), it automatically falls back to `qwen-vl-max`. +Once `dashscope_api_key` is configured, the Agent's Vision tool automatically calls Qwen's vision models to recognize images. Models like `qwen3.7-plus` / `qwen3.6-plus` / `qwen3.5-plus` / `qwen3-max` are already multimodal; if the main model is text-only (e.g. `qwen-turbo`), it automatically falls back to `qwen-vl-max`. To manually specify a Vision model: @@ -33,13 +33,13 @@ To manually specify a Vision model: { "tools": { "vision": { - "model": "qwen3.6-plus" + "model": "qwen3.7-plus" } } } ``` -Supported models: `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`. +Supported models: `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`. ## Image Generation diff --git a/docs/tools/vision.mdx b/docs/tools/vision.mdx index 4db6bec0..4b15b984 100644 --- a/docs/tools/vision.mdx +++ b/docs/tools/vision.mdx @@ -19,7 +19,7 @@ If the current provider fails, the tool automatically tries the next one until i | Provider | Vision Model | Notes | | --- | --- | --- | | OpenAI / Compatible | Main model | All OpenAI-protocol-compatible multimodal models | -| Qwen (DashScope) | Main model | e.g. qwen3.6-plus, etc. | +| Qwen (DashScope) | Main model | e.g. qwen3.7-plus, etc. | | Claude | Main model | Anthropic native image format | | Gemini | Main model | inlineData format | | Doubao | Main model | doubao-seed-2-0 series natively supported | diff --git a/docs/zh/README.md b/docs/zh/README.md index 77cd9d5f..9a52b03d 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -100,7 +100,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | diff --git a/docs/zh/models/index.mdx b/docs/zh/models/index.mdx index e0d4a35c..ce858b47 100644 --- a/docs/zh/models/index.mdx +++ b/docs/zh/models/index.mdx @@ -18,7 +18,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 | [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [智谱 GLM](/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | -| [通义千问](/zh/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ | | [Kimi](/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | diff --git a/docs/zh/models/linkai.mdx b/docs/zh/models/linkai.mdx index 68647ebc..c0762858 100644 --- a/docs/zh/models/linkai.mdx +++ b/docs/zh/models/linkai.mdx @@ -40,7 +40,7 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音 } ``` -可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.6-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。 +可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。 ## 图像生成 diff --git a/docs/zh/models/qwen.mdx b/docs/zh/models/qwen.mdx index 765bae64..67626a4d 100644 --- a/docs/zh/models/qwen.mdx +++ b/docs/zh/models/qwen.mdx @@ -13,19 +13,19 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / ```json { - "model": "qwen3.6-plus", + "model": "qwen3.7-plus", "dashscope_api_key": "YOUR_API_KEY" } ``` | 参数 | 说明 | | --- | --- | -| `model` | 可填 `qwen3.6-plus`、`qwen3.7-max`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` 等 | +| `model` | 可填 `qwen3.7-plus`、`qwen3.7-max`、`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) | ## 图像理解 -配置 `dashscope_api_key` 后 Agent 的 Vision 工具会自动调用千问的视觉模型识别图像。`qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` 等模型本身就是多模态;若主模型是纯文本(如 `qwen-turbo`),会自动回落到 `qwen-vl-max`。 +配置 `dashscope_api_key` 后 Agent 的 Vision 工具会自动调用千问的视觉模型识别图像。`qwen3.7-plus` / `qwen3.6-plus` / `qwen3.5-plus` / `qwen3-max` 等模型本身就是多模态;若主模型是纯文本(如 `qwen-turbo`),会自动回落到 `qwen-vl-max`。 如需手动指定 Vision 模型: @@ -33,13 +33,13 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 / { "tools": { "vision": { - "model": "qwen3.6-plus" + "model": "qwen3.7-plus" } } } ``` -支持模型:`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。 +支持模型:`qwen3.7-plus`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。 ## 图像生成 diff --git a/docs/zh/tools/vision.mdx b/docs/zh/tools/vision.mdx index 675afe41..b10441a7 100644 --- a/docs/zh/tools/vision.mdx +++ b/docs/zh/tools/vision.mdx @@ -19,7 +19,7 @@ Vision 工具采用多级自动选择 + 自动兜底策略,无需手动配置 | 厂商 | 视觉模型 | 说明 | | --- | --- | --- | | OpenAI / 兼容协议 | 使用主模型 | 支持所有 OpenAI 协议兼容的多模态模型 | -| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.6-plus 等 | +| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.7-plus 等 | | Claude | 使用主模型 | Anthropic 原生图像格式 | | Gemini | 使用主模型 | inlineData 格式 | | 豆包 (Doubao) | 使用主模型 | doubao-seed-2-0 系列原生支持 | diff --git a/models/dashscope/dashscope_bot.py b/models/dashscope/dashscope_bot.py index 651ec80b..b224ef8b 100644 --- a/models/dashscope/dashscope_bot.py +++ b/models/dashscope/dashscope_bot.py @@ -28,15 +28,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-", "qwen3.6-") +MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-", "qwen3.6-", "qwen3.7-plus") # Qwen对话模型API class DashscopeBot(Bot): def __init__(self): super().__init__() - self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen3.6-plus") - self.model_name = conf().get("model") or "qwen3.6-plus" + self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen3.7-plus") + self.model_name = conf().get("model") or "qwen3.7-plus" self.client = dashscope.Generation api_key = conf().get("dashscope_api_key") if api_key: diff --git a/run.sh b/run.sh index 4672133f..014fb7f6 100755 --- a/run.sh +++ b/run.sh @@ -604,7 +604,7 @@ select_model() { "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" \ "MiniMax (MiniMax-M3, MiniMax-M2.7, etc.)" \ "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)" \ - "Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" \ + "Qwen (qwen3.7-plus, qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" \ "Doubao (doubao-seed-2-0-code-preview-260215, etc.)" \ "Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)" \ "LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \ @@ -635,7 +635,7 @@ configure_model() { 4) read_model_config "OpenAI GPT" "gpt-5.4" "OPENAI_KEY" ;; 5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;; 6) read_model_config "Zhipu AI" "glm-5.1" "ZHIPU_KEY" ;; - 7) read_model_config "Qwen (DashScope)" "qwen3.6-plus" "DASHSCOPE_KEY" ;; + 7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;; 8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;; 9) read_model_config "Kimi (Moonshot)" "kimi-k2.6" "MOONSHOT_KEY" ;; 10) diff --git a/scripts/run.ps1 b/scripts/run.ps1 index e69786a3..a3bfda63 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -370,7 +370,7 @@ $ModelChoices = @{ 4 = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" } 6 = @{ Provider = "Zhipu AI"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" } - 7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Field = "dashscope_api_key" } + 7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" } 8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Field = "ark_api_key" } 9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Field = "moonshot_api_key" } 10 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true } @@ -386,7 +386,7 @@ function Select-Model { "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)", "MiniMax (MiniMax-M3, MiniMax-M2.7, etc.)", "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)", - "Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)", + "Qwen (qwen3.7-plus, qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)", "Doubao (doubao-seed-2-0-code-preview-260215, etc.)", "Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)", ("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"), diff --git a/tests/test_dashscope_provider.py b/tests/test_dashscope_provider.py new file mode 100644 index 00000000..f8eb349a --- /dev/null +++ b/tests/test_dashscope_provider.py @@ -0,0 +1,63 @@ +# encoding:utf-8 +"""Unit tests for Qwen DashScope qwen3.7-plus provider updates.""" +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +class TestDashscopeConst(unittest.TestCase): + def test_qwen37_plus_constant_defined(self): + from common import const + self.assertEqual(const.QWEN37_PLUS, "qwen3.7-plus") + + def test_qwen37_plus_in_model_list(self): + from common import const + self.assertIn("qwen3.7-plus", const.MODEL_LIST) + + def test_qwen37_plus_before_qwen37_max_in_model_list(self): + from common import const + qwen_models = [m for m in const.MODEL_LIST if str(m).startswith("qwen")] + self.assertGreater( + len(qwen_models), + 1, + ) + self.assertEqual(qwen_models[0], "qwen3.7-plus") + + +class TestDashscopeBotDefaultModel(unittest.TestCase): + def test_default_model_is_qwen37_plus(self): + mock_conf = MagicMock() + mock_conf.get = MagicMock(side_effect=lambda key, default=None: default) + + with patch("models.dashscope.dashscope_bot.conf", return_value=mock_conf): + with patch("models.dashscope.dashscope_bot.SessionManager"): + from models.dashscope.dashscope_bot import DashscopeBot + bot = DashscopeBot.__new__(DashscopeBot) + bot.sessions = MagicMock() + bot.model_name = mock_conf.get("model") or "qwen3.7-plus" + self.assertEqual(bot.model_name, "qwen3.7-plus") + + def test_default_model_string_in_source(self): + bot_path = os.path.join( + os.path.dirname(__file__), "..", "models", "dashscope", "dashscope_bot.py" + ) + with open(bot_path, encoding="utf-8") as f: + source = f.read() + self.assertIn('"qwen3.7-plus"', source) + + +class TestDashscopeMultimodalRouting(unittest.TestCase): + def test_qwen37_plus_uses_multimodal_api(self): + from models.dashscope.dashscope_bot import DashscopeBot + self.assertTrue(DashscopeBot._is_multimodal_model("qwen3.7-plus")) + + def test_qwen37_max_uses_generation_api(self): + from models.dashscope.dashscope_bot import DashscopeBot + self.assertFalse(DashscopeBot._is_multimodal_model("qwen3.7-max")) + + +if __name__ == "__main__": + unittest.main() From 4d8458669c67c5237d43a7f998e924727e34068e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 2 Jun 2026 17:10:26 +0800 Subject: [PATCH 311/399] chore(install): simplify model menu, add MiMo option --- run.sh | 34 +++++++++++++++++++--------------- scripts/run.ps1 | 31 +++++++++++++++++-------------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/run.sh b/run.sh index 014fb7f6..cb624ac4 100755 --- a/run.sh +++ b/run.sh @@ -596,17 +596,18 @@ select_model() { echo "" local title sel title="$(t "选择 AI 模型" "Select AI Model")" - # The 11th option is "skip" -> configure later in the web console. + # The 12th option is "skip" -> configure later in the web console. select_menu sel "$title" \ "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \ - "Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)" \ - "Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" \ - "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" \ - "MiniMax (MiniMax-M3, MiniMax-M2.7, etc.)" \ - "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)" \ - "Qwen (qwen3.7-plus, qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" \ - "Doubao (doubao-seed-2-0-code-preview-260215, etc.)" \ - "Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)" \ + "Claude (claude-opus-4-8, claude-opus-4-7, etc.)" \ + "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \ + "OpenAI (gpt-5.5, etc.)" \ + "MiniMax (MiniMax-M3, etc.)" \ + "GLM (glm-5.1, etc.)" \ + "Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \ + "Doubao (doubao-seed-2.0, etc.)" \ + "Kimi (kimi-k2.6, etc.)" \ + "MiMo (mimo-v2.5-pro, etc.)" \ "LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \ "$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")" model_choice="$sel" @@ -632,19 +633,20 @@ configure_model() { 1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;; 2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;; 3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;; - 4) read_model_config "OpenAI GPT" "gpt-5.4" "OPENAI_KEY" ;; + 4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;; 5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;; - 6) read_model_config "Zhipu AI" "glm-5.1" "ZHIPU_KEY" ;; + 6) read_model_config "GLM" "glm-5.1" "ZHIPU_KEY" ;; 7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;; 8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;; 9) read_model_config "Kimi (Moonshot)" "kimi-k2.6" "MOONSHOT_KEY" ;; - 10) + 10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;; + 11) # Show where to obtain a LinkAI key (zh users -> console page). echo -e "${CYAN}$(t "获取 LinkAI Key" "Get your LinkAI Key"): https://link-ai.tech/console/interface${NC}" read_model_config "LinkAI" "deepseek-v4-flash" "LINKAI_KEY" USE_LINKAI="true" ;; - 11) + 12) # Skip: leave model unset, will be configured in web console MODEL_SKIPPED="true" MODEL_NAME="" @@ -657,8 +659,8 @@ configure_model() { channel_label() { case "$1" in web) t "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)" ;; - weixin) t "微信" "WeChat (Weixin)" ;; - feishu) t "飞书" "Feishu / Lark" ;; + weixin) t "微信" "Wechat" ;; + feishu) t "飞书" "Feishu" ;; dingtalk) t "钉钉" "DingTalk" ;; wecom_bot) t "企微智能机器人" "WeCom Bot" ;; qq) printf '%s' "QQ" ;; @@ -823,6 +825,7 @@ create_config_file() { ARK_KEY="${ARK_KEY:-}" \ DASHSCOPE_KEY="${DASHSCOPE_KEY:-}" \ MINIMAX_KEY="${MINIMAX_KEY:-}" \ + MIMO_KEY="${MIMO_KEY:-}" \ DEEPSEEK_KEY="${DEEPSEEK_KEY:-}" \ DEEPSEEK_BASE="${DEEPSEEK_BASE:-https://api.deepseek.com/v1}" \ USE_LINKAI="${USE_LINKAI:-false}" \ @@ -865,6 +868,7 @@ base = { 'ark_api_key': e('ARK_KEY', ''), 'dashscope_api_key': e('DASHSCOPE_KEY', ''), 'minimax_api_key': e('MINIMAX_KEY', ''), + 'mimo_api_key': e('MIMO_KEY', ''), 'deepseek_api_key': e('DEEPSEEK_KEY', ''), 'deepseek_api_base': e('DEEPSEEK_BASE'), 'voice_to_text': 'openai', diff --git a/scripts/run.ps1 b/scripts/run.ps1 index a3bfda63..993e8b49 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -367,13 +367,14 @@ $ModelChoices = @{ 1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" } 2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" } 3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" } - 4 = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } + 4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" } - 6 = @{ Provider = "Zhipu AI"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" } + 6 = @{ Provider = "GLM"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" } 7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" } 8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Field = "ark_api_key" } 9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Field = "moonshot_api_key" } - 10 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true } + 10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" } + 11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true } } function Select-Model { @@ -381,14 +382,15 @@ function Select-Model { $title = T "选择 AI 模型" "Select AI Model" $options = @( "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)", - "Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)", - "Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)", - "OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)", - "MiniMax (MiniMax-M3, MiniMax-M2.7, etc.)", - "Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)", - "Qwen (qwen3.7-plus, qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)", - "Doubao (doubao-seed-2-0-code-preview-260215, etc.)", - "Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)", + "Claude (claude-opus-4-8, claude-opus-4-7, etc.)", + "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)", + "OpenAI (gpt-5.5, etc.)", + "MiniMax (MiniMax-M3, etc.)", + "GLM (glm-5.1, etc.)", + "Qwen (qwen3.7-plus, qwen3.7-max, etc.)", + "Doubao (doubao-seed-2.0, etc.)", + "Kimi (kimi-k2.6, etc.)", + "MiMo (mimo-v2.5-pro, etc.)", ("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"), (T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)") ) @@ -406,7 +408,7 @@ function Configure-Model { $script:ApiBaseField = "" $script:UseLinkai = $false - if ($script:ModelChoice -eq 11) { + if ($script:ModelChoice -eq 12) { # Skip: leave model unset, will be configured in the web console. Write-Warn (T "已跳过模型配置,稍后可在 Web 控制台填写" "Model configuration skipped, you can set it later in the web console") return @@ -432,8 +434,8 @@ function Get-ChannelLabel { param([string]$Key) switch ($Key) { "web" { return (T "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)") } - "weixin" { return (T "微信 Weixin" "WeChat (Weixin)") } - "feishu" { return (T "飞书 Feishu" "Feishu / Lark") } + "weixin" { return (T "微信 Weixin" "Wechat") } + "feishu" { return (T "飞书 Feishu" "Feishu") } "dingtalk" { return (T "钉钉 DingTalk" "DingTalk") } "wecom_bot" { return (T "企微智能机器人 WeCom Bot" "WeCom Bot") } "qq" { return "QQ" } @@ -563,6 +565,7 @@ function New-ConfigFile { ark_api_key = "" dashscope_api_key = "" minimax_api_key = "" + mimo_api_key = "" deepseek_api_key = "" deepseek_api_base = "https://api.deepseek.com/v1" voice_to_text = "openai" From 1d797cdaf5d30ecd5303de013f1b8f4c51d3d717 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 3 Jun 2026 11:26:36 +0800 Subject: [PATCH 312/399] feat(channel): support telegram/slack/discord credential mapping --- app.py | 3 +++ common/cloud_client.py | 44 ++++++++++++++++++++---------------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index dbb15209..cee532bd 100644 --- a/app.py +++ b/app.py @@ -236,6 +236,9 @@ 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.TELEGRAM: "channel.telegram.telegram_channel.TelegramChannel", + const.SLACK: "channel.slack.slack_channel.SlackChannel", + const.DISCORD: "channel.discord.discord_channel.DiscordChannel", const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel", "wx": "channel.weixin.weixin_channel.WeixinChannel", } diff --git a/common/cloud_client.py b/common/cloud_client.py index 9361ecb8..b260c410 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -34,7 +34,9 @@ chat_client: LinkAIClient CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"} -# channelType -> config key mapping for app credentials +# channelType -> config key mapping for app credentials. +# secret_key may be "" for single-token channels (e.g. telegram/discord). +# For slack, appId carries bot_token and appSecret carries app_token. CREDENTIAL_MAP = { "feishu": ("feishu_app_id", "feishu_app_secret"), "dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"), @@ -43,6 +45,9 @@ CREDENTIAL_MAP = { "wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"), "wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"), "wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"), + "telegram": ("telegram_token", ""), + "slack": ("slack_bot_token", "slack_app_token"), + "discord": ("discord_token", ""), } @@ -357,7 +362,8 @@ class CloudClient(LinkAIClient): local_config[id_key] = app_id os.environ[id_key.upper()] = str(app_id) changed = True - if app_secret is not None and local_config.get(secret_key) != app_secret: + # secret_key may be empty for single-token channels (e.g. telegram/discord) + if secret_key and app_secret is not None and local_config.get(secret_key) != app_secret: local_config[secret_key] = app_secret os.environ[secret_key.upper()] = str(app_secret) changed = True @@ -372,9 +378,10 @@ class CloudClient(LinkAIClient): return id_key, secret_key = cred local_config.pop(id_key, None) - local_config.pop(secret_key, None) os.environ.pop(id_key.upper(), None) - os.environ.pop(secret_key.upper(), None) + if secret_key: + local_config.pop(secret_key, None) + os.environ.pop(secret_key.upper(), None) # ------------------------------------------------------------------ # channel_type list helpers @@ -862,25 +869,16 @@ def _build_config(): if plugin_config.get("Godcmd"): config["admin_password"] = plugin_config.get("Godcmd").get("password") - # Add channel-specific app credentials + # Add channel-specific app credentials based on CREDENTIAL_MAP. + # For multi-channel channel_type (comma-separated), the first matched type wins. current_channel_type = local_conf.get("channel_type", "") - if current_channel_type == "feishu": - config["app_id"] = local_conf.get("feishu_app_id") - config["app_secret"] = local_conf.get("feishu_app_secret") - elif current_channel_type == "dingtalk": - config["app_id"] = local_conf.get("dingtalk_client_id") - config["app_secret"] = local_conf.get("dingtalk_client_secret") - elif current_channel_type in ("wechatmp", "wechatmp_service"): - config["app_id"] = local_conf.get("wechatmp_app_id") - config["app_secret"] = local_conf.get("wechatmp_app_secret") - elif current_channel_type == "wecom_bot": - config["app_id"] = local_conf.get("wecom_bot_id") - config["app_secret"] = local_conf.get("wecom_bot_secret") - elif current_channel_type == "qq": - config["app_id"] = local_conf.get("qq_app_id") - config["app_secret"] = local_conf.get("qq_app_secret") - elif current_channel_type == "wechatcom_app": - config["app_id"] = local_conf.get("wechatcomapp_agent_id") - config["app_secret"] = local_conf.get("wechatcomapp_secret") + for ch_type in CloudClient._parse_channel_types({"channel_type": current_channel_type}): + cred = CREDENTIAL_MAP.get(ch_type) + if not cred: + continue + id_key, secret_key = cred + config["app_id"] = local_conf.get(id_key) + config["app_secret"] = local_conf.get(secret_key) if secret_key else "" + break return config From 4805f3d4d325e7747ad3d6e8dd80a3b903bbc6db Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 3 Jun 2026 14:47:11 +0800 Subject: [PATCH 313/399] fix(agent): register cancel token in ChatService stream run --- agent/chat/service.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/agent/chat/service.py b/agent/chat/service.py index 550063f1..47767aa4 100644 --- a/agent/chat/service.py +++ b/agent/chat/service.py @@ -171,6 +171,12 @@ class ChatService: from agent.protocol.agent_stream import AgentStreamExecutor + # Register a cancel token so /cancel can abort this in-flight run. + # IM channels key on session_id (no per-turn request_id here). + from agent.protocol import get_cancel_registry + registry = get_cancel_registry() + cancel_event = registry.register(session_id, session_id=session_id) if session_id else None + executor = AgentStreamExecutor( agent=agent, model=agent.model, @@ -180,6 +186,7 @@ class ChatService: on_event=on_event, messages=messages_copy, max_context_turns=max_context_turns, + cancel_event=cancel_event, ) try: @@ -191,6 +198,13 @@ class ChatService: agent.messages.clear() logger.info("[ChatService] Cleared agent message history after executor recovery") raise + finally: + # Release cancel token to keep the registry bounded. + if session_id: + try: + registry.unregister(session_id) + except Exception: + pass # Sync executor messages back to agent (thread-safe). # The executor may have trimmed context, making its list shorter than From f6f3ce5f0588b95ffce20d22b9a0eb0f2cfe5642 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 3 Jun 2026 15:33:29 +0800 Subject: [PATCH 314/399] fix(i18n): refine auto language fallback for deployments --- common/i18n.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/i18n.py b/common/i18n.py index 8cce5860..6dc24767 100644 --- a/common/i18n.py +++ b/common/i18n.py @@ -124,10 +124,12 @@ def detect_language(): 3. Python locale module 4. default English """ + deployed = bool(os.environ.get("CLOUD_DEPLOYMENT_ID")) return ( _detect_from_macos() or _detect_from_env() or _detect_from_python_locale() + or (ZH if deployed else None) or DEFAULT_LANG ) From cdb093c74ab19f9ea57c74138f125ba2aee28eca Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 3 Jun 2026 16:09:15 +0800 Subject: [PATCH 315/399] fix(i18n): refine auto language fallback for deployments --- common/i18n.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/i18n.py b/common/i18n.py index 6dc24767..9afbd0ef 100644 --- a/common/i18n.py +++ b/common/i18n.py @@ -124,12 +124,12 @@ def detect_language(): 3. Python locale module 4. default English """ - deployed = bool(os.environ.get("CLOUD_DEPLOYMENT_ID")) + if os.environ.get("CLOUD_DEPLOYMENT_ID"): + return ZH return ( _detect_from_macos() or _detect_from_env() or _detect_from_python_locale() - or (ZH if deployed else None) or DEFAULT_LANG ) From 79323358e59bf31eb8197714402c4343d47ef304 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 3 Jun 2026 17:42:57 +0800 Subject: [PATCH 316/399] feat: add X-Title header for linkai request --- models/linkai/link_ai_bot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/models/linkai/link_ai_bot.py b/models/linkai/link_ai_bot.py index 801adce6..2e470681 100644 --- a/models/linkai/link_ai_bot.py +++ b/models/linkai/link_ai_bot.py @@ -133,7 +133,7 @@ class LinkAIBot(Bot, OpenAICompatibleBot): if file_id: body["file_id"] = file_id logger.info(f"[LINKAI] query={query}, app_code={app_code}, model={body.get('model')}, file_id={file_id}") - headers = {"Authorization": "Bearer " + linkai_api_key} + headers = {"Authorization": "Bearer " + linkai_api_key, "X-Title": "CowAgent"} # do http request base_url = conf().get("linkai_api_base", "https://api.link-ai.tech") @@ -272,7 +272,7 @@ class LinkAIBot(Bot, OpenAICompatibleBot): } if self.args.get("max_tokens"): body["max_tokens"] = self.args.get("max_tokens") - headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")} + headers = {"Authorization": "Bearer " + conf().get("linkai_api_key"), "X-Title": "CowAgent"} # do http request base_url = conf().get("linkai_api_base", "https://api.link-ai.tech") @@ -565,7 +565,7 @@ def _linkai_call_with_tools(self, messages, tools=None, stream=False, **kwargs): body["thinking"] = thinking # Prepare headers - headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")} + headers = {"Authorization": "Bearer " + conf().get("linkai_api_key"), "X-Title": "CowAgent"} base_url = conf().get("linkai_api_base", "https://api.link-ai.tech") if stream: From 639a3eac1e467b832c80ffe8a260221e32be9849 Mon Sep 17 00:00:00 2001 From: liusk <16952718+ai-trip@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 09:10:48 +0800 Subject: [PATCH 317/399] fix(mcp): replace select.select with queue.Queue for cross-platform stdio I/O - Use reader thread + queue.Queue instead of select.select() which does not work with pipes on Windows (only sockets) - Make MCP server timeout configurable via mcp.json (default 120s) - Validate JSON-RPC response id to skip stale responses from timed-out calls - Log MCP server stderr at WARNING level instead of DEBUG for visibility --- agent/tools/mcp/mcp_client.py | 52 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/agent/tools/mcp/mcp_client.py b/agent/tools/mcp/mcp_client.py index be93c716..5cf86174 100644 --- a/agent/tools/mcp/mcp_client.py +++ b/agent/tools/mcp/mcp_client.py @@ -7,7 +7,7 @@ without any external MCP SDK dependency. import json import os -import select +import queue import subprocess import threading import urllib.request @@ -34,6 +34,8 @@ class McpClient: self.config = config self.name: str = config.get("name", "unknown") raw_transport: str = config.get("type", "stdio") + # Per-server timeout for tool calls (default 120s, suitable for data queries) + self._timeout: int = int(config.get("timeout", 120)) # Normalize streamable-http aliases to a single internal key self.transport: str = ( "streamable-http" @@ -43,6 +45,7 @@ class McpClient: # stdio state self._proc: Optional[subprocess.Popen] = None + self._read_queue: queue.Queue = queue.Queue() # SSE state self._sse_url: Optional[str] = None @@ -172,6 +175,9 @@ class McpClient: threading.Thread( target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}" ).start() + threading.Thread( + target=self._drain_stdout, daemon=True, name=f"mcp-stdout-{self.name}" + ).start() return self._handshake() @@ -179,14 +185,35 @@ class McpClient: for line in self._proc.stderr: line = line.strip() if line: - logger.debug(f"[MCP:{self.name}] stderr: {line}") + logger.warning(f"[MCP:{self.name}] stderr: {line}") - def _readline_with_timeout(self, timeout: int = 30) -> str: - """Read one line from stdio stdout with a hard timeout.""" - ready, _, _ = select.select([self._proc.stdout], [], [], timeout) - if not ready: - raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {timeout}s") - return self._proc.stdout.readline() + def _drain_stdout(self): + """Background thread: read lines from stdout and put them into the queue.""" + try: + for line in self._proc.stdout: + self._read_queue.put(line) + except Exception: + pass + finally: + try: + self._read_queue.put("") + except Exception: + pass + + def _readline_with_timeout(self, timeout: Optional[int] = None) -> str: + """Read one line from stdio stdout with a hard timeout (cross-platform). + + Uses the per-server timeout from mcp.json config when no explicit + timeout is provided. + """ + effective = timeout if timeout is not None else self._timeout + try: + line = self._read_queue.get(timeout=effective) + except queue.Empty: + raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {effective}s") + if not line: + raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly") + return line def _stdio_send(self, message: dict) -> dict: """Send a JSON-RPC message over stdio and read the response.""" @@ -194,6 +221,7 @@ class McpClient: self._proc.stdin.write(raw) self._proc.stdin.flush() + expected_id = message.get("id") while True: line = self._readline_with_timeout() if not line: @@ -208,6 +236,14 @@ class McpClient: if "id" not in data: logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}") continue + # Verify response id matches request id to avoid consuming a stale + # response left over from a previously failed/timed-out request. + if data.get("id") != expected_id: + logger.warning( + f"[MCP:{self.name}] Stale response id={data.get('id')} " + f"(expected {expected_id}), skipping" + ) + continue return data # ------------------------------------------------------------------ From 4cc57cc08d202e7859c6a66da1e99df1256227ff Mon Sep 17 00:00:00 2001 From: liusk <16952718+ai-trip@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 11:44:35 +0800 Subject: [PATCH 318/399] fix(mcp): enable concurrent calls for SSE and streamable-http transports _stdio_send (single pipe) must remain serialized under _call_lock, but SSE and streamable-http use independent HTTP requests and can safely execute concurrently across sessions. - Scope _call_lock to stdio transport only - Add _http_lock with double-checked pattern to protect _http_session_id initialization during concurrent streamable-http requests --- agent/tools/mcp/mcp_client.py | 40 +++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/agent/tools/mcp/mcp_client.py b/agent/tools/mcp/mcp_client.py index 5cf86174..81d81004 100644 --- a/agent/tools/mcp/mcp_client.py +++ b/agent/tools/mcp/mcp_client.py @@ -59,7 +59,13 @@ class McpClient: # Shared state self._next_id = 1 self._id_lock = threading.Lock() + # _call_lock serializes all requests on the single stdio pipe. + # SSE and streamable-http use independent HTTP requests, so they + # do not acquire this lock (see _send_request). self._call_lock = threading.Lock() + # _http_lock protects _http_session_id initialization across + # concurrent streamable-http requests. + self._http_lock = threading.Lock() self._initialized = False # ------------------------------------------------------------------ @@ -338,8 +344,12 @@ class McpClient: "Content-Type": "application/json", "Accept": "application/json, text/event-stream", } - if self._http_session_id: - headers["Mcp-Session-Id"] = self._http_session_id + # Read session id under lock to avoid racing with the + # initialization write below during concurrent requests. + with self._http_lock: + sid = self._http_session_id + if sid: + headers["Mcp-Session-Id"] = sid headers.update(self._http_headers) req = urllib.request.Request( @@ -365,8 +375,13 @@ class McpClient: with resp: # Capture session id assigned by the server (if any) session_id = resp.headers.get("Mcp-Session-Id") + # Double-checked lock: only the first response sets the + # session id, preventing concurrent initializers from + # overwriting each other. if session_id and not self._http_session_id: - self._http_session_id = session_id + with self._http_lock: + if not self._http_session_id: + self._http_session_id = session_id status = resp.status if hasattr(resp, "status") else resp.getcode() @@ -445,15 +460,18 @@ class McpClient: message = self._build_request(method, params) - with self._call_lock: - if self.transport == "stdio": + # stdio transport uses a single pipe and must be serialized. + # SSE and streamable-http use independent HTTP requests and + # can safely run concurrently across sessions. + if self.transport == "stdio": + with self._call_lock: return self._stdio_send(message) - elif self.transport == "sse": - return self._sse_send(message) - elif self.transport == "streamable-http": - return self._streamable_http_send(message) - else: - raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}") + elif self.transport == "sse": + return self._sse_send(message) + elif self.transport == "streamable-http": + return self._streamable_http_send(message) + else: + raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}") def _send_notification(self, method: str, params: dict): """Fire-and-forget notification (no response expected).""" From 3994594019c705f7c967ae686868969d3faba6ee Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 4 Jun 2026 22:43:45 +0800 Subject: [PATCH 319/399] docs: update badge in README.md --- README.md | 4 ++-- docs/ja/README.md | 4 ++-- docs/zh/README.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 95ca70d2..d3f07ef9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Latest release - License: MIT - Stars
+ License: MIT + Stars
[English] | [中文] | [日本語]

diff --git a/docs/ja/README.md b/docs/ja/README.md index 99a1e6c3..f69c686a 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -2,8 +2,8 @@

Latest release - License: MIT - Stars
+ License: MIT + Stars
[English] | [中文] | [日本語]

diff --git a/docs/zh/README.md b/docs/zh/README.md index 9a52b03d..4f45dd12 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -2,8 +2,8 @@

Latest release - License: MIT - Stars
+ License: MIT + Stars
[English] | [中文] | [日本語]

From 2b670e73f38393364ee397904b60373e1b504b14 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 4 Jun 2026 23:17:37 +0800 Subject: [PATCH 320/399] docs: update README.md --- README.md | 12 ++++++++++-- docs/ja/README.md | 12 ++++++++++-- docs/zh/README.md | 12 ++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d3f07ef9..462c2cac 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@

CowAgent

- Latest release + Latest release License: MIT - Stars
+ Stars + Docs +

+ +

+ zhayujie%2FCowAgent | Trendshift +

+ +

[English] | [中文] | [日本語]

diff --git a/docs/ja/README.md b/docs/ja/README.md index f69c686a..3b98c79c 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -1,9 +1,17 @@

CowAgent

- Latest release + Latest release License: MIT - Stars
+ Stars + ドキュメント +

+ +

+ zhayujie%2FCowAgent | Trendshift +

+ +

[English] | [中文] | [日本語]

diff --git a/docs/zh/README.md b/docs/zh/README.md index 4f45dd12..ec815932 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -1,9 +1,17 @@

CowAgent

- Latest release + Latest release License: MIT - Stars
+ Stars + 文档 +

+ +

+ zhayujie%2FCowAgent | Trendshift +

+ +

[English] | [中文] | [日本語]

From 2a16c562a8c06352f10ad17b66dde799e486d2bf Mon Sep 17 00:00:00 2001 From: OrbisAI Security Date: Fri, 5 Jun 2026 11:36:22 +0530 Subject: [PATCH 321/399] fix(bash): narrow credential-file block to ~/.cow/.env only Replace the broad `~/.cow` directory check with a regex that matches only the credential file path (`\.cow[/\\]\.env`), so legitimate access to other `~/.cow/` subdirectories (e.g. skills) is no longer blocked. Drop the incomplete env/printenv blocking rule per reviewer feedback. Rewrite test_invariant_bash.py to use the correct Bash().execute() API and cover both the blocked and allowed cases. Co-Authored-By: Claude Sonnet 4.6 --- agent/tools/bash/bash.py | 4 ++-- tests/test_invariant_bash.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/test_invariant_bash.py diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py index 890cf046..5e02832e 100644 --- a/agent/tools/bash/bash.py +++ b/agent/tools/bash/bash.py @@ -69,8 +69,8 @@ SAFETY: if not command: return ToolResult.fail("Error: command parameter is required") - # Security check: Prevent accessing sensitive config files - if "~/.cow/.env" in command or "~/.cow" in command: + # Security check: Prevent direct access to the credential file + if re.search(r'\.cow[/\\]\.env', command): return ToolResult.fail( "Error: Access denied. API keys and credentials must be accessed through the env_config tool only." ) diff --git a/tests/test_invariant_bash.py b/tests/test_invariant_bash.py new file mode 100644 index 00000000..776f33cf --- /dev/null +++ b/tests/test_invariant_bash.py @@ -0,0 +1,24 @@ +import pytest +from agent.tools.bash.bash import Bash + + +@pytest.mark.parametrize("command", [ + "cat ~/.cow/.env", + "cat .cow/.env", + "less ~/.cow/.env", + "cat /home/user/.cow/.env", +]) +def test_credential_file_access_is_blocked(command): + result = Bash().execute({"command": command}) + assert result.status == "error", f"Expected blocked result for: {command}" + assert "Access denied" in str(result.result) + + +@pytest.mark.parametrize("command", [ + "ls ~/.cow/skills", + "ls ~/.cow/", + "echo hello", +]) +def test_legitimate_cow_directory_access_is_not_blocked(command): + result = Bash().execute({"command": command}) + assert "Access denied" not in str(result.result) From 72a477f10c5cdf3f77b5337c70e917f1208034da Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 5 Jun 2026 14:46:16 +0800 Subject: [PATCH 322/399] fix(models): route mimo-* models to MiMo bot in agent mode --- bridge/agent_bridge.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index a924dab2..22be33c0 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -78,6 +78,7 @@ class AgentLLMModel(LLMModel): ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), ("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK), ("ernie", const.QIANFAN), + ("mimo-", const.MIMO), ] def __init__(self, bridge: Bridge, bot_type: str = "chat"): From 3d7c68bac6ee74fad63f43cf99e45c62e202ed55 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 5 Jun 2026 15:14:28 +0800 Subject: [PATCH 323/399] fix(wechatmp): reject webhook requests when wechatmp_token is empty --- channel/wechatmp/common.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/channel/wechatmp/common.py b/channel/wechatmp/common.py index e1cbe7b6..a994c01d 100644 --- a/channel/wechatmp/common.py +++ b/channel/wechatmp/common.py @@ -19,9 +19,15 @@ def verify_server(data): nonce = data.nonce echostr = data.get("echostr", None) token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写 + # Reject when token is empty: an empty token reduces signature verification + # to a predictable hash over attacker-controlled values. + if not token: + raise web.Forbidden("wechatmp_token is not configured") check_signature(token, signature, timestamp, nonce) return echostr except InvalidSignatureException: raise web.Forbidden("Invalid signature") + except web.Forbidden: + raise except Exception as e: raise web.Forbidden(str(e)) From c62175c06ba98d8a82e78f8582526a960c1b5034 Mon Sep 17 00:00:00 2001 From: "PF4YZYNS\\admin" <2250832148@qq.com> Date: Fri, 5 Jun 2026 18:51:35 +0800 Subject: [PATCH 324/399] - Add edit/delete/regenerate for user and bot messages - Add language labels and copy buttons to code blocks - Enhance drag-and-drop to full chat view - Fix data consistency bugs in message operations - Use RLock to prevent deadlock in conversation store" --- agent/memory/conversation_store.py | 106 ++++++- channel/web/static/css/console.css | 168 +++++++++++ channel/web/static/js/console.js | 434 ++++++++++++++++++++++++++++- channel/web/web_channel.py | 51 ++++ 4 files changed, 748 insertions(+), 11 deletions(-) diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index 48148f61..a286efc4 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -291,7 +291,7 @@ class ConversationStore: def __init__(self, db_path: Path): self._db_path = db_path - self._lock = threading.Lock() + self._lock = threading.RLock() # Use RLock to allow reentrant locking self._init_db() # ------------------------------------------------------------------ @@ -524,6 +524,109 @@ class ConversationStore: finally: conn.close() + def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int: + """Delete a user message and/or its corresponding assistant reply. + + The assistant reply is identified as all messages between user_seq + and the next visible user message (or end of session). + + Args: + session_id: Session identifier. + user_seq: The seq number of the user message. + delete_user: If True (default), delete the user message too. + If False, only delete assistant reply (for regenerate scenarios). + cascade: If True, also delete all subsequent turns after this one. + Used by edit-message which removes this turn and everything after. + + Returns: + Number of message rows deleted. + """ + with self._lock: + conn = self._connect() + try: + with conn: + # Verify this is a user message + row = conn.execute( + "SELECT role FROM messages WHERE session_id = ? AND seq = ?", + (session_id, user_seq), + ).fetchone() + if not row or row[0] != "user": + return 0 + + if cascade: + # Delete from this message to end of session + start_seq = user_seq if delete_user else user_seq + 1 + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + else: + # Find the next visible user message seq (exclude tool_result) + # Use batched query to avoid loading too many rows at once + next_user_seq = None + batch_size = 100 + offset = 0 + while True: + batch = conn.execute( + """ + SELECT seq, content FROM messages + WHERE session_id = ? AND seq > ? AND role = 'user' + ORDER BY seq ASC + LIMIT ? OFFSET ? + """, + (session_id, user_seq, batch_size, offset), + ).fetchall() + if not batch: + break + for seq, content in batch: + try: + content_obj = json.loads(content) + except Exception: + content_obj = content + if _is_visible_user_message(content_obj): + next_user_seq = seq + break + if next_user_seq is not None: + break + offset += batch_size + + # Determine the end boundary for deletion + if next_user_seq is not None: + end_seq = next_user_seq + else: + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + + # Determine the start boundary for deletion + start_seq = user_seq if delete_user else user_seq + 1 + + # Delete messages from start_seq to end_seq (exclusive) + cur = conn.execute( + "DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?", + (session_id, start_seq, end_seq), + ) + deleted = cur.rowcount + + # Update session msg_count + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + + return deleted + finally: + conn.close() + def prune_scheduled_messages( self, session_id: str, @@ -1053,3 +1156,4 @@ def get_conversation_store() -> ConversationStore: _store_instance = ConversationStore(db_path) logger.debug(f"[ConversationStore] Using shared DB at: {db_path}") return _store_instance + diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 957db3e0..188093b3 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -1399,3 +1399,171 @@ .agent-cancelled-tag { font-style: italic; } + +/* ===================================================================== + Code Block Enhancements + ===================================================================== */ +.code-block-wrapper { + position: relative; + margin: 1em 0; + border-radius: 8px; + overflow: hidden; + background: #f8f9fa; + border: 1px solid #e2e8f0; +} + +.dark .code-block-wrapper { + background: #1e293b; + border-color: #334155; +} + +.code-block-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 1em; + background: #e2e8f0; + border-bottom: 1px solid #cbd5e1; + font-size: 0.85em; +} + +.dark .code-block-header { + background: #0f172a; + border-bottom-color: #334155; +} + +.code-block-lang { + color: #64748b; + font-weight: 500; + text-transform: lowercase; +} + +.dark .code-block-lang { + color: #94a3b8; +} + +.code-copy-btn { + background: transparent; + border: none; + color: #64748b; + cursor: pointer; + padding: 0.25em 0.5em; + border-radius: 4px; + transition: all 0.2s; + font-size: 0.9em; +} + +.code-copy-btn:hover { + background: rgba(100, 116, 139, 0.1); + color: #475569; +} + +.dark .code-copy-btn { + color: #94a3b8; +} + +.dark .code-copy-btn:hover { + background: rgba(148, 163, 184, 0.1); + color: #cbd5e1; +} + +.code-block-wrapper pre { + margin: 0; + border-radius: 0; + border: none; +} + +/* ===================================================================== + Drag and Drop Overlay + ===================================================================== */ +.drag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(59, 130, 246, 0.1); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; +} + +.drag-overlay.active { + opacity: 1; +} + +.drag-overlay.hidden { + display: none; +} + +.drag-overlay-content { + background: white; + border: 3px dashed #3b82f6; + border-radius: 16px; + padding: 3em 4em; + text-align: center; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + animation: bounce 1s ease infinite; +} + +.dark .drag-overlay-content { + background: #1e293b; + border-color: #60a5fa; +} + +.drag-overlay-content i { + font-size: 4em; + color: #3b82f6; + margin-bottom: 0.5em; +} + +.dark .drag-overlay-content i { + color: #60a5fa; +} + +.drag-overlay-content p { + font-size: 1.5em; + font-weight: 600; + color: #1e293b; + margin: 0; +} + +.dark .drag-overlay-content p { + color: #f1f5f9; +} + +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +/* ===================================================================== + Message Action Buttons + ===================================================================== */ +.edit-msg-btn, +.delete-msg-btn, +.regenerate-msg-btn { + opacity: 0; + transition: opacity 0.2s, color 0.2s; +} + +.user-message-group:hover .edit-msg-btn, +.user-message-group:hover .delete-msg-btn, +.flex.gap-3:hover .regenerate-msg-btn, +.flex.gap-3:hover .delete-msg-btn { + opacity: 1; +} + +.edit-msg-btn:hover, +.regenerate-msg-btn:hover { + color: #3b82f6 !important; +} + +.delete-msg-btn:hover { + color: #ef4444 !important; +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index e4d84387..70ab97c7 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -184,6 +184,8 @@ const I18N = { today: '今天', yesterday: '昨天', earlier: '更早', delete_session_confirm: '确认删除该会话?所有消息将被清除。', delete_session_title: '删除会话', + delete_message_confirm: '确认删除这条消息?', + delete_message_title: '删除消息', untitled_session: '新对话', context_cleared: '— 以上内容已从上下文中移除 —', tip_new_chat: '新建对话', @@ -206,6 +208,10 @@ const I18N = { confirm_cancel: '取消', error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。', thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时', + edit_message: '编辑消息', + regenerate_response: '重新生成', + edit_save: '保存并发送', + edit_cancel: '取消', }, en: { console: 'Console', @@ -380,6 +386,8 @@ const I18N = { today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier', delete_session_confirm: 'Delete this session? All messages will be removed.', delete_session_title: 'Delete Session', + delete_message_confirm: 'Delete this message?', + delete_message_title: 'Delete Message', untitled_session: 'New Chat', context_cleared: '— Context above has been cleared —', tip_new_chat: 'New Chat', @@ -402,6 +410,10 @@ const I18N = { confirm_cancel: 'Cancel', error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.', thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration', + edit_message: 'Edit message', + regenerate_response: 'Regenerate', + edit_save: 'Save and send', + edit_cancel: 'Cancel', } }; @@ -821,11 +833,45 @@ function renderMarkdown(text) { let html = md.render(text); html = _rewriteLocalImgSrc(html); // Order matters: video first (more specific), then image. - return injectImagePreviews(injectVideoPlayers(html)); + html = injectImagePreviews(injectVideoPlayers(html)); + // Note: Code block headers are added via DOM manipulation after insertion + // See addCodeBlockHeadersToElement() + return html; } catch (e) { return text.replace(/\n/g, '
'); } } +function _addCodeBlockHeaders(container) { + // Add header with language label and copy button to each
 block using DOM manipulation
+    const preBlocks = container.querySelectorAll('pre');
+    preBlocks.forEach(pre => {
+        if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrapper')) return;
+        
+        const codeEl = pre.querySelector('code');
+        if (!codeEl) return;
+        
+        const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-'));
+        const language = langClass ? langClass.replace('language-', '') : 'code';
+        const langLabel = language.charAt(0).toUpperCase() + language.slice(1);
+        
+        const wrapper = document.createElement('div');
+        wrapper.className = 'code-block-wrapper';
+        
+        const header = document.createElement('div');
+        header.className = 'code-block-header';
+        header.innerHTML = `
+            ${langLabel}
+            
+        `;
+        
+        pre.parentNode.insertBefore(wrapper, pre);
+        wrapper.appendChild(header);
+        wrapper.appendChild(pre);
+    });
+}
+
 // =====================================================================
 // Chat Module
 // =====================================================================
@@ -1085,6 +1131,22 @@ messagesDiv.addEventListener('scroll', () => {
 
 // Intercept internal navigation links in chat messages
 messagesDiv.addEventListener('click', (e) => {
+    // Code block copy button
+    const codeCopyBtn = e.target.closest('.code-copy-btn');
+    if (codeCopyBtn) {
+        e.preventDefault();
+        const wrapper = codeCopyBtn.closest('.code-block-wrapper');
+        const codeEl = wrapper && wrapper.querySelector('pre code');
+        if (codeEl) {
+            const codeText = codeEl.textContent;
+            copyToClipboard(codeText).then(() => {
+                const icon = codeCopyBtn.querySelector('i');
+                if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
+            });
+        }
+        return;
+    }
+
     const copyBtn = e.target.closest('.copy-msg-btn');
     if (copyBtn) {
         e.preventDefault();
@@ -1092,13 +1154,97 @@ messagesDiv.addEventListener('click', (e) => {
         const answerEl = msgRoot && msgRoot.querySelector('.answer-content');
         const rawMd = answerEl && answerEl.dataset.rawMd;
         if (rawMd) {
-            navigator.clipboard.writeText(rawMd).then(() => {
+            copyToClipboard(rawMd).then(() => {
                 const icon = copyBtn.querySelector('i');
                 if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
             });
         }
         return;
     }
+
+    // Edit user message
+    const editBtn = e.target.closest('.edit-msg-btn');
+    if (editBtn) {
+        e.preventDefault();
+        const msgRoot = editBtn.closest('.user-message-group');
+        if (msgRoot) editUserMessage(msgRoot);
+        return;
+    }
+
+    // Regenerate bot response
+    const regenerateBtn = e.target.closest('.regenerate-msg-btn');
+    if (regenerateBtn) {
+        e.preventDefault();
+        const botMsgRoot = regenerateBtn.closest('.flex.gap-3');
+        if (botMsgRoot) regenerateResponse(botMsgRoot);
+        return;
+    }
+
+    // Delete message
+    const deleteBtn = e.target.closest('.delete-msg-btn');
+    if (deleteBtn) {
+        e.preventDefault();
+        const userMsgEl = deleteBtn.closest('.user-message-group');
+        const botMsgEl = deleteBtn.closest('.flex.gap-3:not(.user-message-group)');
+        
+        if (userMsgEl) {
+            showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
+                let nextSibling = userMsgEl.nextElementSibling;
+                let botReplyEl = null;
+                while (nextSibling) {
+                    if (nextSibling.classList && nextSibling.classList.contains('flex') && nextSibling.classList.contains('gap-3') && !nextSibling.classList.contains('user-message-group')) {
+                        botReplyEl = nextSibling;
+                        break;
+                    }
+                    nextSibling = nextSibling.nextElementSibling;
+                }
+                userMsgEl.remove();
+                if (botReplyEl) botReplyEl.remove();
+                
+                const userSeq = userMsgEl.dataset.seq;
+                if (userSeq) {
+                    fetch('/api/messages/delete', {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
+                    }).then(r => r.json()).then(data => {
+                        if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
+                    }).catch(err => console.error('Failed to delete:', err));
+                }
+            });
+        } else if (botMsgEl) {
+            showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
+                // Find the preceding user message to get its seq
+                let prevUserEl = botMsgEl.previousElementSibling;
+                while (prevUserEl && !prevUserEl.classList.contains('user-message-group')) {
+                    prevUserEl = prevUserEl.previousElementSibling;
+                }
+                
+                // Delete from database (keep user message, only delete bot reply)
+                if (prevUserEl) {
+                    const userSeq = prevUserEl.dataset.seq;
+                    if (userSeq) {
+                        fetch('/api/messages/delete', {
+                            method: 'POST',
+                            headers: { 'Content-Type': 'application/json' },
+                            body: JSON.stringify({ 
+                                session_id: sessionId, 
+                                user_seq: parseInt(userSeq),
+                                delete_user: false
+                            })
+                        }).then(r => r.json()).then(data => {
+                            if (data.status === 'success') console.log(`Deleted ${data.deleted} bot reply messages`);
+                        }).catch(err => console.error('Failed to delete bot reply:', err));
+                    }
+                }
+                
+                // Remove from DOM
+                botMsgEl.remove();
+            });
+        }
+        return;
+    }
+
     const a = e.target.closest('a');
     if (!a) return;
     const href = a.getAttribute('href') || '';
@@ -1376,14 +1522,83 @@ document.addEventListener('click', (e) => {
     hideAttachMenu();
 });
 
-// Drag-and-drop support on chat input area
+// Drag-and-drop support on entire chat view
+const chatView = document.getElementById('view-chat');
 const chatInputArea = chatInput.closest('.flex-shrink-0');
-chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
-chatInputArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); });
-chatInputArea.addEventListener('drop', (e) => {
-    e.preventDefault(); e.stopPropagation();
+
+// Create drag overlay for visual feedback
+let dragOverlay = document.getElementById('drag-overlay');
+if (!dragOverlay) {
+    dragOverlay = document.createElement('div');
+    dragOverlay.id = 'drag-overlay';
+    dragOverlay.className = 'drag-overlay hidden';
+    dragOverlay.innerHTML = `
+        
+ +

Drop files here to upload

+
+ `; + chatView.appendChild(dragOverlay); +} + +let dragCounter = 0; + +function showDragOverlay() { + dragOverlay.classList.remove('hidden'); + dragOverlay.classList.add('active'); +} + +function hideDragOverlay() { + dragOverlay.classList.remove('active'); + dragOverlay.classList.add('hidden'); +} + +chatView.addEventListener('dragenter', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter++; + if (e.dataTransfer.types.includes('Files')) { + showDragOverlay(); + } +}); + +chatView.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + chatInputArea.classList.add('drag-over'); +}); + +chatView.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter--; + if (dragCounter === 0) { + hideDragOverlay(); + chatInputArea.classList.remove('drag-over'); + } +}); + +chatView.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = 0; + hideDragOverlay(); chatInputArea.classList.remove('drag-over'); - if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files); + if (e.dataTransfer.files.length) { + handleFileSelect(e.dataTransfer.files); + } +}); + +document.body.addEventListener('dragover', (e) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } +}); + +document.body.addEventListener('drop', (e) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } }); // Paste image support @@ -1761,6 +1976,183 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) { scrollChatToBottom(true); } +// Clipboard helper with fallback for non-HTTPS environments +function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + // Fallback for HTTP environments + return new Promise((resolve, reject) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy') ? resolve() : reject(new Error('Copy failed')); + } catch (err) { + reject(err); + } finally { + textArea.remove(); + } + }); +} + +// Edit user message: extract content, remove this and subsequent messages, fill input +async function editUserMessage(msgEl) { + const rawContent = msgEl.dataset.rawContent; + if (!rawContent) return; + + // Delete this message and ALL subsequent messages from database (cascade) + // Must await to ensure delete completes before user sends a new message + const userSeq = msgEl.dataset.seq; + if (userSeq) { + try { + const resp = await fetch('/api/messages/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + user_seq: parseInt(userSeq), + delete_user: true, + cascade: true + }) + }); + const data = await resp.json(); + if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`); + } catch (err) { + console.error('Failed to delete old messages:', err); + } + } + + // Find all subsequent messages (this message and everything after it) + const messagesToRemove = []; + let current = msgEl; + while (current) { + if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) { + messagesToRemove.push(current); + } + current = current.nextElementSibling; + } + + // Remove all messages from this one onwards + messagesToRemove.forEach(el => { + if (el && el.parentNode) el.parentNode.removeChild(el); + }); + + // Fill input with the original content + chatInput.value = rawContent; + chatInput.style.height = 'auto'; + chatInput.style.height = chatInput.scrollHeight + 'px'; + chatInput.focus(); + scrollChatToBottom(); +} + +// Regenerate bot response: find the preceding user message and resend it +async function regenerateResponse(botMsgEl) { + let prevEl = botMsgEl.previousElementSibling; + while (prevEl && !prevEl.classList.contains('user-message-group')) { + prevEl = prevEl.previousElementSibling; + } + + if (!prevEl) { + console.warn('No preceding user message found'); + return; + } + + const userContent = prevEl.dataset.rawContent; + if (!userContent) { + console.warn('No content in preceding user message'); + return; + } + + // Delete both the old user message AND bot reply from database + // (because /message will create a fresh user message + new bot reply) + // Must await to ensure delete completes before /message is sent + const userSeq = prevEl.dataset.seq; + if (userSeq) { + try { + const resp = await fetch('/api/messages/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + user_seq: parseInt(userSeq), + delete_user: true + }) + }); + const data = await resp.json(); + if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`); + } catch (err) { + console.error('Failed to delete old messages:', err); + } + } + + // Remove both the old user message and bot message from DOM + if (prevEl.parentNode) prevEl.parentNode.removeChild(prevEl); + if (botMsgEl.parentNode) botMsgEl.parentNode.removeChild(botMsgEl); + + // Re-add the user message to DOM (so it appears before the loading indicator) + addUserMessage(userContent, new Date()); + + // Show loading indicator + const loadingEl = addLoadingIndicator(); + + // Resend the message + const timestamp = new Date(); + const body = { session_id: sessionId, message: userContent, stream: true, timestamp: timestamp.toISOString(), lang: currentLang }; + + 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.inline_reply) { + loadingEl.remove(); + addBotMessage(data.inline_reply, new Date()); + } else if (data.stream) { + setSendBtnCancelMode(data.request_id); + startSSE(data.request_id, loadingEl, timestamp, null); + } else { + loadingContainers[data.request_id] = loadingEl; + } + } else { + loadingEl.remove(); + addBotMessage(t('error_send'), new Date()); + resetSendBtnSendMode(); + } + }) + .catch(err => { + if (err.name === 'AbortError') { + loadingEl.remove(); + addBotMessage(t('error_timeout'), new Date()); + resetSendBtnSendMode(); + return; + } + if (attempt < MAX_RETRIES) { + console.warn(`[regenerateResponse] attempt ${attempt + 1} failed, retrying...`, err); + setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1)); + return; + } + loadingEl.remove(); + addBotMessage(t('error_send'), new Date()); + resetSendBtnSendMode(); + }); + } + + postWithRetry(0); +} + function sendMessage() { // Do NOT branch on sendBtnMode here: Enter should always send (so // typing "/cancel" submits normally). Cancel is wired only to the @@ -2252,7 +2644,7 @@ function startPolling() { function createUserMessageEl(content, timestamp, attachments) { const el = document.createElement('div'); - el.className = 'flex justify-end px-4 sm:px-6 py-3'; + el.className = 'flex justify-end px-4 sm:px-6 py-3 user-message-group'; let attachHtml = ''; if (attachments && attachments.length > 0) { @@ -2277,9 +2669,19 @@ function createUserMessageEl(content, timestamp, attachments) {
${attachHtml}${textHtml}
-
${formatTime(timestamp)}
+
+ + + ${formatTime(timestamp)} +
`; + // Store raw content for editing + el.dataset.rawContent = content || ''; return el; } @@ -2487,6 +2889,12 @@ function createBotMessageEl(content, timestamp, requestId, msg) { + + @@ -2709,6 +3117,10 @@ function loadHistory(page) { const el = msg.role === 'user' ? createUserMessageEl(msg.content, ts) : createBotMessageEl(msg.content || '', ts, null, msg); + // Store seq for delete functionality + if (msg._seq !== undefined) { + el.dataset.seq = msg._seq; + } fragment.appendChild(el); }); @@ -3285,6 +3697,8 @@ function applyHighlighting(container) { hljsLib.highlightElement(block); } }); + // Add language labels and copy buttons to code blocks + _addCodeBlockHeaders(root); }, 0); } diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 92cd9fcf..ea95b60e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1096,6 +1096,7 @@ class WebChannel(ChatChannel): '/api/sessions/(.*)/clear_context', 'SessionClearContextHandler', '/api/sessions/(.*)', 'SessionDetailHandler', '/api/history', 'HistoryHandler', + '/api/messages/delete', 'MessageDeleteHandler', '/api/logs', 'LogsHandler', '/api/version', 'VersionHandler', '/assets/(.*)', 'AssetsHandler', @@ -3920,6 +3921,56 @@ class HistoryHandler: return json.dumps({"status": "error", "message": str(e)}) +class MessageDeleteHandler: + def POST(self): + _require_auth() + web.header('Content-Type', 'application/json; charset=utf-8') + web.header('Access-Control-Allow-Origin', '*') + try: + data = json.loads(web.data()) + session_id = data.get('session_id', '').strip() + user_seq = data.get('user_seq') + delete_user = data.get('delete_user', True) + cascade = data.get('cascade', False) + + if not session_id or user_seq is None: + return json.dumps({"status": "error", "message": "session_id and user_seq required"}) + + # 1. Delete from database + from agent.memory import get_conversation_store + store = get_conversation_store() + deleted = store.delete_message_pair(session_id, int(user_seq), delete_user=delete_user, cascade=cascade) + + # 2. Sync agent's in-memory context + try: + from bridge import Bridge + ab = Bridge().get_agent_bridge() + agent = ab.get_agent(session_id) + if agent and hasattr(agent, 'messages') and hasattr(agent, 'messages_lock'): + with agent.messages_lock: + # Rebuild agent.messages from database + # load_messages returns: [{"role": "user", "content": "..."}, ...] + remaining_msgs = store.load_messages(session_id, max_turns=1000) + + agent.messages.clear() + for msg in remaining_msgs: + agent.messages.append({ + "role": msg["role"], + "content": msg["content"] + }) + + logger.info(f"[WebChannel] Synced agent memory for session {session_id}, " + f"remaining messages: {len(agent.messages)}") + except Exception as sync_err: + logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}") + # Don't fail the request if memory sync fails + + return json.dumps({"status": "success", "deleted": deleted}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Message delete error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + class LogsHandler: def GET(self): _require_auth() From 3c19614c746eaa82bd6bbd6254a90ae3c0305083 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 6 Jun 2026 16:07:31 +0800 Subject: [PATCH 325/399] refactor(web-console): polish message actions on bubbles after #2865 --- agent/memory/conversation_store.py | 59 ++++++++++++ bridge/agent_bridge.py | 44 ++++++++- channel/web/static/css/console.css | 8 +- channel/web/static/js/console.js | 145 +++++++++++++++-------------- channel/web/web_channel.py | 51 +++++----- 5 files changed, 211 insertions(+), 96 deletions(-) diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index a286efc4..23a81192 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -509,6 +509,65 @@ class ConversationStore: finally: conn.close() + def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]: + """Return the seq numbers of the latest visible user message and the + latest assistant message in a session. + + A "visible" user message is one whose content is real user text + (not just a tool_result block), so tool-execution turns do not + shadow the actual user query. + + Returns: + Dict with keys ``user_seq`` and ``bot_seq``; either may be None + when no matching message exists. + """ + result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None} + with self._lock: + conn = self._connect() + try: + # Latest assistant message (cheap: single row by seq DESC). + row = conn.execute( + "SELECT seq FROM messages " + "WHERE session_id = ? AND role = 'assistant' " + "ORDER BY seq DESC LIMIT 1", + (session_id,), + ).fetchone() + if row: + result["bot_seq"] = int(row[0]) + + # Latest visible user message: scan recent user rows and + # skip pure tool_result entries. + rows = conn.execute( + "SELECT seq, content FROM messages " + "WHERE session_id = ? AND role = 'user' " + "ORDER BY seq DESC LIMIT 20", + (session_id,), + ).fetchall() + for seq, content_raw in rows: + try: + content = json.loads(content_raw) + except Exception: + result["user_seq"] = int(seq) + break + 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 + ) + if has_text and not has_tool_result: + result["user_seq"] = int(seq) + break + else: + result["user_seq"] = int(seq) + break + finally: + conn.close() + return result + def clear_session(self, session_id: str) -> None: """Delete all messages and the session record for a given session_id.""" with self._lock: diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 22be33c0..e8424eb5 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -383,7 +383,49 @@ class AgentBridge: """Initialize agent for a specific session""" agent = self.initializer.initialize_agent(session_id=session_id) self.agents[session_id] = agent - + + def sync_session_messages_from_store(self, session_id: str) -> int: + """Reload an agent's in-memory ``messages`` list from the persistent + conversation store. + + Used after an external mutation (e.g. user edits / deletes a message + via the web console) so the agent's next turn sees the same history + as the database. The operation is a no-op when the agent has not been + instantiated yet for the session. + + Returns: + Number of messages now held in the agent's memory. Returns -1 if + the agent does not exist or has no compatible ``messages`` attr. + """ + if not session_id or session_id not in self.agents: + return -1 + agent = self.agents[session_id] + if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")): + return -1 + try: + from agent.memory import get_conversation_store + store = get_conversation_store() + # No turn cap here: we want a faithful mirror of what the store + # has for this session after deletion. + remaining = store.load_messages(session_id, max_turns=10**6) + except Exception as e: + logger.warning( + f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}" + ) + return -1 + with agent.messages_lock: + agent.messages.clear() + for msg in remaining: + agent.messages.append({ + "role": msg["role"], + "content": msg["content"], + }) + count = len(agent.messages) + logger.info( + f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}" + ) + return count + def agent_reply(self, query: str, context: Context = None, on_event=None, clear_history: bool = False) -> Reply: """ diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 188093b3..e260bbc9 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -1476,6 +1476,11 @@ /* ===================================================================== Drag and Drop Overlay ===================================================================== */ +/* Anchor the absolutely-positioned overlay to the chat view. */ +#view-chat { + position: relative; +} + .drag-overlay { position: absolute; top: 0; @@ -1554,8 +1559,7 @@ .user-message-group:hover .edit-msg-btn, .user-message-group:hover .delete-msg-btn, -.flex.gap-3:hover .regenerate-msg-btn, -.flex.gap-3:hover .delete-msg-btn { +.bot-message-group:hover .regenerate-msg-btn { opacity: 1; } diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 70ab97c7..31c8d271 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -1180,68 +1180,40 @@ messagesDiv.addEventListener('click', (e) => { return; } - // Delete message + // Delete message (user bubble only; bot bubbles intentionally lack a + // delete button — removing only the bot reply would leave an orphan + // user message that breaks LLM context alternation). const deleteBtn = e.target.closest('.delete-msg-btn'); if (deleteBtn) { e.preventDefault(); const userMsgEl = deleteBtn.closest('.user-message-group'); - const botMsgEl = deleteBtn.closest('.flex.gap-3:not(.user-message-group)'); - - if (userMsgEl) { - showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => { - let nextSibling = userMsgEl.nextElementSibling; - let botReplyEl = null; - while (nextSibling) { - if (nextSibling.classList && nextSibling.classList.contains('flex') && nextSibling.classList.contains('gap-3') && !nextSibling.classList.contains('user-message-group')) { - botReplyEl = nextSibling; - break; - } - nextSibling = nextSibling.nextElementSibling; + if (!userMsgEl) return; + + showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => { + // Find the next bot reply for this turn (skip non-message nodes). + let botReplyEl = null; + let sibling = userMsgEl.nextElementSibling; + while (sibling) { + if (sibling.classList && sibling.classList.contains('bot-message-group')) { + botReplyEl = sibling; + break; } - userMsgEl.remove(); - if (botReplyEl) botReplyEl.remove(); - - const userSeq = userMsgEl.dataset.seq; - if (userSeq) { - fetch('/api/messages/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) }) - }).then(r => r.json()).then(data => { - if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`); - }).catch(err => console.error('Failed to delete:', err)); - } - }); - } else if (botMsgEl) { - showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => { - // Find the preceding user message to get its seq - let prevUserEl = botMsgEl.previousElementSibling; - while (prevUserEl && !prevUserEl.classList.contains('user-message-group')) { - prevUserEl = prevUserEl.previousElementSibling; - } - - // Delete from database (keep user message, only delete bot reply) - if (prevUserEl) { - const userSeq = prevUserEl.dataset.seq; - if (userSeq) { - fetch('/api/messages/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: sessionId, - user_seq: parseInt(userSeq), - delete_user: false - }) - }).then(r => r.json()).then(data => { - if (data.status === 'success') console.log(`Deleted ${data.deleted} bot reply messages`); - }).catch(err => console.error('Failed to delete bot reply:', err)); - } - } - - // Remove from DOM - botMsgEl.remove(); - }); - } + sibling = sibling.nextElementSibling; + } + userMsgEl.remove(); + if (botReplyEl) botReplyEl.remove(); + + const userSeq = userMsgEl.dataset.seq; + if (userSeq) { + fetch('/api/messages/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) }) + }).then(r => r.json()).then(data => { + if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`); + }).catch(err => console.error('Failed to delete:', err)); + } + }); return; } @@ -2028,17 +2000,25 @@ async function editUserMessage(msgEl) { } } - // Find all subsequent messages (this message and everything after it) + // Remove this message bubble and every later bubble that belongs to + // this or a subsequent turn. We mirror the backend cascade contract: + // anything with a data-seq >= current seq, plus any live SSE bubble + // that is still being streamed (no seq yet) after this point. + const currentSeqNum = userSeq ? parseInt(userSeq) : null; const messagesToRemove = []; let current = msgEl; while (current) { - if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) { - messagesToRemove.push(current); + if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('bot-message-group'))) { + const seqAttr = current.dataset.seq; + if (seqAttr === undefined || seqAttr === '') { + // Live message without a persisted seq yet — treat as later. + messagesToRemove.push(current); + } else if (currentSeqNum === null || parseInt(seqAttr) >= currentSeqNum) { + messagesToRemove.push(current); + } } current = current.nextElementSibling; } - - // Remove all messages from this one onwards messagesToRemove.forEach(el => { if (el && el.parentNode) el.parentNode.removeChild(el); }); @@ -2266,8 +2246,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { if (botEl) return; if (loadingEl) { loadingEl.remove(); loadingEl = null; } botEl = document.createElement('div'); - botEl.className = 'flex gap-3 px-4 sm:px-6 py-3'; + botEl.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group'; botEl.dataset.requestId = requestId; + // Regenerate button starts hidden; it's revealed in the "done" + // event handler once seq metadata arrives from the backend. botEl.innerHTML = ` CowAgent
@@ -2285,6 +2267,9 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { +
`; @@ -2534,6 +2519,29 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { if (copyBtn && finalText) copyBtn.style.display = ''; applyHighlighting(botEl); } + + // Backfill seq metadata so edit/regenerate buttons can call + // the delete API without a page refresh. Backend includes + // user_seq / bot_seq on the done event after persistence. + const targetBotEl = botEl || (requestId ? messagesDiv.querySelector(`[data-request-id="${requestId}"]`) : null); + if (targetBotEl) { + if (item.bot_seq !== undefined && item.bot_seq !== null) { + targetBotEl.dataset.seq = item.bot_seq; + } + // Reveal regenerate button now that the seq is wired up. + const regenBtn = targetBotEl.querySelector('.regenerate-msg-btn'); + if (regenBtn) regenBtn.style.display = ''; + if (item.user_seq !== undefined && item.user_seq !== null) { + // Locate the preceding user bubble for this turn. + let prev = targetBotEl.previousElementSibling; + while (prev && !prev.classList.contains('user-message-group')) { + prev = prev.previousElementSibling; + } + if (prev && !prev.dataset.seq) { + prev.dataset.seq = item.user_seq; + } + } + } renderBotSpeakerButton(botEl, finalText); scrollChatToBottom(); @@ -2857,7 +2865,7 @@ function localizeCancelMarker(text) { function createBotMessageEl(content, timestamp, requestId, msg) { const el = document.createElement('div'); - el.className = 'flex gap-3 px-4 sm:px-6 py-3'; + el.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group'; if (requestId) el.dataset.requestId = requestId; let stepsHtml = ''; @@ -2889,15 +2897,12 @@ function createBotMessageEl(content, timestamp, requestId, msg) { - - + `; diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index ea95b60e..ddac97dd 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -251,6 +251,21 @@ class WebChannel(ChatChannel): """生成唯一的请求ID""" return str(uuid.uuid4()) + def _fetch_latest_pair_seqs(self, session_id: str): + """Query the conversation store for the latest user/bot message seqs. + + Returned as ``{"user_seq": int|None, "bot_seq": int|None}``; used to + attach seq metadata onto the SSE ``done`` event so the frontend can + wire edit / regenerate buttons for live-streamed bubbles without a + page refresh. + """ + try: + from agent.memory import get_conversation_store + return get_conversation_store().get_latest_pair_seqs(session_id) + except Exception as e: + logger.debug(f"[WebChannel] _fetch_latest_pair_seqs failed: {e}") + return {"user_seq": None, "bot_seq": None} + def send(self, reply: Reply, context: Context): try: if reply.type in self.NOT_SUPPORT_REPLYTYPE: @@ -291,11 +306,14 @@ class WebChannel(ChatChannel): if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"): text_content = getattr(reply, 'text_content', '') if text_content: + seqs = self._fetch_latest_pair_seqs(session_id) self.sse_queues[request_id].put({ "type": "done", "content": text_content, "request_id": request_id, - "timestamp": time.time() + "timestamp": time.time(), + "user_seq": seqs.get("user_seq"), + "bot_seq": seqs.get("bot_seq"), }) logger.debug(f"SSE skipped duplicate file for request {request_id}") return @@ -307,11 +325,14 @@ class WebChannel(ChatChannel): logger.debug(f"SSE skipped http media reply for request {request_id}") return + seqs = self._fetch_latest_pair_seqs(session_id) self.sse_queues[request_id].put({ "type": "done", "content": content, "request_id": request_id, - "timestamp": time.time() + "timestamp": time.time(), + "user_seq": seqs.get("user_seq"), + "bot_seq": seqs.get("bot_seq"), }) logger.debug(f"SSE done sent for request {request_id}") # Auto-trigger TTS once the bot finishes its text reply. The @@ -3940,31 +3961,15 @@ class MessageDeleteHandler: from agent.memory import get_conversation_store store = get_conversation_store() deleted = store.delete_message_pair(session_id, int(user_seq), delete_user=delete_user, cascade=cascade) - - # 2. Sync agent's in-memory context + + # 2. Sync agent's in-memory context so its next turn sees the + # same history as the DB. Handled by the agent_bridge helper. try: from bridge import Bridge - ab = Bridge().get_agent_bridge() - agent = ab.get_agent(session_id) - if agent and hasattr(agent, 'messages') and hasattr(agent, 'messages_lock'): - with agent.messages_lock: - # Rebuild agent.messages from database - # load_messages returns: [{"role": "user", "content": "..."}, ...] - remaining_msgs = store.load_messages(session_id, max_turns=1000) - - agent.messages.clear() - for msg in remaining_msgs: - agent.messages.append({ - "role": msg["role"], - "content": msg["content"] - }) - - logger.info(f"[WebChannel] Synced agent memory for session {session_id}, " - f"remaining messages: {len(agent.messages)}") + Bridge().get_agent_bridge().sync_session_messages_from_store(session_id) except Exception as sync_err: logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}") - # Don't fail the request if memory sync fails - + return json.dumps({"status": "success", "deleted": deleted}, ensure_ascii=False) except Exception as e: logger.error(f"[WebChannel] Message delete error: {e}") From 72847e0711d174252e63d5fabfb454ff19d37136 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 6 Jun 2026 19:00:38 +0800 Subject: [PATCH 326/399] feat(i18n): order channel list by UI language --- channel/web/web_channel.py | 50 +++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index ddac97dd..9f3393d7 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1046,22 +1046,44 @@ class WebChannel(ChatChannel): self._cleanup_stale_voice_recordings() - # Print available channel types + # Print available channel types (ordered by language: prioritize + # locally-popular channels for the current UI language) logger.info( "[WebChannel] Available channels (edit `channel_type` in config.json to switch, separate multiple with commas):") - logger.info("[WebChannel] 1. web - Web") - logger.info("[WebChannel] 2. terminal - Terminal") - logger.info("[WebChannel] 3. weixin - WeChat") - logger.info("[WebChannel] 4. feishu - Feishu") - logger.info("[WebChannel] 5. dingtalk - DingTalk") - logger.info("[WebChannel] 6. wecom_bot - WeCom Bot") - logger.info("[WebChannel] 7. wechatcom_app - WeCom App") - logger.info("[WebChannel] 8. wechat_kf - WeChat Customer Service") - logger.info("[WebChannel] 9. wechatmp - WeChat Official Account") - logger.info("[WebChannel] 10. wechatmp_service - WeChat Official Account (Service)") - logger.info("[WebChannel] 11. telegram - Telegram") - logger.info("[WebChannel] 12. slack - Slack") - logger.info("[WebChannel] 13. discord - Discord") + zh_channels = [ + ("web", "Web"), + ("terminal", "Terminal"), + ("weixin", "WeChat"), + ("feishu", "Feishu"), + ("dingtalk", "DingTalk"), + ("wecom_bot", "WeCom Bot"), + ("wechatcom_app", "WeCom App"), + ("wechat_kf", "WeChat Customer Service"), + ("wechatmp", "WeChat Official Account"), + ("wechatmp_service", "WeChat Official Account (Service)"), + ("telegram", "Telegram"), + ("slack", "Slack"), + ("discord", "Discord"), + ] + en_channels = [ + ("web", "Web"), + ("terminal", "Terminal"), + ("telegram", "Telegram"), + ("slack", "Slack"), + ("discord", "Discord"), + ("weixin", "WeChat"), + ("feishu", "Feishu"), + ("dingtalk", "DingTalk"), + ("wecom_bot", "WeCom Bot"), + ("wechatcom_app", "WeCom App"), + ("wechat_kf", "WeChat Customer Service"), + ("wechatmp", "WeChat Official Account"), + ("wechatmp_service", "WeChat Official Account (Service)"), + ] + channels = en_channels if i18n.get_language() == "en" else zh_channels + name_width = max(len(name) for name, _ in channels) + for idx, (name, label) in enumerate(channels, 1): + logger.info(f"[WebChannel] {idx:>2}. {name:<{name_width}} - {label}") logger.info("[WebChannel] ✅ Web console is running") logger.info(f"[WebChannel] 🌐 Local access: http://localhost:{port}") if is_public_bind: From 0e4da1d1c58ad4be34e4d54ae5cdaf0b760c7e3a Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 6 Jun 2026 19:06:19 +0800 Subject: [PATCH 327/399] feat(cli): show project path in `cow status` --- cli/commands/process.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/commands/process.py b/cli/commands/process.py index 94e8126b..d6b1aaf4 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -275,7 +275,7 @@ def update(ctx): def status(): """Show CowAgent running status.""" from cli import __version__ - from cli.utils import load_config_json, get_cli_language + from cli.utils import load_config_json, get_cli_language, get_project_root # get_cli_language() calls ensure_sys_path(), which adds the project root # to sys.path. Import `common` only AFTER that, otherwise it fails with @@ -292,6 +292,11 @@ def status(): click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}")) + # Project path bound to this `cow` CLI — disambiguates which checkout the + # command actually controls when the user has multiple clones. + project_root = get_project_root() + click.echo(_t(f" 路径: {project_root}", f" Path: {project_root}")) + cfg = load_config_json() if cfg: channel = cfg.get("channel_type", "unknown") From ba777ed70630145c6089c69835dc2dfbd7ba92c5 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 7 Jun 2026 18:55:33 +0800 Subject: [PATCH 328/399] feat(evolution): add self-evolution subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a self-evolution subsystem that reviews idle conversations in an isolated agent and durably learns from them — patching/creating skills, finishing unfinished tasks, and backfilling missed memory. - Trigger: background idle scan, fires when a session is idle >= N min AND (>= N turns OR context usage > 80%). In-memory cursor reviews only new messages so a session never re-learns old content. - Isolated review agent: same model, restricted toolset, hard write-guard confining edits to the workspace (built-in skills are protected). - Safety: file-level backup before edits + evolution_undo tool; notify the user ONLY when a workspace file actually changed (no-nag rule); capped concurrency. - Records to memory/evolution/.md, surfaced in the memory UI's renamed "Self-Evolution" tab (merged with dream diaries). - Hide internal [SCHEDULED]/[EVOLUTION]/backup_id markers from chat history display (also fixes scheduler marker leakage) while keeping them in stored content for undo. - Flat config: self_evolution_enabled (default off until release), self_evolution_idle_minutes (15), self_evolution_min_turns (6). - Tests: tests/test_evolution.py (stub + real model modes, 7 scenarios). --- agent/evolution/__init__.py | 19 + agent/evolution/backup.py | 102 +++ agent/evolution/config.py | 76 +++ agent/evolution/executor.py | 449 +++++++++++++ agent/evolution/prompts.py | 163 +++++ agent/evolution/record.py | 55 ++ agent/evolution/trigger.py | 133 ++++ agent/memory/conversation_store.py | 51 +- agent/memory/service.py | 38 +- agent/protocol/agent_stream.py | 9 +- agent/tools/__init__.py | 4 + agent/tools/evolution_undo/__init__.py | 3 + agent/tools/evolution_undo/evolution_undo.py | 58 ++ bridge/agent_bridge.py | 24 + channel/web/chat.html | 2 +- channel/web/static/js/console.js | 22 +- config.py | 4 + plugins/keyword/keyword.py | 4 +- tests/test_evolution.py | 660 +++++++++++++++++++ 19 files changed, 1856 insertions(+), 20 deletions(-) create mode 100644 agent/evolution/__init__.py create mode 100644 agent/evolution/backup.py create mode 100644 agent/evolution/config.py create mode 100644 agent/evolution/executor.py create mode 100644 agent/evolution/prompts.py create mode 100644 agent/evolution/record.py create mode 100644 agent/evolution/trigger.py create mode 100644 agent/tools/evolution_undo/__init__.py create mode 100644 agent/tools/evolution_undo/evolution_undo.py create mode 100644 tests/test_evolution.py diff --git a/agent/evolution/__init__.py b/agent/evolution/__init__.py new file mode 100644 index 00000000..f9f665f3 --- /dev/null +++ b/agent/evolution/__init__.py @@ -0,0 +1,19 @@ +""" +Self-evolution subsystem for CowAgent. + +Runs a lightweight, isolated review pass after a conversation goes idle to +decide whether anything is worth durably learning (memory / skill) or whether +an unfinished task can be pushed forward. Conservative by design: most +conversations should produce no change at all. + +Public entry points: + from agent.evolution import get_evolution_config + from agent.evolution.trigger import start_evolution_trigger, note_user_turn +""" + +from agent.evolution.config import EvolutionConfig, get_evolution_config + +__all__ = [ + "EvolutionConfig", + "get_evolution_config", +] diff --git a/agent/evolution/backup.py b/agent/evolution/backup.py new file mode 100644 index 00000000..4f294b96 --- /dev/null +++ b/agent/evolution/backup.py @@ -0,0 +1,102 @@ +"""File backup / rollback support for self-evolution. + +Before the evolution agent edits MEMORY.md or a skill file, we snapshot the +current state into ``memory/.evolution_backups//`` so a later "undo" +can restore it. File-level restore only — simple and reliable. +""" + +from __future__ import annotations + +import json +import shutil +import time +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from common.log import logger + +_BACKUP_DIRNAME = ".evolution_backups" +_MANIFEST_NAME = "manifest.json" +# Keep only the most recent N backups to bound disk usage. +_MAX_BACKUPS = 10 + + +def _backups_root(workspace_dir: Path) -> Path: + return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME + + +def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]: + """Snapshot ``files`` (those that exist) under a new backup id. + + Returns the backup_id, or None when there is nothing to back up. + """ + existing = [Path(f) for f in files if Path(f).exists()] + if not existing: + return None + + backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000) + root = _backups_root(workspace_dir) + target = root / backup_id + try: + target.mkdir(parents=True, exist_ok=True) + ws = Path(workspace_dir) + manifest = [] + for idx, src in enumerate(existing): + # Store under a flat index plus the relative path so restore knows + # where it came from, even for nested skill files. + try: + rel = str(src.relative_to(ws)) + except ValueError: + rel = src.name + dst = target / f"{idx}.bak" + shutil.copy2(src, dst) + manifest.append({"rel": rel, "bak": f"{idx}.bak"}) + (target / _MANIFEST_NAME).write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + _prune_old_backups(root) + # Caller logs a combined backup+review line; keep this at debug. + logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))") + return backup_id + except Exception as e: + logger.warning(f"[Evolution] Failed to create backup: {e}") + return None + + +def restore_backup(workspace_dir: Path, backup_id: str) -> bool: + """Restore all files captured under ``backup_id``. Returns success.""" + if not backup_id: + return False + target = _backups_root(workspace_dir) / backup_id + manifest_path = target / _MANIFEST_NAME + if not manifest_path.exists(): + logger.warning(f"[Evolution] Backup not found: {backup_id}") + return False + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ws = Path(workspace_dir) + for entry in manifest: + bak = target / entry["bak"] + dst = ws / entry["rel"] + if bak.exists(): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bak, dst) + logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))") + return True + except Exception as e: + logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}") + return False + + +def _prune_old_backups(root: Path) -> None: + """Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological).""" + try: + dirs = sorted( + [d for d in root.iterdir() if d.is_dir()], + key=lambda p: p.name, + ) + for old in dirs[:-_MAX_BACKUPS]: + shutil.rmtree(old, ignore_errors=True) + except Exception as e: + logger.debug(f"[Evolution] Backup prune skipped: {e}") diff --git a/agent/evolution/config.py b/agent/evolution/config.py new file mode 100644 index 00000000..864cbb1e --- /dev/null +++ b/agent/evolution/config.py @@ -0,0 +1,76 @@ +"""Configuration for the self-evolution subsystem. + +Reads flat ``self_evolution_*`` keys from config.json. All fields have safe +defaults so the feature degrades gracefully when keys are absent. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +# Defaults — conservative (see executor module docstring). Disabled by default +# until release; enable via ``self_evolution_enabled``. +DEFAULT_ENABLED = False +DEFAULT_IDLE_MINUTES = 15 +DEFAULT_MIN_TURNS = 6 +# Max review steps for the isolated evolution agent. Kept small (not exposed as +# config): the review is meant to be cheap and focused, not a long autonomous run. +DEFAULT_MAX_STEPS = 12 + + +@dataclass +class EvolutionConfig: + """Resolved self-evolution settings.""" + + enabled: bool = DEFAULT_ENABLED + idle_minutes: int = DEFAULT_IDLE_MINUTES + min_turns: int = DEFAULT_MIN_TURNS + max_steps: int = DEFAULT_MAX_STEPS + + @property + def idle_seconds(self) -> int: + return max(60, self.idle_minutes * 60) + + +def _as_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return fallback + + +def _as_pos_int(value: Any, fallback: int) -> int: + try: + n = int(value) + return n if n > 0 else fallback + except (TypeError, ValueError): + return fallback + + +def get_evolution_config() -> EvolutionConfig: + """Build EvolutionConfig from the live config.json ``self_evolution_*`` keys.""" + try: + from config import conf + c = conf() + except Exception: + c = {} + + def _get(key, default): + try: + return c.get(key, default) + except Exception: + return default + + return EvolutionConfig( + enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED), + idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES), + min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS), + max_steps=DEFAULT_MAX_STEPS, + ) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py new file mode 100644 index 00000000..4c0c19d6 --- /dev/null +++ b/agent/evolution/executor.py @@ -0,0 +1,449 @@ +"""Self-evolution executor. + +Runs an isolated review agent over an idle conversation's transcript and, if a +clear signal is found, lets it edit memory / skills via a restricted toolset. +Conservative by design: most runs return ``[SILENT]`` and change nothing. + +Flow: + 1. Build a transcript from the session's new (since last pass) messages. + 2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id. + 3. Run an isolated agent (same model, restricted tools, evolution prompt). + 4. If output is [SILENT], or no workspace file actually changed -> done. + 5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into + the user session (so the main agent can honor "undo"), and push the + summary to the user's channel. + +Reuses existing infrastructure (AgentBridge.create_agent, ToolManager, +remember_scheduled_output, channel_factory) rather than introducing a fork. +""" + +from __future__ import annotations + +import threading +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from common.log import logger + +from agent.evolution.backup import create_backup +from agent.evolution.config import get_evolution_config +from agent.evolution.prompts import ( + EVOLUTION_MARKER, + EVOLUTION_SYSTEM_PROMPT, + SILENT_TOKEN, + build_review_user_message, +) +from agent.evolution.record import append_session_evolution + +# Tools the isolated evolution agent is allowed to use. Everything else is +# withheld so a review pass can only read context and edit memory/skill files. +_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"} + +# Cap concurrent evolution passes so a burst of idle sessions can't spawn many +# background model runs at once. Extra sessions simply wait for the next scan. +_MAX_CONCURRENT = 2 +_running_lock = threading.Lock() +_running_count = 0 + + +def _builtin_skill_names() -> set: + """Names of skills shipped with the product (project-root ``skills/``). + + These are protected: the evolution agent must never edit them, even though + a same-named copy exists in the workspace at runtime. The project dir is the + authoritative list of what counts as built-in. + """ + try: + # executor.py -> agent/evolution -> agent -> project root + project_root = Path(__file__).resolve().parents[2] + builtin_dir = project_root / "skills" + if not builtin_dir.is_dir(): + return set() + names = set() + for entry in builtin_dir.iterdir(): + if entry.is_dir() and not entry.name.startswith("."): + names.add(entry.name) + return names + except Exception: + return set() + + +def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str: + """Render the session messages into a compact text transcript.""" + lines: List[str] = [] + for msg in messages: + role = msg.get("role", "") + if role not in ("user", "assistant"): + continue + content = msg.get("content", "") + text = _extract_text(content) + if not text.strip(): + continue + speaker = "User" if role == "user" else "Assistant" + lines.append(f"{speaker}: {text.strip()}") + transcript = "\n".join(lines) + # Keep the most RECENT context if oversized (tail is most relevant). + if len(transcript) > max_chars: + transcript = "...(earlier omitted)...\n" + transcript[-max_chars:] + return transcript + + +def _extract_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + return "" + + +def _select_tools(all_tools: list) -> list: + return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS] + + +# Tools whose writes must be confined to the workspace during evolution. +_WRITE_TOOLS = {"write", "edit"} + + +class _WorkspaceWriteGuard: + """Wraps a write/edit tool so it can ONLY write inside the workspace. + + Hard engineering guard (not prompt-based): any write resolving outside the + workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This + protects built-in skills regardless of what the model attempts. + """ + + def __init__(self, inner, workspace_dir: str): + self._inner = inner + self._ws = Path(workspace_dir).resolve() + # Mirror the attributes the agent runtime reads off a tool. + self.name = inner.name + self.description = inner.description + self.params = inner.params + + def __getattr__(self, item): + return getattr(self._inner, item) + + def execute_tool(self, params): + # The agent runtime calls execute_tool (not execute); route it through + # our guarded execute so the path checks always run. + try: + return self.execute(params) + except Exception as e: + logger.error(f"[Evolution] guarded tool error: {e}") + from agent.tools.base_tool import ToolResult + return ToolResult.fail(f"Error: {e}") + + def execute(self, args): + path = (args.get("path") or "").strip() + if path: + try: + resolved = Path(self._inner._resolve_path(path)).resolve() + from agent.tools.base_tool import ToolResult + # Confine writes to the workspace. This protects the product's + # bundled skills (which live outside the workspace) from ever + # being modified, no matter what path the model attempts. + if self._ws not in resolved.parents and resolved != self._ws: + return ToolResult.fail( + "Error: evolution may only write inside the workspace; " + f"path '{path}' is outside and was blocked." + ) + except Exception: + pass + return self._inner.execute(args) + + +def _guard_tools(tools: list, workspace_dir: str) -> list: + """Wrap write/edit tools with the workspace guard; leave others as-is.""" + guarded = [] + for t in tools: + if getattr(t, "name", None) in _WRITE_TOOLS: + guarded.append(_WorkspaceWriteGuard(t, workspace_dir)) + else: + guarded.append(t) + return guarded + + +# Workspace subtrees worth watching for evolution-induced changes. +_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output") +# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly +# dream diary, none of which count as a user-facing change signal. +_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution") + + +def _workspace_snapshot(workspace_dir) -> dict: + """Map relative path -> (mtime, size) for watched files. Cheap, no reads.""" + ws = Path(workspace_dir) + snap: dict = {} + for name in _WATCH_SUBDIRS: + root = ws / name + if root.is_file(): + try: + st = root.stat() + snap[name] = (st.st_mtime, st.st_size) + except OSError: + pass + continue + if not root.is_dir(): + continue + for p in root.rglob("*"): + if not p.is_file(): + continue + try: + st = p.stat() + snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) + except OSError: + pass + + # Watch the daily memory files (memory/*.md and per-user dailies) since + # evolution now records learnings there. Skip backups/dreams bookkeeping. + mem_dir = ws / "memory" + if mem_dir.is_dir(): + for p in mem_dir.rglob("*.md"): + rel_parts = p.relative_to(mem_dir).parts + if rel_parts and rel_parts[0] in _MEMORY_IGNORE: + continue + try: + st = p.stat() + snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) + except OSError: + pass + return snap + + +def _workspace_changed(workspace_dir, pre: dict) -> bool: + """True if any watched file was added, removed, or modified since ``pre``.""" + return _workspace_snapshot(workspace_dir) != pre + + +def run_evolution_for_session( + agent_bridge, + session_id: str, + channel_type: str = "", + receiver: str = "", + user_id: Optional[str] = None, + idle_minutes: float = 0.0, +) -> bool: + """Run one evolution pass for a session. Returns True if it changed anything. + + Safe to call from a background thread. All failures are swallowed and + logged — evolution must never disrupt the main pipeline. + """ + cfg = get_evolution_config() + if not cfg.enabled: + return False + + # Concurrency gate: bound how many evolution passes run at once. + global _running_count + with _running_lock: + if _running_count >= _MAX_CONCURRENT: + logger.info( + f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); " + f"skipping session={session_id} this scan" + ) + return False + _running_count += 1 + + try: + agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent + if not agent: + return False + + with agent.messages_lock: + all_messages = list(agent.messages) + total_msgs = len(all_messages) + # In-memory evolution cursor: only review messages added since the last + # pass so a long session doesn't re-judge (and re-write) old content. + # Stored on the agent instance; lost on restart (acceptable — at worst + # one redundant pass right after a restart, gated by the file-change + # check downstream so it won't double-write identical memory). + done = int(getattr(agent, "_evo_done_msg_count", 0)) + if done > total_msgs: + done = 0 # history was trimmed/reset; start fresh + new_messages = all_messages[done:] + transcript = _build_transcript(new_messages) + if not transcript.strip(): + logger.info(f"[Evolution] session={session_id}: no new messages, skip") + # Advance the cursor anyway so we don't re-scan the same tail. + agent._evo_done_msg_count = total_msgs + return False + + logger.info( + f"[Evolution] ▶ Reviewing session={session_id} " + f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, " + f"~{len(transcript)} chars)" + ) + + # Resolve workspace + files to snapshot for undo. + from agent.memory.config import get_default_memory_config + mem_cfg = get_default_memory_config() + workspace_dir = mem_cfg.get_workspace() + if user_id: + memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md" + else: + memory_file = Path(workspace_dir) / "MEMORY.md" + skills_dir = mem_cfg.get_skills_dir() + + # Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected + # built-in skills are excluded from backup because they must never be + # edited in the first place. + protected_names = _builtin_skill_names() + # Back up both MEMORY.md and today's daily file: evolution now writes to + # the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe + # if the model ever edits it. + today_daily = Path(workspace_dir) / "memory" / ( + datetime.now().strftime("%Y-%m-%d") + ".md" + ) + if user_id: + today_daily = Path(workspace_dir) / "memory" / "users" / user_id / ( + datetime.now().strftime("%Y-%m-%d") + ".md" + ) + backup_files = [Path(memory_file), today_daily] + if skills_dir.exists(): + for skill_md in skills_dir.rglob("SKILL.md"): + # The skill dir is the SKILL.md's parent (or an ancestor for + # collections); guard by checking the immediate top-level dir. + try: + top = skill_md.relative_to(skills_dir).parts[0] + except (ValueError, IndexError): + continue + if top in protected_names: + continue + backup_files.append(skill_md) + backup_id = create_backup(workspace_dir, backup_files) + _backup_n = sum(1 for f in backup_files if Path(f).exists()) + + # Snapshot the whole workspace (path -> mtime/size) so we can reliably + # detect ANY file change — including new output files written when + # finishing an unfinished task, which are not in backup_files. + pre_snapshot = _workspace_snapshot(workspace_dir) + + # Build the isolated review agent: same model, restricted tools, with a + # hard guard that confines all writes to the workspace (protects the + # project's bundled skills from ever being modified). + review_tools = _guard_tools( + _select_tools(list(getattr(agent, "tools", []) or [])), + str(workspace_dir), + ) + review_agent = agent_bridge.create_agent( + system_prompt=EVOLUTION_SYSTEM_PROMPT, + tools=review_tools, + description="Self-evolution review agent", + max_steps=cfg.max_steps, + workspace_dir=str(workspace_dir), + skill_manager=getattr(agent, "skill_manager", None), + memory_manager=getattr(agent, "memory_manager", None), + enable_skills=False, + ) + # Reuse the live model so it follows the user's configured model. + review_agent.model = agent.model + + logger.info( + f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent" + ) + user_msg = build_review_user_message(transcript, protected_skills=list(protected_names)) + result = review_agent.run_stream(user_msg, clear_history=True) + result = (result or "").strip() + + # These messages are now reviewed; advance the cursor so the next pass + # only looks at messages added after this point (silent or not). + agent._evo_done_msg_count = total_msgs + + if not result or SILENT_TOKEN in result: + logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") + return False + + # Hard gate: an evolution only counts (and only notifies) if a workspace + # file ACTUALLY changed. If the model did real work (wrote memory / + # patched a skill / finished a task) the user is told; if it merely + # produced text without changing anything, we stay silent. This is the + # key anti-nag rule — no notification unless something was actually done. + if not _workspace_changed(workspace_dir, pre_snapshot): + logger.info( + f"[Evolution] ✗ session={session_id}: model produced text but " + f"changed no file — treating as silent" + ) + return False + + logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}") + append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id) + # Inject an [EVOLUTION] note so the main agent can honor "undo". + _inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id) + + # Push the summary to the user's channel. The "did a file actually + # change" gate above is the only throttle we need: real evolutions are + # rare, so no extra opt-in switch or daily-count limit is required. + if channel_type and receiver: + _notify_user(channel_type, receiver, result) + + return True + + except Exception as e: + logger.warning(f"[Evolution] Run failed for session={session_id}: {e}") + return False + finally: + with _running_lock: + _running_count -= 1 + + +def _inject_evolution_record( + agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str] +) -> None: + """Add an [EVOLUTION] note to the user session so the main agent can undo.""" + try: + note = f"{EVOLUTION_MARKER} {summary}" + if backup_id: + note += f"\n(backup_id: {backup_id}; to undo, restore this backup)" + # Reuse the scheduler-output injection path: isolated execution, only a + # compact record lands in the user session. + agent_bridge.remember_scheduled_output( + session_id=session_id, + content=note, + channel_type=channel_type, + task_description="self-evolution", + ) + except Exception as e: + logger.debug(f"[Evolution] Failed to inject evolution record: {e}") + + +def _notify_user(channel_type: str, receiver: str, summary: str) -> None: + """Push the evolution summary to the user's channel as a new message.""" + try: + from bridge.context import Context, ContextType + from bridge.reply import Reply, ReplyType + from channel.channel_factory import create_channel + + context = Context(ContextType.TEXT, summary) + context["receiver"] = receiver + context["isgroup"] = False + context["session_id"] = receiver + # Channels that reply to an original message need msg=None for a fresh push. + if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"): + context["msg"] = None + if channel_type == "feishu": + context["receive_id_type"] = "open_id" + + channel = create_channel(channel_type) + if not channel: + return + + # Web is request-response: a background push needs a synthetic request_id + # plus a request->session mapping so the channel can route the message to + # the user's polling queue (same approach the scheduler uses). + if channel_type == "web": + import uuid + request_id = f"evolution_{uuid.uuid4().hex[:8]}" + context["request_id"] = request_id + if hasattr(channel, "request_to_session"): + channel.request_to_session[request_id] = receiver + + channel.send(Reply(ReplyType.TEXT, summary), context) + logger.info(f"[Evolution] Notified user via {channel_type}") + except Exception as e: + logger.warning(f"[Evolution] Failed to notify user: {e}") diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py new file mode 100644 index 00000000..39f01989 --- /dev/null +++ b/agent/evolution/prompts.py @@ -0,0 +1,163 @@ +"""Prompts for the self-evolution review agent. + +The system prompt is intentionally English-only: it governs the agent's +internal reasoning and is more stable / cheaper to maintain in one language. +The user-facing summary the agent produces should follow the user's own +language (instructed at the end of the prompt). + +Design goals (see ref/hermes-agent background_review for inspiration): + - Default to doing NOTHING. Evolution is the exception, not the rule. + - Three signal types: memory, skill, unfinished task. + - An explicit "do NOT capture" list to avoid self-poisoning over time. + - Generic examples only — never bake in domain-specific business terms. +""" + +# Sentinel the agent emits when there is nothing worth evolving. +SILENT_TOKEN = "[SILENT]" + +# Marker prefix for the evolution record injected into the user session, so the +# main chat agent can recognize past evolutions and honor an "undo" request. +EVOLUTION_MARKER = "[EVOLUTION]" + + +EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant. + +You are given a transcript of a conversation that just went idle. Your job is to +decide whether anything from it is worth durably learning so future +conversations go better — and if so, to make that change. + +# Top principle: default to doing NOTHING + +Most ordinary conversations need no evolution. Only act when there is a CLEAR +signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying +silent is the normal, correct outcome — not a failure. + +Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual +chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not +explore files, do not write a summary, do not be polite. Just `[SILENT]`. + +IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a +tool (write/edit) in this pass. If you did not change any file, you MUST output +exactly `[SILENT]` — never describe a change you only intended to make. + +# Signals worth acting on (act only if at least one clearly appears) + +SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles +them. When their signal is clear, act; do not be shy here. + +1. SKILL — two cases: + a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a + missing step/section, a wrong or outdated detail, an error in its + content), or its OUTPUT repeatedly misses something the user flagged. Read + the relevant skill file under the skills directory and make a small + incremental edit so it never recurs. + b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that + no existing skill covers and the user is likely to want again. To create + one, follow the `skill-creator` skill's conventions (read its SKILL.md for + the required structure) and write the new skill under the workspace + `skills/` directory. Only create when the workflow is genuinely reusable — + not for a one-off task. + + CRITICAL — fix the SOURCE, do not just remember the symptom: when the root + cause of a problem lives IN a skill file itself (its instructions, content, + or configuration are wrong/outdated), the correct action is to EDIT that + skill so the problem cannot recur. Recording the corrected fact in memory + does NOT prevent recurrence — only fixing the skill does. Never log "skill X + has wrong detail Y" as a memory note in place of editing skill X. + +2. UNFINISHED TASK — a specific deliverable you promised but didn't produce, + AND you already have everything needed to finish it. DO IT now with the + available tools and produce the result (e.g. write the file you said you'd + write). If key info is missing, or the task is merely waiting on the user's + reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user. + You only ever notify the user as a side effect of having actually done work. + +3. MEMORY — LAST resort, and you are only a SAFETY NET here, not the primary + writer. The main assistant already writes memory DURING the conversation, and + a nightly pass consolidates daily notes into long-term memory. Prefer fixing + a skill (above) over writing memory whenever the fact belongs in a skill. + Act ONLY on something the main assistant clearly MISSED that does not belong + in any skill. + - MEMORY.md is the curated long-term index, auto-loaded into EVERY future + conversation. Treat it as precious: writing here is RARE and reserved for + CORRECTING a wrong fact already in MEMORY.md (edit that line in place). + Do NOT append new entries to MEMORY.md — that is the nightly pass's job. + - For a genuinely important NEW durable fact the chat missed, append ONE + short bullet to today's `memory/YYYY-MM-DD.md` (not MEMORY.md). When unsure, + the daily file is the safe place — but first ask whether this really + belongs in a skill instead. + - Keep it to ONE short bullet. Never write paragraphs, never re-summarize the + conversation, never copy what the main assistant already recorded. + - If it is already captured anywhere (check MEMORY.md AND the daily file + first), do NOTHING. + +# Do NOT capture (these poison future behavior) + +- Environment failures: missing binaries, unset credentials, uninstalled + packages, "command not found". The user can fix these; they are not durable + rules. +- Negative claims about tools or features ("tool X does not work"). These + harden into refusals the agent cites against itself later. +- One-off task narratives (e.g. summarizing today's content). Not a class of + reusable work. +- Transient errors that resolved on retry within the conversation. + +# Execution constraints + +- Before changing memory or a skill, READ the current content first and make a + small INCREMENTAL edit. Never fabricate, never rewrite large sections. +- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's + daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded + in EITHER (even if worded differently), do NOT add it again. The main + assistant likely already wrote it during the chat — only add what is + genuinely new or a correction not yet reflected anywhere. +- You may only edit files inside the workspace. Built-in skills shipped with + the product live outside it and are write-protected; do not try to edit them. +- Make at most the few edits the signals justify; do not go looking for work. + +# Output + +- Nothing worth evolving -> output exactly `[SILENT]` and nothing else. +- Otherwise, after performing the edits, output a short user-facing summary in + the SAME LANGUAGE the user used in the conversation. Tell the user, briefly: + 1) that you just did a self-learning pass, + 2) what you learned and what you changed (in plain terms — no need to cite + exact file paths; "remembered X" / "improved the weekly-report skill" is + enough). + Keep it to 1-3 lines. Generic shape (do not copy domain words): + "I just did a self-learning pass. + - Learned: + - Changed: skill / finished > + Reply 'undo the last learning' if this is wrong." +""" + + +def build_review_user_message(transcript: str, protected_skills: list = None) -> str: + """Wrap the conversation transcript as the review agent's user message. + + ``protected_skills`` lists skill names that must never be edited (built-in + skills shipped with the product). Surfaced so the agent avoids them. + """ + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + + protected_note = "" + if protected_skills: + names = ", ".join(sorted(protected_skills)) + protected_note = ( + "\n\nPROTECTED skills (built-in — never edit these): " + f"{names}\n" + ) + return ( + "Here is the conversation transcript that just went idle. Review it per " + "your instructions and act on any clear signal. Prefer fixing a skill at " + "its source over writing memory whenever the fact belongs in a skill.\n" + f"Today is {today}. Only if a fact genuinely belongs in memory (and not " + f"in a skill): append one short bullet to the daily file " + f"`memory/{today}.md` for a new fact, or edit MEMORY.md in place to " + f"correct an existing wrong fact." + f"{protected_note}\n" + "\n" + f"{transcript}\n" + "" + ) diff --git a/agent/evolution/record.py b/agent/evolution/record.py new file mode 100644 index 00000000..751e3a83 --- /dev/null +++ b/agent/evolution/record.py @@ -0,0 +1,55 @@ +"""Self-evolution record log. + +Session-level evolutions are appended to their OWN per-day file under +``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary +in ``memory/dreams/``). Each day's file accumulates one short section per +evolution pass — tagged with a timestamp and a backup id for undo — so the +memory UI can surface "what the agent learned/changed today" on one timeline +without ever mixing into the dream diary or the main conversation memory. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Optional + +from common.log import logger + + +def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path: + base = Path(workspace_dir) / "memory" + if user_id: + return base / "users" / user_id / "evolution" + return base / "evolution" + + +def append_session_evolution( + workspace_dir: Path, + summary: str, + backup_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> None: + """Append a session-evolution entry to today's evolution log.""" + if not summary or not summary.strip(): + return + try: + evo_dir = _evolution_dir(workspace_dir, user_id) + evo_dir.mkdir(parents=True, exist_ok=True) + today = datetime.now().strftime("%Y-%m-%d") + log_file = evo_dir / f"{today}.md" + + ts = datetime.now().strftime("%H:%M") + header = f"## {ts}" + body = summary.strip() + if backup_id: + body += f"\n\n_backup_id: {backup_id}_" + + # Create with a title if the file is new, otherwise append a section. + if not log_file.exists(): + log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"\n{header}\n\n{body}\n") + logger.info(f"[Evolution] Recorded session evolution to {log_file.name}") + except Exception as e: + logger.warning(f"[Evolution] Failed to record session evolution: {e}") diff --git a/agent/evolution/trigger.py b/agent/evolution/trigger.py new file mode 100644 index 00000000..8055cbae --- /dev/null +++ b/agent/evolution/trigger.py @@ -0,0 +1,133 @@ +"""Idle-based evolution trigger. + +A single background thread periodically scans live agent sessions and runs an +evolution pass for any session that is idle for >= idle_minutes AND has enough +accumulated signal, where "enough signal" is EITHER: + - >= min_turns user turns since the last evolution, OR + - the live context has grown past _CONTEXT_RATIO of the agent's token budget + (mirrors how OpenClacky / Claude Code consolidate under context pressure). + +Turn counting is per user turn (not per message), measured from the last +evolution (or session start). After a pass runs, the baseline resets so a long +session can evolve multiple times without re-judging old content. + +Per-session evolution state is stored on the agent instance via lightweight +attributes set by AgentBridge.agent_reply (see _note_user_turn). +""" + +from __future__ import annotations + +import threading +import time + +from common.log import logger + +from agent.evolution.config import get_evolution_config +from agent.evolution.executor import run_evolution_for_session + +_SCAN_INTERVAL_SECONDS = 60 + +# Context-pressure trigger: evolve once the live context exceeds this fraction +# of the agent's token budget, even if min_turns hasn't been reached. Kept as a +# module constant (not user config) for now. Fallback budget matches +# agent_initializer / config.py (agent_max_context_tokens default = 50000). +_CONTEXT_RATIO = 0.8 +_FALLBACK_CONTEXT_BUDGET = 50000 + + +def _context_pressure_reached(agent) -> bool: + """True if the agent's live context exceeds _CONTEXT_RATIO of its budget. + + Uses the agent's own (estimated) token accounting so behavior matches the + existing context-trimming path. Best-effort: any error -> False. + """ + try: + with agent.messages_lock: + messages = list(agent.messages) + if not messages: + return False + est = sum(agent._estimate_message_tokens(m) for m in messages) + budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET + return est / budget > _CONTEXT_RATIO + except Exception: + return False + + +def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None: + """Record activity for a session's agent. Called once per real user turn. + + Maintains, on the agent instance: + _evo_last_active : epoch seconds of the last user turn + _evo_turns : user turns since the last evolution + _evo_channel_type : originating channel (for later notify) + _evo_receiver : push target for notify + """ + try: + agent._evo_last_active = time.time() + agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1 + if channel_type: + agent._evo_channel_type = channel_type + if receiver: + agent._evo_receiver = receiver + except Exception: + pass + + +def start_evolution_trigger(agent_bridge) -> None: + """Start the idle-scan thread once per process (idempotent).""" + if getattr(agent_bridge, "_evolution_trigger_started", False): + return + agent_bridge._evolution_trigger_started = True + + t = threading.Thread( + target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger" + ) + t.start() + logger.info("[Evolution] Idle trigger started") + + +def _scan_loop(agent_bridge) -> None: + while True: + try: + time.sleep(_SCAN_INTERVAL_SECONDS) + cfg = get_evolution_config() + if not cfg.enabled: + continue + _scan_once(agent_bridge, cfg) + except Exception as e: + logger.warning(f"[Evolution] Scan loop error: {e}") + time.sleep(_SCAN_INTERVAL_SECONDS) + + +def _scan_once(agent_bridge, cfg) -> None: + now = time.time() + # Snapshot to avoid holding the dict while running long evolutions. + sessions = list(getattr(agent_bridge, "agents", {}).items()) + for session_id, agent in sessions: + try: + last_active = getattr(agent, "_evo_last_active", 0) + turns = int(getattr(agent, "_evo_turns", 0)) + # Enough signal = enough turns OR enough context pressure. + enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent) + if not enough_signal: + continue + idle = now - last_active if last_active > 0 else -1 + if last_active <= 0 or idle < cfg.idle_seconds: + continue + + channel_type = getattr(agent, "_evo_channel_type", "") or "" + receiver = getattr(agent, "_evo_receiver", "") or "" + + # Reset baseline BEFORE running so a long pass / new messages during + # it don't double-trigger; turns accrue fresh from here. + agent._evo_turns = 0 + + run_evolution_for_session( + agent_bridge, + session_id=session_id, + channel_type=channel_type, + receiver=receiver, + idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0, + ) + except Exception as e: + logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}") diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py index 23a81192..305e2576 100644 --- a/agent/memory/conversation_store.py +++ b/agent/memory/conversation_store.py @@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db from __future__ import annotations import json +import re import sqlite3 import threading import time @@ -109,6 +110,43 @@ def _extract_display_text(content: Any) -> str: return "" +# Internal markers written into the session for the agent's own bookkeeping +# (scheduler injection / self-evolution undo). They must stay in the stored +# content (the LLM reads them, e.g. to find a backup_id for undo) but should +# never be shown verbatim to the user in the chat history UI. +_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task") +_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]" + + +def _is_internal_user_marker(text: str) -> bool: + """True if a user-turn text is an internal injection marker (hide from UI).""" + t = (text or "").lstrip() + return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS) + + +def _clean_display_text(text: str) -> str: + """Strip internal markers from assistant text for user-facing display. + + Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)`` + undo hint. The raw stored message is untouched, so undo + LLM context still + work; only the rendered chat bubble is cleaned. + """ + if not text: + return text + cleaned = text + stripped = cleaned.lstrip() + if stripped.startswith(_EVOLUTION_DISPLAY_MARKER): + cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip() + # Drop a trailing backup_id undo hint line, e.g. + # "(backup_id: 20260607-...; to undo, restore this backup)" + cleaned = re.sub( + r"\n*\(backup_id:[^\)]*\)\s*$", + "", + cleaned, + ).rstrip() + return cleaned + + def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]: """ Extract tool_use blocks from an assistant message content. @@ -210,7 +248,10 @@ def _group_into_display_turns( if user_row: content, created_at, _u_extras = user_row text = _extract_display_text(content) - if text: + # Hide internal injection markers (scheduler / self-evolution) so the + # user never sees a synthetic "[SCHEDULED] self-evolution" bubble; + # the assistant reply that follows is still rendered. + if text and not _is_internal_user_marker(text): turns.append({"role": "user", "content": text, "created_at": created_at}) # Build an ordered list of steps preserving the original sequence: @@ -265,6 +306,14 @@ def _group_into_display_turns( step["result"] = tr.get("result", "") step["is_error"] = tr.get("is_error", False) + # Clean internal markers from the user-facing assistant text. Applies to + # both the final content and the mirrored content step so the rendered + # bubble shows clean text while the stored message keeps the markers. + final_text = _clean_display_text(final_text) + for step in steps: + if step.get("type") == "content": + step["content"] = _clean_display_text(step.get("content", "")) + if steps or final_text: turn = { "role": "assistant", diff --git a/agent/memory/service.py b/agent/memory/service.py index eb565b13..055fe831 100644 --- a/agent/memory/service.py +++ b/agent/memory/service.py @@ -34,13 +34,18 @@ class MemoryService: # ------------------------------------------------------------------ def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict: """ - List memory or dream files with metadata (without content). + List memory, dream, or evolution files with metadata (without content). Args: category: ``"memory"`` (default) — MEMORY.md + daily files; - ``"dream"`` — dream diary files from memory/dreams/ + ``"dream"`` — dream diary files from memory/dreams/; + ``"evolution"`` — self-evolution logs from memory/evolution/ + merged with the nightly dream diaries, so + one tab shows everything the agent learned. """ - if category == "dream": + if category == "evolution": + files = self._list_evolution_files() + elif category == "dream": files = self._list_dream_files() else: files = self._list_memory_files() @@ -93,6 +98,26 @@ class MemoryService: return files + def _list_evolution_files(self) -> List[dict]: + """Self-evolution logs (memory/evolution/*.md) merged with the nightly + dream diaries (memory/dreams/*.md), newest first. + + Both are surfaced under the unified "Self-Evolution" tab. A file's + ``type`` records its origin so the reader can resolve the right dir. + """ + files: List[dict] = [] + for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")): + sub_dir = os.path.join(self.memory_dir, sub) + if not os.path.isdir(sub_dir): + continue + for name in os.listdir(sub_dir): + full = os.path.join(sub_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + files.append(self._file_info(full, name, ftype)) + # Sort newest first by filename (date-named); ties favor evolution. + files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True) + return files + # ------------------------------------------------------------------ # content — read a single file # ------------------------------------------------------------------ @@ -101,7 +126,7 @@ class MemoryService: Read the full content of a memory or dream file. :param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md`` - :param category: ``"memory"`` or ``"dream"`` + :param category: ``"memory"``, ``"dream"`` or ``"evolution"`` :return: dict with ``filename`` and ``content`` :raises FileNotFoundError: if the file does not exist """ @@ -125,7 +150,7 @@ class MemoryService: Dispatch a memory management action. :param action: ``list`` or ``content`` - :param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``) + :param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``) :return: protocol-compatible response dict """ payload = payload or {} @@ -166,6 +191,7 @@ class MemoryService: - ``MEMORY.md`` → ``{workspace_root}/MEMORY.md`` - ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md`` - ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md`` + - ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md`` Raises ValueError if the resolved path escapes the allowed directory. """ @@ -173,6 +199,8 @@ class MemoryService: base_dir = self.workspace_root elif category == "dream": base_dir = os.path.join(self.memory_dir, "dreams") + elif category == "evolution": + base_dir = os.path.join(self.memory_dir, "evolution") else: base_dir = self.memory_dir diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index f2be2ab7..1b49baa7 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -347,11 +347,14 @@ class AgentStreamExecutor: Returns: Final response text """ - # Log user message with model info - + # Log user message with model info. Truncate very long messages (e.g. + # injected transcripts / large prompts) so logs stay readable. thinking_enabled = self._is_thinking_enabled() thinking_label = " | 💭 thinking" if thinking_enabled else "" - logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {user_message}") + _log_msg = user_message if len(user_message) <= 500 else ( + user_message[:500] + f" …(+{len(user_message) - 500} chars)" + ) + logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}") # Add user message (Claude format - use content blocks for consistency) self.messages.append({ diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 78e7b979..1e508c0c 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -14,6 +14,9 @@ from agent.tools.send.send import Send from agent.tools.memory.memory_search import MemorySearchTool from agent.tools.memory.memory_get import MemoryGetTool +# Import self-evolution tools +from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool + # Import tools with optional dependencies def _import_optional_tools(): """Import tools that have optional dependencies""" @@ -135,6 +138,7 @@ __all__ = [ 'Send', 'MemorySearchTool', 'MemoryGetTool', + 'EvolutionUndoTool', 'EnvConfig', 'SchedulerTool', 'WebSearch', diff --git a/agent/tools/evolution_undo/__init__.py b/agent/tools/evolution_undo/__init__.py new file mode 100644 index 00000000..a51a9551 --- /dev/null +++ b/agent/tools/evolution_undo/__init__.py @@ -0,0 +1,3 @@ +from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool + +__all__ = ["EvolutionUndoTool"] diff --git a/agent/tools/evolution_undo/evolution_undo.py b/agent/tools/evolution_undo/evolution_undo.py new file mode 100644 index 00000000..abfbc289 --- /dev/null +++ b/agent/tools/evolution_undo/evolution_undo.py @@ -0,0 +1,58 @@ +"""Evolution undo tool. + +Lets the main chat agent roll back a previous self-evolution when the user asks +("undo the last learning"). The rollback itself is a deterministic FILE RESTORE +from the snapshot taken before the evolution — the model only supplies the +backup_id it reads from the [EVOLUTION] record in the conversation. No LLM-driven +re-editing is involved, so a restore can never make things worse. +""" + +from agent.tools.base_tool import BaseTool, ToolResult + + +class EvolutionUndoTool(BaseTool): + """Restore memory/skill files to the state before a self-evolution.""" + + name: str = "evolution_undo" + description: str = ( + "Undo a previous self-evolution (self-learning) by restoring the " + "memory/skill files to their state before that learning. Use this when " + "the user asks to undo / revert / roll back the last self-learning. " + "Find the backup_id in the most recent [EVOLUTION] record in the " + "conversation and pass it here." + ) + params: dict = { + "type": "object", + "properties": { + "backup_id": { + "type": "string", + "description": ( + "The backup_id from the [EVOLUTION] record to restore " + "(e.g. '20260607-155551-850')." + ), + } + }, + "required": ["backup_id"], + } + + def execute(self, args: dict): + backup_id = (args.get("backup_id") or "").strip() + if not backup_id: + return ToolResult.fail("Error: backup_id is required") + try: + from agent.memory.config import get_default_memory_config + from agent.evolution.backup import restore_backup + + workspace_dir = get_default_memory_config().get_workspace() + ok = restore_backup(workspace_dir, backup_id) + if ok: + return ToolResult.success( + f"Restored memory/skills to the state before evolution " + f"{backup_id}. The previous self-learning has been undone." + ) + return ToolResult.fail( + f"Could not find or restore backup {backup_id}. It may have " + f"expired or already been rolled back." + ) + except Exception as e: + return ToolResult.fail(f"Error during undo: {e}") diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index e8424eb5..09d21c06 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -295,6 +295,13 @@ class AgentBridge: self.scheduler_initialized = True except Exception as e: logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}") + + # Start the self-evolution idle trigger (idempotent, daemon thread). + try: + from agent.evolution.trigger import start_evolution_trigger + start_evolution_trigger(self) + except Exception as e: + logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}") def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent: """ Create the super agent with COW integration @@ -547,6 +554,23 @@ class AgentBridge: except Exception as e: logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}") + # Record this user turn for the self-evolution idle trigger. Skip + # scheduler-injected / scheduled-task sessions so internal runs do + # not count as user activity. + if session_id and not session_id.startswith("scheduler_") and not ( + context and context.get("is_scheduled_task") + ): + try: + from agent.evolution.trigger import note_user_turn + ch = (context.get("channel_type") or "") if context else "" + rcv = (context.get("receiver") or "") if context else "" + is_group = bool(context.get("isgroup")) if context else False + # Only enable proactive push for single chats (group push is + # noisy); group sessions still evolve, just without notify. + note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else "")) + except Exception: + pass + # Post-message hot-reload: detect edits to ~/cow/mcp.json and # sync any new/removed MCP tools into the live agent in the # background. Off the critical path so user latency is unaffected; diff --git a/channel/web/chat.html b/channel/web/chat.html index 96bb67bd..35e6ad9b 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -760,7 +760,7 @@ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 31c8d271..e5ab5f25 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -140,7 +140,7 @@ const I18N = { skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', - memory_tab_files: '记忆文件', memory_tab_dreams: '梦境日记', + memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', @@ -342,7 +342,7 @@ const I18N = { skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable', skill_toggle_error: 'Operation failed, please try again', memory_title: 'Memory', memory_desc: 'View agent memory files and contents', - memory_tab_files: 'Memory Files', memory_tab_dreams: 'Dream Diary', + memory_tab_files: 'Memory Files', memory_tab_dreams: 'Self-Evolution', memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here', memory_back: 'Back to list', memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated', @@ -4304,13 +4304,14 @@ function toggleSkill(name, currentlyEnabled) { // Memory View // ===================================================================== let memoryPage = 1; -let memoryCategory = 'memory'; // 'memory' | 'dream' +let memoryCategory = 'memory'; // 'memory' | 'evolution' const memoryPageSize = 10; function switchMemoryTab(tab) { document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active')); document.getElementById('memory-tab-' + tab).classList.add('active'); - memoryCategory = tab === 'dreams' ? 'dream' : 'memory'; + // The "dreams" tab now surfaces self-evolution logs (merged with dream diaries). + memoryCategory = tab === 'dreams' ? 'evolution' : 'memory'; loadMemoryView(1); } @@ -4327,9 +4328,9 @@ function loadMemoryView(page) { if (total === 0) { const emptyIcon = emptyEl.querySelector('i'); const emptyTitle = emptyEl.querySelector('p'); - if (memoryCategory === 'dream') { - emptyIcon.className = 'fas fa-moon text-purple-400 text-xl'; - emptyTitle.textContent = currentLang === 'zh' ? '暂无梦境日记' : 'No dream diaries yet'; + if (memoryCategory === 'evolution') { + emptyIcon.className = 'fas fa-seedling text-emerald-400 text-xl'; + emptyTitle.textContent = currentLang === 'zh' ? '暂无进化记录' : 'No evolution records yet'; } else { emptyIcon.className = 'fas fa-brain text-purple-400 text-xl'; emptyTitle.textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files'; @@ -4346,10 +4347,15 @@ function loadMemoryView(page) { files.forEach(f => { const tr = document.createElement('tr'); tr.className = 'border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors'; - tr.onclick = () => openMemoryFile(f.filename, memoryCategory); + // In the merged evolution tab, resolve each file by its own origin + // (evolution logs vs dream diaries live in different dirs). + const fileCategory = (f.type === 'dream' || f.type === 'evolution') ? f.type : memoryCategory; + tr.onclick = () => openMemoryFile(f.filename, fileCategory); let typeLabel; if (f.type === 'global') { typeLabel = 'Global'; + } else if (f.type === 'evolution') { + typeLabel = 'Evolution'; } else if (f.type === 'dream') { typeLabel = 'Dream'; } else { diff --git a/config.py b/config.py index da1935d5..cd54d29a 100644 --- a/config.py +++ b/config.py @@ -251,6 +251,10 @@ available_setting = { "enable_thinking": False, # Enable deep-thinking mode for thinking-capable models "reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max" "knowledge": True, # whether to enable the knowledge base feature + # Self-evolution: review idle conversations to learn memory/skills. Flat keys. + "self_evolution_enabled": False, # master switch (off until release) + "self_evolution_idle_minutes": 15, # idle time before a session is reviewed + "self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL__ env vars at startup "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) } diff --git a/plugins/keyword/keyword.py b/plugins/keyword/keyword.py index b5fced39..45ebd3ab 100644 --- a/plugins/keyword/keyword.py +++ b/plugins/keyword/keyword.py @@ -26,12 +26,12 @@ class Keyword(Plugin): config_path = os.path.join(curdir, "config.json") conf = None if not os.path.exists(config_path): - logger.debug(f"[keyword]不存在配置文件{config_path}") + logger.debug(f"[keyword] config file not found: {config_path}") conf = {"keyword": {}} with open(config_path, "w", encoding="utf-8") as f: json.dump(conf, f, indent=4) else: - logger.debug(f"[keyword]加载配置文件{config_path}") + logger.debug(f"[keyword] loading config file: {config_path}") with open(config_path, "r", encoding="utf-8") as f: conf = json.load(f) # 加载关键词 diff --git a/tests/test_evolution.py b/tests/test_evolution.py new file mode 100644 index 00000000..7efe770e --- /dev/null +++ b/tests/test_evolution.py @@ -0,0 +1,660 @@ +"""Self-evolution test harness. + +Simulates multiple realistic conversations and checks the evolution pass behaves +correctly: stays silent when it should, evolves (memory/skill) when it should, +backs up before editing, notifies the user, and supports undo. + +Two modes: + - stub (default): the review agent's reasoning is replaced by a scripted + output per scenario. Fast, deterministic, validates the WIRING (backup, + record, inject, notify, undo, protection). No model calls. + - real: the review agent runs the configured model for real. Validates the + QUALITY of the judgement (does it correctly decide to act / stay silent). + +Run: + python tests/test_evolution.py # stub mode + python tests/test_evolution.py --real # real model mode +""" + +import os +import sys +import shutil +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- +class FakeChannel: + """Captures channel.send calls instead of sending.""" + + def __init__(self): + self.sent = [] + + def send(self, reply, context): + self.sent.append({"content": getattr(reply, "content", str(reply)), "receiver": context.get("receiver")}) + + +class FakeModel: + pass + + +class FakeAgent: + """Minimal stand-in for a chat Agent.""" + + def __init__(self, messages, tools=None): + import threading + self.messages = messages + self.messages_lock = threading.Lock() + self.tools = tools or [] + self.model = FakeModel() + self.skill_manager = None + self.memory_manager = None + + +class FakeReviewAgent: + """Review agent whose run_stream returns a scripted result (stub mode).""" + + def __init__(self, scripted_output, workspace, on_edit=None): + self._out = scripted_output + self._workspace = workspace + self._on_edit = on_edit + self.model = None + + def run_stream(self, user_message, clear_history=False, **kwargs): + # Simulate the side effects a real review agent would perform. + if self._on_edit: + self._on_edit(self._workspace) + return self._out + + +class FakeAgentBridge: + """Stand-in for AgentBridge wiring used by the executor.""" + + def __init__(self, agent, scripted_output, on_edit=None): + self.agents = {"session_test": agent} + self.default_agent = agent + self._scripted = scripted_output + self._on_edit = on_edit + self.injected = [] + + def create_agent(self, **kwargs): + from agent.memory.config import get_default_memory_config + ws = get_default_memory_config().get_workspace() + return FakeReviewAgent(self._scripted, ws, on_edit=self._on_edit) + + def remember_scheduled_output(self, session_id, content, channel_type="", task_description=""): + self.injected.append(content) + + +# --------------------------------------------------------------------------- +# Test scaffolding +# --------------------------------------------------------------------------- +def _setup_workspace(): + """Create a realistic temp workspace: seeded memory + real editable skills. + + Mirrors a real CowAgent workspace closely enough that the model has genuine + content to read, reason about, and edit during a real evolution pass. + """ + ws = Path(tempfile.mkdtemp(prefix="evo_test_")) + (ws / "MEMORY.md").write_text( + "# Long-term Memory\n\n" + "## User\n" + "- Name: 大锤 (David)\n" + "- Lives in Shenzhen, works as a backend engineer\n" + "- Company: a fintech startup, team of 8\n\n" + "## Preferences\n" + "- Likes detailed technical explanations\n", + encoding="utf-8", + ) + (ws / "memory").mkdir() + (ws / "output").mkdir() + skills = ws / "skills" + + # Editable skill 1: weekly report generator (has a structural gap: no risk). + (skills / "weekly-report").mkdir(parents=True) + (skills / "weekly-report" / "SKILL.md").write_text( + "# Weekly Report\n\n" + "Generate a weekly work report from the user's notes.\n\n" + "## Steps\n" + "1. Collect this week's completed items.\n" + "2. Summarize key progress in 3-5 bullets.\n" + "3. List next week's plan.\n\n" + "## Output format\n" + "Markdown with sections: 本周进展 / 下周计划\n", + encoding="utf-8", + ) + + # Editable skill 2: expense tracker (has a wrong currency-format step). + (skills / "expense-tracker").mkdir(parents=True) + (skills / "expense-tracker" / "SKILL.md").write_text( + "# Expense Tracker\n\n" + "Record an expense into output/expenses.md.\n\n" + "## Steps\n" + "1. Parse amount and category from the user message.\n" + "2. Append a row to output/expenses.md.\n" + "3. Format the amount with a `$` prefix.\n", + encoding="utf-8", + ) + + # Editable skill 3: an API caller whose SKILL.md hardcodes a WRONG endpoint + # host. The conversation discovers the correct host at runtime; the right + # fix is to edit this file's source, not just log the corrected fact. + (skills / "data-fetch").mkdir(parents=True) + (skills / "data-fetch" / "SKILL.md").write_text( + "# Data Fetch\n\n" + "Fetch records from the data service.\n\n" + "## Steps\n" + "1. Build the request payload from the user's query.\n" + "2. POST it to `https://api.example-wrong.com/v1/fetch`.\n" + "3. Parse and return the `data` field.\n", + encoding="utf-8", + ) + + # Protected built-in skill: must never be edited by evolution. + (skills / "image-generation").mkdir(parents=True) + (skills / "image-generation" / "SKILL.md").write_text( + "# Image Generation (built-in)\nDo not modify.\n", encoding="utf-8" + ) + return ws + + +def _point_config_at(ws): + """Force the global memory config to use the temp workspace.""" + from agent.memory.config import MemoryConfig, set_global_memory_config + set_global_memory_config(MemoryConfig(workspace_root=str(ws))) + + +def _make_messages(turns): + msgs = [] + for u, a in turns: + msgs.append({"role": "user", "content": u}) + msgs.append({"role": "assistant", "content": a}) + return msgs + + +# --------------------------------------------------------------------------- +# Scenarios +# --------------------------------------------------------------------------- +def scenario_silent(): + """Pure small talk -> should stay SILENT (no change, no notify).""" + return { + "name": "闲聊 (should stay SILENT)", + "goal": "none", + "turns": [ + ("在吗", "在的,有什么可以帮你?"), + ("今天周五了,终于要放假了", "是呀,周末好好休息一下。"), + ("哈哈是的,那没事了", "好的,随时找我。"), + ], + "scripted": "[SILENT]", + "on_edit": None, + "expect_evolved": False, + } + + +def scenario_memory_preference(): + """User states a durable working preference -> update MEMORY.md.""" + def edit(ws): + from datetime import datetime + p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md") + p.write_text((p.read_text() if p.exists() else "") + "- Prefers concise replies; avoid long explanations\n", encoding="utf-8") + + return { + "name": "记忆-偏好 (should note in daily memory)", + "goal": "memory", + "turns": [ + ("你能帮我解释下什么是数据库事务吗", "当然可以。数据库事务(Transaction)是一组要么全部成功、要么全部失败的操作单元,它有四个核心特性 ACID:原子性、一致性、隔离性、持久性……(接着是 5 段详细展开)"), + ("打住打住,太长了", "抱歉,我说得太细了。"), + ("对,你以后回答我都简短一点,抓重点就行,别长篇大论", "好的,明白了。"), + ("嗯这样挺好", "👌"), + ], + "scripted": ( + "我刚做了一次自我学习。\n" + "- 学到:你希望回答简短、抓重点\n" + "- 调整:更新了长期记忆 MEMORY.md\n" + "如果不对,回复「撤销上次学习」即可。" + ), + "on_edit": edit, + "expect_evolved": True, + "expect_memory_contains": "concise", + } + + +def scenario_memory_correction(): + """User corrects a wrong fact the agent stated -> fix MEMORY.md.""" + def edit(ws): + from datetime import datetime + p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md") + p.write_text((p.read_text() if p.exists() else "") + "- Correction: team size is now 15 (was recorded as 8)\n", encoding="utf-8") + + return { + "name": "记忆-纠正事实 (should note correction in daily)", + "goal": "memory", + "turns": [ + ("我们团队最近又招了几个人", "记得你们是 8 人的小团队,扩张了挺好的。"), + ("不对,我们现在是 15 个人了,上次你就记错了", "抱歉记错了,你们现在是 15 人团队。"), + ("嗯对,记住啊", "好的,记住了。"), + ], + "scripted": ( + "我刚做了一次自我学习。\n" + "- 学到:团队规模已是 15 人(之前记成 8 人)\n" + "- 调整:修正了长期记忆 MEMORY.md\n" + "如果不对,回复「撤销上次学习」即可。" + ), + "on_edit": edit, + "expect_evolved": True, + "expect_memory_contains": "15", + } + + +def scenario_skill_gap(): + """Using weekly-report reveals a missing section -> patch the skill.""" + def edit(ws): + p = ws / "skills" / "weekly-report" / "SKILL.md" + p.write_text(p.read_text() + "4. List risk items / blockers.\n", encoding="utf-8") + + return { + "name": "技能-缺陷 (should patch weekly-report skill)", + "goal": "skill", + "turns": [ + ("用周报技能帮我生成这周的周报,素材:完成了支付网关重构、修复了3个线上bug、对接了风控接口", + "好的,我用 weekly-report 技能生成:\n\n## 本周进展\n- 完成支付网关重构\n- 修复 3 个线上 bug\n- 对接风控接口\n\n## 下周计划\n- 联调风控接口"), + ("不错,但是每次周报都漏了风险项,我们 leader 每次都要问这个", "确实,风险项很重要。"), + ("对,你能不能让这个周报技能以后都带上风险项这一节", "可以的,我把它固化进技能。"), + ], + "scripted": ( + "我刚做了一次自我学习。\n" + "- 学到:周报需要固定包含「风险项」一节\n" + "- 调整:给 weekly-report 技能补充了风险项步骤\n" + "如果不对,回复「撤销上次学习」即可。" + ), + "on_edit": edit, + "expect_evolved": True, + "expect_skill_contains": ("weekly-report", "risk"), + } + + +def scenario_skill_error(): + """A skill step is wrong (currency format) -> correct the skill.""" + def edit(ws): + p = ws / "skills" / "expense-tracker" / "SKILL.md" + p.write_text(p.read_text().replace("`$` prefix", "`¥` prefix (RMB)"), encoding="utf-8") + + return { + "name": "技能-报错纠正 (should fix expense-tracker skill)", + "goal": "skill", + "turns": [ + ("用记账技能记一笔:午饭 35", "好的,已记录:午饭 $35(已写入 output/expenses.md)"), + ("怎么是美元?我们都是人民币啊", "抱歉,格式用错了,应该是 ¥35。"), + ("对,以后这个记账技能都用人民币符号", "明白,我修正技能里的货币格式。"), + ], + "scripted": ( + "我刚做了一次自我学习。\n" + "- 学到:记账应使用人民币符号 ¥ 而非 $\n" + "- 调整:修正了 expense-tracker 技能的货币格式步骤\n" + "如果不对,回复「撤销上次学习」即可。" + ), + "on_edit": edit, + "expect_evolved": True, + "expect_skill_contains": ("expense-tracker", "¥"), + } + + +def scenario_skill_wrong_config(): + """A skill's SKILL.md hardcodes a wrong endpoint; the chat works around it + at runtime. Correct evolution = FIX the skill source, not log a memory note. + """ + def edit(ws): + p = ws / "skills" / "data-fetch" / "SKILL.md" + p.write_text( + p.read_text().replace("api.example-wrong.com", "api.example-correct.com"), + encoding="utf-8", + ) + + return { + "name": "技能-配置错误 (should fix skill source, not log memory)", + "goal": "skill", + "turns": [ + ("用 data-fetch 技能拉一下最新数据", + "好的,我按技能里的步骤 POST 到 https://api.example-wrong.com/v1/fetch …… 报错了,连接失败。"), + ("哦那个地址不对,正确的是 api.example-correct.com,你用这个调", + "好的,换成 https://api.example-correct.com/v1/fetch ,成功拿到数据了 ✅"), + ("嗯对,就是这个地址", "明白了,这个才是正确的服务地址。"), + ], + # The model must decide on its own to edit the skill; we still provide a + # scripted output for stub mode wiring. + "scripted": ( + "我刚做了一次自我学习。\n" + "- 学到:data-fetch 的正确服务地址是 api.example-correct.com\n" + "- 调整:修正了 data-fetch 技能里写错的接口地址\n" + "如果不对,回复「撤销上次学习」即可。" + ), + "on_edit": edit, + "expect_evolved": True, + "expect_skill_contains": ("data-fetch", "api.example-correct.com"), + } + + +def scenario_unfinished_task(): + """A promised deliverable was not produced -> finish it now via tools.""" + def edit(ws): + p = ws / "output" / "team-roster.md" + p.write_text("# Team Roster (backend)\n- 张伟\n- 李娜\n- 王强\n- 大锤\n", encoding="utf-8") + + return { + "name": "未完成任务 (should finish & write output file)", + "goal": "task", + "turns": [ + ("帮我把后端团队花名册整理成一个文件保存下,成员有:张伟、李娜、王强,还有我自己(大锤)", + "好的,后端 4 个人:张伟、李娜、王强、大锤。我整理成文件保存到 output/team-roster.md。"), + ("好的麻烦了,我先去开个会", "没问题,我现在就处理。"), + ("(用户离开,会话中断,文件尚未写入)", "(助手未及写入文件,对话中断)"), + ], + "scripted": ( + "我刚做了一次自我学习。\n" + "- 发现:之前答应整理团队花名册但没完成\n" + "- 已完成:把后端成员名单写入 output/team-roster.md\n" + "如果不需要,回复「撤销上次学习」即可。" + ), + "on_edit": edit, + "expect_evolved": True, + "expect_output_file": "team-roster.md", + } + + +SCENARIOS = [ + scenario_silent, + scenario_memory_preference, + scenario_memory_correction, + scenario_skill_gap, + scenario_skill_error, + scenario_skill_wrong_config, + scenario_unfinished_task, +] + + +# --------------------------------------------------------------------------- +# Runner (stub mode) +# --------------------------------------------------------------------------- +def run_stub(): + from agent.evolution.executor import run_evolution_for_session + from agent.evolution import backup as backup_mod + from config import conf + # Evolution is disabled by default now; enable for the test. + conf()["self_evolution_enabled"] = True + + passed, failed = 0, 0 + for make in SCENARIOS: + sc = make() + ws = _setup_workspace() + try: + _point_config_at(ws) + # Patch channel push to capture instead of send. + channel = FakeChannel() + import agent.evolution.executor as ex + orig_notify = ex._notify_user + ex._notify_user = lambda ct, rcv, summary: channel.send( + type("R", (), {"content": summary})(), + {"receiver": rcv}, + ) + + agent = FakeAgent(_make_messages(sc["turns"])) + bridge = FakeAgentBridge(agent, sc["scripted"], on_edit=sc["on_edit"]) + + evolved = run_evolution_for_session( + bridge, "session_test", channel_type="telegram", receiver="user_42" + ) + + ok = True + errs = [] + + if evolved != sc["expect_evolved"]: + ok = False + errs.append(f"evolved={evolved}, expected {sc['expect_evolved']}") + + if sc["expect_evolved"]: + # memory / skill content checks + if "expect_memory_contains" in sc: + # Evolution now writes to the dated daily file, not MEMORY.md. + from datetime import datetime + daily = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md") + mem = daily.read_text() if daily.exists() else "" + if sc["expect_memory_contains"] not in mem: + ok = False + errs.append("daily memory missing expected content") + if "expect_skill_contains" in sc: + sk, txt = sc["expect_skill_contains"] + content = (ws / "skills" / sk / "SKILL.md").read_text() + if txt not in content: + ok = False + errs.append("skill missing expected content") + # notify happened + if not channel.sent: + ok = False + errs.append("no notification sent") + # injection happened (undo support) + if not bridge.injected or "[EVOLUTION]" not in bridge.injected[0]: + ok = False + errs.append("no [EVOLUTION] record injected") + # protected skill untouched + prot = (ws / "skills" / "image-generation" / "SKILL.md").read_text() + if prot != "# Image Generation (built-in)\nDo not modify.\n": + ok = False + errs.append("PROTECTED skill was modified!") + # backup exists (undo possible) + backups = list((ws / "memory" / ".evolution_backups").glob("*")) + if not backups: + ok = False + errs.append("no backup created") + else: + # SILENT: nothing should have changed / been sent + if channel.sent: + ok = False + errs.append("notification sent on SILENT") + if bridge.injected: + ok = False + errs.append("injected record on SILENT") + + ex._notify_user = orig_notify + + if ok: + passed += 1 + print(f" PASS {sc['name']}") + else: + failed += 1 + print(f" FAIL {sc['name']}: {'; '.join(errs)}") + finally: + shutil.rmtree(ws, ignore_errors=True) + + # Undo verification (uses the memory scenario's backup path). + print("\n-- undo tool --") + _verify_undo() + + print(f"\nStub results: {passed} passed, {failed} failed") + return failed == 0 + + +def _verify_undo(): + from agent.evolution.backup import create_backup, restore_backup + ws = _setup_workspace() + try: + _point_config_at(ws) + mem = ws / "MEMORY.md" + bid = create_backup(ws, [mem]) + mem.write_text("CORRUPTED", encoding="utf-8") + from agent.tools.evolution_undo import EvolutionUndoTool + r = EvolutionUndoTool().execute({"backup_id": bid}) + restored = mem.read_text() + if r.status == "success" and "大锤" in restored: + print(" PASS undo restores pre-evolution state") + else: + print(f" FAIL undo: status={r.status}, content={restored[:40]}") + finally: + shutil.rmtree(ws, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Runner (real mode) — minimal: just prints the model's decision per scenario. +# --------------------------------------------------------------------------- +def _snapshot_ws(ws: Path) -> dict: + """Map every text file under the workspace -> content (skip backups dir).""" + snap = {} + for p in ws.rglob("*"): + if not p.is_file(): + continue + rel = str(p.relative_to(ws)) + if rel.startswith("memory/.evolution_backups"): + continue + try: + snap[rel] = p.read_text(encoding="utf-8") + except Exception: + pass + return snap + + +def _print_diff(before: dict, after: dict) -> bool: + """Print added/changed files. Returns True if anything changed.""" + changed = False + keys = sorted(set(before) | set(after)) + for rel in keys: + old = before.get(rel) + new = after.get(rel) + if old == new: + continue + changed = True + tag = "NEW FILE" if old is None else "CHANGED" + print(f" ~ {rel} [{tag}]") + old_lines = set((old or "").splitlines()) + for line in (new or "").splitlines(): + if line not in old_lines: + print(f" + {line}") + return changed + + +def run_real(): + """Run real model evolution on each scenario and print the actual output. + + Uses config.json's configured model via a real AgentBridge, so you see + exactly what the model decides and writes for each conversation. + """ + from bridge.bridge import Bridge + from agent.memory.config import ( + MemoryConfig, + set_global_memory_config, + get_default_memory_config, + ) + from config import conf, load_config + + # Load config.json so real API keys are available to the bots. + load_config() + + # Default the test to deepseek-v4-flash (fast, low cost) unless overridden. + override_model = os.environ.get("EVO_TEST_MODEL", "deepseek-v4-flash") + conf()["model"] = override_model + conf()["bot_type"] = os.environ.get("EVO_TEST_BOT_TYPE", "deepseek") + # Force-enable evolution for the test regardless of config.json default. + conf()["self_evolution_enabled"] = True + print(f"[test] model: {override_model} (bot_type={conf().get('bot_type')}, " + f"key={'set' if conf().get('deepseek_api_key') else 'MISSING'})") + + from agent.memory.manager import MemoryManager + import agent.evolution.executor as ex + + bridge = Bridge() + agent_bridge = bridge.get_agent_bridge() + + # Capture the user-facing reply instead of pushing it to a channel. + captured = {"reply": None} + orig_notify = ex._notify_user + ex._notify_user = lambda ct, rcv, summary: captured.__setitem__("reply", summary) + + results = [] # (name, goal, evolved, changed, reply_ok) + + only = os.environ.get("EVO_TEST_ONLY") # substring filter on goal/name + try: + for make in SCENARIOS: + sc = make() + if only and only not in sc["goal"] and only not in sc["name"]: + continue + ws = _setup_workspace() + captured["reply"] = None + try: + mem_cfg = MemoryConfig(workspace_root=str(ws)) + set_global_memory_config(mem_cfg) + + sid = "session_evo_real" + # Fully isolated agent: tool cwd + memory_manager -> temp ws. + iso_mem = MemoryManager(mem_cfg) + agent = agent_bridge.create_agent( + system_prompt="You are a helpful assistant.", + tools=None, + workspace_dir=str(ws), + memory_manager=iso_mem, + enable_skills=False, + ) + # Notify path needs a channel+receiver to fire; give dummies. + agent_bridge.agents[sid] = agent + with agent.messages_lock: + agent.messages.clear() + agent.messages.extend(_make_messages(sc["turns"])) + + before = _snapshot_ws(ws) + + print("\n" + "=" * 72) + print(f"场景: {sc['name']} [目标: {sc['goal']}]") + print("-" * 72) + print("【会话输入】") + for u, a in sc["turns"]: + print(f" 用户: {u}") + print(f" 助手: {a}") + + from agent.evolution.executor import run_evolution_for_session + evolved = run_evolution_for_session( + agent_bridge, sid, channel_type="telegram", receiver="tester" + ) + + after = _snapshot_ws(ws) + print("\n【进化结果】 evolved =", evolved) + changed = False + if evolved: + changed = _print_diff(before, after) + if not changed: + print(" (无文件变更)") + else: + print(" (静默,未做任何改动)") + + print("\n【给用户的回复】") + if captured["reply"]: + for line in captured["reply"].splitlines(): + print(f" {line}") + else: + print(" (无推送)") + + reply_ok = bool(captured["reply"]) == bool(evolved) + results.append((sc["name"], sc["goal"], evolved, changed, reply_ok)) + agent_bridge.agents.pop(sid, None) + finally: + shutil.rmtree(ws, ignore_errors=True) + finally: + ex._notify_user = orig_notify + + # Summary table. + print("\n" + "=" * 72) + print("汇总 (deepseek-v4-flash 真实运行)") + print("-" * 72) + for name, goal, evolved, changed, reply_ok in results: + exp = "静默" if goal == "none" else "应进化" + got = "进化" if evolved else "静默" + mark = "✓" if (goal == "none") != evolved else "✗" + print(f" {mark} {name:42s} 预期={exp} 实际={got}") + + +if __name__ == "__main__": + if "--real" in sys.argv: + run_real() + else: + ok = run_stub() + sys.exit(0 if ok else 1) From 157374401ad4ed5260ea2bf2e8a9e3fc70c030d8 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 7 Jun 2026 19:12:32 +0800 Subject: [PATCH 329/399] feat(web): add self-evolution toggle in agent config --- channel/web/chat.html | 12 ++++++++++++ channel/web/static/js/console.js | 4 ++++ channel/web/web_channel.py | 5 +++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/channel/web/chat.html b/channel/web/chat.html index 35e6ad9b..3a56f8c1 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -620,6 +620,18 @@ after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"> +
+ + +
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 0e7f33ef..fef64b76 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -123,7 +123,7 @@ const I18N = { config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理', config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数', config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否启用深度思考模式', - config_self_evolution: '自主进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', + config_self_evolution: '自我进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', evolution_badge: '自主学习', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', @@ -142,7 +142,7 @@ const I18N = { skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', - memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化', + memory_tab_files: '记忆文件', memory_tab_dreams: '自我进化', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', @@ -883,6 +883,7 @@ let isPolling = false; let pollGeneration = 0; // incremented on each restart to cancel stale poll loops let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource +let sessionActiveRequest = {}; // session_id -> request_id (in-flight stream per session) let isComposing = false; let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; @@ -2242,6 +2243,27 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { let reasoningStartTime = 0; let done = false; + // The session this stream belongs to. Sessions run in parallel: the user + // may switch to another session while this one is still streaming. When + // that happens the stream keeps running in the background (so the reply + // still completes and persists) but must NOT touch the now-foreign view. + // On return, the completed reply is loaded from the DB via loadHistory. + const ownerSession = sessionId; + // Once the user navigates away, this stream is permanently "detached": + // its bubble was wiped from the DOM by the switch, so it must never render + // again — even if the user returns to this session. It keeps running to + // finish + persist the reply; the completed reply is then surfaced via + // history reload / polling. This avoids re-creating a mangled bubble that + // starts mid-stream. + let detached = false; + const isActive = () => ownerSession === sessionId && !detached; + sessionActiveRequest[ownerSession] = requestId; + const clearOwnerRequest = () => { + if (sessionActiveRequest[ownerSession] === requestId) { + delete sessionActiveRequest[ownerSession]; + } + }; + const MAX_RECONNECTS = 10; const RECONNECT_BASE_MS = 1000; let reconnectCount = 0; @@ -2294,6 +2316,23 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { // Successful data received, reset reconnect counter reconnectCount = 0; + // Background session: keep the stream alive so the reply finishes + // and persists, but skip all UI rendering for the foreign view. + // Once a stream has rendered into a view that later got cleared + // (the user switched away), mark it detached for good so it never + // tries to render into a rebuilt/foreign view — switching back + // shows the finished reply via history reload / polling instead. + if (ownerSession !== sessionId || detached) { + detached = true; + if (item.type === 'done' || item.type === 'error' || item.type === 'voice_attach') { + done = true; + es.close(); + delete activeStreams[requestId]; + clearOwnerRequest(); + } + return; + } + if (item.type === 'reasoning') { ensureBotEl(); reasoningText += item.content; @@ -2507,6 +2546,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { // TTS audio (`voice_attach`). It will close the stream on // its own via onerror once the tail expires. done = true; + clearOwnerRequest(); resetSendBtnSendMode(); const finalTextRaw = item.content || accumulatedText; @@ -2564,11 +2604,13 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { } es.close(); delete activeStreams[requestId]; + clearOwnerRequest(); } else if (item.type === 'error') { done = true; es.close(); delete activeStreams[requestId]; + clearOwnerRequest(); if (loadingEl) { loadingEl.remove(); loadingEl = null; } addBotMessage(t('error_send'), new Date()); resetSendBtnSendMode(); @@ -2598,7 +2640,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { return; } - // Exhausted retries, show whatever we have + // Exhausted retries. Only surface the failure in the owning view — + // a background session must not mutate the currently shown chat. + clearOwnerRequest(); + if (!isActive()) return; if (loadingEl) { loadingEl.remove(); loadingEl = null; } if (!botEl) { addBotMessage(t('error_send'), new Date()); @@ -2641,10 +2686,19 @@ function startPolling() { loadingContainers[rid].remove(); delete loadingContainers[rid]; } - const welcomeScreen = document.getElementById('welcome-screen'); - if (welcomeScreen) welcomeScreen.remove(); - addBotMessage(data.content, new Date(data.timestamp * 1000), rid); - scrollChatToBottom(); + // Skip if this reply is already on screen. Happens when a reply + // arrives via both the SSE stream and the poll queue (e.g. the + // user switched away mid-run, leaving the queued reply to be + // re-fetched on return) — render it only once. + const already = rid && messagesDiv.querySelector( + `[data-request-id="${rid}"]` + ); + if (!already) { + const welcomeScreen = document.getElementById('welcome-screen'); + if (welcomeScreen) welcomeScreen.remove(); + addBotMessage(data.content, new Date(data.timestamp * 1000), rid); + scrollChatToBottom(); + } } const delay = (data.status === 'success' && data.has_content) ? 5000 : 10000; setTimeout(poll, delay); @@ -2889,8 +2943,11 @@ function createBotMessageEl(content, timestamp, requestId, msg) { } // Self-evolution bubbles get a small badge so the user can feel the agent - // learned something on its own (text itself stays clean). - const evolutionBadge = (msg && msg.kind === 'evolution') + // learned something on its own (text itself stays clean). History replay + // carries msg.kind; live pushes are identified by the evolution_ request id. + const isEvolution = (msg && msg.kind === 'evolution') + || (typeof requestId === 'string' && requestId.startsWith('evolution_')); + const evolutionBadge = isEvolution ? `
${t('evolution_badge')} @@ -3205,14 +3262,17 @@ function addLoadingIndicator() { } function newChat() { - // Close all active SSE connections for the current session - Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} }); - activeStreams = {}; + // Do NOT close active streams: other sessions keep streaming in the + // background (each stream self-guards against the foreign view) and their + // replies still complete and persist. + + // Stop any pending-reply poller from the previous session. + if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } // Generate a fresh session and persist it so the next page load also starts clean sessionId = generateSessionId(); localStorage.setItem(SESSION_ID_KEY, sessionId); - loadingContainers = {}; + resetSendBtnSendMode(); // fresh session has no in-flight reply startPolling(); // bump generation so old loop self-cancels, new loop uses fresh sessionId messagesDiv.innerHTML = ''; const ws = document.createElement('div'); @@ -3547,15 +3607,48 @@ function _onSessionListScroll() { } })(); +// When returning to a session whose reply is still streaming in the +// background, the reply isn't in history yet. Show a loading indicator and +// reload history until the reply has persisted (stream cleared) or the user +// navigates away. Stable bubbles are de-duplicated by request_id on reload. +let _pendingReplyTimer = null; +function _waitForPendingReply(sid) { + if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } + // The user turn is already in history (persisted eagerly); show a loading + // hint beneath it while the reply streams in the background. + addLoadingIndicator(); + + function tick() { + // User switched away — abandon; the other session manages its own view. + if (sid !== sessionId) return; + if (sessionActiveRequest[sid]) { + // Still streaming — keep waiting without touching the view. + _pendingReplyTimer = setTimeout(tick, 1500); + return; + } + // Reply finished and persisted: rebuild the view from history once. + // loadHistory only prepends, so clear first to avoid duplicates. + historyPage = 0; + historyLoading = false; + messagesDiv.innerHTML = ''; + loadHistory(1); + } + _pendingReplyTimer = setTimeout(tick, 1500); +} + function switchSession(newSessionId) { if (newSessionId === sessionId) { if (currentView !== 'chat') navigateTo('chat'); return; } - Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} }); - activeStreams = {}; - loadingContainers = {}; + // Stop any pending-reply poller from the session we're leaving. + if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } + + // Do NOT close active streams here: sessions run in parallel, so any + // in-flight reply for another session must keep streaming in the + // background (it self-guards against rendering into the foreign view). + // The reply completes and persists; switching back loads it from history. sessionId = newSessionId; localStorage.setItem(SESSION_ID_KEY, sessionId); @@ -3568,6 +3661,18 @@ function switchSession(newSessionId) { loadHistory(1); startPolling(); + // Restore the send button to match this session's stream state: if it has + // an in-flight reply, show Cancel; otherwise show Send. A reply that is + // still streaming in the background won't be in history yet, so poll the + // history until it lands. + const pendingReq = sessionActiveRequest[sessionId]; + if (pendingReq) { + setSendBtnCancelMode(pendingReq); + _waitForPendingReply(sessionId); + } else { + resetSendBtnSendMode(); + } + document.querySelectorAll('.session-item').forEach(el => { el.classList.toggle('active', el.dataset.sessionId === sessionId); }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index eaa20f2b..e706ab41 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -360,6 +360,13 @@ class WebChannel(ChatChannel): ): logger.debug(f"Polling skipped duplicate file reply for session {session_id}") return + # SSE-enabled requests already stream the text reply to the + # client. Do NOT also enqueue it for polling: if the user + # switched away mid-run, the queued copy would resurface as a + # duplicate bubble when they return and poll the session. + if reply.type == ReplyType.TEXT and context.get("on_event") is not None: + logger.debug(f"Polling skipped SSE text reply for session {session_id}") + return response_data = { "type": str(reply.type), "content": content, diff --git a/docs/docs.json b/docs/docs.json index 0d18e321..8f848823 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -456,6 +456,7 @@ "group": "发布记录", "pages": [ "zh/releases/overview", + "zh/releases/v2.1.1", "zh/releases/v2.1.0", "zh/releases/v2.0.9", "zh/releases/v2.0.8", diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index 1d04b28f..bf18029b 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -1,5 +1,5 @@ --- -title: 自主进化 +title: 自我进化 description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项 --- @@ -7,13 +7,13 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 简介 -自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 +自我进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 -> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 +> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自我进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 ### 三个目标 -自主进化围绕三件事工作: +自我进化围绕三件事工作: | 目标 | 说明 | | --- | --- | @@ -27,7 +27,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 触发时机 -自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: +自我进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: - **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟) - **对话有足够内容** — 自上次进化以来累积了足够轮次,或上下文已接近容量上限 @@ -36,11 +36,11 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 相关配置 -自主进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: +自我进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: | 参数 | 说明 | 默认值 | | --- | --- | --- | -| `self_evolution_enabled` | 是否启用自主进化 | `false` | +| `self_evolution_enabled` | 是否启用自我进化 | `false` | | `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` | | `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` | @@ -50,7 +50,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 进化记录 -每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自主进化」tab 中查看。该 tab 同时汇总了自主进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 +每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自我进化」tab 中查看。该 tab 同时汇总了自我进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 ### 如何回滚 @@ -58,7 +58,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ## 实现设计 -自主进化复用了系统已有的能力,保持轻量: +自我进化复用了系统已有的能力,保持轻量: - **隔离执行**:每次复盘都启动一个独立的、临时的复盘任务,使用与主对话相同的模型,但拥有受限的工具集(只能读上下文、改记忆与技能文件)。它不会污染主对话的上下文,也不会影响主对话的性能。 - **基于备份的撤销**:进化前对相关文件做快照备份,撤销时按备份还原,因此每一次改动都可追溯、可逆。 @@ -66,7 +66,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 克制与安全 -自主进化的设计原则是"必要时执行,减少打扰": +自我进化的设计原则是"必要时执行,减少打扰": | 机制 | 说明 | | --- | --- | diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx index 35409fee..84013906 100644 --- a/docs/zh/releases/overview.mdx +++ b/docs/zh/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent 版本更新历史 | 版本 | 日期 | 说明 | | --- | --- | --- | +| [2.1.1](/zh/releases/v2.1.1) | 2026.06.08 | 自进化能力、Web 控制台消息管理升级、MCP 跨平台增强与并发调用、新模型接入(MiniMax-M3、qwen3.7-plus 等)、多项优化 | | [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 | | [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | | [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | diff --git a/docs/zh/releases/v2.1.1.mdx b/docs/zh/releases/v2.1.1.mdx new file mode 100644 index 00000000..bafbdeec --- /dev/null +++ b/docs/zh/releases/v2.1.1.mdx @@ -0,0 +1,61 @@ +--- +title: v2.1.1 +description: CowAgent 2.1.1 - 自进化能力、Web 控制台消息管理升级、MCP 跨平台增强、多模型接入与优化 +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1) + +## 🧬 自进化能力 + +CowAgent 新增**自进化(Self-Evolution)**能力,让 Agent 不止于完成单次任务,而是在与你的日常协作中持续成长: + +- **自动复盘成长**:一段对话自然结束并进入空闲后,Agent 会在后台悄悄回顾刚刚的对话,沉淀值得记住的偏好和事实、修复技能中暴露的问题、并补做未完成的任务 +- **越用越懂你**:随着使用,Agent 会逐渐记住你的习惯、减少重复犯错,在收尾上做得更好 +- **不打扰原则**:只有在确实做出了改动时,才用一句话告诉你它学到了什么、调整了什么,没有变化时全程静默 +- **安全可回退**:每次复盘前都会自动备份,对结果不满意时只需在对话中让它撤销即可恢复;内置技能受保护、所有改动均限制在工作空间内 + +默认关闭,可在 Web 控制台 **设置 → Agent 配置** 中一键开启。 + +相关文档:[自进化](https://docs.cowagent.ai/zh/memory/self-evolution) + +## 💬 Web 控制台消息管理升级 + +Web 控制台的聊天体验进一步增强,操作更接近主流聊天产品: + +- **消息编辑与重发**:用户与机器人的消息均支持编辑、删除、重新生成 +- **代码块更易用**:代码块新增语言标签和一键复制按钮 +- **拖拽体验优化**:支持将文件拖拽到整个聊天区域 + +Thanks @core-power (#2865) + +## 🧩 MCP 跨平台增强 + +- **Windows 兼容修复**:修复 MCP 在 Windows 下 `stdio` 通信不可用的问题,并支持通过 `mcp.json` 配置服务超时时间 +- **并发调用支持**:`sse` 与 `streamable-http` 传输支持跨会话并发调用,多工具响应更快 + +Thanks @xliu123321 (#2859) + +相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp) + +## 🤖 模型新增与优化 + +- **MiniMax-M3**:新增并设为默认模型,保留 M2.7 系列作为可选项。Thanks @octo-patch (#2855) +- **通义千问 qwen3.7-plus**:支持多模态对话 +- **语音识别模型可选**:Web 控制台支持选择 ASR(语音识别)模型并持久化保存。Thanks @nightwhite (#2857) +- **安装菜单简化**:一键安装脚本精简模型选择菜单,新增小米 MiMo 选项 + +相关文档:[模型概览](https://docs.cowagent.ai/zh/models) + +## 🛠 体验优化与修复 + +- **国际化体验**:通道列表按界面语言排序展示;优化 `auto` 模式下的语言自动回退逻辑 +- **任务取消更可靠**:修复部分场景下流式回复无法中断的问题 +- **CLI 增强**:`cow status` 新增显示当前项目路径 +- **部署安全加固**:凭证文件拦截范围收敛至 `~/.cow/.env`,不再误拦其他目录(Thanks @orbisai0security #2863);微信公众号在 `wechatmp_token` 为空时拒绝 Webhook 请求 +- **群任务看板插件**:内置群任务看板插件源。Thanks @Wyh-max-star (#2853) + +## 📦 升级方式 + +源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。 + +**发布日期**:2026.06.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1) From 7cf0f7d42d7780c2af4b1bda0e31da4332699724 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 8 Jun 2026 15:48:21 +0800 Subject: [PATCH 334/399] fix(web): self-heal stuck Cancel send button --- channel/web/static/js/console.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index fef64b76..42179fb8 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -1298,8 +1298,15 @@ sendBtn.addEventListener('click', () => { function updateSendBtnState() { if (sendBtnMode === 'cancel') { - // Don't downgrade a Cancel button on input edits. - return; + // Self-heal a stuck Cancel button: if there's no live stream backing + // the current request, the cancel state leaked (e.g. a stream ended + // without resetting). Recover to Send so the input isn't blocked. + if (!activeRequestId || !activeStreams[activeRequestId]) { + resetSendBtnSendMode(); + } else { + // Don't downgrade a genuinely active Cancel button on input edits. + return; + } } sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0); } From ec9557e3d89322a6630cbff8b41b5a3b382b9182 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 8 Jun 2026 17:32:27 +0800 Subject: [PATCH 335/399] feat(web): resume live streaming when switching back to a session --- channel/web/static/js/console.js | 201 ++++++++++++++++++------------- channel/web/web_channel.py | 7 +- 2 files changed, 126 insertions(+), 82 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 42179fb8..4f61e8db 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -884,6 +884,7 @@ let pollGeneration = 0; // incremented on each restart to cancel stale poll lo let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource let sessionActiveRequest = {}; // session_id -> request_id (in-flight stream per session) +let streamBuffers = {}; // request_id -> { items: [event...], timestamp } for re-attach replay let isComposing = false; let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; @@ -2238,7 +2239,7 @@ function sendMessage() { postWithRetry(0); } -function startSSE(requestId, loadingEl, timestamp, titleInfo) { +function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { let botEl = null; let stepsEl = null; // .agent-steps (thinking summaries + tool indicators) let contentEl = null; // .answer-content (final streaming answer) @@ -2251,24 +2252,22 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { let done = false; // The session this stream belongs to. Sessions run in parallel: the user - // may switch to another session while this one is still streaming. When - // that happens the stream keeps running in the background (so the reply - // still completes and persists) but must NOT touch the now-foreign view. - // On return, the completed reply is loaded from the DB via loadHistory. + // may switch to another session while this one is still streaming. The + // stream keeps running in the background (so the reply still completes and + // persists); when foreign it does not touch the view but still records + // every event into a buffer, so returning to the session can rebuild the + // bubble by replaying the buffer and then resume live rendering. const ownerSession = sessionId; - // Once the user navigates away, this stream is permanently "detached": - // its bubble was wiped from the DOM by the switch, so it must never render - // again — even if the user returns to this session. It keeps running to - // finish + persist the reply; the completed reply is then surfaced via - // history reload / polling. This avoids re-creating a mangled bubble that - // starts mid-stream. - let detached = false; - const isActive = () => ownerSession === sessionId && !detached; + const isActive = () => ownerSession === sessionId; sessionActiveRequest[ownerSession] = requestId; + // Per-request event buffer used to rebuild the bubble on re-attach. + const buffer = streamBuffers[requestId] || { items: [], timestamp }; + streamBuffers[requestId] = buffer; const clearOwnerRequest = () => { if (sessionActiveRequest[ownerSession] === requestId) { delete sessionActiveRequest[ownerSession]; } + delete streamBuffers[requestId]; }; const MAX_RECONNECTS = 10; @@ -2312,34 +2311,13 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { mediaEl = botEl.querySelector('.media-content'); } - function connect() { - const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`); - activeStreams[requestId] = es; - - es.onmessage = function(e) { - let item; - try { item = JSON.parse(e.data); } catch (_) { return; } - - // Successful data received, reset reconnect counter - reconnectCount = 0; - - // Background session: keep the stream alive so the reply finishes - // and persists, but skip all UI rendering for the foreign view. - // Once a stream has rendered into a view that later got cleared - // (the user switched away), mark it detached for good so it never - // tries to render into a rebuilt/foreign view — switching back - // shows the finished reply via history reload / polling instead. - if (ownerSession !== sessionId || detached) { - detached = true; - if (item.type === 'done' || item.type === 'error' || item.type === 'voice_attach') { - done = true; - es.close(); - delete activeStreams[requestId]; - clearOwnerRequest(); - } - return; - } + // Holds the live EventSource so terminal events (done/voice_attach/error) + // can close it. During replay there is no live connection (null). + let currentEs = null; + // Render one SSE event into the bubble. Used by the live handler and by + // re-attach replay alike, so both paths produce identical UI. + function processSSEItem(item) { if (item.type === 'reasoning') { ensureBotEl(); reasoningText += item.content; @@ -2609,19 +2587,52 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { if (botEl && item.url) { attachAudioToBotBubble(botEl, item.url, { autoplay: true }); } - es.close(); + if (currentEs) { currentEs.close(); } delete activeStreams[requestId]; clearOwnerRequest(); } else if (item.type === 'error') { done = true; - es.close(); + if (currentEs) { currentEs.close(); } delete activeStreams[requestId]; clearOwnerRequest(); if (loadingEl) { loadingEl.remove(); loadingEl = null; } addBotMessage(t('error_send'), new Date()); resetSendBtnSendMode(); } + } + + function connect() { + const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`); + currentEs = es; + activeStreams[requestId] = es; + + es.onmessage = function(e) { + let item; + try { item = JSON.parse(e.data); } catch (_) { return; } + + // Successful data received, reset reconnect counter + reconnectCount = 0; + + // Record every event for re-attach replay (capped to avoid + // unbounded growth on very long streams). + if (buffer.items.length < 5000) buffer.items.push(item); + + // Background session: keep the stream alive so the reply finishes + // and persists, but skip rendering into the now-foreign view. The + // buffer above still grows so returning to the session can rebuild + // the bubble and resume live rendering. + if (ownerSession !== sessionId) { + if (item.type === 'done' || item.type === 'error' || item.type === 'voice_attach') { + done = true; + es.close(); + delete activeStreams[requestId]; + clearOwnerRequest(); + } + return; + } + + processSSEItem(item); }; es.onerror = function() { @@ -2664,6 +2675,27 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) { }; } + // Re-attach replay: rebuild the bubble from buffered events (snapshot, + // not animated) before connecting for the live tail. `processSSEItem` + // is the same renderer used by the live onmessage handler, so the + // snapshot matches exactly what live rendering would have produced. + if (replayItems && replayItems.length) { + for (const item of replayItems) { + try { processSSEItem(item); } catch (_) {} + if (item.type === 'done' || item.type === 'error' || item.type === 'voice_attach') { + done = true; + } + } + // If the buffered stream already finished, don't reconnect — the + // reply is complete and persisted; show its final state and stop. + if (done) { + clearOwnerRequest(); + resetSendBtnSendMode(); + scrollChatToBottom(true); + return; + } + } + connect(); } @@ -3238,9 +3270,12 @@ function loadHistory(page) { historyPage = page; if (isFirstLoad) { - // Use requestAnimationFrame to ensure the DOM has fully rendered - // before scrolling, otherwise scrollHeight may not reflect new content. + // Scroll to the very bottom after the DOM settles. A single + // rAF isn't enough: markdown/code-highlight/images keep growing + // scrollHeight after the first paint, leaving the last bubble's + // timestamp clipped. Re-pin a few times to catch late layout. requestAnimationFrame(() => scrollChatToBottom(true)); + [120, 350, 700].forEach(d => setTimeout(() => scrollChatToBottom(true), d)); } else { // Restore scroll position so loading older messages doesn't jump the view messagesDiv.scrollTop = messagesDiv.scrollHeight - prevScrollHeight; @@ -3273,9 +3308,6 @@ function newChat() { // background (each stream self-guards against the foreign view) and their // replies still complete and persist. - // Stop any pending-reply poller from the previous session. - if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } - // Generate a fresh session and persist it so the next page load also starts clean sessionId = generateSessionId(); localStorage.setItem(SESSION_ID_KEY, sessionId); @@ -3614,33 +3646,44 @@ function _onSessionListScroll() { } })(); -// When returning to a session whose reply is still streaming in the -// background, the reply isn't in history yet. Show a loading indicator and -// reload history until the reply has persisted (stream cleared) or the user -// navigates away. Stable bubbles are de-duplicated by request_id on reload. -let _pendingReplyTimer = null; -function _waitForPendingReply(sid) { - if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } - // The user turn is already in history (persisted eagerly); show a loading - // hint beneath it while the reply streams in the background. - addLoadingIndicator(); +// Returning to a session whose reply is still streaming in the background. +// Close the background EventSource, rebuild the bubble from the buffered +// events (snapshot), then resume live streaming via a fresh connection that +// reads the remaining tail from the backend queue. Returns true if a stream +// was re-attached. The user's own bubble is already in history (persisted +// eagerly), so it was rendered by loadHistory before this runs. +function _reattachStream(sid) { + const requestId = sessionActiveRequest[sid]; + if (!requestId) return false; + const buffer = streamBuffers[requestId]; + if (!buffer) return false; - function tick() { - // User switched away — abandon; the other session manages its own view. - if (sid !== sessionId) return; - if (sessionActiveRequest[sid]) { - // Still streaming — keep waiting without touching the view. - _pendingReplyTimer = setTimeout(tick, 1500); - return; - } - // Reply finished and persisted: rebuild the view from history once. - // loadHistory only prepends, so clear first to avoid duplicates. - historyPage = 0; - historyLoading = false; - messagesDiv.innerHTML = ''; - loadHistory(1); + // If the buffered stream already finished, the assistant reply is already + // persisted and rendered by loadHistory — re-attaching would duplicate it. + // Just clean up the buffer/cursor and rely on history. + const finished = buffer.items.some( + it => it.type === 'done' || it.type === 'error' + ); + if (finished) { + const oldEs = activeStreams[requestId]; + if (oldEs) { try { oldEs.close(); } catch (_) {} delete activeStreams[requestId]; } + delete streamBuffers[requestId]; + delete sessionActiveRequest[sid]; + resetSendBtnSendMode(); + return false; } - _pendingReplyTimer = setTimeout(tick, 1500); + + // Stop the background stream so the rebuilt one is the sole consumer of + // the backend queue (the queue survives until "done", so the new + // connection picks up any remaining events). + const oldEs = activeStreams[requestId]; + if (oldEs) { try { oldEs.close(); } catch (_) {} delete activeStreams[requestId]; } + + // Snapshot the buffered events into the replay, then start a fresh stream + // that replays them and reconnects for the live tail. + const replay = buffer.items.slice(); + startSSE(requestId, null, buffer.timestamp || new Date(), null, replay); + return true; } function switchSession(newSessionId) { @@ -3649,13 +3692,10 @@ function switchSession(newSessionId) { return; } - // Stop any pending-reply poller from the session we're leaving. - if (_pendingReplyTimer) { clearTimeout(_pendingReplyTimer); _pendingReplyTimer = null; } - // Do NOT close active streams here: sessions run in parallel, so any // in-flight reply for another session must keep streaming in the // background (it self-guards against rendering into the foreign view). - // The reply completes and persists; switching back loads it from history. + // Switching back re-attaches and resumes live streaming. sessionId = newSessionId; localStorage.setItem(SESSION_ID_KEY, sessionId); @@ -3668,14 +3708,13 @@ function switchSession(newSessionId) { loadHistory(1); startPolling(); - // Restore the send button to match this session's stream state: if it has - // an in-flight reply, show Cancel; otherwise show Send. A reply that is - // still streaming in the background won't be in history yet, so poll the - // history until it lands. + // Restore the send button to match this session's stream state, and if a + // reply is still streaming in the background, re-attach to resume showing + // it live (the user turn itself comes from history above). const pendingReq = sessionActiveRequest[sessionId]; if (pendingReq) { setSendBtnCancelMode(pendingReq); - _waitForPendingReply(sessionId); + _reattachStream(sessionId); } else { resetSendBtnSendMode(); } diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index e706ab41..72f07246 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -947,7 +947,12 @@ class WebChannel(ChatChannel): post_done = True post_deadline = time.time() + 2 # 2s post-attach tail finally: - self.sse_queues.pop(request_id, None) + # Only drop the queue once the reply is actually complete. If the + # client disconnected early (e.g. switched sessions and will + # re-attach with the same request_id), keep the queue so the new + # connection can resume reading the remaining events. + if post_done or time.time() >= deadline: + self.sse_queues.pop(request_id, None) def cancel_request(self): """ From 9fc39f648f476b83a92361bdb582198b13636593 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 8 Jun 2026 20:06:01 +0800 Subject: [PATCH 336/399] feat(evolution): give review agent full context, add knowledge signal, polish UX --- agent/evolution/config.py | 2 +- agent/evolution/executor.py | 21 +++++++---- agent/evolution/prompts.py | 32 ++++++++--------- agent/protocol/agent.py | 12 ++++++- bridge/agent_initializer.py | 8 +++++ channel/web/chat.html | 2 +- channel/web/static/js/console.js | 4 +-- config.py | 2 +- docs/memory/self-evolution.mdx | 2 +- docs/zh/memory/self-evolution.mdx | 27 +++++++------- tests/test_evolution.py | 58 +++++++++++++++++++++++++++++++ 11 files changed, 128 insertions(+), 42 deletions(-) diff --git a/agent/evolution/config.py b/agent/evolution/config.py index 864cbb1e..a3215858 100644 --- a/agent/evolution/config.py +++ b/agent/evolution/config.py @@ -14,7 +14,7 @@ from typing import Any # until release; enable via ``self_evolution_enabled``. DEFAULT_ENABLED = False DEFAULT_IDLE_MINUTES = 15 -DEFAULT_MIN_TURNS = 6 +DEFAULT_MIN_TURNS = 8 # Max review steps for the isolated evolution agent. Kept small (not exposed as # config): the review is meant to be cheap and focused, not a long autonomous run. DEFAULT_MAX_STEPS = 12 diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py index 8734d263..33f5453e 100644 --- a/agent/evolution/executor.py +++ b/agent/evolution/executor.py @@ -175,6 +175,9 @@ _WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output") # Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly # dream diary, none of which count as a user-facing change signal. _MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution") +# Files the skill subsystem maintains automatically (the enable/disable index). +# Not an evolution result, so a rewrite must not count as a change signal. +_WATCH_IGNORE_NAMES = ("skills_config.json",) def _workspace_snapshot(workspace_dir) -> dict: @@ -195,6 +198,8 @@ def _workspace_snapshot(workspace_dir) -> dict: for p in root.rglob("*"): if not p.is_file(): continue + if p.name in _WATCH_IGNORE_NAMES: + continue try: st = p.stat() snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) @@ -269,10 +274,8 @@ def run_evolution_for_session( new_messages = all_messages[done:] transcript = _build_transcript(new_messages) if not transcript.strip(): - # Routine no-op: the per-minute scan hits every idle session, so keep - # this at debug to avoid spamming the log. - logger.debug(f"[Evolution] session={session_id}: no new messages, skip") - # Advance the cursor anyway so we don't re-scan the same tail. + # Routine no-op: the per-minute scan hits every idle session. Advance + # the cursor so we don't re-scan the same tail; no log (pure noise). agent._evo_done_msg_count = total_msgs return False @@ -334,17 +337,23 @@ def run_evolution_for_session( str(workspace_dir), ) review_agent = agent_bridge.create_agent( - system_prompt=EVOLUTION_SYSTEM_PROMPT, + system_prompt="", tools=review_tools, description="Self-evolution review agent", max_steps=cfg.max_steps, workspace_dir=str(workspace_dir), skill_manager=getattr(agent, "skill_manager", None), memory_manager=getattr(agent, "memory_manager", None), - enable_skills=False, + enable_skills=True, + runtime_info=getattr(agent, "runtime_info", None), ) # Reuse the live model so it follows the user's configured model. review_agent.model = agent.model + # Inject the evolution task brief AFTER the full system prompt: the agent + # gets the full context (tools, workspace, user preferences, memory, time) + # AND its evolution-specific instructions on top, instead of one + # overwriting the other. + review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT logger.info( f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent" diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py index dd71d82a..def9428b 100644 --- a/agent/evolution/prompts.py +++ b/agent/evolution/prompts.py @@ -7,7 +7,7 @@ language (instructed at the end of the prompt). Design goals (see ref/hermes-agent background_review for inspiration): - Default to doing NOTHING. Evolution is the exception, not the rule. - - Three signal types: memory, skill, unfinished task. + - Signal types: skill, unfinished task, memory, knowledge. - An explicit "do NOT capture" list to avoid self-poisoning over time. - Generic examples only — never bake in domain-specific business terms. """ @@ -72,12 +72,11 @@ them. When their signal is clear, act; do not be shy here. reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user. You only ever notify the user as a side effect of having actually done work. -3. MEMORY — LAST resort, and you are only a SAFETY NET here, not the primary - writer. The main assistant already writes memory DURING the conversation, and - a nightly pass consolidates daily notes into long-term memory. Prefer fixing - a skill (above) over writing memory whenever the fact belongs in a skill. - Act ONLY on something the main assistant clearly MISSED that does not belong - in any skill. +3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main + assistant already writes memory during the chat, and a nightly pass plus + context-overflow saves are dedicated safety nets — so memory is almost always + already covered without you. Skip unless the main assistant clearly missed a + durable fact that belongs in no skill AND would visibly change future replies. - MEMORY.md is the curated long-term index, auto-loaded into EVERY future conversation. Treat it as precious: edit it in place to CORRECT a wrong fact, or append a new durable preference/decision/lesson — but do so @@ -92,6 +91,12 @@ them. When their signal is clear, act; do not be shy here. - If it is already captured anywhere (check MEMORY.md AND the daily file first), do NOTHING. +4. KNOWLEDGE — only if the conversation produced durable, reusable reference + knowledge on a topic (the kind worth looking up again) that the main + assistant did NOT already save to `knowledge/`. Add or update the relevant + file there. Like memory, this is the exception: skip routine Q&A, and if the + topic is already covered in `knowledge/`, do NOTHING rather than duplicate. + # Do NOT capture (these poison future behavior) - Environment failures: missing binaries, unset credentials, uninstalled @@ -140,9 +145,6 @@ def build_review_user_message(transcript: str, protected_skills: list = None) -> ``protected_skills`` lists skill names that must never be edited (built-in skills shipped with the product). Surfaced so the agent avoids them. """ - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - protected_note = "" if protected_skills: names = ", ".join(sorted(protected_skills)) @@ -152,12 +154,10 @@ def build_review_user_message(transcript: str, protected_skills: list = None) -> ) return ( "Here is the conversation transcript that just went idle. Review it per " - "your instructions and act on any clear signal. Prefer fixing a skill at " - "its source over writing memory whenever the fact belongs in a skill.\n" - f"Today is {today}. Only if a fact genuinely belongs in memory (and not " - f"in a skill): append one short bullet to the daily file " - f"`memory/{today}.md` for a new fact, or edit MEMORY.md in place to " - f"correct an existing wrong fact." + "your instructions. Acting is the exception: the main value is fixing or " + "creating a skill and finishing promised work. Memory and knowledge are " + "rare last resorts — stay [SILENT] unless there is a clear, durable signal " + "not already covered." f"{protected_note}\n" "\n" f"{transcript}\n" diff --git a/agent/protocol/agent.py b/agent/protocol/agent.py index 1dc72797..0b2e36ad 100644 --- a/agent/protocol/agent.py +++ b/agent/protocol/agent.py @@ -52,6 +52,11 @@ class Agent: self.workspace_dir = workspace_dir # Workspace directory self.enable_skills = enable_skills # Skills enabled flag self.runtime_info = runtime_info # Runtime info for dynamic time update + # Optional extra instructions appended AFTER the rebuilt full system + # prompt. Used by the self-evolution review agent to add its task brief + # on top of the full context (tools, workspace, user preferences, time) + # so it both follows the user's preferences and knows its evolution job. + self.extra_system_suffix = None # Initialize skill manager self.skill_manager = None @@ -120,15 +125,20 @@ class Agent: except Exception: lang = "zh" builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang) - return builder.build( + full = builder.build( tools=self.tools, context_files=context_files, skill_manager=self.skill_manager, memory_manager=self.memory_manager, runtime_info=self.runtime_info, ) + if self.extra_system_suffix: + full = f"{full}\n\n{self.extra_system_suffix}" + return full except Exception as e: logger.warning(f"Failed to rebuild system prompt, using cached version: {e}") + if self.extra_system_suffix: + return f"{self.system_prompt}\n\n{self.extra_system_suffix}" return self.system_prompt def refresh_skills(self): diff --git a/bridge/agent_initializer.py b/bridge/agent_initializer.py index e7161454..857c0f92 100644 --- a/bridge/agent_initializer.py +++ b/bridge/agent_initializer.py @@ -524,6 +524,14 @@ class AgentInitializer: logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured") continue + # Skip evolution_undo when self-evolution is disabled: with no + # evolution there is nothing to roll back, so the tool is dead weight. + if tool_name == "evolution_undo": + from agent.evolution.config import get_evolution_config + if not get_evolution_config().enabled: + logger.debug("[AgentInitializer] evolution_undo skipped - self-evolution disabled") + continue + # Special handling for EnvConfig tool if tool_name == "env_config": from agent.tools import EnvConfig diff --git a/channel/web/chat.html b/channel/web/chat.html index 5bb4c7ae..1af8e334 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -772,7 +772,7 @@
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 4f61e8db..00598e25 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -123,7 +123,7 @@ const I18N = { config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理', config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数', config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否启用深度思考模式', - config_self_evolution: '自我进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', + config_self_evolution: '自进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', evolution_badge: '自主学习', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', @@ -142,7 +142,7 @@ const I18N = { skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', - memory_tab_files: '记忆文件', memory_tab_dreams: '自我进化', + memory_tab_files: '记忆文件', memory_tab_dreams: '自进化', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', diff --git a/config.py b/config.py index cd54d29a..1f1100fe 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ available_setting = { # Self-evolution: review idle conversations to learn memory/skills. Flat keys. "self_evolution_enabled": False, # master switch (off until release) "self_evolution_idle_minutes": 15, # idle time before a session is reviewed - "self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger + "self_evolution_min_turns": 8, # min user turns (or context pressure) to trigger "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL__ env vars at startup "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) } diff --git a/docs/memory/self-evolution.mdx b/docs/memory/self-evolution.mdx index d06855f9..ad0ebd73 100644 --- a/docs/memory/self-evolution.mdx +++ b/docs/memory/self-evolution.mdx @@ -18,7 +18,7 @@ Self-Evolution focuses on three things: | Goal | Description | | --- | --- | | **Consolidate memory** | Record important preferences, decisions, and facts from the conversation, filling in what the main chat may have missed | -| **Improve skills** | When a skill shows a problem in use (such as a wrong setting or a missing step), fix the skill file directly instead of just noting it; create a new skill when one is genuinely needed | +| **Improve skills** | ① When a skill shows a problem in use (such as a wrong setting or a missing step), fix the skill file directly; ② when a reusable workflow emerges, turn it into a new skill so it can be reused next time | | **Follow up on unfinished tasks** | Spot the to-dos left in a conversation and finish them when possible | Once a review is done, if it actually changed something, the Agent tells you in a single line what it just learned and what it adjusted, so you can decide whether to roll it back. diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index bf18029b..3557f018 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -1,5 +1,5 @@ --- -title: 自我进化 +title: 自进化 description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项 --- @@ -7,19 +7,20 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 简介 -自我进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 +自进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 -> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自我进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 +> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 -### 三个目标 +### 几个目标 -自我进化围绕三件事工作: +自进化围绕以下几件事工作,并以「优化技能、处理未完成事项」为主,「沉淀记忆、知识」作为主对话的查缺补漏: | 目标 | 说明 | | --- | --- | -| **沉淀记忆** | 把对话中重要的偏好、决策、事实补记到记忆中,作为主对话的查缺补漏 | -| **优化技能** | 当某个技能在使用中暴露出问题(如配置错误、步骤缺失),直接修正技能文件,而不只是记一笔;也可在需要时创建新技能 | +| **优化技能** | ① 技能在使用中暴露问题(如配置错误、步骤缺失)时,直接修正技能文件;② 出现一套可复用的流程时,主动固化为新技能,下次直接调用 | | **处理未完成事项** | 识别对话中遗留的待办,在能完成时直接完成 | +| **沉淀记忆** | 把对话中重要的偏好、决策、事实补记到记忆中,作为主对话的查缺补漏 | +| **沉淀知识** | 把对话中产生的、值得日后查阅的可复用知识补充进知识库(主对话遗漏时) | 复盘完成后,如果确实做了改动,Agent 会在对话中用一句话告诉你"刚刚自主学习了什么、调整了哪里",方便你判断是否需要回滚。 @@ -27,7 +28,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 触发时机 -自我进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: +自进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: - **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟) - **对话有足够内容** — 自上次进化以来累积了足够轮次,或上下文已接近容量上限 @@ -36,11 +37,11 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 相关配置 -自我进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: +自进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: | 参数 | 说明 | 默认值 | | --- | --- | --- | -| `self_evolution_enabled` | 是否启用自我进化 | `false` | +| `self_evolution_enabled` | 是否启用自进化 | `false` | | `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` | | `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` | @@ -50,7 +51,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 进化记录 -每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自我进化」tab 中查看。该 tab 同时汇总了自我进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 +每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自进化」tab 中查看。该 tab 同时汇总了自进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 ### 如何回滚 @@ -58,7 +59,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ## 实现设计 -自我进化复用了系统已有的能力,保持轻量: +自进化复用了系统已有的能力,保持轻量: - **隔离执行**:每次复盘都启动一个独立的、临时的复盘任务,使用与主对话相同的模型,但拥有受限的工具集(只能读上下文、改记忆与技能文件)。它不会污染主对话的上下文,也不会影响主对话的性能。 - **基于备份的撤销**:进化前对相关文件做快照备份,撤销时按备份还原,因此每一次改动都可追溯、可逆。 @@ -66,7 +67,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 克制与安全 -自我进化的设计原则是"必要时执行,减少打扰": +自进化的设计原则是"必要时执行,减少打扰": | 机制 | 说明 | | --- | --- | diff --git a/tests/test_evolution.py b/tests/test_evolution.py index 4ebc47e9..4b535234 100644 --- a/tests/test_evolution.py +++ b/tests/test_evolution.py @@ -195,6 +195,55 @@ def scenario_silent(): } +def scenario_silent_qa(): + """A normal knowledge Q&A -> nothing durable, should stay SILENT.""" + return { + "name": "普通问答 (should stay SILENT)", + "goal": "none", + "turns": [ + ("Python 里 list 和 tuple 有什么区别?", + "主要区别:list 可变、用 [];tuple 不可变、用 ()。tuple 更省内存、可作字典键。"), + ("那什么时候该用 tuple?", "当数据不应被修改、或要做字典键/集合元素时用 tuple。"), + ("懂了,谢谢", "不客气。"), + ], + "scripted": "[SILENT]", + "on_edit": None, + "expect_evolved": False, + } + + +def scenario_silent_transient(): + """User shares transient, non-durable info -> should stay SILENT.""" + return { + "name": "临时信息 (should stay SILENT)", + "goal": "none", + "turns": [ + ("帮我看下今天天气适合跑步吗,深圳", "深圳今天多云 26°C,傍晚湿度高,清晨或晚上跑步比较合适。"), + ("那我晚上去吧", "好的,记得补水。"), + ("行", "👍"), + ], + "scripted": "[SILENT]", + "on_edit": None, + "expect_evolved": False, + } + + +def scenario_silent_advice(): + """User asks for one-off advice, no reusable workflow -> should stay SILENT.""" + return { + "name": "一次性建议 (should stay SILENT)", + "goal": "none", + "turns": [ + ("给我起三个适合咖啡馆的名字", "可以考虑:① 拾光咖啡 ② 角落 Corner ③ 慢半拍。"), + ("第二个不错", "嗯,「角落 Corner」简洁好记。"), + ("就用这个了", "好的,祝开业顺利。"), + ], + "scripted": "[SILENT]", + "on_edit": None, + "expect_evolved": False, + } + + def scenario_memory_preference(): """User states a durable working preference -> update MEMORY.md.""" def edit(ws): @@ -475,6 +524,9 @@ def scenario_unfinished_task(): SCENARIOS = [ scenario_silent, + scenario_silent_qa, + scenario_silent_transient, + scenario_silent_advice, scenario_memory_preference, scenario_memory_correction, scenario_skill_gap, @@ -791,6 +843,12 @@ def run_real(): if __name__ == "__main__": + if "--debug" in sys.argv: + import logging + from common.log import logger as _cow_logger + _cow_logger.setLevel(logging.DEBUG) + for _h in _cow_logger.handlers: + _h.setLevel(logging.DEBUG) if "--real" in sys.argv: run_real() else: From c887fc71ad41d3b854787aff40e5e9e08534aa12 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 8 Jun 2026 20:15:32 +0800 Subject: [PATCH 337/399] fix: support Python 3.13 by installing web.py from GitHub --- docs/guide/quick-start.mdx | 2 +- docs/ja/guide/quick-start.mdx | 2 +- docs/zh/guide/quick-start.mdx | 2 +- requirements.txt | 9 ++++++--- run.sh | 30 ++++++++++++++---------------- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/guide/quick-start.mdx b/docs/guide/quick-start.mdx index ed5ec3f1..a4aa35f3 100644 --- a/docs/guide/quick-start.mdx +++ b/docs/guide/quick-start.mdx @@ -5,7 +5,7 @@ description: One-click install and manage CowAgent with scripts The project provides scripts for one-click install, configuration, startup, and management. Script-based deployment is recommended for quick setup. -Supports Linux, macOS, and Windows. Requires Python 3.7-3.12 (3.9 recommended). +Supports Linux, macOS, and Windows. Requires Python 3.7-3.13 (3.9 recommended). ## Install Command diff --git a/docs/ja/guide/quick-start.mdx b/docs/ja/guide/quick-start.mdx index 407f1e21..a24c706d 100644 --- a/docs/ja/guide/quick-start.mdx +++ b/docs/ja/guide/quick-start.mdx @@ -5,7 +5,7 @@ description: スクリプトによるCowAgentのワンクリックインスト 本プロジェクトでは、ワンクリックでのインストール、設定、起動、管理を行うスクリプトを提供しています。素早くセットアップするには、スクリプトによるデプロイを推奨します。 -Linux、macOS、Windowsに対応しています。Python 3.7〜3.12が必要です(3.9を推奨)。 +Linux、macOS、Windowsに対応しています。Python 3.7〜3.13が必要です(3.9を推奨)。 ## インストールコマンド diff --git a/docs/zh/guide/quick-start.mdx b/docs/zh/guide/quick-start.mdx index 964ffe74..735225e2 100644 --- a/docs/zh/guide/quick-start.mdx +++ b/docs/zh/guide/quick-start.mdx @@ -5,7 +5,7 @@ description: 使用脚本一键安装和管理 CowAgent 项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行。 -支持 Linux、macOS、Windows 操作系统,需安装 Python 3.7 ~ 3.12(推荐 3.9)。 +支持 Linux、macOS、Windows 操作系统,需安装 Python 3.7 ~ 3.13(推荐 3.9)。 ## 安装命令 diff --git a/requirements.txt b/requirements.txt index 24ea9da8..e059cd29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,6 @@ aiohttp>=3.8.6,<3.10 requests>=2.28.2 chardet>=5.1.0 Pillow -web.py -legacy-cgi; python_version >= "3.13" python-dotenv>=1.0.0 PyYAML>=6.0 croniter>=2.0.0 @@ -32,4 +30,9 @@ python-telegram-bot # slack bot slack_bolt # discord bot -discord.py \ No newline at end of file +discord.py + +# web.py: PyPI 0.62 fails to build on Python 3.13+ (cgi module removed), use GitHub fix instead +web.py; python_version < "3.13" +web.py @ git+https://github.com/webpy/webpy.git ; python_version >= "3.13" +legacy-cgi; python_version >= "3.13" diff --git a/run.sh b/run.sh index cb624ac4..1099eb43 100755 --- a/run.sh +++ b/run.sh @@ -359,39 +359,37 @@ detect_python_command() { FOUND_NEWER_VERSION="" # Try to find Python command in order of preference - for cmd in python3 python python3.12 python3.11 python3.10 python3.9 python3.8 python3.7; do + for cmd in python3 python python3.13 python3.12 python3.11 python3.10 python3.9 python3.8 python3.7; do if command -v $cmd &> /dev/null; then # Check Python version major_version=$($cmd -c 'import sys; print(sys.version_info[0])' 2>/dev/null) minor_version=$($cmd -c 'import sys; print(sys.version_info[1])' 2>/dev/null) if [[ "$major_version" == "3" ]]; then - # Check if version is in supported range (3.7 - 3.12) - if (( minor_version >= 7 && minor_version <= 12 )); then + # Supported range is 3.7+. On 3.13+ web.py is installed from a + # pinned GitHub commit (see requirements.txt), which needs git. + if (( minor_version >= 7 )); then PYTHON_CMD=$cmd PYTHON_VERSION="${major_version}.${minor_version}" break - elif (( minor_version >= 13 )); then - # Found Python 3.13+, but not compatible - if [ -z "$FOUND_NEWER_VERSION" ]; then - FOUND_NEWER_VERSION="${major_version}.${minor_version}" - fi fi fi fi done if [ -z "$PYTHON_CMD" ]; then - echo -e "${YELLOW}Tried: python3, python, python3.12, python3.11, python3.10, python3.9, python3.8, python3.7${NC}" - if [ -n "$FOUND_NEWER_VERSION" ]; then - echo -e "${RED}❌ Found Python $FOUND_NEWER_VERSION, but this project requires Python 3.7-3.12${NC}" - echo -e "${YELLOW}Python 3.13+ has compatibility issues with some dependencies (web.py, cgi module removed)${NC}" - echo -e "${YELLOW}Please install Python 3.7-3.12 (recommend Python 3.12)${NC}" - else - echo -e "${RED}❌ No suitable Python found. Please install Python 3.7-3.12${NC}" - fi + echo -e "${YELLOW}Tried: python3, python, python3.13, python3.12, python3.11, python3.10, python3.9, python3.8, python3.7${NC}" + echo -e "${RED}❌ No suitable Python found. Please install Python 3.7 or newer${NC}" exit 1 fi + + # On 3.13+, web.py is pulled from GitHub via pip, which requires git. + if [[ "$major_version" == "3" ]] && (( minor_version >= 13 )); then + if ! command -v git &> /dev/null; then + echo -e "${YELLOW}⚠️ Python $PYTHON_VERSION detected. Installing web.py from GitHub requires git, which was not found.${NC}" + echo -e "${YELLOW} Please install git, or use Python 3.12 where web.py installs directly from PyPI.${NC}" + fi + fi # Export for global use export PYTHON_CMD From 16134bd150a5a74a15ce2254284fa6f5741732bc Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 8 Jun 2026 20:19:57 +0800 Subject: [PATCH 338/399] fix: update python version in powershell script --- scripts/run.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 993e8b49..636ade2f 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -201,7 +201,7 @@ function Find-Python { $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) { + if ($major -eq 3 -and $minor -ge 7 -and $minor -le 13) { return $bin.Source } } catch {} @@ -212,7 +212,7 @@ function Find-Python { $PythonCmd = Find-Python function Assert-Python { if (-not $PythonCmd) { - Write-Err (T "未找到 Python 3.9-3.13,请从 https://www.python.org/downloads/ 安装" "Python 3.9-3.13 not found. Please install from https://www.python.org/downloads/") + Write-Err (T "未找到 Python 3.7-3.13,请从 https://www.python.org/downloads/ 安装" "Python 3.7-3.13 not found. Please install from https://www.python.org/downloads/") Read-Host (T "按回车退出" "Press Enter to exit") exit 1 } From 1f1abdd7b642a195175f2696949f931d5a3ba36a Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 09:29:30 +0800 Subject: [PATCH 339/399] fix(evolution): correct [SILENT] verdict and enable guarded bash for skill creation --- agent/evolution/executor.py | 102 +++++++++++++++++++++++++++++++----- agent/evolution/prompts.py | 12 +++-- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py index 33f5453e..20f36864 100644 --- a/agent/evolution/executor.py +++ b/agent/evolution/executor.py @@ -19,6 +19,7 @@ remember_scheduled_output, channel_factory) rather than introducing a fork. from __future__ import annotations +import re import threading from datetime import datetime from pathlib import Path @@ -37,8 +38,10 @@ from agent.evolution.prompts import ( from agent.evolution.record import append_session_evolution # Tools the isolated evolution agent is allowed to use. Everything else is -# withheld so a review pass can only read context and edit memory/skill files. -_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"} +# withheld so a review pass can only read context, run workspace scripts, and +# edit memory/skill files. bash is needed by skill-creator's init script and is +# confined to the workspace by _BashWorkspaceGuard. +_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"} # Cap concurrent evolution passes so a burst of idle sessions can't spawn many # background model runs at once. Extra sessions simply wait for the next scan. @@ -159,12 +162,81 @@ class _WorkspaceWriteGuard: return self._inner.execute(args) +class _BashWorkspaceGuard: + """Wraps the bash tool so evolution can only run commands inside the + workspace. + + Evolution needs bash for skill-creator's init script, but it runs + unattended in the background, so a raw shell is too broad. This guard: + - forces the command to execute with cwd = workspace, + - rejects commands that reference an absolute path or ``..`` segment + pointing OUTSIDE the workspace (the common ways to escape it). + It is a coarse textual check, not a sandbox — paired with the model's + instruction to only run skill-creator scripts, it keeps writes local. + """ + + def __init__(self, inner, workspace_dir: str): + self._inner = inner + self._ws = Path(workspace_dir).resolve() + # Pin the shell's working directory to the workspace. + try: + self._inner.cwd = str(self._ws) + except Exception: + pass + self.name = inner.name + self.description = inner.description + self.params = inner.params + + def __getattr__(self, item): + return getattr(self._inner, item) + + def execute_tool(self, params): + try: + return self.execute(params) + except Exception as e: + logger.error(f"[Evolution] guarded bash error: {e}") + from agent.tools.base_tool import ToolResult + return ToolResult.fail(f"Error: {e}") + + def _escapes_workspace(self, command: str) -> bool: + # Absolute paths that are not under the workspace. + for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command): + try: + resolved = Path(tok).resolve() + except Exception: + continue + if self._ws != resolved and self._ws not in resolved.parents: + return True + # Parent-dir traversal that climbs above the workspace. + for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command): + try: + resolved = (self._ws / tok).resolve() + except Exception: + continue + if self._ws != resolved and self._ws not in resolved.parents: + return True + return False + + def execute(self, args): + from agent.tools.base_tool import ToolResult + command = (args.get("command") or "").strip() + if command and self._escapes_workspace(command): + return ToolResult.fail( + "Error: evolution may only run commands inside the workspace; " + "this command references a path outside it and was blocked." + ) + return self._inner.execute(args) + + def _guard_tools(tools: list, workspace_dir: str) -> list: - """Wrap write/edit tools with the workspace guard; leave others as-is.""" + """Wrap write/edit/bash tools with workspace guards; leave others as-is.""" guarded = [] for t in tools: - if getattr(t, "name", None) in _WRITE_TOOLS: + name = getattr(t, "name", None) + if name in _WRITE_TOOLS: guarded.append(_WorkspaceWriteGuard(t, workspace_dir)) + elif name == "bash": + guarded.append(_BashWorkspaceGuard(t, workspace_dir)) else: guarded.append(t) return guarded @@ -366,22 +438,28 @@ def run_evolution_for_session( # only looks at messages added after this point (silent or not). agent._evo_done_msg_count = total_msgs - if not result or SILENT_TOKEN in result: + # Respect an explicit silent verdict: empty, exactly [SILENT], or text + # that STARTS with [SILENT] means the model chose to stay quiet. + if not result or result.startswith(SILENT_TOKEN): logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") return False - # Hard gate: an evolution only counts (and only notifies) if a workspace - # file ACTUALLY changed. If the model did real work (wrote memory / - # patched a skill / finished a task) the user is told; if it merely - # produced text without changing anything, we stay silent. This is the - # key anti-nag rule — no notification unless something was actually done. + # Anti-nag backstop: if the model wrote a summary but actually changed no + # watched file, stay silent — never notify about work that didn't happen. if not _workspace_changed(workspace_dir, pre_snapshot): logger.info( - f"[Evolution] ✗ session={session_id}: model produced text but " - f"changed no file — treating as silent" + f"[Evolution] ✗ session={session_id}: text produced but no file " + f"changed — staying silent" ) return False + # The model produced a real summary. Strip any stray [SILENT] tokens it + # left mid-text, then notify. + result = result.replace(SILENT_TOKEN, "").strip() + if not result: + logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") + return False + logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}") append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id) # Inject an [EVOLUTION] note so the main agent can honor "undo". diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py index def9428b..6a4e9e40 100644 --- a/agent/evolution/prompts.py +++ b/agent/evolution/prompts.py @@ -52,11 +52,13 @@ them. When their signal is clear, act; do not be shy here. the relevant skill file under the skills directory and make a small incremental edit so it never recurs. b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that - no existing skill covers and the user is likely to want again. To create - one, follow the `skill-creator` skill's conventions (read its SKILL.md for - the required structure) and write the new skill under the workspace - `skills/` directory. Only create when the workflow is genuinely reusable — - not for a one-off task. + no existing skill covers and the user is likely to want again. Follow the + `skill-creator` skill's conventions (read its SKILL.md for the required + structure), then create `skills//SKILL.md` by WRITING the file + directly with the write tool — this is the simplest reliable path. (bash + is available and confined to the workspace if a helper script is truly + needed, but a direct write is preferred.) Only create when the workflow is + genuinely reusable — not for a one-off task. CRITICAL — fix the SOURCE, do not just remember the symptom: when the root cause of a problem lives IN a skill file itself (its instructions, content, From 33c03e30d934b3c5407f4fb677cbd0c50305a245 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 10:49:34 +0800 Subject: [PATCH 340/399] fix(web): switch to a sibling session when deleting the active one --- channel/web/chat.html | 2 +- channel/web/static/js/console.js | 51 +++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/channel/web/chat.html b/channel/web/chat.html index 1af8e334..3a56f8c1 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -772,7 +772,7 @@ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 00598e25..e50ff0c9 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -123,7 +123,7 @@ const I18N = { config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理', config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数', config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否启用深度思考模式', - config_self_evolution: '自进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', + config_self_evolution: '自主进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项', evolution_badge: '自主学习', config_channel_type: '通道类型', config_provider: '模型厂商', config_model_name: '模型', @@ -142,7 +142,7 @@ const I18N = { skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用', skill_toggle_error: '操作失败,请稍后再试', memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', - memory_tab_files: '记忆文件', memory_tab_dreams: '自进化', + memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化', memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', memory_back: '返回列表', memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', @@ -3303,7 +3303,7 @@ function addLoadingIndicator() { return el; } -function newChat() { +function newChat(optimistic = true) { // Do NOT close active streams: other sessions keep streaming in the // background (each stream self-guards against the foreign view) and their // replies still complete and persist. @@ -3407,8 +3407,16 @@ function newChat() { _showSessionOverlay(); _persistPanelState(); } + // Only prepend an optimistic "new chat" item when this is a real new-chat + // action. When called after deleting the current session, skip it: the + // fresh session has no backend record yet, so inserting it would leave an + // empty, undeletable item in the list (deleting it just spawns another). const newSid = sessionId; - loadSessionList(() => _addOptimisticSessionItem(newSid)); + if (optimistic) { + loadSessionList(() => _addOptimisticSessionItem(newSid)); + } else { + loadSessionList(); + } } // ===================================================================== @@ -3729,20 +3737,49 @@ function switchSession(newSessionId) { function deleteSession(sid) { showConfirmModal(t('delete_session_title'), t('delete_session_confirm'), () => { + // Before deleting, find the next real session to fall back to when the + // current one is removed (the sibling item in the list, which is sorted + // newest-first). Falls back to the welcome screen if none remain. + const nextSid = sid === sessionId ? _findNextSessionId(sid) : null; + fetch(`/api/sessions/${encodeURIComponent(sid)}`, { method: 'DELETE' }) .then(r => r.json()) .then(data => { if (data.status !== 'success') return; - if (sid === sessionId) { - newChat(); - } else { + if (sid !== sessionId) { loadSessionList(); + return; + } + if (nextSid) { + // Switch to an existing session; refresh the list afterwards + // so the deleted item disappears. + switchSession(nextSid); + loadSessionList(); + } else { + // No other sessions: reset to a fresh empty session without + // inserting an optimistic placeholder (it has no backend + // record and would be an empty, undeletable item). + newChat(false); } }) .catch(() => {}); }); } +// Pick the session to show after deleting `sid` (the current session): prefer +// the next item below it in the list, otherwise the previous one. Returns null +// if no other session exists. +function _findNextSessionId(sid) { + const items = Array.from(document.querySelectorAll('.session-item[data-session-id]')); + const idx = items.findIndex(el => el.dataset.sessionId === sid); + if (idx === -1) { + const other = items.find(el => el.dataset.sessionId !== sid); + return other ? other.dataset.sessionId : null; + } + const next = items[idx + 1] || items[idx - 1]; + return next ? next.dataset.sessionId : null; +} + function showConfirmModal(title, message, onConfirm) { let overlay = document.getElementById('confirm-modal-overlay'); if (overlay) overlay.remove(); From 7e6a3099359612fed4981d65b4f64918732b5e08 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 10:49:43 +0800 Subject: [PATCH 341/399] feat(evolution): default on for new installs, unify naming, add docs --- agent/evolution/executor.py | 8 +++++++ config-template.json | 3 ++- config.py | 2 +- docs/ja/memory/self-evolution.mdx | 6 ++--- docs/memory/self-evolution.mdx | 18 +++++++++++--- docs/zh/memory/self-evolution.mdx | 40 ++++++++++++++++++++----------- docs/zh/releases/overview.mdx | 2 +- docs/zh/releases/v2.1.1.mdx | 8 +++---- run.sh | 3 +++ 9 files changed, 63 insertions(+), 27 deletions(-) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py index 20f36864..278ad6b2 100644 --- a/agent/evolution/executor.py +++ b/agent/evolution/executor.py @@ -464,6 +464,14 @@ def run_evolution_for_session( append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id) # Inject an [EVOLUTION] note so the main agent can honor "undo". _inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id) + # The injection appended its own messages ([SCHEDULED]/[EVOLUTION]). + # Advance the cursor past them so the next scan does not treat + # evolution's own bookkeeping as new user content and re-trigger. + try: + with agent.messages_lock: + agent._evo_done_msg_count = len(agent.messages) + except Exception: + pass # Push the summary to the user's channel. The "did a file actually # change" gate above is the only throttle we need: real evolutions are diff --git a/config-template.json b/config-template.json index 8be82885..1fa50b24 100644 --- a/config-template.json +++ b/config-template.json @@ -40,5 +40,6 @@ "agent_max_steps": 20, "enable_thinking": false, "reasoning_effort": "high", - "knowledge": true + "knowledge": true, + "self_evolution_enabled": true } diff --git a/config.py b/config.py index 1f1100fe..0f64f6fc 100644 --- a/config.py +++ b/config.py @@ -252,7 +252,7 @@ available_setting = { "reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max" "knowledge": True, # whether to enable the knowledge base feature # Self-evolution: review idle conversations to learn memory/skills. Flat keys. - "self_evolution_enabled": False, # master switch (off until release) + "self_evolution_enabled": False, # switch to enable/disable self-evolution "self_evolution_idle_minutes": 15, # idle time before a session is reviewed "self_evolution_min_turns": 8, # min user turns (or context pressure) to trigger "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL__ env vars at startup diff --git a/docs/ja/memory/self-evolution.mdx b/docs/ja/memory/self-evolution.mdx index c25fb704..7b065b92 100644 --- a/docs/ja/memory/self-evolution.mdx +++ b/docs/ja/memory/self-evolution.mdx @@ -36,13 +36,13 @@ description: Self-Evolution — 会話がアイドル状態になった後に振 ### 関連設定 -自律進化はデフォルトでは無効です。Web コンソールの「設定 → Agent 設定」(「ディープシンキング」の下)にあるスイッチで有効にできるほか、設定ファイルで調整することもできます: +自律進化は Web コンソールの「設定 → Agent 設定」(「ディープシンキング」の下)にあるスイッチで切り替えられるほか、設定ファイルで調整することもできます: | パラメータ | 説明 | デフォルト値 | | --- | --- | --- | -| `self_evolution_enabled` | 自律進化を有効にするかどうか | `false` | +| `self_evolution_enabled` | 自律進化を有効にするかどうか(新規インストールはデフォルトで有効) | `false` | | `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `15` | -| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `6` | +| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `8` | Web コンソールでは有効・無効のスイッチのみを提供しています。アイドル時間やターン数のしきい値を変更したい場合は、設定ファイルを編集してください。変更は即時に反映され、再起動は不要です。 diff --git a/docs/memory/self-evolution.mdx b/docs/memory/self-evolution.mdx index ad0ebd73..01838bc7 100644 --- a/docs/memory/self-evolution.mdx +++ b/docs/memory/self-evolution.mdx @@ -11,6 +11,10 @@ Self-Evolution lets the Agent do more than finish one task at a time; it keeps i > Self-Evolution complements [Deep Dream](/memory/deep-dream). Deep Dream organizes memory itself, while Self-Evolution goes a step further to improve skills and push unfinished tasks forward, sharpening the Agent's abilities through everyday use. + + Self-Evolution in a conversation + + ### Three Goals Self-Evolution focuses on three things: @@ -36,13 +40,17 @@ Only when both are met does a review begin. This makes sure there is something w ### Configuration -Self-Evolution is off by default. You can turn it on with the toggle in the Web console under **Settings → Agent Config** (below "Deep Thinking"), or adjust it in the config file: +You can toggle Self-Evolution in the Web console under **Settings → Agent Config** (below "Deep Thinking"), or adjust it in the config file: | Parameter | Description | Default | | --- | --- | --- | -| `self_evolution_enabled` | Whether Self-Evolution is enabled | `false` | +| `self_evolution_enabled` | Whether Self-Evolution is enabled (on by default for new installs) | `false` | | `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `15` | -| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `6` | +| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `8` | + + + Enable Self-Evolution in the Web console + The Web console only exposes the on/off toggle. To change the idle window or the turn threshold, edit the config file. Changes take effect immediately, with no restart needed. @@ -52,6 +60,10 @@ Self-Evolution is off by default. You can turn it on with the toggle in the Web Each review is recorded by date in `memory/evolution/YYYY-MM-DD.md`, viewable in the Web console under the **Memory → Self-Evolution** tab. That tab gathers both self-evolution records and dream diaries in one place, so you can look back on how the Agent has grown. + + Self-Evolution records list + + ### Rolling Back If you disagree with a change from a review, just tell the Agent in chat to undo the last change. It restores the affected files from the backup taken before the review. Every review keeps its own backup, so they never interfere with each other. diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index 3557f018..aaec09b4 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -1,19 +1,23 @@ --- -title: 自进化 -description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项 +title: 自主进化 +description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处理未完成事项 --- ## 功能介绍 ### 简介 -自进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。 +自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。在每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,当真正做了事情时才会主动地告诉你。 -> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 +> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。 + + + 自主进化对话示例 + ### 几个目标 -自进化围绕以下几件事工作,并以「优化技能、处理未完成事项」为主,「沉淀记忆、知识」作为主对话的查缺补漏: +自主进化围绕以下几件事工作,并以「优化技能、处理未完成事项」为主,「沉淀记忆、知识」作为主对话的查缺补漏: | 目标 | 说明 | | --- | --- | @@ -28,22 +32,26 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 触发时机 -自进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: +自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: - **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟) -- **对话有足够内容** — 自上次进化以来累积了足够轮次,或上下文已接近容量上限 +- **对话有足够内容** — 自上次进化以来累积了足够轮次(默认 8 轮),或上下文已接近容量上限 只有两个条件都满足,才会启动一次复盘。这样既保证有足够的内容值得复盘,又不会在你还在对话时打扰你。 ### 相关配置 -自进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整: +自主进化可在 Web 控制台「配置 → Agent 配置」中通过开关启停(位于"深度思考"下方),也可在配置文件中调整: | 参数 | 说明 | 默认值 | | --- | --- | --- | -| `self_evolution_enabled` | 是否启用自进化 | `false` | +| `self_evolution_enabled` | 是否启用自主进化(新部署默认开启) | `false` | | `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` | -| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` | +| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `8` | + + + 在 Web 控制台开启自主进化 + Web 控制台只提供启用开关,若需调整空闲时长或轮次阈值,请编辑配置文件。修改后即时生效,无需重启。 @@ -51,7 +59,11 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 进化记录 -每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自进化」tab 中查看。该 tab 同时汇总了自进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 +每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自主进化」tab 中查看。该 tab 同时汇总了自主进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。 + + + 自主进化记录列表 + ### 如何回滚 @@ -59,7 +71,7 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ## 实现设计 -自进化复用了系统已有的能力,保持轻量: +自主进化复用了系统已有的能力,保持轻量: - **隔离执行**:每次复盘都启动一个独立的、临时的复盘任务,使用与主对话相同的模型,但拥有受限的工具集(只能读上下文、改记忆与技能文件)。它不会污染主对话的上下文,也不会影响主对话的性能。 - **基于备份的撤销**:进化前对相关文件做快照备份,撤销时按备份还原,因此每一次改动都可追溯、可逆。 @@ -67,13 +79,13 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优 ### 克制与安全 -自进化的设计原则是"必要时执行,减少打扰": +自主进化的设计原则是"必要时执行,减少打扰": | 机制 | 说明 | | --- | --- | | **没做事不通知** | 如果复盘后没有任何实际改动,全程静默,不产生任何通知 | | **空闲才触发** | 仅在对话空闲后运行,绝不打断正在进行的对话 | | **改动可回滚** | 每次进化前自动备份,若对结果不满意,可一键撤销本次改动 | -| **保护内置技能** | 产品自带的内置技能受保护,进化过程不会改动 | +| **保护内置技能** | 项目自带的内置技能受保护,进化过程不会改动 | | **限定工作空间** | 所有读写都限定在工作空间内,不会触碰系统其他文件 | | **后台异步** | 复盘在后台进行,不阻塞正常对话回复 | diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx index 84013906..42a8ced0 100644 --- a/docs/zh/releases/overview.mdx +++ b/docs/zh/releases/overview.mdx @@ -5,7 +5,7 @@ description: CowAgent 版本更新历史 | 版本 | 日期 | 说明 | | --- | --- | --- | -| [2.1.1](/zh/releases/v2.1.1) | 2026.06.08 | 自进化能力、Web 控制台消息管理升级、MCP 跨平台增强与并发调用、新模型接入(MiniMax-M3、qwen3.7-plus 等)、多项优化 | +| [2.1.1](/zh/releases/v2.1.1) | 2026.06.08 | 自主进化能力、Web 控制台消息管理升级、MCP 跨平台增强与并发调用、新模型接入(MiniMax-M3、qwen3.7-plus 等)、多项优化 | | [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 | | [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | | [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | diff --git a/docs/zh/releases/v2.1.1.mdx b/docs/zh/releases/v2.1.1.mdx index bafbdeec..5f275984 100644 --- a/docs/zh/releases/v2.1.1.mdx +++ b/docs/zh/releases/v2.1.1.mdx @@ -1,13 +1,13 @@ --- title: v2.1.1 -description: CowAgent 2.1.1 - 自进化能力、Web 控制台消息管理升级、MCP 跨平台增强、多模型接入与优化 +description: CowAgent 2.1.1 - 自主进化能力、Web 控制台消息管理升级、MCP 跨平台增强、多模型接入与优化 --- 🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1) -## 🧬 自进化能力 +## 🧬 自主进化能力 -CowAgent 新增**自进化(Self-Evolution)**能力,让 Agent 不止于完成单次任务,而是在与你的日常协作中持续成长: +CowAgent 新增**自主进化(Self-Evolution)**能力,让 Agent 不止于完成单次任务,而是在与你的日常协作中持续成长: - **自动复盘成长**:一段对话自然结束并进入空闲后,Agent 会在后台悄悄回顾刚刚的对话,沉淀值得记住的偏好和事实、修复技能中暴露的问题、并补做未完成的任务 - **越用越懂你**:随着使用,Agent 会逐渐记住你的习惯、减少重复犯错,在收尾上做得更好 @@ -16,7 +16,7 @@ CowAgent 新增**自进化(Self-Evolution)**能力,让 Agent 不止于完 默认关闭,可在 Web 控制台 **设置 → Agent 配置** 中一键开启。 -相关文档:[自进化](https://docs.cowagent.ai/zh/memory/self-evolution) +相关文档:[自主进化](https://docs.cowagent.ai/zh/memory/self-evolution) ## 💬 Web 控制台消息管理升级 diff --git a/run.sh b/run.sh index 1099eb43..1895ea67 100755 --- a/run.sh +++ b/run.sh @@ -881,6 +881,9 @@ base = { 'agent_max_context_tokens': 40000, 'agent_max_context_turns': 30, 'agent_max_steps': 15, + # New installs opt into self-evolution; existing users (no key) keep the + # code default (off) so an upgrade never silently changes their behavior. + 'self_evolution_enabled': True, } channel_map = { 'feishu': {'feishu_app_id': 'FEISHU_APP_ID', 'feishu_app_secret': 'FEISHU_APP_SECRET'}, From 83b53039f322dd2c7109a7721ea1fab7cbfe0f90 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 11:41:32 +0800 Subject: [PATCH 342/399] feat: add 2.1.1 release docs --- agent/evolution/prompts.py | 2 +- docs/docs.json | 1 + docs/releases/overview.mdx | 1 + docs/releases/v2.1.1.mdx | 63 +++++++++++++++++++++++++++++++++++ docs/zh/releases/overview.mdx | 2 +- docs/zh/releases/v2.1.1.mdx | 30 +++++++++-------- 6 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 docs/releases/v2.1.1.mdx diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py index 6a4e9e40..6cf060bc 100644 --- a/agent/evolution/prompts.py +++ b/agent/evolution/prompts.py @@ -127,7 +127,7 @@ them. When their signal is clear, act; do not be shy here. - Nothing worth evolving -> output exactly `[SILENT]` and nothing else. - Otherwise, after performing the edits, output a short user-facing summary in - the SAME LANGUAGE the user used. Write it for an ordinary user, in plain + the SAME LANGUAGE the user speaks in the conversation. Write it for an ordinary user, in plain everyday words — NOT a developer report. No need to expose internal details (file names/paths, system mechanics, etc.). Tell the user, briefly: 1) that you just did a self-learning pass, diff --git a/docs/docs.json b/docs/docs.json index 8f848823..f11a1d2b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -241,6 +241,7 @@ "group": "Release Notes", "pages": [ "releases/overview", + "releases/v2.1.1", "releases/v2.1.0", "releases/v2.0.9", "releases/v2.0.8", diff --git a/docs/releases/overview.mdx b/docs/releases/overview.mdx index c47237ed..6f0542fd 100644 --- a/docs/releases/overview.mdx +++ b/docs/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent version history | Version | Date | Description | | --- | --- | --- | +| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements | | [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models | | [2.0.9](/releases/v2.0.9) | 2026.05.22 | Model management console, MCP protocol support, browser persistent login, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max, etc.), deployment hardening | | [2.0.8](/releases/v2.0.8) | 2026.05.06 | Major Feishu channel upgrade (voice, streaming and Markdown, one-click QR-scan setup), DeepSeek V4 and Baidu models, scheduler tool enhancements | diff --git a/docs/releases/v2.1.1.mdx b/docs/releases/v2.1.1.mdx new file mode 100644 index 00000000..61f0ba29 --- /dev/null +++ b/docs/releases/v2.1.1.mdx @@ -0,0 +1,63 @@ +--- +title: v2.1.1 +description: CowAgent 2.1.1 - Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements, new models and improvements +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1) + +## 🧬 Self-Evolution + +CowAgent introduces **Self-Evolution**, letting the agent go beyond completing a single task and keep improving through everyday collaboration with you: + +- **Automatic review after idle**: Once a conversation goes idle, the agent reviews it in the background to fix problems a skill exposed in use, create reusable new skills, follow up on unfinished tasks, and record important information into memory and the knowledge base +- **Silent by default, notify on demand**: It reports what it changed only when it actually made a change, and stays silent otherwise +- **Safe and reversible**: Every review is backed up beforehand and can be undone at any time. Built-in skills are protected, and all reads and writes stay within the workspace + +Enabled by default for new installs. Existing users can turn it on with a single click in the Web Console under **Settings → Agent Config**. + +Self-Evolution conversation example + +Documentation: [Self-Evolution](https://docs.cowagent.ai/memory/self-evolution) + +## 💬 Web Console Upgrades + +The Web Console chat experience gets several enhancements: + +- **Message management**: Edit, delete, and regenerate both user and bot messages; code blocks now include language labels and a one-click copy button +- **Parallel sessions**: Run multiple sessions at the same time without interference, with live streaming automatically resumed when you switch back to a session +- **Refinements**: Drag and drop files anywhere in the chat view; automatically switch to a sibling session after deleting the active one + +Thanks [@core-power](https://github.com/core-power) (#2865) + +## 🧩 Cross-platform MCP Enhancements + +- **Windows compatibility fix**: Fixed `stdio` communication failing on Windows, and made the server timeout configurable via `mcp.json` +- **Concurrent calls**: The `sse` and `streamable-http` transports now support concurrent calls across sessions for faster multi-tool responses + +Thanks [@xliu123321](https://github.com/xliu123321) (#2859) + +Documentation: [MCP Tools](https://docs.cowagent.ai/tools/mcp) + +## 🤖 New Models & Improvements + +- **MiniMax-M3**: Added and set as the default model, with the M2.7 series kept as an option. Thanks [@octo-patch](https://github.com/octo-patch) (#2855) +- **Qwen3.7-plus**: Added support for multi-modal conversations +- **Selectable ASR model**: The Web Console can now select and persist the ASR (speech recognition) model. Thanks [@nightwhite](https://github.com/nightwhite) (#2857) +- **Simplified install menu**: The one-line install script streamlines the model menu and adds the Xiaomi MiMo option + +Documentation: [Models Overview](https://docs.cowagent.ai/models) + +## 🛠 Improvements & Fixes + +- **Python 3.13 support**: Fixed installation and dependency compatibility on Python 3.13 +- **Internationalization**: The channel list is now ordered by the interface language; refined the automatic language fallback under `auto` mode +- **More reliable cancellation**: Fixed cases where a streaming reply could not be interrupted +- **CLI**: `cow status` now shows the current project path +- **Hardened deployment security**: The credential-file block is narrowed to `~/.cow/.env` so other directories are no longer affected (Thanks [@orbisai0security](https://github.com/orbisai0security) #2863); the WeChat Official Account now rejects webhook requests when `wechatmp_token` is empty +- **Group task board plugin**: Added the group task board plugin source. Thanks [@Wyh-max-star](https://github.com/Wyh-max-star) (#2853) + +## 📦 Upgrade + +Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/guide/upgrade) for details. + +**Release Date**: 2026.06.09 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1) diff --git a/docs/zh/releases/overview.mdx b/docs/zh/releases/overview.mdx index 42a8ced0..d9263da1 100644 --- a/docs/zh/releases/overview.mdx +++ b/docs/zh/releases/overview.mdx @@ -5,7 +5,7 @@ description: CowAgent 版本更新历史 | 版本 | 日期 | 说明 | | --- | --- | --- | -| [2.1.1](/zh/releases/v2.1.1) | 2026.06.08 | 自主进化能力、Web 控制台消息管理升级、MCP 跨平台增强与并发调用、新模型接入(MiniMax-M3、qwen3.7-plus 等)、多项优化 | +| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入(MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 | | [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 | | [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 | | [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 | diff --git a/docs/zh/releases/v2.1.1.mdx b/docs/zh/releases/v2.1.1.mdx index 5f275984..53669843 100644 --- a/docs/zh/releases/v2.1.1.mdx +++ b/docs/zh/releases/v2.1.1.mdx @@ -1,30 +1,31 @@ --- title: v2.1.1 -description: CowAgent 2.1.1 - 自主进化能力、Web 控制台消息管理升级、MCP 跨平台增强、多模型接入与优化 +description: CowAgent 2.1.1:自主进化能力、Web 控制台消息管理与多会话并行、MCP 跨平台增强、多模型接入与优化 --- 🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1) ## 🧬 自主进化能力 -CowAgent 新增**自主进化(Self-Evolution)**能力,让 Agent 不止于完成单次任务,而是在与你的日常协作中持续成长: +CowAgent 新增 **自主进化(Self-Evolution)** 能力,让 Agent 不止于完成单次任务,而是在与你的日常协作中持续成长: -- **自动复盘成长**:一段对话自然结束并进入空闲后,Agent 会在后台悄悄回顾刚刚的对话,沉淀值得记住的偏好和事实、修复技能中暴露的问题、并补做未完成的任务 -- **越用越懂你**:随着使用,Agent 会逐渐记住你的习惯、减少重复犯错,在收尾上做得更好 -- **不打扰原则**:只有在确实做出了改动时,才用一句话告诉你它学到了什么、调整了什么,没有变化时全程静默 -- **安全可回退**:每次复盘前都会自动备份,对结果不满意时只需在对话中让它撤销即可恢复;内置技能受保护、所有改动均限制在工作空间内 +- **空闲后自动复盘**:对话空闲后自动复盘,修正技能在使用中暴露的问题、创建可复用的新技能,处理遗留的未完成事项,并将重要信息补充进记忆与知识库 +- **静默执行、按需提醒**:仅在实际有改动时主动告知本次调整内容,无变更时全程静默 +- **安全可回退**:每次复盘前自动备份,可随时撤销本次改动;内置技能受保护,所有读写均限定在工作空间内 -默认关闭,可在 Web 控制台 **设置 → Agent 配置** 中一键开启。 +新安装用户默认开启,已有用户可在 Web 控制台 **设置 → Agent 配置** 中一键开启。 + +自主进化对话示例 相关文档:[自主进化](https://docs.cowagent.ai/zh/memory/self-evolution) -## 💬 Web 控制台消息管理升级 +## 💬 Web 控制台升级 -Web 控制台的聊天体验进一步增强,操作更接近主流聊天产品: +Web 控制台的聊天体验进一步增强: -- **消息编辑与重发**:用户与机器人的消息均支持编辑、删除、重新生成 -- **代码块更易用**:代码块新增语言标签和一键复制按钮 -- **拖拽体验优化**:支持将文件拖拽到整个聊天区域 +- **消息管理**:用户与机器人的消息均支持编辑、删除、重新生成;代码块新增语言标签和一键复制按钮 +- **多会话并行**:支持多个会话同时进行、互不干扰,切回会话时自动恢复实时流式输出 +- **细节优化**:支持将文件拖拽到整个聊天区域;删除当前会话后自动切换到相邻会话 Thanks @core-power (#2865) @@ -48,14 +49,15 @@ Thanks @xliu123321 (#2859) ## 🛠 体验优化与修复 +- **Python 3.13 支持**:修复在 Python 3.13 环境下的安装与依赖兼容问题 - **国际化体验**:通道列表按界面语言排序展示;优化 `auto` 模式下的语言自动回退逻辑 - **任务取消更可靠**:修复部分场景下流式回复无法中断的问题 - **CLI 增强**:`cow status` 新增显示当前项目路径 - **部署安全加固**:凭证文件拦截范围收敛至 `~/.cow/.env`,不再误拦其他目录(Thanks @orbisai0security #2863);微信公众号在 `wechatmp_token` 为空时拒绝 Webhook 请求 -- **群任务看板插件**:内置群任务看板插件源。Thanks @Wyh-max-star (#2853) +- **群任务看板插件**:新增群聊任务看板插件源。Thanks @Wyh-max-star (#2853) ## 📦 升级方式 源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。 -**发布日期**:2026.06.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1) +**发布日期**:2026.06.09 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1) From 12c0383dc82d1654ccf326c5a07c5670da1441d7 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 12:07:41 +0800 Subject: [PATCH 343/399] docs: update self-evolution docs --- README.md | 5 ++++- docs/intro/architecture.mdx | 2 ++ docs/intro/features.mdx | 8 +++++++- docs/intro/index.mdx | 3 +++ docs/ja/README.md | 5 ++++- docs/ja/intro/architecture.mdx | 2 ++ docs/ja/intro/features.mdx | 8 +++++++- docs/ja/intro/index.mdx | 3 +++ docs/zh/README.md | 5 ++++- docs/zh/intro/architecture.mdx | 2 ++ docs/zh/intro/features.mdx | 8 +++++++- docs/zh/intro/index.mdx | 3 +++ docs/zh/memory/self-evolution.mdx | 2 +- 13 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 462c2cac..252d6603 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [English] | [中文] | [日本語]

-**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, and grows alongside you through a personal knowledge base and long-term memory — a reference implementation of Agent Harness engineering. +**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering. CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms. @@ -36,6 +36,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major | [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached | | [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval | | [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | +| [Evolution](https://docs.cowagent.ai/memory/self-evolution) | Self-Evolution reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use | | [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | | [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | | [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | @@ -198,6 +199,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin ## 🏷 Changelog +> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support. + > **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo). > **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening. diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index 98084b48..a9797bf1 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -16,6 +16,7 @@ CowAgent's architecture consists of the following core modules: | **Plan** | Understands user intent, decomposes complex tasks into multi-step plans, and iteratively invokes tools until the goal is achieved | | **Memory** | Automatically persists important information as core memory and daily memory, with hybrid keyword and vector retrieval for cross-session context continuity | | **Knowledge** | Organizes structured knowledge by topic. The Agent autonomously distills valuable information into Markdown pages, maintaining indexes and cross-references to build a growing knowledge network | +| **Evolution** | Reviews a conversation in an isolated environment after it goes idle, improving skills, following up on unfinished tasks, and backfilling memory and knowledge so the Agent keeps growing through everyday use | | **Tools** | Core capability for Agent to access OS resources. 10+ built-in tools including file read/write, terminal, browser, scheduler, memory search, web search, and more | | **Skills** | Loads and manages Skills. Supports one-click installation from Skill Hub, GitHub, and more, or custom skill creation through conversation | | **Models** | Model layer with unified access to OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, and other mainstream LLMs | @@ -84,4 +85,5 @@ Configure Agent mode parameters in `config.json`: | `agent_max_steps` | Max decision steps per task | `20` | | `enable_thinking` | Enable deep-thinking mode | `false` | | `knowledge` | Enable personal knowledge base | `true` | +| `self_evolution_enabled` | Enable Self-Evolution (on by default for new installs) | `false` | | `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` | diff --git a/docs/intro/features.mdx b/docs/intro/features.mdx index 3f27012a..e2b7c226 100644 --- a/docs/intro/features.mdx +++ b/docs/intro/features.mdx @@ -15,7 +15,13 @@ In subsequent long-term conversations, the Agent intelligently stores or retriev -See [Long-term Memory](/memory) and [Deep Dream](/memory/deep-dream) for details. +Building on this, **Self-Evolution** lets the Agent keep growing through everyday use: after a conversation goes idle, it reviews it automatically to improve skills, follow up on unfinished tasks, and backfill memory and knowledge. It speaks up only when it actually made a change, and every change can be undone. Enabled by default for new installs. + + + + + +See [Long-term Memory](/memory), [Deep Dream](/memory/deep-dream), and [Self-Evolution](/memory/self-evolution) for details. ## 2. Personal Knowledge Base diff --git a/docs/intro/index.mdx b/docs/intro/index.mdx index 508d1362..5177d557 100644 --- a/docs/intro/index.mdx +++ b/docs/intro/index.mdx @@ -32,6 +32,9 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing. + + Reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use. + A complete skill creation and execution engine. Install from Skill Hub or generate custom skills via natural-language conversation. diff --git a/docs/ja/README.md b/docs/ja/README.md index 3b98c79c..39c2b6a2 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -15,7 +15,7 @@ [English] | [中文] | [日本語]

-**CowAgent** は、自律的にタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、パーソナルナレッジベースと長期記憶でユーザーとともに成長するオープンソースのスーパー AI アシスタントです。エンドツーエンドの Agent Harness のリファレンス実装の一つでもあります。 +**CowAgent** は、自律的にタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、パーソナルナレッジベースと長期記憶を構築し、自己進化によってユーザーとともに成長するオープンソースのスーパー AI アシスタントです。エンドツーエンドの Agent Harness のリファレンス実装の一つでもあります。 CowAgent は軽量でデプロイしやすく、拡張性に優れています。主要な LLM プロバイダーをそのまま組み込み、Web や主要な IM プラットフォーム上で動作。個人 PC やサーバー上で 24 時間 365 日稼働できます。 @@ -36,6 +36,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています | [タスク計画](https://docs.cowagent.ai/ja/intro/architecture) | 複雑なタスクを分解し、目標達成までツールを繰り返し呼び出して段階的に実行 | | [長期記憶](https://docs.cowagent.ai/ja/memory/index) | 三層構造(コンテキスト → デイリー → コア)、Deep Dream による自動蒸留、キーワードとベクトルのハイブリッド検索 | | [ナレッジベース](https://docs.cowagent.ai/ja/knowledge/index) | 構造化された知識を Markdown Wiki として自動整理し、進化し続けるナレッジグラフを可視化ブラウジング | +| [自己進化](https://docs.cowagent.ai/ja/memory/self-evolution) | 会話を自動でレビューして Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完。日々の利用を通じて成長 | | [Skill](https://docs.cowagent.ai/ja/skills/index) | [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub からワンクリックでインストール;対話によるカスタム Skill 作成にも対応 | | [ツール](https://docs.cowagent.ai/ja/tools/index) | ファイル I/O、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10+ の組み込みツール — MCP プロトコルに完全対応 | | [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram、Slack を同時にサポート | @@ -198,6 +199,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 ## 🏷 更新履歴 +> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル(MiniMax-M3、qwen3.7-plus)、Python 3.13 対応。 + > **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国際化対応、新チャネル(Telegram、Discord、Slack、WeChat カスタマーサービス)、CLI インタラクション強化、ワンライナーインストールの最適化、MCP Streamable HTTP 対応、新モデル(claude-opus-4-8、MiMo)。 > **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — モデル管理、MCP プロトコル対応、ブラウザセッション永続化、新モデル(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、デプロイのセキュリティ強化。 diff --git a/docs/ja/intro/architecture.mdx b/docs/ja/intro/architecture.mdx index d0e0ea2a..5e8b202b 100644 --- a/docs/ja/intro/architecture.mdx +++ b/docs/ja/intro/architecture.mdx @@ -16,6 +16,7 @@ CowAgent のアーキテクチャは以下のコアモジュールで構成さ | **Plan** | ユーザーの意図を理解し、複雑なタスクをマルチステップの計画に分解、目標達成までツールを反復的に呼び出す | | **Memory** | 重要な情報をコアメモリとデイリーメモリとして自動永続化し、キーワードとベクトルのハイブリッド検索でセッション間の連続性を実現 | | **Knowledge** | トピック別に構造化された知識を整理。Agent が価値ある情報を Markdown ページとして自律的に整理し、インデックスと相互参照で成長するナレッジネットワークを構築 | +| **Evolution** | 会話がアイドルになった後、隔離環境で自動レビューを実行。Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完して、日々の利用を通じて Agent を成長させる | | **Tools** | Agent が OS リソースにアクセスするための中核能力。ファイル読み書き、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10 以上の組み込みツール | | **Skills** | Skill の読み込み・管理。Skill Hub や GitHub からのワンクリックインストール、または会話を通じたカスタム Skill の作成をサポート | | **Models** | モデル層。OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen など主要 LLM への統一アクセスを提供 | @@ -82,4 +83,5 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス | `agent_max_context_turns` | 最大コンテキストターン数 | `30` | | `agent_max_steps` | タスクあたりの最大判断ステップ数 | `15` | | `knowledge` | パーソナルナレッジベースの有効化 | `true` | +| `self_evolution_enabled` | 自己進化の有効化(新規インストールではデフォルト有効) | `false` | | `cow_lang` | UI・コマンド文言・システムプロンプトなどの言語。`auto` で自動検出、`zh` / `en` も指定可 | `auto` | diff --git a/docs/ja/intro/features.mdx b/docs/ja/intro/features.mdx index e5c65685..0e509ea7 100644 --- a/docs/ja/intro/features.mdx +++ b/docs/ja/intro/features.mdx @@ -15,7 +15,13 @@ description: CowAgent の長期記憶、タスク計画、Skill システム、C -詳細は [長期記憶](/ja/memory) と [Deep Dream](/ja/memory/deep-dream) を参照してください。 +これに加えて、**自己進化(Self-Evolution)** により Agent は日々の利用を通じて成長し続けます。会話がアイドルになった後に自動でレビューを行い、Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完します。実際に変更があったときのみ簡潔に通知し、変更はいつでも取り消せます。新規インストールではデフォルトで有効です。 + + + + + +詳細は [長期記憶](/ja/memory)、[Deep Dream](/ja/memory/deep-dream)、[自己進化](/ja/memory/self-evolution) を参照してください。 ## 2. パーソナルナレッジベース diff --git a/docs/ja/intro/index.mdx b/docs/ja/intro/index.mdx index 404d9d72..317e4056 100644 --- a/docs/ja/intro/index.mdx +++ b/docs/ja/intro/index.mdx @@ -25,6 +25,9 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部 構造化された知識を自動整理し、ナレッジグラフの可視化をサポート。相互参照により継続的に成長するナレッジネットワークを構築します。 + + 会話の終了後に自動でレビューし、Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完。日々の利用を通じて Agent が成長し続けます。 + Skill の作成・実行エンジンを実装し、組み込み Skill を搭載。自然言語の会話を通じてカスタム Skill の開発もサポートしています。 diff --git a/docs/zh/README.md b/docs/zh/README.md index ec815932..a39b4d13 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -15,7 +15,7 @@ [English] | [中文] | [日本語]

-**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆,与你一同成长,是 Agent Harness 工程的最佳实践之一。 +**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆、通过自主进化与你一同成长,是 Agent Harness 工程的最佳实践之一。 CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、Slack、网页等多渠道,7×24 运行于个人电脑或服务器中。 @@ -36,6 +36,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖 | [任务规划](https://docs.cowagent.ai/zh/intro/architecture) | 理解复杂任务并自主分解执行,循环调用工具直到完成目标 | | [长期记忆](https://docs.cowagent.ai/zh/memory) | 三层记忆架构(上下文 → 天级 → 核心),梦境蒸馏自动整理,支持关键词与向量混合检索 | | [知识库](https://docs.cowagent.ai/zh/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 | +| [自主进化](https://docs.cowagent.ai/zh/memory/self-evolution) | 自动复盘对话,优化技能、处理未完成事项、沉淀记忆与知识,在使用中持续成长 | | [技能](https://docs.cowagent.ai/zh/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 | | [工具](https://docs.cowagent.ai/zh/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 | | [通道](https://docs.cowagent.ai/zh/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 | @@ -199,6 +200,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 ## 🏷 更新日志 +> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自进化能力、Web 控制台升级(消息管理、多会话并行)、新模型接入(MiniMax-M3、qwen3.7-plus)、Python 3.13 支持 + > **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国际化支持、新增通道(Telegram、Discord、Slack、微信客服)、命令行交互升级、一键安装脚本优化、MCP Streamable HTTP 支持、新模型接入(claude-opus-4-8、MiMo) > **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — 模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、部署安全加固 diff --git a/docs/zh/intro/architecture.mdx b/docs/zh/intro/architecture.mdx index 9d8c3da2..6e3899bf 100644 --- a/docs/zh/intro/architecture.mdx +++ b/docs/zh/intro/architecture.mdx @@ -16,6 +16,7 @@ CowAgent 的整体架构由以下核心模块组成: | **Plan** | 理解用户意图,将复杂任务分解为多步骤计划,循环调用工具直到完成目标 | | **Memory** | 自动将重要信息持久化为核心记忆和日级记忆,支持关键词和向量混合检索,跨会话保持上下文连续性 | | **Knowledge** | 以主题维度组织结构化知识,Agent 自主整理有价值信息为 Markdown 页面,维护索引和交叉引用,构建持续增长的知识网络 | +| **Evolution** | 对话空闲后在隔离环境中自动复盘,优化技能、处理未完成事项、补全记忆与知识,让 Agent 在使用中持续成长 | | **Tools** | Agent 访问操作系统资源的核心能力,内置文件读写、终端执行、浏览器操作、定时调度、记忆检索、联网搜索等 10+ 种工具 | | **Skills** | 加载和管理 Skills,支持从 Skill Hub、GitHub 等一键安装,或通过对话创建自定义技能 | | **Models** | 模型层,统一接入 OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen 等国内外主流大语言模型 | @@ -84,4 +85,5 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词 | `agent_max_steps` | 单次任务最大决策步数 | `20` | | `enable_thinking` | 是否启用深度思考模式 | `false` | | `knowledge` | 是否启用个人知识库 | `true` | +| `self_evolution_enabled` | 是否启用自主进化 | `false` | | `cow_lang` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` | diff --git a/docs/zh/intro/features.mdx b/docs/zh/intro/features.mdx index 86aa5c95..5e8d1b4b 100644 --- a/docs/zh/intro/features.mdx +++ b/docs/zh/intro/features.mdx @@ -15,7 +15,13 @@ description: CowAgent 长期记忆、个人知识库、任务规划、技能系 -详细说明请参考 [长期记忆](/zh/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)。 +在此基础上,**自主进化(Self-Evolution)** 让 Agent 在使用中持续成长:对话空闲后自动复盘,优化技能、处理遗留任务、补全记忆与知识,仅在确有改动时简短告知,且每次改动可随时撤销。 + + + + + +详细说明请参考 [长期记忆](/zh/memory)、[梦境蒸馏](/zh/memory/deep-dream) 和 [自主进化](/zh/memory/self-evolution)。 ## 2. 个人知识库 diff --git a/docs/zh/intro/index.mdx b/docs/zh/intro/index.mdx index 87076e0f..db4a4613 100644 --- a/docs/zh/intro/index.mdx +++ b/docs/zh/intro/index.mdx @@ -32,6 +32,9 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、 自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。 + + 对话结束后自动复盘,优化技能、处理遗留任务、沉淀记忆与知识,让 Agent 在使用中持续成长。 + 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发。 diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index aaec09b4..8b230b84 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -45,7 +45,7 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处 | 参数 | 说明 | 默认值 | | --- | --- | --- | -| `self_evolution_enabled` | 是否启用自主进化(新部署默认开启) | `false` | +| `self_evolution_enabled` | 是否启用自主进化 | `false` | | `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` | | `self_evolution_min_turns` | 触发所需的最少对话轮次 | `8` | From 8cb53e612919ddbd8231dd86e6c060222b517536 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 14:38:05 +0800 Subject: [PATCH 344/399] feat: release 2.1.1 --- cli/VERSION | 2 +- docs/docs.json | 1 + docs/ja/releases/overview.mdx | 1 + docs/ja/releases/v2.1.1.mdx | 61 +++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 docs/ja/releases/v2.1.1.mdx diff --git a/cli/VERSION b/cli/VERSION index 7ec1d6db..3e3c2f1e 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -2.1.0 +2.1.1 diff --git a/docs/docs.json b/docs/docs.json index f11a1d2b..c5c27837 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -673,6 +673,7 @@ "group": "リリースノート", "pages": [ "ja/releases/overview", + "ja/releases/v2.1.1", "ja/releases/v2.1.0", "ja/releases/v2.0.9", "ja/releases/v2.0.8", diff --git a/docs/ja/releases/overview.mdx b/docs/ja/releases/overview.mdx index 9f0d80e4..355165ab 100644 --- a/docs/ja/releases/overview.mdx +++ b/docs/ja/releases/overview.mdx @@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴 | バージョン | 日付 | 説明 | | --- | --- | --- | +| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加(MiniMax-M3、qwen3.7-plus など)、各種改善 | | [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化(ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル)、MCP Streamable HTTP、新モデル追加 | | [2.0.9](/ja/releases/v2.0.9) | 2026.05.22 | モデル管理機能の追加、MCP プロトコル対応、ブラウザログイン状態の永続化、新モデル追加(gpt-5.5、gemini-3.5-flash、qwen3.7-max など)、デプロイ・セキュリティ強化 | | [2.0.8](/ja/releases/v2.0.8) | 2026.05.06 | Feishu チャネル全面アップグレード(音声、ストリーミング出力と Markdown、QR コードによるワンクリック接続)、DeepSeek V4 と百度モデルの追加、スケジュールタスクツールの強化 | diff --git a/docs/ja/releases/v2.1.1.mdx b/docs/ja/releases/v2.1.1.mdx new file mode 100644 index 00000000..b4e48906 --- /dev/null +++ b/docs/ja/releases/v2.1.1.mdx @@ -0,0 +1,61 @@ +--- +title: v2.1.1 +description: CowAgent 2.1.1 - 自己進化、Web コンソールのメッセージ管理とマルチセッション並行、クロスプラットフォーム対応の MCP 強化、新モデルと改善 +--- + +🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1) + +## 🧬 自己進化 + +CowAgent に **自己進化(Self-Evolution)** が加わりました。Agent は単発のタスクをこなすだけでなく、日々の協働を通じて成長し続けます: + +- **アイドル後の自動レビュー**:会話がアイドルになると、Agent はバックグラウンドでその会話をレビューし、使用中に表面化した Skill の問題を修正し、再利用可能な新しい Skill を作成し、未完了のタスクを引き継ぎ、重要な情報を記憶とナレッジベースに記録します +- **静かに実行、必要なときだけ通知**:実際に変更があったときのみ調整内容を一言で伝え、変更がなければ何も通知しません +- **安全で取り消し可能**:レビューのたびに事前にバックアップを取り、いつでも取り消せます。組み込み Skill は保護され、すべての読み書きはワークスペース内に限定されます + +新規インストールではデフォルトで有効です。既存ユーザーは Web コンソールの **設定 → Agent 設定** からワンクリックで有効化できます。 + +自己進化の会話例 + +ドキュメント:[自己進化](https://docs.cowagent.ai/ja/memory/self-evolution) + +## 💬 Web コンソールの強化 + +Web コンソールのチャット体験がさらに強化され、主要なチャット製品に近い操作感になりました: + +- **メッセージ管理**:ユーザーと Bot のメッセージを編集・削除・再生成できます。コードブロックに言語ラベルとワンクリックコピーボタンを追加。Thanks [@core-power](https://github.com/core-power) (#2865) +- **マルチセッション並行**:複数のセッションを同時に実行しても互いに干渉せず、セッションに戻るとライブストリーミングが自動的に再開されます +- **細部の改善**:チャット画面のどこにでもファイルをドラッグ&ドロップ可能。アクティブなセッションを削除すると隣接セッションへ自動で切り替わります + +## 🧩 クロスプラットフォーム対応の MCP 強化 + +- **Windows 互換性の修正**:Windows で `stdio` 通信が動作しない問題を修正し、サーバーのタイムアウトを `mcp.json` で設定できるようにしました +- **並行呼び出し**:`sse` と `streamable-http` トランスポートがセッション間の並行呼び出しに対応し、複数ツールの応答が高速化されました + +Thanks [@xliu123321](https://github.com/xliu123321) (#2859) + +ドキュメント:[MCP ツール](https://docs.cowagent.ai/ja/tools/mcp) + +## 🤖 新モデルと改善 + +- **MiniMax-M3**:追加してデフォルトモデルに設定(M2.7 シリーズはオプションとして継続)。Thanks [@octo-patch](https://github.com/octo-patch) (#2855) +- **Qwen3.7-plus**:マルチモーダル対話に対応 +- **ASR モデルの選択**:Web コンソールで ASR(音声認識)モデルを選択して永続化できるようになりました。Thanks [@nightwhite](https://github.com/nightwhite) (#2857) +- **インストールメニューの簡素化**:ワンライナーインストールスクリプトのモデルメニューを簡素化し、Xiaomi MiMo オプションを追加 + +ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models) + +## 🛠 改善と修正 + +- **Python 3.13 対応**:Python 3.13 環境でのインストールと依存関係の互換性を修正 +- **国際化**:チャネル一覧を UI の言語順に表示。`auto` モードでの言語自動フォールバックを改善 +- **より確実なキャンセル**:ストリーミング応答を中断できない場合がある問題を修正 +- **CLI**:`cow status` に現在のプロジェクトパスを表示 +- **デプロイのセキュリティ強化**:認証ファイルのブロック範囲を `~/.cow/.env` に限定し、他のディレクトリに影響しないように修正(Thanks [@orbisai0security](https://github.com/orbisai0security) #2863)。WeChat 公式アカウントは `wechatmp_token` が空の場合に Webhook リクエストを拒否します +- **グループタスクボードプラグイン**:グループタスクボードプラグインのソースを追加。Thanks [@Wyh-max-star](https://github.com/Wyh-max-star) (#2853) + +## 📦 アップグレード + +ソースコードでデプロイしている場合は `cow update` でワンクリックアップグレードするか、最新コードを取得して手動で再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。 + +**リリース日**:2026.06.09 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1) From 7c9ea62993e730e2a848fbc6a52dd437473061a3 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 15:22:38 +0800 Subject: [PATCH 345/399] chore(evolution): lower trigger thresholds to 6 turns / 10 min idle --- agent/evolution/config.py | 4 ++-- channel/web/chat.html | 2 +- channel/web/static/js/console.js | 3 +++ config.py | 4 ++-- docs/ja/memory/self-evolution.mdx | 6 +++--- docs/memory/self-evolution.mdx | 6 +++--- docs/zh/memory/self-evolution.mdx | 8 ++++---- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/agent/evolution/config.py b/agent/evolution/config.py index a3215858..948876fb 100644 --- a/agent/evolution/config.py +++ b/agent/evolution/config.py @@ -13,8 +13,8 @@ from typing import Any # Defaults — conservative (see executor module docstring). Disabled by default # until release; enable via ``self_evolution_enabled``. DEFAULT_ENABLED = False -DEFAULT_IDLE_MINUTES = 15 -DEFAULT_MIN_TURNS = 8 +DEFAULT_IDLE_MINUTES = 10 +DEFAULT_MIN_TURNS = 6 # Max review steps for the isolated evolution agent. Kept small (not exposed as # config): the review is meant to be cheap and focused, not a long autonomous run. DEFAULT_MAX_STEPS = 12 diff --git a/channel/web/chat.html b/channel/web/chat.html index 3a56f8c1..4f527bb4 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -285,7 +285,7 @@ - diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index e50ff0c9..1b170de8 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -470,6 +470,9 @@ function applyI18n() { installCfgTipPortal(); const langLabel = document.getElementById('lang-label'); if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN'; + // Point the docs link to the locale-specific documentation site. + const docsLink = document.getElementById('docs-link'); + if (docsLink) docsLink.href = currentLang === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai'; } // Single entry point for switching language. Updates the in-memory language, diff --git a/config.py b/config.py index 0f64f6fc..f711aad2 100644 --- a/config.py +++ b/config.py @@ -253,8 +253,8 @@ available_setting = { "knowledge": True, # whether to enable the knowledge base feature # Self-evolution: review idle conversations to learn memory/skills. Flat keys. "self_evolution_enabled": False, # switch to enable/disable self-evolution - "self_evolution_idle_minutes": 15, # idle time before a session is reviewed - "self_evolution_min_turns": 8, # min user turns (or context pressure) to trigger + "self_evolution_idle_minutes": 10, # idle time before a session is reviewed + "self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL__ env vars at startup "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) } diff --git a/docs/ja/memory/self-evolution.mdx b/docs/ja/memory/self-evolution.mdx index 7b065b92..7e81f5a2 100644 --- a/docs/ja/memory/self-evolution.mdx +++ b/docs/ja/memory/self-evolution.mdx @@ -29,7 +29,7 @@ description: Self-Evolution — 会話がアイドル状態になった後に振 自律進化は定時実行ではなく、**会話が自然に終わってアイドル状態になった後**にのみ起動するため、進行中のやり取りを妨げることはありません。次の 2 つの条件を同時に満たす必要があります: -- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 15 分)以上が経過している +- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 10 分)以上が経過している - **振り返るだけの内容がある**:前回の進化から十分なターン数が蓄積されている、またはコンテキストが容量の上限に近づいている 両方の条件を満たしたときにのみ振り返りが始まります。これにより、振り返る価値のある内容を確保しつつ、会話の途中で邪魔をしないようにしています。 @@ -41,8 +41,8 @@ description: Self-Evolution — 会話がアイドル状態になった後に振 | パラメータ | 説明 | デフォルト値 | | --- | --- | --- | | `self_evolution_enabled` | 自律進化を有効にするかどうか(新規インストールはデフォルトで有効) | `false` | -| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `15` | -| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `8` | +| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `10` | +| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `6` | Web コンソールでは有効・無効のスイッチのみを提供しています。アイドル時間やターン数のしきい値を変更したい場合は、設定ファイルを編集してください。変更は即時に反映され、再起動は不要です。 diff --git a/docs/memory/self-evolution.mdx b/docs/memory/self-evolution.mdx index 01838bc7..ca9342a8 100644 --- a/docs/memory/self-evolution.mdx +++ b/docs/memory/self-evolution.mdx @@ -33,7 +33,7 @@ Once a review is done, if it actually changed something, the Agent tells you in Self-Evolution does not run on a fixed schedule. It only kicks in **after a conversation naturally ends and goes idle**, so it never interrupts an ongoing exchange. Two conditions must both hold: -- **The conversation is idle**: more time has passed since the last interaction than the configured idle window (15 minutes by default) +- **The conversation is idle**: more time has passed since the last interaction than the configured idle window (10 minutes by default) - **There is enough to review**: enough turns have accumulated since the last evolution, or the context is close to its capacity Only when both are met does a review begin. This makes sure there is something worth reviewing while keeping it from bothering you mid-conversation. @@ -45,8 +45,8 @@ You can toggle Self-Evolution in the Web console under **Settings → Agent Conf | Parameter | Description | Default | | --- | --- | --- | | `self_evolution_enabled` | Whether Self-Evolution is enabled (on by default for new installs) | `false` | -| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `15` | -| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `8` | +| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `10` | +| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `6` | Enable Self-Evolution in the Web console diff --git a/docs/zh/memory/self-evolution.mdx b/docs/zh/memory/self-evolution.mdx index 8b230b84..ed21d677 100644 --- a/docs/zh/memory/self-evolution.mdx +++ b/docs/zh/memory/self-evolution.mdx @@ -34,8 +34,8 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处 自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足: -- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟) -- **对话有足够内容** — 自上次进化以来累积了足够轮次(默认 8 轮),或上下文已接近容量上限 +- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 10 分钟) +- **对话有足够内容** — 自上次进化以来累积了足够轮次(默认 6 轮),或上下文已接近容量上限 只有两个条件都满足,才会启动一次复盘。这样既保证有足够的内容值得复盘,又不会在你还在对话时打扰你。 @@ -46,8 +46,8 @@ description: Self-Evolution:自动复盘,沉淀记忆、优化技能、处 | 参数 | 说明 | 默认值 | | --- | --- | --- | | `self_evolution_enabled` | 是否启用自主进化 | `false` | -| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` | -| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `8` | +| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `10` | +| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` | 在 Web 控制台开启自主进化 From ef46199346b593feba64cee6b5a83bac011bcdfd Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 15:24:32 +0800 Subject: [PATCH 346/399] feat: update run.sh for python3.13 --- run.sh | 4 ++-- scripts/run.ps1 | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/run.sh b/run.sh index 1895ea67..015f5d92 100755 --- a/run.sh +++ b/run.sh @@ -359,7 +359,7 @@ detect_python_command() { FOUND_NEWER_VERSION="" # Try to find Python command in order of preference - for cmd in python3 python python3.13 python3.12 python3.11 python3.10 python3.9 python3.8 python3.7; do + for cmd in python3 python python3.12 python3.11 python3.10 python3.9 python3.8 python3.7 python3.13; do if command -v $cmd &> /dev/null; then # Check Python version major_version=$($cmd -c 'import sys; print(sys.version_info[0])' 2>/dev/null) @@ -378,7 +378,7 @@ detect_python_command() { done if [ -z "$PYTHON_CMD" ]; then - echo -e "${YELLOW}Tried: python3, python, python3.13, python3.12, python3.11, python3.10, python3.9, python3.8, python3.7${NC}" + echo -e "${YELLOW}Tried: python3, python, python3.12, python3.11, python3.10, python3.9, python3.8, python3.7, python3.13${NC}" echo -e "${RED}❌ No suitable Python found. Please install Python 3.7 or newer${NC}" exit 1 fi diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 636ade2f..53ad3cb8 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -194,6 +194,8 @@ function Select-Language { # ── Python detection ───────────────────────────────────────────── function Find-Python { + # 3.13 compatibility is not great, so prefer 3.7-3.12 and only fall back to 3.13. + $fallback = $null foreach ($cmd in @("python3", "python")) { $bin = Get-Command $cmd -ErrorAction SilentlyContinue if (-not $bin) { continue } @@ -201,12 +203,15 @@ function Find-Python { $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 7 -and $minor -le 13) { + if ($major -eq 3 -and $minor -ge 7 -and $minor -le 12) { return $bin.Source } + if ($major -eq 3 -and $minor -eq 13 -and -not $fallback) { + $fallback = $bin.Source + } } catch {} } - return $null + return $fallback } $PythonCmd = Find-Python From e812c7d29a761246702d1946ea71c74ede3048cc Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 16:08:17 +0800 Subject: [PATCH 347/399] feat(vision): increase vision tool max_tokens --- agent/tools/vision/vision.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index e022b4f3..bee51c5c 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -32,7 +32,7 @@ from config import conf DEFAULT_MODEL = const.GPT_41_MINI DEFAULT_TIMEOUT = 60 -MAX_TOKENS = 1000 +MAX_TOKENS = 4000 COMPRESS_THRESHOLD = 1_048_576 # 1 MB SUPPORTED_EXTENSIONS = { From 08e23e5bd89327d1a3cb4c5feb4d57df5bc4131d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 16:36:01 +0800 Subject: [PATCH 348/399] fix(vision): bump vision timeout from 60s to 180s to avoid premature failures --- agent/tools/vision/vision.py | 2 +- models/deepseek/deepseek_bot.py | 2 +- models/doubao/doubao_bot.py | 2 +- models/gemini/google_gemini_bot.py | 2 +- models/mimo/mimo_bot.py | 2 +- models/minimax/minimax_bot.py | 2 +- models/moonshot/moonshot_bot.py | 2 +- models/openai_compatible_bot.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index bee51c5c..19878f6d 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -31,7 +31,7 @@ from common.log import logger from config import conf DEFAULT_MODEL = const.GPT_41_MINI -DEFAULT_TIMEOUT = 60 +DEFAULT_TIMEOUT = 180 MAX_TOKENS = 4000 COMPRESS_THRESHOLD = 1_048_576 # 1 MB diff --git a/models/deepseek/deepseek_bot.py b/models/deepseek/deepseek_bot.py index 6f44ec23..23c72b1d 100644 --- a/models/deepseek/deepseek_bot.py +++ b/models/deepseek/deepseek_bot.py @@ -657,7 +657,7 @@ class DeepSeekBot(Bot, OpenAICompatibleBot): headers = self._build_headers() resp = requests.post( f"{self.api_base}/chat/completions", - headers=headers, json=payload, timeout=60, + headers=headers, json=payload, timeout=180, ) if resp.status_code != 200: return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} diff --git a/models/doubao/doubao_bot.py b/models/doubao/doubao_bot.py index f8ccfff7..3c8e35e6 100644 --- a/models/doubao/doubao_bot.py +++ b/models/doubao/doubao_bot.py @@ -170,7 +170,7 @@ class DoubaoBot(Bot): "Content-Type": "application/json", } resp = requests.post(f"{self.base_url}/chat/completions", - headers=headers, json=payload, timeout=60) + headers=headers, json=payload, timeout=180) if resp.status_code != 200: return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} data = resp.json() diff --git a/models/gemini/google_gemini_bot.py b/models/gemini/google_gemini_bot.py index 3c9ac9ae..02cdd06b 100644 --- a/models/gemini/google_gemini_bot.py +++ b/models/gemini/google_gemini_bot.py @@ -257,7 +257,7 @@ class GoogleGeminiBot(Bot): } endpoint = f"{self.api_base}/v1beta/models/{model_name}:generateContent" headers = {"x-goog-api-key": self.api_key, "Content-Type": "application/json"} - resp = requests.post(endpoint, headers=headers, json=payload, timeout=60) + resp = requests.post(endpoint, headers=headers, json=payload, timeout=180) if resp.status_code != 200: return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} diff --git a/models/mimo/mimo_bot.py b/models/mimo/mimo_bot.py index a815e9f0..762003fb 100644 --- a/models/mimo/mimo_bot.py +++ b/models/mimo/mimo_bot.py @@ -643,7 +643,7 @@ class MimoBot(Bot, OpenAICompatibleBot): headers = self._build_headers() resp = requests.post( f"{self.api_base}/chat/completions", - headers=headers, json=payload, timeout=60, + headers=headers, json=payload, timeout=180, ) if resp.status_code != 200: return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} diff --git a/models/minimax/minimax_bot.py b/models/minimax/minimax_bot.py index c010371d..530c0179 100644 --- a/models/minimax/minimax_bot.py +++ b/models/minimax/minimax_bot.py @@ -201,7 +201,7 @@ class MinimaxBot(Bot): "Content-Type": "application/json", } resp = requests.post(f"{self.api_base}/chat/completions", - headers=headers, json=payload, timeout=60) + headers=headers, json=payload, timeout=180) if resp.status_code != 200: return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} data = resp.json() diff --git a/models/moonshot/moonshot_bot.py b/models/moonshot/moonshot_bot.py index 1ee09599..a2cbbe7f 100644 --- a/models/moonshot/moonshot_bot.py +++ b/models/moonshot/moonshot_bot.py @@ -195,7 +195,7 @@ class MoonshotBot(Bot): } headers = self._build_headers() resp = requests.post(f"{self.base_url}/chat/completions", - headers=headers, json=payload, timeout=60) + headers=headers, json=payload, timeout=180) if resp.status_code != 200: return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"} data = resp.json() diff --git a/models/openai_compatible_bot.py b/models/openai_compatible_bot.py index e669fed2..d89690a0 100644 --- a/models/openai_compatible_bot.py +++ b/models/openai_compatible_bot.py @@ -415,7 +415,7 @@ class OpenAICompatibleBot: } resp = requests.post( f"{api_base}/chat/completions", - headers=headers, json=payload, timeout=60, + headers=headers, json=payload, timeout=180, ) if resp.status_code != 200: body = resp.text[:500] From 0513298f57df821fc9b50fc4a1d8c212c034030f Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 19:03:49 +0800 Subject: [PATCH 349/399] feat(evolution): allow rare persona (AGENT.md) self-evolution --- agent/evolution/executor.py | 11 ++++++++--- agent/evolution/prompts.py | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py index 278ad6b2..63380343 100644 --- a/agent/evolution/executor.py +++ b/agent/evolution/executor.py @@ -242,8 +242,9 @@ def _guard_tools(tools: list, workspace_dir: str) -> list: return guarded -# Workspace subtrees worth watching for evolution-induced changes. -_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output") +# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is +# watched too: evolution may rarely refine the assistant's persona/style there. +_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output") # Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly # dream diary, none of which count as a user-facing change signal. _MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution") @@ -381,7 +382,11 @@ def run_evolution_for_session( today_daily = Path(workspace_dir) / "memory" / "users" / user_id / ( datetime.now().strftime("%Y-%m-%d") + ".md" ) - backup_files = [Path(memory_file), today_daily] + # AGENT.md (persona) is backed up too so a rare persona edit is undoable. + # Persona is workspace-global (not per-user): it always lives at the + # workspace root, regardless of user_id. + agent_file = Path(workspace_dir) / "AGENT.md" + backup_files = [Path(memory_file), today_daily, agent_file] if skills_dir.exists(): for skill_md in skills_dir.rglob("SKILL.md"): # The skill dir is the SKILL.md's parent (or an ancestor for diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py index 6cf060bc..66b068bb 100644 --- a/agent/evolution/prompts.py +++ b/agent/evolution/prompts.py @@ -88,6 +88,9 @@ them. When their signal is clear, act; do not be shy here. short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the daily file is the safe place — but first ask whether this really belongs in a skill. + - PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal + about the assistant's own identity/personality/style, make a small edit to + AGENT.md; never for user/world facts, and when in doubt do nothing. - Keep it to ONE short bullet. Never write paragraphs, never re-summarize the conversation, never copy what the main assistant already recorded. - If it is already captured anywhere (check MEMORY.md AND the daily file From 354350dec98e0934e3f2c7008f61512649ab9955 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Tue, 9 Jun 2026 19:20:15 +0800 Subject: [PATCH 350/399] fix: hide code block language label when language is undefined --- channel/web/static/js/console.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 1b170de8..903c7df6 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -858,8 +858,10 @@ function _addCodeBlockHeaders(container) { if (!codeEl) return; const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-')); - const language = langClass ? langClass.replace('language-', '') : 'code'; - const langLabel = language.charAt(0).toUpperCase() + language.slice(1); + const language = langClass ? langClass.replace('language-', '') : ''; + // Hide label for unknown/empty languages (e.g. language-undefined) + const showLang = language && language !== 'undefined' && language !== 'code'; + const langLabel = showLang ? language.charAt(0).toUpperCase() + language.slice(1) : ''; const wrapper = document.createElement('div'); wrapper.className = 'code-block-wrapper'; From f5caba81d62645c6d11306f4e5ddec32dda52294 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Wed, 10 Jun 2026 09:39:37 +0800 Subject: [PATCH 351/399] feat(models): support claude-fable-5 --- README.md | 2 +- channel/web/web_channel.py | 9 ++- common/const.py | 88 +++++++++++++++--------------- docs/ja/README.md | 2 +- docs/ja/models/claude.mdx | 9 +-- docs/ja/models/index.mdx | 2 +- docs/models/claude.mdx | 9 +-- docs/models/index.mdx | 2 +- docs/zh/README.md | 2 +- docs/zh/models/claude.mdx | 9 +-- docs/zh/models/index.mdx | 2 +- models/claudeapi/claude_api_bot.py | 2 +- run.sh | 4 +- scripts/run.ps1 | 4 +- 14 files changed, 77 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 252d6603..4c91f1cf 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation | Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 72f07246..1be31c6a 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1459,9 +1459,12 @@ class ChatHandler: class ConfigHandler: _RECOMMENDED_MODELS = [ - const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER, + const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, - const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET, + # claude-fable-5 is intentionally placed at the end of the Claude + # group here: it is expensive, so avoid surfacing it too early in + # the LinkAI dropdown. + const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_FABLE_5, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, @@ -1506,7 +1509,7 @@ class ConfigHandler: "api_base_key": "claude_api_base", "api_base_default": "https://api.anthropic.com/v1", "api_base_placeholder": _PLACEHOLDER_V1, - "models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET], + "models": [const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], }), ("gemini", { "label": "Gemini", diff --git a/common/const.py b/common/const.py index 53db4984..a7dde568 100644 --- a/common/const.py +++ b/common/const.py @@ -1,4 +1,4 @@ -# 厂商类型 +# Provider types OPEN_AI = "openAI" OPENAI = "openai" CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility @@ -8,48 +8,49 @@ XUNFEI = "xunfei" CHATGPTONAZURE = "chatGPTOnAzure" LINKAI = "linkai" CLAUDEAPI= "claudeAPI" -QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot) -QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入 +QWEN = "qwen" # legacy alias, actually routed to DashscopeBot +QWEN_DASHSCOPE = "dashscope" # Qwen via DashScope GEMINI = "gemini" ZHIPU_AI = "zhipu" MOONSHOT = "moonshot" MiniMax = "minimax" DEEPSEEK = "deepseek" -MIMO = "mimo" # 小米 MiMo 大模型 +MIMO = "mimo" # Xiaomi MiMo CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change MODELSCOPE = "modelscope" -# 模型列表 +# Model list # Claude (Anthropic) CLAUDE3 = "claude-3-opus-20240229" CLAUDE_3_OPUS = "claude-3-opus-latest" CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229" CLAUDE_3_SONNET = "claude-3-sonnet-20240229" CLAUDE_3_HAIKU = "claude-3-haiku-20240307" -CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名称,会不断更新指向最新发布的模型 -CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型 +CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to the newest release +CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620" CLAUDE_4_OPUS = "claude-opus-4-0" -CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent推荐模型 +CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - Agent recommended model +CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7 CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0 -CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型 -CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent推荐模型 +CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model +CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model # Gemini (Google) GEMINI_PRO = "gemini-1.0-pro" GEMINI_15_flash = "gemini-1.5-flash" GEMINI_15_PRO = "gemini-1.5-pro" -GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持 -GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型 +GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # "-exp" models are experimental and will be phased out +GEMINI_20_FLASH = "gemini-2.0-flash" # stable release GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20" GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06" -GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent推荐模型 +GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent recommended model GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview -GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型 -GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent推荐模型 -GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent推荐模型 +GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent recommended model +GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent recommended model +GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent recommended model # OpenAI GPT35 = "gpt-3.5-turbo" @@ -85,10 +86,10 @@ TTS_1 = "tts-1" TTS_1_HD = "tts-1-hd" # DeepSeek -DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型 -DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型 -DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用) -DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用) +DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3 chat model +DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1 model +DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - default recommendation (thinking + tool calls) +DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - stronger on complex tasks (thinking + tool calls) # Baidu Qianfan / ERNIE ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship @@ -100,30 +101,30 @@ ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K" ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl" ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k" -# Qwen (通义千问 - 阿里云 DashScope) +# Qwen (Alibaba Cloud DashScope) QWEN_TURBO = "qwen-turbo" QWEN_PLUS = "qwen-plus" QWEN_MAX = "qwen-max" QWEN_LONG = "qwen-long" -QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型 +QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent recommended model QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation) QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation) QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation) -QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型 +QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent recommended model QWQ_PLUS = "qwq-plus" # MiniMax MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default) MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed -MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision) +MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision) MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5 -# GLM (智谱AI) -GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default) -GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo -GLM_5 = "glm-5" # 智谱 GLM-5 -GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision) +# GLM (Zhipu AI) +GLM_5_1 = "glm-5.1" # GLM-5.1 - Agent recommended model (default) +GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo +GLM_5 = "glm-5" # GLM-5 +GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision) GLM_4 = "glm-4" GLM_4_PLUS = "glm-4-plus" GLM_4_flash = "glm-4-flash" @@ -132,7 +133,7 @@ GLM_4_ALLTOOLS = "glm-4-alltools" GLM_4_0520 = "glm-4-0520" GLM_4_AIR = "glm-4-air" GLM_4_AIRX = "glm-4-airx" -GLM_4_7 = "glm-4.7" # 智谱 GLM-4.7 - Agent推荐模型 +GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model # Kimi (Moonshot) MOONSHOT = "moonshot" @@ -140,12 +141,12 @@ KIMI_K2 = "kimi-k2" KIMI_K2_5 = "kimi-k2.5" KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default) -# 小米 MiMo -MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐) -MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频) +# Xiaomi MiMo +MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - flagship, long context (default recommendation) +MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - multimodal (text/image/audio/video) MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro -MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态 -MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版 +MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - multimodal +MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed # Doubao (Volcengine Ark) DOUBAO = "doubao" @@ -154,11 +155,11 @@ 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(魔搭社区) +# ModelScope QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507" QWEN3_5_27B = "Qwen/Qwen3.5-27B" -# 其他模型 +# Other models WEN_XIN = "wenxin" WEN_XIN_4 = "wenxin-4" XUNFEI = "xunfei" @@ -189,11 +190,11 @@ MODEL_LIST = [ # MiniMax MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_ABAB6_5, - # 小米 MiMo + # Xiaomi MiMo MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, # Claude - CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, 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, + CLAUDE3, CLAUDE_FABLE_5, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, 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, CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU, "claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet", @@ -211,14 +212,14 @@ MODEL_LIST = [ GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO, O1, O1_MINI, - # GLM (智谱AI) + # GLM (Zhipu AI) ZHIPU_AI, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS, GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7, - # Qwen (通义千问) + # Qwen QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, - # Doubao (豆包) + # Doubao DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI, # Kimi (Moonshot) @@ -231,11 +232,12 @@ MODEL_LIST = [ # LinkAI LINKAI_35, LINKAI_4_TURBO, LINKAI_4o, - # 其他模型 + # Other models WEN_XIN, WEN_XIN_4, XUNFEI, ] MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST + # channel FEISHU = "feishu" DINGTALK = "dingtalk" diff --git a/docs/ja/README.md b/docs/ja/README.md index 39c2b6a2..afe51342 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -103,7 +103,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。 | プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | -| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | diff --git a/docs/ja/models/claude.mdx b/docs/ja/models/claude.mdx index 2b951e34..826ec2d2 100644 --- a/docs/ja/models/claude.mdx +++ b/docs/ja/models/claude.mdx @@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像 ```json { - "model": "claude-opus-4-8", + "model": "claude-fable-5", "claude_api_key": "YOUR_API_KEY" } ``` | パラメータ | 説明 | | --- | --- | -| `model` | `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 | +| `model` | `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 | | `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 | | `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 | @@ -28,8 +28,9 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像 | モデル | 用途 | | --- | --- | -| `claude-opus-4-8` | デフォルト推奨。最新フラッグシップ。複雑な推論や長いタスクチェーンに最適 | -| `claude-opus-4-7` | 前世代の Opus フラッグシップ | +| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め | +| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い | +| `claude-opus-4-7` | より以前の Opus フラッグシップ | | `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い | | `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い | diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 916ac469..2e25171e 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -20,7 +20,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Zhipu GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/docs/models/claude.mdx b/docs/models/claude.mdx index bb831eb8..c5e4fd56 100644 --- a/docs/models/claude.mdx +++ b/docs/models/claude.mdx @@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand ```json { - "model": "claude-opus-4-8", + "model": "claude-fable-5", "claude_api_key": "YOUR_API_KEY" } ``` | Parameter | Description | | --- | --- | -| `model` | Supports `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) | +| `model` | Supports `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) | | `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) | | `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy | @@ -28,8 +28,9 @@ Claude is provided by Anthropic and supports both text chat and image understand | Model | Use Case | | --- | --- | -| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks | -| `claude-opus-4-7` | Previous-generation Opus flagship | +| `claude-fable-5` | Latest flagship; best for complex reasoning and long-running tasks, at a higher price | +| `claude-opus-4-8` | Previous flagship with balanced quality and cost | +| `claude-opus-4-7` | Earlier Opus flagship | | `claude-sonnet-4-6` | Balanced cost and speed, lower cost | | `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price | diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 2492270b..7575adb4 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -13,7 +13,7 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [GLM](/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/docs/zh/README.md b/docs/zh/README.md index a39b4d13..c57332b3 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -105,7 +105,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像 | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/docs/zh/models/claude.mdx b/docs/zh/models/claude.mdx index ee1809d6..a3e495d3 100644 --- a/docs/zh/models/claude.mdx +++ b/docs/zh/models/claude.mdx @@ -13,14 +13,14 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet ```json { - "model": "claude-opus-4-8", + "model": "claude-fable-5", "claude_api_key": "YOUR_API_KEY" } ``` | 参数 | 说明 | | --- | --- | -| `model` | 支持 `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) | +| `model` | 支持 `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) | | `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 | | `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 | @@ -28,8 +28,9 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet | 模型 | 适用场景 | | --- | --- | -| `claude-opus-4-8` | 默认推荐,最新旗舰,复杂推理与长链路任务效果最佳 | -| `claude-opus-4-7` | 上一代 Opus 旗舰 | +| `claude-fable-5` | 最新旗舰,复杂推理与长链路任务效果最佳,价格较高 | +| `claude-opus-4-8` | 上一代 Opus 旗舰,综合表现与成本均衡 | +| `claude-opus-4-7` | 更早的 Opus 旗舰 | | `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 | | `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 | diff --git a/docs/zh/models/index.mdx b/docs/zh/models/index.mdx index ce858b47..0a1e0910 100644 --- a/docs/zh/models/index.mdx +++ b/docs/zh/models/index.mdx @@ -14,7 +14,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | -| [Claude](/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | | +| [Claude](/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [智谱 GLM](/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | diff --git a/models/claudeapi/claude_api_bot.py b/models/claudeapi/claude_api_bot.py index 05a7b567..116f7bc0 100644 --- a/models/claudeapi/claude_api_bot.py +++ b/models/claudeapi/claude_api_bot.py @@ -223,7 +223,7 @@ class ClaudeAPIBot(Bot, OpenAIImage): return 8192 elif model and model.startswith("claude-3") and "opus" in model: return 4096 - elif model and (model.startswith("claude-sonnet-4") or model.startswith("claude-opus-4")): + elif model and (model.startswith("claude-sonnet-4") or model.startswith("claude-opus-4") or model.startswith("claude-fable")): return 64000 return 8192 diff --git a/run.sh b/run.sh index 015f5d92..638fecfc 100755 --- a/run.sh +++ b/run.sh @@ -597,7 +597,7 @@ select_model() { # The 12th option is "skip" -> configure later in the web console. select_menu sel "$title" \ "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \ - "Claude (claude-opus-4-8, claude-opus-4-7, etc.)" \ + "Claude (claude-fable-5, claude-opus-4-8, etc.)" \ "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \ "OpenAI (gpt-5.5, etc.)" \ "MiniMax (MiniMax-M3, etc.)" \ @@ -629,7 +629,7 @@ read_model_config() { configure_model() { case "$model_choice" in 1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;; - 2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;; + 2) read_model_config "Claude" "claude-fable-5" "CLAUDE_KEY" ;; 3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;; 4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;; 5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;; diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 53ad3cb8..76fa7708 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -370,7 +370,7 @@ function Install-Dependencies { # Each entry: Provider / default model name / config key field / optional base. $ModelChoices = @{ 1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" } - 2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" } + 2 = @{ Provider = "Claude"; Default = "claude-fable-5"; Field = "claude_api_key"; BaseField = "claude_api_base" } 3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" } 4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" } @@ -387,7 +387,7 @@ function Select-Model { $title = T "选择 AI 模型" "Select AI Model" $options = @( "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)", - "Claude (claude-opus-4-8, claude-opus-4-7, etc.)", + "Claude (claude-fable-5, claude-opus-4-8, etc.)", "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)", "OpenAI (gpt-5.5, etc.)", "MiniMax (MiniMax-M3, etc.)", From 402e2bfee090c62ebbef29630d95bc4133c69ba8 Mon Sep 17 00:00:00 2001 From: yangziyu-hhh Date: Wed, 10 Jun 2026 13:46:50 +0800 Subject: [PATCH 352/399] feat: stream Bash progress and guard message actions during replies --- agent/protocol/agent_stream.py | 15 +++- agent/tools/base_tool.py | 10 +++ agent/tools/bash/bash.py | 106 ++++++++++++++++++++++++++--- channel/web/static/css/console.css | 18 +++++ channel/web/static/js/console.js | 78 ++++++++++++++++----- channel/web/web_channel.py | 11 ++- 6 files changed, 207 insertions(+), 31 deletions(-) diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 1b49baa7..1eb96e50 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -1174,10 +1174,21 @@ class AgentStreamExecutor: # Set tool context tool.model = self.model tool.context = self.agent + tool.progress_callback = lambda message: self._emit_event( + "tool_execution_progress", + { + "tool_call_id": tool_id, + "tool_name": tool_name, + "message": message, + } + ) # Execute tool start_time = time.time() - result: ToolResult = tool.execute_tool(arguments) + try: + result: ToolResult = tool.execute_tool(arguments) + finally: + tool.progress_callback = None execution_time = time.time() - start_time result_dict = { @@ -1719,4 +1730,4 @@ class AgentStreamExecutor: not as a message. The AgentLLMModel will handle this. """ # Don't add system message here - it will be handled separately by the LLM adapter - return self.messages \ No newline at end of file + return self.messages diff --git a/agent/tools/base_tool.py b/agent/tools/base_tool.py index a3ca2625..53f7cf9c 100644 --- a/agent/tools/base_tool.py +++ b/agent/tools/base_tool.py @@ -38,6 +38,16 @@ class BaseTool: description: str = "Base tool" params: dict = {} # Store JSON Schema model: Optional[Any] = None # LLM model instance, type depends on bot implementation + progress_callback = None + + def report_progress(self, message: str): + callback = getattr(self, "progress_callback", None) + if not callback: + return + try: + callback(str(message)) + except Exception as e: + logger.debug(f"[{self.name}] progress callback failed: {e}") @classmethod def get_json_schema(cls) -> dict: diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py index 5e02832e..4fcc724c 100644 --- a/agent/tools/bash/bash.py +++ b/agent/tools/bash/bash.py @@ -4,9 +4,12 @@ Bash tool - Execute bash commands import os import re +import signal import sys import subprocess import tempfile +import threading +import time from typing import Dict, Any from agent.tools.base_tool import BaseTool, ToolResult @@ -19,6 +22,8 @@ class Bash(BaseTool): """Tool for executing bash commands""" _IS_WIN = sys.platform == "win32" + _PROGRESS_MAX_BYTES = 32 * 1024 + _PROGRESS_INTERVAL = 0.5 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. @@ -113,17 +118,11 @@ SAFETY: if command and not command.strip().lower().startswith("chcp"): command = f"chcp 65001 >nul 2>&1 && {command}" - result = subprocess.run( + result = self._run_streaming( command, - shell=True, - cwd=self.cwd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - env=env, + timeout, + env, + dotenv_vars, ) logger.debug(f"[Bash] Exit code: {result.returncode}") @@ -236,6 +235,93 @@ SAFETY: except Exception as e: return ToolResult.fail(f"Error executing command: {str(e)}") + def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict): + process = subprocess.Popen( + command, + shell=True, + cwd=self.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=not self._IS_WIN, + ) + stdout_chunks, stderr_chunks = [], [] + recent = bytearray() + recent_lock = threading.Lock() + + def drain(stream, chunks): + while True: + chunk = os.read(stream.fileno(), 4096) + if not chunk: + break + chunks.append(chunk) + with recent_lock: + recent.extend(chunk) + if len(recent) > self._PROGRESS_MAX_BYTES: + del recent[:-self._PROGRESS_MAX_BYTES] + + readers = [ + threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True), + threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True), + ] + for reader in readers: + reader.start() + + started = time.monotonic() + last_reported_at = started + last_snapshot = None + try: + while process.poll() is None: + now = time.monotonic() + elapsed = now - started + if elapsed >= timeout: + self._kill_process(process) + raise subprocess.TimeoutExpired(command, timeout) + if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL: + with recent_lock: + snapshot = bytes(recent).decode("utf-8", errors="replace") + snapshot = self._redact_progress(snapshot, dotenv_vars) + if snapshot and snapshot != last_snapshot: + self.report_progress(snapshot) + last_snapshot = snapshot + last_reported_at = now + time.sleep(0.1) + finally: + if process.poll() is None: + self._kill_process(process) + process.wait() + for reader in readers: + reader.join() + + from types import SimpleNamespace + return SimpleNamespace( + returncode=process.returncode, + stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"), + stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"), + ) + + def _kill_process(self, process): + if self._IS_WIN: + process.kill() + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except PermissionError: + process.kill() + + @staticmethod + def _redact_progress(text: str, dotenv_vars: dict) -> str: + text = re.sub( + r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+', + lambda match: f"{match.group(1)}=[REDACTED]", + text, + ) + for value in dotenv_vars.values(): + value = str(value or "") + if len(value) >= 6: + text = text.replace(value, "[REDACTED]") + return text + def _get_safety_warning(self, command: str) -> str: """ Get safety warning for absolutely catastrophic commands only. diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index e260bbc9..0e2a09db 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -605,6 +605,18 @@ color: inherit; } .tool-error-text { color: #f87171; } +.tool-live-output:empty { display: none; } +.tool-streaming .tool-live-output:not(:empty)::after { + content: ' '; + display: inline-block; + width: 0.45em; + height: 1em; + margin-left: 0.15em; + vertical-align: -0.15em; + background: currentColor; + animation: tool-cursor-blink 1s steps(1) infinite; +} +@keyframes tool-cursor-blink { 50% { opacity: 0; } } /* Log level highlighting */ .log-line { display: block; } @@ -1571,3 +1583,9 @@ .delete-msg-btn:hover { color: #ef4444 !important; } + +.edit-msg-btn:disabled, +.delete-msg-btn:disabled { + cursor: not-allowed !important; + opacity: 0.35 !important; +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 903c7df6..31727cbf 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -889,6 +889,26 @@ let pollGeneration = 0; // incremented on each restart to cancel stale poll lo let loadingContainers = {}; let activeStreams = {}; // request_id -> EventSource let sessionActiveRequest = {}; // session_id -> request_id (in-flight stream per session) + +function isCurrentSessionConversationActive() { + return !!sessionActiveRequest[sessionId]; +} + +function updateEditButtonsState() { + const active = isCurrentSessionConversationActive(); + document.querySelectorAll('.edit-msg-btn, .delete-msg-btn').forEach(btn => { + btn.disabled = active; + if (btn.classList.contains('edit-msg-btn')) { + btn.title = active + ? 'Reply is being generated; editing is temporarily unavailable.' + : (currentLang === 'zh' ? '编辑消息' : 'Edit Message'); + } else { + btn.title = active + ? 'Reply is being generated; deletion is temporarily unavailable.' + : t('delete_message_title'); + } + }); +} let streamBuffers = {}; // request_id -> { items: [event...], timestamp } for re-attach replay let isComposing = false; let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '', providers: {}, api_bases: {} }; @@ -1177,6 +1197,7 @@ messagesDiv.addEventListener('click', (e) => { const editBtn = e.target.closest('.edit-msg-btn'); if (editBtn) { e.preventDefault(); + if (isCurrentSessionConversationActive()) return; const msgRoot = editBtn.closest('.user-message-group'); if (msgRoot) editUserMessage(msgRoot); return; @@ -1197,6 +1218,7 @@ messagesDiv.addEventListener('click', (e) => { const deleteBtn = e.target.closest('.delete-msg-btn'); if (deleteBtn) { e.preventDefault(); + if (isCurrentSessionConversationActive()) return; const userMsgEl = deleteBtn.closest('.user-message-group'); if (!userMsgEl) return; @@ -1993,6 +2015,7 @@ function copyToClipboard(text) { // Edit user message: extract content, remove this and subsequent messages, fill input async function editUserMessage(msgEl) { + if (isCurrentSessionConversationActive()) return; const rawContent = msgEl.dataset.rawContent; if (!rawContent) return; @@ -2043,9 +2066,9 @@ async function editUserMessage(msgEl) { // Fill input with the original content chatInput.value = rawContent; - chatInput.style.height = 'auto'; - chatInput.style.height = chatInput.scrollHeight + 'px'; + chatInput.dispatchEvent(new Event("input", { bubbles: true })); chatInput.focus(); + chatInput.selectionStart = chatInput.selectionEnd = chatInput.value.length; scrollChatToBottom(); } @@ -2250,7 +2273,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { let contentEl = null; // .answer-content (final streaming answer) let mediaEl = null; // .media-content (images & file attachments) let accumulatedText = ''; - let currentToolEl = null; + const toolElements = new Map(); let currentReasoningEl = null; // live reasoning bubble let reasoningText = ''; let reasoningStartTime = 0; @@ -2265,12 +2288,14 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { const ownerSession = sessionId; const isActive = () => ownerSession === sessionId; sessionActiveRequest[ownerSession] = requestId; + updateEditButtonsState(); // Per-request event buffer used to rebuild the bubble on re-attach. const buffer = streamBuffers[requestId] || { items: [], timestamp }; streamBuffers[requestId] = buffer; const clearOwnerRequest = () => { if (sessionActiveRequest[ownerSession] === requestId) { delete sessionActiveRequest[ownerSession]; + updateEditButtonsState(); } delete streamBuffers[requestId]; }; @@ -2419,10 +2444,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { contentEl.innerHTML = ''; // Add tool execution indicator (collapsible) - currentToolEl = document.createElement('div'); - currentToolEl.className = 'agent-step agent-tool-step'; + const toolEl = document.createElement('div'); + toolEl.className = 'agent-step agent-tool-step tool-streaming'; const argsStr = formatToolArgs(item.arguments || {}); - currentToolEl.innerHTML = ` + toolEl.innerHTML = `
${item.tool} @@ -2433,36 +2458,51 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) {
Input
${argsStr}
-
+
+
Output
+

+                        
`; - stepsEl.appendChild(currentToolEl); + stepsEl.appendChild(toolEl); + toolElements.set(item.tool_call_id, toolEl); scrollChatToBottom(); + } else if (item.type === 'tool_progress') { + const toolEl = toolElements.get(item.tool_call_id); + if (toolEl) { + toolEl.classList.add('expanded'); + toolEl.querySelector('.tool-live-output').textContent = String(item.content || ''); + scrollChatToBottom(); + } + } else if (item.type === 'tool_end') { - if (currentToolEl) { + const toolEl = toolElements.get(item.tool_call_id); + if (toolEl) { const isError = item.status !== 'success'; - const icon = currentToolEl.querySelector('.tool-icon'); + const icon = toolEl.querySelector('.tool-icon'); icon.className = isError ? 'fas fa-times text-red-400 flex-shrink-0 tool-icon' : 'fas fa-check text-primary-400 flex-shrink-0 tool-icon'; // Show execution time - const nameEl = currentToolEl.querySelector('.tool-name'); + const nameEl = toolEl.querySelector('.tool-name'); if (item.execution_time !== undefined) { nameEl.innerHTML += ` ${item.execution_time}s`; } // Fill output section - const outputSection = currentToolEl.querySelector('.tool-output-section'); - if (outputSection && item.result) { - outputSection.innerHTML = ` -
${isError ? 'Error' : 'Output'}
-
${escapeHtml(String(item.result))}
`; + const outputLabel = toolEl.querySelector('.tool-output-label'); + const outputEl = toolEl.querySelector('.tool-live-output'); + if (outputLabel) outputLabel.textContent = isError ? 'Error' : 'Output'; + if (outputEl) { + outputEl.textContent = item.result ? String(item.result) : ''; + outputEl.classList.toggle('tool-error-text', isError); } - if (isError) currentToolEl.classList.add('tool-failed'); - currentToolEl = null; + toolEl.classList.remove('tool-streaming'); + if (isError) toolEl.classList.add('tool-failed'); + toolElements.delete(item.tool_call_id); } } else if (item.type === 'image') { @@ -3256,6 +3296,7 @@ function loadHistory(page) { const sentinel = document.getElementById('history-load-more'); const insertBefore = sentinel ? sentinel.nextSibling : messagesDiv.firstChild; messagesDiv.insertBefore(fragment, insertBefore); + updateEditButtonsState(); // Manage the "load more" sentinel at the very top if (data.has_more) { @@ -3711,6 +3752,7 @@ function switchSession(newSessionId) { // Switching back re-attaches and resumes live streaming. sessionId = newSessionId; + updateEditButtonsState(); localStorage.setItem(SESSION_ID_KEY, sessionId); historyPage = 0; diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 1be31c6a..3b374c5f 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -432,7 +432,15 @@ class WebChannel(ChatChannel): elif event_type == "tool_execution_start": tool_name = data.get("tool_name", "tool") arguments = data.get("arguments", {}) - q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments}) + q.put({"type": "tool_start", "tool_call_id": data.get("tool_call_id"), "tool": tool_name, "arguments": arguments}) + + elif event_type == "tool_execution_progress": + q.put({ + "type": "tool_progress", + "tool_call_id": data.get("tool_call_id"), + "tool": data.get("tool_name", "tool"), + "content": str(data.get("message", ""))[-32 * 1024:], + }) elif event_type == "tool_execution_end": tool_name = data.get("tool_name", "tool") @@ -445,6 +453,7 @@ class WebChannel(ChatChannel): result_str = result_str[:2000] + "…" q.put({ "type": "tool_end", + "tool_call_id": data.get("tool_call_id"), "tool": tool_name, "status": status, "result": result_str, From cffa590d3e9eac5b59284df1565dd76951e9f049 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Wed, 10 Jun 2026 18:12:30 +0800 Subject: [PATCH 353/399] feat(web): manage multiple custom (OpenAI-compatible) providers in console UI Adds first-class support for configuring more than one custom OpenAI-compatible provider (e.g. SiliconFlow, DeepSeek, local vLLM) and switching the active one from the web console, addressing #2838. Backend: - config: new `custom_providers` (list) and `custom_active_provider` fields, fully backward compatible with the legacy single `open_ai_api_base`/`model` fields (used as fallback). - models/custom_provider.py: centralized resolver `resolve_custom_credentials()` returning (api_key, api_base, model), with active-provider selection and graceful fallback. - chat_gpt_bot.py wired to use the resolver. - web_channel.py: `_provider_overview` expands `custom_providers` into one card per provider (id `custom:`, active flag, masked key); new POST actions `set_custom_provider`, `delete_custom_provider`, `set_active_custom_provider` with hermetic persistence + bridge reset. Frontend: - console.js: dedicated "Custom providers" section with add / edit / delete / set-active actions, masked-key keep-existing handling, and ~20 new zh/en i18n strings. - chat.html: custom provider modal. Tests: - tests/test_custom_provider.py (11) - resolver/config behavior. - tests/test_custom_provider_handlers.py (18) - write-side handlers and overview expansion, including duplicate-name rejection. All 29 unit tests pass. --- channel/web/chat.html | 66 ++++++ channel/web/static/js/console.js | 305 ++++++++++++++++++++++++- channel/web/web_channel.py | 253 +++++++++++++++++++- config.py | 8 +- docs/ja/models/custom.mdx | 34 +++ docs/models/custom.mdx | 34 +++ docs/zh/models/custom.mdx | 34 +++ models/chatgpt/chat_gpt_bot.py | 33 ++- models/custom_provider.py | 84 +++++++ tests/test_custom_provider.py | 170 ++++++++++++++ tests/test_custom_provider_handlers.py | 258 +++++++++++++++++++++ 11 files changed, 1262 insertions(+), 17 deletions(-) create mode 100644 models/custom_provider.py create mode 100644 tests/test_custom_provider.py create mode 100644 tests/test_custom_provider_handlers.py diff --git a/channel/web/chat.html b/channel/web/chat.html index 4f527bb4..0b71d745 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1165,6 +1165,72 @@ + + + diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 903c7df6..74bd885f 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -32,6 +32,24 @@ const I18N = { models_clear_credential: '清除凭据', models_base_default_hint: '留空将使用官方默认地址', models_base_default: '默认', + models_custom_section: '自定义厂商', + models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换', + models_custom_add: '添加自定义厂商', + models_custom_name: '名称', + models_custom_name_placeholder: '例如 siliconflow', + models_custom_default_model: '默认模型(可选)', + models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3', + models_custom_active: '使用中', + models_custom_set_active: '设为使用中', + models_custom_delete: '删除', + models_custom_delete_confirm_title: '删除自定义厂商', + models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。', + models_custom_name_required: '请填写名称', + models_custom_base_required: '请填写 API Base', + models_custom_name_exists: '该名称已存在', + models_custom_empty: '尚未配置自定义厂商,点击添加', + models_custom_edit_title: '编辑自定义厂商', + models_custom_add_title: '添加自定义厂商', models_capability_chat: '主模型', models_capability_chat_desc: '用于基础对话和 Agent 推理', models_capability_vision: '图像理解', @@ -236,6 +254,24 @@ const I18N = { models_clear_credential: 'Clear credentials', models_base_default_hint: 'Leave blank to use the official default base URL', models_base_default: 'Default', + models_custom_section: 'Custom Providers', + models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely', + models_custom_add: 'Add custom provider', + models_custom_name: 'Name', + models_custom_name_placeholder: 'e.g. siliconflow', + models_custom_default_model: 'Default model (optional)', + models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3', + models_custom_active: 'Active', + models_custom_set_active: 'Set active', + models_custom_delete: 'Delete', + models_custom_delete_confirm_title: 'Delete custom provider', + models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.', + models_custom_name_required: 'Name is required', + models_custom_base_required: 'API Base is required', + models_custom_name_exists: 'A provider with this name already exists', + models_custom_empty: 'No custom providers yet, click to add', + models_custom_edit_title: 'Edit custom provider', + models_custom_add_title: 'Add custom provider', models_capability_chat: 'Main Model', models_capability_chat_desc: 'Used for basic chat and agent reasoning', models_capability_vision: 'Image Understanding', @@ -4732,16 +4768,26 @@ function renderModelsView() { const container = document.getElementById('models-content'); container.innerHTML = ''; container.appendChild(renderVendorsSection()); + container.appendChild(renderCustomProvidersSection()); MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def))); } +// True when a provider card is one of the expanded custom (OpenAI-compatible) +// providers — these are managed in their own section, not the vendor grid. +function isCustomProviderCard(p) { + return !!(p && p.is_custom && p.custom_name); +} + // ---------- Vendor section (Layer 1) ----------------------------------- function renderVendorsSection() { const wrap = document.createElement('div'); wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; - const configured = modelsState.providers.filter(p => p.configured); + // Expanded custom providers live in their own section; keep the built-in + // vendor grid focused on the canonical (field-based) providers. + const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); + const configured = builtinProviders.filter(p => p.configured); const header = `
@@ -4752,7 +4798,7 @@ function renderVendorsSection() {

${t('models_section_vendors')}

${t('models_section_vendors_desc')}

- ${configured.length}/${modelsState.providers.length} + ${configured.length}/${builtinProviders.length} `; let body; @@ -4812,6 +4858,98 @@ function renderProviderLogo(p, sizePx) { `; } +// ---------- Custom providers section (multiple OpenAI-compatible) ------- +// Renders the user-defined OpenAI-compatible providers as a dedicated, +// independently managed list: add / edit / delete / activate. The backend +// expands `custom_providers` into provider cards with id="custom:", +// is_custom=true, custom_name and an `active` flag (see +// ModelsHandler._custom_provider_cards / _provider_overview). + +function getCustomProviderCards() { + return modelsState.providers.filter(isCustomProviderCard); +} + +function renderCustomProvidersSection() { + const wrap = document.createElement('div'); + wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; + + const customs = getCustomProviderCards(); + + const header = ` +
+
+ +
+
+

${t('models_custom_section')}

+

${t('models_custom_section_desc')}

+
+ +
`; + + let body; + if (customs.length === 0) { + body = ` +
+

${t('models_custom_empty')}

+
`; + } else { + body = `
+ ${customs.map(renderCustomProviderRow).join('')} +
`; + } + + wrap.innerHTML = header + body; + return wrap; +} + +function renderCustomProviderRow(p) { + const name = p.custom_name || ''; + const nameEsc = escapeHtml(name); + // The active provider gets a highlighted ring + badge; others show a + // "set active" affordance. + const activeBadge = p.active + ? ` + ${t('models_custom_active')}` + : ``; + + const ring = p.active + ? 'border-emerald-300 dark:border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-900/10' + : 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5'; + + const model = p.model + ? `${escapeHtml(p.model)}` + : ''; + const base = p.api_base + ? `${escapeHtml(p.api_base)}` + : ''; + + return ` +
+ ${renderProviderLogo(p, 28)} +
+
+ ${nameEsc} + ${activeBadge} +
+
${base}${model ? '·' + model : ''}
+
+ + +
`; +} + // ---------- Capability cards (Layer 2) --------------------------------- function renderCapabilityCard(def) { @@ -5933,11 +6071,15 @@ function openVendorModal(providerId, onSaved) { // currently selected vendor via its own background highlight, so we // intentionally suppress the global active-row ✓ for this picker // (see CSS) — otherwise configured + selected rows would show two. - const unconfigured = modelsState.providers.filter(p => !p.configured); - const defaultId = (unconfigured[0] && unconfigured[0].id) || (modelsState.providers[0] && modelsState.providers[0].id) || ''; + // Custom (OpenAI-compatible) providers are managed in their own + // section with a dedicated modal, so they are excluded from this + // built-in vendor picker. + const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); + const unconfigured = builtinProviders.filter(p => !p.configured); + const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || ''; pickerWrap.classList.remove('hidden'); const pickerEl = document.getElementById('vendor-modal-picker'); - const pickerOpts = modelsState.providers.map(p => ({ + const pickerOpts = builtinProviders.map(p => ({ value: p.id, label: localizedLabel(p.label), _configured: !!p.configured, @@ -6100,6 +6242,159 @@ function clearVendorModal() { }); } +// ===================================================================== +// Custom (OpenAI-compatible) provider modal — add / edit +// ===================================================================== +// State for the dedicated custom-provider modal. `originalName` is empty when +// adding and set to the provider name when editing (so the backend can rename +// without losing the entry). +let customProviderModalState = { originalName: '' }; + +function openCustomProviderModal(name) { + const editing = !!name; + customProviderModalState = { originalName: editing ? name : '' }; + + const card = editing ? getCustomProviderCards().find(p => p.custom_name === name) : null; + + const overlay = document.getElementById('custom-provider-modal-overlay'); + if (!overlay) return; + + document.getElementById('custom-provider-modal-title').textContent = + editing ? t('models_custom_edit_title') : t('models_custom_add_title'); + + const nameInput = document.getElementById('custom-provider-name'); + const baseInput = document.getElementById('custom-provider-base'); + const keyInput = document.getElementById('custom-provider-key'); + const modelInput = document.getElementById('custom-provider-model'); + + nameInput.value = card ? (card.custom_name || '') : ''; + baseInput.value = card ? (card.api_base || '') : ''; + modelInput.value = card ? (card.model || '') : ''; + + // Surface the masked key as the value for configured providers so the + // "already set" state is unambiguous; an untouched masked value means + // "keep the existing key" on save (mirrors the vendor modal contract). + if (card && card.configured && card.api_key_masked) { + keyInput.value = card.api_key_masked; + keyInput.dataset.masked = '1'; + keyInput.dataset.maskedVal = card.api_key_masked; + } else { + keyInput.value = ''; + keyInput.dataset.masked = ''; + keyInput.dataset.maskedVal = ''; + } + keyInput.oninput = function () { + if (keyInput.dataset.masked === '1' && keyInput.value !== keyInput.dataset.maskedVal) { + keyInput.dataset.masked = ''; + } + }; + + const statusEl = document.getElementById('custom-provider-modal-status'); + if (statusEl) { statusEl.textContent = ''; statusEl.classList.add('opacity-0'); } + + overlay.classList.remove('hidden'); + document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal; + document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal; + + function onOverlayClick(e) { + if (e.target === overlay) { + closeCustomProviderModal(); + overlay.removeEventListener('click', onOverlayClick); + } + } + overlay.addEventListener('click', onOverlayClick); + nameInput.focus(); +} + +function closeCustomProviderModal() { + const overlay = document.getElementById('custom-provider-modal-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +function saveCustomProviderModal() { + const name = document.getElementById('custom-provider-name').value.trim(); + const apiBase = document.getElementById('custom-provider-base').value.trim(); + const model = document.getElementById('custom-provider-model').value.trim(); + const keyInput = document.getElementById('custom-provider-key'); + + if (!name) { + showStatus('custom-provider-modal-status', 'models_custom_name_required', true); + document.getElementById('custom-provider-name').focus(); + return; + } + const editing = !!customProviderModalState.originalName; + if (!editing && !apiBase) { + showStatus('custom-provider-modal-status', 'models_custom_base_required', true); + document.getElementById('custom-provider-base').focus(); + return; + } + + // Untouched masked key => no change (omit from payload). + let apiKey = keyInput.value.trim(); + if (keyInput.dataset.masked === '1' && apiKey === (keyInput.dataset.maskedVal || '')) { + apiKey = ''; + } + + const payload = { + action: 'set_custom_provider', + name: name, + api_base: apiBase, + model: model, + }; + if (apiKey) payload.api_key = apiKey; + if (editing) payload.original_name = customProviderModalState.originalName; + + const btn = document.getElementById('custom-provider-modal-save'); + btn.disabled = true; + fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(r => r.json()).then(data => { + btn.disabled = false; + if (data.status === 'success') { + closeCustomProviderModal(); + loadModelsView(); + } else { + // Surface the most useful known error; fall back to generic save fail. + const msg = (data.message || '').includes('already exists') + ? 'models_custom_name_exists' : 'models_save_failed'; + showStatus('custom-provider-modal-status', msg, true); + } + }).catch(() => { + btn.disabled = false; + showStatus('custom-provider-modal-status', 'models_save_failed', true); + }); +} + +function setActiveCustomProvider(name) { + fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set_active_custom_provider', name: name }), + }).then(r => r.json()).then(data => { + if (data.status === 'success') loadModelsView(); + }).catch(() => { /* noop */ }); +} + +function deleteCustomProvider(name) { + showConfirmDialog({ + title: t('models_custom_delete_confirm_title'), + message: t('models_custom_delete_confirm_msg'), + okText: t('models_custom_delete'), + cancelText: t('cancel'), + onConfirm: () => { + fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete_custom_provider', name: name }), + }).then(r => r.json()).then(data => { + if (data.status === 'success') loadModelsView(); + }).catch(() => { /* noop */ }); + } + }); +} + // ===================================================================== // Channels View // ===================================================================== diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 1be31c6a..d1185000 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1601,6 +1601,7 @@ class ConfigHandler: "open_ai_api_key", "deepseek_api_key", "qianfan_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", "custom_api_key", "mimo_api_key", + "custom_providers", "custom_active_provider", "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", "enable_thinking", "self_evolution_enabled", "web_password", } @@ -2131,13 +2132,85 @@ class ModelsHandler: def _is_real_key(value: str) -> bool: return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY") + @classmethod + def _custom_provider_cards(cls, local_config: dict) -> List[dict]: + """Expand ``custom_providers`` into one card per provider. + + Each user-defined OpenAI-compatible provider becomes its own card with + a synthetic id ``custom:`` so the frontend can render, edit, + delete and activate them independently. The card carries + ``is_custom=True`` and ``active`` flags that the UI uses to render the + extra controls (delete button, "set active" affordance). + + Returns an empty list when no multi-providers are configured, in which + case the caller keeps the single legacy ``custom`` card untouched — + guaranteeing backward compatibility with the flat + ``custom_api_key`` / ``custom_api_base`` config. + """ + try: + from models.custom_provider import get_custom_providers + providers = get_custom_providers() + except Exception as e: # pragma: no cover - defensive + logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}") + providers = [] + if not providers: + return [] + + active_name = (local_config.get("custom_active_provider") or "").strip() + # When no valid active name is set, the resolver treats the first entry + # as active; mirror that here so exactly one card is highlighted. + names = [p.get("name") for p in providers] + if active_name not in names: + active_name = names[0] if names else "" + + meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {} + cards = [] + for p in providers: + name = p.get("name") or "" + raw_key = p.get("api_key") or "" + raw_base = p.get("api_base") or "" + configured = cls._is_real_key(raw_key) + cards.append({ + "id": f"custom:{name}", + "label": {"zh": name, "en": name}, + "configured": configured, + "is_custom": True, + "custom_name": name, + "active": (name == active_name), + "model": p.get("model") or "", + # Custom cards are edited via the dedicated set_custom_provider + # action, not the field-based set_provider flow, so the field + # names are intentionally null. + "api_key_field": None, + "api_base_field": None, + "api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "", + "api_base": raw_base, + "api_base_default": "", + "api_base_placeholder": meta.get("api_base_placeholder") or "", + "models": [p.get("model")] if p.get("model") else [], + }) + return cards + @classmethod def _provider_overview(cls) -> List[dict]: """All known providers (configured first, unconfigured after). - Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.""" + Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list. + + When the user has defined multiple custom (OpenAI-compatible) + providers via ``custom_providers``, the single built-in ``custom`` + card is replaced by one card per provider (see + ``_custom_provider_cards``). Otherwise the legacy single ``custom`` + card is shown unchanged. + """ local_config = conf() + custom_cards = cls._custom_provider_cards(local_config) items = [] for pid, p in ConfigHandler.PROVIDER_MODELS.items(): + if pid == "custom" and custom_cards: + # Multi-provider mode: emit the expanded cards instead of the + # single legacy custom card. + items.extend(custom_cards) + continue key_field = p.get("api_key_field") base_field = p.get("api_base_key") raw_key = local_config.get(key_field, "") if key_field else "" @@ -2147,6 +2220,7 @@ class ModelsHandler: "id": pid, "label": p["label"], "configured": configured, + "is_custom": (pid == "custom"), "api_key_field": key_field, "api_base_field": base_field, "api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "", @@ -2155,7 +2229,19 @@ class ModelsHandler: "api_base_placeholder": p.get("api_base_placeholder") or "", "models": list(p.get("models") or []), }) - items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"]))) + + def _sort_key(it): + pid = it["id"] + # Custom expanded cards share the sort weight of the base "custom" + # entry so they cluster where the single custom card used to be. + base_id = "custom" if it.get("is_custom") else pid + try: + order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id) + except ValueError: + order = len(ConfigHandler.PROVIDER_MODELS) + return (0 if it["configured"] else 1, order) + + items.sort(key=_sort_key) return items @classmethod @@ -2603,6 +2689,12 @@ class ModelsHandler: return self._handle_set_provider(data) if action == "delete_provider": return self._handle_delete_provider(data) + if action == "set_custom_provider": + return self._handle_set_custom_provider(data) + if action == "delete_custom_provider": + return self._handle_delete_custom_provider(data) + if action == "set_active_custom_provider": + return self._handle_set_active_custom_provider(data) if action == "set_capability": return self._handle_set_capability(data) if action == "set_voice_reply_mode": @@ -2686,6 +2778,163 @@ class ModelsHandler: self._reset_bridge() return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared}) + # ------------------------------------------------------------------ + # Multiple custom (OpenAI-compatible) providers + # ------------------------------------------------------------------ + # These actions manage the ``custom_providers`` list and the + # ``custom_active_provider`` selector. They are the write-side companion to + # ``_custom_provider_cards`` and let the console add / edit / delete / + # activate user-defined OpenAI-compatible providers individually. + + @staticmethod + def _normalize_custom_providers(raw) -> List[dict]: + """Return a clean list of provider dicts (drops malformed entries).""" + if not isinstance(raw, list): + return [] + out = [] + for p in raw: + if isinstance(p, dict) and (p.get("name") or "").strip(): + out.append(p) + return out + + def _persist_custom_providers(self, providers: List[dict], active_name) -> None: + """Write the providers list + active selector to both in-memory conf + and the on-disk config, then reset the bridge so bots rebuild.""" + local_config = conf() + file_cfg = self._read_file_config() + local_config["custom_providers"] = providers + file_cfg["custom_providers"] = providers + if active_name is not None: + local_config["custom_active_provider"] = active_name + file_cfg["custom_active_provider"] = active_name + self._write_file_config(file_cfg) + self._reset_bridge() + + def _handle_set_custom_provider(self, data: dict) -> str: + """Add a new custom provider or update an existing one. + + Payload:: + + { + "action": "set_custom_provider", + "name": "siliconflow", # required, unique + "api_base": "https://...", # required when creating + "api_key": "sk-...", # optional on edit (keep existing) + "model": "deepseek-ai/...", # optional default model + "original_name": "old-name", # optional, set when renaming + "make_active": true # optional, also activate it + } + """ + name = (data.get("name") or "").strip() + if not name: + return json.dumps({"status": "error", "message": "name is required"}) + + api_base = (data.get("api_base") or "").strip() + # api_key omitted/empty on edit => keep the existing one. + api_key_raw = data.get("api_key") + api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else "" + model = (data.get("model") or "").strip() + # ``original_name`` is supplied only when editing an existing entry + # (so it can be renamed). Its absence means "create a new provider"; + # we must keep that distinction explicit, otherwise a create request + # for an already-taken name would be misread as an in-place edit. + original_name = (data.get("original_name") or "").strip() + is_edit = bool(original_name) + make_active = bool(data.get("make_active")) + + local_config = conf() + providers = self._normalize_custom_providers(local_config.get("custom_providers")) + + # Reject a name collision unless it is the very entry being edited. + for p in providers: + if p.get("name") == name and p.get("name") != original_name: + return json.dumps({ + "status": "error", + "message": f"a custom provider named {name!r} already exists", + }) + + existing = next((p for p in providers if p.get("name") == original_name), None) if is_edit else None + if existing is None: + # Creating a new provider — api_base is mandatory. + if not api_base: + return json.dumps({"status": "error", "message": "api_base is required"}) + entry = {"name": name, "api_key": api_key, "api_base": api_base} + if model: + entry["model"] = model + providers.append(entry) + created = True + else: + existing["name"] = name + if api_base: + existing["api_base"] = api_base + if api_key: + existing["api_key"] = api_key + # model is always overwritten (empty clears the default model). + if model: + existing["model"] = model + else: + existing.pop("model", None) + created = False + + # Decide the active selector. + active_name = (local_config.get("custom_active_provider") or "").strip() + if make_active or created and not active_name: + # Activate on explicit request, or auto-activate the very first + # provider so the resolver has a definite target. + active_name = name + elif active_name == original_name and original_name != name: + # The active provider was renamed; keep it pointed at the new name. + active_name = name + + self._persist_custom_providers(providers, active_name) + logger.info( + f"[ModelsHandler] custom provider {name!r} " + f"{'created' if created else 'updated'} (active={active_name!r})" + ) + return json.dumps({ + "status": "success", + "name": name, + "created": created, + "active": active_name, + }) + + def _handle_delete_custom_provider(self, data: dict) -> str: + """Remove a custom provider by name.""" + name = (data.get("name") or "").strip() + if not name: + return json.dumps({"status": "error", "message": "name is required"}) + + local_config = conf() + providers = self._normalize_custom_providers(local_config.get("custom_providers")) + remaining = [p for p in providers if p.get("name") != name] + if len(remaining) == len(providers): + return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"}) + + active_name = (local_config.get("custom_active_provider") or "").strip() + if active_name == name: + # The active provider was removed — fall back to the first + # remaining entry (resolver does the same when the name is stale). + active_name = remaining[0]["name"] if remaining else "" + + self._persist_custom_providers(remaining, active_name) + logger.info(f"[ModelsHandler] custom provider {name!r} deleted (active={active_name!r})") + return json.dumps({"status": "success", "name": name, "active": active_name}) + + def _handle_set_active_custom_provider(self, data: dict) -> str: + """Mark one of the existing custom providers as active.""" + name = (data.get("name") or "").strip() + if not name: + return json.dumps({"status": "error", "message": "name is required"}) + + local_config = conf() + providers = self._normalize_custom_providers(local_config.get("custom_providers")) + if not any(p.get("name") == name for p in providers): + return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"}) + + self._persist_custom_providers(providers, name) + logger.info(f"[ModelsHandler] active custom provider set to {name!r}") + return json.dumps({"status": "success", "active": name}) + def _handle_set_capability(self, data: dict) -> str: capability = (data.get("capability") or "").strip() provider_id = (data.get("provider_id") or "").strip() diff --git a/config.py b/config.py index f711aad2..5c12e4b0 100644 --- a/config.py +++ b/config.py @@ -24,8 +24,12 @@ available_setting = { "open_ai_api_base": "https://api.openai.com/v1", "claude_api_base": "https://api.anthropic.com/v1", # claude api base "gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base - "custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom") - "custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom") + "custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field + "custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field + # Multiple custom (OpenAI-compatible) providers. When non-empty, supersedes the legacy custom_api_key/base above. + # Each item: {"name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"} + "custom_providers": [], + "custom_active_provider": "", # name of the active provider in custom_providers; empty = use the first entry / legacy fields "proxy": "", # proxy used by openai # chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name "model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index c2a3cfa9..8ce33d0e 100644 --- a/docs/ja/models/custom.mdx +++ b/docs/ja/models/custom.mdx @@ -60,3 +60,37 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ ``` /config model qwen3.5:27b ``` + +## 複数のカスタムベンダーを設定する + +複数の OpenAI 互換サードパーティサービス(例:SiliconFlow、Qiniu)を同時に設定したい場合は、`custom_providers` リストを使用し、`custom_active_provider` で現在有効なベンダーを指定します: + +```json +{ + "bot_type": "custom", + "custom_active_provider": "siliconflow", + "custom_providers": [ + { + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| パラメータ | 説明 | +| --- | --- | +| `custom_providers` | カスタムベンダーのリスト。各項目に `name`、`api_key`、`api_base`、任意の `model` を含む | +| `custom_active_provider` | 有効なベンダーの `name`。空の場合はリストの最初の項目が使用される | + + + `custom_providers` が空の場合、上記の単一ベンダー設定 `custom_api_key` / `custom_api_base` に自動的にフォールバックするため、既存の設定はそのまま動作します。 + diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index 45a7d2e1..d65b60b6 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -60,3 +60,37 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t ``` /config model qwen3.5:27b ``` + +## Configuring Multiple Custom Providers + +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and select the active one with `custom_active_provider`: + +```json +{ + "bot_type": "custom", + "custom_active_provider": "siliconflow", + "custom_providers": [ + { + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| Parameter | Description | +| --- | --- | +| `custom_providers` | List of custom providers; each item has `name`, `api_key`, `api_base`, and an optional `model` | +| `custom_active_provider` | The `name` of the active provider; when empty, the first entry in the list is used | + + + When `custom_providers` is empty, CowAgent automatically falls back to the single-provider `custom_api_key` / `custom_api_base` fields above, so existing configurations keep working without any changes. + diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx index 2673a8de..69c71494 100644 --- a/docs/zh/models/custom.mdx +++ b/docs/zh/models/custom.mdx @@ -60,3 +60,37 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 ``` /config model qwen3.5:27b ``` + +## 配置多个自定义厂商 + +如果需要同时配置多个第三方 OpenAI 兼容服务(例如硅基流动、七牛云等),可以使用 `custom_providers` 列表,并通过 `custom_active_provider` 指定当前生效的厂商: + +```json +{ + "bot_type": "custom", + "custom_active_provider": "siliconflow", + "custom_providers": [ + { + "name": "siliconflow", + "api_key": "YOUR_SILICONFLOW_KEY", + "api_base": "https://api.siliconflow.cn/v1", + "model": "deepseek-ai/DeepSeek-V3" + }, + { + "name": "qiniu", + "api_key": "YOUR_QINIU_KEY", + "api_base": "https://api.qnaigc.com/v1", + "model": "deepseek-v3" + } + ] +} +``` + +| 参数 | 说明 | +| --- | --- | +| `custom_providers` | 自定义厂商列表,每项包含 `name`、`api_key`、`api_base`、可选的 `model` | +| `custom_active_provider` | 当前生效厂商的 `name`;留空时默认使用列表中的第一个厂商 | + + + 当 `custom_providers` 为空时,将自动回退到上文的 `custom_api_key` / `custom_api_base` 单厂商配置,已有配置无需改动即可正常工作。 + diff --git a/models/chatgpt/chat_gpt_bot.py b/models/chatgpt/chat_gpt_bot.py index 999986bc..991ca646 100644 --- a/models/chatgpt/chat_gpt_bot.py +++ b/models/chatgpt/chat_gpt_bot.py @@ -17,6 +17,7 @@ from common import const from common.i18n import t as _t from models.bot import Bot from models.openai_compatible_bot import OpenAICompatibleBot +from models.custom_provider import resolve_custom_credentials from models.chatgpt.chat_gpt_session import ChatGPTSession from models.openai.open_ai_image import OpenAIImage from models.session_manager import SessionManager @@ -33,8 +34,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): super().__init__() # Resolve api key / base from config (no global SDK state anymore). if conf().get("bot_type") == "custom": - self._api_key = conf().get("custom_api_key", "") - self._api_base = conf().get("custom_api_base") or None + # Supports multiple custom providers (custom_providers) with + # automatic fallback to the legacy custom_api_key/base fields. + self._api_key, self._api_base, _ = resolve_custom_credentials() else: self._api_key = conf().get("open_ai_api_key") self._api_base = conf().get("open_ai_api_base") or None @@ -71,10 +73,19 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def get_api_config(self): """Get API configuration for OpenAI-compatible base class""" is_custom = conf().get("bot_type") == "custom" + if is_custom: + custom_key, custom_base, custom_model = resolve_custom_credentials() + api_key = custom_key + api_base = custom_base + model = custom_model or conf().get("model", "gpt-3.5-turbo") + else: + api_key = conf().get("open_ai_api_key") + api_base = conf().get("open_ai_api_base") + model = conf().get("model", "gpt-3.5-turbo") return { - 'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"), - 'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"), - 'model': conf().get("model", "gpt-3.5-turbo"), + 'api_key': api_key, + 'api_base': api_base, + 'model': model, 'default_temperature': conf().get("temperature", 0.9), 'default_top_p': conf().get("top_p", 1.0), 'default_frequency_penalty': conf().get("frequency_penalty", 0.0), @@ -186,9 +197,15 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): # Get model and API config is_custom = conf().get("bot_type") == "custom" - model = context.get("gpt_model") or conf().get("model", "gpt-4o") - api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key")) - api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base") + if is_custom: + custom_key, custom_base, custom_model = resolve_custom_credentials() + model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o") + api_key = context.get("openai_api_key") or custom_key + api_base = custom_base + else: + model = context.get("gpt_model") or conf().get("model", "gpt-4o") + api_key = context.get("openai_api_key") or conf().get("open_ai_api_key") + api_base = conf().get("open_ai_api_base") # Build vision request messages = [ diff --git a/models/custom_provider.py b/models/custom_provider.py new file mode 100644 index 00000000..a1570f7a --- /dev/null +++ b/models/custom_provider.py @@ -0,0 +1,84 @@ +# encoding:utf-8 + +""" +Centralized resolver for custom (OpenAI-compatible) provider credentials. + +CowAgent historically supported only a *single* custom provider via the flat +config keys ``custom_api_key`` / ``custom_api_base``. This module adds support +for *multiple* custom providers (see issue #2838) while remaining 100% +backward compatible. + +Config model +------------ +- ``custom_providers``: list of dicts, each describing one custom provider:: + + { + "name": "siliconflow", # unique, user-facing identifier + "api_key": "sk-...", # required + "api_base": "https://...", # required, must be OpenAI-compatible + "model": "deepseek-ai/DeepSeek-V3" # optional default model + } + +- ``custom_active_provider``: the ``name`` of the provider to use. When empty + (or pointing to a non-existent name) we fall back to the first provider in + the list, and finally to the legacy ``custom_api_key`` / ``custom_api_base``. + +Backward-compatibility contract +------------------------------- +When ``custom_providers`` is empty, ``resolve_custom_credentials`` returns +exactly the legacy ``custom_api_key`` / ``custom_api_base`` values, so existing +deployments behave byte-for-byte identically. +""" + +from config import conf +from common.log import logger + + +def get_custom_providers(): + """Return the list of configured custom providers (always a list).""" + providers = conf().get("custom_providers") + if not isinstance(providers, list): + return [] + # Keep only well-formed entries with a name. + return [p for p in providers if isinstance(p, dict) and p.get("name")] + + +def _find_active_provider(providers): + """Pick the active provider from the list, or None when list is empty.""" + if not providers: + return None + active_name = conf().get("custom_active_provider") or "" + if active_name: + for p in providers: + if p.get("name") == active_name: + return p + logger.warning( + "[CUSTOM] active provider '%s' not found in custom_providers, " + "falling back to the first entry", active_name + ) + return providers[0] + + +def resolve_custom_credentials(): + """Resolve the effective (api_key, api_base, model) for custom mode. + + Resolution order: + 1. The active entry in ``custom_providers`` (multi-provider mode). + 2. The legacy flat keys ``custom_api_key`` / ``custom_api_base``. + + :return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model`` + may be ``None`` / empty when not configured. + """ + provider = _find_active_provider(get_custom_providers()) + if provider is not None: + return ( + provider.get("api_key", ""), + provider.get("api_base") or None, + provider.get("model") or None, + ) + # Legacy single-provider fallback — unchanged behavior. + return ( + conf().get("custom_api_key", ""), + conf().get("custom_api_base") or None, + None, + ) diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py new file mode 100644 index 00000000..26df3bfa --- /dev/null +++ b/tests/test_custom_provider.py @@ -0,0 +1,170 @@ +# encoding:utf-8 +""" +Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838). + +Covers models/custom_provider.py: + - Backward compatibility: legacy custom_api_key / custom_api_base fallback + - Multi-provider selection via custom_providers / custom_active_provider + - Robustness against malformed config (missing name, non-dict, non-list) +""" +import sys +import os +import unittest + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import config as config_module +from config import Config + + +def set_conf(d): + """Install a fresh Config as the global config used by conf().""" + config_module.config = Config(d) + + +class TestResolveCustomCredentials(unittest.TestCase): + """resolve_custom_credentials() resolution order and fallbacks.""" + + def setUp(self): + # Import here so the module picks up our config-swapping helper. + from models.custom_provider import resolve_custom_credentials, get_custom_providers + self.resolve = resolve_custom_credentials + self.get_providers = get_custom_providers + + # --- Backward compatibility --- + + def test_legacy_fallback_when_no_providers(self): + set_conf({ + "bot_type": "custom", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + }) + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + def test_empty_config(self): + set_conf({"bot_type": "custom"}) + self.assertEqual(self.resolve(), ("", None, None)) + + # --- Multi-provider selection --- + + def test_multi_providers_no_active_uses_first(self): + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}, + {"name": "qiniu", "api_key": "qn-key", + "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, + ], + }) + self.assertEqual( + self.resolve(), + ("sf-key", "https://api.siliconflow.cn/v1", "deepseek-ai/DeepSeek-V3"), + ) + + def test_active_provider_selected(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "qiniu", + "custom_providers": [ + {"name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1", "model": "m1"}, + {"name": "qiniu", "api_key": "qn-key", + "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, + ], + }) + self.assertEqual( + self.resolve(), + ("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"), + ) + + def test_active_name_missing_falls_back_to_first(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "ghost", + "custom_providers": [ + {"name": "siliconflow", "api_key": "sf-key", + "api_base": "https://api.siliconflow.cn/v1"}, + ], + }) + self.assertEqual( + self.resolve(), + ("sf-key", "https://api.siliconflow.cn/v1", None), + ) + + def test_provider_without_model_returns_none_model(self): + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"}, + ], + }) + self.assertEqual( + self.resolve(), + ("", "http://localhost:11434/v1", None), + ) + + # --- Robustness against malformed config --- + + def test_malformed_entries_filtered_and_fallback(self): + set_conf({ + "bot_type": "custom", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + "custom_providers": [ + {"api_key": "no-name-key"}, # invalid: no name + "not-a-dict", # invalid: wrong type + ], + }) + # All entries invalid -> treated as empty -> legacy fallback + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + def test_get_custom_providers_filters_invalid(self): + set_conf({ + "bot_type": "custom", + "custom_providers": [ + {"name": "ok", "api_key": "k", "api_base": "https://x/v1"}, + {"api_key": "no-name"}, # dropped + 123, # dropped + ], + }) + providers = self.get_providers() + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["name"], "ok") + + def test_custom_providers_not_a_list_falls_back(self): + set_conf({ + "bot_type": "custom", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", + "custom_providers": "oops-a-string", + }) + self.assertEqual( + self.resolve(), + ("legacy-key", "https://legacy.example.com/v1", None), + ) + + +class TestConfigDefaults(unittest.TestCase): + """The new config fields must exist with safe defaults.""" + + def test_default_config_has_custom_providers(self): + from config import available_setting + self.assertIn("custom_providers", available_setting) + self.assertEqual(available_setting["custom_providers"], []) + + def test_default_config_has_active_provider(self): + from config import available_setting + self.assertIn("custom_active_provider", available_setting) + self.assertEqual(available_setting["custom_active_provider"], "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py new file mode 100644 index 00000000..1420776d --- /dev/null +++ b/tests/test_custom_provider_handlers.py @@ -0,0 +1,258 @@ +# encoding:utf-8 +""" +Unit tests for the multi custom-provider management API (issue #2838, web UI). + +Covers channel/web/web_channel.py::ModelsHandler: + - _custom_provider_cards / _provider_overview expansion + - _handle_set_custom_provider (create / edit / rename / activate) + - _handle_delete_custom_provider + - _handle_set_active_custom_provider + +These handlers are normally driven by the `web.py` framework, which isn't +available in the headless test environment, so we stub the `web` module before +import. The on-disk config read/write and the Bridge reset are patched to keep +the tests hermetic (no file I/O, no live bot routing). +""" +import json +import os +import sys +import types +import unittest + +# Add project root to path. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Stub the web.py framework so web_channel imports without the dependency. +if "web" not in sys.modules: + _web_stub = types.ModuleType("web") + _web_stub.header = lambda *a, **k: None + _web_stub.data = lambda: b"{}" + _web_stub.ctx = types.SimpleNamespace() + sys.modules["web"] = _web_stub + +import config as config_module +from config import Config +from channel.web.web_channel import ModelsHandler + + +def set_conf(d): + """Install a fresh Config as the global config used by conf().""" + config_module.config = Config(d) + + +class _HandlerHarness: + """Test double around ModelsHandler that captures persisted config in + memory instead of touching config.json, and no-ops the Bridge reset.""" + + def __init__(self): + self.handler = ModelsHandler.__new__(ModelsHandler) + self._file_cfg = {} + self.bridge_resets = 0 + # Patch the disk + bridge boundary on this instance. + self.handler._read_file_config = lambda: dict(self._file_cfg) + self.handler._write_file_config = self._capture_write + self.handler._reset_bridge = self._capture_reset + + def _capture_write(self, data): + self._file_cfg = dict(data) + + def _capture_reset(self): + self.bridge_resets += 1 + + def call(self, **payload): + # Resolve the bound method by action for convenience. + action = payload.get("action") + method = { + "set_custom_provider": self.handler._handle_set_custom_provider, + "delete_custom_provider": self.handler._handle_delete_custom_provider, + "set_active_custom_provider": self.handler._handle_set_active_custom_provider, + }[action] + return json.loads(method(payload)) + + +class TestSetCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + self.h = _HandlerHarness() + + def test_create_first_provider_auto_activates(self): + res = self.h.call(action="set_custom_provider", name="siliconflow", + api_base="https://api.siliconflow.cn/v1", api_key="sf-key") + self.assertEqual(res["status"], "success") + self.assertTrue(res["created"]) + self.assertEqual(res["active"], "siliconflow") + providers = config_module.conf().get("custom_providers") + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["name"], "siliconflow") + self.assertEqual(config_module.conf().get("custom_active_provider"), "siliconflow") + self.assertEqual(self.h.bridge_resets, 1) + + def test_create_requires_api_base(self): + res = self.h.call(action="set_custom_provider", name="x", api_key="k") + self.assertEqual(res["status"], "error") + self.assertIn("api_base", res["message"]) + + def test_create_requires_name(self): + res = self.h.call(action="set_custom_provider", name="", api_base="https://x/v1") + self.assertEqual(res["status"], "error") + + def test_second_provider_does_not_steal_active(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + res = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") + self.assertTrue(res["created"]) + # First provider stays active unless make_active is requested. + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + + def test_make_active_flag(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk", make_active=True) + self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + + def test_duplicate_name_rejected(self): + self.h.call(action="set_custom_provider", name="dup", + api_base="https://a/v1", api_key="ak") + res = self.h.call(action="set_custom_provider", name="dup", + api_base="https://b/v1", api_key="bk") + self.assertEqual(res["status"], "error") + self.assertIn("already exists", res["message"]) + # The original entry must be untouched. + providers = config_module.conf().get("custom_providers") + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["api_base"], "https://a/v1") + + def test_edit_keeps_key_when_omitted(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="secret") + # Edit only the base; omit api_key. + res = self.h.call(action="set_custom_provider", name="a", + original_name="a", api_base="https://a2/v1") + self.assertEqual(res["status"], "success") + self.assertFalse(res["created"]) + providers = config_module.conf().get("custom_providers") + self.assertEqual(providers[0]["api_base"], "https://a2/v1") + self.assertEqual(providers[0]["api_key"], "secret") # preserved + + def test_rename_updates_active_pointer(self): + self.h.call(action="set_custom_provider", name="old", + api_base="https://a/v1", api_key="ak") + self.assertEqual(config_module.conf().get("custom_active_provider"), "old") + res = self.h.call(action="set_custom_provider", name="new", + original_name="old", api_base="https://a/v1") + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("custom_active_provider"), "new") + names = [p["name"] for p in config_module.conf().get("custom_providers")] + self.assertEqual(names, ["new"]) + + def test_edit_clears_model_when_empty(self): + self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", model="m1") + self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1") + self.h.call(action="set_custom_provider", name="a", original_name="a", + api_base="https://a/v1", model="") + self.assertNotIn("model", config_module.conf().get("custom_providers")[0]) + + +class TestDeleteCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + self.h = _HandlerHarness() + self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") + self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") + + def test_delete_unknown(self): + res = self.h.call(action="delete_custom_provider", name="ghost") + self.assertEqual(res["status"], "error") + + def test_delete_non_active(self): + res = self.h.call(action="delete_custom_provider", name="b") + self.assertEqual(res["status"], "success") + names = [p["name"] for p in config_module.conf().get("custom_providers")] + self.assertEqual(names, ["a"]) + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + + def test_delete_active_falls_back_to_first_remaining(self): + # 'a' is active (created first); deleting it should re-point to 'b'. + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + res = self.h.call(action="delete_custom_provider", name="a") + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + + def test_delete_last_clears_active(self): + self.h.call(action="delete_custom_provider", name="a") + self.h.call(action="delete_custom_provider", name="b") + self.assertEqual(config_module.conf().get("custom_providers"), []) + self.assertEqual(config_module.conf().get("custom_active_provider"), "") + + +class TestSetActiveCustomProvider(unittest.TestCase): + def setUp(self): + set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + self.h = _HandlerHarness() + self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") + self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") + + def test_set_active_valid(self): + res = self.h.call(action="set_active_custom_provider", name="b") + self.assertEqual(res["status"], "success") + self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + + def test_set_active_unknown(self): + res = self.h.call(action="set_active_custom_provider", name="ghost") + self.assertEqual(res["status"], "error") + self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + + +class TestProviderOverviewExpansion(unittest.TestCase): + """_provider_overview / _custom_provider_cards should expand the list.""" + + def test_no_custom_providers_keeps_single_card(self): + set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + cards = ModelsHandler._custom_provider_cards(config_module.conf()) + self.assertEqual(cards, []) + overview = ModelsHandler._provider_overview() + custom_cards = [c for c in overview if c.get("id") == "custom"] + # Legacy single custom card remains present. + self.assertEqual(len(custom_cards), 1) + self.assertTrue(custom_cards[0].get("is_custom")) + + def test_multi_providers_expand_into_cards(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "b", + "custom_providers": [ + {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"}, + ], + }) + overview = ModelsHandler._provider_overview() + custom_cards = [c for c in overview if c.get("is_custom")] + self.assertEqual(len(custom_cards), 2) + by_name = {c["custom_name"]: c for c in custom_cards} + self.assertEqual(by_name["a"]["id"], "custom:a") + self.assertFalse(by_name["a"]["active"]) + self.assertTrue(by_name["b"]["active"]) + self.assertEqual(by_name["b"]["model"], "m") + # No single legacy "custom" card when expanded. + self.assertFalse(any(c.get("id") == "custom" for c in overview)) + + def test_active_defaults_to_first_when_unset(self): + set_conf({ + "bot_type": "custom", + "custom_active_provider": "", + "custom_providers": [ + {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"name": "b", "api_key": "bk", "api_base": "https://b/v1"}, + ], + }) + cards = ModelsHandler._custom_provider_cards(config_module.conf()) + by_name = {c["custom_name"]: c for c in cards} + self.assertTrue(by_name["a"]["active"]) + self.assertFalse(by_name["b"]["active"]) + + +if __name__ == "__main__": + unittest.main() From 1940d628a82b307786722402b69d0d2e350d06a5 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Thu, 11 Jun 2026 17:25:24 +0800 Subject: [PATCH 354/399] refactor(custom): id-based routing, single source of truth, security fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the multi custom-provider design per maintainer review: 1. Data model: use server-generated uuid4 short id as primary key; 'name' is now a pure display label that can be freely renamed. 2. Routing: drop 'custom_active_provider'; activate a provider by setting bot_type to 'custom:'. Single source of truth — no pointer drift between bot_type and a separate active selector. 3. Security: drag_sensitive() now recursively masks api_key/secret in nested structures (custom_providers list); previously only top-level string fields were masked. 4. Per-provider model: the provider's 'model' field now takes effect on the main chat path and agent path (was silently ignored before). 5. XSS fix: replace all inline onclick handlers in custom-provider UI with data-* attributes + event delegation. Provider names never appear in executable HTML contexts. Legacy compatibility: bot_type='custom' (no colon) still reads the flat custom_api_key/custom_api_base fields byte-for-byte identically. Closes: consolidates #2876 into this PR as requested. Ref: #2838 --- channel/web/static/js/console.js | 62 +++++---- channel/web/web_channel.py | 155 +++++++++++----------- config.py | 40 ++++-- docs/ja/models/custom.mdx | 64 +++++---- docs/models/custom.mdx | 20 ++- docs/zh/models/custom.mdx | 64 +++++---- models/bot_factory.py | 2 +- models/chatgpt/chat_gpt_bot.py | 21 +-- models/custom_provider.py | 103 ++++++++++----- tests/test_custom_provider.py | 152 +++++++++++++++------ tests/test_custom_provider_handlers.py | 174 ++++++++++++------------- 11 files changed, 504 insertions(+), 353 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 74bd885f..d15f0248 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -4861,9 +4861,11 @@ function renderProviderLogo(p, sizePx) { // ---------- Custom providers section (multiple OpenAI-compatible) ------- // Renders the user-defined OpenAI-compatible providers as a dedicated, // independently managed list: add / edit / delete / activate. The backend -// expands `custom_providers` into provider cards with id="custom:", -// is_custom=true, custom_name and an `active` flag (see +// expands `custom_providers` into provider cards with id="custom:", +// is_custom=true, custom_id, custom_name and an `active` flag (see // ModelsHandler._custom_provider_cards / _provider_overview). +// All button interactions use data-* attributes + event delegation (no inline +// onclick) to avoid XSS via user-supplied names. function getCustomProviderCards() { return modelsState.providers.filter(isCustomProviderCard); @@ -4884,7 +4886,7 @@ function renderCustomProvidersSection() {

${t('models_custom_section')}

${t('models_custom_section_desc')}

- @@ -4903,18 +4905,30 @@ function renderCustomProvidersSection() { } wrap.innerHTML = header + body; + // Event delegation — handles all custom-provider actions via data-action attrs. + wrap.addEventListener('click', function(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.getAttribute('data-action'); + const providerId = btn.getAttribute('data-provider-id') || ''; + if (action === 'add-custom') openCustomProviderModal(''); + else if (action === 'edit-custom') openCustomProviderModal(providerId); + else if (action === 'delete-custom') deleteCustomProvider(providerId); + else if (action === 'set-active-custom') setActiveCustomProvider(providerId); + }); return wrap; } function renderCustomProviderRow(p) { + const id = p.custom_id || ''; const name = p.custom_name || ''; const nameEsc = escapeHtml(name); // The active provider gets a highlighted ring + badge; others show a - // "set active" affordance. + // "set active" affordance via data-attributes (no inline onclick — XSS safe). const activeBadge = p.active ? ` ${t('models_custom_active')}` - : ``; @@ -4930,7 +4944,7 @@ function renderCustomProviderRow(p) { : ''; return ` -
+
${renderProviderLogo(p, 28)}
@@ -4939,11 +4953,11 @@ function renderCustomProviderRow(p) {
${base}${model ? '·' + model : ''}
- - @@ -6245,16 +6259,15 @@ function clearVendorModal() { // ===================================================================== // Custom (OpenAI-compatible) provider modal — add / edit // ===================================================================== -// State for the dedicated custom-provider modal. `originalName` is empty when -// adding and set to the provider name when editing (so the backend can rename -// without losing the entry). -let customProviderModalState = { originalName: '' }; +// State for the dedicated custom-provider modal. `editId` is empty when +// adding and set to the provider id when editing. +let customProviderModalState = { editId: '' }; -function openCustomProviderModal(name) { - const editing = !!name; - customProviderModalState = { originalName: editing ? name : '' }; +function openCustomProviderModal(providerId) { + const editing = !!providerId; + customProviderModalState = { editId: editing ? providerId : '' }; - const card = editing ? getCustomProviderCards().find(p => p.custom_name === name) : null; + const card = editing ? getCustomProviderCards().find(p => p.custom_id === providerId) : null; const overlay = document.getElementById('custom-provider-modal-overlay'); if (!overlay) return; @@ -6322,7 +6335,7 @@ function saveCustomProviderModal() { document.getElementById('custom-provider-name').focus(); return; } - const editing = !!customProviderModalState.originalName; + const editing = !!customProviderModalState.editId; if (!editing && !apiBase) { showStatus('custom-provider-modal-status', 'models_custom_base_required', true); document.getElementById('custom-provider-base').focus(); @@ -6342,7 +6355,7 @@ function saveCustomProviderModal() { model: model, }; if (apiKey) payload.api_key = apiKey; - if (editing) payload.original_name = customProviderModalState.originalName; + if (editing) payload.id = customProviderModalState.editId; const btn = document.getElementById('custom-provider-modal-save'); btn.disabled = true; @@ -6356,10 +6369,7 @@ function saveCustomProviderModal() { closeCustomProviderModal(); loadModelsView(); } else { - // Surface the most useful known error; fall back to generic save fail. - const msg = (data.message || '').includes('already exists') - ? 'models_custom_name_exists' : 'models_save_failed'; - showStatus('custom-provider-modal-status', msg, true); + showStatus('custom-provider-modal-status', 'models_save_failed', true); } }).catch(() => { btn.disabled = false; @@ -6367,17 +6377,17 @@ function saveCustomProviderModal() { }); } -function setActiveCustomProvider(name) { +function setActiveCustomProvider(providerId) { fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'set_active_custom_provider', name: name }), + body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }), }).then(r => r.json()).then(data => { if (data.status === 'success') loadModelsView(); }).catch(() => { /* noop */ }); } -function deleteCustomProvider(name) { +function deleteCustomProvider(providerId) { showConfirmDialog({ title: t('models_custom_delete_confirm_title'), message: t('models_custom_delete_confirm_msg'), @@ -6387,7 +6397,7 @@ function deleteCustomProvider(name) { fetch('/api/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'delete_custom_provider', name: name }), + body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }), }).then(r => r.json()).then(data => { if (data.status === 'success') loadModelsView(); }).catch(() => { /* noop */ }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index d1185000..6eb933db 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1601,7 +1601,7 @@ class ConfigHandler: "open_ai_api_key", "deepseek_api_key", "qianfan_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", "custom_api_key", "mimo_api_key", - "custom_providers", "custom_active_provider", + "custom_providers", "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", "enable_thinking", "self_evolution_enabled", "web_password", } @@ -2137,10 +2137,9 @@ class ModelsHandler: """Expand ``custom_providers`` into one card per provider. Each user-defined OpenAI-compatible provider becomes its own card with - a synthetic id ``custom:`` so the frontend can render, edit, - delete and activate them independently. The card carries - ``is_custom=True`` and ``active`` flags that the UI uses to render the - extra controls (delete button, "set active" affordance). + id ``custom:`` so the frontend can render, edit, delete and + activate them independently. The card carries ``is_custom=True`` and + ``active`` flags that the UI uses to render the extra controls. Returns an empty list when no multi-providers are configured, in which case the caller keeps the single legacy ``custom`` card untouched — @@ -2148,7 +2147,7 @@ class ModelsHandler: ``custom_api_key`` / ``custom_api_base`` config. """ try: - from models.custom_provider import get_custom_providers + from models.custom_provider import get_custom_providers, parse_custom_bot_type providers = get_custom_providers() except Exception as e: # pragma: no cover - defensive logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}") @@ -2156,27 +2155,26 @@ class ModelsHandler: if not providers: return [] - active_name = (local_config.get("custom_active_provider") or "").strip() - # When no valid active name is set, the resolver treats the first entry - # as active; mirror that here so exactly one card is highlighted. - names = [p.get("name") for p in providers] - if active_name not in names: - active_name = names[0] if names else "" + # Determine the currently active provider id from bot_type. + bot_type = local_config.get("bot_type") or "" + _, active_id = parse_custom_bot_type(bot_type) meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {} cards = [] for p in providers: - name = p.get("name") or "" + pid = p.get("id") or "" + name = p.get("name") or pid raw_key = p.get("api_key") or "" raw_base = p.get("api_base") or "" configured = cls._is_real_key(raw_key) cards.append({ - "id": f"custom:{name}", + "id": f"custom:{pid}", "label": {"zh": name, "en": name}, "configured": configured, "is_custom": True, + "custom_id": pid, "custom_name": name, - "active": (name == active_name), + "active": (pid == active_id), "model": p.get("model") or "", # Custom cards are edited via the dedicated set_custom_provider # action, not the field-based set_provider flow, so the field @@ -2781,10 +2779,9 @@ class ModelsHandler: # ------------------------------------------------------------------ # Multiple custom (OpenAI-compatible) providers # ------------------------------------------------------------------ - # These actions manage the ``custom_providers`` list and the - # ``custom_active_provider`` selector. They are the write-side companion to - # ``_custom_provider_cards`` and let the console add / edit / delete / - # activate user-defined OpenAI-compatible providers individually. + # These actions manage the ``custom_providers`` list. Activation is done + # by setting ``bot_type`` to ``"custom:"``. There is no separate + # ``custom_active_provider`` field — a single source of truth. @staticmethod def _normalize_custom_providers(raw) -> List[dict]: @@ -2793,20 +2790,22 @@ class ModelsHandler: return [] out = [] for p in raw: - if isinstance(p, dict) and (p.get("name") or "").strip(): + if isinstance(p, dict) and (p.get("id") or "").strip(): out.append(p) return out - def _persist_custom_providers(self, providers: List[dict], active_name) -> None: - """Write the providers list + active selector to both in-memory conf - and the on-disk config, then reset the bridge so bots rebuild.""" + def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None: + """Write the providers list to both in-memory conf and the on-disk + config, then reset the bridge so bots rebuild. + + If ``bot_type`` is given, also update ``bot_type``.""" local_config = conf() file_cfg = self._read_file_config() local_config["custom_providers"] = providers file_cfg["custom_providers"] = providers - if active_name is not None: - local_config["custom_active_provider"] = active_name - file_cfg["custom_active_provider"] = active_name + if bot_type is not None: + local_config["bot_type"] = bot_type + file_cfg["bot_type"] = bot_type self._write_file_config(file_cfg) self._reset_bridge() @@ -2817,48 +2816,38 @@ class ModelsHandler: { "action": "set_custom_provider", - "name": "siliconflow", # required, unique - "api_base": "https://...", # required when creating - "api_key": "sk-...", # optional on edit (keep existing) - "model": "deepseek-ai/...", # optional default model - "original_name": "old-name", # optional, set when renaming + "id": "3f2a9c1b", # required for edit; omit for create + "name": "siliconflow", # required, display label + "api_base": "https://...", # required when creating + "api_key": "sk-...", # optional on edit (keep existing) + "model": "deepseek-ai/...", # optional default model "make_active": true # optional, also activate it } """ + from models.custom_provider import generate_provider_id, parse_custom_bot_type + name = (data.get("name") or "").strip() if not name: return json.dumps({"status": "error", "message": "name is required"}) + provider_id = (data.get("id") or "").strip() api_base = (data.get("api_base") or "").strip() # api_key omitted/empty on edit => keep the existing one. api_key_raw = data.get("api_key") api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else "" model = (data.get("model") or "").strip() - # ``original_name`` is supplied only when editing an existing entry - # (so it can be renamed). Its absence means "create a new provider"; - # we must keep that distinction explicit, otherwise a create request - # for an already-taken name would be misread as an in-place edit. - original_name = (data.get("original_name") or "").strip() - is_edit = bool(original_name) make_active = bool(data.get("make_active")) local_config = conf() providers = self._normalize_custom_providers(local_config.get("custom_providers")) - # Reject a name collision unless it is the very entry being edited. - for p in providers: - if p.get("name") == name and p.get("name") != original_name: - return json.dumps({ - "status": "error", - "message": f"a custom provider named {name!r} already exists", - }) - - existing = next((p for p in providers if p.get("name") == original_name), None) if is_edit else None + existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None if existing is None: # Creating a new provider — api_base is mandatory. if not api_base: return json.dumps({"status": "error", "message": "api_base is required"}) - entry = {"name": name, "api_key": api_key, "api_base": api_base} + provider_id = generate_provider_id() + entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base} if model: entry["model"] = model providers.append(entry) @@ -2876,64 +2865,68 @@ class ModelsHandler: existing.pop("model", None) created = False - # Decide the active selector. - active_name = (local_config.get("custom_active_provider") or "").strip() - if make_active or created and not active_name: + # Decide bot_type. + _, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "") + new_bot_type = None + if make_active or (created and not current_active_id): # Activate on explicit request, or auto-activate the very first # provider so the resolver has a definite target. - active_name = name - elif active_name == original_name and original_name != name: - # The active provider was renamed; keep it pointed at the new name. - active_name = name + new_bot_type = f"custom:{provider_id}" - self._persist_custom_providers(providers, active_name) + self._persist_custom_providers(providers, new_bot_type) logger.info( - f"[ModelsHandler] custom provider {name!r} " - f"{'created' if created else 'updated'} (active={active_name!r})" + f"[ModelsHandler] custom provider {name!r} (id={provider_id}) " + f"{'created' if created else 'updated'}" ) return json.dumps({ "status": "success", + "id": provider_id, "name": name, "created": created, - "active": active_name, }) def _handle_delete_custom_provider(self, data: dict) -> str: - """Remove a custom provider by name.""" - name = (data.get("name") or "").strip() - if not name: - return json.dumps({"status": "error", "message": "name is required"}) + """Remove a custom provider by id.""" + from models.custom_provider import parse_custom_bot_type + + provider_id = (data.get("id") or "").strip() + if not provider_id: + return json.dumps({"status": "error", "message": "id is required"}) local_config = conf() providers = self._normalize_custom_providers(local_config.get("custom_providers")) - remaining = [p for p in providers if p.get("name") != name] + remaining = [p for p in providers if p.get("id") != provider_id] if len(remaining) == len(providers): - return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"}) + return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"}) - active_name = (local_config.get("custom_active_provider") or "").strip() - if active_name == name: - # The active provider was removed — fall back to the first - # remaining entry (resolver does the same when the name is stale). - active_name = remaining[0]["name"] if remaining else "" + # If the deleted provider was active, fall back to the first remaining. + _, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "") + new_bot_type = None + if current_active_id == provider_id: + if remaining: + new_bot_type = f"custom:{remaining[0]['id']}" + else: + new_bot_type = "custom" # revert to legacy - self._persist_custom_providers(remaining, active_name) - logger.info(f"[ModelsHandler] custom provider {name!r} deleted (active={active_name!r})") - return json.dumps({"status": "success", "name": name, "active": active_name}) + self._persist_custom_providers(remaining, new_bot_type) + logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted") + return json.dumps({"status": "success", "id": provider_id}) def _handle_set_active_custom_provider(self, data: dict) -> str: - """Mark one of the existing custom providers as active.""" - name = (data.get("name") or "").strip() - if not name: - return json.dumps({"status": "error", "message": "name is required"}) + """Activate a custom provider by setting bot_type to 'custom:'.""" + provider_id = (data.get("id") or "").strip() + if not provider_id: + return json.dumps({"status": "error", "message": "id is required"}) local_config = conf() providers = self._normalize_custom_providers(local_config.get("custom_providers")) - if not any(p.get("name") == name for p in providers): - return json.dumps({"status": "error", "message": f"unknown custom provider: {name}"}) + if not any(p.get("id") == provider_id for p in providers): + return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"}) - self._persist_custom_providers(providers, name) - logger.info(f"[ModelsHandler] active custom provider set to {name!r}") - return json.dumps({"status": "success", "active": name}) + new_bot_type = f"custom:{provider_id}" + self._persist_custom_providers(providers, new_bot_type) + logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}") + return json.dumps({"status": "success", "active_id": provider_id}) def _handle_set_capability(self, data: dict) -> str: capability = (data.get("capability") or "").strip() diff --git a/config.py b/config.py index 5c12e4b0..6d81f5ff 100644 --- a/config.py +++ b/config.py @@ -26,10 +26,9 @@ available_setting = { "gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base "custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field "custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field - # Multiple custom (OpenAI-compatible) providers. When non-empty, supersedes the legacy custom_api_key/base above. - # Each item: {"name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"} + # Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:". + # Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"} "custom_providers": [], - "custom_active_provider": "", # name of the active provider in custom_providers; empty = use the first entry / legacy fields "proxy": "", # proxy used by openai # chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name "model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list @@ -325,24 +324,37 @@ class Config(dict): config = Config() +def _mask_value(val): + """Mask a sensitive string value, keeping first 3 and last 3 chars.""" + if not isinstance(val, str) or len(val) <= 8: + return val + return val[0:3] + "*" * 5 + val[-3:] + + +def _mask_sensitive_recursive(obj): + """Recursively mask values whose keys contain 'key' or 'secret'.""" + if isinstance(obj, dict): + masked = {} + for k, v in obj.items(): + if ("key" in k or "secret" in k) and isinstance(v, str): + masked[k] = _mask_value(v) + else: + masked[k] = _mask_sensitive_recursive(v) + return masked + elif isinstance(obj, list): + return [_mask_sensitive_recursive(item) for item in obj] + return obj + + def drag_sensitive(config): try: if isinstance(config, str): conf_dict: dict = json.loads(config) - conf_dict_copy = copy.deepcopy(conf_dict) - for key in conf_dict_copy: - if "key" in key or "secret" in key: - if isinstance(conf_dict_copy[key], str): - conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:] + conf_dict_copy = _mask_sensitive_recursive(conf_dict) return json.dumps(conf_dict_copy, indent=4) elif isinstance(config, dict): - config_copy = copy.deepcopy(config) - for key in config: - if "key" in key or "secret" in key: - if isinstance(config_copy[key], str): - config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:] - return config_copy + return _mask_sensitive_recursive(config) except Exception as e: logger.exception(e) return config diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index 8ce33d0e..b63f4e34 100644 --- a/docs/ja/models/custom.mdx +++ b/docs/ja/models/custom.mdx @@ -1,21 +1,21 @@ --- -title: カスタム -description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け +title: Custom +description: Custom vendor configuration for third-party API proxies and local models --- -OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば: +For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: -- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す -- **ローカルモデル**:Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル -- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス +- **Third-party API proxies**: call multiple models through a unified API base +- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI +- **Private deployments**: model services deployed inside an enterprise - `openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。 + Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. -## テキスト対話 +## Text Chat -### サードパーティ API プロキシ +### Third-party API proxy ```json { @@ -26,16 +26,16 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ } ``` -| パラメータ | 説明 | +| Parameter | Description | | --- | --- | -| `bot_type` | `custom` に設定する必要があります | -| `model` | モデル名。プロキシサービスがサポートする任意のモデル名を指定 | -| `custom_api_key` | API キー。プロキシサービスから提供されます | -| `custom_api_base` | API アドレス。プロキシサービスから提供され、OpenAI プロトコル互換である必要があります | +| `bot_type` | Must be set to `custom` | +| `model` | Model name; any model name supported by the proxy service | +| `custom_api_key` | API key provided by the proxy service | +| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | -### ローカルモデル +### Local models -ローカルモデルは通常 API Key が不要で、API Base のみ設定します: +Local models usually do not require an API key — only the API base needs to be filled in: ```json { @@ -45,38 +45,39 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ } ``` -一般的なローカルデプロイツールとデフォルトアドレス: +Common local deployment tools and their default endpoints: -| ツール | デフォルト API Base | +| Tool | Default API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### モデル切り替え +### Switching Models -カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません: +Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: ``` /config model qwen3.5:27b ``` -## 複数のカスタムベンダーを設定する +## Configuring Multiple Custom Providers -複数の OpenAI 互換サードパーティサービス(例:SiliconFlow、Qiniu)を同時に設定したい場合は、`custom_providers` リストを使用し、`custom_active_provider` で現在有効なベンダーを指定します: +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: ```json { - "bot_type": "custom", - "custom_active_provider": "siliconflow", + "bot_type": "custom:3f2a9c1b", "custom_providers": [ { + "id": "3f2a9c1b", "name": "siliconflow", "api_key": "YOUR_SILICONFLOW_KEY", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3" }, { + "id": "a1b2c3d4", "name": "qiniu", "api_key": "YOUR_QINIU_KEY", "api_base": "https://api.qnaigc.com/v1", @@ -86,11 +87,18 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ } ``` -| パラメータ | 説明 | +| Parameter | Description | | --- | --- | -| `custom_providers` | カスタムベンダーのリスト。各項目に `name`、`api_key`、`api_base`、任意の `model` を含む | -| `custom_active_provider` | 有効なベンダーの `name`。空の場合はリストの最初の項目が使用される | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` to activate a specific provider; `id` is the provider's unique identifier | +| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | +| `name` | User-facing display label — can be freely renamed without breaking anything | +| `model` | Optional; when set, this model is used instead of the global `model` field | - `custom_providers` が空の場合、上記の単一ベンダー設定 `custom_api_key` / `custom_api_base` に自動的にフォールバックするため、既存の設定はそのまま動作します。 + The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`). + + + + When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes. diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index d65b60b6..b63f4e34 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -63,20 +63,21 @@ Switching models under a custom vendor only changes `model` — `bot_type` and t ## Configuring Multiple Custom Providers -If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and select the active one with `custom_active_provider`: +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: ```json { - "bot_type": "custom", - "custom_active_provider": "siliconflow", + "bot_type": "custom:3f2a9c1b", "custom_providers": [ { + "id": "3f2a9c1b", "name": "siliconflow", "api_key": "YOUR_SILICONFLOW_KEY", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3" }, { + "id": "a1b2c3d4", "name": "qiniu", "api_key": "YOUR_QINIU_KEY", "api_base": "https://api.qnaigc.com/v1", @@ -88,9 +89,16 @@ If you need to configure several OpenAI-compatible third-party services at once | Parameter | Description | | --- | --- | -| `custom_providers` | List of custom providers; each item has `name`, `api_key`, `api_base`, and an optional `model` | -| `custom_active_provider` | The `name` of the active provider; when empty, the first entry in the list is used | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` to activate a specific provider; `id` is the provider's unique identifier | +| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | +| `name` | User-facing display label — can be freely renamed without breaking anything | +| `model` | Optional; when set, this model is used instead of the global `model` field | - When `custom_providers` is empty, CowAgent automatically falls back to the single-provider `custom_api_key` / `custom_api_base` fields above, so existing configurations keep working without any changes. + The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`). + + + + When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes. diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx index 69c71494..b63f4e34 100644 --- a/docs/zh/models/custom.mdx +++ b/docs/zh/models/custom.mdx @@ -1,21 +1,21 @@ --- -title: 自定义 -description: 自定义厂商配置,适用于第三方 API 代理和本地模型 +title: Custom +description: Custom vendor configuration for third-party API proxies and local models --- -适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如: +For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: -- **第三方 API 代理**:使用统一的 API Base 调用多种模型 -- **本地模型**:通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型 -- **私有化部署**:企业内部部署的模型服务 +- **Third-party API proxies**: call multiple models through a unified API base +- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI +- **Private deployments**: model services deployed inside an enterprise - 与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。 + Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. -## 文本对话 +## Text Chat -### 第三方 API 代理 +### Third-party API proxy ```json { @@ -26,16 +26,16 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `bot_type` | 必须设为 `custom` | -| `model` | 模型名称,填写代理服务支持的任意模型名 | -| `custom_api_key` | API 密钥,由代理服务提供 | -| `custom_api_base` | API 地址,由代理服务提供,需兼容 OpenAI 协议 | +| `bot_type` | Must be set to `custom` | +| `model` | Model name; any model name supported by the proxy service | +| `custom_api_key` | API key provided by the proxy service | +| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | -### 本地模型 +### Local models -本地模型通常不需要 API Key,只需填写 API Base: +Local models usually do not require an API key — only the API base needs to be filled in: ```json { @@ -45,38 +45,39 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -常见的本地部署工具及默认地址: +Common local deployment tools and their default endpoints: -| 工具 | 默认 API Base | +| Tool | Default API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### 切换模型 +### Switching Models -自定义厂商下切换模型时,只会修改 `model`,不会改变 `bot_type` 和 API 地址: +Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: ``` /config model qwen3.5:27b ``` -## 配置多个自定义厂商 +## Configuring Multiple Custom Providers -如果需要同时配置多个第三方 OpenAI 兼容服务(例如硅基流动、七牛云等),可以使用 `custom_providers` 列表,并通过 `custom_active_provider` 指定当前生效的厂商: +If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: ```json { - "bot_type": "custom", - "custom_active_provider": "siliconflow", + "bot_type": "custom:3f2a9c1b", "custom_providers": [ { + "id": "3f2a9c1b", "name": "siliconflow", "api_key": "YOUR_SILICONFLOW_KEY", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3" }, { + "id": "a1b2c3d4", "name": "qiniu", "api_key": "YOUR_QINIU_KEY", "api_base": "https://api.qnaigc.com/v1", @@ -86,11 +87,18 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模 } ``` -| 参数 | 说明 | +| Parameter | Description | | --- | --- | -| `custom_providers` | 自定义厂商列表,每项包含 `name`、`api_key`、`api_base`、可选的 `model` | -| `custom_active_provider` | 当前生效厂商的 `name`;留空时默认使用列表中的第一个厂商 | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | +| `bot_type` | Set to `"custom:"` to activate a specific provider; `id` is the provider's unique identifier | +| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | +| `name` | User-facing display label — can be freely renamed without breaking anything | +| `model` | Optional; when set, this model is used instead of the global `model` field | - 当 `custom_providers` 为空时,将自动回退到上文的 `custom_api_key` / `custom_api_base` 单厂商配置,已有配置无需改动即可正常工作。 + The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`). + + + + When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes. diff --git a/models/bot_factory.py b/models/bot_factory.py index 5d07a236..415e9229 100644 --- a/models/bot_factory.py +++ b/models/bot_factory.py @@ -29,7 +29,7 @@ def create_bot(bot_type): from models.mimo.mimo_bot import MimoBot return MimoBot() - elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API + elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM) or bot_type.startswith("custom:"): # OpenAI-compatible API from models.chatgpt.chat_gpt_bot import ChatGPTBot return ChatGPTBot() diff --git a/models/chatgpt/chat_gpt_bot.py b/models/chatgpt/chat_gpt_bot.py index 991ca646..1bc2a9da 100644 --- a/models/chatgpt/chat_gpt_bot.py +++ b/models/chatgpt/chat_gpt_bot.py @@ -17,7 +17,7 @@ from common import const from common.i18n import t as _t from models.bot import Bot from models.openai_compatible_bot import OpenAICompatibleBot -from models.custom_provider import resolve_custom_credentials +from models.custom_provider import resolve_custom_credentials, parse_custom_bot_type from models.chatgpt.chat_gpt_session import ChatGPTSession from models.openai.open_ai_image import OpenAIImage from models.session_manager import SessionManager @@ -33,10 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def __init__(self): super().__init__() # Resolve api key / base from config (no global SDK state anymore). - if conf().get("bot_type") == "custom": - # Supports multiple custom providers (custom_providers) with - # automatic fallback to the legacy custom_api_key/base fields. - self._api_key, self._api_base, _ = resolve_custom_credentials() + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) + custom_model = None + if is_custom: + # Supports multiple custom providers via bot_type "custom:" + # with automatic fallback to the legacy custom_api_key/base fields. + self._api_key, self._api_base, custom_model = resolve_custom_credentials() else: self._api_key = conf().get("open_ai_api_key") self._api_base = conf().get("open_ai_api_base") or None @@ -48,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): ) if conf().get("rate_limit_chatgpt"): self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20)) - conf_model = conf().get("model") or "gpt-3.5-turbo" - self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo") + # Per-provider model takes precedence over global model. + conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo" + self.sessions = SessionManager(ChatGPTSession, model=conf_model) # o1相关模型不支持system prompt,暂时用文心模型的session self.args = { @@ -72,7 +75,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def get_api_config(self): """Get API configuration for OpenAI-compatible base class""" - is_custom = conf().get("bot_type") == "custom" + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) if is_custom: custom_key, custom_base, custom_model = resolve_custom_credentials() api_key = custom_key @@ -196,7 +199,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): mime_type = mime_type_map.get(extension, "image/jpeg") # Get model and API config - is_custom = conf().get("bot_type") == "custom" + is_custom, _ = parse_custom_bot_type(conf().get("bot_type", "")) if is_custom: custom_key, custom_base, custom_model = resolve_custom_credentials() model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o") diff --git a/models/custom_provider.py b/models/custom_provider.py index a1570f7a..f6db2d34 100644 --- a/models/custom_provider.py +++ b/models/custom_provider.py @@ -13,69 +13,110 @@ Config model - ``custom_providers``: list of dicts, each describing one custom provider:: { - "name": "siliconflow", # unique, user-facing identifier - "api_key": "sk-...", # required - "api_base": "https://...", # required, must be OpenAI-compatible + "id": "3f2a9c1b", # server-generated short uuid (primary key) + "name": "siliconflow", # user-facing display label (not a key) + "api_key": "sk-...", # required + "api_base": "https://...", # required, must be OpenAI-compatible "model": "deepseek-ai/DeepSeek-V3" # optional default model } -- ``custom_active_provider``: the ``name`` of the provider to use. When empty - (or pointing to a non-existent name) we fall back to the first provider in - the list, and finally to the legacy ``custom_api_key`` / ``custom_api_base``. +Routing +------- +- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``. +- ``bot_type: "custom:"`` (multi-provider): looks up the provider by id in + ``custom_providers``. There is a single source of truth — no separate + ``custom_active_provider`` field. Backward-compatibility contract ------------------------------- -When ``custom_providers`` is empty, ``resolve_custom_credentials`` returns -exactly the legacy ``custom_api_key`` / ``custom_api_base`` values, so existing -deployments behave byte-for-byte identically. +When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is +unchanged: we return ``custom_api_key`` / ``custom_api_base`` values. """ +import uuid from config import conf from common.log import logger +def generate_provider_id() -> str: + """Generate a short random id for a new custom provider.""" + return uuid.uuid4().hex[:8] + + def get_custom_providers(): """Return the list of configured custom providers (always a list).""" providers = conf().get("custom_providers") if not isinstance(providers, list): return [] - # Keep only well-formed entries with a name. - return [p for p in providers if isinstance(p, dict) and p.get("name")] + # Keep only well-formed entries with an id. + return [p for p in providers if isinstance(p, dict) and p.get("id")] -def _find_active_provider(providers): - """Pick the active provider from the list, or None when list is empty.""" - if not providers: +def _find_provider_by_id(providers, provider_id): + """Look up a provider by its id, or None if not found.""" + if not providers or not provider_id: return None - active_name = conf().get("custom_active_provider") or "" - if active_name: - for p in providers: - if p.get("name") == active_name: - return p - logger.warning( - "[CUSTOM] active provider '%s' not found in custom_providers, " - "falling back to the first entry", active_name - ) - return providers[0] + for p in providers: + if p.get("id") == provider_id: + return p + return None + + +def parse_custom_bot_type(bot_type): + """Parse bot_type to extract custom provider id. + + Returns: + (is_custom, provider_id) where: + - is_custom: True if bot_type starts with "custom" + - provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode + """ + if not bot_type or not isinstance(bot_type, str): + return False, "" + if bot_type == "custom": + return True, "" + if bot_type.startswith("custom:"): + return True, bot_type[7:] # len("custom:") == 7 + return False, "" def resolve_custom_credentials(): """Resolve the effective (api_key, api_base, model) for custom mode. Resolution order: - 1. The active entry in ``custom_providers`` (multi-provider mode). - 2. The legacy flat keys ``custom_api_key`` / ``custom_api_base``. + 1. If ``bot_type`` is ``"custom:"``, look up that id in + ``custom_providers``. + 2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat + ``custom_api_key`` / ``custom_api_base``. :return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model`` may be ``None`` / empty when not configured. """ - provider = _find_active_provider(get_custom_providers()) - if provider is not None: + bot_type = conf().get("bot_type", "") + is_custom, provider_id = parse_custom_bot_type(bot_type) + + if not is_custom: + # Not custom at all — should not happen but be defensive. return ( - provider.get("api_key", ""), - provider.get("api_base") or None, - provider.get("model") or None, + conf().get("open_ai_api_key", ""), + conf().get("open_ai_api_base") or None, + None, ) + + if provider_id: + # Multi-provider mode: look up by id. + providers = get_custom_providers() + provider = _find_provider_by_id(providers, provider_id) + if provider is not None: + return ( + provider.get("api_key", ""), + provider.get("api_base") or None, + provider.get("model") or None, + ) + logger.warning( + "[CUSTOM] provider id '%s' not found in custom_providers, " + "falling back to legacy fields", provider_id + ) + # Legacy single-provider fallback — unchanged behavior. return ( conf().get("custom_api_key", ""), diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index 26df3bfa..fcd5dceb 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -4,8 +4,9 @@ Unit tests for multiple custom (OpenAI-compatible) provider support (issue #2838 Covers models/custom_provider.py: - Backward compatibility: legacy custom_api_key / custom_api_base fallback - - Multi-provider selection via custom_providers / custom_active_provider - - Robustness against malformed config (missing name, non-dict, non-list) + - Multi-provider selection via bot_type "custom:" routing + - parse_custom_bot_type helper + - Robustness against malformed config (missing id, non-dict, non-list) """ import sys import os @@ -23,11 +24,43 @@ def set_conf(d): config_module.config = Config(d) +class TestParseCustomBotType(unittest.TestCase): + """parse_custom_bot_type() parsing logic.""" + + def setUp(self): + from models.custom_provider import parse_custom_bot_type + self.parse = parse_custom_bot_type + + def test_legacy_custom(self): + is_custom, pid = self.parse("custom") + self.assertTrue(is_custom) + self.assertEqual(pid, "") + + def test_custom_with_id(self): + is_custom, pid = self.parse("custom:3f2a9c1b") + self.assertTrue(is_custom) + self.assertEqual(pid, "3f2a9c1b") + + def test_non_custom(self): + is_custom, pid = self.parse("openai") + self.assertFalse(is_custom) + self.assertEqual(pid, "") + + def test_empty(self): + is_custom, pid = self.parse("") + self.assertFalse(is_custom) + self.assertEqual(pid, "") + + def test_none(self): + is_custom, pid = self.parse(None) + self.assertFalse(is_custom) + self.assertEqual(pid, "") + + class TestResolveCustomCredentials(unittest.TestCase): """resolve_custom_credentials() resolution order and fallbacks.""" def setUp(self): - # Import here so the module picks up our config-swapping helper. from models.custom_provider import resolve_custom_credentials, get_custom_providers self.resolve = resolve_custom_credentials self.get_providers = get_custom_providers @@ -49,31 +82,15 @@ class TestResolveCustomCredentials(unittest.TestCase): set_conf({"bot_type": "custom"}) self.assertEqual(self.resolve(), ("", None, None)) - # --- Multi-provider selection --- + # --- Multi-provider selection via bot_type --- - def test_multi_providers_no_active_uses_first(self): + def test_provider_selected_by_id(self): set_conf({ - "bot_type": "custom", + "bot_type": "custom:abc12345", "custom_providers": [ - {"name": "siliconflow", "api_key": "sf-key", + {"id": "sf001", "name": "siliconflow", "api_key": "sf-key", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}, - {"name": "qiniu", "api_key": "qn-key", - "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, - ], - }) - self.assertEqual( - self.resolve(), - ("sf-key", "https://api.siliconflow.cn/v1", "deepseek-ai/DeepSeek-V3"), - ) - - def test_active_provider_selected(self): - set_conf({ - "bot_type": "custom", - "custom_active_provider": "qiniu", - "custom_providers": [ - {"name": "siliconflow", "api_key": "sf-key", - "api_base": "https://api.siliconflow.cn/v1", "model": "m1"}, - {"name": "qiniu", "api_key": "qn-key", + {"id": "abc12345", "name": "qiniu", "api_key": "qn-key", "api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"}, ], }) @@ -82,25 +99,26 @@ class TestResolveCustomCredentials(unittest.TestCase): ("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"), ) - def test_active_name_missing_falls_back_to_first(self): + def test_id_not_found_falls_back_to_legacy(self): set_conf({ - "bot_type": "custom", - "custom_active_provider": "ghost", + "bot_type": "custom:ghost", + "custom_api_key": "legacy-key", + "custom_api_base": "https://legacy.example.com/v1", "custom_providers": [ - {"name": "siliconflow", "api_key": "sf-key", + {"id": "sf001", "name": "siliconflow", "api_key": "sf-key", "api_base": "https://api.siliconflow.cn/v1"}, ], }) self.assertEqual( self.resolve(), - ("sf-key", "https://api.siliconflow.cn/v1", None), + ("legacy-key", "https://legacy.example.com/v1", None), ) def test_provider_without_model_returns_none_model(self): set_conf({ - "bot_type": "custom", + "bot_type": "custom:local01", "custom_providers": [ - {"name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"}, + {"id": "local01", "name": "local", "api_key": "", "api_base": "http://localhost:11434/v1"}, ], }) self.assertEqual( @@ -112,12 +130,12 @@ class TestResolveCustomCredentials(unittest.TestCase): def test_malformed_entries_filtered_and_fallback(self): set_conf({ - "bot_type": "custom", + "bot_type": "custom:nope", "custom_api_key": "legacy-key", "custom_api_base": "https://legacy.example.com/v1", "custom_providers": [ - {"api_key": "no-name-key"}, # invalid: no name - "not-a-dict", # invalid: wrong type + {"name": "no-id", "api_key": "no-id-key"}, # invalid: no id + "not-a-dict", # invalid: wrong type ], }) # All entries invalid -> treated as empty -> legacy fallback @@ -130,14 +148,14 @@ class TestResolveCustomCredentials(unittest.TestCase): set_conf({ "bot_type": "custom", "custom_providers": [ - {"name": "ok", "api_key": "k", "api_base": "https://x/v1"}, - {"api_key": "no-name"}, # dropped - 123, # dropped + {"id": "ok1", "name": "ok", "api_key": "k", "api_base": "https://x/v1"}, + {"name": "no-id", "api_key": "no-id"}, # dropped: no id + 123, # dropped ], }) providers = self.get_providers() self.assertEqual(len(providers), 1) - self.assertEqual(providers[0]["name"], "ok") + self.assertEqual(providers[0]["id"], "ok1") def test_custom_providers_not_a_list_falls_back(self): set_conf({ @@ -152,6 +170,22 @@ class TestResolveCustomCredentials(unittest.TestCase): ) +class TestGenerateProviderId(unittest.TestCase): + """generate_provider_id() produces valid short ids.""" + + def test_length_and_hex(self): + from models.custom_provider import generate_provider_id + pid = generate_provider_id() + self.assertEqual(len(pid), 8) + # Must be valid hex characters + int(pid, 16) + + def test_uniqueness(self): + from models.custom_provider import generate_provider_id + ids = {generate_provider_id() for _ in range(100)} + self.assertEqual(len(ids), 100) + + class TestConfigDefaults(unittest.TestCase): """The new config fields must exist with safe defaults.""" @@ -160,10 +194,46 @@ class TestConfigDefaults(unittest.TestCase): self.assertIn("custom_providers", available_setting) self.assertEqual(available_setting["custom_providers"], []) - def test_default_config_has_active_provider(self): + def test_default_config_no_custom_active_provider(self): + """custom_active_provider was removed — replaced by bot_type routing.""" from config import available_setting - self.assertIn("custom_active_provider", available_setting) - self.assertEqual(available_setting["custom_active_provider"], "") + self.assertNotIn("custom_active_provider", available_setting) + + +class TestDragSensitiveNested(unittest.TestCase): + """drag_sensitive() must mask api_key in nested structures.""" + + def test_nested_api_key_masked(self): + from config import drag_sensitive + import json + test_config = { + "open_ai_api_key": "sk-1234567890abcdef", + "custom_providers": [ + {"id": "x1", "name": "test", "api_key": "sk-nested-secret-key-long", "api_base": "https://x/v1"} + ], + } + result = drag_sensitive(test_config) + # Top-level key should be masked + self.assertNotIn("1234567890abcdef", str(result)) + # Nested key should also be masked + self.assertNotIn("nested-secret-key-long", str(result)) + # But the id/name/api_base should not be masked + self.assertIn("x1", str(result)) + self.assertIn("test", str(result)) + self.assertIn("https://x/v1", str(result)) + + def test_string_config_masked(self): + from config import drag_sensitive + import json + test_str = json.dumps({ + "open_ai_api_key": "sk-1234567890abcdef", + "custom_providers": [ + {"id": "x1", "api_key": "sk-nested-very-long-secret"} + ], + }) + result = drag_sensitive(test_str) + self.assertNotIn("1234567890abcdef", result) + self.assertNotIn("nested-very-long-secret", result) if __name__ == "__main__": diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py index 1420776d..f7792261 100644 --- a/tests/test_custom_provider_handlers.py +++ b/tests/test_custom_provider_handlers.py @@ -4,14 +4,11 @@ Unit tests for the multi custom-provider management API (issue #2838, web UI). Covers channel/web/web_channel.py::ModelsHandler: - _custom_provider_cards / _provider_overview expansion - - _handle_set_custom_provider (create / edit / rename / activate) + - _handle_set_custom_provider (create / edit / activate) - _handle_delete_custom_provider - _handle_set_active_custom_provider -These handlers are normally driven by the `web.py` framework, which isn't -available in the headless test environment, so we stub the `web` module before -import. The on-disk config read/write and the Bridge reset are patched to keep -the tests hermetic (no file I/O, no live bot routing). +Uses id-based routing (bot_type: "custom:") — no custom_active_provider. """ import json import os @@ -72,7 +69,7 @@ class _HandlerHarness: class TestSetCustomProvider(unittest.TestCase): def setUp(self): - set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() def test_create_first_provider_auto_activates(self): @@ -80,11 +77,15 @@ class TestSetCustomProvider(unittest.TestCase): api_base="https://api.siliconflow.cn/v1", api_key="sf-key") self.assertEqual(res["status"], "success") self.assertTrue(res["created"]) - self.assertEqual(res["active"], "siliconflow") + self.assertIn("id", res) + # bot_type should be updated to "custom:" + bot_type = config_module.conf().get("bot_type") + self.assertTrue(bot_type.startswith("custom:")) + self.assertEqual(bot_type, f"custom:{res['id']}") providers = config_module.conf().get("custom_providers") self.assertEqual(len(providers), 1) + self.assertEqual(providers[0]["id"], res["id"]) self.assertEqual(providers[0]["name"], "siliconflow") - self.assertEqual(config_module.conf().get("custom_active_provider"), "siliconflow") self.assertEqual(self.h.bridge_resets, 1) def test_create_requires_api_base(self): @@ -97,161 +98,158 @@ class TestSetCustomProvider(unittest.TestCase): self.assertEqual(res["status"], "error") def test_second_provider_does_not_steal_active(self): - self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="ak") - res = self.h.call(action="set_custom_provider", name="b", - api_base="https://b/v1", api_key="bk") - self.assertTrue(res["created"]) + res1 = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + res2 = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") + self.assertTrue(res2["created"]) # First provider stays active unless make_active is requested. - self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, f"custom:{res1['id']}") def test_make_active_flag(self): self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") - self.h.call(action="set_custom_provider", name="b", - api_base="https://b/v1", api_key="bk", make_active=True) - self.assertEqual(config_module.conf().get("custom_active_provider"), "b") - - def test_duplicate_name_rejected(self): - self.h.call(action="set_custom_provider", name="dup", - api_base="https://a/v1", api_key="ak") - res = self.h.call(action="set_custom_provider", name="dup", - api_base="https://b/v1", api_key="bk") - self.assertEqual(res["status"], "error") - self.assertIn("already exists", res["message"]) - # The original entry must be untouched. - providers = config_module.conf().get("custom_providers") - self.assertEqual(len(providers), 1) - self.assertEqual(providers[0]["api_base"], "https://a/v1") + res2 = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk", make_active=True) + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, f"custom:{res2['id']}") def test_edit_keeps_key_when_omitted(self): - self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="secret") - # Edit only the base; omit api_key. res = self.h.call(action="set_custom_provider", name="a", - original_name="a", api_base="https://a2/v1") - self.assertEqual(res["status"], "success") - self.assertFalse(res["created"]) + api_base="https://a/v1", api_key="secret") + pid = res["id"] + # Edit only the base; omit api_key. + res2 = self.h.call(action="set_custom_provider", name="a", + id=pid, api_base="https://a2/v1") + self.assertEqual(res2["status"], "success") + self.assertFalse(res2["created"]) providers = config_module.conf().get("custom_providers") self.assertEqual(providers[0]["api_base"], "https://a2/v1") self.assertEqual(providers[0]["api_key"], "secret") # preserved - def test_rename_updates_active_pointer(self): - self.h.call(action="set_custom_provider", name="old", - api_base="https://a/v1", api_key="ak") - self.assertEqual(config_module.conf().get("custom_active_provider"), "old") - res = self.h.call(action="set_custom_provider", name="new", - original_name="old", api_base="https://a/v1") - self.assertEqual(res["status"], "success") - self.assertEqual(config_module.conf().get("custom_active_provider"), "new") - names = [p["name"] for p in config_module.conf().get("custom_providers")] - self.assertEqual(names, ["new"]) + def test_edit_can_rename(self): + res = self.h.call(action="set_custom_provider", name="old", + api_base="https://a/v1", api_key="ak") + pid = res["id"] + res2 = self.h.call(action="set_custom_provider", name="new", + id=pid, api_base="https://a/v1") + self.assertEqual(res2["status"], "success") + providers = config_module.conf().get("custom_providers") + self.assertEqual(providers[0]["name"], "new") + # ID stays the same + self.assertEqual(providers[0]["id"], pid) def test_edit_clears_model_when_empty(self): - self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="ak", model="m1") + res = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak", model="m1") + pid = res["id"] self.assertEqual(config_module.conf().get("custom_providers")[0]["model"], "m1") - self.h.call(action="set_custom_provider", name="a", original_name="a", + self.h.call(action="set_custom_provider", name="a", id=pid, api_base="https://a/v1", model="") self.assertNotIn("model", config_module.conf().get("custom_providers")[0]) class TestDeleteCustomProvider(unittest.TestCase): def setUp(self): - set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() - self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") - self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") + self.res_a = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + self.res_b = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") def test_delete_unknown(self): - res = self.h.call(action="delete_custom_provider", name="ghost") + res = self.h.call(action="delete_custom_provider", id="ghost") self.assertEqual(res["status"], "error") def test_delete_non_active(self): - res = self.h.call(action="delete_custom_provider", name="b") + res = self.h.call(action="delete_custom_provider", id=self.res_b["id"]) self.assertEqual(res["status"], "success") - names = [p["name"] for p in config_module.conf().get("custom_providers")] - self.assertEqual(names, ["a"]) - self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + ids = [p["id"] for p in config_module.conf().get("custom_providers")] + self.assertEqual(ids, [self.res_a["id"]]) + # bot_type unchanged (still pointing to a) + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") def test_delete_active_falls_back_to_first_remaining(self): # 'a' is active (created first); deleting it should re-point to 'b'. - self.assertEqual(config_module.conf().get("custom_active_provider"), "a") - res = self.h.call(action="delete_custom_provider", name="a") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") + res = self.h.call(action="delete_custom_provider", id=self.res_a["id"]) self.assertEqual(res["status"], "success") - self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}") - def test_delete_last_clears_active(self): - self.h.call(action="delete_custom_provider", name="a") - self.h.call(action="delete_custom_provider", name="b") + def test_delete_last_reverts_to_legacy(self): + self.h.call(action="delete_custom_provider", id=self.res_a["id"]) + self.h.call(action="delete_custom_provider", id=self.res_b["id"]) self.assertEqual(config_module.conf().get("custom_providers"), []) - self.assertEqual(config_module.conf().get("custom_active_provider"), "") + # When all providers deleted, reverts to legacy "custom" + self.assertEqual(config_module.conf().get("bot_type"), "custom") class TestSetActiveCustomProvider(unittest.TestCase): def setUp(self): - set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() - self.h.call(action="set_custom_provider", name="a", api_base="https://a/v1", api_key="ak") - self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") + self.res_a = self.h.call(action="set_custom_provider", name="a", + api_base="https://a/v1", api_key="ak") + self.res_b = self.h.call(action="set_custom_provider", name="b", + api_base="https://b/v1", api_key="bk") def test_set_active_valid(self): - res = self.h.call(action="set_active_custom_provider", name="b") + res = self.h.call(action="set_active_custom_provider", id=self.res_b["id"]) self.assertEqual(res["status"], "success") - self.assertEqual(config_module.conf().get("custom_active_provider"), "b") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_b['id']}") def test_set_active_unknown(self): - res = self.h.call(action="set_active_custom_provider", name="ghost") + res = self.h.call(action="set_active_custom_provider", id="ghost") self.assertEqual(res["status"], "error") - self.assertEqual(config_module.conf().get("custom_active_provider"), "a") + # bot_type unchanged + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") class TestProviderOverviewExpansion(unittest.TestCase): """_provider_overview / _custom_provider_cards should expand the list.""" def test_no_custom_providers_keeps_single_card(self): - set_conf({"bot_type": "custom", "custom_providers": [], "custom_active_provider": ""}) + set_conf({"bot_type": "custom", "custom_providers": []}) cards = ModelsHandler._custom_provider_cards(config_module.conf()) self.assertEqual(cards, []) overview = ModelsHandler._provider_overview() custom_cards = [c for c in overview if c.get("id") == "custom"] # Legacy single custom card remains present. self.assertEqual(len(custom_cards), 1) - self.assertTrue(custom_cards[0].get("is_custom")) def test_multi_providers_expand_into_cards(self): set_conf({ - "bot_type": "custom", - "custom_active_provider": "b", + "bot_type": "custom:id_b", "custom_providers": [ - {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, - {"name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"}, + {"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1", "model": "m"}, ], }) overview = ModelsHandler._provider_overview() custom_cards = [c for c in overview if c.get("is_custom")] self.assertEqual(len(custom_cards), 2) - by_name = {c["custom_name"]: c for c in custom_cards} - self.assertEqual(by_name["a"]["id"], "custom:a") - self.assertFalse(by_name["a"]["active"]) - self.assertTrue(by_name["b"]["active"]) - self.assertEqual(by_name["b"]["model"], "m") + by_id = {c["custom_id"]: c for c in custom_cards} + self.assertEqual(by_id["id_a"]["id"], "custom:id_a") + self.assertFalse(by_id["id_a"]["active"]) + self.assertTrue(by_id["id_b"]["active"]) + self.assertEqual(by_id["id_b"]["model"], "m") # No single legacy "custom" card when expanded. self.assertFalse(any(c.get("id") == "custom" for c in overview)) - def test_active_defaults_to_first_when_unset(self): + def test_no_active_shows_none_active(self): + """When bot_type is plain 'custom', no card is marked active.""" set_conf({ "bot_type": "custom", - "custom_active_provider": "", "custom_providers": [ - {"name": "a", "api_key": "ak", "api_base": "https://a/v1"}, - {"name": "b", "api_key": "bk", "api_base": "https://b/v1"}, + {"id": "id_a", "name": "a", "api_key": "ak", "api_base": "https://a/v1"}, + {"id": "id_b", "name": "b", "api_key": "bk", "api_base": "https://b/v1"}, ], }) cards = ModelsHandler._custom_provider_cards(config_module.conf()) - by_name = {c["custom_name"]: c for c in cards} - self.assertTrue(by_name["a"]["active"]) - self.assertFalse(by_name["b"]["active"]) + active_cards = [c for c in cards if c.get("active")] + self.assertEqual(len(active_cards), 0) if __name__ == "__main__": From e85290cddcbb5ffc9c235927f4c92e5b4c3ec264 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Thu, 11 Jun 2026 17:40:41 +0800 Subject: [PATCH 355/399] fix(security): SSRF protection for vision tool + path traversal guard for skill install 1. Vision SSRF (#2878, #2872): Add _validate_url_safe() that resolves the target hostname via DNS and rejects any IP in private (RFC1918), loopback, link-local, or reserved ranges before requests.get() is called. This blocks attacks that use attacker-controlled image URLs to probe internal services or cloud metadata endpoints (169.254.169.254). 2. Skill install path traversal (#2873): Add _safe_skill_dir() that validates the skill name cannot escape the skills/ root directory. Rejects names containing '..', absolute paths, and any resolved path that falls outside the custom_dir boundary. Applied to _add_url(), _add_package(), and delete(). Both fixes include comprehensive unit tests (19 test cases) covering blocked patterns, edge cases, and allowed legitimate usage. Closes #2878 Closes #2873 Ref: #2872 --- agent/skills/service.py | 27 ++- agent/tools/vision/vision.py | 38 ++++ tests/test_security_ssrf_path_traversal.py | 194 +++++++++++++++++++++ 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 tests/test_security_ssrf_path_traversal.py diff --git a/agent/skills/service.py b/agent/skills/service.py index a34a546f..95cfb9bb 100644 --- a/agent/skills/service.py +++ b/agent/skills/service.py @@ -34,6 +34,27 @@ class SkillService: """ self.manager = skill_manager + def _safe_skill_dir(self, name: str) -> str: + """Derive and validate the skill directory path. + + Ensures the resolved path stays within the custom_dir root, + preventing path traversal via names like ``../escaped``. + + :raises ValueError: if the name would escape the skills root. + """ + if not name or not name.strip(): + raise ValueError("skill name is required") + # Reject obvious traversal components. + if ".." in name or name.startswith("/") or name.startswith("\\"): + raise ValueError(f"invalid skill name (path traversal detected): {name!r}") + skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name)) + root = os.path.realpath(self.manager.custom_dir) + if not skill_dir.startswith(root + os.sep) and skill_dir != root: + raise ValueError( + f"skill name {name!r} resolves outside the skills directory" + ) + return skill_dir + # ------------------------------------------------------------------ # query # ------------------------------------------------------------------ @@ -107,7 +128,7 @@ class SkillService: if not files: raise ValueError("skill files list is empty") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) tmp_dir = skill_dir + ".tmp" if os.path.exists(tmp_dir): @@ -146,7 +167,7 @@ class SkillService: raise ValueError("package url is required") url = files[0]["url"] - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) with tempfile.TemporaryDirectory() as tmp_dir: zip_path = os.path.join(tmp_dir, "package.zip") @@ -217,7 +238,7 @@ class SkillService: if not name: raise ValueError("skill name is required") - skill_dir = os.path.join(self.manager.custom_dir, name) + skill_dir = self._safe_skill_dir(name) if os.path.exists(skill_dir): shutil.rmtree(skill_dir) logger.info(f"[SkillService] delete: removed directory {skill_dir}") diff --git a/agent/tools/vision/vision.py b/agent/tools/vision/vision.py index 19878f6d..170d2175 100644 --- a/agent/tools/vision/vision.py +++ b/agent/tools/vision/vision.py @@ -17,11 +17,14 @@ Provider resolution: """ import base64 +import ipaddress import os +import socket import subprocess import tempfile from dataclasses import dataclass, field from typing import Any, Dict, List, Optional +from urllib.parse import urlparse import requests @@ -654,6 +657,40 @@ class Vision(BaseTool): return api_base return api_base.rstrip("/") + "/v1" + @staticmethod + def _validate_url_safe(url: str) -> None: + """Reject URLs that target private/loopback/link-local addresses (SSRF guard). + + Resolves the hostname to its IP address(es) and blocks any that fall + into non-public ranges. Also rejects URLs with no host, non-HTTP(S) + schemes, or hosts that fail DNS resolution. + + Raises: + ValueError: if the URL targets a disallowed address. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Unsupported URL scheme: {parsed.scheme}") + + hostname = parsed.hostname + if not hostname: + raise ValueError("URL has no hostname") + + try: + # Resolve all addresses for the hostname. + addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + raise ValueError(f"Cannot resolve hostname: {hostname}") + + for family, _, _, _, sockaddr in addr_infos: + ip_str = sockaddr[0] + ip = ipaddress.ip_address(ip_str) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ValueError( + f"URL resolves to a non-public address ({ip_str}), " + f"request blocked for security" + ) + def _build_image_content(self, image: str) -> dict: """ Build the image_url content block. @@ -661,6 +698,7 @@ class Vision(BaseTool): so every bot backend can consume them without extra downloads. """ if image.startswith(("http://", "https://")): + self._validate_url_safe(image) return self._download_to_data_url(image) if not os.path.isfile(image): diff --git a/tests/test_security_ssrf_path_traversal.py b/tests/test_security_ssrf_path_traversal.py new file mode 100644 index 00000000..6c2dbb09 --- /dev/null +++ b/tests/test_security_ssrf_path_traversal.py @@ -0,0 +1,194 @@ +# encoding:utf-8 +""" +Unit tests for security fixes: + 1. Vision tool SSRF protection (issue #2878, #2872) + 2. Skill service path traversal protection (issue #2873) +""" +import os +import sys +import tempfile +import types +import unittest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Stub 'requests' if not installed so vision.py can be imported for testing. +if "requests" not in sys.modules: + _requests_stub = types.ModuleType("requests") + _requests_stub.get = lambda *a, **k: None + sys.modules["requests"] = _requests_stub + + +# ============================================================================= +# Vision SSRF tests +# ============================================================================= + +class TestVisionSSRFValidation(unittest.TestCase): + """Test that _validate_url_safe blocks internal/private URLs.""" + + def setUp(self): + from agent.tools.vision.vision import Vision + self.validate = Vision._validate_url_safe + + def test_loopback_ipv4_blocked(self): + """127.0.0.1 must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("http://127.0.0.1/canary.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_loopback_localhost_blocked(self): + """localhost must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("http://localhost/canary.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_private_10_network_blocked(self): + """10.x.x.x RFC1918 must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("10.0.0.1", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://internal.corp/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_private_172_network_blocked(self): + """172.16.x.x RFC1918 must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("172.16.0.1", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://internal.corp/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_private_192_168_blocked(self): + """192.168.x.x RFC1918 must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("192.168.1.1", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://router.local/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_link_local_blocked(self): + """169.254.x.x (link-local / cloud metadata) must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("169.254.169.254", 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://metadata.google.internal/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_ipv6_loopback_blocked(self): + """::1 (IPv6 loopback) must be rejected.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (10, 1, 6, "", ("::1", 0, 0, 0)), + ] + with self.assertRaises(ValueError) as ctx: + self.validate("http://[::1]/image.png") + self.assertIn("non-public", str(ctx.exception)) + + def test_public_url_allowed(self): + """A URL resolving to a public IP should pass validation.""" + with patch("socket.getaddrinfo") as mock_gai: + mock_gai.return_value = [ + (2, 1, 6, "", ("151.101.1.140", 0)), + ] + # Should not raise + self.validate("https://cdn.example.com/image.png") + + def test_no_hostname_rejected(self): + """A URL with no host must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("http:///path/to/image.png") + self.assertIn("no hostname", str(ctx.exception)) + + def test_non_http_scheme_rejected(self): + """file:// and ftp:// schemes must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.validate("file:///etc/passwd") + self.assertIn("scheme", str(ctx.exception)) + + def test_dns_failure_rejected(self): + """Unresolvable hostname must be rejected.""" + import socket as sock_mod + with patch("socket.getaddrinfo", side_effect=sock_mod.gaierror("Name does not resolve")): + with self.assertRaises(ValueError) as ctx: + self.validate("http://nonexistent.invalid/img.png") + self.assertIn("Cannot resolve", str(ctx.exception)) + + +# ============================================================================= +# Skill service path traversal tests +# ============================================================================= + +class TestSkillServicePathTraversal(unittest.TestCase): + """Test that _safe_skill_dir blocks path traversal attempts.""" + + def setUp(self): + self.tmp_root = tempfile.mkdtemp() + # Create a minimal SkillManager mock with custom_dir set. + from agent.skills.service import SkillService + mock_manager = MagicMock() + mock_manager.custom_dir = self.tmp_root + self.svc = SkillService(mock_manager) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp_root, ignore_errors=True) + + def test_normal_name_allowed(self): + """A simple name like 'my-skill' should produce a valid path.""" + result = self.svc._safe_skill_dir("my-skill") + expected = os.path.realpath(os.path.join(self.tmp_root, "my-skill")) + self.assertEqual(result, expected) + + def test_dotdot_traversal_blocked(self): + """'../escaped' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("../escaped") + self.assertIn("path traversal", str(ctx.exception)) + + def test_nested_dotdot_blocked(self): + """'foo/../../escaped' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("foo/../../escaped") + self.assertIn("path traversal", str(ctx.exception)) + + def test_absolute_path_blocked(self): + """'/tmp/evil' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("/tmp/evil") + self.assertIn("path traversal", str(ctx.exception)) + + def test_backslash_path_blocked(self): + r"""'\\server\share' must be rejected.""" + with self.assertRaises(ValueError) as ctx: + self.svc._safe_skill_dir("\\server\\share") + self.assertIn("path traversal", str(ctx.exception)) + + def test_empty_name_blocked(self): + """Empty name must be rejected.""" + with self.assertRaises(ValueError): + self.svc._safe_skill_dir("") + + def test_whitespace_only_blocked(self): + """Whitespace-only name must be rejected.""" + with self.assertRaises(ValueError): + self.svc._safe_skill_dir(" ") + + def test_subdir_name_allowed(self): + """A name with a forward slash but no traversal is allowed if it stays in root.""" + # e.g. "category/skill-name" is a valid nested skill directory + result = self.svc._safe_skill_dir("category/skill-name") + expected = os.path.realpath(os.path.join(self.tmp_root, "category/skill-name")) + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() From 7fd30b608c77e51826bd187c0255f08026d35f2d Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 11 Jun 2026 19:08:30 +0800 Subject: [PATCH 356/399] fix(cow_cli): fix line breaks in CLI replies --- plugins/cow_cli/cow_cli.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index 62bbc786..5ee25cf9 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -147,6 +147,7 @@ class CowCliPlugin(Plugin): else: # "typo" reply_text = self._typo_hint(token, result[1]) + reply_text = self._harden_line_breaks(reply_text, e_context) e_context["reply"] = Reply(ReplyType.TEXT, reply_text) e_context.action = EventAction.BREAK_PASS @@ -1516,6 +1517,43 @@ class CowCliPlugin(Plugin): except Exception: return False + @staticmethod + def _harden_line_breaks(text: str, e_context) -> str: + """WeChat PC renders bot messages as Markdown, where a lone '\\n' is + collapsed into a space, so plain-text CLI output gets squashed onto + one line. Prefix consecutive text lines with '- ' so the Markdown + list keeps each on its own line (the only form WeChat respects). + WeChat-only; other channels are untouched. Blank lines, code fences, + and lines that are already list items are left intact.""" + if e_context is None or not text or "\n" not in text: + return text + try: + if e_context["context"].kwargs.get("channel_type") != "weixin": + return text + except Exception: + return text + + out = [] + in_code = False + lines = text.split("\n") + for i, line in enumerate(lines): + if line.lstrip().startswith("```"): + in_code = not in_code + out.append(line) + continue + stripped = line.lstrip() + prev_packed = i > 0 and lines[i - 1].strip() != "" + next_packed = i < len(lines) - 1 and lines[i + 1].strip() != "" + # Only convert lines inside a multi-line block (a neighbour line is + # non-blank); standalone paragraphs separated by blank lines, code + # blocks, blank lines, and existing list items are left intact. + if (in_code or not stripped or stripped.startswith(("- ", "* ", "+ ")) + or not (prev_packed or next_packed)): + out.append(line) + else: + out.append("- " + stripped) + return "\n".join(out) + @staticmethod def _build_dream_result(flush_mgr, is_web: bool) -> str: """Build dream completion message with diary content.""" From d5427d967a5ca85d350fc95afc70b6966fecb882 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 11 Jun 2026 19:24:08 +0800 Subject: [PATCH 357/399] fix(installer): fix ASR/TTS default, self-evolution flag, and QuickEdit hang --- run.sh | 6 +++-- scripts/run.ps1 | 62 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/run.sh b/run.sh index 638fecfc..e4f5ba5a 100755 --- a/run.sh +++ b/run.sh @@ -869,8 +869,10 @@ base = { 'mimo_api_key': e('MIMO_KEY', ''), 'deepseek_api_key': e('DEEPSEEK_KEY', ''), 'deepseek_api_base': e('DEEPSEEK_BASE'), - 'voice_to_text': 'openai', - 'text_to_voice': 'openai', + # Leave ASR/TTS provider empty so the web console auto-suggests the vendor + # whose API key is actually configured (e.g. LinkAI), not always OpenAI. + 'voice_to_text': '', + 'text_to_voice': '', 'voice_reply_voice': False, 'speech_recognition': True, 'group_speech_recognition': False, diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 76fa7708..83e969e1 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -32,6 +32,44 @@ try { $OutputEncoding = [System.Text.Encoding]::UTF8 $env:PYTHONIOENCODING = "utf-8" +# ── disable console QuickEdit mode ─────────────────────────────── +# On Windows, QuickEdit is ON by default: a stray click/selection in the +# window FREEZES all output until the user presses a key. During long steps +# (git clone, pip install) this looks like a hang, and the wake-up key gets +# buffered and later auto-confirms a menu default. Turning QuickEdit off +# removes both the freeze and the phantom keystrokes. +try { + if (-not ([Console]::IsInputRedirected)) { + $quickEditType = @' +using System; +using System.Runtime.InteropServices; +public static class ConsoleQuickEdit { + const int STD_INPUT_HANDLE = -10; + const uint ENABLE_QUICK_EDIT = 0x0040; + const uint ENABLE_EXTENDED = 0x0080; + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int nStdHandle); + [DllImport("kernel32.dll")] + static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + [DllImport("kernel32.dll")] + static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + public static void Disable() { + IntPtr handle = GetStdHandle(STD_INPUT_HANDLE); + uint mode; + if (!GetConsoleMode(handle, out mode)) { return; } + mode &= ~ENABLE_QUICK_EDIT; // clear QuickEdit + mode |= ENABLE_EXTENDED; // keep extended flags valid + SetConsoleMode(handle, mode); + } +} +'@ + if (-not ([System.Management.Automation.PSTypeName]'ConsoleQuickEdit').Type) { + Add-Type -TypeDefinition $quickEditType -ErrorAction Stop + } + [ConsoleQuickEdit]::Disable() + } +} catch {} + # ── colours ────────────────────────────────────────────────────── function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green } function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow } @@ -91,6 +129,17 @@ function Initialize-UiLang { } } +# Drain any buffered console keystrokes. During long steps (git clone, pip +# install) users often hit Enter to "wake up" a seemingly stuck console; those +# keys sit in the input buffer and would otherwise be consumed by the next +# ReadKey, auto-confirming a menu's default. Flush them before reading input. +function Clear-InputBuffer { + try { + if ([Console]::IsInputRedirected) { return } + while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) } + } catch {} +} + # ── arrow-key selectable menu with number fallback ─────────────── # Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1] # Returns the selected 1-based index. @@ -125,6 +174,10 @@ function Select-Menu { Write-Info $Title Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan + # Discard keystrokes buffered during the previous (possibly long-running) + # step so a leftover Enter doesn't instantly confirm the default option. + Clear-InputBuffer + [Console]::CursorVisible = $false $firstDraw = $true try { @@ -573,8 +626,11 @@ function New-ConfigFile { mimo_api_key = "" deepseek_api_key = "" deepseek_api_base = "https://api.deepseek.com/v1" - voice_to_text = "openai" - text_to_voice = "openai" + # Leave ASR/TTS provider empty so the web console auto-suggests the + # vendor whose API key is actually configured (e.g. LinkAI), instead + # of always defaulting to OpenAI. + voice_to_text = "" + text_to_voice = "" voice_reply_voice = $false speech_recognition = $true group_speech_recognition = $false @@ -585,6 +641,8 @@ function New-ConfigFile { agent_max_context_tokens = 40000 agent_max_context_turns = 30 agent_max_steps = 15 + # New installs opt into self-evolution (matches run.sh). + self_evolution_enabled = $true } # Set the API key into the right field (skipped models leave it empty). From 6fb19a68b5ee812ee70065a94fd61982ff41c522 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Thu, 11 Jun 2026 19:30:08 +0800 Subject: [PATCH 358/399] feat: update default config in run script --- run.sh | 6 +++--- scripts/run.ps1 | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/run.sh b/run.sh index e4f5ba5a..3c15b0f0 100755 --- a/run.sh +++ b/run.sh @@ -880,9 +880,9 @@ base = { 'linkai_api_key': e('LINKAI_KEY', ''), 'linkai_app_code': '', 'agent': True, - 'agent_max_context_tokens': 40000, - 'agent_max_context_turns': 30, - 'agent_max_steps': 15, + 'agent_max_context_tokens': 50000, + 'agent_max_context_turns': 20, + 'agent_max_steps': 20, # New installs opt into self-evolution; existing users (no key) keep the # code default (off) so an upgrade never silently changes their behavior. 'self_evolution_enabled': True, diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 83e969e1..2386dd87 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -638,9 +638,9 @@ function New-ConfigFile { linkai_api_key = "" linkai_app_code = "" agent = $true - agent_max_context_tokens = 40000 - agent_max_context_turns = 30 - agent_max_steps = 15 + agent_max_context_tokens = 50000 + agent_max_context_turns = 20 + agent_max_steps = 20 # New installs opt into self-evolution (matches run.sh). self_evolution_enabled = $true } From 0092376c07129de4b353400a0b59b32e0f699b1a Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Fri, 12 Jun 2026 10:05:05 +0800 Subject: [PATCH 359/399] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20n?= =?UTF-8?q?o=20auto-hijack,=20sync=20model=20on=20activation,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Creating a provider no longer auto-switches bot_type. Only an explicit make_active=true changes the active model — prevents silently hijacking users on Claude/OpenAI/etc. 2. When a provider IS activated, its 'model' is now written into the global 'model' field. This ensures all three paths (regular chat, agent_bridge, vision) use the correct model without per-path patches. 3. Removed unused i18n key 'models_custom_name_exists' (no longer referenced after the id-based rework removed name-collision checks). 4. Updated tests: 39 passing (added model-sync tests, fixed tests that relied on the removed auto-activation behavior). --- channel/web/static/js/console.js | 2 -- channel/web/web_channel.py | 21 +++++++---- tests/test_custom_provider_handlers.py | 49 +++++++++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index d15f0248..f9a7b43c 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -46,7 +46,6 @@ const I18N = { models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。', models_custom_name_required: '请填写名称', models_custom_base_required: '请填写 API Base', - models_custom_name_exists: '该名称已存在', models_custom_empty: '尚未配置自定义厂商,点击添加', models_custom_edit_title: '编辑自定义厂商', models_custom_add_title: '添加自定义厂商', @@ -268,7 +267,6 @@ const I18N = { models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.', models_custom_name_required: 'Name is required', models_custom_base_required: 'API Base is required', - models_custom_name_exists: 'A provider with this name already exists', models_custom_empty: 'No custom providers yet, click to add', models_custom_edit_title: 'Edit custom provider', models_custom_add_title: 'Add custom provider', diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 6eb933db..9a02b4d3 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2798,7 +2798,12 @@ class ModelsHandler: """Write the providers list to both in-memory conf and the on-disk config, then reset the bridge so bots rebuild. - If ``bot_type`` is given, also update ``bot_type``.""" + If ``bot_type`` is given, also update ``bot_type``. When activating a + provider (bot_type is ``custom:``), also write the provider's + ``model`` into the global ``model`` field so that all paths (chat, + agent, vision) automatically use the correct model.""" + from models.custom_provider import parse_custom_bot_type + local_config = conf() file_cfg = self._read_file_config() local_config["custom_providers"] = providers @@ -2806,6 +2811,13 @@ class ModelsHandler: if bot_type is not None: local_config["bot_type"] = bot_type file_cfg["bot_type"] = bot_type + # Sync the provider's model into the global model field. + _, pid = parse_custom_bot_type(bot_type) + if pid: + provider = next((p for p in providers if p.get("id") == pid), None) + if provider and provider.get("model"): + local_config["model"] = provider["model"] + file_cfg["model"] = provider["model"] self._write_file_config(file_cfg) self._reset_bridge() @@ -2865,12 +2877,9 @@ class ModelsHandler: existing.pop("model", None) created = False - # Decide bot_type. - _, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "") + # Decide bot_type — only switch when explicitly requested. new_bot_type = None - if make_active or (created and not current_active_id): - # Activate on explicit request, or auto-activate the very first - # provider so the resolver has a definite target. + if make_active: new_bot_type = f"custom:{provider_id}" self._persist_custom_providers(providers, new_bot_type) diff --git a/tests/test_custom_provider_handlers.py b/tests/test_custom_provider_handlers.py index f7792261..9a47b564 100644 --- a/tests/test_custom_provider_handlers.py +++ b/tests/test_custom_provider_handlers.py @@ -72,22 +72,31 @@ class TestSetCustomProvider(unittest.TestCase): set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() - def test_create_first_provider_auto_activates(self): + def test_create_provider_does_not_hijack_bot_type(self): + """Creating a provider without make_active must not change bot_type.""" res = self.h.call(action="set_custom_provider", name="siliconflow", api_base="https://api.siliconflow.cn/v1", api_key="sf-key") self.assertEqual(res["status"], "success") self.assertTrue(res["created"]) self.assertIn("id", res) - # bot_type should be updated to "custom:" + # bot_type must remain unchanged — no auto-activation. bot_type = config_module.conf().get("bot_type") - self.assertTrue(bot_type.startswith("custom:")) - self.assertEqual(bot_type, f"custom:{res['id']}") + self.assertEqual(bot_type, "custom") # unchanged from setUp providers = config_module.conf().get("custom_providers") self.assertEqual(len(providers), 1) self.assertEqual(providers[0]["id"], res["id"]) self.assertEqual(providers[0]["name"], "siliconflow") self.assertEqual(self.h.bridge_resets, 1) + def test_create_with_make_active_switches_bot_type(self): + """Creating a provider with make_active=true must switch bot_type.""" + res = self.h.call(action="set_custom_provider", name="siliconflow", + api_base="https://api.siliconflow.cn/v1", api_key="sf-key", + make_active=True) + self.assertEqual(res["status"], "success") + bot_type = config_module.conf().get("bot_type") + self.assertEqual(bot_type, f"custom:{res['id']}") + def test_create_requires_api_base(self): res = self.h.call(action="set_custom_provider", name="x", api_key="k") self.assertEqual(res["status"], "error") @@ -98,12 +107,13 @@ class TestSetCustomProvider(unittest.TestCase): self.assertEqual(res["status"], "error") def test_second_provider_does_not_steal_active(self): + # Explicitly activate the first provider. res1 = self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="ak") + api_base="https://a/v1", api_key="ak", make_active=True) res2 = self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") self.assertTrue(res2["created"]) - # First provider stays active unless make_active is requested. + # First provider stays active — second creation doesn't steal it. bot_type = config_module.conf().get("bot_type") self.assertEqual(bot_type, f"custom:{res1['id']}") @@ -155,7 +165,8 @@ class TestDeleteCustomProvider(unittest.TestCase): set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() self.res_a = self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="ak") + api_base="https://a/v1", api_key="ak", + make_active=True) self.res_b = self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") @@ -191,7 +202,8 @@ class TestSetActiveCustomProvider(unittest.TestCase): set_conf({"bot_type": "custom", "custom_providers": []}) self.h = _HandlerHarness() self.res_a = self.h.call(action="set_custom_provider", name="a", - api_base="https://a/v1", api_key="ak") + api_base="https://a/v1", api_key="ak", + make_active=True) self.res_b = self.h.call(action="set_custom_provider", name="b", api_base="https://b/v1", api_key="bk") @@ -206,6 +218,27 @@ class TestSetActiveCustomProvider(unittest.TestCase): # bot_type unchanged self.assertEqual(config_module.conf().get("bot_type"), f"custom:{self.res_a['id']}") + def test_activation_syncs_model_to_global(self): + """Activating a provider must write its model into global model field.""" + set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"}) + h = _HandlerHarness() + res = h.call(action="set_custom_provider", name="sf", + api_base="https://sf/v1", api_key="k", model="deepseek-v3", + make_active=True) + # Global model field should now be the provider's model. + self.assertEqual(config_module.conf().get("model"), "deepseek-v3") + self.assertEqual(config_module.conf().get("bot_type"), f"custom:{res['id']}") + + def test_activation_without_model_keeps_global_model(self): + """Activating a provider with no model must not overwrite global model.""" + set_conf({"bot_type": "custom", "custom_providers": [], "model": "gpt-4o"}) + h = _HandlerHarness() + h.call(action="set_custom_provider", name="local", + api_base="http://localhost:11434/v1", api_key="", + make_active=True) + # Global model field should remain unchanged. + self.assertEqual(config_module.conf().get("model"), "gpt-4o") + class TestProviderOverviewExpansion(unittest.TestCase): """_provider_overview / _custom_provider_cards should expand the list.""" From ad64e17a345826140be844f81a90967a28ffbed9 Mon Sep 17 00:00:00 2001 From: kirs-hi Date: Fri, 12 Jun 2026 11:03:04 +0800 Subject: [PATCH 360/399] fix: avoid KeyError on cancel and infinite loop in image compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent robustness fixes: 1. ChatChannel.cancel_session / cancel_all_session raised KeyError when a session existed in self.sessions but no future had been dispatched yet. self.sessions[sid] is created in produce(), but self.futures[sid] is only created later in consume() on first dispatch. Cancelling in that window (e.g. user sends a message then immediately cancels) crashed the cancel path. Use self.futures.get(sid, []) so an absent entry is a no-op. 2. compress_imgfile decremented JPEG quality by 5 with no lower bound. For an image that cannot be compressed below max_size, quality went 0, negative, ... — the loop never terminated and passed invalid quality values to PIL. Add a min_quality floor (10) and return the best effort once reached. Adds tests/test_robustness_fixes.py covering both paths (5 tests). --- channel/chat_channel.py | 7 +- common/utils.py | 6 +- tests/test_robustness_fixes.py | 121 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tests/test_robustness_fixes.py diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 9104a38e..e2a65c5b 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -519,7 +519,10 @@ class ChatChannel(Channel): def cancel_session(self, session_id): with self.lock: if session_id in self.sessions: - for future in self.futures[session_id]: + # futures[session_id] is only created in consume() when a task is + # dispatched, so it may be absent if cancel happens right after + # produce() but before the first dispatch. Default to []. + for future in self.futures.get(session_id, []): future.cancel() cnt = self.sessions[session_id][0].qsize() if cnt > 0: @@ -529,7 +532,7 @@ class ChatChannel(Channel): def cancel_all_session(self): with self.lock: for session_id in self.sessions: - for future in self.futures[session_id]: + for future in self.futures.get(session_id, []): future.cancel() cnt = self.sessions[session_id][0].qsize() if cnt > 0: diff --git a/common/utils.py b/common/utils.py index e7264e20..0597d979 100644 --- a/common/utils.py +++ b/common/utils.py @@ -27,10 +27,14 @@ def compress_imgfile(file, max_size): img = Image.open(file) rgb_image = img.convert("RGB") quality = 95 + min_quality = 10 while True: out_buf = io.BytesIO() rgb_image.save(out_buf, "JPEG", quality=quality) - if fsize(out_buf) <= max_size: + if fsize(out_buf) <= max_size or quality <= min_quality: + # Stop at min_quality: further decrements would pass an invalid + # quality (<1) to PIL and the loop would otherwise never terminate + # for images that cannot be compressed below max_size. return out_buf quality -= 5 diff --git a/tests/test_robustness_fixes.py b/tests/test_robustness_fixes.py new file mode 100644 index 00000000..9f8aa453 --- /dev/null +++ b/tests/test_robustness_fixes.py @@ -0,0 +1,121 @@ +# encoding:utf-8 +""" +Unit tests for robustness fixes: + 1. ChatChannel.cancel_session / cancel_all_session must not raise KeyError + when a session has been produced but no task has been dispatched yet + (so self.futures[session_id] does not exist). + 2. common.utils.compress_imgfile must terminate (no infinite loop / invalid + PIL quality) when an image cannot be compressed below max_size. +""" +import io +import os +import sys +import types +import unittest +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +# ============================================================================= +# 1. cancel_session / cancel_all_session KeyError regression +# ============================================================================= + +class TestCancelSessionMissingFutures(unittest.TestCase): + """A session may exist in self.sessions before any future is recorded.""" + + def _make_channel(self): + # Import lazily and build a bare object without running __init__, + # to avoid pulling the full channel setup / config. + from channel.chat_channel import ChatChannel + + ch = ChatChannel.__new__(ChatChannel) + import threading + + ch.lock = threading.RLock() + # A produced session whose future has NOT been dispatched yet. + queue = MagicMock() + queue.qsize.return_value = 0 + semaphore = MagicMock() + ch.sessions = {"sid": [queue, semaphore]} + ch.futures = {} # intentionally empty: consume() never ran + return ch + + def test_cancel_session_no_futures_entry(self): + ch = self._make_channel() + # Should not raise KeyError. + try: + ch.cancel_session("sid") + except KeyError: + self.fail("cancel_session raised KeyError when futures entry missing") + + def test_cancel_all_session_no_futures_entry(self): + ch = self._make_channel() + try: + ch.cancel_all_session() + except KeyError: + self.fail("cancel_all_session raised KeyError when futures entry missing") + + def test_cancel_session_cancels_existing_futures(self): + ch = self._make_channel() + fut = MagicMock() + ch.futures["sid"] = [fut] + ch.cancel_session("sid") + fut.cancel.assert_called_once() + + +# ============================================================================= +# 2. compress_imgfile termination +# ============================================================================= + +class TestCompressImgfileTermination(unittest.TestCase): + """compress_imgfile must always return, even for incompressible input.""" + + def setUp(self): + # Skip if Pillow is not available in the test environment. + try: + import PIL # noqa: F401 + except ImportError: + self.skipTest("Pillow not installed") + + def _make_image_buf(self, size=(64, 64)): + from PIL import Image + import random + + img = Image.new("RGB", size) + # Fill with random noise so JPEG cannot compress it well. + pixels = [ + (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + for _ in range(size[0] * size[1]) + ] + img.putdata(pixels) + buf = io.BytesIO() + img.save(buf, "JPEG", quality=95) + buf.seek(0) + return buf + + def test_returns_when_target_unreachable(self): + from common.utils import compress_imgfile + + buf = self._make_image_buf() + # An impossibly small target that even quality=10 won't reach. + out = compress_imgfile(buf, max_size=10) + self.assertIsInstance(out, io.BytesIO) + # Verify the result is still a valid JPEG (PIL never got invalid quality). + from PIL import Image + + out.seek(0) + img = Image.open(out) + img.verify() + + def test_no_compression_needed_returns_same_object(self): + from common.utils import compress_imgfile + + buf = self._make_image_buf() + size = buf.getbuffer().nbytes + out = compress_imgfile(buf, max_size=size + 1) + self.assertIs(out, buf) + + +if __name__ == "__main__": + unittest.main() From 1d7e6b370328a990cf0c7008d40400e92bbb1a5e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 12 Jun 2026 11:54:19 +0800 Subject: [PATCH 361/399] fix(web): accept custom: providers in the chat capability card _set_chat rejected the expanded "custom:" ids with "unknown provider", so switching to a custom provider was only possible from the custom providers section. Now the chat card and the custom section behave consistently: _set_chat validates the id against custom_providers, falls back to the provider's default model when none is picked, and _chat_capability expands the dropdown with the "custom:" entries (legacy single-custom mode unchanged). Co-authored-by: Cursor --- channel/web/web_channel.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 9a02b4d3..6a59beb6 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2247,13 +2247,24 @@ class ModelsHandler: """Main chat model — drives the agent. bot_type maps to a provider id.""" bot_type = local_config.get("bot_type") or "" provider_id = "openai" if bot_type == "chatGPT" else bot_type - if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"): + is_custom_id = provider_id.startswith("custom:") + if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id + and local_config.get("use_linkai")): provider_id = "linkai" + # In multi-provider mode, replace the single "custom" entry with the + # expanded "custom:" ids so the chat dropdown matches the cards. + provider_ids = [] + custom_cards = cls._custom_provider_cards(local_config) + for pid in ConfigHandler.PROVIDER_MODELS.keys(): + if pid == "custom" and custom_cards: + provider_ids.extend(c["id"] for c in custom_cards) + else: + provider_ids.append(pid) return { "editable": True, "current_provider": provider_id, "current_model": local_config.get("model", ""), - "providers": list(ConfigHandler.PROVIDER_MODELS.keys()), + "providers": provider_ids, "use_linkai": bool(local_config.get("use_linkai", False)), } @@ -3001,13 +3012,28 @@ class ModelsHandler: }) def _set_chat(self, provider_id: str, model: str) -> str: - if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS: + # Accept expanded custom provider ids ("custom:") as well as the + # built-in vendors, so the chat capability card and the custom + # providers section behave consistently. + custom_provider = None + if provider_id.startswith("custom:"): + from models.custom_provider import parse_custom_bot_type + _, custom_id = parse_custom_bot_type(provider_id) + providers = self._normalize_custom_providers(conf().get("custom_providers")) + custom_provider = next((p for p in providers if p.get("id") == custom_id), None) + if custom_provider is None: + return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"}) + elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS: return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"}) applied = {} local_config = conf() file_cfg = self._read_file_config() + # Fall back to the custom provider's default model when none is given. + if not model and custom_provider: + model = custom_provider.get("model") or "" + if provider_id: bot_type_value = "chatGPT" if provider_id == "openai" else provider_id local_config["bot_type"] = bot_type_value From 075d9fc608b26638c6670e9c7a124ce9ce602923 Mon Sep 17 00:00:00 2001 From: yangziyu-hhh Date: Fri, 12 Jun 2026 13:45:39 +0800 Subject: [PATCH 362/399] fix: address Bash streaming review feedback --- agent/tools/bash/bash.py | 22 +++- channel/web/static/js/console.js | 28 ++++- channel/web/web_channel.py | 2 +- tests/test_bash_streaming.py | 185 +++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 tests/test_bash_streaming.py diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py index 4fcc724c..dad6fb95 100644 --- a/agent/tools/bash/bash.py +++ b/agent/tools/bash/bash.py @@ -22,7 +22,7 @@ class Bash(BaseTool): """Tool for executing bash commands""" _IS_WIN = sys.platform == "win32" - _PROGRESS_MAX_BYTES = 32 * 1024 + _PROGRESS_MAX_BYTES = 4 * 1024 _PROGRESS_INTERVAL = 0.5 name: str = "bash" @@ -290,8 +290,9 @@ SAFETY: if process.poll() is None: self._kill_process(process) process.wait() + join_deadline = time.monotonic() + 5 for reader in readers: - reader.join() + reader.join(timeout=max(0, join_deadline - time.monotonic())) from types import SimpleNamespace return SimpleNamespace( @@ -302,12 +303,23 @@ SAFETY: def _kill_process(self, process): if self._IS_WIN: - process.kill() + try: + result = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + timeout=5, + ) + if result.returncode != 0 and process.poll() is None: + process.kill() + except (OSError, subprocess.SubprocessError): + if process.poll() is None: + process.kill() else: try: os.killpg(process.pid, signal.SIGKILL) - except PermissionError: - process.kill() + except (PermissionError, ProcessLookupError): + if process.poll() is None: + process.kill() @staticmethod def _redact_progress(text: str, dotenv_vars: dict) -> str: diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 31727cbf..825871a9 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -188,6 +188,8 @@ const I18N = { delete_session_title: '删除会话', delete_message_confirm: '确认删除这条消息?', delete_message_title: '删除消息', + edit_disabled_reply_active: '正在生成回复,暂时无法编辑。', + delete_disabled_reply_active: '正在生成回复,暂时无法删除。', untitled_session: '新对话', context_cleared: '— 以上内容已从上下文中移除 —', tip_new_chat: '新建对话', @@ -392,6 +394,8 @@ const I18N = { delete_session_title: 'Delete Session', delete_message_confirm: 'Delete this message?', delete_message_title: 'Delete Message', + edit_disabled_reply_active: 'Reply is being generated; editing is temporarily unavailable.', + delete_disabled_reply_active: 'Reply is being generated; deletion is temporarily unavailable.', untitled_session: 'New Chat', context_cleared: '— Context above has been cleared —', tip_new_chat: 'New Chat', @@ -900,11 +904,11 @@ function updateEditButtonsState() { btn.disabled = active; if (btn.classList.contains('edit-msg-btn')) { btn.title = active - ? 'Reply is being generated; editing is temporarily unavailable.' - : (currentLang === 'zh' ? '编辑消息' : 'Edit Message'); + ? t('edit_disabled_reply_active') + : t('edit_message'); } else { btn.title = active - ? 'Reply is being generated; deletion is temporarily unavailable.' + ? t('delete_disabled_reply_active') : t('delete_message_title'); } }); @@ -2446,6 +2450,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { // Add tool execution indicator (collapsible) const toolEl = document.createElement('div'); toolEl.className = 'agent-step agent-tool-step tool-streaming'; + toolEl.dataset.progressReceived = 'false'; const argsStr = formatToolArgs(item.arguments || {}); toolEl.innerHTML = `
@@ -2471,7 +2476,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { } else if (item.type === 'tool_progress') { const toolEl = toolElements.get(item.tool_call_id); if (toolEl) { - toolEl.classList.add('expanded'); + if (toolEl.dataset.progressReceived !== 'true') { + toolEl.classList.add('expanded'); + toolEl.dataset.progressReceived = 'true'; + } toolEl.querySelector('.tool-live-output').textContent = String(item.content || ''); scrollChatToBottom(); } @@ -2501,6 +2509,11 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { } toolEl.classList.remove('tool-streaming'); + toolEl.classList.remove('expanded'); + if (!item.result) { + const outputSection = toolEl.querySelector('.tool-output-section'); + if (outputSection) outputSection.remove(); + } if (isError) toolEl.classList.add('tool-failed'); toolElements.delete(item.tool_call_id); } @@ -2661,6 +2674,13 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo, replayItems) { // Record every event for re-attach replay (capped to avoid // unbounded growth on very long streams). + if (item.type === 'tool_progress' && item.tool_call_id) { + const previousIndex = buffer.items.findIndex( + buffered => buffered.type === 'tool_progress' + && buffered.tool_call_id === item.tool_call_id + ); + if (previousIndex >= 0) buffer.items.splice(previousIndex, 1); + } if (buffer.items.length < 5000) buffer.items.push(item); // Background session: keep the stream alive so the reply finishes diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 3b374c5f..341c13bc 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -439,7 +439,7 @@ class WebChannel(ChatChannel): "type": "tool_progress", "tool_call_id": data.get("tool_call_id"), "tool": data.get("tool_name", "tool"), - "content": str(data.get("message", ""))[-32 * 1024:], + "content": str(data.get("message", ""))[-4 * 1024:], }) elif event_type == "tool_execution_end": diff --git a/tests/test_bash_streaming.py b/tests/test_bash_streaming.py new file mode 100644 index 00000000..ff3d3591 --- /dev/null +++ b/tests/test_bash_streaming.py @@ -0,0 +1,185 @@ +import subprocess +import sys +import time +from unittest.mock import patch + +import pytest + +from agent.tools.bash.bash import Bash + + +posix_only = pytest.mark.skipif(Bash._IS_WIN, reason="POSIX shell command") +windows_only = pytest.mark.skipif(not Bash._IS_WIN, reason="Windows integration test") + + +def _windows_pid_command(pid_file): + path = str(pid_file).replace("\\", "\\\\") + return ( + f'"{sys.executable}" -c ' + f'"import os,time;open(r\'{path}\',\'w\').write(str(os.getpid()));time.sleep(30)"' + ) + + +def _windows_pid_is_running(pid): + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + errors="replace", + ) + return f'"{pid}"' in result.stdout + + +def _windows_kill_pid_file(pid_file): + if pid_file.exists(): + subprocess.run( + ["taskkill", "/F", "/PID", pid_file.read_text()], + capture_output=True, + ) + + +@posix_only +def test_fast_command_returns_output_without_progress(tmp_path): + tool = Bash({"cwd": str(tmp_path)}) + progress = [] + tool.progress_callback = progress.append + + result = tool.execute({"command": "printf fast"}) + + assert result.status == "success" + assert result.result["output"] == "fast" + assert progress == [] + + +@posix_only +def test_timeout_returns_promptly(tmp_path): + tool = Bash({"cwd": str(tmp_path)}) + started = time.monotonic() + + result = tool.execute({"command": "sleep 10", "timeout": 1}) + + assert result.status == "error" + assert "timed out after 1 seconds" in result.result + assert time.monotonic() - started < 3 + + +@posix_only +def test_background_process_holding_pipe_does_not_hang(tmp_path): + tool = Bash({"cwd": str(tmp_path)}) + started = time.monotonic() + + result = tool.execute({"command": "sleep 10 & printf done", "timeout": 3}) + + assert result.status == "success" + assert result.result["output"] == "done" + assert time.monotonic() - started < 7 + + +@posix_only +def test_output_without_trailing_newline_streams_progress(tmp_path): + tool = Bash({"cwd": str(tmp_path)}) + progress = [] + tool.progress_callback = progress.append + + result = tool.execute({ + "command": "for i in 1 2 3 4 5; do printf .; sleep 0.3; done", + "timeout": 5, + }) + + assert result.status == "success" + assert result.result["output"] == "....." + assert progress + assert progress[-1].endswith(".") + + +def test_windows_kill_uses_taskkill_for_process_tree(): + tool = Bash() + process = type("Process", (), {"pid": 1234, "poll": lambda self: None, "kill": lambda self: None})() + + with patch.object(tool, "_IS_WIN", True), patch.object(subprocess, "run") as run: + run.return_value.returncode = 0 + tool._kill_process(process) + + run.assert_called_once_with( + ["taskkill", "/F", "/T", "/PID", "1234"], + capture_output=True, + timeout=5, + ) + + +def test_windows_kill_falls_back_when_taskkill_fails(): + tool = Bash() + process = type("Process", (), {"pid": 1234, "poll": lambda self: None, "kill": lambda self: None})() + + with patch.object(tool, "_IS_WIN", True), \ + patch.object(subprocess, "run", side_effect=OSError), \ + patch.object(process, "kill") as kill: + tool._kill_process(process) + + kill.assert_called_once() + + +@windows_only +def test_windows_timeout_kills_process_tree(tmp_path): + pid_file = tmp_path / "timeout-child.pid" + child_command = _windows_pid_command(pid_file) + tool = Bash({"cwd": str(tmp_path)}) + started = time.monotonic() + + try: + result = tool.execute({ + "command": f'start "" /b {child_command} & ping -n 30 127.0.0.1 >nul', + "timeout": 1, + }) + + assert result.status == "error" + assert "timed out after 1 seconds" in result.result + assert time.monotonic() - started < 8 + assert pid_file.exists() + time.sleep(0.5) + assert not _windows_pid_is_running(pid_file.read_text()) + finally: + _windows_kill_pid_file(pid_file) + + +@windows_only +def test_windows_long_running_command_streams_progress(tmp_path): + tool = Bash({"cwd": str(tmp_path)}) + progress = [] + tool.progress_callback = progress.append + + result = tool.execute({ + "command": ( + f'"{sys.executable}" -u -c ' + '"import sys,time; ' + "[(sys.stdout.write('.'),sys.stdout.flush(),time.sleep(0.3)) " + 'for _ in range(5)]"' + ), + "timeout": 5, + }) + + assert result.status == "success" + assert result.result["output"] == "....." + assert progress + assert progress[-1].endswith(".") + + +@windows_only +def test_windows_background_process_holding_pipe_does_not_hang(tmp_path): + pid_file = tmp_path / "background-child.pid" + child_command = _windows_pid_command(pid_file) + tool = Bash({"cwd": str(tmp_path)}) + started = time.monotonic() + + try: + result = tool.execute({ + "command": f'start "" /b {child_command} & echo done', + "timeout": 3, + }) + + assert result.status == "success" + assert result.result["output"].strip() == "done" + assert time.monotonic() - started < 8 + assert pid_file.exists() + finally: + _windows_kill_pid_file(pid_file) From 9387980e74ae31a209ad1b1170b70492dddcf091 Mon Sep 17 00:00:00 2001 From: yangziyu-hhh Date: Fri, 12 Jun 2026 14:07:40 +0800 Subject: [PATCH 363/399] ci: add Windows Bash streaming tests --- .github/workflows/test-windows-bash.yml | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/test-windows-bash.yml diff --git a/.github/workflows/test-windows-bash.yml b/.github/workflows/test-windows-bash.yml new file mode 100644 index 00000000..4a39dbe6 --- /dev/null +++ b/.github/workflows/test-windows-bash.yml @@ -0,0 +1,32 @@ +name: Windows Bash Streaming Tests + +on: + workflow_dispatch: + pull_request: + paths: + - "agent/tools/bash/bash.py" + - "tests/test_bash_streaming.py" + - ".github/workflows/test-windows-bash.yml" + +jobs: + windows-bash-tests: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest + python -m pip install -r requirements.txt + + - name: Run Windows Bash streaming tests + run: python -m pytest tests/test_bash_streaming.py -v From 5c43c2f5193dfb69cb16f8b9e23ac9b53a3b5bfa Mon Sep 17 00:00:00 2001 From: 6vision Date: Fri, 12 Jun 2026 16:26:54 +0800 Subject: [PATCH 364/399] feat(wecom_bot): add callback (webhook) mode alongside long connection Support receiving WeCom smart-bot messages via encrypted HTTP callback in addition to the existing WebSocket long connection. Disabled by default (wecom_bot_callback=false). - Add wecom_bot_callback / wecom_bot_token / wecom_bot_encoding_aes_key / wecom_bot_port config keys - Add WXBizJsonMsgCrypt-based crypto module for URL verification, callback decryption and passive-reply encryption (receive_id empty for internal bots) - Reply asynchronously via the official stream-refresh polling: register a stream id on first reply, accumulate agent output into per-stream state, and serve the latest content (text + image) on each poll until finish - Fall back to EncodingAESKey for media decryption when callback bodies carry no per-message aeskey - Degrade unsupported passive replies (file/voice/video) to a text notice - Expose the new fields in the Web console channel config Co-authored-by: Cursor --- channel/web/web_channel.py | 4 + channel/wecom_bot/wecom_bot_channel.py | 399 ++++++++++++++++++++++++- channel/wecom_bot/wecom_bot_crypt.py | 203 +++++++++++++ channel/wecom_bot/wecom_bot_message.py | 13 +- config.py | 5 + 5 files changed, 608 insertions(+), 16 deletions(-) create mode 100644 channel/wecom_bot/wecom_bot_crypt.py diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 1be31c6a..1dc36945 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -3016,6 +3016,10 @@ class ChannelsHandler: "fields": [ {"key": "wecom_bot_id", "label": "Bot ID", "type": "text"}, {"key": "wecom_bot_secret", "label": "Secret", "type": "secret"}, + {"key": "wecom_bot_callback", "label": "Callback Mode", "type": "bool", "default": False}, + {"key": "wecom_bot_token", "label": "Token", "type": "secret"}, + {"key": "wecom_bot_encoding_aes_key", "label": "EncodingAESKey", "type": "secret"}, + {"key": "wecom_bot_port", "label": "Port", "type": "number", "default": 9892}, ], }), ("qq", { diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py index ebc1104b..aaa37aa8 100644 --- a/channel/wecom_bot/wecom_bot_channel.py +++ b/channel/wecom_bot/wecom_bot_channel.py @@ -17,11 +17,13 @@ import time import uuid import requests +import web import websocket from bridge.context import Context, ContextType from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel, check_prefix +from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt from channel.wecom_bot.wecom_bot_message import WecomBotMessage from common.expired_dict import ExpiredDict from common.log import logger @@ -97,6 +99,14 @@ class WecomBotChannel(ChatChannel): self._pending_lock = threading.Lock() self._stream_states = {} # req_id -> {"stream_id": str, "content": str} + # Callback (webhook) mode state + self.callback_mode = False + self._crypt = None + self._http_server = None + # stream_id -> {"committed", "current", "finished", "images", "last_access"} + self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min) + self._callback_lock = threading.Lock() + conf()["group_name_white_list"] = ["ALL_GROUP"] conf()["single_chat_prefix"] = [""] @@ -105,6 +115,11 @@ class WecomBotChannel(ChatChannel): # ------------------------------------------------------------------ def startup(self): + self.callback_mode = bool(conf().get("wecom_bot_callback", False)) + if self.callback_mode: + self._startup_callback() + return + self.bot_id = conf().get("wecom_bot_id", "") self.bot_secret = conf().get("wecom_bot_secret", "") @@ -127,6 +142,13 @@ class WecomBotChannel(ChatChannel): pass self._ws = None self._connected = False + if self._http_server: + try: + self._http_server.stop() + logger.info("[WecomBot] Callback HTTP server stopped") + except Exception as e: + logger.warning(f"[WecomBot] Error stopping HTTP server: {e}") + self._http_server = None # ------------------------------------------------------------------ # WebSocket connection @@ -183,6 +205,158 @@ class WecomBotChannel(ChatChannel): def _gen_req_id(self) -> str: return uuid.uuid4().hex[:16] + # ------------------------------------------------------------------ + # Callback (webhook) mode + # ------------------------------------------------------------------ + + def _startup_callback(self): + """Start an HTTP server that receives encrypted callbacks (webhook mode). + + The bot's "接收消息" URL in the WeCom admin console should point at this + server (any path is accepted). Verification (GET) and message delivery + (POST) are both handled by ``WecomBotCallbackController``. + """ + token = conf().get("wecom_bot_token", "") + aes_key = conf().get("wecom_bot_encoding_aes_key", "") + if not token or not aes_key: + err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key" + logger.error(err) + self.report_startup_error(err) + return + + try: + # Enterprise-internal smart bot: receive_id is an empty string. + self._crypt = WecomBotCrypt(token, aes_key, "") + except Exception as e: + err = f"[WecomBot] invalid callback credentials: {e}" + logger.error(err) + self.report_startup_error(err) + return + + port = int(conf().get("wecom_bot_port", 9892)) + logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}...") + urls = ("/.*", "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController") + app = web.application(urls, globals(), autoreload=False) + func = web.httpserver.StaticMiddleware(app.wsgifunc()) + func = web.httpserver.LogMiddleware(func) + server = web.httpserver.WSGIServer(("0.0.0.0", port), func) + self._http_server = server + self.report_startup_success() + try: + server.start() + except (KeyboardInterrupt, SystemExit): + server.stop() + + def _new_callback_stream(self) -> str: + """Create a new stream state and return its id.""" + stream_id = uuid.uuid4().hex[:16] + with self._callback_lock: + self._callback_streams[stream_id] = { + "committed": "", + "current": "", + "finished": False, + "images": [], # list of (base64_str, md5_str), flushed only at finish + "last_access": time.time(), + } + return stream_id + + def _callback_handle_message(self, data: dict) -> dict: + """Handle a freshly-received user message in callback mode. + + Produces the context for async processing and returns the initial passive + reply (a stream packet with finish=false) so WeCom starts polling for the + agent's streamed answer. Returns ``None`` when there's nothing to reply + (e.g. an image/file silently cached for the next query). + """ + msg_id = data.get("msgid", "") + if msg_id and self.received_msgs.get(msg_id): + logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}") + return None + if msg_id: + self.received_msgs[msg_id] = True + + chattype = data.get("chattype", "single") + is_group = chattype == "group" + + default_aeskey = conf().get("wecom_bot_encoding_aes_key", "") + result = self._build_context(data, is_group, default_aeskey=default_aeskey) + if not result: + return None + context, wecom_msg = result + + stream_id = self._new_callback_stream() + wecom_msg.stream_id = stream_id + context["wecom_stream_id"] = stream_id + context["on_event"] = self._make_callback_stream_callback(stream_id) + self.produce(context) + + # First passive reply: register the stream id, WeCom will poll for updates. + return { + "msgtype": "stream", + "stream": {"id": stream_id, "finish": False, "content": ""}, + } + + def _callback_handle_stream_poll(self, data: dict) -> dict: + """Handle a "流式消息刷新" poll: return the latest accumulated content.""" + stream_id = data.get("stream", {}).get("id", "") + with self._callback_lock: + state = self._callback_streams.get(stream_id) + if state is None: + # Unknown / expired stream: tell WeCom we're done to stop polling. + return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}} + state["last_access"] = time.time() + content = state["committed"] + state["current"] + finished = state["finished"] + images = state["images"] if finished else [] + + stream = {"id": stream_id, "finish": finished, "content": content} + if images: + stream["msg_item"] = [ + {"msgtype": "image", "image": {"base64": b64, "md5": md5}} + for (b64, md5) in images + ] + return {"msgtype": "stream", "stream": stream} + + def _make_callback_stream_callback(self, stream_id: str): + """Build an on_event callback that accumulates agent output into stream state. + + Mirrors the websocket streaming behaviour: intermediate turns (text before + a tool call) are committed with a '---' separator; WeCom reads the full + accumulated content on each poll. + """ + def on_event(event: dict): + event_type = event.get("type") + edata = event.get("data", {}) + with self._callback_lock: + state = self._callback_streams.get(stream_id) + if not state: + return + + if event_type == "turn_start": + state["current"] = "" + elif event_type == "message_update": + delta = edata.get("delta", "") + if delta: + state["current"] += delta + elif event_type == "message_end": + tool_calls = edata.get("tool_calls", []) + if tool_calls: + if state["current"].strip(): + state["committed"] += state["current"].strip() + "\n\n---\n\n" + state["current"] = "" + else: + state["committed"] += state["current"] + state["current"] = "" + elif event_type == "agent_cancelled": + if state["current"]: + state["committed"] += state["current"] + state["current"] = "" + state["committed"] = state["committed"].rstrip() + if state["committed"].endswith("---"): + state["committed"] = state["committed"][:-3].rstrip() + + return on_event + # ------------------------------------------------------------------ # Subscribe & heartbeat # ------------------------------------------------------------------ @@ -287,16 +461,31 @@ class WecomBotChannel(ChatChannel): chattype = body.get("chattype", "single") is_group = chattype == "group" + result = self._build_context(body, is_group) + if not result: + return + context, wecom_msg = result + wecom_msg.req_id = req_id + if req_id: + context["on_event"] = self._make_stream_callback(req_id) + self.produce(context) + + def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""): + """Parse a wecom message body into a Context, applying file-cache logic. + + Shared by both the websocket (long-connection) and callback (webhook) + receive paths. Returns ``(context, wecom_msg)`` when the message should be + handed to the agent, or ``None`` when it was consumed (cached image/file, + parse failure, etc.). + """ try: - wecom_msg = WecomBotMessage(body, is_group=is_group) + wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey) except NotImplementedError as e: logger.warning(f"[WecomBot] {e}") - return + return None except Exception as e: logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True) - return - - wecom_msg.req_id = req_id + return None # File cache logic (same pattern as feishu) from channel.file_cache import get_file_cache @@ -314,13 +503,13 @@ class WecomBotChannel(ChatChannel): if hasattr(wecom_msg, "image_path") and wecom_msg.image_path: file_cache.add(session_id, wecom_msg.image_path, file_type="image") logger.info(f"[WecomBot] Image cached for session {session_id}") - return + return None if wecom_msg.ctype == ContextType.FILE: wecom_msg.prepare() file_cache.add(session_id, wecom_msg.content, file_type="file") logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}") - return + return None if wecom_msg.ctype == ContextType.TEXT: cached_files = file_cache.get(session_id) @@ -346,10 +535,9 @@ class WecomBotChannel(ChatChannel): msg=wecom_msg, no_need_at=True, ) - if context: - if req_id: - context["on_event"] = self._make_stream_callback(req_id) - self.produce(context) + if not context: + return None + return context, wecom_msg # ------------------------------------------------------------------ # Event callback @@ -490,11 +678,121 @@ class WecomBotChannel(ChatChannel): return context + # ------------------------------------------------------------------ + # Callback (webhook) send: write the final reply into the stream state + # so the next "流式消息刷新" poll returns it with finish=true. + # ------------------------------------------------------------------ + + def _callback_send(self, reply: Reply, context: Context): + msg = context.get("msg") + stream_id = getattr(msg, "stream_id", None) if msg else None + if not stream_id: + stream_id = context.get("wecom_stream_id") + if not stream_id: + logger.warning("[WecomBot] callback send without stream_id, dropping reply") + return + + if reply.type == ReplyType.TEXT: + self._callback_finalize_text(stream_id, reply.content) + elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE): + self._callback_finalize_image(stream_id, reply.content) + elif reply.type == ReplyType.FILE: + # Passive callback replies only support text + image (base64); files + # are not supported by the protocol, so degrade to a text notice. + text = getattr(reply, "text_content", "") or "" + note = (text + "\n" if text else "") + "[文件回复在回调模式下暂不支持]" + self._callback_finalize_text(stream_id, note) + elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE): + logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode") + self._callback_finalize_text(stream_id, "[该消息类型在回调模式下暂不支持]") + else: + self._callback_finalize_text(stream_id, str(reply.content)) + + def _callback_get_or_create_state(self, stream_id: str) -> dict: + state = self._callback_streams.get(stream_id) + if state is None: + state = { + "committed": "", + "current": "", + "finished": False, + "images": [], + "last_access": time.time(), + } + self._callback_streams[stream_id] = state + return state + + def _callback_finalize_text(self, stream_id: str, content: str): + with self._callback_lock: + state = self._callback_get_or_create_state(stream_id) + accumulated = (state["committed"] + state["current"]).strip() + state["committed"] = accumulated if accumulated else (content or "") + state["current"] = "" + state["finished"] = True + state["last_access"] = time.time() + + def _callback_finalize_image(self, stream_id: str, img_path_or_url: str): + b64md5 = self._load_image_base64(img_path_or_url) + with self._callback_lock: + state = self._callback_get_or_create_state(stream_id) + accumulated = (state["committed"] + state["current"]).strip() + state["current"] = "" + if b64md5: + state["images"].append(b64md5) + state["committed"] = accumulated + else: + state["committed"] = accumulated or "[图片发送失败]" + state["finished"] = True + state["last_access"] = time.time() + + def _load_image_base64(self, img_path_or_url: str): + """Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5).""" + local_path = img_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=30) + resp.raise_for_status() + tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}" + with open(tmp_path, "wb") as f: + f.write(resp.content) + local_path = tmp_path + except Exception as e: + logger.error(f"[WecomBot] Failed to download image for callback reply: {e}") + return None + + if not os.path.exists(local_path): + logger.error(f"[WecomBot] Image file not found: {local_path}") + return None + + local_path = self._ensure_image_format(local_path) + if not local_path: + return None + + max_image_size = 10 * 1024 * 1024 # callback image base64 input max is 10MB + if os.path.getsize(local_path) > max_image_size: + local_path = self._compress_image(local_path, max_image_size) + if not local_path: + return None + + try: + with open(local_path, "rb") as f: + raw = f.read() + return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest() + except Exception as e: + logger.error(f"[WecomBot] Failed to read image for callback reply: {e}") + return None + # ------------------------------------------------------------------ # Send reply # ------------------------------------------------------------------ def send(self, reply: Reply, context: Context): + if self.callback_mode: + self._callback_send(reply, context) + return + msg = context.get("msg") is_group = context.get("isgroup", False) receiver = context.get("receiver", "") @@ -906,3 +1204,82 @@ class WecomBotChannel(ChatChannel): else: logger.error("[WecomBot] Failed to get media_id from finish response") return media_id + + +class WecomBotCallbackController: + """HTTP controller for wecom bot callback (webhook) mode. + + - GET : URL verification (echo the decrypted echostr). + - POST : encrypted message / stream-refresh / event callbacks; returns an + encrypted passive reply (or "success" for an empty reply). + """ + + @staticmethod + def _channel() -> "WecomBotChannel": + return WecomBotChannel() + + def GET(self): + channel = self._channel() + params = web.input(msg_signature="", timestamp="", nonce="", echostr="") + if not channel._crypt: + return "wecom bot callback not ready" + ret, echo = channel._crypt.verify_url( + params.msg_signature, params.timestamp, params.nonce, params.echostr + ) + if ret != 0: + logger.error(f"[WecomBot] URL verify failed: ret={ret}") + return "verify fail" + if isinstance(echo, bytes): + echo = echo.decode("utf-8") + return echo + + def POST(self): + channel = self._channel() + if not channel._crypt: + return "success" + + params = web.input(msg_signature="", timestamp="", nonce="") + body = web.data() + ret, plain = channel._crypt.decrypt_msg( + body, params.msg_signature, params.timestamp, params.nonce + ) + if ret != 0: + logger.error(f"[WecomBot] callback decrypt failed: ret={ret}") + return "success" + + try: + data = json.loads(plain) + except Exception as e: + logger.error(f"[WecomBot] callback json parse failed: {e}") + return "success" + + msgtype = data.get("msgtype", "") + logger.debug(f"[WecomBot] callback received msgtype={msgtype}") + + try: + if msgtype == "stream": + reply = channel._callback_handle_stream_poll(data) + elif msgtype == "event": + event_type = data.get("event", {}).get("eventtype", "") + logger.info(f"[WecomBot] callback event: {event_type}") + reply = None + elif msgtype in ("text", "image", "voice", "file", "video", "mixed"): + reply = channel._callback_handle_message(data) + else: + logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}") + reply = None + except Exception as e: + logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True) + reply = None + + if not reply: + # Empty reply package is acceptable. + return "success" + + plain_reply = json.dumps(reply, ensure_ascii=False) + ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp) + if ret != 0: + logger.error(f"[WecomBot] callback encrypt failed: ret={ret}") + return "success" + web.header("Content-Type", "application/json; charset=utf-8") + return json.dumps(enc, ensure_ascii=False) diff --git a/channel/wecom_bot/wecom_bot_crypt.py b/channel/wecom_bot/wecom_bot_crypt.py new file mode 100644 index 00000000..262ca3f1 --- /dev/null +++ b/channel/wecom_bot/wecom_bot_crypt.py @@ -0,0 +1,203 @@ +""" +WeCom (企业微信) smart-bot callback message encryption/decryption. + +Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the +AI bot callback (webhook) mode. The bot's receive-message callback delivers +AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the +same way before being returned in the HTTP response. + +For an enterprise-internal smart bot, ``receive_id`` is always an empty string. +""" + +import base64 +import hashlib +import random +import socket +import struct +import time + +from Crypto.Cipher import AES + +from common.log import logger + +# Error codes (mirrors the official ierror.py) +WXBizMsgCrypt_OK = 0 +WXBizMsgCrypt_ValidateSignature_Error = -40001 +WXBizMsgCrypt_ParseJson_Error = -40002 +WXBizMsgCrypt_ComputeSignature_Error = -40003 +WXBizMsgCrypt_IllegalAesKey = -40004 +WXBizMsgCrypt_ValidateCorpid_Error = -40005 +WXBizMsgCrypt_EncryptAES_Error = -40006 +WXBizMsgCrypt_DecryptAES_Error = -40007 +WXBizMsgCrypt_IllegalBuffer = -40008 +WXBizMsgCrypt_EncodeBase64_Error = -40009 +WXBizMsgCrypt_DecodeBase64_Error = -40010 +WXBizMsgCrypt_GenReturnJson_Error = -40011 + + +class FormatException(Exception): + pass + + +def _gen_sha1(token, timestamp, nonce, encrypt): + """Compute the WeCom message signature with SHA1 over the sorted parts.""" + try: + if isinstance(encrypt, bytes): + encrypt = encrypt.decode("utf-8") + sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)] + sortlist.sort() + sha = hashlib.sha1() + sha.update("".join(sortlist).encode("utf-8")) + return WXBizMsgCrypt_OK, sha.hexdigest() + except Exception as e: + logger.error(f"[WecomBot] compute signature error: {e}") + return WXBizMsgCrypt_ComputeSignature_Error, None + + +class _PKCS7Encoder: + """PKCS#7 padding with a 32-byte block size (AES-256).""" + + block_size = 32 + + def encode(self, text: bytes) -> bytes: + text_length = len(text) + amount_to_pad = self.block_size - (text_length % self.block_size) + if amount_to_pad == 0: + amount_to_pad = self.block_size + pad = bytes([amount_to_pad]) + return text + pad * amount_to_pad + + def decode(self, decrypted: bytes) -> bytes: + pad = decrypted[-1] + if pad < 1 or pad > 32: + pad = 0 + return decrypted[:-pad] if pad else decrypted + + +class _Prpcrypt: + """AES-256-CBC encrypt/decrypt for WeCom callback messages.""" + + def __init__(self, key: bytes): + self.key = key + self.mode = AES.MODE_CBC + + def encrypt(self, text: str, receive_id: str): + text_bytes = text.encode() + # 16-byte random prefix + network-order length + body + receive_id + text_bytes = ( + self._get_random_str() + + struct.pack("I", socket.htonl(len(text_bytes))) + + text_bytes + + receive_id.encode() + ) + text_bytes = _PKCS7Encoder().encode(text_bytes) + try: + cryptor = AES.new(self.key, self.mode, self.key[:16]) + ciphertext = cryptor.encrypt(text_bytes) + return WXBizMsgCrypt_OK, base64.b64encode(ciphertext) + except Exception as e: + logger.error(f"[WecomBot] AES encrypt error: {e}") + return WXBizMsgCrypt_EncryptAES_Error, None + + def decrypt(self, text, receive_id: str): + try: + cryptor = AES.new(self.key, self.mode, self.key[:16]) + plain_text = cryptor.decrypt(base64.b64decode(text)) + except Exception as e: + logger.error(f"[WecomBot] AES decrypt error: {e}") + return WXBizMsgCrypt_DecryptAES_Error, None + try: + pad = plain_text[-1] + content = plain_text[16:-pad] + json_len = socket.ntohl(struct.unpack("I", content[:4])[0]) + json_content = content[4 : json_len + 4].decode("utf-8") + from_receive_id = content[json_len + 4 :].decode("utf-8") + except Exception as e: + logger.error(f"[WecomBot] illegal buffer when decrypting: {e}") + return WXBizMsgCrypt_IllegalBuffer, None + if from_receive_id != receive_id: + logger.error( + f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}" + ) + return WXBizMsgCrypt_ValidateCorpid_Error, None + return WXBizMsgCrypt_OK, json_content + + @staticmethod + def _get_random_str() -> bytes: + return str(random.randint(1000000000000000, 9999999999999999)).encode() + + +class WecomBotCrypt: + """High-level helper for verifying URLs and (de)crypting callback messages.""" + + def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""): + try: + self.key = base64.b64decode(encoding_aes_key + "=") + assert len(self.key) == 32 + except Exception: + raise FormatException("[WecomBot] invalid EncodingAESKey") + self.token = token + self.receive_id = receive_id + + def verify_url(self, msg_signature, timestamp, nonce, echostr): + ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr) + if ret != 0: + return ret, None + if signature != msg_signature: + return WXBizMsgCrypt_ValidateSignature_Error, None + pc = _Prpcrypt(self.key) + return pc.decrypt(echostr, self.receive_id) + + def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None): + """Encrypt a passive-reply JSON string and return the full response JSON. + + Returns (ret, response_dict). On success ret==0 and response_dict is a + dict with encrypt/msgsignature/timestamp/nonce fields. + """ + pc = _Prpcrypt(self.key) + ret, encrypt = pc.encrypt(reply_msg, self.receive_id) + if ret != 0: + return ret, None + encrypt = encrypt.decode("utf-8") + if timestamp is None: + timestamp = str(int(time.time())) + ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt) + if ret != 0: + return ret, None + return WXBizMsgCrypt_OK, { + "encrypt": encrypt, + "msgsignature": signature, + "timestamp": timestamp, + "nonce": nonce, + } + + def decrypt_msg(self, post_data, msg_signature, timestamp, nonce): + """Verify signature and decrypt the encrypted callback payload. + + ``post_data`` may be the raw request body (bytes/str) containing + ``{"encrypt": "..."}`` or the already-extracted encrypt string. + Returns (ret, plaintext_json_str). + """ + import json + + encrypt = None + if isinstance(post_data, (bytes, bytearray)): + post_data = post_data.decode("utf-8") + if isinstance(post_data, str): + try: + encrypt = json.loads(post_data).get("encrypt") + except Exception: + encrypt = post_data + elif isinstance(post_data, dict): + encrypt = post_data.get("encrypt") + if not encrypt: + return WXBizMsgCrypt_ParseJson_Error, None + + ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt) + if ret != 0: + return ret, None + if signature != msg_signature: + logger.error("[WecomBot] callback signature not match") + return WXBizMsgCrypt_ValidateSignature_Error, None + pc = _Prpcrypt(self.key) + return pc.decrypt(encrypt, self.receive_id) diff --git a/channel/wecom_bot/wecom_bot_message.py b/channel/wecom_bot/wecom_bot_message.py index 16b7dfdc..f021a24f 100644 --- a/channel/wecom_bot/wecom_bot_message.py +++ b/channel/wecom_bot/wecom_bot_message.py @@ -87,11 +87,14 @@ def _get_tmp_dir() -> str: class WecomBotMessage(ChatMessage): """Message wrapper for wecom bot (websocket long-connection mode).""" - def __init__(self, msg_body: dict, is_group: bool = False): + def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""): super().__init__(msg_body) self.msg_id = msg_body.get("msgid") self.create_time = msg_body.get("create_time") self.is_group = is_group + # In callback (webhook) mode the media bodies carry no per-message aeskey; + # the download url is encrypted with the bot's EncodingAESKey instead. + self._default_aeskey = default_aeskey msg_type = msg_body.get("msgtype") from_userid = msg_body.get("from", {}).get("userid", "") @@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage): self.ctype = ContextType.IMAGE image_info = msg_body.get("image", {}) image_url = image_info.get("url", "") - aeskey = image_info.get("aeskey", "") + aeskey = image_info.get("aeskey", "") or self._default_aeskey tmp_dir = _get_tmp_dir() image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png") @@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage): elif item_type == "image": img_info = item.get("image", {}) img_url = img_info.get("url", "") - img_aeskey = img_info.get("aeskey", "") + img_aeskey = img_info.get("aeskey", "") or self._default_aeskey img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png") try: img_data = _decrypt_media(img_url, img_aeskey) @@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage): self.ctype = ContextType.FILE file_info = msg_body.get("file", {}) file_url = file_info.get("url", "") - aeskey = file_info.get("aeskey", "") + aeskey = file_info.get("aeskey", "") or self._default_aeskey tmp_dir = _get_tmp_dir() base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}") self.content = base_path @@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage): self.ctype = ContextType.FILE video_info = msg_body.get("video", {}) video_url = video_info.get("url", "") - aeskey = video_info.get("aeskey", "") + aeskey = video_info.get("aeskey", "") or self._default_aeskey tmp_dir = _get_tmp_dir() self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4") diff --git a/config.py b/config.py index f711aad2..c6c67bf5 100644 --- a/config.py +++ b/config.py @@ -180,6 +180,11 @@ available_setting = { # WeCom smart bot config (long connection mode) "wecom_bot_id": "", # WeCom smart bot BotID "wecom_bot_secret": "", # WeCom smart bot long-connection secret + # WeCom smart bot callback (webhook) mode; default off, keep using the long connection + "wecom_bot_callback": False, # whether to receive messages via HTTP callback (webhook) instead of the long connection + "wecom_bot_token": "", # callback mode: Token configured on the bot's receive-message URL + "wecom_bot_encoding_aes_key": "", # callback mode: EncodingAESKey configured on the bot's receive-message URL + "wecom_bot_port": 9892, # callback mode: local HTTP server port for the receive-message URL # Telegram config "telegram_token": "", # Bot token from @BotFather "telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars) From 6b5ee245ae7417506653a15432cfa9bf3caf06ca Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 12 Jun 2026 18:03:33 +0800 Subject: [PATCH 365/399] feat(web): integrate custom providers into the provider credentials section - Merge the separate custom-providers section into the unified provider grid; "Custom" in the add-provider picker now acts as an add-new action (trailing + mark) and opens the dedicated modal, supporting multiple OpenAI-compatible endpoints - Simplify the custom provider modal: drop the default-model field, add an inline delete button, align colors with the theme - Keep the legacy single "custom" card visible (models page, chat dropdown and legacy config page) while custom_api_key/custom_api_base is still in use, so existing single-provider setups don't disappear - Unify user-facing wording from "vendor" to "provider" in UI and docs - Restructure custom provider docs (zh/en/ja) around Web console and config file usage --- channel/web/chat.html | 28 ++-- channel/web/static/css/console.css | 8 ++ channel/web/static/js/console.js | 221 +++++++++-------------------- channel/web/web_channel.py | 65 +++++++-- docs/ja/models/custom.mdx | 98 ++++--------- docs/models/custom.mdx | 88 +++--------- docs/models/deepseek.mdx | 2 +- docs/models/index.mdx | 14 +- docs/models/linkai.mdx | 2 +- docs/models/mimo.mdx | 2 +- docs/models/openai.mdx | 2 +- docs/models/qwen.mdx | 2 +- docs/releases/v2.0.3.mdx | 2 +- docs/releases/v2.0.7.mdx | 2 +- docs/zh/models/custom.mdx | 98 ++++--------- 15 files changed, 234 insertions(+), 400 deletions(-) diff --git a/channel/web/chat.html b/channel/web/chat.html index 0b71d745..9e5dadd6 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1171,8 +1171,8 @@ w-full max-w-md mx-4">
-
- +
+

@@ -1185,15 +1185,14 @@ + focus:outline-none focus:border-primary-500 transition-colors">
@@ -1201,20 +1200,15 @@ -
-
- - + focus:outline-none focus:border-primary-500 font-mono transition-colors">
-
+
+
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index e260bbc9..155e240a 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -854,6 +854,14 @@ font-size: 11px; flex-shrink: 0; } +/* "Custom" row is an add-new action: trailing + instead of ✓. */ +.vendor-picker-add-mark { + margin-left: auto; + padding-left: 12px; + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; +} /* Chat Input */ #chat-input { diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index f9a7b43c..c2b65819 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -32,21 +32,13 @@ const I18N = { models_clear_credential: '清除凭据', models_base_default_hint: '留空将使用官方默认地址', models_base_default: '默认', - models_custom_section: '自定义厂商', - models_custom_section_desc: '配置多个 OpenAI 兼容厂商,自由切换', - models_custom_add: '添加自定义厂商', + models_custom_vendor_label: '自定义', models_custom_name: '名称', - models_custom_name_placeholder: '例如 siliconflow', - models_custom_default_model: '默认模型(可选)', - models_custom_default_model_placeholder: '例如 deepseek-ai/DeepSeek-V3', - models_custom_active: '使用中', - models_custom_set_active: '设为使用中', models_custom_delete: '删除', models_custom_delete_confirm_title: '删除自定义厂商', models_custom_delete_confirm_msg: '确定删除该自定义厂商吗?此操作无法撤销。', models_custom_name_required: '请填写名称', models_custom_base_required: '请填写 API Base', - models_custom_empty: '尚未配置自定义厂商,点击添加', models_custom_edit_title: '编辑自定义厂商', models_custom_add_title: '添加自定义厂商', models_capability_chat: '主模型', @@ -240,10 +232,10 @@ const I18N = { menu_logs: 'Logs', models_title: 'Models', models_desc: 'Manage chat, image, voice, embedding and search capabilities in one place', - models_section_vendors: 'Vendor Credentials', + models_section_vendors: 'Provider Credentials', models_section_vendors_desc: 'Configured once, shared by multiple model capabilities', models_section_capabilities: 'Capabilities', - models_add_vendor: 'Add Vendor', + models_add_vendor: 'Add Provider', models_provider: 'Provider', models_model: 'Model', models_voice: 'Voice', @@ -253,21 +245,13 @@ const I18N = { models_clear_credential: 'Clear credentials', models_base_default_hint: 'Leave blank to use the official default base URL', models_base_default: 'Default', - models_custom_section: 'Custom Providers', - models_custom_section_desc: 'Configure multiple OpenAI-compatible providers and switch freely', - models_custom_add: 'Add custom provider', + models_custom_vendor_label: 'Custom', models_custom_name: 'Name', - models_custom_name_placeholder: 'e.g. siliconflow', - models_custom_default_model: 'Default model (optional)', - models_custom_default_model_placeholder: 'e.g. deepseek-ai/DeepSeek-V3', - models_custom_active: 'Active', - models_custom_set_active: 'Set active', models_custom_delete: 'Delete', models_custom_delete_confirm_title: 'Delete custom provider', models_custom_delete_confirm_msg: 'Delete this custom provider? This cannot be undone.', models_custom_name_required: 'Name is required', models_custom_base_required: 'API Base is required', - models_custom_empty: 'No custom providers yet, click to add', models_custom_edit_title: 'Edit custom provider', models_custom_add_title: 'Add custom provider', models_capability_chat: 'Main Model', @@ -311,8 +295,8 @@ const I18N = { models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.', models_embedding_saved_ok: 'Go', models_pick_provider: 'Pick a provider', - models_clear_confirm_title: 'Clear vendor credentials', - models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.', + models_clear_confirm_title: 'Clear provider credentials', + models_clear_confirm_msg: 'Remove this provider\'s API Key and Base URL? Capabilities relying on it will stop working.', cancel: 'Cancel', save: 'Save', ok: 'OK', @@ -4766,12 +4750,12 @@ function renderModelsView() { const container = document.getElementById('models-content'); container.innerHTML = ''; container.appendChild(renderVendorsSection()); - container.appendChild(renderCustomProvidersSection()); MODELS_CAPABILITY_DEFS.forEach(def => container.appendChild(renderCapabilityCard(def))); } // True when a provider card is one of the expanded custom (OpenAI-compatible) -// providers — these are managed in their own section, not the vendor grid. +// providers (id "custom:") — shown in the vendor grid alongside built-in +// vendors, but edited via the dedicated custom-provider modal. function isCustomProviderCard(p) { return !!(p && p.is_custom && p.custom_name); } @@ -4782,10 +4766,9 @@ function renderVendorsSection() { const wrap = document.createElement('div'); wrap.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6'; - // Expanded custom providers live in their own section; keep the built-in - // vendor grid focused on the canonical (field-based) providers. - const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); - const configured = builtinProviders.filter(p => p.configured); + // Custom providers always show once created (even without an api key, + // e.g. a local vLLM/Ollama endpoint); built-in vendors show when configured. + const configured = modelsState.providers.filter(p => p.configured || isCustomProviderCard(p)); const header = `
@@ -4796,7 +4779,6 @@ function renderVendorsSection() {

${t('models_section_vendors')}

${t('models_section_vendors_desc')}

- ${configured.length}/${builtinProviders.length}
`; let body; @@ -4822,8 +4804,13 @@ function renderVendorsSection() { function renderVendorChip(p) { // The masked API key is intentionally not surfaced here; it is shown // inside the edit modal so the chip stays uncluttered and scannable. + // Custom providers open their dedicated modal (name + base + key); + // their ids are server-generated hex, safe to inline. + const onclick = isCustomProviderCard(p) + ? `openCustomProviderModal('${escapeHtml(p.custom_id)}')` + : `openVendorModal('${escapeHtml(p.id)}')`; return ` - -
`; - - let body; - if (customs.length === 0) { - body = ` -
-

${t('models_custom_empty')}

-
`; - } else { - body = `
- ${customs.map(renderCustomProviderRow).join('')} -
`; - } - - wrap.innerHTML = header + body; - // Event delegation — handles all custom-provider actions via data-action attrs. - wrap.addEventListener('click', function(e) { - const btn = e.target.closest('[data-action]'); - if (!btn) return; - const action = btn.getAttribute('data-action'); - const providerId = btn.getAttribute('data-provider-id') || ''; - if (action === 'add-custom') openCustomProviderModal(''); - else if (action === 'edit-custom') openCustomProviderModal(providerId); - else if (action === 'delete-custom') deleteCustomProvider(providerId); - else if (action === 'set-active-custom') setActiveCustomProvider(providerId); - }); - return wrap; -} - -function renderCustomProviderRow(p) { - const id = p.custom_id || ''; - const name = p.custom_name || ''; - const nameEsc = escapeHtml(name); - // The active provider gets a highlighted ring + badge; others show a - // "set active" affordance via data-attributes (no inline onclick — XSS safe). - const activeBadge = p.active - ? ` - ${t('models_custom_active')}` - : ``; - - const ring = p.active - ? 'border-emerald-300 dark:border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-900/10' - : 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5'; - - const model = p.model - ? `${escapeHtml(p.model)}` - : ''; - const base = p.api_base - ? `${escapeHtml(p.api_base)}` - : ''; - - return ` -
- ${renderProviderLogo(p, 28)} -
-
- ${nameEsc} - ${activeBadge} -
-
${base}${model ? '·' + model : ''}
-
- - -
`; -} - // ---------- Capability cards (Layer 2) --------------------------------- function renderCapabilityCard(def) { @@ -5776,6 +5661,14 @@ function decorateVendorModalPicker(ddEl, opts) { // Tag the row so the global active-row ✓ rule is suppressed in CSS // (otherwise configured AND selected rows would render two checks). item.classList.add('vendor-picker-item'); + if (opt._isAddNew) { + // "Custom" is an add-new action (multiple entries allowed), + // so show a trailing + instead of the configured ✓. + const plus = document.createElement('i'); + plus.className = 'fas fa-plus vendor-picker-add-mark'; + item.appendChild(plus); + return; + } if (!opt._configured) return; const check = document.createElement('i'); check.className = 'fas fa-check vendor-picker-configured-mark'; @@ -6083,22 +5976,42 @@ function openVendorModal(providerId, onSaved) { // currently selected vendor via its own background highlight, so we // intentionally suppress the global active-row ✓ for this picker // (see CSS) — otherwise configured + selected rows would show two. - // Custom (OpenAI-compatible) providers are managed in their own - // section with a dedicated modal, so they are excluded from this - // built-in vendor picker. + // Expanded custom provider cards ("custom:") are edited via their + // dedicated modal, so they are excluded from this picker. Picking the + // "custom" entry creates a *new* custom provider via that modal — + // this is how multiple OpenAI-compatible endpoints are added. const builtinProviders = modelsState.providers.filter(p => !isCustomProviderCard(p)); - const unconfigured = builtinProviders.filter(p => !p.configured); - const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || ''; - pickerWrap.classList.remove('hidden'); - const pickerEl = document.getElementById('vendor-modal-picker'); const pickerOpts = builtinProviders.map(p => ({ value: p.id, label: localizedLabel(p.label), _configured: !!p.configured, })); - initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val)); + // In multi-provider mode the backend replaces the bare "custom" card + // with the expanded ones; re-add it here so the entry stays available. + if (!pickerOpts.some(o => o.value === 'custom')) { + pickerOpts.push({ value: 'custom', label: t('models_custom_vendor_label'), _configured: false }); + } + // "Custom" always behaves as an add-new action (multiple entries + // allowed), so it shows a + mark instead of the configured ✓. + pickerOpts.forEach(o => { if (o.value === 'custom') { o._isAddNew = true; o._configured = false; } }); + const unconfigured = builtinProviders.filter(p => !p.configured); + const defaultId = (unconfigured[0] && unconfigured[0].id) || (builtinProviders[0] && builtinProviders[0].id) || 'custom'; + pickerWrap.classList.remove('hidden'); + const pickerEl = document.getElementById('vendor-modal-picker'); + const onPick = (val) => { + if (val === 'custom') { + // "Custom" in the add flow always creates a new + // OpenAI-compatible provider entry via the dedicated modal + // (name + base + key), supporting multiple custom endpoints. + closeVendorModal(); + openCustomProviderModal(''); + return; + } + fillVendorModalForProvider(val); + }; + initDropdown(pickerEl, pickerOpts, defaultId, onPick); decorateVendorModalPicker(pickerEl, pickerOpts); - fillVendorModalForProvider(defaultId); + onPick(defaultId); } else { pickerWrap.classList.add('hidden'); fillVendorModalForProvider(providerId); @@ -6276,11 +6189,9 @@ function openCustomProviderModal(providerId) { const nameInput = document.getElementById('custom-provider-name'); const baseInput = document.getElementById('custom-provider-base'); const keyInput = document.getElementById('custom-provider-key'); - const modelInput = document.getElementById('custom-provider-model'); nameInput.value = card ? (card.custom_name || '') : ''; baseInput.value = card ? (card.api_base || '') : ''; - modelInput.value = card ? (card.model || '') : ''; // Surface the masked key as the value for configured providers so the // "already set" state is unambiguous; an untouched masked value means @@ -6307,6 +6218,13 @@ function openCustomProviderModal(providerId) { document.getElementById('custom-provider-modal-cancel').onclick = closeCustomProviderModal; document.getElementById('custom-provider-modal-save').onclick = saveCustomProviderModal; + // Delete is only available when editing an existing provider. + const deleteBtn = document.getElementById('custom-provider-modal-delete'); + if (deleteBtn) { + deleteBtn.classList.toggle('hidden', !editing); + deleteBtn.onclick = editing ? () => deleteCustomProvider(providerId) : null; + } + function onOverlayClick(e) { if (e.target === overlay) { closeCustomProviderModal(); @@ -6325,7 +6243,6 @@ function closeCustomProviderModal() { function saveCustomProviderModal() { const name = document.getElementById('custom-provider-name').value.trim(); const apiBase = document.getElementById('custom-provider-base').value.trim(); - const model = document.getElementById('custom-provider-model').value.trim(); const keyInput = document.getElementById('custom-provider-key'); if (!name) { @@ -6350,7 +6267,6 @@ function saveCustomProviderModal() { action: 'set_custom_provider', name: name, api_base: apiBase, - model: model, }; if (apiKey) payload.api_key = apiKey; if (editing) payload.id = customProviderModalState.editId; @@ -6375,16 +6291,6 @@ function saveCustomProviderModal() { }); } -function setActiveCustomProvider(providerId) { - fetch('/api/models', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'set_active_custom_provider', id: providerId }), - }).then(r => r.json()).then(data => { - if (data.status === 'success') loadModelsView(); - }).catch(() => { /* noop */ }); -} - function deleteCustomProvider(providerId) { showConfirmDialog({ title: t('models_custom_delete_confirm_title'), @@ -6397,7 +6303,10 @@ function deleteCustomProvider(providerId) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete_custom_provider', id: providerId }), }).then(r => r.json()).then(data => { - if (data.status === 'success') loadModelsView(); + if (data.status === 'success') { + closeCustomProviderModal(); + loadModelsView(); + } }).catch(() => { /* noop */ }); } }); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 6a59beb6..e45690f4 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1643,6 +1643,32 @@ class ConfigHandler: "api_key_field": p.get("api_key_field"), } + # Expose user-defined custom providers as "custom:" entries so + # the legacy config page can display and select them. Credentials + # are managed on the Models page, hence the null key/base fields. + # Mirrors the Models page: when expanded entries exist, the bare + # legacy "custom" entry is hidden — unless the flat single-provider + # custom config is still active or filled in. + try: + from models.custom_provider import get_custom_providers + custom_list = get_custom_providers() + legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config) + if custom_list and not legacy_custom_in_use: + providers.pop("custom", None) + for cp in custom_list: + cid = f"custom:{cp.get('id')}" + cname = cp.get("name") or cp.get("id") + providers[cid] = { + "label": {"zh": cname, "en": cname}, + "models": [cp["model"]] if cp.get("model") else [], + "api_base_key": None, + "api_base_default": None, + "api_base_placeholder": "", + "api_key_field": None, + } + except Exception as cp_err: + logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}") + raw_pwd = str(local_config.get("web_password", "") or "") masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else "" @@ -2189,6 +2215,17 @@ class ModelsHandler: }) return cards + @classmethod + def _legacy_custom_in_use(cls, local_config: dict) -> bool: + """True when the flat single-provider custom config is still relevant: + either it is the active bot_type, or its key/base fields are filled. + In that case the legacy "custom" card must stay visible even when + multi ``custom_providers`` entries exist.""" + if (local_config.get("bot_type") or "") == "custom": + return True + return (cls._is_real_key(local_config.get("custom_api_key") or "") + or bool(local_config.get("custom_api_base"))) + @classmethod def _provider_overview(cls) -> List[dict]: """All known providers (configured first, unconfigured after). @@ -2202,13 +2239,18 @@ class ModelsHandler: """ local_config = conf() custom_cards = cls._custom_provider_cards(local_config) + # Keep the legacy single "custom" card visible alongside the expanded + # ones when the flat custom_api_key/base config is active or filled, + # so existing single-provider setups never disappear from the UI. + keep_legacy_custom = cls._legacy_custom_in_use(local_config) items = [] for pid, p in ConfigHandler.PROVIDER_MODELS.items(): if pid == "custom" and custom_cards: - # Multi-provider mode: emit the expanded cards instead of the - # single legacy custom card. + # Multi-provider mode: emit the expanded cards, plus the + # legacy card when it is still in use. items.extend(custom_cards) - continue + if not keep_legacy_custom: + continue key_field = p.get("api_key_field") base_field = p.get("api_base_key") raw_key = local_config.get(key_field, "") if key_field else "" @@ -2253,11 +2295,15 @@ class ModelsHandler: provider_id = "linkai" # In multi-provider mode, replace the single "custom" entry with the # expanded "custom:" ids so the chat dropdown matches the cards. + # The legacy "custom" entry stays when its flat config is still used. provider_ids = [] custom_cards = cls._custom_provider_cards(local_config) + keep_legacy_custom = cls._legacy_custom_in_use(local_config) for pid in ConfigHandler.PROVIDER_MODELS.keys(): if pid == "custom" and custom_cards: provider_ids.extend(c["id"] for c in custom_cards) + if keep_legacy_custom: + provider_ids.append(pid) else: provider_ids.append(pid) return { @@ -2881,11 +2927,14 @@ class ModelsHandler: existing["api_base"] = api_base if api_key: existing["api_key"] = api_key - # model is always overwritten (empty clears the default model). - if model: - existing["model"] = model - else: - existing.pop("model", None) + # Only touch model when explicitly provided in the payload; an + # explicit empty string clears it, a missing key keeps it (the + # UI modal no longer sends model, so manual config survives edits). + if "model" in data: + if model: + existing["model"] = model + else: + existing.pop("model", None) created = False # Decide bot_type — only switch when explicitly requested. diff --git a/docs/ja/models/custom.mdx b/docs/ja/models/custom.mdx index b63f4e34..3df7dc11 100644 --- a/docs/ja/models/custom.mdx +++ b/docs/ja/models/custom.mdx @@ -1,69 +1,31 @@ --- -title: Custom -description: Custom vendor configuration for third-party API proxies and local models +title: カスタム +description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定 --- -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: +OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば: -- **Third-party API proxies**: call multiple models through a unified API base -- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI -- **Private deployments**: model services deployed inside an enterprise +- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す +- **ローカルモデル**:Ollama、vLLM などのツールでローカルにデプロイしたモデル +- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス - - Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. - +## Web コンソールでの設定 -## Text Chat +推奨方法です。Web コンソールの「モデル」ページで「プロバイダーを追加」をクリックし、「カスタム」を選択して、名称・API Base・API Key を入力します。複数のカスタムプロバイダーを追加でき、追加後は「メインモデル」でプロバイダーとモデルを選択すると有効になります。 -### Third-party API proxy + -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` +主なローカルデプロイツールのデフォルトアドレス: -| Parameter | Description | -| --- | --- | -| `bot_type` | Must be set to `custom` | -| `model` | Model name; any model name supported by the proxy service | -| `custom_api_key` | API key provided by the proxy service | -| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | - -### Local models - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: - -| Tool | Default API Base | +| ツール | デフォルト API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### Switching Models +## 設定ファイルでの設定 -Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: - -``` -/config model qwen3.5:27b -``` - -## Configuring Multiple Custom Providers - -If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: +`config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:"` に設定していずれかを有効化します: ```json { @@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once "custom_providers": [ { "id": "3f2a9c1b", - "name": "siliconflow", - "api_key": "YOUR_SILICONFLOW_KEY", - "api_base": "https://api.siliconflow.cn/v1", - "model": "deepseek-ai/DeepSeek-V3" + "name": "プロバイダーA", + "api_key": "YOUR_API_KEY_A", + "api_base": "https://api.a.com/v1", + "model": "deepseek-v3" }, { "id": "a1b2c3d4", - "name": "qiniu", - "api_key": "YOUR_QINIU_KEY", - "api_base": "https://api.qnaigc.com/v1", - "model": "deepseek-v3" + "name": "プロバイダーB", + "api_key": "YOUR_API_KEY_B", + "api_base": "https://api.b.com/v1", + "model": "qwen3-max" } ] } ``` -| Parameter | Description | +| パラメータ | 説明 | | --- | --- | -| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | -| `bot_type` | Set to `"custom:"` to activate a specific provider; `id` is the provider's unique identifier | -| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | -| `name` | User-facing display label — can be freely renamed without breaking anything | -| `model` | Optional; when set, this model is used instead of the global `model` field | +| `custom_providers` | カスタムプロバイダーのリスト。各項目は `id`、`name`、`api_base`、`api_key`(任意)、`model`(任意)を含む | +| `bot_type` | `"custom:"` に設定して対応するプロバイダーを有効化 | +| `id` | 一意の識別子(8 桁の 16 進数)。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい | +| `name` | 表示名。自由に変更可能 | +| `model` | このプロバイダーで使用するモデル。有効化時に適用される | - The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`). - - - - When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes. + 従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。 diff --git a/docs/models/custom.mdx b/docs/models/custom.mdx index b63f4e34..33b84fa7 100644 --- a/docs/models/custom.mdx +++ b/docs/models/custom.mdx @@ -1,51 +1,21 @@ --- title: Custom -description: Custom vendor configuration for third-party API proxies and local models +description: Custom provider configuration for third-party API proxies and local models --- -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: +For model services accessed via the OpenAI-compatible protocol, such as: - **Third-party API proxies**: call multiple models through a unified API base -- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI +- **Local models**: models deployed locally with tools like Ollama, vLLM - **Private deployments**: model services deployed inside an enterprise - - Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. - +## Web Console -## Text Chat +Recommended. On the "Models" page of the Web console, click "Add Provider" and pick "Custom", then fill in the name, API Base and API Key. Multiple custom providers can be added; after adding one, select it together with a model in the "Main Model" card to enable it. -### Third-party API proxy + -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` - -| Parameter | Description | -| --- | --- | -| `bot_type` | Must be set to `custom` | -| `model` | Model name; any model name supported by the proxy service | -| `custom_api_key` | API key provided by the proxy service | -| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | - -### Local models - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: +Default endpoints of common local deployment tools: | Tool | Default API Base | | --- | --- | @@ -53,17 +23,9 @@ Common local deployment tools and their default endpoints: | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### Switching Models +## Configuration File -Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: - -``` -/config model qwen3.5:27b -``` - -## Configuring Multiple Custom Providers - -If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: +You can also edit `config.json` directly: define multiple providers in the `custom_providers` list and set `bot_type` to `"custom:"` to activate one of them: ```json { @@ -71,17 +33,17 @@ If you need to configure several OpenAI-compatible third-party services at once "custom_providers": [ { "id": "3f2a9c1b", - "name": "siliconflow", - "api_key": "YOUR_SILICONFLOW_KEY", - "api_base": "https://api.siliconflow.cn/v1", - "model": "deepseek-ai/DeepSeek-V3" + "name": "ProviderA", + "api_key": "YOUR_API_KEY_A", + "api_base": "https://api.a.com/v1", + "model": "deepseek-v3" }, { "id": "a1b2c3d4", - "name": "qiniu", - "api_key": "YOUR_QINIU_KEY", - "api_base": "https://api.qnaigc.com/v1", - "model": "deepseek-v3" + "name": "ProviderB", + "api_key": "YOUR_API_KEY_B", + "api_base": "https://api.b.com/v1", + "model": "qwen3-max" } ] } @@ -89,16 +51,12 @@ If you need to configure several OpenAI-compatible third-party services at once | Parameter | Description | | --- | --- | -| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | -| `bot_type` | Set to `"custom:"` to activate a specific provider; `id` is the provider's unique identifier | -| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | -| `name` | User-facing display label — can be freely renamed without breaking anything | -| `model` | Optional; when set, this model is used instead of the global `model` field | +| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_base`, `api_key` (optional) and `model` (optional) | +| `bot_type` | Set to `"custom:"` to activate the corresponding provider | +| `id` | Unique identifier (8-char hex); auto-generated when adding via the Web console, or any unique string when editing manually | +| `name` | Display label, can be renamed freely | +| `model` | Model used by this provider, takes effect when activated | - The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`). - - - - When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes. + The legacy single-provider configuration (`bot_type` set to `"custom"` with `custom_api_key` / `custom_api_base`) remains fully compatible and keeps working without any changes. diff --git a/docs/models/deepseek.mdx b/docs/models/deepseek.mdx index 6de8d09b..a389701e 100644 --- a/docs/models/deepseek.mdx +++ b/docs/models/deepseek.mdx @@ -3,7 +3,7 @@ title: DeepSeek description: DeepSeek model configuration (Text Chat + Thinking Mode) --- -DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning. +DeepSeek is one of the default recommended providers in Agent mode, focused on cost-effective text chat and task planning. ## Text Chat diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 7575adb4..f99acd6f 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -1,15 +1,15 @@ --- title: Models Overview -description: Model vendors supported by CowAgent and their capability matrix +description: Model providers supported by CowAgent and their capability matrix --- -CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow. +CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several providers also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow. ## Capability Matrix -A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power. +A snapshot of each provider's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the provider can power. -| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | +| Provider | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | @@ -22,11 +22,11 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; | [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | | | [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | | [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | | -| [LinkAI](/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | | - Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them. + Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own provider and model — there is no forced binding between them. ## How to Configure @@ -35,4 +35,4 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model; -**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`. +**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected provider. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`. diff --git a/docs/models/linkai.mdx b/docs/models/linkai.mdx index e078a945..3610a1a1 100644 --- a/docs/models/linkai.mdx +++ b/docs/models/linkai.mdx @@ -3,7 +3,7 @@ title: LinkAI description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform --- -A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao. +A single `linkai_api_key` gives you access to all capabilities of mainstream providers such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao. All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. diff --git a/docs/models/mimo.mdx b/docs/models/mimo.mdx index 6f808b8e..038c1fff 100644 --- a/docs/models/mimo.mdx +++ b/docs/models/mimo.mdx @@ -49,7 +49,7 @@ Use the global `enable_thinking` flag to toggle visibility (also switchable from Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models: - When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup. -- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order. +- When the main model belongs to another provider, the Vision tool falls back to `mimo-v2.5-pro` in order. To force a specific Vision model, set it explicitly in the configuration: diff --git a/docs/models/openai.mdx b/docs/models/openai.mdx index f8715562..7d428d95 100644 --- a/docs/models/openai.mdx +++ b/docs/models/openai.mdx @@ -25,7 +25,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat, | `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency | | `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) | | `open_ai_api_base` | Optional; change it to access a third-party proxy | -| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol | +| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol | ## Image Understanding diff --git a/docs/models/qwen.mdx b/docs/models/qwen.mdx index 76a419fe..60965131 100644 --- a/docs/models/qwen.mdx +++ b/docs/models/qwen.mdx @@ -3,7 +3,7 @@ title: Qwen description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding) --- -Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`. +Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured providers. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`. All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file. diff --git a/docs/releases/v2.0.3.mdx b/docs/releases/v2.0.3.mdx index 5f9a837d..da68ccfd 100644 --- a/docs/releases/v2.0.3.mdx +++ b/docs/releases/v2.0.3.mdx @@ -34,7 +34,7 @@ Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b) ## 💰 Coding Plan Support -Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine. +Added integration with provider Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported providers include Aliyun, MiniMax, GLM, Kimi, and Volcengine. See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration. diff --git a/docs/releases/v2.0.7.mdx b/docs/releases/v2.0.7.mdx index 522e5339..a843bea5 100644 --- a/docs/releases/v2.0.7.mdx +++ b/docs/releases/v2.0.7.mdx @@ -22,7 +22,7 @@ Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generati - **Claude Opus 4.7**: Added `claude-opus-4-7` model support - **GLM 5.1**: Added `glm-5.1` model support - **Kimi Coding Plan**: Support for Kimi Coding Plan mode -- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors +- **Custom model providers**: New custom model provider configuration for easier integration with additional providers ## 💬 Web Console Improvements diff --git a/docs/zh/models/custom.mdx b/docs/zh/models/custom.mdx index b63f4e34..79709247 100644 --- a/docs/zh/models/custom.mdx +++ b/docs/zh/models/custom.mdx @@ -1,69 +1,31 @@ --- -title: Custom -description: Custom vendor configuration for third-party API proxies and local models +title: 自定义 +description: 自定义厂商配置,用于第三方 API 代理与本地模型 --- -For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as: +适用于通过 OpenAI 兼容协议接入的模型服务,例如: -- **Third-party API proxies**: call multiple models through a unified API base -- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI -- **Private deployments**: model services deployed inside an enterprise +- **第三方 API 代理**:通过统一的 API 地址调用多种模型 +- **本地模型**:使用 Ollama、vLLM 等工具在本地部署的模型 +- **私有化部署**:企业内部部署的模型服务 - - Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used. - +## Web 端配置 -## Text Chat +推荐方式。在 Web 控制台「模型」页面点击「添加厂商」,选择「自定义」,填写名称、API Base 和 API Key 即可。支持添加多个自定义厂商,添加后在「主模型」中选择对应厂商和模型即可启用。 -### Third-party API proxy + -```json -{ - "bot_type": "custom", - "model": "", - "custom_api_key": "YOUR_API_KEY", - "custom_api_base": "https://{your-proxy.com}/v1" -} -``` +本地部署工具的默认地址: -| Parameter | Description | -| --- | --- | -| `bot_type` | Must be set to `custom` | -| `model` | Model name; any model name supported by the proxy service | -| `custom_api_key` | API key provided by the proxy service | -| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible | - -### Local models - -Local models usually do not require an API key — only the API base needs to be filled in: - -```json -{ - "bot_type": "custom", - "model": "qwen3.5:27b", - "custom_api_base": "http://localhost:11434/v1" -} -``` - -Common local deployment tools and their default endpoints: - -| Tool | Default API Base | +| 工具 | 默认 API Base | | --- | --- | | [Ollama](https://ollama.com) | `http://localhost:11434/v1` | | [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` | | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | -### Switching Models +## 配置文件配置 -Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged: - -``` -/config model qwen3.5:27b -``` - -## Configuring Multiple Custom Providers - -If you need to configure several OpenAI-compatible third-party services at once (e.g. SiliconFlow, Qiniu), use the `custom_providers` list and activate one by setting `bot_type` to `"custom:"`: +也可以直接编辑 `config.json`,在 `custom_providers` 列表中定义多个厂商,并将 `bot_type` 设置为 `"custom:"` 来激活其中一个: ```json { @@ -71,34 +33,30 @@ If you need to configure several OpenAI-compatible third-party services at once "custom_providers": [ { "id": "3f2a9c1b", - "name": "siliconflow", - "api_key": "YOUR_SILICONFLOW_KEY", - "api_base": "https://api.siliconflow.cn/v1", - "model": "deepseek-ai/DeepSeek-V3" + "name": "厂商A", + "api_key": "YOUR_API_KEY_A", + "api_base": "https://api.a.com/v1", + "model": "deepseek-v3" }, { "id": "a1b2c3d4", - "name": "qiniu", - "api_key": "YOUR_QINIU_KEY", - "api_base": "https://api.qnaigc.com/v1", - "model": "deepseek-v3" + "name": "厂商B", + "api_key": "YOUR_API_KEY_B", + "api_base": "https://api.b.com/v1", + "model": "qwen3-max" } ] } ``` -| Parameter | Description | +| 参数 | 说明 | | --- | --- | -| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_key`, `api_base`, and an optional `model` | -| `bot_type` | Set to `"custom:"` to activate a specific provider; `id` is the provider's unique identifier | -| `id` | Server-generated short identifier (8-char hex); used as the primary key for each provider | -| `name` | User-facing display label — can be freely renamed without breaking anything | -| `model` | Optional; when set, this model is used instead of the global `model` field | +| `custom_providers` | 自定义厂商列表,每项包含 `id`、`name`、`api_base`、`api_key`(可选)、`model`(可选) | +| `bot_type` | 设置为 `"custom:"` 激活对应厂商 | +| `id` | 厂商唯一标识(8 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 | +| `name` | 展示名称,可随意修改 | +| `model` | 该厂商使用的模型,激活时生效 | - The `id` is auto-generated by the web console when you add a provider — you don't need to create it manually. If editing `config.json` by hand, use any unique 8-character hex string (e.g. run `python3 -c "import uuid; print(uuid.uuid4().hex[:8])"`). - - - - When `bot_type` is exactly `"custom"` (no colon suffix), CowAgent uses the legacy single-provider `custom_api_key` / `custom_api_base` fields, so existing configurations keep working without any changes. + 历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。 From 561631babad6d089beb692032641a20f77a9975c Mon Sep 17 00:00:00 2001 From: 6vision Date: Fri, 12 Jun 2026 18:24:33 +0800 Subject: [PATCH 366/399] fix(wecom_bot): rescue late replies via response_url + fix degrade notice Handle agent replies that finish after WeCom stops polling the passive stream (the poll window is ~6min from the user's message). - Capture response_url from the message callback and, when a reply is finalized but no poll picks it up within a short grace period, push it as a one-shot active markdown reply (valid 1h, single use) - Guard against double delivery via delivered/url_sent flags - Embed public image URLs in the active markdown; note when a local image can't be delivered post-timeout - Append (instead of discarding) the unsupported-type notice for file/voice/video replies so streamed text is preserved - Quiet the per-poll debug log and log stream completion with content size Co-authored-by: Cursor --- channel/wecom_bot/wecom_bot_channel.py | 110 ++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/channel/wecom_bot/wecom_bot_channel.py b/channel/wecom_bot/wecom_bot_channel.py index aaa37aa8..d13236ac 100644 --- a/channel/wecom_bot/wecom_bot_channel.py +++ b/channel/wecom_bot/wecom_bot_channel.py @@ -247,16 +247,22 @@ class WecomBotChannel(ChatChannel): except (KeyboardInterrupt, SystemExit): server.stop() - def _new_callback_stream(self) -> str: + def _new_callback_stream(self, response_url: str = "") -> str: """Create a new stream state and return its id.""" stream_id = uuid.uuid4().hex[:16] + now = time.time() with self._callback_lock: self._callback_streams[stream_id] = { "committed": "", "current": "", "finished": False, "images": [], # list of (base64_str, md5_str), flushed only at finish - "last_access": time.time(), + "image_urls": [], # public http(s) image urls (usable in response_url markdown) + "last_access": now, + "created_at": now, + "response_url": response_url or "", + "delivered": False, # final answer handed to WeCom via a poll + "url_sent": False, # final answer pushed via response_url (active reply) } return stream_id @@ -284,7 +290,10 @@ class WecomBotChannel(ChatChannel): return None context, wecom_msg = result - stream_id = self._new_callback_stream() + # response_url lets us actively reply once within 1h, used as a fallback + # when the agent finishes after WeCom stops polling (max ~6min window). + response_url = data.get("response_url", "") or "" + stream_id = self._new_callback_stream(response_url=response_url) wecom_msg.stream_id = stream_id context["wecom_stream_id"] = stream_id context["on_event"] = self._make_callback_stream_callback(stream_id) @@ -305,9 +314,15 @@ class WecomBotChannel(ChatChannel): # Unknown / expired stream: tell WeCom we're done to stop polling. return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}} state["last_access"] = time.time() - content = state["committed"] + state["current"] finished = state["finished"] + if state.get("url_sent"): + # Final answer already pushed via response_url; finish silently. + return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}} + content = state["committed"] + state["current"] images = state["images"] if finished else [] + if finished: + state["delivered"] = True + logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}") stream = {"id": stream_id, "finish": finished, "content": content} if images: @@ -698,37 +713,50 @@ class WecomBotChannel(ChatChannel): self._callback_finalize_image(stream_id, reply.content) elif reply.type == ReplyType.FILE: # Passive callback replies only support text + image (base64); files - # are not supported by the protocol, so degrade to a text notice. + # are not supported by the protocol, so append a notice to whatever + # text the agent already streamed (do not drop it). text = getattr(reply, "text_content", "") or "" - note = (text + "\n" if text else "") + "[文件回复在回调模式下暂不支持]" - self._callback_finalize_text(stream_id, note) + note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)" + self._callback_finalize_text(stream_id, note, append=True) elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE): logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode") - self._callback_finalize_text(stream_id, "[该消息类型在回调模式下暂不支持]") + text = getattr(reply, "text_content", "") or "" + note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)" + self._callback_finalize_text(stream_id, note, append=True) else: self._callback_finalize_text(stream_id, str(reply.content)) def _callback_get_or_create_state(self, stream_id: str) -> dict: state = self._callback_streams.get(stream_id) if state is None: + now = time.time() state = { "committed": "", "current": "", "finished": False, "images": [], - "last_access": time.time(), + "image_urls": [], + "last_access": now, + "created_at": now, + "response_url": "", + "delivered": False, + "url_sent": False, } self._callback_streams[stream_id] = state return state - def _callback_finalize_text(self, stream_id: str, content: str): + def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False): with self._callback_lock: state = self._callback_get_or_create_state(stream_id) accumulated = (state["committed"] + state["current"]).strip() - state["committed"] = accumulated if accumulated else (content or "") + if append and accumulated: + state["committed"] = (accumulated + "\n\n" + (content or "")).strip() + else: + state["committed"] = accumulated if accumulated else (content or "") state["current"] = "" state["finished"] = True state["last_access"] = time.time() + self._schedule_response_url_fallback(stream_id) def _callback_finalize_image(self, stream_id: str, img_path_or_url: str): b64md5 = self._load_image_base64(img_path_or_url) @@ -739,10 +767,65 @@ class WecomBotChannel(ChatChannel): if b64md5: state["images"].append(b64md5) state["committed"] = accumulated + # Remember the public url (if any) so the response_url fallback + # can embed it as markdown when the poll window has closed. + if img_path_or_url.startswith(("http://", "https://")): + state["image_urls"].append(img_path_or_url) else: state["committed"] = accumulated or "[图片发送失败]" state["finished"] = True state["last_access"] = time.time() + self._schedule_response_url_fallback(stream_id) + + # ------------------------------------------------------------------ + # Active reply fallback (response_url): rescue replies that finish after + # WeCom stops polling (the passive stream window is ~6 min from the user's + # message). A short delay lets an in-flight poll deliver first; only if no + # poll picks up the finished answer do we push it actively via response_url. + # ------------------------------------------------------------------ + + def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0): + def _run(): + time.sleep(delay) + with self._callback_lock: + state = self._callback_streams.get(stream_id) + if not state: + return + if state.get("delivered") or state.get("url_sent"): + return # a poll already delivered (or fallback already ran) + response_url = state.get("response_url") or "" + if not response_url: + logger.warning( + f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped" + ) + return + content = (state["committed"] + state["current"]).strip() + image_urls = list(state.get("image_urls") or []) + has_images = bool(state.get("images")) + state["url_sent"] = True + + self._send_via_response_url(stream_id, response_url, content, image_urls, has_images) + + threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start() + + def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images): + """Push a one-shot active markdown reply to response_url (valid 1h, single use).""" + md = content or "" + if image_urls: + md += ("\n\n" if md else "") + "\n".join(f"![]({u})" for u in image_urls) + elif has_images: + md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)" + if not md: + md = "(处理完成)" + payload = {"msgtype": "markdown", "markdown": {"content": md}} + try: + resp = requests.post(response_url, json=payload, timeout=15) + logger.info( + f"[WecomBot] response_url active reply sent for {stream_id}: " + f"status={resp.status_code}, body={resp.text[:200]}" + ) + except Exception as e: + logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}") def _load_image_base64(self, img_path_or_url: str): """Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5).""" @@ -1254,7 +1337,10 @@ class WecomBotCallbackController: return "success" msgtype = data.get("msgtype", "") - logger.debug(f"[WecomBot] callback received msgtype={msgtype}") + # Stream polls arrive ~1/s; logging each is noisy, so only log non-poll + # callbacks here (poll completion is logged in the stream-poll handler). + if msgtype != "stream": + logger.debug(f"[WecomBot] callback received msgtype={msgtype}") try: if msgtype == "stream": From 583c1de5baa704f6ae6a9c6dd1bf2f31ee2d3625 Mon Sep 17 00:00:00 2001 From: sufan721 Date: Sat, 13 Jun 2026 16:50:54 +0800 Subject: [PATCH 367/399] =?UTF-8?q?=20=20-=20Auto-scan=20roles/*.json=20on?= =?UTF-8?q?=20startup=20and=20merge=20into=20built-in=20roles=20=20=20-=20?= =?UTF-8?q?Same=20title=20overrides=20built-in=20role;=20different=20title?= =?UTF-8?q?=20appends=20as=20new=20=20=20-=20roles/=20directory=20is=20opt?= =?UTF-8?q?ional=20=E2=80=94=20no=20impact=20when=20absent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now add custom roles by simply dropping a .json file into the roles/ directory and restarting. No config changes needed. --- plugins/role/README.md | 53 +++++++++++++++++++++++++++++++++++------- plugins/role/role.py | 29 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/plugins/role/README.md b/plugins/role/README.md index f53e9575..25f7640c 100644 --- a/plugins/role/README.md +++ b/plugins/role/README.md @@ -4,19 +4,41 @@ - `$角色/$role <角色名>` - 让AI扮演该角色,角色名支持模糊匹配。 - `$停止扮演` - 停止角色扮演。 -添加自定义角色请在`roles/roles.json`中添加。 +## 目录结构 + +``` +plugins/role/ +├── README.md +├── __init__.py +├── role.py # 插件主逻辑 +├── roles.json # 内置角色库(所有默认角色) +├── schema/ +│ └── role_template.json # 角色模板,新增角色时复制此文件 +└── roles/ # 可选:放自定义角色文件,自动加载 + └── 我的角色.json # 同title会覆盖内置角色,不同则追加 +``` + +## 添加自定义角色 + +1. 新建roles目录 +2. 复制 下面模板 到 `roles/` 目录并重命名(如 `我的角色.json`)。 +3. 编辑文件内容,填写角色信息。 +4. 重启Bot即可生效,插件会自动扫描 `roles/` 目录下的所有 `.json` 文件。 + +> **覆盖规则**:如果自定义角色的 `title` 与内置角色同名,则覆盖内置角色;否则作为新角色追加。无需修改任何映射文件。 (大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx) -以下为例子: +以下为角色文件内容例子: ```json - { - "title": "写作助理", - "description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.", - "descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。", - "wrapper": "内容是:\n\"%s\"", - "remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。" - } +{ + "title": "写作助理", + "description": "As a writing improvement assistant...", + "descn": "作为一名中文写作改进助理...", + "wrapper": "内容是:\n\"%s\"", + "remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。", + "tags": ["favorite", "write"] +} ``` - `title`: 角色名。 @@ -24,3 +46,16 @@ - `descn`: 使用`$角色`触发时,使用中文prompt。 - `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。 - `remark`: 简短描述该角色,在打印帮助文档时显示。 +- `tags`: 角色分类标签,可用`$角色类型 <标签>`查看。 + + +```json +{ + "title": "", + "description": "", + "descn": "", + "wrapper": "", + "remark": "", + "tags": [] +} +``` \ No newline at end of file diff --git a/plugins/role/role.py b/plugins/role/role.py index e23ff361..01739fe9 100644 --- a/plugins/role/role.py +++ b/plugins/role/role.py @@ -57,6 +57,14 @@ class Role(Plugin): logger.warning(f"[Role] unknown tag {tag} ") self.tags[tag] = (tag, []) self.tags[tag][1].append(role) + + # 扫描 roles/ 目录,加载可选的扩展/覆盖角色文件 + roles_dir = os.path.join(curdir, "roles") + if os.path.isdir(roles_dir): + for fname in sorted(os.listdir(roles_dir)): + if fname.endswith(".json"): + self._load_role_file(os.path.join(roles_dir, fname)) + for tag in list(self.tags.keys()): if len(self.tags[tag][1]) == 0: logger.debug(f"[Role] no role found for tag {tag} ") @@ -74,6 +82,27 @@ class Role(Plugin): logger.warn("[Role] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/role .") raise e + def _load_role_file(self, filepath): + """从roles/目录加载单个角色文件,同title则覆盖内置角色,否则追加""" + try: + with open(filepath, "r", encoding="utf-8") as f: + role = json.load(f) + title_lower = role["title"].lower() + if title_lower in self.roles: + old_tags = self.roles[title_lower].get("tags", []) + for tag in old_tags: + if tag in self.tags: + self.tags[tag][1] = [r for r in self.tags[tag][1] if r["title"].lower() != title_lower] + logger.info(f"[Role] overridden: {role['title']}") + self.roles[title_lower] = role + for tag in role.get("tags", []): + if tag not in self.tags: + self.tags[tag] = (tag, []) + self.tags[tag][1].append(role) + logger.debug(f"[Role] loaded: {role['title']} from {os.path.basename(filepath)}") + except Exception as e: + logger.warn(f"[Role] failed to load {filepath}: {e}") + def get_role(self, name, find_closest=True, min_sim=0.35): name = name.lower() found_role = None From bd5fede12282f37e7b0b8f90568f6c6920ba4ac7 Mon Sep 17 00:00:00 2001 From: HnBigVolibear Date: Sun, 14 Jun 2026 02:11:15 +0800 Subject: [PATCH 368/399] feat(web): enhance scheduled task management in web console. Now we can edit any scheduled tasks in web! - Add toggle, update and delete APIs for scheduled tasks - Add task edit modal with schedule/action updates in web console (PS: In edit box, now I Prevent channel type changes during editing (weixin token bound to session) ) - Add enable/disable switch with visual feedback in task cards - Sort task list by enabled status first, then by next_run_at Closes #2882 --- agent/tools/scheduler/task_store.py | 11 +- channel/web/chat.html | 182 ++++++++++ channel/web/static/js/console.js | 527 +++++++++++++++++++++++++++- channel/web/web_channel.py | 138 ++++++++ 4 files changed, 846 insertions(+), 12 deletions(-) diff --git a/agent/tools/scheduler/task_store.py b/agent/tools/scheduler/task_store.py index a54ce46b..a904788b 100644 --- a/agent/tools/scheduler/task_store.py +++ b/agent/tools/scheduler/task_store.py @@ -182,8 +182,15 @@ class TaskStore: if enabled_only: task_list = [t for t in task_list if t.get("enabled", True)] - # Sort by next_run_at - task_list.sort(key=lambda t: t.get("next_run_at", float('inf'))) + # Sort by enabled status (enabled first), then by next_run_at + def sort_key(t): + enabled = t.get("enabled", True) + next_run = t.get("next_run_at", "") + # Enabled tasks first (0), disabled tasks second (1) + # Then sort by next_run_at (empty string sorts last) + return (0 if enabled else 1, next_run if next_run else "9999-12-31") + + task_list.sort(key=sort_key) return task_list diff --git a/channel/web/chat.html b/channel/web/chat.html index 9e5dadd6..af0b9aab 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -995,6 +995,14 @@

定时任务

查看和管理定时任务

+
+ +
@@ -1225,6 +1233,180 @@
+ + + diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index c135903d..c1c6a744 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -188,6 +188,30 @@ const I18N = { feishu_mode_scan: '扫码创建', feishu_mode_manual: '手动填写', tasks_title: '定时任务', tasks_desc: '查看和管理定时任务', tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供', + task_add_btn: '新增任务', + task_edit_title: '编辑定时任务', + task_add_title: '新增定时任务', + task_name: '任务名称', + task_enabled: '启用任务', + task_schedule_type: '调度类型', + task_schedule_cron: 'Cron 表达式', + task_schedule_interval: '固定间隔', + task_schedule_once: '一次性任务', + task_cron_expression: 'Cron 表达式', + task_cron_hint: '格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00', + task_interval_seconds: '间隔秒数', + task_interval_hint: '最小 60 秒,例如 3600 表示每小时执行一次', + task_once_time: '执行时间', + task_action_type: '动作类型', + task_action_send_message: '发送消息', + task_action_agent_task: 'AI 任务', + task_channel_type: '通道类型', + task_channel_hint: '选择定时消息发送的通道', + task_message_content: '消息内容', + task_task_description: '任务描述', + task_delete_btn: '删除任务', + task_delete_confirm_title: '删除定时任务', + task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。', logs_title: '日志', logs_desc: '实时日志输出 (run.log)', logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。', new_chat: '新对话', @@ -403,6 +427,30 @@ const I18N = { feishu_mode_scan: 'Scan QR', feishu_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', + task_add_btn: 'Add Task', + task_edit_title: 'Edit Task', + task_add_title: 'Add Task', + task_name: 'Task Name', + task_enabled: 'Enable Task', + task_schedule_type: 'Schedule Type', + task_schedule_cron: 'Cron Expression', + task_schedule_interval: 'Fixed Interval', + task_schedule_once: 'One-time Task', + task_cron_expression: 'Cron Expression', + task_cron_hint: 'Format: minute hour day month weekday, e.g. "0 9 * * *" means daily at 9:00', + task_interval_seconds: 'Interval (seconds)', + task_interval_hint: 'Minimum 60 seconds, e.g. 3600 means once per hour', + task_once_time: 'Execution Time', + task_action_type: 'Action Type', + task_action_send_message: 'Send Message', + task_action_agent_task: 'AI Task', + task_channel_type: 'Channel Type', + task_channel_hint: 'Select the channel to send scheduled messages', + task_message_content: 'Message Content', + task_task_description: 'Task Description', + task_delete_btn: 'Delete Task', + task_delete_confirm_title: 'Delete Task', + task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.', logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)', logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.', new_chat: 'New Chat', @@ -560,6 +608,11 @@ function rerenderDynamicViews() { && modelsState && (modelsState.providers || modelsState.capabilities)) { renderModelsView(); } + // Reload task list after language switch + if (currentView === 'tasks') { + tasksLoaded = false; + loadTasksView(); + } } // Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are @@ -7377,6 +7430,26 @@ function connectFeishuAfterRegister(appId, appSecret) { // Scheduler View // ===================================================================== let tasksLoaded = false; +function refreshTasksView() { + const btn = document.getElementById('task-refresh-btn'); + const icon = btn.querySelector('i'); + + // Add spin animation + icon.classList.add('fa-spin'); + btn.disabled = true; + + tasksLoaded = false; + const listEl = document.getElementById('tasks-list'); + listEl.innerHTML = ''; + + loadTasksView(); + + // Restore button after animation ends + setTimeout(() => { + icon.classList.remove('fa-spin'); + btn.disabled = false; + }, 500); +} function loadTasksView() { if (tasksLoaded) return; fetch('/api/scheduler').then(r => r.json()).then(data => { @@ -7384,39 +7457,94 @@ function loadTasksView() { const emptyEl = document.getElementById('tasks-empty'); const listEl = document.getElementById('tasks-list'); const allTasks = data.tasks || []; - // Only show active (enabled) tasks - const tasks = allTasks.filter(t => t.enabled !== false); - if (tasks.length === 0) { + // Backend already sorted by enabled and next_run_at, no need to re-sort on frontend + if (allTasks.length === 0) { emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无定时任务' : 'No scheduled tasks'; + emptyEl.classList.remove('hidden'); + listEl.classList.add('hidden'); + tasksLoaded = true; return; } emptyEl.classList.add('hidden'); listEl.classList.remove('hidden'); listEl.innerHTML = ''; - tasks.forEach(task => { + allTasks.forEach(task => { + const isEnabled = task.enabled !== false; const card = document.createElement('div'); card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4'; - const typeLabel = task.type === 'cron' - ? `${escapeHtml(task.cron || '')}` - : `${escapeHtml(task.type || 'once')}`; + card.dataset.taskId = task.id; + if (!isEnabled) card.classList.add('opacity-50'); + const schedule = task.schedule || {}; + let typeLabel = ''; + if (schedule.type === 'cron') { + typeLabel = `${escapeHtml(schedule.expression || '')}`; + } else if (schedule.type === 'interval') { + const seconds = schedule.seconds || 0; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + let intervalText = []; + if (hours > 0) intervalText.push(`${hours}h`); + if (mins > 0) intervalText.push(`${mins}m`); + if (secs > 0 || intervalText.length === 0) intervalText.push(`${secs}s`); + typeLabel = `${intervalText.join(' ')}`; + } else { + typeLabel = `${escapeHtml(schedule.type || 'once')}`; + } let nextRun = '--'; if (task.next_run_at) { - // next_run_at is an ISO string, not a Unix timestamp const d = new Date(task.next_run_at); if (!isNaN(d.getTime())) nextRun = d.toLocaleString(); } + const action = task.action || {}; + const taskContent = action.content || action.task_description || ''; + const toggleId = 'toggle-' + task.id; card.innerHTML = `
- + ${escapeHtml(task.name || task.id || '--')}
${typeLabel}
-

${escapeHtml(task.prompt || task.description || '')}

+

${escapeHtml(taskContent)}

${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun} +
+
`; + const checkbox = card.querySelector('#' + toggleId); + checkbox.addEventListener('change', function() { + const newEnabled = this.checked; + fetch('/api/scheduler/toggle', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({task_id: task.id, enabled: newEnabled}) + }).then(r => r.json()).then(res => { + if (res.status === 'success') { + const dot = card.querySelector('.rounded-full.w-2'); + if (newEnabled) { + card.classList.remove('opacity-50'); + if (dot) { dot.classList.remove('bg-slate-300','dark:bg-slate-600'); dot.classList.add('bg-primary-400'); } + } else { + card.classList.add('opacity-50'); + if (dot) { dot.classList.remove('bg-primary-400'); dot.classList.add('bg-slate-300','dark:bg-slate-600'); } + } + } else { + this.checked = !newEnabled; + } + }).catch(() => { this.checked = !newEnabled; }); + }); + // Card click event (excluding toggle switch clicks) + card.addEventListener('click', function(e) { + if (!e.target.closest('label') && !e.target.closest('input[type="checkbox"]')) { + openTaskEditModal(task); + } + }); + card.style.cursor = 'pointer'; listEl.appendChild(card); }); tasksLoaded = true; @@ -8149,3 +8277,382 @@ fetch('/auth/check').then(r => r.json()).then(data => { requestAnimationFrame(() => { document.body.classList.add('transition-colors', 'duration-200'); }); + +// ===================================================================== +// Task Edit Modal +// ===================================================================== +let currentEditingTask = null; + +function loadTaskChannelOptions(selectedChannelType) { + const select = document.getElementById('task-edit-channel-type'); + select.innerHTML = ''; + fetch('/api/channels').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + const allChannels = data.channels || []; + // Only include currently active channels, strictly following the channel management page logic + let channels = allChannels.filter(c => c.active).map(c => { + const label = (typeof c.label === 'object') ? (c.label[currentLang] || c.label.en || c.name) : (c.label || c.name); + return { name: c.name, label: label }; + }); + const channelNames = channels.map(c => c.name); + // Always include the web console channel + if (!channelNames.includes('web')) { + channels.unshift({ name: 'web', label: currentLang === 'zh' ? 'Web' : 'Web' }); + } + // If the currently selected channel is not in the active list (e.g. disabled), append it to preserve selection + if (selectedChannelType && !channelNames.includes(selectedChannelType) && selectedChannelType !== 'web') { + const ch = allChannels.find(c => c.name === selectedChannelType); + const label = ch + ? ((typeof ch.label === 'object') ? (ch.label[currentLang] || ch.label.en || ch.name) : (ch.label || ch.name)) + : selectedChannelType; + channels.push({ name: selectedChannelType, label: label }); + } + channels.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.name; + opt.textContent = c.label; + select.appendChild(opt); + }); + // Set selected value + if (selectedChannelType) { + select.value = selectedChannelType; + } + }).catch(() => { + // fallback: at least keep the current selection and web + select.innerHTML = ''; + const webOpt = document.createElement('option'); + webOpt.value = 'web'; + webOpt.textContent = 'Web'; + select.appendChild(webOpt); + + if (selectedChannelType && selectedChannelType !== 'web') { + const opt = document.createElement('option'); + opt.value = selectedChannelType; + opt.textContent = selectedChannelType; + select.appendChild(opt); + } + if (selectedChannelType) { + select.value = selectedChannelType; + } + + // Show error message + console.error('Failed to load channel options'); + }); +} + +function openTaskEditModal(task) { + currentEditingTask = task; + const overlay = document.getElementById('task-edit-modal-overlay'); + const titleEl = document.querySelector('#task-edit-modal-overlay h3'); + const subtitle = document.getElementById('task-edit-modal-subtitle'); + const deleteBtn = document.getElementById('task-edit-modal-delete'); + const nameInput = document.getElementById('task-edit-name'); + const enabledInput = document.getElementById('task-edit-enabled'); + const scheduleTypeSelect = document.getElementById('task-edit-schedule-type'); + const cronInput = document.getElementById('task-edit-cron-expression'); + const intervalInput = document.getElementById('task-edit-interval-seconds'); + const onceInput = document.getElementById('task-edit-once-time'); + const actionTypeSelect = document.getElementById('task-edit-action-type'); + const receiverInput = document.getElementById('task-edit-receiver'); + const contentInput = document.getElementById('task-edit-content'); + + // Set title and subtitle + titleEl.textContent = t('task_edit_title'); + subtitle.textContent = task.id; + deleteBtn.classList.remove('hidden'); + + // Populate data + nameInput.value = task.name || ''; + enabledInput.checked = task.enabled !== false; + + const schedule = task.schedule || {}; + scheduleTypeSelect.value = schedule.type || 'cron'; + + // Clear all schedule type input values first to avoid stale data + cronInput.value = ''; + intervalInput.value = ''; + onceInput.value = ''; + + if (schedule.type === 'cron') { + cronInput.value = schedule.expression || ''; + } else if (schedule.type === 'interval') { + intervalInput.value = schedule.seconds || ''; + } else if (schedule.type === 'once') { + if (schedule.run_at) { + // Manually parse ISO time string to avoid cross-browser timezone issues with new Date() + // run_at format: "YYYY-MM-DDTHH:mm:ss" or "YYYY-MM-DDTHH:mm:ss.ffffff" + const parts = schedule.run_at.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/); + if (parts) { + const timeInput = document.getElementById('task-edit-once-time'); + timeInput.value = `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}`; + } + } + } + + const action = task.action || {}; + actionTypeSelect.value = action.type || 'send_message'; + receiverInput.value = action.receiver || ''; + contentInput.value = action.content || action.task_description || ''; + + // Load channel options and set selected value + loadTaskChannelOptions(action.channel_type || 'web'); + + // Disable channel type selector — channel is read-only when editing. + // Switching the channel after a task is created is problematic because: + // 1. The WeChat (weixin/ilink) bot requires a valid context_token that is tied + // to a specific user-session on that channel. Changing the channel to weixin + // would invalidate the existing token — the new receiver on weixin may not + // have an active context_token, causing the scheduled push to silently fail. + // 2. Other channels (DingTalk, Feishu, etc.) also carry channel-specific fields + // (e.g. dingtalk_sender_staff_id) that cannot be trivially re-populated for + // a different channel type without user intervention. + // 3. The receiver identity itself is channel-bound — a weixin user-id means + // nothing on a Feishu channel, so changing the channel would orphan the task. + // For these reasons, the channel type is intentionally frozen once a task exists. + // Users who need a task on a different channel should create a new task through + // the chat interface (by asking the bot) rather than editing an existing one. + document.getElementById('task-edit-channel-type').disabled = true; + + // Update UI + updateTaskScheduleFields(); + updateTaskActionLabel(); + + overlay.classList.remove('hidden'); +} + +function closeTaskEditModal() { + document.getElementById('task-edit-modal-overlay').classList.add('hidden'); + currentEditingTask = null; +} + +function updateTaskScheduleFields() { + const scheduleType = document.getElementById('task-edit-schedule-type').value; + const cronWrap = document.getElementById('task-edit-cron-wrap'); + const intervalWrap = document.getElementById('task-edit-interval-wrap'); + const onceWrap = document.getElementById('task-edit-once-wrap'); + const cronHint = document.getElementById('task-edit-cron-hint'); + const intervalHint = document.getElementById('task-edit-interval-hint'); + + cronWrap.classList.toggle('hidden', scheduleType !== 'cron'); + intervalWrap.classList.toggle('hidden', scheduleType !== 'interval'); + onceWrap.classList.toggle('hidden', scheduleType !== 'once'); + + if (cronHint) cronHint.classList.toggle('hidden', scheduleType !== 'cron'); + if (intervalHint) intervalHint.classList.toggle('hidden', scheduleType !== 'interval'); +} + +function updateTaskActionLabel() { + const actionType = document.getElementById('task-edit-action-type').value; + const label = document.getElementById('task-edit-content-label'); + const content = document.getElementById('task-edit-content'); + + if (actionType === 'send_message') { + label.textContent = t('task_message_content'); + content.placeholder = t('task_message_content'); + } else { + label.textContent = t('task_task_description'); + content.placeholder = t('task_task_description'); + } +} + +function saveTaskEdit() { + const nameInput = document.getElementById('task-edit-name'); + const enabledInput = document.getElementById('task-edit-enabled'); + const scheduleTypeSelect = document.getElementById('task-edit-schedule-type'); + const cronInput = document.getElementById('task-edit-cron-expression'); + const intervalInput = document.getElementById('task-edit-interval-seconds'); + const onceInput = document.getElementById('task-edit-once-time'); + const actionTypeSelect = document.getElementById('task-edit-action-type'); + const channelTypeSelect = document.getElementById('task-edit-channel-type'); + const receiverInput = document.getElementById('task-edit-receiver'); + const contentInput = document.getElementById('task-edit-content'); + const statusEl = document.getElementById('task-edit-modal-status'); + const saveBtn = document.getElementById('task-edit-modal-save'); + + const name = nameInput.value.trim(); + if (!name) { + statusEl.textContent = currentLang === 'zh' ? '请输入任务名称' : 'Please enter task name'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + + const scheduleType = scheduleTypeSelect.value; + const schedule = { type: scheduleType }; + + if (scheduleType === 'cron') { + const expr = cronInput.value.trim(); + if (!expr) { + statusEl.textContent = currentLang === 'zh' ? '请输入 Cron 表达式' : 'Please enter cron expression'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + // Basic cron expression format validation: 5 or 6 fields + const fields = expr.split(/\s+/); + if (fields.length < 5 || fields.length > 6) { + statusEl.textContent = currentLang === 'zh' ? 'Cron 表达式格式错误,应为 5 或 6 个字段(分 时 日 月 周)' : 'Invalid cron expression, expected 5 or 6 fields (min hour day month weekday)'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + schedule.expression = expr; + // Note: detailed cron expression validity is verified by the backend croniter library; frontend only does basic format validation + } else if (scheduleType === 'interval') { + const seconds = parseInt(intervalInput.value); + if (!seconds || seconds < 60) { + statusEl.textContent = currentLang === 'zh' ? '间隔秒数最小为 60 秒' : 'Interval must be at least 60 seconds'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + schedule.seconds = seconds; + } else if (scheduleType === 'once') { + const time = onceInput.value; + if (!time) { + statusEl.textContent = currentLang === 'zh' ? '请选择执行时间' : 'Please select execution time'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + // Validate execution time format + const selectedTime = new Date(time); + if (isNaN(selectedTime.getTime())) { + statusEl.textContent = currentLang === 'zh' ? '执行时间格式错误' : 'Invalid execution time format'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + // Validate that time is in the future for one-time tasks + if (selectedTime <= new Date()) { + statusEl.textContent = currentLang === 'zh' ? '执行时间必须在当前时间之后' : 'Execution time must be in the future'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + // datetime-local value with step="1" is already in YYYY-MM-DDTHH:mm:ss format + // Backend _parse_naive_local treats strings without timezone suffix as local time + schedule.run_at = time; + } + + const actionType = actionTypeSelect.value; + const channelType = channelTypeSelect.value; + const content = contentInput.value.trim(); + + if (!content) { + statusEl.textContent = currentLang === 'zh' ? '请输入内容' : 'Please enter content'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + return; + } + + // Build action with only necessary fields to avoid stale data + const action = { + type: actionType, + channel_type: channelType, + receiver: '', + receiver_name: '', + is_group: false, + notify_session_id: '' + }; + + if (actionType === 'send_message') { + action.content = content; + } else { + action.task_description = content; + } + + // Preserve the original receiver info (channel is read-only, so it never changes) + if (currentEditingTask && currentEditingTask.action) { + action.receiver = currentEditingTask.action.receiver || ''; + action.receiver_name = currentEditingTask.action.receiver_name || ''; + action.is_group = currentEditingTask.action.is_group || false; + action.notify_session_id = currentEditingTask.action.notify_session_id || ''; + + // Preserve channel-specific fields (e.g. DingTalk sender_staff_id) + if (channelType === 'dingtalk' && currentEditingTask.action.dingtalk_sender_staff_id) { + action.dingtalk_sender_staff_id = currentEditingTask.action.dingtalk_sender_staff_id; + } + } + + saveBtn.disabled = true; + + const payload = { + task_id: currentEditingTask.id, + name: name, + enabled: enabledInput.checked, + schedule: schedule, + action: action + }; + + fetch('/api/scheduler/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).then(r => r.json()).then(res => { + saveBtn.disabled = false; + if (res.status === 'success') { + closeTaskEditModal(); + tasksLoaded = false; + loadTasksView(); + } else { + statusEl.textContent = res.message || (currentLang === 'zh' ? '保存失败' : 'Save failed'); + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + } + }).catch(() => { + saveBtn.disabled = false; + statusEl.textContent = currentLang === 'zh' ? '网络错误' : 'Network error'; + statusEl.style.opacity = '1'; + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + }); +} + +function deleteTask() { + if (!currentEditingTask) return; + + const taskName = currentEditingTask.name || currentEditingTask.id || '未知任务'; + const taskId = currentEditingTask.id; // Capture early to avoid closure race condition + showConfirmDialog({ + title: t('task_delete_confirm_title'), + message: (currentLang === 'zh' ? `确定要删除任务「${taskName}」吗?` : `Are you sure to delete task "${taskName}"?`), + onConfirm: () => { + fetch('/api/scheduler/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }) + }).then(r => r.json()).then(res => { + if (res.status === 'success') { + closeTaskEditModal(); + tasksLoaded = false; + loadTasksView(); + } else { + const statusEl = document.getElementById('task-edit-modal-status'); + if (statusEl) { + statusEl.textContent = res.message || 'Delete failed'; + statusEl.classList.remove('hidden', 'text-green-500'); + statusEl.classList.add('text-red-500'); + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + } + } + }).catch(() => { + const statusEl = document.getElementById('task-edit-modal-status'); + if (statusEl) { + statusEl.textContent = 'Network error'; + statusEl.classList.remove('hidden', 'text-green-500'); + statusEl.classList.add('text-red-500'); + setTimeout(() => { statusEl.style.opacity = '0'; }, 3000); + } + }); + } + }); +} + +document.getElementById('task-edit-schedule-type').addEventListener('change', updateTaskScheduleFields); +document.getElementById('task-edit-action-type').addEventListener('change', updateTaskActionLabel); +document.getElementById('task-edit-modal-cancel').addEventListener('click', closeTaskEditModal); +document.getElementById('task-edit-modal-save').addEventListener('click', saveTaskEdit); +document.getElementById('task-edit-modal-delete').addEventListener('click', deleteTask); +document.getElementById('task-edit-modal-overlay').addEventListener('click', function(e) { + if (e.target === this) closeTaskEditModal(); +}); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index a290bf31..8b529d4c 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1155,6 +1155,9 @@ class WebChannel(ChatChannel): '/api/knowledge/read', 'KnowledgeReadHandler', '/api/knowledge/graph', 'KnowledgeGraphHandler', '/api/scheduler', 'SchedulerHandler', + '/api/scheduler/toggle', 'SchedulerToggleHandler', + '/api/scheduler/update', 'SchedulerUpdateHandler', + '/api/scheduler/delete', 'SchedulerDeleteHandler', '/api/sessions', 'SessionsHandler', '/api/sessions/(.*)/generate_title', 'SessionTitleHandler', '/api/sessions/(.*)/clear_context', 'SessionClearContextHandler', @@ -4162,6 +4165,141 @@ class SchedulerHandler: return json.dumps({"status": "error", "message": str(e)}) +class SchedulerToggleHandler: + def POST(self): + _require_auth() + web.header('Content-Type', 'application/json; charset=utf-8') + try: + body = json.loads(web.data()) + task_id = body.get("task_id") + enabled = body.get("enabled", True) + if not task_id: + return json.dumps({"status": "error", "message": "task_id required"}) + from agent.tools.scheduler.task_store import TaskStore + workspace_root = _get_workspace_root() + store_path = os.path.join(workspace_root, "scheduler", "tasks.json") + store = TaskStore(store_path) + store.enable_task(task_id, enabled) + task = store.get_task(task_id) + return json.dumps({"status": "success", "task": task}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Scheduler toggle error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class SchedulerUpdateHandler: + def POST(self): + _require_auth() + web.header('Content-Type', 'application/json; charset=utf-8') + try: + body = json.loads(web.data()) + task_id = body.get("task_id") + if not task_id: + return json.dumps({"status": "error", "message": "task_id required"}) + + from agent.tools.scheduler.task_store import TaskStore + from agent.tools.scheduler.scheduler_service import SchedulerService + from datetime import datetime + workspace_root = _get_workspace_root() + store_path = os.path.join(workspace_root, "scheduler", "tasks.json") + store = TaskStore(store_path) + + # Get original task (single query to avoid repeated I/O) + original_task = store.get_task(task_id) + if not original_task: + return json.dumps({"status": "error", "message": f"Task '{task_id}' not found"}) + + # Build updates dict + updates = {} + if "name" in body: + updates["name"] = body["name"] + if "enabled" in body: + updates["enabled"] = body["enabled"] + + # Update schedule + if "schedule" in body: + updates["schedule"] = body["schedule"] + # If schedule config changed, recalculate next_run_at + # Build merged temp task data for calculation (without modifying the original object) + merged = dict(original_task) + merged.update(updates) + if "action" in body: + merged["action"] = body["action"] + temp_service = SchedulerService(store, lambda t: None) + next_run = temp_service._calculate_next_run(merged, datetime.now()) + if next_run: + updates["next_run_at"] = next_run.isoformat() + else: + # Cannot calculate next run time, schedule config may be invalid + return json.dumps({ + "status": "error", + "message": "Cannot calculate next run time. Please check the schedule config (e.g., cron expression format, or whether the one-time task time has already passed)." + }, ensure_ascii=False) + + # Update action + if "action" in body: + action = body["action"] + channel_type = action.get("channel_type", "web") + + # Get the task's original channel_type + old_channel = original_task.get("action", {}).get("channel_type", "web") + + # If channel type changed or no receiver, reject the update. + # Note: the web UI disables the channel selector, so this branch + # is only reachable via direct API calls. Changing a task's channel + # after creation is not supported because the receiver identity is + # channel-bound and cannot be trivially re-populated (e.g. weixin + # requires a valid context_token tied to the original user-session). + if old_channel and old_channel != channel_type: + return json.dumps({ + "status": "error", + "message": f"Cannot change channel type from '{old_channel}' to '{channel_type}'. Please create a new task on the target channel instead." + }, ensure_ascii=False) + if not action.get("receiver"): + return json.dumps({ + "status": "error", + "message": "Receiver is required. Please create a new task through the chat interface." + }, ensure_ascii=False) + updates["action"] = action + + # If schedule was not updated but action was, ensure next_run_at exists + if "schedule" not in body and "next_run_at" not in original_task: + merged = dict(original_task) + merged.update(updates) + temp_service = SchedulerService(store, lambda t: None) + next_run = temp_service._calculate_next_run(merged, datetime.now()) + if next_run: + updates["next_run_at"] = next_run.isoformat() + + store.update_task(task_id, updates) + task = store.get_task(task_id) + return json.dumps({"status": "success", "task": task}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Scheduler update error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class SchedulerDeleteHandler: + def POST(self): + _require_auth() + web.header('Content-Type', 'application/json; charset=utf-8') + try: + body = json.loads(web.data()) + task_id = body.get("task_id") + if not task_id: + return json.dumps({"status": "error", "message": "task_id required"}) + + from agent.tools.scheduler.task_store import TaskStore + workspace_root = _get_workspace_root() + store_path = os.path.join(workspace_root, "scheduler", "tasks.json") + store = TaskStore(store_path) + store.delete_task(task_id) + return json.dumps({"status": "success"}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Scheduler delete error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + class SessionsHandler: def GET(self): _require_auth() From 6538843bdfb359d6c078134d65567d159a0f15b0 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 11:06:25 +0800 Subject: [PATCH 369/399] fix(memory): remove max_tokens cap in deep dream distillation --- agent/memory/summarizer.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py index 9da349d1..066549dc 100644 --- a/agent/memory/summarizer.py +++ b/agent/memory/summarizer.py @@ -462,13 +462,12 @@ class MemoryFlushManager: daily_content=daily_content or "(no recent daily records)", ) from agent.protocol.models import LLMRequest - # Scale max_tokens based on input size to avoid truncating large MEMORY.md - input_chars = len(memory_content) + len(daily_content) - dream_max_tokens = max(2000, min(input_chars, 8000)) + # No output cap: the prompt already keeps MEMORY.md concise (~50 + # items), so a hard max_tokens would only risk truncating a large + # rewrite. Let the model use its default output budget. request = LLMRequest( messages=[{"role": "user", "content": user_msg}], temperature=0.3, - max_tokens=dream_max_tokens, stream=False, system=_dream_system_prompt(), ) From 7d63e7d8fa4cdd983409ffc0f617d057f73b183e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 17:20:41 +0800 Subject: [PATCH 370/399] feat(cli): add agent self-restart command --- cli/cli.py | 3 +- cli/commands/process.py | 114 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/cli/cli.py b/cli/cli.py index 99b837ab..b266b372 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, update, status, logs +from cli.commands.process import start, stop, restart, self_restart, update, status, logs from cli.commands.context import context from cli.commands.install import install_browser from cli.commands.knowledge import knowledge @@ -68,6 +68,7 @@ main.add_command(skill) main.add_command(start) main.add_command(stop) main.add_command(restart) +main.add_command(self_restart) main.add_command(update) main.add_command(status) main.add_command(logs) diff --git a/cli/commands/process.py b/cli/commands/process.py index d6b1aaf4..c7f98448 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -195,6 +195,120 @@ def restart(ctx, no_logs): ctx.invoke(start, no_logs=no_logs) +# Detached relay that survives the caller's process tree. Run via `python -c` +# with start_new_session=True so it keeps going after the agent's bash child +# (and the main app it kills) both die. Flow: self-check the new code FIRST +# (import app); abort without touching the old process if it fails. Only when +# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL +# fallback), then start a fresh app.py and write the pid. +_RELAY_SCRIPT = r""" +import os, sys, time, signal, subprocess + +root, python, app_py, pid_file, log_file = sys.argv[1:6] +old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0 + + +def alive(pid): + if not pid: + return False + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def log(msg): + try: + with open(log_file, "a") as f: + f.write("[self-restart] " + msg + "\n") + except OSError: + pass + + +# 0) Self-check: make sure the new code actually loads BEFORE killing anything. +# `import app` exercises top-level imports / syntax of the entry module. If it +# fails, abort and leave the running service untouched — never end up with the +# old process killed and the new one unable to start. +check = subprocess.run( + [python, "-c", "import app"], cwd=root, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, +) +if check.returncode != 0: + detail = (check.stdout or b"").decode("utf-8", "replace").strip() + log("self-check FAILED, aborting restart; service left running:\n" + detail) + sys.exit(1) +log("self-check passed") + +# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state). +if alive(old_pid): + try: + os.kill(old_pid, signal.SIGTERM) + except OSError: + pass + # 2) Wait up to ~15s for it to go, then force-kill as a backstop. + for _ in range(150): + if not alive(old_pid): + break + time.sleep(0.1) + else: + try: + os.kill(old_pid, signal.SIGKILL) + except OSError: + pass + time.sleep(0.5) + +# 3) Start a fresh instance, detached, logging to the same file. +with open(log_file, "a") as f: + proc = subprocess.Popen( + [python, app_py], cwd=root, + stdout=f, stderr=f, start_new_session=True, + ) +with open(pid_file, "w") as f: + f.write(str(proc.pid)) +log("restarted, new pid=" + str(proc.pid)) +""" + + +@click.command(name="self-restart", hidden=True) +def self_restart(): + """Restart from inside the running agent (detached; survives parent death). + + Intended to be invoked by the agent itself (e.g. via bash after editing its + own code), not by users — so it is hidden from `cow help`. Unlike `restart`, + the actual stop+start runs in a detached relay process that outlives the + agent's bash child, which would otherwise die together with the main app it + kills. + """ + if _IS_WIN: + click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True) + sys.exit(1) + + 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 + pid = _read_pid() or 0 + + subprocess.Popen( + [ + python, "-c", _RELAY_SCRIPT, + root, python, app_py, _get_pid_file(), _get_log_file(), str(pid), + ], + cwd=root, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + click.echo(click.style( + "✓ Restart scheduled. The service will stop and come back in a few seconds.", + fg="green", + )) + + @click.command() @click.pass_context def update(ctx): From ab674a3517ca5e15a2b3206e7bb2551fa551e353 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 17:22:41 +0800 Subject: [PATCH 371/399] fix(docs): architecture graph link --- README.md | 2 +- docs/intro/architecture.mdx | 2 +- docs/ja/README.md | 2 +- docs/ja/intro/architecture.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4c91f1cf..4978a30c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major ## 🏗️ Architecture -CowAgent Architecture +CowAgent Architecture CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible. diff --git a/docs/intro/architecture.mdx b/docs/intro/architecture.mdx index a9797bf1..77fdbc8a 100644 --- a/docs/intro/architecture.mdx +++ b/docs/intro/architecture.mdx @@ -9,7 +9,7 @@ CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistan CowAgent's architecture consists of the following core modules: -CowAgent Architecture +CowAgent Architecture | Module | Description | | --- | --- | diff --git a/docs/ja/README.md b/docs/ja/README.md index afe51342..1ae5cb84 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -48,7 +48,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています ## 🏗️ アーキテクチャ -CowAgent Architecture +CowAgent Architecture CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。 diff --git a/docs/ja/intro/architecture.mdx b/docs/ja/intro/architecture.mdx index 5e8b202b..682b407e 100644 --- a/docs/ja/intro/architecture.mdx +++ b/docs/ja/intro/architecture.mdx @@ -9,7 +9,7 @@ CowAgent 2.0 は、シンプルなチャットボットから、自律的な思 CowAgent のアーキテクチャは以下のコアモジュールで構成されています: -CowAgent Architecture +CowAgent Architecture | モジュール | 説明 | | --- | --- | From d281a34c6fecea84262f2963f5dded820eea8bf9 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 14 Jun 2026 21:06:41 +0800 Subject: [PATCH 372/399] fix(models): demote claude-fable-5 from Claude default --- channel/web/web_channel.py | 9 ++++----- common/const.py | 4 ++-- run.sh | 4 ++-- scripts/run.ps1 | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index a290bf31..29abaa6b 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -1470,10 +1470,9 @@ class ConfigHandler: _RECOMMENDED_MODELS = [ const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, - # claude-fable-5 is intentionally placed at the end of the Claude - # group here: it is expensive, so avoid surfacing it too early in - # the LinkAI dropdown. - const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_FABLE_5, + # claude-fable-5 is placed after claude-opus-4-7 (not as the Claude + # default) since it is often unavailable due to policy restrictions. + const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, @@ -1518,7 +1517,7 @@ class ConfigHandler: "api_base_key": "claude_api_base", "api_base_default": "https://api.anthropic.com/v1", "api_base_placeholder": _PLACEHOLDER_V1, - "models": [const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], + "models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], }), ("gemini", { "label": "Gemini", diff --git a/common/const.py b/common/const.py index a7dde568..4a28c226 100644 --- a/common/const.py +++ b/common/const.py @@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620" CLAUDE_4_OPUS = "claude-opus-4-0" -CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - Agent recommended model +CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy) CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7 CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 @@ -194,7 +194,7 @@ MODEL_LIST = [ MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, # Claude - CLAUDE3, CLAUDE_FABLE_5, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, 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, + CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, 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, CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU, "claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet", diff --git a/run.sh b/run.sh index 3c15b0f0..bfe45630 100755 --- a/run.sh +++ b/run.sh @@ -597,7 +597,7 @@ select_model() { # The 12th option is "skip" -> configure later in the web console. select_menu sel "$title" \ "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \ - "Claude (claude-fable-5, claude-opus-4-8, etc.)" \ + "Claude (claude-opus-4-8, claude-fable-5, etc.)" \ "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \ "OpenAI (gpt-5.5, etc.)" \ "MiniMax (MiniMax-M3, etc.)" \ @@ -629,7 +629,7 @@ read_model_config() { configure_model() { case "$model_choice" in 1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;; - 2) read_model_config "Claude" "claude-fable-5" "CLAUDE_KEY" ;; + 2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;; 3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;; 4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;; 5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;; diff --git a/scripts/run.ps1 b/scripts/run.ps1 index 2386dd87..a9a0673e 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -423,7 +423,7 @@ function Install-Dependencies { # Each entry: Provider / default model name / config key field / optional base. $ModelChoices = @{ 1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" } - 2 = @{ Provider = "Claude"; Default = "claude-fable-5"; Field = "claude_api_key"; BaseField = "claude_api_base" } + 2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" } 3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" } 4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" } 5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" } @@ -440,7 +440,7 @@ function Select-Model { $title = T "选择 AI 模型" "Select AI Model" $options = @( "DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)", - "Claude (claude-fable-5, claude-opus-4-8, etc.)", + "Claude (claude-opus-4-8, claude-fable-5, etc.)", "Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)", "OpenAI (gpt-5.5, etc.)", "MiniMax (MiniMax-M3, etc.)", From e2cb9e11b00cc7f9547e6de96ac7d0e9e0af69ef Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 15 Jun 2026 11:50:41 +0800 Subject: [PATCH 373/399] fix(install): avoid greenlet source build on Windows & guide browser tool install --- cli/commands/install.py | 23 ++++++++++++++++++++--- common/cloud_client.py | 23 +++++++++++++++++++++++ run.sh | 3 +++ scripts/run.ps1 | 1 + 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/cli/commands/install.py b/cli/commands/install.py index b72bcba1..442694d3 100644 --- a/cli/commands/install.py +++ b/cli/commands/install.py @@ -78,12 +78,13 @@ def _is_china_network() -> bool: def _pip_install(package_spec: str, stream: StreamFn) -> int: - """Install a package, retrying with --user on permission failure.""" + """Install a package, preferring prebuilt wheels; retry with --user on perm error.""" python = sys.executable - ret = subprocess.call([python, "-m", "pip", "install", package_spec]) + base = [python, "-m", "pip", "install", "--prefer-binary"] + ret = subprocess.call(base + [package_spec]) if ret != 0: stream(" Retrying with --user flag...", "yellow") - ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec]) + ret = subprocess.call(base + ["--user", package_spec]) return ret @@ -155,6 +156,22 @@ def run_install_browser( target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION + # Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it + # from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for + # py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4. + if sys.platform == "win32": + stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow") + ret = subprocess.call( + [python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"] + ) + if ret != 0: + stream( + " Could not pre-install a prebuilt greenlet wheel.\n" + " playwright may try to build greenlet from source, which needs\n" + " Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/", + "yellow", + ) + _phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) stream("[1/3] Installing playwright Python package...", "yellow") ret = _pip_install(f"playwright=={target_version}", stream) diff --git a/common/cloud_client.py b/common/cloud_client.py index b260c410..c66eecb5 100644 --- a/common/cloud_client.py +++ b/common/cloud_client.py @@ -172,6 +172,11 @@ class CloudClient(LinkAIClient): if key in available_setting and config.get(key) is not None: local_config[key] = config.get(key) + # Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true") + # to a real bool so the evolution config parser reads it correctly. + if config.get("self_evolution_enabled") is not None: + local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled")) + # Voice settings reply_voice_mode = config.get("reply_voice_mode") if reply_voice_mode: @@ -341,6 +346,20 @@ class CloudClient(LinkAIClient): except Exception as e: logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}") + # ------------------------------------------------------------------ + # value helpers + # ------------------------------------------------------------------ + @staticmethod + def _to_bool(value) -> bool: + """Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1").""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in ("y", "yes", "true", "1", "on") + return False + # ------------------------------------------------------------------ # channel credentials helpers # ------------------------------------------------------------------ @@ -855,6 +874,10 @@ def _build_config(): "agent_max_context_turns": local_conf.get("agent_max_context_turns"), "agent_max_context_tokens": local_conf.get("agent_max_context_tokens"), "agent_max_steps": local_conf.get("agent_max_steps"), + # Self-evolution switch reported so the cloud console can reflect state + "self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N", + "self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"), + "self_evolution_min_turns": local_conf.get("self_evolution_min_turns"), "channelType": local_conf.get("channel_type"), } diff --git a/run.sh b/run.sh index bfe45630..769273b5 100755 --- a/run.sh +++ b/run.sh @@ -980,7 +980,10 @@ start_project() { echo -e " ${GREEN}./run.sh status${NC} $(t "查看状态" "Check status")" echo -e " ${GREEN}./run.sh logs${NC} $(t "查看日志" "View logs")" echo -e " ${GREEN}./run.sh update${NC} $(t "更新并重启" "Update and restart")" + echo -e " ${GREEN}cow install-browser${NC} $(t "安装浏览器工具" "Install browser tool")" fi + echo "" + echo -e "${YELLOW}$(t "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")${NC}" echo -e "${CYAN}${BOLD}=========================================${NC}" echo "" diff --git a/scripts/run.ps1 b/scripts/run.ps1 index a9a0673e..5b6c1daa 100644 --- a/scripts/run.ps1 +++ b/scripts/run.ps1 @@ -748,6 +748,7 @@ function Install-Mode { # Auto-start after configuration for a true out-of-the-box experience. Write-Host "" if ($script:AccessInfo) { Write-Cow $script:AccessInfo } + Write-Warn (T "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool") Start-CowAgent } From 6e3933be300c5be4feeebc7ab63ca01b3c3f259f Mon Sep 17 00:00:00 2001 From: yangziyu-hhh Date: Mon, 15 Jun 2026 13:15:07 +0800 Subject: [PATCH 374/399] feat(knowledge): add category and document management --- agent/knowledge/service.py | 217 +++++++++++++++++++++++++++-- agent/memory/storage.py | 3 +- channel/web/chat.html | 33 +++++ channel/web/static/css/console.css | 28 ++++ channel/web/static/js/console.js | 185 +++++++++++++++++++++++- channel/web/web_channel.py | 20 +++ tests/test_knowledge_service.py | 211 ++++++++++++++++++++++++++++ tests/test_knowledge_web.py | 61 ++++++++ 8 files changed, 740 insertions(+), 18 deletions(-) create mode 100644 tests/test_knowledge_service.py create mode 100644 tests/test_knowledge_web.py diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py index fb564afc..80fc6748 100644 --- a/agent/knowledge/service.py +++ b/agent/knowledge/service.py @@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root): import os import re +import asyncio +import shutil +import threading from pathlib import Path -from typing import Optional +from typing import Optional, Iterable from common.log import logger from config import conf +from agent.memory.config import MemoryConfig +from agent.memory.manager import MemoryManager class KnowledgeService: @@ -25,9 +30,189 @@ class KnowledgeService: Operates directly on the filesystem. """ - def __init__(self, workspace_root: str): - self.workspace_root = workspace_root - self.knowledge_dir = os.path.join(workspace_root, "knowledge") + PROTECTED_FILES = {"index.md", "log.md"} + INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]') + + def __init__(self, workspace_root: str, memory_manager=None): + self.workspace_root = os.path.abspath(workspace_root) + self.knowledge_dir = os.path.join(self.workspace_root, "knowledge") + self._memory_manager = memory_manager + + def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None, + allow_missing: bool = True) -> tuple: + if not isinstance(rel_path, str) or not rel_path.strip(): + raise ValueError("path is required") + rel_path = rel_path.replace("\\", "/").strip("/") + parts = rel_path.split("/") + if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts): + raise ValueError("invalid path") + if kind == "document" and not rel_path.lower().endswith(".md"): + raise ValueError("document path must end with .md") + + root = Path(self.knowledge_dir).resolve() + candidate = root.joinpath(*parts) + # Resolve the nearest existing ancestor so a symlink cannot be used + # to escape when the final destination does not exist yet. + ancestor = candidate + while not ancestor.exists() and ancestor != root: + ancestor = ancestor.parent + try: + ancestor.resolve().relative_to(root) + except ValueError: + raise ValueError("path outside knowledge dir") + if candidate.exists(): + try: + candidate.resolve().relative_to(root) + except ValueError: + raise ValueError("path outside knowledge dir") + elif not allow_missing: + raise FileNotFoundError(f"path not found: {rel_path}") + return rel_path, candidate + + def _ensure_not_protected(self, rel_path: str): + if rel_path in self.PROTECTED_FILES: + raise ValueError(f"protected knowledge file: {rel_path}") + + def _manager(self): + if self._memory_manager is None: + self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root)) + return self._memory_manager + + @staticmethod + def _run_sync(coro): + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + result = [] + error = [] + + def runner(): + try: + result.append(asyncio.run(coro)) + except Exception as exc: + error.append(exc) + + thread = threading.Thread(target=runner) + thread.start() + thread.join() + if error: + raise error[0] + return result[0] if result else None + + def _sync_index(self, old_paths: Iterable[str]): + old_paths = sorted(set(old_paths)) + if not old_paths: + return + manager = self._manager() + for rel_path in old_paths: + manager.storage.delete_by_path(f"knowledge/{rel_path}") + manager.mark_dirty() + self._run_sync(manager.sync()) + + def create_category(self, path: str) -> dict: + rel_path, full_path = self._resolve_path(path, kind="category") + if full_path.exists(): + return {"path": rel_path, "created": False, "reason": "already_exists"} + full_path.mkdir(parents=True) + return {"path": rel_path, "created": True} + + def rename_category(self, path: str, new_path: str) -> dict: + old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False) + new_rel, new_full = self._resolve_path(new_path, kind="category") + if not old_full.is_dir(): + raise ValueError(f"not a category: {old_rel}") + if new_full.exists(): + raise FileExistsError(f"target already exists: {new_rel}") + old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/") + for p in old_full.rglob("*.md") if p.is_file()] + new_full.parent.mkdir(parents=True, exist_ok=True) + try: + old_full.rename(new_full) + except FileNotFoundError: + return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"} + except FileExistsError: + raise FileExistsError(f"target already exists: {new_rel}") + old_paths = [f"{old_rel}/{p}" for p in old_documents] + self._sync_index(old_paths) + return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)} + + def delete_category(self, path: str, confirm: bool = False) -> dict: + rel_path, full_path = self._resolve_path(path, kind="category") + if not full_path.exists(): + return {"path": rel_path, "deleted": False, "reason": "not_found"} + if not full_path.is_dir(): + raise ValueError(f"not a category: {rel_path}") + knowledge_root = Path(self.knowledge_dir).resolve() + documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/") + for p in full_path.rglob("*.md") if p.is_file()] + if any(p in self.PROTECTED_FILES for p in documents): + raise ValueError("category contains protected knowledge files") + if any(full_path.iterdir()) and not confirm: + raise ValueError("category is not empty; confirmation is required") + try: + shutil.rmtree(full_path) + except FileNotFoundError: + return {"path": rel_path, "deleted": False, "reason": "not_found"} + self._sync_index(documents) + return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)} + + def delete_documents(self, paths: Iterable[str]) -> dict: + if not isinstance(paths, list): + raise ValueError("paths must be a list") + results = [] + deleted = [] + for path in paths: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if not full_path.exists(): + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": False, "reason": "not_found"}) + continue + if not full_path.is_file(): + raise ValueError(f"not a document: {rel_path}") + try: + full_path.unlink() + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": True}) + except FileNotFoundError: + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": False, "reason": "not_found"}) + self._sync_index(deleted) + return {"results": results, "deleted": sum(1 for item in results if item["deleted"])} + + def move_documents(self, paths: Iterable[str], target_category: str) -> dict: + if not isinstance(paths, list): + raise ValueError("paths must be a list") + target_rel, target_full = self._resolve_path(target_category, kind="category") + if not target_full.is_dir(): + raise FileNotFoundError(f"category not found: {target_rel}") + results = [] + moved_old_paths = [] + for path in paths: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if not full_path.exists(): + results.append({"path": rel_path, "moved": False, "reason": "not_found"}) + continue + destination = target_full / full_path.name + new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/") + if destination.exists(): + results.append({"path": rel_path, "moved": False, "reason": "target_exists", + "target": new_rel}) + continue + try: + os.link(full_path, destination) + full_path.unlink() + moved_old_paths.append(rel_path) + results.append({"path": rel_path, "moved": True, "target": new_rel}) + except FileExistsError: + results.append({"path": rel_path, "moved": False, "reason": "target_exists", + "target": new_rel}) + except FileNotFoundError: + results.append({"path": rel_path, "moved": False, "reason": "not_found"}) + self._sync_index(moved_old_paths) + return {"results": results, "moved": len(moved_old_paths)} # ------------------------------------------------------------------ # list — directory tree with stats @@ -121,15 +306,8 @@ class KnowledgeService: :raises ValueError: if path is invalid or escapes knowledge dir :raises FileNotFoundError: if file does not exist """ - if not rel_path or ".." in rel_path: - raise ValueError("invalid path") - - full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path)) - allowed = os.path.normpath(self.knowledge_dir) - if not full_path.startswith(allowed + os.sep) and full_path != allowed: - raise ValueError("path outside knowledge dir") - - if not os.path.isfile(full_path): + rel_path, full_path = self._resolve_path(rel_path, kind="document") + if not full_path.is_file(): raise FileNotFoundError(f"file not found: {rel_path}") with open(full_path, "r", encoding="utf-8") as f: @@ -228,13 +406,26 @@ class KnowledgeService: result = self.build_graph() return {"action": action, "code": 200, "message": "success", "payload": result} + elif action == "create_category": + result = self.create_category(payload.get("path")) + elif action == "rename_category": + result = self.rename_category(payload.get("path"), payload.get("new_path")) + elif action == "delete_category": + result = self.delete_category(payload.get("path"), payload.get("confirm", False)) + elif action == "delete_documents": + result = self.delete_documents(payload.get("paths") or []) + elif action == "move_documents": + result = self.move_documents(payload.get("paths") or [], payload.get("target_category")) else: return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + return {"action": action, "code": 200, "message": "success", "payload": result} except ValueError as e: return {"action": action, "code": 403, "message": str(e), "payload": None} except FileNotFoundError as e: return {"action": action, "code": 404, "message": str(e), "payload": None} + except FileExistsError as e: + return {"action": action, "code": 409, "message": str(e), "payload": None} except Exception as e: logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}") return {"action": action, "code": 500, "message": str(e), "payload": None} diff --git a/agent/memory/storage.py b/agent/memory/storage.py index 683b083f..240ff9de 100644 --- a/agent/memory/storage.py +++ b/agent/memory/storage.py @@ -825,9 +825,10 @@ class MemoryStorage: return [] def delete_by_path(self, path: str): - """Delete all chunks from a file""" + """Delete all chunks and file metadata for a path.""" with self._lock: self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,)) + self.conn.execute("DELETE FROM files WHERE path = ?", (path,)) self.conn.commit() def get_file_hash(self, path: str) -> Optional[str]: diff --git a/channel/web/chat.html b/channel/web/chat.html index 4f527bb4..73719f71 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -840,6 +840,11 @@
+ +
+ + +