Compare commits

..

110 Commits

Author SHA1 Message Date
zhayujie
830b8f2971 feat: release 2.0.5 2026-04-01 15:01:53 +08:00
zhayujie
b058af122c feat: release 2.0.5 2026-04-01 12:24:21 +08:00
zhayujie
174ee0cafc fix(security): prevent path traversal in memory content API 2026-04-01 10:03:58 +08:00
zhayujie
1c336380c0 docs: update release doc 2026-03-31 22:30:31 +08:00
zhayujie
3068880413 feat: save skill display name when downloading 2026-03-31 21:43:57 +08:00
zhayujie
be596681e5 Merge pull request #2735 from zhayujie/feat-wecom-bot-qrcode
feat(wecom_bot): add Wecom Bot QR code scan auth
2026-03-31 21:28:39 +08:00
zhayujie
66b71c50e9 feat(wecom_bot): add Wecom Bot QR code scan auth 2026-03-31 21:27:50 +08:00
zhayujie
8744810b25 fix: skill install timeout 2026-03-31 20:47:59 +08:00
zhayujie
7f94d37c2e fix: auto-install font in browser 2026-03-31 20:20:13 +08:00
zhayujie
6d9b7baeb4 fix(weixin): file send failed 2026-03-31 18:14:49 +08:00
zhayujie
4470d4c352 fix: reduce docker image size 2026-03-31 16:56:27 +08:00
zhayujie
d2a462a279 fix: add apt source in docker file 2026-03-31 16:34:47 +08:00
zhayujie
14ff2a15e7 fix(cli): cow cli in docker chat 2026-03-31 16:25:47 +08:00
zhayujie
6d1369900e feat: add source args in docker building 2026-03-31 16:06:45 +08:00
zhayujie
1f17ebe69e feat: add browser install in docker image 2026-03-31 16:05:05 +08:00
zhayujie
1ae2918064 feat: support install browser in chat 2026-03-31 15:15:17 +08:00
zhayujie
b6571e5cad fix: browser resource optimization 2026-03-30 21:39:38 +08:00
zhayujie
7549d48cf1 fix: browser thread bug 2026-03-30 21:27:08 +08:00
zhayujie
00353dd0cb feat: support skill hub mirror 2026-03-30 18:46:02 +08:00
zhayujie
afd947195d fix(cli): support skill mirror install 2026-03-30 16:36:17 +08:00
zhayujie
e57ef37167 fix: prevent phantom mouseover from hijacking slash menu 2026-03-30 11:52:05 +08:00
zhayujie
ef33a93654 Merge pull request #2731 from zkjqd/fix/slash-menu-click
Fix the issue where the shortcut command in the input box cannot be clicked to select events
2026-03-30 11:40:06 +08:00
zhayujie
61732aecfc Merge pull request #2721 from yrk111222/feat/modelscope-update
Feat/modelscope update
2026-03-30 11:39:50 +08:00
zkjqd
6764c05c3f input-slash-click 2026-03-30 11:20:03 +08:00
zhayujie
fa149cf4aa fix(browser): multi-thread browser instance bug 2026-03-30 00:57:19 +08:00
zhayujie
e4f9697d06 feat(browser): install font in linux 2026-03-29 23:52:51 +08:00
zhayujie
da061450e5 fix: github skill install cmd 2026-03-29 19:23:47 +08:00
zhayujie
d09ae49287 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
2026-03-29 19:09:11 +08:00
zhayujie
511ee0bbaf fix: windows PowerShell script 2026-03-29 18:28:50 +08:00
zhayujie
3cb5a0fbd6 docs: add CLI system docs 2026-03-29 17:57:12 +08:00
zhayujie
e06925ab85 fix: optimize browser install cli and fix vision prompt 2026-03-29 15:19:59 +08:00
zhayujie
184634e4e7 fix(cli): browser install failed 2026-03-29 15:14:07 +08:00
zhayujie
843c2d02cc Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-03-29 15:09:37 +08:00
zhayujie
8ea2455766 feat(cli): add browser install cmd 2026-03-29 15:09:07 +08:00
zhayujie
9dc9987d56 Merge pull request #2727 from zhayujie/feat-browser-tool
feat: add browser tool
2026-03-29 14:59:39 +08:00
zhayujie
3458621147 feat: add browser tool 2026-03-29 14:59:06 +08:00
zhayujie
079df5a47c feat: support batch skill install from zip and github 2026-03-29 14:38:11 +08:00
zhayujie
ddb07c65a1 feat: support github zip-first download, gitLab, git@ ssh, local path 2026-03-29 13:45:15 +08:00
zhayujie
9b21cd222b fix: update run.sh 2026-03-28 19:36:51 +08:00
zhayujie
90f736843f fix: add click dependencies 2026-03-28 19:35:15 +08:00
zhayujie
13c020eb61 fix(cli): cli output in wecom_bot 2026-03-28 19:26:59 +08:00
zhayujie
dbc06dbe95 fix: use new run.sh when updating 2026-03-28 19:16:41 +08:00
zhayujie
23d097bc1c Merge pull request #2726 from zhayujie/feat-cow-cli
feat: cow cli in terminal and chat
2026-03-28 19:01:56 +08:00
zhayujie
db85b9808e feat(cli): add cow update 2026-03-28 18:58:42 +08:00
zhayujie
df5bae37bc feat: add MiniMax-M2.7 and glm-5-turbo in web console 2026-03-28 18:48:11 +08:00
zhayujie
acc23b6051 feat: optimize agent prompt and fix skill source load 2026-03-28 18:37:07 +08:00
zhayujie
61f2741afc feat: organize skill source field 2026-03-28 17:41:40 +08:00
zhayujie
4dd7ea886a feat(cli): cli options in web console 2026-03-28 16:26:41 +08:00
zhayujie
1e8959fbcf fix: optimize repo clone in run.sh 2026-03-28 15:08:57 +08:00
zhayujie
48729678cf Merge branch 'master' into feat-cow-cli 2026-03-28 14:47:20 +08:00
zhayujie
0684becaa7 fix(cli): register skill when installing 2026-03-28 14:42:18 +08:00
zhayujie
db16bdf8cb fix(cli): add security hardening for skill install and process management 2026-03-27 17:59:15 +08:00
zhayujie
f890318ed9 fix: strip leading/trailing whitespace from agent response 2026-03-26 18:13:39 +08:00
zhayujie
158510cbbe feat(cli): imporve cow cli and skill hub integration 2026-03-26 16:49:42 +08:00
zhayujie
ce90cf7aa8 fix: weixin cdn upload retry 2026-03-26 10:20:29 +08:00
zhayujie
a3a3d006eb Merge pull request #2723 from Xiaozhou345/Xiaozhou345-fix-readme-spacing
优化 README 中的中英文排版空格
2026-03-26 10:14:27 +08:00
zhayujie
8fd029a4a1 feat(cli): support cow cli 2026-03-26 10:08:51 +08:00
Xiaozhou345
2e1b52c1e5 优化 README 中的中英文排版空格
按照中文技术文档规范,在文件名和中文之间增加了空格,提升可读性。
2026-03-25 21:26:01 +08:00
zhayujie
3eb8348708 fix: docker volume permission issue and clean up unused dependencies 2026-03-25 01:25:34 +08:00
zhayujie
393f0c007c fix: context loss after trim 2026-03-24 20:49:28 +08:00
yrk
294e380288 update model_list 2026-03-24 11:00:55 +08:00
yrk
4c1c42efac feat: update modelscope bot 2026-03-24 10:43:45 +08:00
zhayujie
c062ca8c66 Merge pull request #2720 from 6vision/fix/deepseek-docs
Docs: update
2026-03-24 00:25:17 +08:00
6vision
76dcb25103 docs(deepseek): update model descriptions to V3.2 with thinking/non-thinking mode
Made-with: Cursor
2026-03-24 00:05:39 +08:00
6vision
c5b4f236db docs(deepseek): remove migration notes from zh and en docs
Made-with: Cursor
2026-03-24 00:05:39 +08:00
zhayujie
0974c940a8 Merge pull request #2719 from 6vision/feat/deepseek-bot
feat: add independent DeepSeek bot module with dedicated config
2026-03-23 22:42:58 +08:00
6vision
cffa20d37e docs(deepseek): remove migration notes to reduce user cognitive load
Made-with: Cursor
2026-03-23 22:39:15 +08:00
6vision
ef009edd29 docs(deepseek): update config guides for independent DeepSeek module
Update DeepSeek docs (zh/en/ja) and README to reflect the new dedicated deepseek_api_key / deepseek_api_base config fields, with backward compatibility notes.

Made-with: Cursor
2026-03-23 21:43:51 +08:00
zhayujie
3ca52b118d fix(weixin): qrcode url log 2026-03-23 21:33:53 +08:00
zhayujie
13f5fde4fb fix: rebuild system prompt from scratch on every turn 2026-03-23 21:27:44 +08:00
6vision
f512b55ec2 feat(deepseek): add independent DeepSeek bot module with dedicated config
Separate DeepSeek from ChatGPTBot into its own module (models/deepseek/) with dedicated deepseek_api_key and deepseek_api_base config fields, avoiding config conflicts when switching between providers. Backward compatible with old users who configured DeepSeek via open_ai_api_key/open_ai_api_base through automatic fallback.

Made-with: Cursor
2026-03-23 21:23:35 +08:00
zhayujie
22b8ca0095 feat: optimize vision image compression 2026-03-23 21:18:04 +08:00
zhayujie
baf66a103d fix(weixin): preserve original filename for received files 2026-03-23 01:18:02 +08:00
zhayujie
45faa9c1ff fix(wexin): resolve image/file send and receive failures 2026-03-23 00:13:41 +08:00
zhayujie
304381a88d fix: hide breadcrumb on mobile for better space utilization 2026-03-22 23:36:34 +08:00
zhayujie
fc9f54dbc8 feat(weixin): optimize login qrcode generate 2026-03-22 23:04:50 +08:00
zhayujie
7199dc187f fix: default gemini model 2026-03-22 22:52:37 +08:00
zhayujie
e9ae066d53 Merge pull request #2716 from cowagent/fix-gemini-model-attribute
fix: add missing model property to GoogleGeminiBot
2026-03-22 22:49:00 +08:00
cowagent
d71ae406ff 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.
2026-03-22 22:43:26 +08:00
zhayujie
f3216904b3 feat(weixin): optimize weixin login qrcode 2026-03-22 21:34:47 +08:00
zhayujie
5958b69ec9 feat: release 2.0.4 2026-03-22 20:49:41 +08:00
zhayujie
7d4e2cb39a docs: update comments 2026-03-22 19:07:19 +08:00
zhayujie
a483ec0cea feat: optimize weixin channel qr code generate 2026-03-22 18:20:10 +08:00
zhayujie
c1421e0874 feat: support weixin channel in scripts 2026-03-22 16:29:12 +08:00
zhayujie
ce89869c3c feat: support weixin channel 2026-03-22 15:52:13 +08:00
zhayujie
b8b57e34ff fix: auto-repair messages 2026-03-21 14:20:22 +08:00
zhayujie
bc7f627253 fix(wecom_bot): compat with old websocket-client 2026-03-21 14:03:17 +08:00
zhayujie
652156e398 feat: make run.sh executable 2026-03-20 17:56:10 +08:00
zhayujie
9febb071c6 fix: run.sh get pid bug 2026-03-20 17:51:04 +08:00
zhayujie
7d0e1568ac fix: feishu msg and log encoding 2026-03-19 17:07:39 +08:00
zhayujie
b4e711f411 feat: add request header 2026-03-19 17:06:05 +08:00
zhayujie
1b5be1b981 fix: remove feishu_bot_name in run.sh 2026-03-19 14:55:12 +08:00
zhayujie
49d8707c58 refactor: simplify run.sh by extracting shared logic and eliminating duplication 2026-03-19 11:07:16 +08:00
zhayujie
9192f6f7f7 feat: add MiniMax-M2.7 and glm-5-turbo 2026-03-19 10:46:13 +08:00
zhayujie
05022e3745 fix: add log 2026-03-18 23:09:27 +08:00
zhayujie
5356e9ddeb docs: adjust docs order 2026-03-18 21:55:09 +08:00
zhayujie
52acf76e2c docs: update jp docs 2026-03-18 21:01:02 +08:00
zhayujie
40cdbd3b45 Merge pull request #2710 from eltociear/add-ja-doc
docs: add Japanese documents
2026-03-18 19:28:04 +08:00
Ikko Ashimine
5487c0befe docs: add Japanese documents 2026-03-18 19:13:39 +09:00
zhayujie
8bb16c48c0 docs: update install cmd 2026-03-18 16:11:35 +08:00
zhayujie
c6384363f9 feat: workspace volume in docker deploy 2026-03-18 16:03:03 +08:00
zhayujie
8993e8ad3e feat: release 2.0.3 2026-03-18 15:40:49 +08:00
zhayujie
289989d9f7 feat: release 2.0.3 2026-03-18 15:10:21 +08:00
zhayujie
dc2ae0e6f1 feat: support gpt-5.4-mini and gpt-5.4-nano 2026-03-18 14:55:29 +08:00
zhayujie
9c966c152d feat: enhance AGENT.md update prompts to encourage proactive evolution 2026-03-18 12:10:45 +08:00
zhayujie
4efae41048 feat: support coding plan 2026-03-18 11:59:22 +08:00
zhayujie
b8437032e9 fix: optimize image recognition prompts 2026-03-18 10:10:23 +08:00
zhayujie
2d339ca81b Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-03-17 23:03:05 +08:00
zhayujie
d53abc9696 docs: update README.md 2026-03-17 23:02:41 +08:00
zhayujie
446c886d38 Merge pull request #2706 from zhayujie/feat-web-files
feat: support files upload in web console and office parsing
2026-03-17 21:22:38 +08:00
214 changed files with 16418 additions and 2638 deletions

9
.gitignore vendored
View File

@@ -33,7 +33,16 @@ plugins/banwords/lib/__pycache__
!plugins/keyword
!plugins/linkai
!plugins/agent
!plugins/cow_cli
client_config.json
ref/
**/.dev.vars
.cursor/
local/
node_modules/
# cow cli
dist/
build/
*.egg-info/
.cow.pid

390
README.md
View File

@@ -4,42 +4,46 @@
<a href="https://github.com/zhayujie/chatgpt-on-wechat/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/chatgpt-on-wechat" alt="Latest release"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/chatgpt-on-wechat" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat"><img src="https://img.shields.io/github/stars/zhayujie/chatgpt-on-wechat?style=flat-square" alt="Stars"></a> <br/>
[中文] | [<a href="docs/en/README.md">English</a>]
[中文] | [<a href="docs/en/README.md">English</a>] | [<a href="docs/ja/README.md">日本語</a>]
</p>
**CowAgent** 是基于大模型的超级AI助理能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号中使用7*24小时运行于你的个人电脑或服务器中。
**CowAgent** 是基于大模型的超级 AI 助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行 Skills、拥有长期记忆并不断成长,比 OpenClaw 更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用7*24小时运行于你的个人电脑或服务器中。
<p align="center">
<a href="https://cowagent.ai/">🌐 官网</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/">📖 文档中心</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 快速开始</a>
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 快速开始</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 技能广场</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ 在线体验</a>
</p>
# 简介
> 该项目既是一个可以开箱即用的超级AI助理也是一个支持高扩展的Agent框架可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下
> 该项目既是一个可以开箱即用的超级 AI 助理,也是一个支持高扩展的 Agent 框架可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills 系统来灵活实现各种定制需求。核心能力如下:
-**复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
-**长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和级记忆,支持关键词及向量检索
-**技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
-**自主任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标
-**长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括核心记忆和级记忆,支持关键词及向量检索
-**技能系统:** Skills 安装和运行的引擎,支持从 [Skill Hub](https://skills.cowagent.ai/)、GitHub 等一键安装技能,或通过对话创造 Skills
-**工具系统:** 内置文件读写、终端执行、浏览器操作、定时任务等工具Agent 自主调用以完成复杂任务
-**CLI系统** 提供终端命令和对话命令,支持进程管理、技能安装、配置修改等操作
-**多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
-**多模型接入** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
-**多端部署** 支持运行在本地计算机或服务器,可集成到网页、飞书、钉钉、微信公众号、企业微信应用中使用
-**知识库:** 集成企业知识库能力让Agent成为专属数字员工基于[LinkAI](https://link-ai.tech)平台实现
-**多模型支持** 支持 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)
DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
- 免部署在线体验:[CowAgent](https://link-ai.tech/cowagent/create)
- DEMO 视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
## 社区
@@ -51,11 +55,11 @@ DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
# 企业服务
<a href="https://link-ai.tech" target="_blank"><img width="720" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
> [LinkAI](https://link-ai.tech/) 是面向企业和开发者的一站式AI智能体平台聚合多模态大模型、知识库、Agent 插件、工作流等能力,支持一键接入主流平台并进行管理支持SaaS、私有化部署等多种模式。
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持 SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent 助理](https://link-ai.tech/cowagent/create)
>
> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的AI解决方案在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践致力于帮助更多企业和开发者拥抱 AI 生产力。
> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的 AI 解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。
**产品咨询和企业服务** 可联系产品客服:
@@ -65,15 +69,17 @@ DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
# 🏷 更新日志
>**2026.04.01** [2.0.5版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.5)Cow CLI 命令系统、Skill Hub 开源、浏览器工具、企微扫码创建、多项优化和修复。
>**2026.03.22** [2.0.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.4),新增个人微信通道(微信扫码即用)、新增 MiniMax-M2.7 和 GLM-5-Turbo 模型、run.sh 脚本重构、日文文档及多项修复。
>**2026.03.18** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持 Coding Plan、新增多个模型、Web 端文件处理、记忆系统升级。
>**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框架新增多种模型并优化了接入渠道。
>**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接口
>**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)
@@ -85,11 +91,17 @@ DEMO视频(对话模式)https://cdn.link-ai.tech/doc/cow_demo.mp4
在终端执行以下命令:
**Linux / macOS**
```bash
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)
**WindowsPowerShell**
```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) 管理服务。
## 一、准备
@@ -98,15 +110,15 @@ bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
> Agent模式下推荐使用以下模型可根据效果及成本综合选择MiniMax-M2.5、glm-5、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4
> 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平台** 接口,可灵活切换 OpenAI、Claude、Gemini、DeepSeek、Qwen、Kimi 等多种常用模型并支持知识库、工作流、插件等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) 克隆项目代码:**
@@ -128,45 +140,68 @@ 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`
如果某项依赖安装失败可注释掉对应的行后重试。
**(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` 文件:
配置文件的模板在根目录的 `config-template.json` 中,需复制该模板创建最终生效的 `config.json` 文件:
```bash
cp config-template.json config.json
```
然后在`config.json`中填入配置以下是对默认配置的说明可根据需要进行自定义修改注意实际使用时请去掉注释保证JSON格式的规范
然后在 `config.json` 中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(注意实际使用时请去掉注释,保证 JSON 格式的规范):
```bash
# config.json 文件内容示例
{
"channel_type": "web", # 接入渠道类型默认为web支持修改为:feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
"model": "MiniMax-M2.5", # 模型名称
"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 模式下单次任务的最大决策步数,超出后将停止继续调用工具
}
```
@@ -175,25 +210,24 @@ pip3 install -r requirements-optional.txt
<details>
<summary>1. 语音配置</summary>
+ 添加 `"speech_recognition": true` 将开启语音识别默认使用openaiwhisper模型识别为文字同时以文字回复该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图)
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别默认使用openaiwhisper模型识别为文字同时以文字回复参数仅支持群聊 (会匹配group_chat_prefixgroup_chat_keyword, 支持语音触发画图)
+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用 openaiwhisper 模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图)
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用 openaiwhisper 模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配 group_chat_prefixgroup_chat_keyword, 支持语音触发画图)
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊)
</details>
<details>
<summary>2. 其他配置</summary>
+ `model`: 模型名称Agent模式下推荐使用 `MiniMax-M2.5``glm-5``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 的触发词。
</details>
<details>
<summary>3. LinkAI配置</summary>
<summary>3. LinkAI 配置</summary>
+ `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) 创建
+ `linkai_app_code`: LinkAI 应用或工作流的code选填普通对话模式中使用。
</details>
注:全部配置项说明可在 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。
@@ -205,37 +239,48 @@ 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` 在网页端对话。
运行后默认会启动 web 服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。
如果需要接入其他应用通道只需修改 `config.json` 配置文件中的 `channel_type` 参数,详情参考:[通道说明](#通道说明)。
### 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`
此外,项目的 `scripts` 目录下有一键运行、关闭程序的脚本供使用。 运行后默认channel为web通过可以通过修改配置文件进行切换。
此外,项目根目录下的 `run.sh` 脚本也支持一键管理服务,包括 `./run.sh start``./run.sh stop``./run.sh restart` 命令,执行 `./run.sh help` 可查看全部用法。
> 如果需要通过浏览器访问 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/) 。
**(1) 下载 docker-compose.yml 文件**
```bash
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
```
下载完成后打开 `docker-compose.yml` 填写所需配置,例如 `CHANNEL_TYPE``OPEN_AI_API_KEY` 和等配置。
@@ -248,23 +293,13 @@ wget 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
```
**(3) 插件使用**
如果需要在docker容器中修改插件配置可通过挂载的方式完成将 [插件配置文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/config.json.template)
重命名为 `config.json`,放置于 `docker-compose.yml` 相同目录下,并在 `docker-compose.yml` 中的 `chatgpt-on-wechat` 部分下添加 `volumes` 映射:
```
volumes:
- ./config.json:/app/plugins/config.json
```
**注**使用docker方式部署的详细教程可以参考[docker部署CoW项目](https://www.wangpc.cc/ai/docker-deploy-cow/)
> 如果需要通过浏览器访问 Web 控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定 IP 开放以保证安全。
## 模型说明
@@ -273,7 +308,7 @@ volumes:
<details>
<summary>OpenAI</summary>
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,34 +317,33 @@ volumes:
"model": "gpt-5.4",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.openai.com/v1",
"bot_type": "chatGPT"
"bot_type": "openai"
}
```
- `model`: 与OpenAI接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、o系列、gpt-4.1等模型Agent模式推荐使用 `gpt-5.4`
- `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官方模型时该参数设为 `chatGPT`
- `bot_type`: 使用 OpenAI 相关模型时无需填写。当使用第三方代理接口接入 Claude 等非 OpenAI 官方模型时,该参数设为 `openai`
</details>
<details>
<summary>LinkAI</summary>
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. 填写配置
```json
{
"model": "gpt-5.4-mini",
"use_linkai": true,
"linkai_api_key": "YOUR API KEY",
"linkai_app_code": "YOUR APP CODE"
"linkai_api_key": "YOUR API KEY"
}
```
+ `use_linkai`: 是否使用LinkAI接口默认关闭设置为true后可对接LinkAI平台的智能体,使用知识库、工作流、数据库、MCP插件等丰富的Agent能
+ `linkai_api_key`: LinkAI平台的API Key可在 [控制台](https://link-ai.tech/console/interface) 中创建
+ `linkai_app_code`: LinkAI智能体 (应用或工作流) 的code选填普通对话模式可用。智能体创建可参考 [说明文档](https://docs.link-ai.tech/platform/quick-start)
+ `model`: model字段填写空则直接使用智能体的模型可在平台中灵活切换[模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
+ `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)中的全部模型均可使用
</details>
<details>
@@ -319,26 +353,26 @@ volumes:
```json
{
"model": "MiniMax-M2.5",
"model": "MiniMax-M2.7",
"minimax_api_key": ""
}
```
- `model`: 可填写 `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) 创建
- `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) 创建
方式二OpenAI兼容方式接入配置如下
方式二OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "chatGPT",
"model": "MiniMax-M2.5",
"bot_type": "openai",
"model": "MiniMax-M2.7",
"open_ai_api_base": "https://api.minimaxi.com/v1",
"open_ai_api_key": ""
}
```
- `bot_type`: OpenAI兼容方式
- `model`: 可填 `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
- `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
</details>
<details>
@@ -348,32 +382,32 @@ volumes:
```json
{
"model": "glm-5",
"model": "glm-5-turbo",
"zhipu_ai_api_key": ""
}
```
- `model`: 可填 `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": "chatGPT",
"model": "glm-5",
"bot_type": "openai",
"model": "glm-5-turbo",
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
"open_ai_api_key": ""
}
```
- `bot_type`: OpenAI兼容方式
- `model`: 可填 `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
- `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
</details>
<details>
<summary>通义千问 (Qwen)</summary>
方式一官方SDK接入配置如下(推荐)
方式一:官方 SDK 接入,配置如下(推荐)
```json
{
@@ -384,18 +418,18 @@ volumes:
- `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": "chatGPT",
"bot_type": "openai",
"model": "qwen3.5-plus",
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"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
</details>
@@ -411,27 +445,27 @@ volumes:
}
```
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
- `moonshot_api_key`: MoonshotAPI-KEY在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
- `moonshot_api_key`: MoonshotAPI-KEY在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
方式二OpenAI兼容方式接入配置如下
方式二OpenAI 兼容方式接入,配置如下:
```json
{
"bot_type": "chatGPT",
"bot_type": "openai",
"model": "kimi-k2.5",
"open_ai_api_base": "https://api.moonshot.cn/v1",
"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
</details>
<details>
<summary>豆包 (Doubao)</summary>
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. 填写配置
@@ -449,7 +483,7 @@ volumes:
<details>
<summary>Claude</summary>
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. 填写配置
@@ -465,7 +499,7 @@ volumes:
<details>
<summary>Gemini</summary>
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",
@@ -478,30 +512,40 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
<details>
<summary>DeepSeek</summary>
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. 填写配置
方式一:官方接入(推荐):
```json
{
"model": "deepseek-chat",
"open_ai_api_key": "sk-xxxxxxxxxxx",
"open_ai_api_base": "https://api.deepseek.com/v1",
"bot_type": "chatGPT"
"deepseek_api_key": "sk-xxxxxxxxxxx"
}
```
- `bot_type`: OpenAI兼容方式
- `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3 和 DeepSeek-R1 模型
- `open_ai_api_key`: DeepSeek平台的 API Key
- `open_ai_api_base`: DeepSeek平台 BASE URL
</details>
- `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3.2(非思考模式)和 DeepSeek-R1思考模式
- `deepseek_api_key`: DeepSeek 平台的 API Key
- `deepseek_api_base`: 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址
方式二OpenAI 兼容方式接入:
```json
{
"model": "deepseek-chat",
"bot_type": "openai",
"open_ai_api_key": "sk-xxxxxxxxxxx",
"open_ai_api_base": "https://api.deepseek.com/v1"
}
```
</details>
<details>
<summary>Azure</summary>
1. API Key创建在 [Azure平台](https://oai.azure.com/) 创建API Key
1. API Key 创建:在 [Azure平台](https://oai.azure.com/) 创建 API Key
2. 填写配置
@@ -518,15 +562,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) 界面查看
</details>
<details>
<summary>百度文心</summary>
方式一官方SDK接入配置如下
方式一:官方 SDK 接入,配置如下:
```json
{
@@ -539,19 +583,19 @@ 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": "chatGPT",
"bot_type": "openai",
"model": "ERNIE-4.0-Turbo-8K",
"open_ai_api_base": "https://qianfan.baidubce.com/v2",
"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
</details>
@@ -575,16 +619,16 @@ 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": "chatGPT",
"bot_type": "openai",
"model": "4.0Ultra",
"open_ai_api_base": "https://spark-api-open.xf-yun.com/v1",
"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) ,因模型而已
@@ -603,13 +647,30 @@ 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)
</details>
<details>
<summary>Coding Plan</summary>
Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 OpenAI 兼容方式接入:
```json
{
"bot_type": "openai",
"model": "模型名称",
"open_ai_api_base": "厂商 Coding Plan API Base",
"open_ai_api_key": "YOUR_API_KEY"
}
```
目前支持阿里云、MiniMax、智谱 GLM、Kimi、火山引擎等厂商各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
</details>
## 通道说明
@@ -618,9 +679,26 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
支持同时可接入多个通道,配置时可通过逗号进行分割,例如 `"channel_type": "feishu,dingtalk"`
<details>
<summary>1. Web</summary>
<summary>1. Weixin - 微信</summary>
项目启动后会默认运行Web控制台配置如下
接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件等消息收发。
```json
{
"channel_type": "weixin"
}
```
启动后终端会显示二维码,使用微信扫码授权即可,也可以在 Web 控制台的「通道」页面中扫码接入。登录凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动无需重新扫码,如需重新登录删除该文件后重启即可。
详细步骤和参数说明参考 [微信接入](https://docs.cowagent.ai/channels/weixin)
</details>
<details>
<summary>2. Web</summary>
项目启动后会默认运行 Web 控制台,配置如下:
```json
{
@@ -635,7 +713,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
</details>
<details>
<summary>2. Feishu - 飞书</summary>
<summary>3. Feishu - 飞书</summary>
飞书支持两种事件接收模式WebSocket 长连接(推荐)和 Webhook。
@@ -671,7 +749,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
</details>
<details>
<summary>3. DingTalk - 钉钉</summary>
<summary>4. DingTalk - 钉钉</summary>
钉钉需要在开放平台创建智能机器人应用,将以下配置填入 `config.json`
@@ -686,7 +764,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
</details>
<details>
<summary>4. WeCom Bot - 企微智能机器人</summary>
<summary>5. WeCom Bot - 企微智能机器人</summary>
企微智能机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,配置简单:
@@ -702,7 +780,7 @@ API Key创建在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
</details>
<details>
<summary>5. QQ - QQ 机器人</summary>
<summary>6. QQ - QQ 机器人</summary>
QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支持 QQ 单聊、群聊和频道消息:
@@ -718,7 +796,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
</details>
<details>
<summary>6. WeCom App - 企业微信应用</summary>
<summary>7. WeCom App - 企业微信应用</summary>
企业微信自建应用接入需在后台创建应用并启用消息回调,配置示例:
@@ -738,7 +816,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
</details>
<details>
<summary>7. WeChat MP - 微信公众号</summary>
<summary>8. WeChat MP - 微信公众号</summary>
本项目支持订阅号和服务号两种公众号,通过服务号(`wechatmp_service`)体验更佳。
@@ -773,7 +851,7 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
</details>
<details>
<summary>8. Terminal - 终端</summary>
<summary>9. Terminal - 终端</summary>
修改 `config.json` 中的 `channel_type` 字段:
@@ -791,8 +869,10 @@ 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),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同
- [Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub):开源的 AI Agent 技能广场,浏览、搜索、安装和发布技能,支持 CowAgent、OpenClaw、Claude Code 等多种 Agent
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入 Slack, Telegram, Discord, Gmail 等海外平台,可作为本项目的补充使用
- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体( Multi-Agent )框架,可以通过多智能体团队的协同来解决复杂问题。
@@ -804,7 +884,7 @@ FAQs <https://github.com/zhayujie/chatgpt-on-wechat/wiki/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 [Skill Hub](https://skills.cowagent.ai/submit) 提交技能
# ✉ 联系

View File

@@ -44,6 +44,11 @@ class ChatService:
if agent is None:
raise RuntimeError("Failed to initialise agent for the session")
# Pass context metadata to model for downstream API requests
if hasattr(agent, 'model'):
agent.model.channel_type = channel_type or ""
agent.model.session_id = session_id or ""
# State shared between the event callback and this method
state = _StreamState()
@@ -70,6 +75,23 @@ class ChatService:
# a new segment; collect tool results until turn_end.
state.pending_tool_results = []
elif event_type == "file_to_send":
url = data.get("url") or ""
if url:
fname = data.get("file_name") or "file"
ft = data.get("file_type") or "file"
if ft == "image":
link = f"![{fname}]({url})"
else:
link = f"[{fname}]({url})"
send_chunk_fn({
"chunk_type": "content",
"delta": "\n\n" + link + "\n\n",
"segment_id": state.segment_id,
})
# Remove url so the model won't repeat it in its reply
data.pop("url", None)
elif event_type == "tool_execution_start":
# Notify the client that a tool is about to run (with its input args)
tool_name = data.get("tool_name", "")
@@ -161,10 +183,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.

View File

@@ -32,18 +32,21 @@ class EmbeddingProvider(ABC):
class OpenAIEmbeddingProvider(EmbeddingProvider):
"""OpenAI embedding provider using REST API"""
def __init__(self, model: str = "text-embedding-3-small", api_key: Optional[str] = None, api_base: Optional[str] = None):
def __init__(self, model: str = "text-embedding-3-small", api_key: Optional[str] = None,
api_base: Optional[str] = None, extra_headers: Optional[dict] = None):
"""
Initialize OpenAI embedding provider
Args:
model: Model name (text-embedding-3-small or text-embedding-3-large)
api_key: OpenAI API key
api_base: Optional API base URL
extra_headers: Optional extra headers to include in API requests
"""
self.model = model
self.api_key = api_key
self.api_base = api_base or "https://api.openai.com/v1"
self.extra_headers = extra_headers or {}
# Validate API key
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
@@ -59,7 +62,8 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
url = f"{self.api_base}/embeddings"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}"
"Authorization": f"Bearer {self.api_key}",
**self.extra_headers,
}
data = {
"input": input_data,
@@ -134,7 +138,8 @@ def create_embedding_provider(
provider: str = "openai",
model: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None
api_base: Optional[str] = None,
extra_headers: Optional[dict] = None
) -> EmbeddingProvider:
"""
Factory function to create embedding provider
@@ -147,10 +152,11 @@ def create_embedding_provider(
model: Model name (default: text-embedding-3-small)
api_key: API key (required)
api_base: API base URL
extra_headers: Optional extra headers to include in API requests
Returns:
EmbeddingProvider instance
Raises:
ValueError: If provider is unsupported or api_key is missing
"""
@@ -158,4 +164,4 @@ def create_embedding_provider(
raise ValueError(f"Unsupported embedding provider: {provider}. Use 'openai' or 'linkai'.")
model = model or "text-embedding-3-small"
return OpenAIEmbeddingProvider(model=model, api_key=api_key, api_base=api_base)
return OpenAIEmbeddingProvider(model=model, api_key=api_key, api_base=api_base, extra_headers=extra_headers)

View File

@@ -76,11 +76,15 @@ class MemoryManager:
linkai_key = os.environ.get('LINKAI_API_KEY')
linkai_base = os.environ.get('LINKAI_API_BASE', 'https://api.link-ai.tech')
if linkai_key:
from common.utils import get_cloud_headers
cloud_headers = get_cloud_headers(linkai_key)
cloud_headers.pop("Authorization", None)
self.embedding_provider = create_embedding_provider(
provider="linkai",
model=self.config.embedding_model,
api_key=linkai_key,
api_base=f"{linkai_base}/v1"
api_base=f"{linkai_base}/v1",
extra_headers=cloud_headers,
)
except Exception as e:
from common.log import logger

View File

@@ -134,6 +134,8 @@ class MemoryService:
else:
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
except ValueError as e:
return {"action": action, "code": 403, "message": "invalid filename", "payload": None}
except FileNotFoundError as e:
return {"action": action, "code": 404, "message": str(e), "payload": None}
except Exception as e:
@@ -145,14 +147,26 @@ class MemoryService:
# ------------------------------------------------------------------
def _resolve_path(self, filename: str) -> str:
"""
Resolve a filename to its absolute path.
Safely resolve a filename to its absolute path within the allowed directory.
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
- ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md``
Raises ValueError if the resolved path escapes the allowed directory
(path traversal protection).
"""
if filename == "MEMORY.md":
return os.path.join(self.workspace_root, filename)
return os.path.join(self.memory_dir, filename)
base_dir = self.workspace_root
else:
base_dir = self.memory_dir
resolved = os.path.realpath(os.path.join(base_dir, filename))
allowed = os.path.realpath(base_dir)
if resolved != allowed and not resolved.startswith(allowed + os.sep):
raise ValueError(f"Invalid filename: path traversal detected")
return resolved
@staticmethod
def _file_info(path: str, filename: str, file_type: str) -> dict:

View File

@@ -16,16 +16,26 @@ from datetime import datetime
from common.log import logger
SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提取值得记住的信息,生成简洁的记忆摘要
SUMMARIZE_SYSTEM_PROMPT = """你是一个记忆提取助手。你的任务是从对话记录中提炼出值得长期记住的关键事件和核心信息
核心原则:
- 按「事件」维度归纳,而不是按对话轮次逐条记录
- 多轮对话如果围绕同一件事,合并为一条摘要
- 只记录有长期价值的信息,忽略闲聊、问候、无意义的短消息
输出要求:
1. 以事件/关键信息为维度记录,每条一行,用 "- " 开头
2. 记录有价值的关键信息,例如用户提出的求及助手的解决方案,对话中涉及的事实信息用户的偏好决策或重要结论
3. 每条摘要需要简明扼要,只保留关键信息
4. 直接输出摘要内容,不要加任何前缀说明
5. 当对话没有任何记录价值例如只是简单问候,可回复"\""""
1. 每条一行,用 "- " 开头,格式为:事件/主题 + 关键结论或结果
2. 值得记录的信息类型:用户提出的求及最终解决方案、重要的事实信息用户的偏好决策、关键技术方案或配置变更
3. 不值得记录的信息:简单问候、闲聊、无实质内容的短消息、重复的中间过程
4. 每条摘要应当简明扼要,一句话概括事件的核心内容和结果
5. 直接输出摘要内容,不要加任何前缀说明
6. 当对话没有任何记录价值(仅含问候或无意义内容),回复""
SUMMARIZE_USER_PROMPT = """请从以下对话记录中提取关键信息,生成记忆摘要
示例(仅供参考格式)
- 用户配置了 XX 功能,设置参数为 YY已生效
- 用户反馈了 XX 问题,原因是 YY通过 ZZ 方式解决"""
SUMMARIZE_USER_PROMPT = """请从以下对话记录中,按关键事件维度提炼记忆摘要(合并同一事件的多轮对话,不要逐条列出):
{conversation}"""
@@ -220,14 +230,16 @@ class MemoryFlushManager:
if not conversation_text.strip():
return ""
# Try LLM summarization first
if self.llm_model:
try:
summary = self._call_llm_for_summary(conversation_text)
if summary and summary.strip() and summary.strip() != "":
return summary.strip()
logger.info(f"[MemoryFlush] LLM returned empty or '', using fallback")
except Exception as e:
logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}")
else:
logger.info("[MemoryFlush] No LLM model available, using rule-based fallback")
return self._extract_summary_fallback(messages, max_messages)
@@ -277,27 +289,38 @@ class MemoryFlushManager:
@staticmethod
def _extract_summary_fallback(messages: List[Dict], max_messages: int = 0) -> str:
"""Rule-based fallback when LLM is unavailable."""
"""
Rule-based fallback when LLM is unavailable.
Groups consecutive user+assistant messages into events instead of
listing each message individually.
"""
msgs = messages if max_messages == 0 else messages[-max_messages * 2:]
items = []
events: List[str] = []
current_user_text = ""
for msg in msgs:
role = msg.get("role", "")
text = MemoryFlushManager._extract_text_from_content(msg.get("content", ""))
if not text or not text.strip():
continue
text = text.strip()
if role == "user":
if len(text) <= 5:
continue
items.append(f"- 用户请求: {text[:200]}")
elif role == "assistant":
current_user_text = text[:150]
elif role == "assistant" and current_user_text:
first_line = text.split("\n")[0].strip()
if len(first_line) > 10:
items.append(f"- 处理结果: {first_line[:200]}")
return "\n".join(items[:15])
events.append(f"- {current_user_text} {first_line[:150]}")
else:
events.append(f"- {current_user_text}")
current_user_text = ""
if current_user_text:
events.append(f"- {current_user_text}")
return "\n".join(events[:10])
@staticmethod
def _extract_text_from_content(content) -> str:

View File

@@ -165,12 +165,13 @@ 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密钥和技能配置",
"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
@@ -199,7 +200,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 +232,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
break
lines = [
"## 技能系统mandatory",
"## 🧩 技能系统mandatory",
"",
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
"",
@@ -281,7 +282,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 +326,7 @@ def _build_user_identity_section(user_identity: Dict[str, str], language: str) -
return []
lines = [
"## 用户身份",
"## 👤 用户身份",
"",
]
@@ -352,7 +353,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}`",
"",
@@ -376,14 +377,16 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"",
"以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**",
"",
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定。当用户修改你的名字、性格或交流风格时,用 `edit` 更新此文件",
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
"",
"**交流规范**:",
"**💬 交流规范**:",
"",
"- 对话中,无需直接输出工作空间中的技术细节,例如 AGENT.md、USER.md、MEMORY.md 等文件名称",
"- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」",
"- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md",
"- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题",
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
"",
]
@@ -416,14 +419,15 @@ 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("")
# 添加每个文件的内容
@@ -442,7 +446,7 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
return []
lines = [
"## 运行时信息",
"## ⚙️ 运行时信息",
"",
]

View File

@@ -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 字以内
4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯
5. 能力介绍和交流风格选项都只要一行,保持精简
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。
## 信息写入(必须严格执行)
## ✍️ 信息写入(必须严格执行)
每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。
@@ -373,7 +375,7 @@ _你刚刚启动这是你的第一次对话。_
⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。
## 全部完成后
## 🎉 全部完成后
当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。
"""

View File

@@ -100,138 +100,31 @@ class Agent:
def get_full_system_prompt(self, skill_filter=None) -> str:
"""
Get the full system prompt including skills.
Build the complete system prompt from scratch every time.
Note: Skills are now built into the system prompt by PromptBuilder,
so we just return the base prompt directly. This method is kept for
backward compatibility.
:param skill_filter: Optional list of skill names to include (deprecated)
:return: Complete system prompt
"""
prompt = self.system_prompt
# Rebuild tool list section to reflect current self.tools
prompt = self._rebuild_tool_list_section(prompt)
# If runtime_info contains dynamic time function, rebuild runtime section
if self.runtime_info and callable(self.runtime_info.get('_get_current_time')):
prompt = self._rebuild_runtime_section(prompt)
# Rebuild skills section to pick up newly installed/removed skills
if self.skill_manager:
prompt = self._rebuild_skills_section(prompt)
return prompt
def _rebuild_runtime_section(self, prompt: str) -> str:
"""
Rebuild runtime info section with current time.
This method dynamically updates the runtime info section by calling
the _get_current_time function from runtime_info.
:param prompt: Original system prompt
:return: Updated system prompt with current runtime info
Re-reads AGENT.md / USER.md / RULE.md from disk, refreshes skills,
tools, and runtime info so any change takes effect immediately.
Falls back to the cached self.system_prompt on error.
"""
try:
# Get current time dynamically
time_info = self.runtime_info['_get_current_time']()
# Build new runtime section
runtime_lines = [
"\n## 运行时信息\n",
"\n",
f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})\n",
"\n"
]
# Add other runtime info
runtime_parts = []
if self.runtime_info.get("model"):
runtime_parts.append(f"模型={self.runtime_info['model']}")
if self.runtime_info.get("workspace"):
# Replace backslashes with forward slashes for Windows paths
workspace_path = str(self.runtime_info['workspace']).replace('\\', '/')
runtime_parts.append(f"工作空间={workspace_path}")
if self.runtime_info.get("channel") and self.runtime_info.get("channel") != "web":
runtime_parts.append(f"渠道={self.runtime_info['channel']}")
if runtime_parts:
runtime_lines.append("运行时: " + " | ".join(runtime_parts) + "\n")
runtime_lines.append("\n")
new_runtime_section = "".join(runtime_lines)
# Find and replace the runtime section
import re
pattern = r'\n## 运行时信息\s*\n.*?(?=\n##|\Z)'
_repl = new_runtime_section.rstrip('\n')
updated_prompt = re.sub(pattern, lambda m: _repl, prompt, flags=re.DOTALL)
return updated_prompt
from agent.prompt import load_context_files, PromptBuilder
if self.skill_manager:
self.skill_manager.refresh_skills()
context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language="zh")
return builder.build(
tools=self.tools,
context_files=context_files,
skill_manager=self.skill_manager,
memory_manager=self.memory_manager,
runtime_info=self.runtime_info,
)
except Exception as e:
logger.warning(f"Failed to rebuild runtime section: {e}")
return prompt
def _rebuild_skills_section(self, prompt: str) -> str:
"""
Rebuild the <available_skills> block so that newly installed or
removed skills are reflected without re-creating the agent.
"""
try:
import re
self.skill_manager.refresh_skills()
new_skills_xml = self.skill_manager.build_skills_prompt()
old_block_pattern = r'<available_skills>.*?</available_skills>'
has_old_block = re.search(old_block_pattern, prompt, flags=re.DOTALL)
# Extract the new <available_skills>...</available_skills> tag from the prompt
new_block = ""
if new_skills_xml and new_skills_xml.strip():
m = re.search(old_block_pattern, new_skills_xml, flags=re.DOTALL)
if m:
new_block = m.group(0)
if has_old_block:
replacement = new_block or "<available_skills>\n</available_skills>"
# Use lambda to prevent re.sub from interpreting backslashes in replacement
# (e.g. Windows paths like \LinkAI would be treated as bad escape sequences)
prompt = re.sub(old_block_pattern, lambda m: replacement, prompt, flags=re.DOTALL)
elif new_block:
skills_header = "以下是可用技能:"
idx = prompt.find(skills_header)
if idx != -1:
insert_pos = idx + len(skills_header)
prompt = prompt[:insert_pos] + "\n" + new_block + prompt[insert_pos:]
except Exception as e:
logger.warning(f"Failed to rebuild skills section: {e}")
return prompt
def _rebuild_tool_list_section(self, prompt: str) -> str:
"""
Rebuild the tool list inside the '## 工具系统' section so that it
always reflects the current ``self.tools`` (handles dynamic add/remove
of conditional tools like web_search).
"""
import re
from agent.prompt.builder import _build_tooling_section
try:
if not self.tools:
return prompt
new_lines = _build_tooling_section(self.tools, "zh")
new_section = "\n".join(new_lines).rstrip("\n")
# Replace existing tooling section
pattern = r'## 工具系统\s*\n.*?(?=\n## |\Z)'
updated = re.sub(pattern, lambda m: new_section, prompt, count=1, flags=re.DOTALL)
return updated
except Exception as e:
logger.warning(f"Failed to rebuild tool list section: {e}")
return prompt
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
return self.system_prompt
def refresh_skills(self):
"""Refresh the loaded skills."""

View File

@@ -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":
@@ -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})
@@ -609,14 +610,14 @@ class AgentStreamExecutor:
"arguments": ""
}
if "id" in tc_delta:
if tc_delta.get("id"):
tool_calls_buffer[index]["id"] = tc_delta["id"]
if "function" in tc_delta:
func = tc_delta["function"]
if "name" in func:
if func.get("name"):
tool_calls_buffer[index]["name"] = func["name"]
if "arguments" in func:
if func.get("arguments"):
tool_calls_buffer[index]["arguments"] += func["arguments"]
# Preserve _gemini_raw_parts for Gemini thoughtSignature round-trip
@@ -720,9 +721,9 @@ class AgentStreamExecutor:
)
else:
if retry_count >= max_retries:
logger.error(f"❌ LLM API error after {max_retries} retries: {e}")
logger.error(f"❌ LLM API error after {max_retries} retries: {e}", exc_info=True)
else:
logger.error(f"❌ LLM call error (non-retryable): {e}")
logger.error(f"❌ LLM call error (non-retryable): {e}", exc_info=True)
raise
# Parse tool calls

View File

@@ -18,6 +18,107 @@ from typing import Dict, List, Set
from common.log import logger
_SYNTH_TOOL_ERR = (
"Error: Missing tool_result adjacent to tool_use (session repair). "
"The conversation history was inconsistent; continue from here."
)
def _repair_tool_use_adjacency(messages: List[Dict]) -> int:
"""
Anthropic requires: after assistant content with tool_use, the next message
must be user content listing tool_result for every tool_use id (same user msg).
Valid histories satisfy this at every such assistant; the loop only mutates
when that condition fails (broken persistence, bad trims, etc.).
"""
def _synth_block(tid: str) -> Dict:
return {
"type": "tool_result",
"tool_use_id": tid,
"content": _SYNTH_TOOL_ERR,
"is_error": True,
}
repairs = 0
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") != "assistant":
i += 1
continue
content = msg.get("content", [])
if not isinstance(content, list):
i += 1
continue
required = [
b.get("id")
for b in content
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
]
if not required:
i += 1
continue
req_set = set(required)
if i + 1 >= len(messages):
messages.append({
"role": "user",
"content": [_synth_block(tid) for tid in required],
})
logger.warning(
"⚠️ Appended synthetic tool_result after trailing assistant tool_use"
)
repairs += 1
break
nxt = messages[i + 1]
if nxt.get("role") != "user":
messages.insert(
i + 1,
{"role": "user", "content": [_synth_block(tid) for tid in required]},
)
logger.warning(
"⚠️ Inserted synthetic tool_result user after tool_use "
f"(next role={nxt.get('role')!r})"
)
repairs += 1
i += 2
continue
nc = nxt.get("content", [])
if not isinstance(nc, list):
messages.insert(
i + 1,
{"role": "user", "content": [_synth_block(tid) for tid in required]},
)
repairs += 1
i += 2
continue
present = {
b.get("tool_use_id")
for b in nc
if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id")
}
if req_set <= present:
i += 1
continue
missing = [tid for tid in required if tid not in present]
nxt["content"] = [_synth_block(tid) for tid in missing] + nc
logger.warning(
"⚠️ Prepended synthetic tool_result for Anthropic adjacency "
f"(missing_ids={missing})"
)
repairs += len(missing)
i += 1
return repairs
# ------------------------------------------------------------------ #
# Claude-format sanitizer (used by agent_stream)
@@ -28,33 +129,21 @@ def sanitize_claude_messages(messages: List[Dict]) -> int:
Validate and fix a Claude-format message list **in-place**.
Fixes handled:
- Trailing assistant message with tool_use but no following tool_result
- Anthropic adjacency: assistant tool_use must be immediately followed by
user message(s) containing matching tool_result blocks
- Leading orphaned tool_result user messages
- Mid-list tool_result blocks whose tool_use_id has no matching
tool_use in any preceding assistant message
Returns the number of messages / blocks removed.
Returns: number of removals plus adjacency repair operations (inserts/prepends).
"""
if not messages:
return 0
removed = 0
# 1. Remove trailing incomplete tool_use assistant messages
while messages:
last = messages[-1]
if last.get("role") != "assistant":
break
content = last.get("content", [])
if isinstance(content, list) and any(
isinstance(b, dict) and b.get("type") == "tool_use"
for b in content
):
logger.warning("⚠️ Removing trailing incomplete tool_use assistant message")
messages.pop()
removed += 1
else:
break
# 1. Adjacency repair (Anthropic: tool_result must be in the next user message)
adj_repairs = _repair_tool_use_adjacency(messages)
# 2. Remove leading orphaned tool_result user messages
while messages:
@@ -136,9 +225,15 @@ def sanitize_claude_messages(messages: List[Dict]) -> int:
if pass_removed == 0:
break
# 4. Removals above can break adjacency; re-run repair only if something was removed.
if removed:
adj_repairs += _repair_tool_use_adjacency(messages)
if removed:
logger.info(f"🔧 Message validation: removed {removed} broken message(s)")
return removed
if adj_repairs:
logger.info(f"🔧 Message validation: adjacency repairs={adj_repairs}")
return removed + adj_repairs
# ------------------------------------------------------------------ #

View File

@@ -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.

View File

@@ -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 = [
"",
"<unavailable_skills>",
"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(" <skill>")
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
lines.append(f" <missing>{_escape_xml(missing_str)}</missing>")
if setup_hint:
lines.append(f" <setup>{_escape_xml(setup_hint)}</setup>")
lines.append(" </skill>")
lines.append("</unavailable_skills>")
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

View File

@@ -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 = []
@@ -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'),
@@ -138,6 +139,25 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
)
_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"}
def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]:
"""
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.
"""
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:

View File

@@ -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 ""

View File

@@ -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,15 +95,24 @@ 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")
merged[name] = {
if name in saved:
enabled = prev.get("enabled", True)
else:
enabled = entry.metadata.default_enabled if entry.metadata else True
entry_dict = {
"name": name,
"description": skill.description,
"source": skill.source,
"enabled": prev.get("enabled", True),
"source": prev.get("source") or skill.source,
"enabled": enabled,
"category": category,
}
display_name = prev.get("display_name")
if display_name:
entry_dict["display_name"] = display_name
merged[name] = entry_dict
self.skills_config = merged
self._save_skills_config()
@@ -157,69 +166,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

View File

@@ -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

View File

@@ -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',
]
"""

View File

@@ -0,0 +1,3 @@
from agent.tools.browser.browser_tool import BrowserTool
__all__ = ["BrowserTool"]

View File

@@ -0,0 +1,708 @@
"""
Browser service - Playwright wrapper managing browser lifecycle and page operations.
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 os
import sys
import uuid
import queue
import threading
from typing import Optional, Dict, Any, List, Callable
from common.log import logger
try:
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
_HAS_PLAYWRIGHT = True
except ImportError:
_HAS_PLAYWRIGHT = False
# ---------------------------------------------------------------------------
# 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 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._headless: Optional[bool] = None
self._screenshot_dir: Optional[str] = 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
# ------------------------------------------------------------------
# Background-thread lifecycle
# ------------------------------------------------------------------
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
# Wait for old thread to fully exit before creating a new one
old = self._thread
if old and old.is_alive():
old.join(timeout=5)
# Fresh queue to avoid stale sentinels from a previous close()
self._task_queue = queue.Queue()
self._alive = True
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)
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()
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)
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")
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()
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")
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")
# ------------------------------------------------------------------
# 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]:
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
except Exception as e:
return {"error": f"Navigation failed: {e}"}
try:
page.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
page.wait_for_timeout(500)
try:
title = page.title()
except Exception:
title = ""
try:
current_url = page.url
except Exception:
current_url = url
return {"url": current_url, "title": title, "status": status}
def snapshot(self, selector: Optional[str] = None) -> str:
return self._submit(self._do_snapshot, selector)
def _do_snapshot(self, selector: Optional[str] = None) -> str:
page = self._page
try:
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)
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)
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:
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]:
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"""
() => {{
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]:
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"""
() => {{
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]:
return self._submit(self._do_select, value, ref, selector, timeout)
def _do_select(self, value, ref, selector, timeout) -> Dict[str, Any]:
page = self._page
try:
if ref is not None:
result = page.evaluate(f"""
() => {{
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
if (!el || el.tagName.toLowerCase() !== "select")
return {{ error: "ref {ref} is not a <select> 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]:
return self._submit(self._do_scroll, direction, amount)
def _do_scroll(self, direction, amount) -> Dict[str, Any]:
page = self._page
delta_map = {
"down": (0, amount),
"up": (0, -amount),
"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]:
return self._submit(self._do_wait, selector, timeout, state)
def _do_wait(self, selector, timeout, state) -> Dict[str, Any]:
page = self._page
try:
if selector:
page.wait_for_selector(selector, timeout=timeout, state=state)
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]:
return self._submit(self._do_go_back)
def _do_go_back(self) -> Dict[str, Any]:
page = self._page
try:
page.go_back(wait_until="domcontentloaded", timeout=10000)
try:
title = page.title()
except Exception:
title = ""
try:
url = page.url
except Exception:
url = ""
return {"url": url, "title": title}
except Exception as e:
return {"error": f"Go back failed: {e}"}
def go_forward(self) -> Dict[str, Any]:
return self._submit(self._do_go_forward)
def _do_go_forward(self) -> Dict[str, Any]:
page = self._page
try:
page.go_forward(wait_until="domcontentloaded", timeout=10000)
try:
title = page.title()
except Exception:
title = ""
try:
url = page.url
except Exception:
url = ""
return {"url": url, "title": title}
except Exception as e:
return {"error": f"Go forward failed: {e}"}
def get_text(self, selector: str) -> Dict[str, Any]:
return self._submit(self._do_get_text, selector)
def _do_get_text(self, selector) -> Dict[str, Any]:
page = self._page
try:
text = page.text_content(selector, timeout=5000)
return {"text": text or ""}
except Exception as e:
return {"error": f"Get text failed: {e}"}
def evaluate(self, script: str) -> Dict[str, Any]:
return self._submit(self._do_evaluate, script)
def _do_evaluate(self, script) -> Dict[str, Any]:
page = self._page
try:
result = page.evaluate(script)
return {"result": result}
except Exception as e:
return {"error": f"Evaluate failed: {e}"}
def press(self, key: str) -> Dict[str, Any]:
return self._submit(self._do_press, key)
def _do_press(self, key) -> Dict[str, Any]:
page = self._page
try:
page.keyboard.press(key)
page.wait_for_timeout(300)
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

View File

@@ -0,0 +1,290 @@
"""
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 (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 = {
"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)
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"--- Page Snapshot ---\n{snapshot_text}"
)
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")

View File

@@ -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

View File

@@ -98,7 +98,18 @@ class Send(BaseTool):
"size_formatted": self._format_size(file_size),
"message": message or f"正在发送 {file_name}"
}
try:
from common.cloud_client import get_website_base_url, copy_send_file
# Do nothing when in local env
if get_website_base_url():
url = copy_send_file(absolute_path, self.cwd)
if url:
result["url"] = url
except Exception:
pass
return ToolResult.success(result)
def _resolve_path(self, path: str) -> str:

View File

@@ -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":

View File

@@ -35,7 +35,7 @@ class Vision(BaseTool):
name: str = "vision"
description: str = (
"Analyze an image (local file or URL) using Vision API. "
"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."
)
@@ -82,7 +82,7 @@ class Vision(BaseTool):
if not question:
return ToolResult.fail("Error: 'question' parameter is required")
api_key, api_base = self._resolve_provider()
api_key, api_base, extra_headers = self._resolve_provider()
if not api_key:
return ToolResult.fail(
"Error: No API key configured for Vision.\n"
@@ -98,7 +98,7 @@ class Vision(BaseTool):
return ToolResult.fail(f"Error: {e}")
try:
return self._call_api(api_key, api_base, model, question, image_content)
return self._call_api(api_key, api_base, model, question, image_content, extra_headers)
except requests.Timeout:
return ToolResult.fail(f"Error: Vision API request timed out after {DEFAULT_TIMEOUT}s")
except requests.ConnectionError:
@@ -107,22 +107,26 @@ class Vision(BaseTool):
logger.error(f"[Vision] Unexpected error: {e}", exc_info=True)
return ToolResult.fail(f"Error: Vision API call failed - {e}")
def _resolve_provider(self) -> Tuple[Optional[str], str]:
"""Resolve API key and base URL. Priority: conf() > env vars."""
def _resolve_provider(self) -> Tuple[Optional[str], str, dict]:
"""Resolve API key, base URL and extra headers. Priority: conf() > env vars."""
api_key = conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY")
if api_key:
api_base = (conf().get("open_ai_api_base") or os.environ.get("OPENAI_API_BASE", "")).rstrip("/") \
or "https://api.openai.com/v1"
return api_key, self._ensure_v1(api_base)
return api_key, self._ensure_v1(api_base), {}
api_key = conf().get("linkai_api_key") or os.environ.get("LINKAI_API_KEY")
if api_key:
api_base = (conf().get("linkai_api_base") or os.environ.get("LINKAI_API_BASE", "")).rstrip("/") \
or "https://api.link-ai.tech"
logger.debug("[Vision] Using LinkAI API (OPENAI_API_KEY not set)")
return api_key, self._ensure_v1(api_base)
from common.utils import get_cloud_headers
extra = get_cloud_headers(api_key)
extra.pop("Authorization", None)
extra.pop("Content-Type", None)
return api_key, self._ensure_v1(api_base), extra
return None, ""
return None, "", {}
@staticmethod
def _ensure_v1(api_base: str) -> str:
@@ -163,7 +167,7 @@ class Vision(BaseTool):
@staticmethod
def _maybe_compress(path: str) -> str:
"""Compress image if larger than threshold; return path to use."""
"""Compress image to under COMPRESS_THRESHOLD with max long-edge 1536px."""
file_size = os.path.getsize(path)
if file_size <= COMPRESS_THRESHOLD:
return path
@@ -171,33 +175,53 @@ class Vision(BaseTool):
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
try:
# macOS: use sips
subprocess.run(
["sips", "-Z", "800", path, "--out", tmp.name],
capture_output=True, check=True,
)
logger.debug(f"[Vision] Compressed image ({file_size // 1024}KB -> {os.path.getsize(tmp.name) // 1024}KB)")
return tmp.name
except (FileNotFoundError, subprocess.CalledProcessError):
pass
def _try_sips(max_dim: str, quality: str) -> bool:
try:
subprocess.run(
["sips", "-Z", max_dim, "-s", "formatOptions", quality,
path, "--out", tmp.name],
capture_output=True, check=True,
)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False
try:
# Linux: use ImageMagick convert
subprocess.run(
["convert", path, "-resize", "800x800>", tmp.name],
capture_output=True, check=True,
)
logger.debug(f"[Vision] Compressed image ({file_size // 1024}KB -> {os.path.getsize(tmp.name) // 1024}KB)")
def _try_convert(max_dim: str, quality: str) -> bool:
try:
subprocess.run(
["convert", path, "-resize", f"{max_dim}x{max_dim}>",
"-quality", quality, tmp.name],
capture_output=True, check=True,
)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False
attempts = [
("1536", "85"),
("1536", "70"),
("1536", "50"),
]
for max_dim, quality in attempts:
ok = _try_sips(max_dim, quality) or _try_convert(max_dim, quality)
if not ok:
continue
new_size = os.path.getsize(tmp.name)
logger.debug(f"[Vision] Compressed image "
f"({file_size // 1024}KB -> {new_size // 1024}KB, "
f"max_dim={max_dim}, q={quality})")
if new_size <= COMPRESS_THRESHOLD:
return tmp.name
if os.path.exists(tmp.name) and os.path.getsize(tmp.name) > 0:
return tmp.name
except (FileNotFoundError, subprocess.CalledProcessError):
pass
os.remove(tmp.name)
return path
def _call_api(self, api_key: str, api_base: str, model: str,
question: str, image_content: dict) -> ToolResult:
question: str, image_content: dict, extra_headers: dict = None) -> ToolResult:
payload = {
"model": model,
"messages": [
@@ -215,6 +239,7 @@ class Vision(BaseTool):
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
**(extra_headers or {}),
}
resp = requests.post(

View File

@@ -225,10 +225,8 @@ class WebSearch(BaseTool):
api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
url = f"{api_base.rstrip('/')}/v1/plugin/execute"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
from common.utils import get_cloud_headers
headers = get_cloud_headers(api_key)
payload = {
"code": "web-search",

10
app.py
View File

@@ -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(
@@ -229,6 +235,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:

View File

@@ -74,7 +74,7 @@ class AgentLLMModel(LLMModel):
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
("doubao", const.DOUBAO),
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
]
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
@@ -106,7 +106,7 @@ class AgentLLMModel(LLMModel):
return configured_bot_type
if not model_name or not isinstance(model_name, str):
return const.CHATGPT
return const.OPENAI
if model_name in self._MODEL_BOT_TYPE_MAP:
return self._MODEL_BOT_TYPE_MAP[model_name]
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
@@ -115,12 +115,12 @@ class AgentLLMModel(LLMModel):
return const.QWEN_DASHSCOPE
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
return const.MOONSHOT
if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]:
return const.CHATGPT
if conf().get("bot_type") == "modelscope":
return const.MODELSCOPE
for prefix, btype in self._MODEL_PREFIX_MAP:
if model_name.startswith(prefix):
return btype
return const.CHATGPT
return const.OPENAI
@property
def bot(self):
@@ -152,12 +152,20 @@ class AgentLLMModel(LLMModel):
# Only pass max_tokens if it's explicitly set
if request.max_tokens is not None:
kwargs['max_tokens'] = request.max_tokens
# Extract system prompt if present
system_prompt = getattr(request, 'system', None)
if system_prompt:
kwargs['system'] = system_prompt
# Pass context metadata to bot
channel_type = getattr(self, 'channel_type', None)
if channel_type:
kwargs['channel_type'] = channel_type
session_id = getattr(self, 'session_id', None)
if session_id:
kwargs['session_id'] = session_id
response = self.bot.call_with_tools(**kwargs)
return self._format_response(response)
else:
@@ -195,10 +203,13 @@ class AgentLLMModel(LLMModel):
if system_prompt:
kwargs['system'] = system_prompt
# Pass channel_type for linkai tracking
# Pass context metadata to bot
channel_type = getattr(self, 'channel_type', None)
if channel_type:
kwargs['channel_type'] = channel_type
session_id = getattr(self, 'session_id', None)
if session_id:
kwargs['session_id'] = session_id
stream = self.bot.call_with_tools(**kwargs)
@@ -262,10 +273,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}")
@@ -375,9 +389,10 @@ class AgentBridge:
logger.warning(f"[AgentBridge] Failed to attach context to scheduler: {e}")
break
# Pass channel_type to model so linkai requests carry it
# Pass context metadata to model for downstream API requests
if context and hasattr(agent, 'model'):
agent.model.channel_type = context.get("channel_type", "")
agent.model.session_id = session_id or ""
# Store session_id on agent so executor can clear DB on fatal errors
agent._current_session_id = session_id

View File

@@ -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:

View File

@@ -13,7 +13,7 @@ from voice.factory import create_voice
class Bridge(object):
def __init__(self):
self.btype = {
"chat": const.CHATGPT,
"chat": const.OPENAI,
"voice_to_text": conf().get("voice_to_text", "openai"),
"text_to_voice": conf().get("text_to_voice", "google"),
"translate": conf().get("translate", "baidu"),
@@ -61,6 +61,9 @@ class Bridge(object):
if model_type and model_type.startswith("doubao"):
self.btype["chat"] = const.DOUBAO
if model_type and model_type.startswith("deepseek"):
self.btype["chat"] = const.DEEPSEEK
if model_type in [const.MODELSCOPE]:
self.btype["chat"] = const.MODELSCOPE

View File

@@ -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

View File

@@ -454,7 +454,7 @@ class FeiShuChanel(ChatChannel):
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
# Build content JSON
content_json = json.dumps(reply_content) if content_key is None else json.dumps({content_key: reply_content})
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
if can_reply:

View File

@@ -23,6 +23,7 @@ from channel.qq.qq_message import QQMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from common.ws_client_compat import websocket_app_run_forever
from config import conf
# Rich media file_type constants
@@ -210,7 +211,7 @@ class QQChannel(ChatChannel):
def run_forever():
try:
self._ws.run_forever(ping_interval=0, reconnect=0)
websocket_app_run_forever(self._ws, ping_interval=0, reconnect=0)
except (SystemExit, KeyboardInterrupt):
logger.info("[QQ] WebSocket thread interrupted")
except Exception as e:

View File

@@ -166,8 +166,8 @@
<i class="fas fa-bars text-slate-600 dark:text-slate-300"></i>
</button>
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-sm min-w-0">
<!-- Breadcrumb (hidden on mobile) -->
<div class="hidden lg:flex items-center gap-2 text-sm min-w-0">
<span id="breadcrumb-group" class="text-slate-400 dark:text-slate-500 truncate" data-i18n="nav_chat">Chat</span>
<i class="fas fa-chevron-right text-[10px] text-slate-300 dark:text-slate-600"></i>
<span id="breadcrumb-page" class="font-medium text-slate-700 dark:text-slate-200 truncate" data-i18n="menu_chat">Chat</span>
@@ -270,7 +270,7 @@
<div class="max-w-3xl mx-auto">
<!-- Attachment preview bar -->
<div id="attachment-preview" class="attachment-preview hidden"></div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 relative">
<div class="flex items-center flex-shrink-0">
<button id="new-chat-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
@@ -287,6 +287,7 @@
</div>
<input type="file" id="file-input" class="hidden" multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.rar,.7z,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md">
<div id="slash-menu" class="slash-menu hidden"></div>
<textarea id="chat-input"
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
@@ -295,7 +296,7 @@
text-sm leading-relaxed"
rows="1"
data-i18n-placeholder="input_placeholder"
placeholder="Type a message..."></textarea>
placeholder="Type a message, or press / for commands"></textarea>
<button id="send-btn"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
bg-primary-400 text-white hover:bg-primary-500
@@ -454,6 +455,11 @@
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="skills_title">Skills</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="skills_desc">View, enable, or disable agent skills</p>
</div>
<a href="https://skills.cowagent.ai/" target="_blank"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-primary-500 bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors">
<i class="fas fa-puzzle-piece text-[10px]"></i>
<span data-i18n="skills_hub_btn">Skill Hub</span>
</a>
</div>
<!-- Built-in Tools Section -->

View File

@@ -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); }
@@ -446,3 +451,87 @@
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
/* Slash Command Menu */
.slash-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04);
z-index: 50;
padding: 4px;
animation: slashMenuIn 0.15s ease-out;
}
.slash-menu.hidden { display: none; }
@keyframes slashMenuIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.slash-menu-header {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.slash-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s ease;
}
.slash-menu-item:hover,
.slash-menu-item.active {
background: #EDFDF3;
}
.slash-menu-item .cmd {
font-size: 13px;
font-weight: 500;
color: #334155;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
}
.slash-menu-item.active .cmd {
color: #228547;
}
.slash-menu-item .desc {
font-size: 12px;
color: #94a3b8;
margin-left: 12px;
white-space: nowrap;
}
/* Dark mode */
.dark .slash-menu {
background: #1A1A1A;
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.35), 0 2px 8px -2px rgba(0, 0, 0, 0.15);
}
.dark .slash-menu-header {
color: #64748b;
}
.dark .slash-menu-item:hover,
.dark .slash-menu-item.active {
background: rgba(74, 190, 110, 0.1);
}
.dark .slash-menu-item .cmd {
color: #e2e8f0;
}
.dark .slash-menu-item.active .cmd {
color: #4ABE6E;
}
.dark .slash-menu-item .desc {
color: #64748b;
}

View File

@@ -3,9 +3,9 @@
===================================================================== */
// =====================================================================
// Version — update this before each release
// Version — fetched from backend (single source: /VERSION file)
// =====================================================================
const APP_VERSION = 'v2.0.2';
let APP_VERSION = '';
// =====================================================================
// i18n
@@ -21,7 +21,7 @@ const I18N = {
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
input_placeholder: '输入消息...',
input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置',
config_channel: '通道配置',
@@ -33,7 +33,7 @@ const I18N = {
config_save: '保存', config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义...',
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能',
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', skills_hub_btn: '探索技能广场',
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
tools_section_title: '内置工具', tools_loading: '加载工具中...',
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
@@ -51,6 +51,15 @@ 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分钟后过期',
wecom_scan_btn: '扫码创建企微机器人', wecom_scan_desc: '使用企业微信扫码,一键创建智能机器人',
wecom_scan_success: '创建成功,正在启动通道...',
wecom_scan_fail: '创建失败',
wecom_mode_scan: '扫码接入', wecom_mode_manual: '手动填写',
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
@@ -67,7 +76,7 @@ const I18N = {
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',
input_placeholder: 'Type a message...',
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_channel: 'Channel Configuration',
@@ -79,7 +88,7 @@ const I18N = {
config_save: 'Save', config_saved: 'Saved',
config_save_error: 'Save failed',
config_custom_option: 'Custom...',
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills',
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills', skills_hub_btn: 'Skill Hub',
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...',
skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable',
@@ -97,6 +106,15 @@ 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',
wecom_scan_btn: 'Scan to Create WeCom Bot', wecom_scan_desc: 'Scan with WeCom to create a bot instantly',
wecom_scan_success: 'Bot created, starting channel...',
wecom_scan_fail: 'Bot creation failed',
wecom_mode_scan: 'Scan QR', wecom_mode_manual: 'Manual',
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
@@ -312,6 +330,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);
}
@@ -425,6 +448,131 @@ chatInput.addEventListener('paste', (e) => {
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
// ── Slash Command Menu ───────────────────────────────────────
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: '/config', desc: '查看当前配置' },
{ cmd: '/logs', desc: '查看最近日志' },
{ cmd: '/version', desc: '查看版本' },
];
const slashMenu = document.getElementById('slash-menu');
let slashActiveIdx = 0;
let slashFiltered = [];
let slashJustSelected = false;
let slashLastFilter = '';
let slashLastMouseX = -1;
let slashLastMouseY = -1;
function showSlashMenu(filter) {
const q = filter.toLowerCase();
if (q === slashLastFilter && !slashMenu.classList.contains('hidden')) return;
slashLastFilter = q;
const newFiltered = SLASH_COMMANDS.filter(c => c.cmd.toLowerCase().startsWith(q));
if (newFiltered.length === 0) {
hideSlashMenu();
return;
}
const changed = newFiltered.length !== slashFiltered.length ||
newFiltered.some((c, i) => c.cmd !== slashFiltered[i]?.cmd);
slashFiltered = newFiltered;
if (changed) slashActiveIdx = 0;
slashActiveIdx = Math.min(slashActiveIdx, slashFiltered.length - 1);
slashNavByKeyboard = true;
renderSlashItems();
slashMenu.classList.remove('hidden');
}
function hideSlashMenu() {
slashMenu.classList.add('hidden');
slashMenu.innerHTML = '';
slashFiltered = [];
slashActiveIdx = -1;
slashLastFilter = '';
slashNavByKeyboard = false;
slashLastMouseX = -1;
slashLastMouseY = -1;
}
function isSlashMenuVisible() {
return !slashMenu.classList.contains('hidden') && slashFiltered.length > 0;
}
function renderSlashItems() {
slashMenu.innerHTML =
'<div class="slash-menu-header">Commands</div>' +
slashFiltered.map((c, i) =>
`<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` +
`<span class="cmd">${escapeHtml(c.cmd)}</span>` +
`<span class="desc">${escapeHtml(c.desc)}</span></div>`
).join('');
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)
// 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);
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;
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;
@@ -432,11 +580,92 @@ 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();
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;
}
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;
}
}
// 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;
@@ -450,6 +679,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"]');
@@ -465,6 +698,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();
@@ -522,6 +761,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;
@@ -537,6 +777,7 @@ function startSSE(requestId, loadingEl, timestamp) {
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
<div class="agent-steps"></div>
<div class="answer-content sse-streaming"></div>
<div class="media-content"></div>
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5">${formatTime(timestamp)}</div>
</div>
@@ -544,6 +785,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) {
@@ -634,6 +876,38 @@ 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: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 === '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 = `<i class="fas fa-file-download" style="color:#6b7280;"></i> ${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.close();
delete activeStreams[requestId];
@@ -722,7 +996,7 @@ function createUserMessageEl(content, timestamp, attachments) {
const textHtml = content ? renderMarkdown(content) : '';
el.innerHTML = `
<div class="max-w-[75%] sm:max-w-[60%]">
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content">
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
${attachHtml}${textHtml}
</div>
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
@@ -992,7 +1266,6 @@ let configProviders = {};
let configApiBases = {};
let configApiKeys = {};
let configCurrentModel = '';
let configHasBotType = false;
let cfgProviderValue = '';
let cfgModelValue = '';
@@ -1052,7 +1325,6 @@ function initConfigView(data) {
configApiBases = data.api_bases || {};
configApiKeys = data.api_keys || {};
configCurrentModel = data.model || '';
configHasBotType = !!data.has_bot_type;
const providerEl = document.getElementById('cfg-provider');
const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: p.label }));
@@ -1214,8 +1486,10 @@ function saveModelConfig() {
const updates = { model: model };
const p = configProviders[cfgProviderValue];
updates.use_linkai = (cfgProviderValue === 'linkai');
if (configHasBotType) {
updates.bot_type = (cfgProviderValue === 'linkai') ? '' : cfgProviderValue;
if (cfgProviderValue === 'linkai') {
updates.bot_type = '';
} else {
updates.bot_type = cfgProviderValue;
}
if (p && p.api_base_key) {
const base = document.getElementById('cfg-api-base').value.trim();
@@ -1418,7 +1692,7 @@ function renderSkillCard(card, sk) {
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.name)}</span>
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate flex-1">${escapeHtml(sk.display_name || sk.name)}</span>
<button
role="switch"
aria-checked="${enabled}"
@@ -1583,6 +1857,8 @@ function loadChannelsView() {
}
function renderActiveChannels() {
stopWeixinQrPoll();
stopWeixinStatusPoll();
const container = document.getElementById('channels-content');
container.innerHTML = '';
closeAddChannelPanel();
@@ -1608,17 +1884,34 @@ 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';
const wecomNeedsCreds = ch.name === 'wecom_bot' && !_wecomBotHasCreds(ch);
let statusDot, statusText;
if (weixinWaiting) {
statusDot = 'bg-amber-400 animate-pulse';
statusText = ch.login_status === 'scanned'
? `<span class="text-xs text-primary-500">${t('weixin_scan_scanned')}</span>`
: `<span class="text-xs text-amber-500">${t('weixin_scan_waiting')}</span>`;
} else if (wecomNeedsCreds) {
statusDot = 'bg-amber-400 animate-pulse';
statusText = `<span class="text-xs text-amber-500">${t('channels_connecting')}</span>`;
} else {
statusDot = 'bg-primary-400';
statusText = `<span class="text-xs text-primary-500">${t('channels_connected')}</span>`;
}
card.innerHTML = `
<div class="flex items-center gap-4 mb-5">
<div class="flex items-center gap-4${hasFields || weixinWaiting || wecomNeedsCreds ? ' mb-5' : ''}">
<div class="w-10 h-10 rounded-xl bg-${ch.color}-50 dark:bg-${ch.color}-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas ${ch.icon} text-${ch.color}-500 text-base"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-800 dark:text-slate-100">${escapeHtml(label)}</span>
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
<span class="text-xs text-primary-500">${t('channels_connected')}</span>
<span class="w-2 h-2 rounded-full ${statusDot}"></span>
${statusText}
</div>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(ch.name)}</p>
</div>
@@ -1630,7 +1923,23 @@ function renderActiveChannels() {
${t('channels_disconnect')}
</button>
</div>
<div class="space-y-4">
${weixinWaiting ? `<div id="weixin-active-qr" class="flex flex-col items-center py-2">
<button onclick="showWeixinActiveQr()"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150">
${t('weixin_scan_title')}
</button>
</div>` : ''}
${wecomNeedsCreds ? `<div id="wecom-active-auth" class="flex flex-col items-center py-2">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-3">${t('wecom_scan_desc')}</p>
<button onclick="startWecomBotAuthInCard()"
class="px-5 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150">
<i class="fas fa-qrcode mr-2"></i>${t('wecom_scan_btn')}
</button>
<div id="wecom-card-scan-status" class="mt-3"></div>
</div>` : ''}
${hasFields ? `<div class="space-y-4">
${fieldsHtml}
<div class="flex items-center justify-end gap-3 pt-1">
<span id="ch-status-${ch.name}" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
@@ -1639,10 +1948,14 @@ function renderActiveChannels() {
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
id="ch-save-${ch.name}">${t('channels_save')}</button>
</div>
</div>`;
</div>` : ''}`;
container.appendChild(card);
bindSecretFieldEvents(card);
if (weixinWaiting) {
startWeixinActiveStatusPoll();
}
});
}
@@ -1774,6 +2087,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 = `<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6 text-center">
<p class="text-sm text-slate-500 dark:text-slate-400">${currentLang === 'zh' ? '所有通道均已接入' : 'All channels are already connected'}</p>
@@ -1828,14 +2144,18 @@ function openAddChannelPanel() {
}
function closeAddChannelPanel() {
stopWeixinQrPoll();
const panel = document.getElementById('channels-add-panel');
if (panel) {
panel.classList.add('hidden');
panel.innerHTML = '';
}
const content = document.getElementById('channels-content');
if (content) content.classList.remove('hidden');
}
function onAddChannelSelect(chName) {
stopWeixinQrPoll();
const fieldsContainer = document.getElementById('add-channel-fields');
const actions = document.getElementById('add-channel-actions');
@@ -1845,6 +2165,23 @@ function onAddChannelSelect(chName) {
return;
}
if (chName === 'weixin') {
actions.classList.add('hidden');
fieldsContainer.innerHTML = `
<div id="weixin-qr-panel" class="flex flex-col items-center py-4">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">${t('weixin_scan_loading')}</p>
</div>`;
startWeixinQrLogin();
return;
}
if (chName === 'wecom_bot') {
actions.classList.add('hidden');
const ch = channelsData.find(c => c.name === chName);
fieldsContainer.innerHTML = buildWecomBotPanel(ch);
return;
}
const ch = channelsData.find(c => c.name === chName);
if (!ch) return;
@@ -1900,6 +2237,357 @@ 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 = `
<div id="weixin-qr-panel" class="flex flex-col items-center py-2">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">${t('weixin_scan_loading')}</p>
</div>`;
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 = `<p class="text-sm text-red-500">${t('weixin_scan_fail')}: ${data.message || ''}</p>`;
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 = `<p class="text-sm text-red-500">${t('weixin_scan_fail')}</p>`;
});
}
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 = `
<div class="flex flex-col items-center">
<p class="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">${t('weixin_scan_title')}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mb-4">${t('weixin_scan_desc')}</p>
<div class="bg-white p-3 rounded-xl shadow-sm border border-slate-100 dark:border-slate-700 mb-3">
<img src="${escapeHtml(qrcodeUrl)}" alt="QR Code" class="w-52 h-52" style="image-rendering: pixelated;"/>
</div>
<p class="text-xs ${statusColor} mb-1">${statusText}</p>
<p class="text-xs text-slate-400 dark:text-slate-500">${t('weixin_qr_tip')}</p>
</div>`;
}
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 = `
<div class="flex flex-col items-center py-4">
<div class="w-12 h-12 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center mb-3">
<i class="fas fa-check text-primary-500 text-lg"></i>
</div>
<p class="text-sm font-medium text-primary-600 dark:text-primary-400">${t('weixin_scan_success')}</p>
</div>`;
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(() => {});
}
// =====================================================================
// WeCom Bot QR Auth
// =====================================================================
const WECOM_BOT_SDK_URL = 'https://wwcdn.weixin.qq.com/node/wework/js/wecom-aibot-sdk@0.1.0.min.js';
const WECOM_BOT_SOURCE = 'cowagent';
let _wecomSdkLoaded = false;
function ensureWecomSdkLoaded() {
return new Promise((resolve, reject) => {
if (_wecomSdkLoaded && window.WecomAIBotSDK) { resolve(); return; }
if (document.querySelector(`script[src="${WECOM_BOT_SDK_URL}"]`)) {
_wecomSdkLoaded = true; resolve(); return;
}
const s = document.createElement('script');
s.src = WECOM_BOT_SDK_URL;
s.onload = () => { _wecomSdkLoaded = true; resolve(); };
s.onerror = () => reject(new Error('Failed to load WecomAIBotSDK'));
document.head.appendChild(s);
});
}
function _wecomBotHasCreds(ch) {
if (!ch || !ch.fields) return false;
const idField = ch.fields.find(f => f.key === 'wecom_bot_id');
const secretField = ch.fields.find(f => f.key === 'wecom_bot_secret');
return !!(idField && idField.value && secretField && secretField.value);
}
function buildWecomBotPanel(ch) {
const scanLabel = t('wecom_mode_scan');
const manualLabel = t('wecom_mode_manual');
const hasCreds = _wecomBotHasCreds(ch);
const defaultMode = hasCreds ? 'manual' : 'scan';
return `
<div id="wecom-bot-panel" data-default-mode="${defaultMode}">
<div class="flex items-center justify-center gap-1 mb-5 bg-slate-100 dark:bg-white/5 rounded-lg p-1">
<button id="wecom-tab-scan" onclick="switchWecomBotMode('scan')"
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm">
${scanLabel}
</button>
<button id="wecom-tab-manual" onclick="switchWecomBotMode('manual')"
class="flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors
text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
${manualLabel}
</button>
</div>
<div id="wecom-mode-content"></div>
</div>`;
}
function switchWecomBotMode(mode) {
const scanTab = document.getElementById('wecom-tab-scan');
const manualTab = document.getElementById('wecom-tab-manual');
const content = document.getElementById('wecom-mode-content');
const actions = document.getElementById('add-channel-actions');
if (!scanTab || !manualTab || !content) return;
const activeClasses = 'bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm';
const inactiveClasses = 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200';
if (mode === 'scan') {
scanTab.className = scanTab.className.replace(/text-slate-500[^\s]*/g, '').replace(/hover:\S+/g, '');
scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
actions.classList.add('hidden');
content.innerHTML = `
<div class="flex flex-col items-center py-4">
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">${t('wecom_scan_desc')}</p>
<button onclick="startWecomBotAuth()"
class="mt-3 px-6 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150">
<i class="fas fa-qrcode mr-2"></i>${t('wecom_scan_btn')}
</button>
<div id="wecom-scan-status" class="mt-3"></div>
</div>`;
} else {
manualTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${activeClasses}`;
scanTab.className = `flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${inactiveClasses}`;
const ch = channelsData.find(c => c.name === 'wecom_bot');
content.innerHTML = `<div class="space-y-4">${buildChannelFieldsHtml('wecom_bot', ch ? ch.fields || [] : [])}</div>`;
bindSecretFieldEvents(content);
actions.classList.remove('hidden');
}
}
function startWecomBotAuth() {
const statusEl = document.getElementById('wecom-scan-status');
ensureWecomSdkLoaded().then(() => {
WecomAIBotSDK.openBotInfoAuthWindow({
source: WECOM_BOT_SOURCE,
onCreated: function(bot) {
if (statusEl) {
statusEl.innerHTML = `
<div class="flex flex-col items-center py-2">
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
<i class="fas fa-check text-emerald-500 text-lg"></i>
</div>
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('wecom_scan_success')}</p>
</div>`;
}
connectWecomBotAfterAuth(bot.botid, bot.secret);
},
onError: function(err) {
if (statusEl) {
statusEl.innerHTML = `<p class="text-sm text-red-500">${t('wecom_scan_fail')}: ${err.message || err.code || ''}</p>`;
}
}
});
}).catch(err => {
if (statusEl) {
statusEl.innerHTML = `<p class="text-sm text-red-500">SDK load failed: ${err.message}</p>`;
}
});
}
function connectWecomBotAfterAuth(botId, secret) {
fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'connect',
channel: 'wecom_bot',
config: { wecom_bot_id: botId, wecom_bot_secret: secret }
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
const ch = channelsData.find(c => c.name === 'wecom_bot');
if (ch) {
ch.active = true;
(ch.fields || []).forEach(f => {
if (f.key === 'wecom_bot_id') f.value = botId;
if (f.key === 'wecom_bot_secret') f.value = ChannelsHandler_maskSecret(secret);
});
}
setTimeout(() => renderActiveChannels(), 1500);
}
})
.catch(() => {});
}
function startWecomBotAuthInCard() {
const statusEl = document.getElementById('wecom-card-scan-status');
ensureWecomSdkLoaded().then(() => {
WecomAIBotSDK.openBotInfoAuthWindow({
source: WECOM_BOT_SOURCE,
onCreated: function(bot) {
if (statusEl) {
statusEl.innerHTML = `
<div class="flex flex-col items-center py-2">
<div class="w-10 h-10 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center mb-2">
<i class="fas fa-check text-emerald-500 text-lg"></i>
</div>
<p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">${t('wecom_scan_success')}</p>
</div>`;
}
connectWecomBotAfterAuth(bot.botid, bot.secret);
},
onError: function(err) {
if (statusEl) {
statusEl.innerHTML = `<p class="text-sm text-red-500">${t('wecom_scan_fail')}: ${err.message || err.code || ''}</p>`;
}
}
});
}).catch(err => {
if (statusEl) {
statusEl.innerHTML = `<p class="text-sm text-red-500">SDK load failed: ${err.message}</p>`;
}
});
}
// Initialize wecom bot panel with correct default mode when inserted into DOM
document.addEventListener('DOMContentLoaded', function() {
const observer = new MutationObserver(function() {
const panel = document.getElementById('wecom-bot-panel');
if (panel && !panel.dataset.initialized) {
panel.dataset.initialized = '1';
switchWecomBotMode(panel.dataset.defaultMode || 'scan');
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
// =====================================================================
// Scheduler View
// =====================================================================
@@ -2017,7 +2705,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 <head>

View File

@@ -96,9 +96,36 @@ class WebChannel(ChatChannel):
logger.error(f"No session_id found for request {request_id}")
return
# SSE mode: push done event to SSE queue
# SSE mode: push events to SSE queue
if request_id in self.sse_queues:
content = reply.content if reply.content is not None else ""
# Intermediate status lines (e.g. /install-browser phases) must NOT use "done",
# or the frontend closes EventSource and drops subsequent events.
if getattr(reply, "sse_phase", False):
self.sse_queues[request_id].put({
"type": "phase",
"content": content,
"request_id": request_id,
"timestamp": time.time(),
})
logger.debug(f"SSE phase for request {request_id}")
return
# Files are already pushed via on_event (file_to_send) during agent execution.
# Skip duplicate file pushes here; just let the done event through.
if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"):
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 +188,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):
@@ -353,13 +393,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)")
@@ -375,11 +417,13 @@ class WebChannel(ChatChannel):
'/message', 'MessageHandler',
'/upload', 'UploadHandler',
'/uploads/(.*)', 'UploadsHandler',
'/api/file', 'FileServeHandler',
'/poll', 'PollHandler',
'/stream', 'StreamHandler',
'/chat', 'ChatHandler',
'/config', 'ConfigHandler',
'/api/channels', 'ChannelsHandler',
'/api/weixin/qrlogin', 'WeixinQrHandler',
'/api/tools', 'ToolsHandler',
'/api/skills', 'SkillsHandler',
'/api/memory', 'MemoryHandler',
@@ -387,6 +431,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)
@@ -459,6 +504,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()
@@ -490,14 +561,14 @@ 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,
const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_54, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
]
@@ -507,14 +578,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": "通义千问",
@@ -551,19 +622,26 @@ class ConfigHandler:
"api_base_default": "https://generativelanguage.googleapis.com",
"models": [const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
}),
("chatGPT", {
("openai", {
"label": "OpenAI",
"api_key_field": "open_ai_api_key",
"api_base_key": "open_ai_api_base",
"api_base_default": "https://api.openai.com/v1",
"models": [const.GPT_54, const.GPT_5, const.GPT_41, const.GPT_4o],
"models": [const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
}),
("deepseek", {
"label": "DeepSeek",
"api_key_field": "open_ai_api_key",
"api_key_field": "deepseek_api_key",
"api_base_key": "deepseek_api_base",
"api_base_default": "https://api.deepseek.com/v1",
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
}),
("modelscope", {
"label": "ModelScope",
"api_key_field": "modelscope_api_key",
"api_base_key": None,
"api_base_default": None,
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
"models": [const.QWEN3_5_27B, const.QWEN3_235B_A22B_INSTRUCT_2507],
}),
("linkai", {
"label": "LinkAI",
@@ -576,9 +654,9 @@ class ConfigHandler:
EDITABLE_KEYS = {
"model", "bot_type", "use_linkai",
"open_ai_api_base", "claude_api_base", "gemini_api_base",
"open_ai_api_base", "deepseek_api_base", "claude_api_base", "gemini_api_base",
"zhipu_ai_api_base", "moonshot_base_url", "ark_base_url",
"open_ai_api_key", "claude_api_key", "gemini_api_key",
"open_ai_api_key", "deepseek_api_key", "claude_api_key", "gemini_api_key",
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
"ark_api_key", "minimax_api_key", "linkai_api_key",
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
@@ -625,8 +703,7 @@ class ConfigHandler:
"use_agent": use_agent,
"title": title,
"model": local_config.get("model", ""),
"bot_type": local_config.get("bot_type", ""),
"has_bot_type": "bot_type" in local_config,
"bot_type": "openai" if local_config.get("bot_type") == "chatGPT" else local_config.get("bot_type", ""),
"use_linkai": bool(local_config.get("use_linkai", False)),
"channel_type": local_config.get("channel_type", ""),
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000),
@@ -671,9 +748,6 @@ class ConfigHandler:
file_cfg = json.load(f)
else:
file_cfg = {}
if "bot_type" in applied and "bot_type" not in file_cfg:
del applied["bot_type"]
local_config.pop("bot_type", None)
file_cfg.update(applied)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
@@ -689,6 +763,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",
@@ -754,6 +834,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:
@@ -793,14 +887,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}")
@@ -1020,6 +1117,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
@@ -1117,6 +1365,8 @@ class MemoryContentHandler:
service = MemoryService(workspace_root)
result = service.get_content(params.filename)
return json.dumps({"status": "success", **result}, ensure_ascii=False)
except ValueError:
return json.dumps({"status": "error", "message": "invalid filename"})
except FileNotFoundError:
return json.dumps({"status": "error", "message": "file not found"})
except Exception as e:
@@ -1256,3 +1506,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__})

View File

@@ -26,6 +26,7 @@ from channel.wecom_bot.wecom_bot_message import WecomBotMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from common.ws_client_compat import websocket_app_run_forever
from config import conf
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
@@ -119,7 +120,7 @@ class WecomBotChannel(ChatChannel):
def run_forever():
try:
self._ws.run_forever(ping_interval=0, reconnect=0)
websocket_app_run_forever(self._ws, ping_interval=0, reconnect=0)
except (SystemExit, KeyboardInterrupt):
logger.info("[WecomBot] WebSocket thread interrupted")
except Exception as e:
@@ -329,28 +330,42 @@ class WecomBotChannel(ChatChannel):
All intermediate segments (thinking before tool calls) and the final answer
are accumulated into a single stream message, separated by '---'.
Throttles push to at most once per 100ms to avoid WebSocket congestion.
"""
stream_id = uuid.uuid4().hex[:16]
self._stream_states[req_id] = {
"stream_id": stream_id,
"committed": "", # finalized content from previous segments
"current": "", # current segment being streamed
"committed": "",
"current": "",
"last_push_time": 0,
"last_push_len": 0,
}
def _push_stream(state: dict):
"""Push current stream content to wecom."""
self._ws_send({
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {
"msgtype": "stream",
"stream": {
"id": state["stream_id"],
"finish": False,
"content": state["committed"] + state["current"],
def _push_stream(state: dict, force: bool = False):
"""Push current stream content to wecom (throttled unless forced)."""
now = time.time()
if not force and now - state["last_push_time"] < 0.1:
return
content = state["committed"] + state["current"]
if len(content) == state["last_push_len"]:
return
state["last_push_time"] = now
state["last_push_len"] = len(content)
try:
self._ws_send({
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},
"body": {
"msgtype": "stream",
"stream": {
"id": state["stream_id"],
"finish": False,
"content": content,
},
},
},
})
})
except Exception as e:
logger.warning(f"[WecomBot] Stream push failed: {e}")
def on_event(event: dict):
event_type = event.get("type")
@@ -377,6 +392,7 @@ class WecomBotChannel(ChatChannel):
else:
state["committed"] += state["current"]
state["current"] = ""
_push_stream(state, force=True)
return on_event
@@ -451,11 +467,16 @@ class WecomBotChannel(ChatChannel):
if req_id:
state = self._stream_states.pop(req_id, None)
if state:
final_content = state["committed"]
final_content = state["committed"] if state["committed"] else content
stream_id = state["stream_id"]
else:
final_content = content
stream_id = uuid.uuid4().hex[:16]
# Brief pause so the server finishes processing the last intermediate chunk
# before receiving the finish packet
time.sleep(0.15)
self._ws_send({
"cmd": "aibot_respond_msg",
"headers": {"req_id": req_id},

View File

View File

@@ -0,0 +1,400 @@
"""
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) -> dict:
return self._post("ilink/bot/getuploadurl", {
"filekey": filekey,
"media_type": media_type,
"to_user_id": to_user_id,
"rawsize": rawsize,
"rawfilemd5": rawfilemd5,
"filesize": filesize,
"aeskey": aeskey,
"no_need_thumb": True,
})
# ── 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 _aes_ecb_padded_size(plaintext_size: int) -> int:
"""PKCS7 padded size for AES-128-ECB."""
return ((plaintext_size + 1 + 15) // 16) * 16
UPLOAD_MAX_RETRIES = 3
def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
media_type: int) -> dict:
"""
Upload a local file to the Weixin CDN (matching official plugin protocol).
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()
filekey = uuid.uuid4().hex
with open(file_path, "rb") as f:
raw_data = f.read()
raw_size = len(raw_data)
raw_md5 = _md5_bytes(raw_data)
cipher_size = _aes_ecb_padded_size(raw_size)
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
from urllib.parse import quote
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,
)
# API may return either upload_full_url (new) or upload_param (legacy)
upload_full_url = resp.get("upload_full_url", "")
upload_param = resp.get("upload_param", "")
if upload_full_url:
cdn_url = upload_full_url
elif upload_param:
cdn_url = (f"{api.cdn_base_url}/upload"
f"?encrypted_query_param={quote(upload_param)}"
f"&filekey={quote(filekey)}")
else:
raise RuntimeError(f"[Weixin] getUploadUrl returned neither upload_full_url nor upload_param: {resp}")
cdn_resp = requests.post(cdn_url, data=encrypted, headers={
"Content-Type": "application/octet-stream",
"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])
raise RuntimeError(f"CDN client error {cdn_resp.status_code}: {err_msg}")
cdn_resp.raise_for_status()
download_param = cdn_resp.headers.get("x-encrypted-param", "")
if not download_param:
raise RuntimeError("CDN response missing x-encrypted-param header")
logger.debug(f"[Weixin] CDN upload success attempt={attempt} filekey={filekey}")
break
except Exception as e:
last_error = e
if "client error" in str(e):
raise
if attempt < UPLOAD_MAX_RETRIES:
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}")
if not download_param:
raise last_error or RuntimeError("CDN upload failed")
aes_key_b64 = base64.b64encode(aes_key_hex.encode("utf-8")).decode("utf-8")
return {
"encrypt_query_param": download_param,
"aes_key_b64": aes_key_b64,
"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
"""
from urllib.parse import quote
url = f"{cdn_base_url}/download?encrypted_query_param={quote(encrypt_query_param)}"
resp = requests.get(url, timeout=60)
resp.raise_for_status()
# Determine key format:
# 1) 32-char hex string → 16 raw bytes
# 2) base64 string → decode → if 32 bytes, treat as hex-encoded → 16 raw bytes
# 3) base64 string → decode → 16 raw bytes directly
try:
key_bytes = bytes.fromhex(aes_key)
if len(key_bytes) != 16:
raise ValueError()
except (ValueError, TypeError):
decoded = base64.b64decode(aes_key)
if len(decoded) == 32:
try:
key_bytes = bytes.fromhex(decoded.decode("ascii"))
except (ValueError, UnicodeDecodeError):
raise ValueError(f"Invalid AES key: 32 bytes but not valid hex")
elif len(decoded) == 16:
key_bytes = decoded
else:
raise ValueError(f"Invalid AES key length after base64 decode: {len(decoded)}")
decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f:
f.write(decrypted)
return save_path

View File

@@ -0,0 +1,629 @@
"""
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
TEXT_CHUNK_LIMIT = 4000
QR_LOGIN_TIMEOUT_S = 480
QR_MAX_REFRESHES = 10
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):
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", "")
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:
token, base_url = self._login_with_retry(base_url)
if not token:
return
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._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()
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 _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)
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] 微信二维码链接: {qrcode_url}")
self._print_qr(qrcode_url)
self._notify_cloud_qrcode(qrcode_url)
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:
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":
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] 微信二维码链接 ({refresh_count}/{QR_MAX_REFRESHES}): {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 {}
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}")
self._notify_cloud_connected()
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}
self._stop_event.wait(1)
self._current_qr_url = ""
if self._stop_event.is_set():
logger.info("[Weixin] QR login cancelled by stop event")
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):
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)
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 ""

View File

@@ -0,0 +1,204 @@
"""
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 ""
if media_type == ITEM_FILE:
original_name = info.get("file_name", "")
if original_name:
save_path = os.path.join(_get_tmp_dir(), original_name)
else:
save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}.bin")
else:
ext_map = {ITEM_IMAGE: ".jpg", ITEM_VIDEO: ".mp4", ITEM_VOICE: ".silk"}
ext = ext_map.get(media_type, "")
save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}{ext}")
try:
download_media_from_cdn(cdn_base_url, encrypt_param, aes_key, save_path)
logger.info(f"[Weixin] Media downloaded: {save_path}")
return save_path
except Exception as e:
logger.error(f"[Weixin] Media download failed: {e}")
return ""

1
cli/VERSION Normal file
View File

@@ -0,0 +1 @@
2.0.5

13
cli/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""CowAgent CLI - Manage your CowAgent from the command line."""
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()

4
cli/__main__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Allow running as: python -m cli"""
from cli.cli import main
main()

76
cli/cli.py Normal file
View File

@@ -0,0 +1,76 @@
"""CowAgent CLI entry point."""
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.context import context
from cli.commands.install import install_browser
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.
update Update CowAgent and restart.
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."""
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."""
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)
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)
main.add_command(install_browser)
if __name__ == '__main__':
main()

0
cli/commands/__init__.py Normal file
View File

29
cli/commands/context.py Normal file
View File

@@ -0,0 +1,29 @@
"""cow context - Context management commands."""
import click
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)
@click.pass_context
def context(ctx):
"""View or manage conversation context.
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:
click.echo(f"\n {CHAT_HINT}\n")
@context.command()
def clear():
"""Clear conversation context (messages history)."""
click.echo(f"\n {CHAT_HINT}\n")

259
cli/commands/install.py Normal file
View File

@@ -0,0 +1,259 @@
"""cow install-browser - Install Playwright + Chromium for the browser tool."""
import os
import sys
import subprocess
from typing import Callable, Optional
import click
PLAYWRIGHT_VERSION = "1.52.0"
PLAYWRIGHT_LEGACY_VERSION = "1.28.0"
GLIBC_THRESHOLD = (2, 28)
CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
# stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None
StreamFn = Callable[[str, Optional[str]], None]
# on_phase(msg) — coarse-grained progress for chat channels (Chinese)
PhaseFn = Callable[[str], None]
def _phase(cb: Optional[PhaseFn], msg: str) -> None:
if cb:
cb(msg)
def _has_display() -> bool:
"""Check if a graphical display is available (Linux only)."""
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
def _is_headless_linux() -> bool:
return sys.platform == "linux" and not _has_display()
def _get_installed_version() -> str:
try:
out = subprocess.check_output(
[sys.executable, "-c", "import playwright; print(playwright.__version__)"],
stderr=subprocess.DEVNULL,
)
return out.decode().strip()
except Exception:
return ""
def _version_tuple(v: str):
try:
return tuple(int(x) for x in v.split(".")[:3])
except (ValueError, AttributeError):
return (0, 0, 0)
def _get_glibc_version():
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
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, stream: StreamFn) -> int:
"""Install a package, retrying with --user on permission failure."""
python = sys.executable
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
if ret != 0:
stream(" Retrying with --user flag...", "yellow")
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
return ret
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
"""CLI: colored click output."""
if fg == "yellow":
click.echo(click.style(msg, fg="yellow"))
elif fg == "green":
click.echo(click.style(msg, fg="green"))
elif fg == "red":
click.echo(click.style(msg, fg="red"))
else:
click.echo(msg)
def run_install_browser(
stream: Optional[StreamFn] = None,
on_phase: Optional[PhaseFn] = None,
) -> int:
"""
Install Playwright Python package, optional Linux deps, and Chromium.
Reused by ``cow install-browser`` CLI and chat ``/install-browser``.
Args:
stream: Optional callback ``(message, fg)`` for each line. ``fg`` is
``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output.
on_phase: Optional callback for coarse progress (e.g. push to chat);
messages are short Chinese status lines.
Returns:
0 on success, 1 on fatal failure (pip or chromium install failed).
"""
stream = stream or _default_stream
python = sys.executable
legacy_mode = False
_phase(on_phase, "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…")
glibc = _get_glibc_version()
if glibc and glibc < GLIBC_THRESHOLD:
legacy_mode = True
glibc_str = f"{glibc[0]}.{glibc[1]}"
stream(
f"glibc {glibc_str} detected (< 2.28). "
f"Will install playwright {PLAYWRIGHT_LEGACY_VERSION} for compatibility.",
"yellow",
)
stream(" Note: upgrade your OS for full browser tool support.", "yellow")
stream("")
_phase(
on_phase,
f" 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}",
)
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
_phase(on_phase, "📦 [1/3] 正在安装 Playwright Python 包…")
stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream)
if ret != 0:
stream("Failed to install playwright package.", "red")
_phase(on_phase, "❌ [1/3] Playwright Python 包安装失败。")
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})。")
if sys.platform == "linux":
_phase(on_phase, "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo")
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
if ret != 0:
stream(
" Could not auto-install system deps (may need sudo).\n"
f" Run manually: sudo {python} -m playwright install-deps chromium",
"yellow",
)
# Prefer fonts-wqy-zenhei only (~few MB). fonts-noto-cjk is much larger (~150MB+).
stream(" Installing CJK font (fonts-wqy-zenhei, lightweight)...")
font_ret = subprocess.call(
["sudo", "apt-get", "install", "-y", "--no-install-recommends", "fonts-wqy-zenhei"],
stderr=subprocess.DEVNULL,
)
if font_ret != 0:
stream(
" Could not auto-install CJK font.\n"
" Run manually: sudo apt-get install -y fonts-wqy-zenhei\n"
" (Optional, larger full coverage: sudo apt-get install -y fonts-noto-cjk)",
"yellow",
)
else:
subprocess.call(["fc-cache", "-fv"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
stream(" CJK font (wqy-zenhei) installed.", "green")
_phase(
on_phase,
"✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。",
)
else:
stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow")
_phase(on_phase, f" [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。")
stream("")
_phase(on_phase, "🌐 [3/3] 正在下载并安装 Chromium体积较大请耐心等待")
stream("[3/3] Installing Chromium browser...", "yellow")
cmd = [python, "-m", "playwright", "install", "chromium"]
if _is_headless_linux() and not legacy_mode:
ver = _version_tuple(installed or "")
if ver >= (1, 57, 0):
cmd.append("--only-shell")
stream(" (headless shell for Linux server)", None)
else:
stream(" (full Chromium)", None)
elif sys.platform == "linux" and _has_display():
stream(" (full browser for Linux desktop)", None)
env = os.environ.copy()
use_mirror = _is_china_network()
if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
stream(f" (using China mirror: {CHINA_MIRROR})", None)
_phase(on_phase, "📡 检测到国内 pip 源配置Chromium 将优先走国内镜像下载。")
ret = subprocess.call(cmd, env=env)
if ret != 0 and use_mirror:
stream(" Mirror download failed, retrying with official CDN...", "yellow")
_phase(on_phase, "⚠️ 镜像下载失败,正在改用官方源重试…")
env_no_mirror = os.environ.copy()
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
ret = subprocess.call(cmd, env=env_no_mirror)
if ret != 0:
stream("Failed to install Chromium.", "red")
_phase(on_phase, "❌ [3/3] Chromium 安装失败。")
return 1
stream("")
_phase(on_phase, "✅ [3/3] Chromium 已安装。")
stream("Verifying browser installation...", None)
_phase(on_phase, "🔍 正在验证 Playwright 能否正常加载…")
ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL,
)
if ret != 0:
stream(
" Warning: playwright import failed. Browser tool may not work on this system.\n"
" Consider upgrading your OS or using Docker.",
"yellow",
)
_phase(on_phase, "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。")
else:
stream(" Verification passed.", "green")
_phase(on_phase, "✅ 验证通过。")
stream("")
stream("Browser tool ready! Restart CowAgent to enable it.", "green")
_phase(on_phase, "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。")
return 0
@click.command("install-browser")
def install_browser():
"""Install browser tool dependencies (Playwright + Chromium)."""
code = run_install_browser()
if code != 0:
raise SystemExit(code)

281
cli/commands/process.py Normal file
View File

@@ -0,0 +1,281 @@
"""cow start/stop/restart/status/logs - Process management commands."""
import os
import sys
import subprocess
import time
from typing import Optional
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")
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):
return None
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
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):
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)")
@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:
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...")
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],
stdout=log,
stderr=log,
**popen_kwargs,
)
_write_pid(proc.pid)
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():
"""Stop CowAgent."""
pid = _read_pid()
if not pid:
click.echo("CowAgent is not running.")
return
click.echo(f"Stopping CowAgent (PID: {pid})...")
try:
_kill_pid(pid)
for _ in range(30):
time.sleep(0.1)
if not _is_pid_alive(pid):
break
else:
_kill_pid(pid, force=True)
except (ProcessLookupError, OSError):
pass
_remove_pid()
click.echo(click.style("✓ CowAgent stopped.", fg="green"))
@click.command()
@click.option("--no-logs", is_flag=True, help="Don't tail logs after restarting")
@click.pass_context
def restart(ctx, no_logs):
"""Restart CowAgent."""
ctx.invoke(stop)
time.sleep(1)
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 and follow logs
click.echo("")
time.sleep(1)
ctx.invoke(start, no_logs=False)
@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")
@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:
_tail_log(log_file, lines)
else:
_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 (cross-platform)."""
_print_last_lines(log_file, lines)
try:
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

1463
cli/commands/skill.py Normal file

File diff suppressed because it is too large Load Diff

62
cli/utils.py Normal file
View File

@@ -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://skills.cowagent.ai/api"

View File

@@ -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
@@ -201,27 +213,43 @@ class CloudClient(LinkAIClient):
def _handle_channel_create(self, channel_type: str, data: dict):
local_config = conf()
self._set_channel_credentials(local_config, channel_type,
data.get("appId"), data.get("appSecret"))
cred_changed = self._set_channel_credentials(
local_config, channel_type, data.get("appId"), data.get("appSecret"))
self._add_channel_type(local_config, channel_type)
self._save_config_to_file(local_config)
if self.channel_mgr:
if not self.channel_mgr:
return
existing_ch = self.channel_mgr.get_channel(channel_type)
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(
target=self._do_add_channel, args=(channel_type,), daemon=True
target=self._report_channel_startup, args=(channel_type,), daemon=True
).start()
return
threading.Thread(
target=self._do_add_channel, args=(channel_type,), daemon=True
).start()
def _handle_channel_update(self, channel_type: str, data: dict):
local_config = conf()
enabled = data.get("enabled", "Y")
self._set_channel_credentials(local_config, channel_type,
data.get("appId"), data.get("appSecret"))
cred_changed = self._set_channel_credentials(
local_config, channel_type, data.get("appId"), data.get("appSecret"))
if enabled == "N":
self._remove_channel_type(local_config, channel_type)
else:
# Ensure channel_type is persisted even if this channel was not
# previously listed (e.g. update used as implicit create).
self._add_channel_type(local_config, channel_type)
self._save_config_to_file(local_config)
@@ -233,9 +261,24 @@ class CloudClient(LinkAIClient):
target=self._do_remove_channel, args=(channel_type,), daemon=True
).start()
else:
threading.Thread(
target=self._do_restart_channel, args=(self.channel_mgr, channel_type), daemon=True
).start()
existing_ch = self.channel_mgr.get_channel(channel_type)
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(
target=self._report_channel_startup, args=(channel_type,), daemon=True
).start()
else:
threading.Thread(
target=self._do_restart_channel, args=(self.channel_mgr, channel_type), daemon=True
).start()
def _handle_channel_delete(self, channel_type: str, data: dict):
local_config = conf()
@@ -243,11 +286,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
# ------------------------------------------------------------------
@@ -322,7 +381,7 @@ class CloudClient(LinkAIClient):
self.channel_mgr.add_channel(channel_type)
logger.info(f"[CloudClient] Channel '{channel_type}' added successfully")
except Exception as e:
logger.error(f"[CloudClient] Failed to add channel '{channel_type}': {e}")
logger.error(f"[CloudClient] Failed to add channel '{channel_type}': {e}", exc_info=True)
self.send_channel_status(channel_type, "error", str(e))
return
self._report_channel_startup(channel_type)
@@ -334,12 +393,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")
@@ -409,6 +487,19 @@ class CloudClient(LinkAIClient):
session_id = f"session_{session_id}"
logger.info(f"[CloudClient] on_chat: session={session_id}, channel={channel_type}, query={query[:80]}")
# Intercept cow/slash commands before the agent runs
try:
from plugins import PluginManager
mgr = PluginManager()
instance = mgr.instances.get("COW_CLI")
if instance and hasattr(instance, "execute"):
result = instance.execute(query, session_id=session_id)
if result is not None:
send_chunk_fn({"chunk_type": "content", "delta": result, "segment_id": 0})
return
except Exception as e:
logger.warning(f"[CloudClient] cow_cli intercept failed: {e}")
svc = self.chat_service
if svc is None:
raise RuntimeError("ChatService not available")
@@ -551,9 +642,9 @@ def get_deployment_id() -> str:
def get_website_base_url() -> str:
"""Return the public URL prefix that maps to the workspace websites/ dir.
"""Return the URL prefix that maps to the workspace websites/ dir.
Returns empty string when cloud deployment is not configured.
Do nothing when in local env.
"""
deployment_id = get_deployment_id()
if not deployment_id:
@@ -570,6 +661,42 @@ def get_website_base_url() -> str:
return f"https://app.{domain}/{deployment_id}"
# Subdir under websites/ used by the send tool
COW_SEND_WEB_SUBDIR = "cow-send"
def copy_send_file(src_path: str, workspace_root: str) -> str:
"""Copy *src_path* into ``websites/cow-send/`` and return its URL.
Returns empty string in local env.
"""
import shutil
import uuid
from common.utils import expand_path
base = get_website_base_url()
if not base or not src_path or not os.path.isfile(src_path):
return ""
ws = os.path.abspath(expand_path(workspace_root))
send_dir = os.path.join(ws, "websites", COW_SEND_WEB_SUBDIR)
try:
os.makedirs(send_dir, exist_ok=True)
except OSError:
return ""
ext = os.path.splitext(src_path)[1].lower()
if len(ext) > 12 or not ext.replace(".", "").isalnum():
ext = ""
dest_name = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(send_dir, dest_name)
try:
shutil.copy2(src_path, dest_path)
except OSError as e:
logger.warning(f"[cloud] copy_send_file: copy failed: {e}")
return ""
return f"{base}/{COW_SEND_WEB_SUBDIR}/{dest_name}"
def build_website_prompt(workspace_dir: str) -> list:
"""Build system prompt lines for cloud website/file sharing rules.
@@ -590,8 +717,8 @@ def build_website_prompt(workspace_dir: str) -> list:
f" - 例如: `websites/my-app/index.html` → `{base_url}/my-app/index.html`",
"",
"2. **生成文件分享** (PPT、PDF、图片、音视频等): 当你为用户生成了需要下载或查看的文件时,**可以**将文件保存到 `websites/` 目录中",
f" - 例如: 生成的PPT保存到 `websites/files/report.pptx` → 下载链接为 `{base_url}/files/report.pptx`",
" - 你仍然可以同时使用 `send` 工具发送文件(在飞书、钉钉等IM渠道中有效),但**必须同时在回复文本中提供下载链接**作为兜底,因为部分渠道(如网页端)无法通过 send 接收本地文件",
f" - 例如: 生成的PPT保存到 `websites/files/report.pptx` → 下载链接为 `{base_url}/files/report.pptx`",
" - 你仍然可以同时使用 `send` 工具发送文件(在微信、飞书、钉钉、web等渠道中有效),但**必须同时在回复文本中提供下载链接**作为兜底,因为部分渠道无法通过 send 接收本地文件",
"",
"3. **必须发送链接**: 无论是网页还是文件,生成后**必须将完整的访问/下载链接直接写在回复文本中发送给用户**",
"",
@@ -602,6 +729,9 @@ def build_website_prompt(workspace_dir: str) -> list:
]
def start(channel, channel_mgr=None):
if not get_deployment_id():
return
global chat_client
chat_client = CloudClient(api_key=conf().get("linkai_api_key"), host=conf().get("cloud_host", ""), channel=channel)
chat_client.channel_mgr = channel_mgr

View File

@@ -1,6 +1,7 @@
# 厂商类型
OPEN_AI = "openAI"
CHATGPT = "chatGPT"
OPENAI = "openai"
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
BAIDU = "baidu"
XUNFEI = "xunfei"
CHATGPTONAZURE = "chatGPTOnAzure"
@@ -68,6 +69,8 @@ GPT_5 = "gpt-5"
GPT_5_MINI = "gpt-5-mini"
GPT_5_NANO = "gpt-5-nano"
GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
GPT_54_MINI = "gpt-5.4-mini"
GPT_54_NANO = "gpt-5.4-nano"
O1 = "o1-preview"
O1_MINI = "o1-mini"
WHISPER_1 = "whisper-1"
@@ -89,14 +92,16 @@ QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversatio
QWQ_PLUS = "qwq-plus"
# MiniMax
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5 - Latest
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1 - Agent推荐模型
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
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_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
# GLM (智谱AI)
GLM_5 = "glm-5" # 智谱 GLM-5 - Latest
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo - Latest
GLM_5 = "glm-5" # 智谱 GLM-5
GLM_4 = "glm-4"
GLM_4_PLUS = "glm-4-plus"
GLM_4_flash = "glm-4-flash"
@@ -119,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"
@@ -130,11 +139,14 @@ 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"]
MODEL_LIST = [
# Claude
@@ -153,7 +165,7 @@ MODEL_LIST = [
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
GPT_41, GPT_41_MINI, GPT_41_NANO,
GPT_5, GPT_5_MINI, GPT_5_NANO,
GPT_54,
GPT_54, GPT_54_MINI, GPT_54_NANO,
O1, O1_MINI,
# DeepSeek
@@ -163,10 +175,10 @@ MODEL_LIST = [
QWEN, QWEN_TURBO, QWEN_PLUS, QWEN_MAX, QWEN_LONG, QWEN3_MAX, QWEN35_PLUS,
# MiniMax
MiniMax, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
MiniMax, MINIMAX_M2_7, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
# GLM
ZHIPU_AI, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
ZHIPU_AI, 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,
# Kimi
@@ -188,3 +200,4 @@ FEISHU = "feishu"
DINGTALK = "dingtalk"
WECOM_BOT = "wecom_bot"
QQ = "qq"
WEIXIN = "weixin"

View File

@@ -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",

View File

@@ -115,3 +115,22 @@ def expand_path(path: str) -> str:
expanded = os.path.join(home, path[2:])
return expanded
def get_cloud_headers(api_key: str) -> dict:
"""
Build standard headers for LinkAI API requests,
including client_id when available.
"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
try:
from linkai import LinkAIClient
client_id = LinkAIClient.fetch_client_id()
if client_id:
headers["X-Client-Id"] = client_id
except Exception:
pass
return headers

View File

@@ -0,0 +1,17 @@
import inspect
from typing import Any
def websocket_app_run_forever(ws: Any, **kwargs: Any) -> None:
"""
Call WebSocketApp.run_forever; strip reconnect= if the installed
websocket-client is too old (reconnect was added in a later 1.x release).
"""
if "reconnect" in kwargs:
try:
params = inspect.signature(ws.run_forever).parameters
except (TypeError, ValueError):
params = {}
if "reconnect" not in params:
kwargs = {k: v for k, v in kwargs.items() if k != "reconnect"}
ws.run_forever(**kwargs)

View File

@@ -1,6 +1,6 @@
{
"channel_type": "web",
"model": "MiniMax-M2.5",
"channel_type": "weixin",
"model": "MiniMax-M2.7",
"minimax_api_key": "",
"zhipu_ai_api_key": "",
"ark_api_key": "",

View File

@@ -20,7 +20,7 @@ available_setting = {
"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格式的三方服务时候需填"chatGPT"。bot具体名称详见common/const.py文件列出的bot_type如不填根据model名称判断
"bot_type": "", # 可选配置使用兼容openai格式的三方服务时候需填"openai"(历史值"chatGPT"仍兼容)。bot具体名称详见common/const.py文件如不填根据model名称判断
"use_azure_chatgpt": False, # 是否使用azure的chatgpt
"azure_deployment_id": "", # azure 模型部署名称
"azure_api_version": "", # azure api版本
@@ -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():
@@ -402,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()

View File

@@ -4,32 +4,54 @@ LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
# Set to "false" to skip Playwright/Chromium and produce a smaller image
ARG INSTALL_BROWSER=true
# Set to "true" to use China mirrors for apt / pip / playwright (faster in CN)
ARG USE_CN_MIRROR=false
RUN echo /etc/apt/sources.list
# RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
ENV PLAYWRIGHT_BROWSERS_PATH=/app/ms-playwright
ENV BUILD_PREFIX=/app
# Optionally switch apt and pip to China mirrors
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list; \
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple/; \
fi
ADD . ${BUILD_PREFIX}
# All heavy installs + user creation in ONE layer to avoid chown duplication
RUN apt-get update \
&&apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra\
&& apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra \
&& cd ${BUILD_PREFIX} \
&& cp config-template.json config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache -r requirements.txt \
&& pip install --no-cache -r requirements-optional.txt \
&& pip install azure-cognitiveservices-speech
&& pip install --no-cache -e . \
&& if [ "$INSTALL_BROWSER" = "true" ]; then \
apt-get install -y --no-install-recommends fonts-wqy-zenhei \
&& pip install --no-cache "playwright==1.52.0" \
&& python -m playwright install-deps chromium \
&& mkdir -p /app/ms-playwright \
&& if [ "$USE_CN_MIRROR" = "true" ]; then \
PLAYWRIGHT_DOWNLOAD_HOST=https://registry.npmmirror.com/-/binary/playwright \
python -m playwright install chromium; \
else \
python -m playwright install chromium; \
fi; \
fi \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /home/agent/cow \
&& groupadd -r agent \
&& useradd -r -g agent -s /bin/bash -d /home/agent agent \
&& chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
WORKDIR ${BUILD_PREFIX}
ADD docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /home/noroot \
&& groupadd -r noroot \
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
&& chown -R noroot:noroot /home/noroot ${BUILD_PREFIX} /usr/local/lib
USER noroot
&& chown agent:agent /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -5,22 +5,39 @@ services:
container_name: chatgpt-on-wechat
security_opt:
- seccomp:unconfined
ports:
- "9899:9899"
environment:
CHANNEL_TYPE: 'web'
OPEN_AI_API_KEY: 'YOUR API KEY'
MODEL: ''
PROXY: ''
SINGLE_CHAT_PREFIX: '["bot", "@bot"]'
SINGLE_CHAT_REPLY_PREFIX: '"[bot] "'
GROUP_CHAT_PREFIX: '["@bot"]'
GROUP_NAME_WHITE_LIST: '["ChatGPT测试群", "ChatGPT测试群2"]'
IMAGE_CREATE_PREFIX: '["画", "看", "找"]'
CONVERSATION_MAX_TOKENS: 1000
SPEECH_RECOGNITION: 'False'
CHARACTER_DESC: '你是基于大语言模型的AI智能助手旨在回答并解决人们的任何问题并且可以使用多种语言与人交流。'
EXPIRES_IN_SECONDS: 3600
USE_GLOBAL_PLUGIN_CONFIG: 'True'
CHANNEL_TYPE: 'weixin'
MODEL: 'MiniMax-M2.7'
MINIMAX_API_KEY: ''
ZHIPU_AI_API_KEY: ''
ARK_API_KEY: ''
MOONSHOT_API_KEY: ''
DASHSCOPE_API_KEY: ''
CLAUDE_API_KEY: ''
CLAUDE_API_BASE: 'https://api.anthropic.com/v1'
OPEN_AI_API_KEY: ''
OPEN_AI_API_BASE: 'https://api.openai.com/v1'
GEMINI_API_KEY: ''
GEMINI_API_BASE: 'https://generativelanguage.googleapis.com'
VOICE_TO_TEXT: 'openai'
TEXT_TO_VOICE: 'openai'
VOICE_REPLY_VOICE: 'False'
SPEECH_RECOGNITION: 'True'
GROUP_SPEECH_RECOGNITION: 'False'
USE_LINKAI: 'False'
AGENT: 'True'
LINKAI_API_KEY: ''
LINKAI_APP_CODE: ''
FEISHU_APP_ID: ''
FEISHU_APP_SECRET: ''
DINGTALK_CLIENT_ID: ''
DINGTALK_CLIENT_SECRET: ''
WECOM_BOT_ID: ''
WECOM_BOT_SECRET: ''
AGENT: 'True'
AGENT_MAX_CONTEXT_TOKENS: 40000
AGENT_MAX_CONTEXT_TURNS: 20
AGENT_MAX_STEPS: 15
volumes:
- ./cow:/home/agent/cow

View File

@@ -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

View File

@@ -1,185 +0,0 @@
# CowAgent介绍
## 概述
Cow项目从简单的聊天机器人全面升级为超级智能助理 **CowAgent**能够主动规思考和规划任务、拥有长期记忆、操作计算机和外部资源、创造和执行Skill真正理解你并和你一起成长。CowAgent能够长期运行在个人电脑或服务器中通过飞书、钉钉、企业微信、网页等多种方式进行交互。核心能力如下
- **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持多轮推理和上下文理解
- **工具系统**内置实现10+种工具包括文件读写、bash终端、浏览器、定时任务、记忆管理等通过Agent管理你的计算机或服务器
- **长期记忆**:自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
- **Skills系统**新增Skill运行引擎内置多种技能并支持通过自然语言对话完成自定义Skills开发
- **多渠道和多模型支持**支持在Web、飞书、钉钉、企微等多渠道与Agent交互支持Claude、Gemini、OpenAI、GLM、MiniMax、Qwen、Kimi、Doubao 等多种国内外主流模型
- **安全和成本**通过秘钥管理工具、提示词控制、系统权限等手段控制Agent的访问安全通过最大记忆轮次、最大上下文token、工具执行步数对token成本进行限制
## 核心功能
### 1. 长期记忆
> 记忆系统让 Agent 能够长期记住重要信息。Agent 会在用户分享偏好、决策、事实等重要信息时主动存储,也会在对话达到一定长度时自动提取摘要。记忆分为核心记忆、天级记忆,支持语义搜索和向量检索的混合检索模式。
第一次启动Agent会主动向用户获取询问关键信息并记录至工作空间 (默认为 ~/cow) 中的智能体设定、用户身份、记忆文件中。
在后续的长期对话中Agent会在需要的时候智能记录或检索记忆并对自身设定、用户偏好、记忆文件等进行不断更新总结和记录经验和教训真正实现自主思考和不断成长。
<img width="800" src="https://cdn.link-ai.tech/doc/20260203000455.png" />
### 2. 任务规划和工具调用
工具是Agent访问操作系统资源的核心Agent会根据任务需求智能选择和调用工具完成文件读写、命令执行、定时任务等各类操作。内置工具的视线在项目的 `tools` 目录下。
**主要工具:** 文件读写编辑、Bash终端、浏览器、文件发送、定时调度、记忆搜索、环境配置等。
#### 1.1 终端和文件访问能力
针对操作系统的终端和文件的访问能力是最基础和核心的工具其他很多工具或技能都是基于基础工具进行扩展。用户可通过手机端与Agent交互操作个人电脑或服务器上的资源
<img width="800" src="https://cdn.link-ai.tech/doc/20260202181130.png" />
#### 1.2 编程能力
基于编程能力和系统访问能力Agent可以实现从信息搜索、图片等素材生成、编码、测试、部署、Nginx配置修改、发布的 Vibecoding 全流程通过手机端简单的一句命令完成应用的快速demo
<img width="800" src="https://cdn.link-ai.tech/doc/20260203121008.png" />
#### 1.3 定时任务
基于 scheduler 工具实现动态定时任务,支持 **一次性任务、固定时间间隔、Cron表达式** 三种形式,任务触发可选择**固定消息发送** 或 **Agent动态任务** 执行两种模式,有很高灵活性:
<img width="800" src="https://cdn.link-ai.tech/doc/20260202195402.png" />
同时你也可以通过自然语言快速查看和管理已有的定时任务。
#### 1.4 环境变量管理
技能所需要的秘钥存储在环境变量文件中,由 `env_config` 工具进行管理,你可以通过对话的方式更新秘钥,工具内置了安全保护和脱敏策略,会严格保护秘钥安全:
<img width="800" src="https://cdn.link-ai.tech/doc/20260202234939.png" />
### 3. 技能系统
> 技能系统为Agent提供无限的扩展性每个Skill由说明文件、运行脚本 (可选)、资源 (可选) 组成描述如何完成特定类型的任务。通过Skill可以让Agent遵循说明完成复杂流程调用各类工具或对接第三方系统等。
- **内置技能:** 在项目的`skills`目录下包含技能创造器、网络搜索、图像识别openai-image-vision、LinkAI智能体、网页抓取等。内置Skill根据依赖条件 (API Key、系统命令等) 自动判断是否启用。通过技能创造器可以快速创建自定义技能。
- **自定义技能:** 由用户通过对话创建,存放在工作空间中 (`~/cow/skills/`),基于自定义技能可以实现任何复杂的业务流程和第三方系统对接。
#### 3.1 创建技能
通过 `skill-creator` 技能可以通过对话的方式快速创建技能。你可以在与Agent的写作中让他对将某个工作流程固化为技能或者把任意接口文档和示例发送给Agent让他直接完成对接
<img width="800" src="https://cdn.link-ai.tech/doc/20260202202247.png" />
#### 3.2 搜索和图像识别
- **搜索技能:** 系统内置实现了 `bocha-search`(博查搜索)的Skill依赖环境变量 `BOCHA_SEARCH_API_KEY`,可在[控制台](https://open.bochaai.com/)进行创建并发送给Agent完成配置
- **图像识别技能:** 实现了 `openai-image-vision` 插件,可使用 gpt-4.1-mini、gpt-4.1 等图像识别模型。依赖秘钥 `OPENAI_API_KEY`可通过config.json或env_config工具进行维护。
<img width="800" src="https://cdn.link-ai.tech/doc/20260202213219.png" />
#### 3.3 三方知识库和插件
`linkai-agent` 技能可以将 [LinkAI](https://link-ai.tech/) 上的所有智能体作为skill交给Agent使用并实现多智能体决策的效果。
使用方式:需通过对话的方式配置 `LINKAI_API_KEY`或在config.json中添加 `linkai_api_key`。 并在 `skills/linkai-agent/config.json`中添加智能体说明,示例如下:
```json
{
"apps": [
{
"app_code": "G7z6vKwp",
"app_name": "LinkAI客服助手",
"app_description": "当用户需要了解LinkAI平台相关问题时才选择该助手基于LinkAI知识库进行回答"
},
{
"app_code": "SFY5x7JR",
"app_name": "内容创作助手",
"app_description": "当用户需要创作图片或视频时才使用该助手支持Nano Banana、Seedream、即梦、Veo、可灵等多种模型"
}
]
}
```
Agent可根据智能体的名称和描述进行决策并通过 app_code 调用接口访问对应的应用/工作流通过该技能可以灵活访问LinkAI平台上的智能体、知识库、插件等能力实现效果如下
<img width="750" src="https://cdn.link-ai.tech/doc/20260202234350.png" />
注:需通过 `env_config` 配置 `LINKAI_API_KEY`或在config.json中添加 `linkai_api_key` 配置。
## 使用方式
> 详细使用方式参考项目README.md文档进行
### 1.项目运行
在命令行中执行:
```bash
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
```
详细说明及后续程序管理参考:[项目启动脚本](https://github.com/zhayujie/chatgpt-on-wechat/wiki/CowAgentQuickStart)
### 2.模型选择
Agent模式推荐使用以下模型可根据效果及成本综合选择
- **MiniMax**: `MiniMax-M2.5`
- **GLM**: `glm-5`
- **Kimi**: `kimi-k2.5`
- **Doubao**: `doubao-seed-2-0-code-preview-260215`
- **Qwen**: `qwen3.5-plus`
- **Claude**: `claude-sonnet-4-6`
- **Gemini**: `gemini-3.1-flash-lite-preview`
- **OpenAI**: `gpt-5.4`
详细模型配置方式参考 [README.md 模型说明](../README.md#模型说明)
### 3.Agent核心配置
Agent模式的核心配置项如下`config.json` 中配置:
```bash
{
"agent": true, # 是否启用Agent模式
"agent_workspace": "~/cow", # Agent工作空间路径
"agent_max_context_tokens": 40000, # 最大上下文tokens
"agent_max_context_turns": 30, # 最大上下文记忆轮次
"agent_max_steps": 15 # 单次任务最大决策步数
}
```
**配置说明:**
- `agent`: 设为 `true` 启用Agent模式获得多轮工具决策、长期记忆、Skills等能力
- `agent_workspace`: 工作空间路径,用于存储 memory、skills、其他系统设定提示词
- `agent_max_context_tokens`: 上下文token上限超出将自动丢弃最早的对话
- `agent_max_context_turns`: 上下文记忆轮次,每轮包括一次提问和回复
- `agent_max_steps`: 单次任务最大工具调用步数,防止无限循环
### 4.渠道接入
Agent支持在多种渠道中使用只需修改 `config.json` 中的 `channel_type` 配置即可切换。
- **Web网页**:默认使用该渠道,运行后监听本地端口,通过浏览器访问
- **飞书接入**[飞书接入文档](https://docs.link-ai.tech/cow/multi-platform/feishu)
- **钉钉接入**[钉钉接入文档](https://docs.link-ai.tech/cow/multi-platform/dingtalk)
- **企业微信应用接入**[企微应用文档](https://docs.link-ai.tech/cow/multi-platform/wechat-com)
- **企微智能机器人**[企微智能机器人文档](https://docs.link-ai.tech/cow/multi-platform/wecom-bot)
- **QQ机器人**[QQ机器人文档](https://docs.link-ai.tech/cow/multi-platform/qq)
更多渠道配置参考:[通道说明](../README.md#通道说明)

View File

@@ -9,7 +9,23 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
智能机器人与企业微信自建应用是两种不同的接入方式。智能机器人使用 WebSocket 长连接,无需服务器公网 IP 和域名,配置更简单。
</Note>
## 一、创建智能机器人
## 一、接入方式
### 方式一:扫码一键接入(推荐)
无需提前创建机器人,启动 Cow 项目后打开 Web 控制台本地链接http://127.0.0.1:9899/),选择 **通道** 菜单,点击**接入通道**,选择**企微智能机器人**,切换到「扫码接入」模式,使用**企业微信**扫码即可自动完成机器人创建和接入。
<img src="https://cdn.link-ai.tech/doc/20260401121213.png" width="800"/>
<Note>
扫码成功后,可在企业微信工作台 - **智能机器人**页面对机器人进行进一步配置,包括修改名称、头像、可见范围等。
</Note>
### 方式二:手动创建接入
需要先在企业微信中创建智能机器人并获取 Bot ID 和 Secret再通过 Web 控制台或配置文件接入。
**步骤一:创建智能机器人**
1. 打开企业微信客户端,进入工作台,点击**智能机器人**
@@ -25,34 +41,35 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
4. 设置机器人名称、头像、可见范围,并选择**长连接模式**,记录下 **Bot ID** 和 **Secret** 信息后点击保存。
## 二、配置和运行
**步骤二:接入 CowAgent**
### 方式一Web 控制台接入
<Tabs>
<Tab title="Web 控制台">
打开 Web 控制台,选择**通道**菜单,点击**接入通道**,选择**企微智能机器人**,切换到「手动填写」模式,输入 Bot ID 和 Secret点击接入即可。
启动Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **企微智能机器人**,填写上一步保存的 Bot ID 和 Secret点击接入即可。
<img src="https://cdn.link-ai.tech/doc/20260316181711.png" width="800"/>
</Tab>
<Tab title="配置文件">
在 `config.json` 中添加以下配置后启动程序:
<img src="https://cdn.link-ai.tech/doc/20260316181711.png" width="800"/>
```json
{
"channel_type": "wecom_bot",
"wecom_bot_id": "YOUR_BOT_ID",
"wecom_bot_secret": "YOUR_SECRET"
}
```
### 方式二:配置文件接入
| 参数 | 说明 |
| --- | --- |
| `wecom_bot_id` | 智能机器人的 BotID |
| `wecom_bot_secret` | 智能机器人的 Secret |
</Tab>
</Tabs>
在 `config.json` 中添加以下配置:
日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
```json
{
"channel_type": "wecom_bot",
"wecom_bot_id": "YOUR_BOT_ID",
"wecom_bot_secret": "YOUR_SECRET"
}
```
| 参数 | 说明 |
| --- | --- |
| `wecom_bot_id` | 智能机器人的 BotID |
| `wecom_bot_secret` | 智能机器人的 Secret |
配置完成后启动程序,日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
## 三、功能说明
## 二、功能说明
| 功能 | 支持情况 |
| --- | --- |
@@ -64,7 +81,7 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
| 流式回复 | ✅ |
| 定时任务主动推送 | ✅ |
## 、使用
## 、使用
在企业微信中搜索创建的机器人名称,即可开始单聊对话。

74
docs/channels/weixin.mdx Normal file
View File

@@ -0,0 +1,74 @@
---
title: 微信
description: 将 CowAgent 接入个人微信(基于官方接口)
---
> 接入个人微信扫码登录即可使用支持文本、图片、语音、文件、视频等消息的私聊收发。通过微信官方API进行接入无安全风险接入后会在会话中新增一个机器人助手不影响当前账号的使用。
## 一、配置和运行
### 方式一Web 控制台接入
启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。
<img src="https://cdn.link-ai.tech/doc/20260322195114.png" width="800" />
### 方式二:配置文件接入
在 `config.json` 中设置 `channel_type` 为 `weixin`
```json
{
"channel_type": "weixin"
}
```
启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。
<img src="https://cdn.link-ai.tech/doc/20260322195509.png" width="800" />
<Note>
1. 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。
2. 注意微信客户端需要更新至 8.0.69 版本或以上
</Note>
## 二、使用说明
微信扫码并进行授权确认后,即可完成接入并开始对话。接入微信后会在对话中创建出一个机器人助理,不会对已有账号的正常使用有任何影响。
> 你可以通过搜索"微信ClawBot"随时找到这个机器人,还可以修改这个机器人的头像、备注等信息,将机器人置顶在消息列表等。
<img src="https://cdn.link-ai.tech/doc/83ae8251d896219fde4803f4205205be.jpg" width="250" />
## 三、登录说明
### 扫码登录
首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。
- 二维码过期后会自动刷新并重新显示
- `requirements.txt` 中已默认包含 `qrcode` 依赖,安装后可在终端直接渲染二维码图案
### 凭证保存
登录成功后,凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动时无需重新扫码。
如需重新登录,删除该凭证文件后重启程序即可。
### Session 过期
当微信 session 过期时errcode -14程序会自动清除旧凭证并重新发起扫码登录无需手动干预。
## 四、功能说明
| 功能 | 支持情况 |
| --- | --- |
| 单聊 | ✅ |
| 文本消息 | ✅ 收发 |
| 图片消息 | ✅ 收发 |
| 文件消息 | ✅ 收发 |
| 视频消息 | ✅ 收发 |
| 语音消息 | ✅ 接收 (自带语音识别) |

115
docs/commands/general.mdx Normal file
View File

@@ -0,0 +1,115 @@
---
title: 常用命令
description: 查看状态、管理配置和上下文等常用命令
---
以下命令支持在对话中使用 `/` 前缀,也支持在终端中使用 `cow` 前缀(部分命令仅对话可用)。
<Tip>
在 Web 控制台中输入 `/` 会自动弹出命令提示,支持键盘上下选择和 Tab 补全。
</Tip>
## 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` |
<Note>
修改 `model` 时,系统会自动匹配对应的模型调用方式。配置会写入 `config.json` 并持久保存。
</Note>
## context
查看当前会话的上下文信息,包括消息数量、内容长度等统计。
```text
/context
```
**清空当前会话上下文:**
```text
/context clear
```
<Tip>
清空上下文后Agent 会"忘记"之前的对话内容,适用于切换话题或释放上下文空间。
</Tip>
## logs
查看最近的服务日志,默认显示最近 20 行,最多 50 行。
```text
/logs
```
**指定行数:**
```text
/logs 50
```
## version
显示当前 CowAgent 版本号。
```text
/version
```

86
docs/commands/index.mdx Normal file
View File

@@ -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 <command>
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` | 显示版本号 |
<Tip>
对话命令中 `/start`、`/stop`、`/restart` 等服务管理命令会提示到终端中执行,因为它们涉及进程操作。
</Tip>
## 命令对照表
以下是各命令在终端和对话中的可用性:
| 命令 | 终端 (`cow`) | 对话 (`/`) |
| --- | :---: | :---: |
| help | ✓ | ✓ |
| version | ✓ | ✓ |
| status | ✓ | ✓ |
| logs | ✓ | ✓ |
| config | ✗ | ✓ |
| context | — | ✓ |
| skill (子命令) | ✓ | ✓ |
| start / stop / restart | ✓ | ✗ |
| update | ✓ | ✗ |
| install-browser | ✓ | ✗ |
<Note>
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
</Note>

134
docs/commands/process.mdx Normal file
View File

@@ -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
```
<Warning>
如果 `git pull` 失败(如存在本地未提交的修改),更新会中止,服务不受影响。
</Warning>
## 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
```
<Tip>
仅在需要使用浏览器工具(如网页浏览、截图等)时才需要安装。
</Tip>
## 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` |
<Note>
推荐使用 `cow` 命令,它提供更简洁的语法和更丰富的功能。通过一键安装脚本部署时 `cow` 命令会自动安装。
</Note>

218
docs/commands/skill.mdx Normal file
View File

@@ -0,0 +1,218 @@
---
title: 技能管理
description: 通过命令安装、卸载、启用、禁用和管理技能
---
技能管理命令用于安装、查询和管理 CowAgent 的技能。在对话中使用 `/skill <子命令>`,在终端中使用 `cow skill <子命令>`。
## list
列出已安装的技能及其状态。
<CodeGroup>
```text 对话
/skill list
```
```bash 终端
cow skill list
```
</CodeGroup>
输出示例:
```
📦 已安装的技能 (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 上所有可安装的技能):
<CodeGroup>
```text 对话
/skill list --remote
```
```bash 终端
cow skill list --remote
```
</CodeGroup>
**选项:**
| 选项 | 说明 | 默认值 |
| --- | --- | --- |
| `--remote`, `-r` | 浏览 Skill Hub 远程技能列表 | 否 |
| `--page` | 远程列表分页页码 | 1 |
## search
在技能广场中搜索技能。
<CodeGroup>
```text 对话
/skill search pptx
```
```bash 终端
cow skill search pptx
```
</CodeGroup>
## install
安装技能。通过统一的 `install` 命令,可一键安装来自 **Cow 技能广场、GitHub、ClawHub** 以及任意 URLzip 压缩包、SKILL.md 链接)上的技能,无需手动下载和配置。
**从 Cow 技能广场安装(推荐):**
<CodeGroup>
```text 对话
/skill install pptx
```
```bash 终端
cow skill install pptx
```
</CodeGroup>
**从 GitHub 安装:**
<CodeGroup>
```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
```
</CodeGroup>
支持完整的 GitHub URL 和 `owner/repo` 简写。对于 mono-repo一个仓库中包含多个技能不指定子目录时会自动发现并批量安装所有技能指定子目录时只安装该目录下的技能。
**从 ClawHub 安装:**
<CodeGroup>
```text 对话
/skill install clawhub:baidu-search
```
```bash 终端
cow skill install clawhub:baidu-search
```
</CodeGroup>
**从 URL 安装:**
<CodeGroup>
```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
```
</CodeGroup>
支持从 zip / tar.gz 压缩包 URL 安装,解压后自动扫描包含 `SKILL.md` 的目录,支持单个或批量安装。也支持直接从 `SKILL.md` 文件链接安装,会自动解析技能名称和描述。
安装成功后会显示技能名称、描述和来源,例如:
```
✅ baidu-search
百度搜索:使用百度搜索引擎检索信息…
来源: clawhub
```
## uninstall
卸载已安装的技能。
<CodeGroup>
```text 对话
/skill uninstall pptx
```
```bash 终端
cow skill uninstall pptx
```
</CodeGroup>
<Warning>
卸载操作会删除技能目录下的所有文件,此操作不可恢复。
</Warning>
## enable / disable
启用或禁用技能,禁用后技能不会被 Agent 调用。
<CodeGroup>
```text 对话
/skill enable pptx
/skill disable pptx
```
```bash 终端
cow skill enable pptx
cow skill disable pptx
```
</CodeGroup>
## info
查看已安装技能的详细信息,包括 `SKILL.md` 内容预览。
<CodeGroup>
```text 对话
/skill info pptx
```
```bash 终端
cow skill info pptx
```
</CodeGroup>
## 技能来源
安装的技能会记录来源信息,可通过 `/skill list` 查看:
| 来源标识 | 说明 |
| --- | --- |
| `builtin` | 项目内置技能 |
| `cowhub` | 从 CowAgent Skill Hub 安装 |
| `github` | 从 GitHub URL 直接安装 |
| `clawhub` | 从 ClawHub 安装 |
| `url` | 从 SKILL.md URL 安装 |
| `local` | 本地创建的技能 |

View File

@@ -59,7 +59,8 @@
"group": "安装部署",
"pages": [
"guide/quick-start",
"guide/manual-install"
"guide/manual-install",
"guide/upgrade"
]
}
]
@@ -80,7 +81,8 @@
"models/gemini",
"models/openai",
"models/deepseek",
"models/linkai"
"models/linkai",
"models/coding-plan"
]
}
]
@@ -104,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"
]
}
]
@@ -123,15 +128,9 @@
"group": "技能系统",
"pages": [
"skills/index",
"skills/skill-creator"
]
},
{
"group": "内置技能",
"pages": [
"skills/image-vision",
"skills/linkai-agent",
"skills/web-fetch"
"skills/install",
"skills/create",
"skills/hub"
]
}
]
@@ -142,7 +141,8 @@
{
"group": "记忆系统",
"pages": [
"memory"
"memory/index",
"memory/context"
]
}
]
@@ -153,6 +153,7 @@
{
"group": "接入渠道",
"pages": [
"channels/weixin",
"channels/web",
"channels/feishu",
"channels/dingtalk",
@@ -164,6 +165,20 @@
}
]
},
{
"tab": "命令",
"groups": [
{
"group": "命令系统",
"pages": [
"commands/index",
"commands/process",
"commands/skill",
"commands/general"
]
}
]
},
{
"tab": "版本",
"groups": [
@@ -171,6 +186,9 @@
"group": "发布记录",
"pages": [
"releases/overview",
"releases/v2.0.5",
"releases/v2.0.4",
"releases/v2.0.3",
"releases/v2.0.2",
"releases/v2.0.1",
"releases/v2.0.0"
@@ -224,7 +242,8 @@
"en/models/gemini",
"en/models/openai",
"en/models/deepseek",
"en/models/linkai"
"en/models/linkai",
"en/models/coding-plan"
]
}
]
@@ -248,14 +267,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"
]
}
]
@@ -267,15 +289,9 @@
"group": "Skills System",
"pages": [
"en/skills/index",
"en/skills/skill-creator"
]
},
{
"group": "Built-in Skills",
"pages": [
"en/skills/image-vision",
"en/skills/linkai-agent",
"en/skills/web-fetch"
"en/skills/install",
"en/skills/skill-creator",
"en/skills/hub"
]
}
]
@@ -286,7 +302,8 @@
{
"group": "Memory System",
"pages": [
"en/memory"
"en/memory/index",
"en/memory/context"
]
}
]
@@ -297,6 +314,7 @@
{
"group": "Platforms",
"pages": [
"en/channels/weixin",
"en/channels/web",
"en/channels/feishu",
"en/channels/dingtalk",
@@ -308,6 +326,20 @@
}
]
},
{
"tab": "Commands",
"groups": [
{
"group": "Command System",
"pages": [
"en/commands/index",
"en/commands/process",
"en/commands/skill",
"en/commands/chat"
]
}
]
},
{
"tab": "Releases",
"groups": [
@@ -315,6 +347,8 @@
"group": "Release Notes",
"pages": [
"en/releases/overview",
"en/releases/v2.0.5",
"en/releases/v2.0.4",
"en/releases/v2.0.2",
"en/releases/v2.0.1",
"en/releases/v2.0.0"
@@ -323,6 +357,168 @@
]
}
]
},
{
"language": "ja",
"tabs": [
{
"tab": "紹介",
"groups": [
{
"group": "概要",
"pages": [
"ja/intro/index",
"ja/intro/architecture",
"ja/intro/features"
]
}
]
},
{
"tab": "クイックスタート",
"groups": [
{
"group": "インストール",
"pages": [
"ja/guide/quick-start",
"ja/guide/manual-install",
"ja/guide/upgrade"
]
}
]
},
{
"tab": "モデル",
"groups": [
{
"group": "モデル設定",
"pages": [
"ja/models/index",
"ja/models/minimax",
"ja/models/glm",
"ja/models/qwen",
"ja/models/kimi",
"ja/models/doubao",
"ja/models/claude",
"ja/models/gemini",
"ja/models/openai",
"ja/models/deepseek",
"ja/models/linkai",
"ja/models/coding-plan"
]
}
]
},
{
"tab": "ツール",
"groups": [
{
"group": "ツールシステム",
"pages": [
"ja/tools/index"
]
},
{
"group": "内蔵ツール",
"pages": [
"ja/tools/read",
"ja/tools/write",
"ja/tools/edit",
"ja/tools/ls",
"ja/tools/bash",
"ja/tools/send",
"ja/tools/memory",
"ja/tools/env-config",
"ja/tools/web-fetch",
"ja/tools/scheduler"
]
},
{
"group": "オプションツール",
"pages": [
"ja/tools/web-search",
"ja/tools/vision",
"ja/tools/browser"
]
}
]
},
{
"tab": "スキル",
"groups": [
{
"group": "スキルシステム",
"pages": [
"ja/skills/index",
"ja/skills/install",
"ja/skills/create",
"ja/skills/hub"
]
}
]
},
{
"tab": "メモリ",
"groups": [
{
"group": "メモリシステム",
"pages": [
"ja/memory/index",
"ja/memory/context"
]
}
]
},
{
"tab": "チャネル",
"groups": [
{
"group": "プラットフォーム",
"pages": [
"ja/channels/weixin",
"ja/channels/web",
"ja/channels/feishu",
"ja/channels/dingtalk",
"ja/channels/wecom-bot",
"ja/channels/qq",
"ja/channels/wecom",
"ja/channels/wechatmp"
]
}
]
},
{
"tab": "コマンド",
"groups": [
{
"group": "コマンドシステム",
"pages": [
"ja/commands/index",
"ja/commands/process",
"ja/commands/skill",
"ja/commands/general"
]
}
]
},
{
"tab": "リリース",
"groups": [
{
"group": "リリースノート",
"pages": [
"ja/releases/overview",
"ja/releases/v2.0.5",
"ja/releases/v2.0.4",
"ja/releases/v2.0.3",
"ja/releases/v2.0.2",
"ja/releases/v2.0.1",
"ja/releases/v2.0.0"
]
}
]
}
]
}
]
}

View File

@@ -4,28 +4,31 @@
<a href="https://github.com/zhayujie/chatgpt-on-wechat/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/chatgpt-on-wechat" alt="Latest release"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/chatgpt-on-wechat" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/chatgpt-on-wechat"><img src="https://img.shields.io/github/stars/zhayujie/chatgpt-on-wechat?style=flat-square" alt="Stars"></a> <br/>
[<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/README.md">中文</a>] | [English]
[<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/README.md">中文</a>] | [English] | [<a href="https://github.com/zhayujie/chatgpt-on-wechat/blob/master/docs/ja/README.md">日本語</a>]
</p>
**CowAgent** is an AI super assistant powered by LLMs, capable of autonomous task planning, operating computers and external resources, creating and executing Skills, and continuously growing with long-term memory. It supports flexible model switching, handles text, voice, images, and files, and can be integrated into Web, Feishu, DingTalk, WeCom 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.
<p align="center">
<a href="https://cowagent.ai/">🌐 Website</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/en/intro/index">📖 Docs</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/en/guide/quick-start">🚀 Quick Start</a>
<a href="https://docs.cowagent.ai/en/guide/quick-start">🚀 Quick Start</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
</p>
## Introduction
> CowAgent is both an out-of-the-box AI super assistant and a highly extensible Agent framework. You can extend it with new model interfaces, channels, built-in tools, and the Skills system to flexibly implement various customization needs.
-**Autonomous Task Planning**: Understands complex tasks and autonomously plans execution, continuously thinking and invoking tools until goals are achieved. Supports accessing files, terminal, browser, schedulers, and other system resources via tools.
-**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 Web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
-**Knowledge Base**: Integrates enterprise knowledge base capabilities via the [LinkAI](https://link-ai.tech) platform.
-**Multi-platform Deployment**: Runs on local computers or servers, integrable into WeChat, Web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
## Disclaimer
@@ -33,8 +36,14 @@
2. Agent mode consumes more tokens than normal chat mode. Choose models based on effectiveness and cost. Agent has access to the host OS — please deploy in trusted environments.
3. CowAgent focuses on open-source development and does not participate in, authorize, or issue any cryptocurrency.
## Demo
Try online (no deployment needed): [CowAgent](https://link-ai.tech/cowagent/create)
## Changelog
> **2026.04.01:** [v2.0.5](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.5) — Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more.
> **2026.02.27:** [v2.0.2](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2) — Web console overhaul (streaming chat, model/skill/memory/channel/scheduler/log management), multi-channel concurrent running, session persistence, new models including Gemini 3.1 Pro / Claude 4.6 Sonnet / Qwen3.5 Plus.
> **2026.02.13:** [v2.0.1](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1) — Built-in Web Search tool, smart context trimming, runtime info dynamic update, Windows compatibility, fixes for scheduler memory loss, Feishu connection issues, and more.
@@ -55,13 +64,19 @@ 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 -sS https://cdn.link-ai.tech/code/cow/run.sh)
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)
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
@@ -79,7 +94,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
@@ -87,13 +120,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
@@ -102,7 +147,7 @@ nohup python3 app.py & tail -f nohup.out
### Docker Deployment
```bash
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
# Edit docker-compose.yml with your config
sudo docker compose up -d
sudo docker logs -f chatgpt-on-wechat
@@ -116,8 +161,8 @@ Supports mainstream model providers. Recommended models for Agent mode:
| Provider | Recommended Model |
| --- | --- |
| MiniMax | `MiniMax-M2.5` |
| GLM | `glm-5` |
| MiniMax | `MiniMax-M2.7` |
| GLM | `glm-5-turbo` |
| Kimi | `kimi-k2.5` |
| Doubao | `doubao-seed-2-0-code-preview-260215` |
| Qwen | `qwen3.5-plus` |
@@ -128,6 +173,28 @@ Supports mainstream model providers. Recommended models for Agent mode:
For detailed configuration of each model, see the [Models documentation](https://docs.cowagent.ai/en/models/index).
### Coding Plan
Coding Plan is a monthly subscription package offered by various providers, ideal for high-frequency Agent usage. All providers can be accessed via OpenAI-compatible mode:
```json
{
"bot_type": "openai",
"model": "MODEL_NAME",
"open_ai_api_base": "PROVIDER_CODING_PLAN_API_BASE",
"open_ai_api_key": "YOUR_API_KEY"
}
```
- `bot_type`: Must be `openai`
- `model`: Model name supported by the provider
- `open_ai_api_base`: Provider's Coding Plan API Base (different from standard pay-as-you-go)
- `open_ai_api_key`: Provider's Coding Plan API Key
> Note: Coding Plan API Base and API Key are usually separate from standard pay-as-you-go ones. Please obtain them from each provider's platform.
Supported providers include Alibaba Cloud, MiniMax, Zhipu GLM, Kimi, Volcengine, and more. For detailed configuration of each provider, see the [Coding Plan documentation](https://docs.cowagent.ai/en/models/coding-plan).
<br/>
## Channels
@@ -136,6 +203,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) |
@@ -158,6 +226,7 @@ Multiple channels can be enabled simultaneously, separated by commas: `"channel_
## 🔗 Related Projects
- [Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub): Open skill marketplace for AI Agents — browse, search, install, and publish skills for CowAgent, OpenClaw, Claude Code, and more.
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything): Lightweight and highly extensible LLM application framework supporting Slack, Telegram, Discord, Gmail, and more.
- [AgentMesh](https://github.com/MinimalFuture/AgentMesh): Open-source Multi-Agent framework for complex problem solving through agent team collaboration.
@@ -167,7 +236,7 @@ FAQs: <https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs>
## 🛠️ Contributing
Welcome to add new channels, referring to the [Feishu channel](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) as an example. Also welcome to contribute new Skills, referring to the [Skill Creator docs](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md).
Welcome to add new channels, referring to the [Feishu channel](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/feishu/feishu_channel.py) as an example. Also welcome to contribute new Skills, see the [Skill Creation docs](https://docs.cowagent.ai/en/skills/create), or submit to [Skill Hub](https://skills.cowagent.ai/submit).
## ✉ Contact

View File

@@ -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.
<Note>
For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel.
</Note>
## 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
- The `qrcode` dependency is already included in `requirements.txt`, enabling QR code rendering directly in the terminal
### 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.

View File

@@ -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).
<Tip>
In the Web console, typing `/` brings up an autocomplete menu with keyboard navigation and Tab completion.
</Tip>
## 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` |
<Note>
When changing `model`, the system automatically matches the corresponding model API. Configuration is persisted to `config.json`.
</Note>
## context
View current session context statistics, including message count and content length.
```text
/context
```
**Clear current session context:**
```text
/context clear
```
<Tip>
Clearing context makes the Agent "forget" previous conversation, useful for switching topics or freeing context space.
</Tip>
## 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
```

View File

@@ -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 <command>` in your system terminal for service management, skill management, and other operations
- **Chat Commands** — Type `/<command>` or `cow <command>` 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 <command>
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 |
<Tip>
Service management commands like `/start`, `/stop`, `/restart` will prompt you to use them in the terminal instead, as they involve process operations.
</Tip>
## Command Availability
| Command | Terminal (`cow`) | Chat (`/`) |
| --- | :---: | :---: |
| help | ✓ | ✓ |
| version | ✓ | ✓ |
| status | ✓ | ✓ |
| logs | ✓ | ✓ |
| config | ✗ | ✓ |
| context | — | ✓ |
| skill (subcommands) | ✓ | ✓ |
| start / stop / restart | ✓ | ✗ |
| update | ✓ | ✗ |
| install-browser | ✓ | ✗ |
<Note>
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
</Note>

View File

@@ -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
```
<Warning>
If `git pull` fails (e.g., uncommitted local changes), the update aborts and the service remains unaffected.
</Warning>
## 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
```
<Tip>
Only needed when using browser tools (web browsing, screenshots, etc.).
</Tip>
## 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` |
<Note>
The `cow` command is recommended — it provides cleaner syntax and richer features. It is automatically installed via the one-click install script.
</Note>

192
docs/en/commands/skill.mdx Normal file
View File

@@ -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 <subcommand>` in chat or `cow skill <subcommand>` in the terminal.
## list
List installed skills and their status.
<CodeGroup>
```text Chat
/skill list
```
```bash Terminal
cow skill list
```
</CodeGroup>
**Browse the Skill Hub** (view all available skills):
<CodeGroup>
```text Chat
/skill list --remote
```
```bash Terminal
cow skill list --remote
```
</CodeGroup>
**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.
<CodeGroup>
```text Chat
/skill search pptx
```
```bash Terminal
cow skill search pptx
```
</CodeGroup>
## 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):**
<CodeGroup>
```text Chat
/skill install pptx
```
```bash Terminal
cow skill install pptx
```
</CodeGroup>
**From GitHub:**
<CodeGroup>
```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
```
</CodeGroup>
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:**
<CodeGroup>
```text Chat
/skill install clawhub:baidu-search
```
```bash Terminal
cow skill install clawhub:baidu-search
```
</CodeGroup>
**From URL:**
<CodeGroup>
```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
```
</CodeGroup>
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.
<CodeGroup>
```text Chat
/skill uninstall pptx
```
```bash Terminal
cow skill uninstall pptx
```
</CodeGroup>
<Warning>
Uninstalling deletes all files in the skill directory. This action cannot be undone.
</Warning>
## enable / disable
Enable or disable a skill. Disabled skills will not be invoked by the Agent.
<CodeGroup>
```text Chat
/skill enable pptx
/skill disable pptx
```
```bash Terminal
cow skill enable pptx
cow skill disable pptx
```
</CodeGroup>
## info
View details of an installed skill, including a preview of its `SKILL.md`.
<CodeGroup>
```text Chat
/skill info pptx
```
```bash Terminal
cow skill info pptx
```
</CodeGroup>
## 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 |

View File

@@ -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
```
<Note>
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.
</Note>
### 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
```
<Tip>
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.
</Tip>
## Docker Deployment
Docker deployment does not require cloning source code or installing dependencies. For Agent mode, source deployment is recommended for broader system access.
@@ -67,7 +95,7 @@ Docker deployment does not require cloning source code or installing dependencie
**1. Download config**
```bash
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
```
Edit `docker-compose.yml` with your configuration.
@@ -84,6 +112,10 @@ sudo docker compose up -d
sudo docker logs -f chatgpt-on-wechat
```
<Tip>
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.
</Tip>
## Core Configuration
```json

View File

@@ -9,31 +9,46 @@ Supports Linux, macOS, and Windows. Requires Python 3.7-3.12 (3.9 recommended).
## Install Command
```bash
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
```
<Tabs>
<Tab title="Linux / macOS">
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
</Tab>
<Tab title="Windows (PowerShell)">
```powershell
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
```
</Tab>
</Tabs>
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 |
| `cow install-browser` | Install browser tool dependencies |
See the [Commands documentation](/en/commands/index) for more details.
<Note>
If the `cow` command is not available, you can use `./run.sh <command>` (Linux/macOS) or `.\scripts\run.ps1 <command>` (Windows) as a fallback. Both are functionally equivalent.
</Note>

View File

@@ -1,6 +1,6 @@
---
title: Features
description: CowAgent long-term memory, task planning, and skills system in detail
description: CowAgent long-term memory, task planning, skills system, CLI commands, and browser tool in detail
---
## 1. Long-term Memory
@@ -19,7 +19,7 @@ In subsequent long-term conversations, the Agent intelligently stores or retriev
Tools are the core of how the Agent accesses operating system resources. The Agent intelligently selects and invokes tools based on task requirements, performing file read/write, command execution, scheduled tasks, and more. Built-in tools are implemented in the project's `agent/tools/` directory.
**Key tools:** file read/write/edit, Bash terminal, file send, scheduler, memory search, web search, environment config, and more.
**Key tools:** file read/write/edit, Bash terminal, browser, file send, scheduler, memory search, web search, environment config, and more.
### 2.1 Terminal and File Access
@@ -45,7 +45,15 @@ The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time task
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
</Frame>
### 2.4 Environment Variable Management
### 2.4 Browser
The built-in `browser` tool allows the Agent to control a Chromium browser to visit web pages, fill forms, click elements, and take screenshots, with support for dynamic JS-rendered pages. Run `cow install-browser` to install with one command, automatically adapting to server (headless) and desktop environments:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260401110103.png" width="800" />
</Frame>
### 2.5 Environment Variable Management
Secrets required by skills are stored in an environment variable file, managed by the `env_config` tool. You can update secrets through conversation, with built-in security protection and desensitization:
@@ -57,9 +65,12 @@ Secrets required by skills are stored in an environment variable file, managed b
The Skills system provides infinite extensibility for the Agent. Each Skill consists of a description file, execution scripts (optional), and resources (optional), describing how to complete specific types of tasks. Skills allow the Agent to follow instructions for complex workflows, invoke tools, or integrate third-party systems.
- **[Skill Hub](https://skills.cowagent.ai/):** An open skill marketplace featuring official, community, and third-party skills. Install with one command.
- **Built-in skills:** Located in the project's `skills/` directory, including skill creator, image recognition, LinkAI agent, web fetch, and more. Built-in skills are automatically enabled based on dependency conditions (API keys, system commands, etc.).
- **Custom skills:** Created by users through conversation, stored in the workspace (`~/cow/skills/`), capable of implementing any complex business process or third-party integration.
Install skills: `/skill install <name>` or `cow skill install <name>`, supporting Skill Hub, GitHub, ClawHub, URL, and more.
### 3.1 Creating Skills
The `skill-creator` skill enables rapid skill creation through conversation. You can ask the Agent to codify a workflow as a skill, or send any API documentation and examples for the Agent to complete the integration directly:
@@ -77,29 +88,33 @@ The `skill-creator` skill enables rapid skill creation through conversation. You
<img src="https://cdn.link-ai.tech/doc/20260202213219.png" width="800" />
</Frame>
### 3.3 Third-party Knowledge Bases and Plugins
### 3.3 Skill Hub
The `linkai-agent` skill makes all agents on [LinkAI](https://link-ai.tech/) available as Skills for the Agent, enabling multi-agent decision making.
Visit [skills.cowagent.ai](https://skills.cowagent.ai/) to browse all available skills, or use commands in conversation:
Configuration: set `LINKAI_API_KEY` via `env_config`, then add agent descriptions in `skills/linkai-agent/config.json`:
```json
{
"apps": [
{
"app_code": "G7z6vKwp",
"app_name": "LinkAI Customer Support",
"app_description": "Select only when the user needs help with LinkAI platform questions"
},
{
"app_code": "SFY5x7JR",
"app_name": "Content Creator",
"app_description": "Use only when the user needs to create images or videos"
}
]
}
```text
/skill list --remote # Browse Skill Hub
/skill search <keyword> # Search skills
/skill install <name> # Install with one command
```
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202234350.png" width="750" />
</Frame>
Also supports installing skills from GitHub, ClawHub, LinkAI, and other third-party platforms. See [Install Skills](/en/skills/install) for details.
<img src="https://cdn.link-ai.tech/doc/20260401110103.png" width="750" />
## 4. CLI Command System
CowAgent provides two command interaction methods, covering service management, skill installation, configuration, and more:
- **Terminal CLI:** Run `cow <command>` in the system terminal, supporting `start`, `stop`, `restart`, `update`, `status`, `logs`, `skill`, etc.
- **Chat commands:** Type `/<command>` in conversation. The Web console shows a command menu when you type `/`.
```bash
cow start # Start service
cow stop # Stop service
cow update # Update and restart
cow skill install pptx # Install a skill
cow install-browser # Install browser tool
```
See [Command Overview](https://docs.cowagent.ai/en/commands) for details.

View File

@@ -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.
<Card title="GitHub" icon="github" href="https://github.com/zhayujie/chatgpt-on-wechat">
github.com/zhayujie/chatgpt-on-wechat
@@ -28,11 +28,17 @@ CowAgent can proactively think and plan tasks, operate computers and external re
<Card title="Multimodal Messages" icon="image" href="/en/channels/web">
Supports parsing, processing, generating, and sending text, images, voice, files, and other message types.
</Card>
<Card title="Tool System" icon="wrench" href="/en/tools/index">
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.
</Card>
<Card title="Command System" icon="terminal" href="/en/commands/index">
Provides terminal CLI and in-chat commands for process management, skill installation, configuration, context inspection, and other common operations.
</Card>
<Card title="Multiple Model Support" icon="microchip" href="/en/models/index">
Supports mainstream model providers including OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, Kimi, Doubao, and more.
</Card>
<Card title="Multi-platform Deployment" icon="server" href="/en/channels/web">
Runs on local computers or servers, integrable into web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
<Card title="Multi-platform Deployment" icon="server" href="/en/channels/weixin">
Runs on local computers or servers, integrable into WeChat, web, Feishu, DingTalk, WeChat Official Account, and WeCom applications.
</Card>
</CardGroup>
@@ -40,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 -sS https://cdn.link-ai.tech/code/cow/run.sh)
```
<Tabs>
<Tab title="Linux / macOS">
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
</Tab>
<Tab title="Windows (PowerShell)">
```powershell
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
```
</Tab>
</Tabs>
By default, the Web service starts after running. Access `http://localhost:9899/chat` to chat in the web interface.

View File

@@ -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 |
<Tip>
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.
</Tip>

View File

@@ -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
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
</Frame>
## 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` |

View File

@@ -0,0 +1,139 @@
---
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.
<Note>
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.
</Note>
## 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:
<img src="https://cdn.link-ai.tech/doc/20260318113134.png" width="800"/>
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)
---
## Zhipu 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": "openai",
"model": "kimi-for-coding",
"open_ai_api_base": "https://api.kimi.com/coding/v1",
"open_ai_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | `kimi-for-coding` |
| `open_ai_api_base` | `https://api.kimi.com/coding/v1` |
| `open_ai_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)

View File

@@ -3,20 +3,30 @@ title: DeepSeek
description: DeepSeek model configuration
---
Use OpenAI-compatible configuration:
Option 1: Native integration (recommended):
```json
{
"model": "deepseek-chat",
"bot_type": "chatGPT",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.deepseek.com/v1"
"deepseek_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | `deepseek-chat` (DeepSeek-V3), `deepseek-reasoner` (DeepSeek-R1) |
| `bot_type` | Must be `chatGPT` (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 |
| `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 |
Option 2: OpenAI-compatible configuration:
```json
{
"model": "deepseek-chat",
"bot_type": "openai",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.deepseek.com/v1"
}
```

View File

@@ -5,22 +5,22 @@ description: Zhipu AI GLM model configuration
```json
{
"model": "glm-5",
"model": "glm-5-turbo",
"zhipu_ai_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Options include `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) |
| `model` | Options include `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 at [Zhipu AI Console](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
OpenAI-compatible configuration is also supported:
```json
{
"bot_type": "chatGPT",
"model": "glm-5",
"bot_type": "openai",
"model": "glm-5-turbo",
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
"open_ai_api_key": "YOUR_API_KEY"
}

View File

@@ -6,12 +6,12 @@ description: Supported models and recommended choices for CowAgent
CowAgent supports mainstream LLMs from domestic and international providers. Model interfaces are implemented in the project's `models/` directory.
<Note>
For Agent mode, the following models are recommended based on quality and cost: MiniMax-M2.5, glm-5, kimi-k2.5, qwen3.5-plus, claude-sonnet-4-6, gemini-3.1-pro-preview
For Agent mode, the following models are recommended based on quality and cost: MiniMax-M2.7, glm-5-turbo, kimi-k2.5, qwen3.5-plus, claude-sonnet-4-6, gemini-3.1-pro-preview
</Note>
## Configuration
Configure the model name and API key in `config.json` according to your chosen model. Each model also supports OpenAI-compatible access by setting `bot_type` to `chatGPT` and configuring `open_ai_api_base` and `open_ai_api_key`.
Configure the model name and API key in `config.json` according to your chosen model. Each model also supports OpenAI-compatible access by setting `bot_type` to `openai` and configuring `open_ai_api_base` and `open_ai_api_key`.
You can also use the [LinkAI](https://link-ai.tech) platform interface to flexibly switch between multiple models with support for knowledge base, workflows, and other Agent capabilities.
@@ -19,10 +19,10 @@ You can also use the [LinkAI](https://link-ai.tech) platform interface to flexib
<CardGroup cols={2}>
<Card title="MiniMax" href="/en/models/minimax">
MiniMax-M2.5 and other series models
MiniMax-M2.7 and other series models
</Card>
<Card title="GLM (Zhipu AI)" href="/en/models/glm">
glm-5, glm-4.7 and other series models
glm-5-turbo, glm-5 and other series models
</Card>
<Card title="Qwen (Tongyi Qianwen)" href="/en/models/qwen">
qwen3.5-plus, qwen3-max and more

View File

@@ -19,7 +19,7 @@ OpenAI-compatible configuration is also supported:
```json
{
"bot_type": "chatGPT",
"bot_type": "openai",
"model": "kimi-k2.5",
"open_ai_api_base": "https://api.moonshot.cn/v1",
"open_ai_api_key": "YOUR_API_KEY"

View File

@@ -5,22 +5,22 @@ description: MiniMax model configuration
```json
{
"model": "MiniMax-M2.5",
"model": "MiniMax-M2.7",
"minimax_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Options include `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2.1-lightning`, `MiniMax-M2`, etc. |
| `model` | Options include `MiniMax-M2.7`, `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2.1-lightning`, `MiniMax-M2`, etc. |
| `minimax_api_key` | Create at [MiniMax Console](https://platform.minimaxi.com/user-center/basic-information/interface-key) |
OpenAI-compatible configuration is also supported:
```json
{
"bot_type": "chatGPT",
"model": "MiniMax-M2.5",
"bot_type": "openai",
"model": "MiniMax-M2.7",
"open_ai_api_base": "https://api.minimaxi.com/v1",
"open_ai_api_key": "YOUR_API_KEY"
}

View File

@@ -16,4 +16,4 @@ description: OpenAI model configuration
| `model` | Matches the [model parameter](https://platform.openai.com/docs/models) of the OpenAI API. Supports o-series, gpt-5.4, gpt-5 series, gpt-4.1, etc. Recommended for Agent mode: `gpt-5.4` |
| `open_ai_api_key` | Create at [OpenAI Platform](https://platform.openai.com/api-keys) |
| `open_ai_api_base` | Optional. Change to use third-party proxy |
| `bot_type` | Not required for official OpenAI models. Set to `chatGPT` when using Claude or other non-OpenAI models via proxy |
| `bot_type` | Not required for official OpenAI models. Set to `openai` when using Claude or other non-OpenAI models via proxy |

View File

@@ -19,7 +19,7 @@ OpenAI-compatible configuration is also supported:
```json
{
"bot_type": "chatGPT",
"bot_type": "openai",
"model": "qwen3.5-plus",
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"open_ai_api_key": "YOUR_API_KEY"

View File

@@ -5,6 +5,8 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
| [2.0.5](/en/releases/v2.0.5) | 2026.04.01 | Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more |
| [2.0.4](/en/releases/v2.0.4) | 2026.03.22 | Personal WeChat channel, new model support, Japanese docs, script refactoring and bug fixes |
| [2.0.2](/en/releases/v2.0.2) | 2026.02.27 | Web Console upgrade, multi-channel concurrency, session persistence |
| [2.0.1](/en/releases/v2.0.1) | 2026.02.27 | Built-in Web Search tool, smart context management, multiple fixes |
| [2.0.0](/en/releases/v2.0.0) | 2026.02.03 | Full upgrade to AI super assistant |

View File

@@ -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)

View File

@@ -0,0 +1,77 @@
---
title: v2.0.5
description: CowAgent 2.0.5 - Cow CLI, Skill Hub open source, Browser tool, WeCom Bot QR scan, and more
---
## 🖥️ Cow CLI
New CLI command system for managing CowAgent from terminal and chat:
- **Terminal commands**: Run `cow <command>` for `start`, `stop`, `restart`, `update`, `status`, `logs`, etc.
- **Chat commands**: Type `/<command>` in conversation for `/help`, `/status`, `/config`, `/skill`, `/context`, `/logs`, `/version`, etc.
- **Web console**: Type `/` in the input box to open a slash command menu, with arrow-key input history
- **Windows support**: New PowerShell script `scripts/run.ps1` with `cow` command support
Docs: [Command Overview](https://docs.cowagent.ai/en/commands)
<img src="https://cdn.link-ai.tech/doc/20260401114549.png" width="750" />
## 🧩 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 <name>` in chat or `cow skill install <name>` 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)
<img src="https://cdn.link-ai.tech/doc/20260401110103.png" width="750" />
## 🌐 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)
<img src="https://cdn.link-ai.tech/doc/20260401115728.png" width="750" />
## 🤖 WeCom Bot QR Code Setup
WeCom Bot channel now supports QR code scan for one-click bot creation:
- **QR scan in Web console**: Select "Scan QR" mode, scan with WeCom to auto-create and connect a bot — no manual configuration needed
- **Manual mode**: Still supports manual Bot ID and Secret input
- **Stream push optimization**: Throttled push to avoid WebSocket congestion
Docs: [WeCom Bot](https://docs.cowagent.ai/en/channels/wecom-bot)
PR: [#2735](https://github.com/zhayujie/chatgpt-on-wechat/pull/2735). Thanks [@WecomTeam](https://github.com/WecomTeam)
## 🐛 Other Improvements & Fixes
- **DeepSeek module**: Independent DeepSeek Bot with dedicated `deepseek_api_key` config ([#2719](https://github.com/zhayujie/chatgpt-on-wechat/pull/2719)). Thanks [@6vision](https://github.com/6vision)
- **Web console**: Slash command menu, input history, new model options, mobile optimization ([#2731](https://github.com/zhayujie/chatgpt-on-wechat/pull/2731)). Thanks [@zkjqd](https://github.com/zkjqd)
- **Context loss**: Fix context loss after trimming ([393f0c0](https://github.com/zhayujie/chatgpt-on-wechat/commit/393f0c0))
- **System prompt**: Fix system prompt not rebuilding on every turn ([13f5fde](https://github.com/zhayujie/chatgpt-on-wechat/commit/13f5fde))
- **Gemini**: Fix missing model attribute in GoogleGeminiBot ([#2716](https://github.com/zhayujie/chatgpt-on-wechat/pull/2716)). Thanks [@cowagent](https://github.com/cowagent)
- **WeChat channel**: Fix file send failures and filename loss ([6d9b7ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/6d9b7ba), [45faa9c](https://github.com/zhayujie/chatgpt-on-wechat/commit/45faa9c))
- **Docker**: Fix volume permissions, reduce image size ([3eb8348](https://github.com/zhayujie/chatgpt-on-wechat/commit/3eb8348), [4470d4c](https://github.com/zhayujie/chatgpt-on-wechat/commit/4470d4c))
- **Security**: Fix Memory Content path traversal risk. Thanks [@August829](https://github.com/August829)
## 📦 Upgrade
Run `cow update` or `./run.sh update` to upgrade, or pull the latest code and restart. See [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade).
**Release Date**: 2026.04.01 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.4...master)

58
docs/en/skills/create.mdx Normal file
View File

@@ -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
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202202247.png" width="800" />
</Frame>
## 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) |
<Tip>
See the [Skill Creator documentation](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md) for details.
</Tip>

View File

@@ -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.
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202213219.png" width="800" />
</Frame>

View File

@@ -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

View File

@@ -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
```
<Tip>
All commands above work in the terminal by replacing `/skill` with `cow skill`. See [Skill Management Commands](/en/commands/skill) for full documentation.
</Tip>

View File

@@ -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.
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202234350.png" width="750" />
</Frame>

View File

@@ -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
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202202247.png" width="800" />
</Frame>
<Tip>
See the [Skill Creator documentation](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/skills/skill-creator/SKILL.md) for details.
</Tip>

Some files were not shown because too many files have changed in this diff Show More