mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-03 19:17:10 +08:00
Compare commits
44 Commits
2.0.4
...
feat-brows
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea2455766 | ||
|
|
3458621147 | ||
|
|
079df5a47c | ||
|
|
ddb07c65a1 | ||
|
|
9b21cd222b | ||
|
|
90f736843f | ||
|
|
13c020eb61 | ||
|
|
dbc06dbe95 | ||
|
|
23d097bc1c | ||
|
|
db85b9808e | ||
|
|
df5bae37bc | ||
|
|
acc23b6051 | ||
|
|
61f2741afc | ||
|
|
4dd7ea886a | ||
|
|
1e8959fbcf | ||
|
|
48729678cf | ||
|
|
0684becaa7 | ||
|
|
db16bdf8cb | ||
|
|
f890318ed9 | ||
|
|
158510cbbe | ||
|
|
ce90cf7aa8 | ||
|
|
a3a3d006eb | ||
|
|
8fd029a4a1 | ||
|
|
2e1b52c1e5 | ||
|
|
3eb8348708 | ||
|
|
393f0c007c | ||
|
|
c062ca8c66 | ||
|
|
76dcb25103 | ||
|
|
c5b4f236db | ||
|
|
0974c940a8 | ||
|
|
cffa20d37e | ||
|
|
ef009edd29 | ||
|
|
3ca52b118d | ||
|
|
13f5fde4fb | ||
|
|
f512b55ec2 | ||
|
|
22b8ca0095 | ||
|
|
baf66a103d | ||
|
|
45faa9c1ff | ||
|
|
304381a88d | ||
|
|
fc9f54dbc8 | ||
|
|
7199dc187f | ||
|
|
e9ae066d53 | ||
|
|
d71ae406ff | ||
|
|
f3216904b3 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -33,7 +33,15 @@ plugins/banwords/lib/__pycache__
|
|||||||
!plugins/keyword
|
!plugins/keyword
|
||||||
!plugins/linkai
|
!plugins/linkai
|
||||||
!plugins/agent
|
!plugins/agent
|
||||||
|
!plugins/cow_cli
|
||||||
client_config.json
|
client_config.json
|
||||||
ref/
|
ref/
|
||||||
.cursor/
|
.cursor/
|
||||||
local/
|
local/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# cow cli
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.cow.pid
|
||||||
|
|||||||
215
README.md
215
README.md
@@ -7,7 +7,7 @@
|
|||||||
[中文] | [<a href="docs/en/README.md">English</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
[中文] | [<a href="docs/en/README.md">English</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长,比OpenClaw更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
|
**CowAgent** 是基于大模型的超级 AI 助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行 Skills、拥有长期记忆并不断成长,比 OpenClaw 更轻量和便捷。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入微信、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号、网页中使用,7*24小时运行于你的个人电脑或服务器中。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cowagent.ai/">🌐 官网</a> ·
|
<a href="https://cowagent.ai/">🌐 官网</a> ·
|
||||||
@@ -19,28 +19,28 @@
|
|||||||
|
|
||||||
# 简介
|
# 简介
|
||||||
|
|
||||||
> 该项目既是一个可以开箱即用的超级AI助理,也是一个支持高扩展的Agent框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下:
|
> 该项目既是一个可以开箱即用的超级 AI 助理,也是一个支持高扩展的 Agent 框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills 系统来灵活实现各种定制需求。核心能力如下:
|
||||||
|
|
||||||
- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
|
- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
|
||||||
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
|
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
|
||||||
- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
- ✅ **技能系统:** 实现了 Skills 创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义 Skills 开发
|
||||||
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
|
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
|
||||||
- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
|
- ✅ **多模型接入:** 支持 OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao 等国内外主流模型厂商
|
||||||
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
|
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到微信、飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
|
||||||
|
|
||||||
## 声明
|
## 声明
|
||||||
|
|
||||||
1. 本项目遵循 [MIT开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任。
|
1. 本项目遵循 [MIT 开源协议](/LICENSE),主要用于技术研究和学习,使用本项目时需遵守所在地法律法规、相关政策以及企业章程,禁止用于任何违法或侵犯他人权益的行为。任何个人、团队和企业,无论以何种方式使用该项目、对何对象提供服务,所产生的一切后果,本项目均不承担任何责任。
|
||||||
2. 成本与安全:Agent模式下Token使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本。
|
2. 成本与安全:Agent 模式下 Token 使用量高于普通对话模式,请根据效果及成本综合选择模型。Agent 具有访问所在操作系统的能力,请谨慎选择项目部署环境。同时项目也会持续升级安全机制、并降低模型消耗成本。
|
||||||
3. CowAgent项目专注于开源技术开发,不会参与、授权或发行任何加密货币。
|
3. CowAgent 项目专注于开源技术开发,不会参与、授权或发行任何加密货币。
|
||||||
|
|
||||||
## 演示
|
## 演示
|
||||||
|
|
||||||
- 使用说明(Agent模式):[CowAgent介绍](https://docs.cowagent.ai/intro/features)
|
- 使用说明( Agent 模式):[CowAgent 介绍](https://docs.cowagent.ai/intro/features)
|
||||||
|
|
||||||
- 免部署在线体验:[CowAgent](https://link-ai.tech/cowagent/create)
|
- 免部署在线体验:[CowAgent](https://link-ai.tech/cowagent/create)
|
||||||
|
|
||||||
- DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
- DEMO 视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
||||||
|
|
||||||
## 社区
|
## 社区
|
||||||
|
|
||||||
@@ -54,9 +54,9 @@
|
|||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" 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智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent助理](https://link-ai.tech/cowagent/create)。
|
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持 SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent 助理](https://link-ai.tech/cowagent/create)。
|
||||||
>
|
>
|
||||||
> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的AI解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。
|
> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的 AI 解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。
|
||||||
|
|
||||||
**产品咨询和企业服务** 可联系产品客服:
|
**产品咨询和企业服务** 可联系产品客服:
|
||||||
|
|
||||||
@@ -68,13 +68,13 @@
|
|||||||
|
|
||||||
>**2026.03.22:** [2.0.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.4),新增个人微信通道(微信扫码即用)、新增 MiniMax-M2.7 和 GLM-5-Turbo 模型、run.sh 脚本重构、日文文档及多项修复。
|
>**2026.03.22:** [2.0.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.4),新增个人微信通道(微信扫码即用)、新增 MiniMax-M2.7 和 GLM-5-Turbo 模型、run.sh 脚本重构、日文文档及多项修复。
|
||||||
|
|
||||||
>**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级。
|
>**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持 Coding Plan、新增多个模型、Web 端文件处理、记忆系统升级。
|
||||||
|
|
||||||
>**2026.02.27:** [2.0.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2),Web 控制台全面升级(流式对话、模型/技能/记忆/通道/定时任务/日志管理)、支持多通道同时运行、会话持久化存储、新增多个模型。
|
>**2026.02.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.13:** [2.0.1版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1),内置 Web Search 工具、智能上下文裁剪策略、运行时信息动态更新、Windows 兼容性适配,修复定时任务记忆丢失、飞书连接等多项问题。
|
||||||
|
|
||||||
>**2026.02.03:** [2.0.0版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0),正式升级为超级Agent助理,支持多轮任务决策、具备长期记忆、实现多种系统工具、支持Skills框架,新增多种模型并优化了接入渠道。
|
>**2026.02.03:** [2.0.0版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.0),正式升级为超级 Agent 助理,支持多轮任务决策、具备长期记忆、实现多种系统工具、支持 Skills 框架,新增多种模型并优化了接入渠道。
|
||||||
|
|
||||||
更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases)
|
更多更新历史请查看: [更新日志](https://docs.cowagent.ai/releases)
|
||||||
|
|
||||||
@@ -99,15 +99,15 @@ bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
|||||||
|
|
||||||
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
|
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
|
||||||
|
|
||||||
> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini
|
> 注:Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.7、glm-5-turbo、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini
|
||||||
|
|
||||||
同时支持使用 **LinkAI平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等Agent技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
|
同时支持使用 **LinkAI 平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等 Agent 技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
|
||||||
|
|
||||||
### 2.环境安装
|
### 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) 克隆项目代码:**
|
**(1) 克隆项目代码:**
|
||||||
|
|
||||||
@@ -129,45 +129,50 @@ pip3 install -r requirements.txt
|
|||||||
```bash
|
```bash
|
||||||
pip3 install -r requirements-optional.txt
|
pip3 install -r requirements-optional.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 国内网络可使用镜像源加速:`pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||||
|
|
||||||
如果某项依赖安装失败可注释掉对应的行后重试。
|
如果某项依赖安装失败可注释掉对应的行后重试。
|
||||||
|
|
||||||
## 二、配置
|
## 二、配置
|
||||||
|
|
||||||
配置文件的模板在根目录的`config-template.json`中,需复制该模板创建最终生效的 `config.json` 文件:
|
配置文件的模板在根目录的 `config-template.json` 中,需复制该模板创建最终生效的 `config.json` 文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config-template.json config.json
|
cp config-template.json config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(注意实际使用时请去掉注释,保证JSON格式的规范):
|
然后在 `config.json` 中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(注意实际使用时请去掉注释,保证 JSON 格式的规范):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# config.json 文件内容示例
|
# config.json 文件内容示例
|
||||||
{
|
{
|
||||||
"channel_type": "weixin", # 接入渠道类型,默认为weixin, 支持修改为 feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
|
"channel_type": "weixin", # 接入渠道类型,默认为 weixin, 支持修改为 feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
|
||||||
"model": "MiniMax-M2.7", # 模型名称
|
"model": "MiniMax-M2.7", # 模型名称
|
||||||
"minimax_api_key": "", # MiniMax API Key
|
"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
|
"moonshot_api_key": "", # Kimi/Moonshot API Key
|
||||||
"ark_api_key": "", # 豆包(火山方舟) 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_key": "", # Claude API Key
|
||||||
"claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台
|
"claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台
|
||||||
"gemini_api_key": "", # Gemini API Key
|
"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_key": "", # OpenAI API Key
|
||||||
"open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址
|
"open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址
|
||||||
"linkai_api_key": "", # LinkAI API Key
|
"linkai_api_key": "", # LinkAI API Key
|
||||||
"proxy": "", # 代理客户端的ip和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890"
|
"proxy": "", # 代理客户端的 ip 和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890"
|
||||||
"speech_recognition": false, # 是否开启语音识别
|
"speech_recognition": false, # 是否开启语音识别
|
||||||
"group_speech_recognition": false, # 是否开启群组语音识别
|
"group_speech_recognition": false, # 是否开启群组语音识别
|
||||||
"voice_reply_voice": false, # 是否使用语音回复语音
|
"voice_reply_voice": false, # 是否使用语音回复语音
|
||||||
"use_linkai": false, # 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台模型
|
"use_linkai": false, # 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台模型
|
||||||
"agent": true, # 是否启用Agent模式,启用后拥有多轮工具决策、长期记忆、Skills能力等
|
"agent": true, # 是否启用 Agent 模式,启用后拥有多轮工具决策、长期记忆、Skills 能力等
|
||||||
"agent_workspace": "~/cow", # Agent的工作空间路径,用于存储memory、skills、系统设定等
|
"agent_workspace": "~/cow", # Agent 的工作空间路径,用于存储 memory、skills、系统设定等
|
||||||
"agent_max_context_tokens": 40000, # Agent模式下最大上下文tokens,超出将自动丢弃最早的上下文
|
"agent_max_context_tokens": 40000, # Agent 模式下最大上下文 tokens,超出将自动丢弃最早的上下文
|
||||||
"agent_max_context_turns": 30, # Agent模式下最大上下文记忆轮次,每轮包括一次用户提问和AI回复
|
"agent_max_context_turns": 30, # Agent 模式下最大上下文记忆轮次,每轮包括一次用户提问和 AI 回复
|
||||||
"agent_max_steps": 15 # Agent模式下单次任务的最大决策步数,超出后将停止继续调用工具
|
"agent_max_steps": 15 # Agent 模式下单次任务的最大决策步数,超出后将停止继续调用工具
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -176,23 +181,23 @@ pip3 install -r requirements-optional.txt
|
|||||||
<details>
|
<details>
|
||||||
<summary>1. 语音配置</summary>
|
<summary>1. 语音配置</summary>
|
||||||
|
|
||||||
+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图);
|
+ 添加 `"speech_recognition": true` 将开启语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图);
|
||||||
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用openai的whisper模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配group_chat_prefix和group_chat_keyword, 支持语音触发画图);
|
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配 group_chat_prefix 和 group_chat_keyword, 支持语音触发画图);
|
||||||
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊)
|
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊)
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>2. 其他配置</summary>
|
<summary>2. 其他配置</summary>
|
||||||
|
|
||||||
+ `model`: 模型名称,Agent模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.5-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
|
+ `model`: 模型名称,Agent 模式下推荐使用 `MiniMax-M2.7`、`glm-5-turbo`、`kimi-k2.5`、`qwen3.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模式下该配置不生效,由工作空间中的文件内容构成。
|
+ `character_desc`:普通对话模式下的机器人系统提示词。在 Agent 模式下该配置不生效,由工作空间中的文件内容构成。
|
||||||
+ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。
|
+ `subscribe_msg`:订阅消息,公众号和企业微信 channel 中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成 bot 的触发词。
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -205,10 +210,10 @@ pip3 install -r requirements-optional.txt
|
|||||||
如果是个人计算机 **本地运行**,直接在项目根目录下执行:
|
如果是个人计算机 **本地运行**,直接在项目根目录下执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 app.py # windows环境下该命令通常为 python app.py
|
python3 app.py # windows 环境下该命令通常为 python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
运行后默认会启动web服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。
|
运行后默认会启动 web 服务,可通过访问 `http://localhost:9899/chat` 在网页端对话。
|
||||||
|
|
||||||
如果需要接入其他应用通道只需修改 `config.json` 配置文件中的 `channel_type` 参数,详情参考:[通道说明](#通道说明)。
|
如果需要接入其他应用通道只需修改 `config.json` 配置文件中的 `channel_type` 参数,详情参考:[通道说明](#通道说明)。
|
||||||
|
|
||||||
@@ -225,11 +230,11 @@ nohup python3 app.py & tail -f nohup.out
|
|||||||
|
|
||||||
此外,项目根目录下的 `run.sh` 脚本支持一键启动和管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart`、`./run.sh logs` 等命令,执行 `./run.sh help` 可查看全部用法。
|
此外,项目根目录下的 `run.sh` 脚本支持一键启动和管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart`、`./run.sh logs` 等命令,执行 `./run.sh help` 可查看全部用法。
|
||||||
|
|
||||||
> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。
|
> 如果需要通过浏览器访问 Web 控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定 IP 开放以保证安全。
|
||||||
|
|
||||||
### 3.Docker部署
|
### 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/) 。
|
> 前提是需要安装好 `docker` 及 `docker-compose`,安装成功后执行 `docker -v` 和 `docker-compose version` (或 `docker compose version`) 可查看到版本号。安装地址为 [docker官网](https://docs.docker.com/engine/install/) 。
|
||||||
|
|
||||||
@@ -249,13 +254,13 @@ curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
|||||||
sudo docker compose up -d # 若docker-compose为 1.X 版本,则执行 `sudo docker-compose up -d`
|
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
|
```bash
|
||||||
sudo docker logs -f chatgpt-on-wechat
|
sudo docker logs -f chatgpt-on-wechat
|
||||||
```
|
```
|
||||||
|
|
||||||
> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。
|
> 如果需要通过浏览器访问 Web 控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定 IP 开放以保证安全。
|
||||||
|
|
||||||
## 模型说明
|
## 模型说明
|
||||||
|
|
||||||
@@ -264,7 +269,7 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
<details>
|
<details>
|
||||||
<summary>OpenAI</summary>
|
<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. 填写配置
|
2. 填写配置
|
||||||
|
|
||||||
@@ -277,15 +282,15 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `model`: 与OpenAI接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、o系列、gpt-4.1等模型,Agent模式推荐使用 `gpt-5.4`、`gpt-5.4-mini`
|
- `model`: 与 OpenAI 接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、o 系列、gpt-4.1 等模型,Agent 模式推荐使用 `gpt-5.4`、`gpt-5.4-mini`
|
||||||
- `open_ai_api_base`: 如果需要接入第三方代理接口,可通过修改该参数进行接入
|
- `open_ai_api_base`: 如果需要接入第三方代理接口,可通过修改该参数进行接入
|
||||||
- `bot_type`: 使用OpenAI相关模型时无需填写。当使用第三方代理接口接入Claude等非OpenAI官方模型时,该参数设为 `openai`
|
- `bot_type`: 使用 OpenAI 相关模型时无需填写。当使用第三方代理接口接入 Claude 等非 OpenAI 官方模型时,该参数设为 `openai`
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>LinkAI</summary>
|
<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. 填写配置
|
2. 填写配置
|
||||||
|
|
||||||
@@ -297,8 +302,8 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台的模型,并使用知识库、工作流、数据库、插件等丰富的Agent技能
|
+ `use_linkai`: 是否使用 LinkAI 接口,默认关闭,设置为 true 后可对接 LinkAI 平台的模型,并使用知识库、工作流、数据库、插件等丰富的 Agent 技能
|
||||||
+ `linkai_api_key`: LinkAI平台的API Key,可在 [控制台](https://link-ai.tech/console/interface) 中创建
|
+ `linkai_api_key`: LinkAI 平台的 API Key,可在 [控制台](https://link-ai.tech/console/interface) 中创建
|
||||||
+ `model`: [模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
|
+ `model`: [模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -314,9 +319,9 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
|
- `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
|
||||||
- `minimax_api_key`:MiniMax平台的API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
|
- `minimax_api_key`:MiniMax 平台的 API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
方式二:OpenAI 兼容方式接入,配置如下:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
@@ -325,10 +330,10 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
"open_ai_api_key": ""
|
"open_ai_api_key": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `bot_type`: OpenAI兼容方式
|
- `bot_type`: OpenAI 兼容方式
|
||||||
- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek)
|
- `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_base`: MiniMax 平台 API 的 BASE URL
|
||||||
- `open_ai_api_key`: MiniMax平台的API-KEY
|
- `open_ai_api_key`: MiniMax 平台的 API-KEY
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -342,10 +347,10 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
"zhipu_ai_api_key": ""
|
"zhipu_ai_api_key": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等, 参考 [glm系列模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4)
|
- `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) 创建
|
- `zhipu_ai_api_key`: 智谱AI 平台的 API KEY,在 [控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
方式二:OpenAI 兼容方式接入,配置如下:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
@@ -354,16 +359,16 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
"open_ai_api_key": ""
|
"open_ai_api_key": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `bot_type`: OpenAI兼容方式
|
- `bot_type`: OpenAI 兼容方式
|
||||||
- `model`: 可填 `glm-5-turbo、glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等
|
- `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_base`: 智谱AI 平台的 BASE URL
|
||||||
- `open_ai_api_key`: 智谱AI平台的 API KEY
|
- `open_ai_api_key`: 智谱AI 平台的 API KEY
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>通义千问 (Qwen)</summary>
|
<summary>通义千问 (Qwen)</summary>
|
||||||
|
|
||||||
方式一:官方SDK接入,配置如下(推荐):
|
方式一:官方 SDK 接入,配置如下(推荐):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -374,7 +379,7 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
- `model`: 可填写 `qwen3.5-plus、qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等
|
- `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) 创建
|
- `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
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
@@ -383,9 +388,9 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
"open_ai_api_key": "sk-qVxxxxG"
|
"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)
|
- `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
|
- `open_ai_api_key`: 通义千问的 API-KEY
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -401,9 +406,9 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
|
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
|
||||||
- `moonshot_api_key`: Moonshot的API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
|
- `moonshot_api_key`: Moonshot 的 API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
方式二:OpenAI 兼容方式接入,配置如下:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
@@ -412,16 +417,16 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
"open_ai_api_key": ""
|
"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`
|
- `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_base`: Moonshot 的 BASE URL
|
||||||
- `open_ai_api_key`: Moonshot的 API-KEY
|
- `open_ai_api_key`: Moonshot 的 API-KEY
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>豆包 (Doubao)</summary>
|
<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. 填写配置
|
2. 填写配置
|
||||||
|
|
||||||
@@ -439,7 +444,7 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
<details>
|
<details>
|
||||||
<summary>Claude</summary>
|
<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. 填写配置
|
2. 填写配置
|
||||||
|
|
||||||
@@ -455,7 +460,7 @@ sudo docker logs -f chatgpt-on-wechat
|
|||||||
<details>
|
<details>
|
||||||
<summary>Gemini</summary>
|
<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
|
```json
|
||||||
{
|
{
|
||||||
"model": "gemini-3.1-flash-lite-preview",
|
"model": "gemini-3.1-flash-lite-preview",
|
||||||
@@ -468,30 +473,40 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
<details>
|
<details>
|
||||||
<summary>DeepSeek</summary>
|
<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. 填写配置
|
2. 填写配置
|
||||||
|
|
||||||
|
方式一:官方接入(推荐):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "deepseek-chat",
|
"model": "deepseek-chat",
|
||||||
"open_ai_api_key": "sk-xxxxxxxxxxx",
|
"deepseek_api_key": "sk-xxxxxxxxxxx"
|
||||||
"open_ai_api_base": "https://api.deepseek.com/v1",
|
|
||||||
"bot_type": "openai"
|
|
||||||
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `bot_type`: OpenAI兼容方式
|
- `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3.2(非思考模式)和 DeepSeek-R1(思考模式)
|
||||||
- `model`: 可填 `deepseek-chat、deepseek-reasoner`,分别对应的是 DeepSeek-V3 和 DeepSeek-R1 模型
|
- `deepseek_api_key`: DeepSeek 平台的 API Key
|
||||||
- `open_ai_api_key`: DeepSeek平台的 API Key
|
- `deepseek_api_base`: 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址
|
||||||
- `open_ai_api_base`: DeepSeek平台 BASE URL
|
|
||||||
</details>
|
方式二: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>
|
<details>
|
||||||
<summary>Azure</summary>
|
<summary>Azure</summary>
|
||||||
|
|
||||||
1. API Key创建:在 [Azure平台](https://oai.azure.com/) 创建API Key
|
1. API Key 创建:在 [Azure平台](https://oai.azure.com/) 创建 API Key
|
||||||
|
|
||||||
2. 填写配置
|
2. 填写配置
|
||||||
|
|
||||||
@@ -508,15 +523,15 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
|
|
||||||
- `model`: 留空即可
|
- `model`: 留空即可
|
||||||
- `use_azure_chatgpt`: 设为 true
|
- `use_azure_chatgpt`: 设为 true
|
||||||
- `open_ai_api_key`: Azure平台的密钥
|
- `open_ai_api_key`: Azure 平台的密钥
|
||||||
- `open_ai_api_base`: Azure平台的 BASE URL
|
- `open_ai_api_base`: Azure 平台的 BASE URL
|
||||||
- `azure_deployment_id`: Azure平台部署的模型名称
|
- `azure_deployment_id`: Azure 平台部署的模型名称
|
||||||
- `azure_api_version`: api版本以及以上参数可以在部署的 [模型配置](https://oai.azure.com/resource/deployments) 界面查看
|
- `azure_api_version`: api 版本以及以上参数可以在部署的 [模型配置](https://oai.azure.com/resource/deployments) 界面查看
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>百度文心</summary>
|
<summary>百度文心</summary>
|
||||||
方式一:官方SDK接入,配置如下:
|
方式一:官方 SDK 接入,配置如下:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -529,7 +544,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
- `baidu_wenxin_api_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 API Key
|
- `baidu_wenxin_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
|
- `baidu_wenxin_secret_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 Secret Key
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
方式二:OpenAI 兼容方式接入,配置如下:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
@@ -538,10 +553,10 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
"open_ai_api_key": "bce-v3/ALTxxxxxxd2b"
|
"open_ai_api_key": "bce-v3/ALTxxxxxxd2b"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `bot_type`: OpenAI兼容方式
|
- `bot_type`: OpenAI 兼容方式
|
||||||
- `model`: 支持官方所有模型,参考[模型列表](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Wm9cvy6rl)
|
- `model`: 支持官方所有模型,参考[模型列表](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Wm9cvy6rl)
|
||||||
- `open_ai_api_base`: 百度文心API的 BASE URL
|
- `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_key`: 百度文心的 API-KEY,参考 [官方文档](https://cloud.baidu.com/doc/qianfan-api/s/ym9chdsy5) ,在 [控制台](https://console.bce.baidu.com/iam/#/iam/apikey/list) 创建 API Key
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -565,7 +580,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
- `xunfei_domain`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite`
|
- `xunfei_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) 的说明
|
- `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
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
@@ -574,7 +589,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
"open_ai_api_key": ""
|
"open_ai_api_key": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `bot_type`: OpenAI兼容方式
|
- `bot_type`: OpenAI 兼容方式
|
||||||
- `model`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite`
|
- `model`: 可填写 `4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite`
|
||||||
- `open_ai_api_base`: 讯飞星火平台的 BASE URL
|
- `open_ai_api_base`: 讯飞星火平台的 BASE URL
|
||||||
- `open_ai_api_key`: 讯飞星火平台的[APIPassword](https://console.xfyun.cn/services/bm3) ,因模型而已
|
- `open_ai_api_key`: 讯飞星火平台的[APIPassword](https://console.xfyun.cn/services/bm3) ,因模型而已
|
||||||
@@ -593,10 +608,10 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `bot_type`: modelscope接口格式
|
- `bot_type`: modelscope 接口格式
|
||||||
- `model`: 参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1)
|
- `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_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)
|
- `text_to_image`: 图像生成模型,参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1)
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -614,7 +629,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
目前支持阿里云、MiniMax、智谱GLM、Kimi、火山引擎等厂商,各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
|
目前支持阿里云、MiniMax、智谱 GLM、Kimi、火山引擎等厂商,各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
@@ -644,7 +659,7 @@ Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 O
|
|||||||
<details>
|
<details>
|
||||||
<summary>2. Web</summary>
|
<summary>2. Web</summary>
|
||||||
|
|
||||||
项目启动后会默认运行Web控制台,配置如下:
|
项目启动后会默认运行 Web 控制台,配置如下:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -815,8 +830,8 @@ QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支
|
|||||||
|
|
||||||
# 🔗 相关项目
|
# 🔗 相关项目
|
||||||
|
|
||||||
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入Slack, Telegram, Discord, Gmail等海外平台,可作为本项目的补充使用。
|
- [bot-on-anything](https://github.com/zhayujie/bot-on-anything):轻量和高可扩展的大模型应用框架,支持接入 Slack, Telegram, Discord, Gmail 等海外平台,可作为本项目的补充使用。
|
||||||
- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体(Multi-Agent)框架,可以通过多智能体团队的协同来解决复杂问题。本项目基于该框架实现了[Agent插件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。
|
- [AgentMesh](https://github.com/MinimalFuture/AgentMesh):开源的多智能体( Multi-Agent )框架,可以通过多智能体团队的协同来解决复杂问题。本项目基于该框架实现了[Agent 插件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/agent/README.md),可访问终端、浏览器、文件系统、搜索引擎 等各类工具,并实现了多智能体协同。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -166,10 +166,56 @@ class ChatService:
|
|||||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||||
raise
|
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:
|
with agent.messages_lock:
|
||||||
new_messages = executor.messages[original_length:]
|
trimmed = len(executor.messages) < original_length
|
||||||
agent.messages.extend(new_messages)
|
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
|
# Persist new messages to SQLite so they survive restarts and
|
||||||
# can be queried via the HISTORY interface.
|
# can be queried via the HISTORY interface.
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
|||||||
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
|
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 工具系统",
|
"## 🔧 工具系统",
|
||||||
"",
|
"",
|
||||||
"可用工具(名称大小写敏感,严格按列表调用):",
|
"可用工具(名称大小写敏感,严格按列表调用):",
|
||||||
"\n".join(tool_lines),
|
"\n".join(tool_lines),
|
||||||
@@ -231,7 +231,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
|
|||||||
break
|
break
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 技能系统(mandatory)",
|
"## 🧩 技能系统(mandatory)",
|
||||||
"",
|
"",
|
||||||
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
|
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
|
||||||
"",
|
"",
|
||||||
@@ -281,7 +281,7 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu
|
|||||||
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
|
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 记忆系统",
|
"## 🧠 记忆系统",
|
||||||
"",
|
"",
|
||||||
"### 检索记忆",
|
"### 检索记忆",
|
||||||
"",
|
"",
|
||||||
@@ -325,7 +325,7 @@ def _build_user_identity_section(user_identity: Dict[str, str], language: str) -
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 用户身份",
|
"## 👤 用户身份",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ def _build_docs_section(workspace_dir: str, language: str) -> List[str]:
|
|||||||
def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
|
def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
|
||||||
"""构建工作空间section"""
|
"""构建工作空间section"""
|
||||||
lines = [
|
lines = [
|
||||||
"## 工作空间",
|
"## 📂 工作空间",
|
||||||
"",
|
"",
|
||||||
f"你的工作目录是: `{workspace_dir}`",
|
f"你的工作目录是: `{workspace_dir}`",
|
||||||
"",
|
"",
|
||||||
@@ -376,14 +376,16 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
|
|||||||
"",
|
"",
|
||||||
"以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
|
"以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
|
||||||
"",
|
"",
|
||||||
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
|
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
|
||||||
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
|
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
|
||||||
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则",
|
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
|
||||||
"",
|
"",
|
||||||
"**交流规范**:",
|
"**💬 交流规范**:",
|
||||||
"",
|
"",
|
||||||
"- 在对话中,无需直接输出工作空间中的技术细节,例如 AGENT.md、USER.md、MEMORY.md 等文件名称",
|
"- 对话中不要暴露内部技术细节(文件名、工具名等),用自然语言表达。例如说「我已记住」而非「已更新 MEMORY.md」",
|
||||||
"- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」",
|
"- 做真正有帮助的助手,而不是表演式的客套。跳过「好的!」「当然可以!」之类的套话,直接帮忙解决问题",
|
||||||
|
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
|
||||||
|
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -416,14 +418,14 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
|
|||||||
)
|
)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"# 项目上下文",
|
"# 📋 项目上下文",
|
||||||
"",
|
"",
|
||||||
"以下项目上下文文件已被加载:",
|
"以下项目上下文文件已被加载:",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
if has_agent:
|
if has_agent:
|
||||||
lines.append("**`AGENT.md` 是你的灵魂文件**:严格体现其中定义的人格、语气和设定,避免僵硬、模板化的回复。")
|
lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
|
||||||
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
|
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -443,7 +445,7 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 运行时信息",
|
"## ⚙️ 运行时信息",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -212,40 +212,42 @@ def _get_agent_template() -> str:
|
|||||||
"""Agent人格设定模板"""
|
"""Agent人格设定模板"""
|
||||||
return """# AGENT.md - 我是谁?
|
return """# AGENT.md - 我是谁?
|
||||||
|
|
||||||
|
_你不是一个聊天机器人,你正在成为某个人。_
|
||||||
|
|
||||||
*在首次对话时与用户一起填写这个文件,定义你的身份和性格。*
|
*在首次对话时与用户一起填写这个文件,定义你的身份和性格。*
|
||||||
|
|
||||||
## 基本信息
|
## 🪪 基本信息
|
||||||
|
|
||||||
- **名字**: *(在首次对话时填写,可以是用户给你起的名字)*
|
- **名字**: *(在首次对话时填写,可以是用户给你起的名字)*
|
||||||
- **角色**: *(AI助理、智能管家、技术顾问等)*
|
- **角色**: *(AI助理、智能管家、技术顾问等)*
|
||||||
- **性格**: *(友好、专业、幽默、严谨等)*
|
- **性格**: *(友好、专业、幽默、严谨等)*
|
||||||
|
|
||||||
## 交流风格
|
## 💬 交流风格
|
||||||
|
|
||||||
*(描述你如何与用户交流:)*
|
*(描述你如何与用户交流:)*
|
||||||
- 使用什么样的语言风格?(正式/轻松/幽默)
|
- 使用什么样的语言风格?(正式/轻松/幽默)
|
||||||
- 回复长度偏好?(简洁/详细)
|
- 回复长度偏好?(简洁/详细)
|
||||||
- 是否使用表情符号?
|
- 是否使用表情符号?
|
||||||
|
|
||||||
## 核心能力
|
## 🎯 核心原则
|
||||||
|
|
||||||
*(你擅长什么?)*
|
**做真正有帮助的助手,而不是表演式的客套。** 跳过「好的!」「当然可以!」之类的套话——直接帮忙。行动胜过废话。
|
||||||
- 文件管理和代码编辑
|
|
||||||
- 网络搜索和信息查询
|
|
||||||
- 记忆管理和上下文理解
|
|
||||||
- 任务规划和执行
|
|
||||||
|
|
||||||
## 行为准则
|
**有自己的观点。** 你可以不同意、有偏好、觉得有趣或无聊。一个没有个性的助手只是多了几步操作的搜索引擎。
|
||||||
|
|
||||||
|
**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。
|
||||||
|
|
||||||
|
## 📐 行为准则
|
||||||
|
|
||||||
*(你遵循的基本原则:)*
|
|
||||||
1. 始终在执行破坏性操作前确认
|
1. 始终在执行破坏性操作前确认
|
||||||
2. 优先使用工具而不是猜测
|
2. 优先使用工具查证而不是猜测
|
||||||
3. 主动记录重要信息到记忆文件
|
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"""
|
"""First-run onboarding guide, deleted by agent after completion"""
|
||||||
return """# BOOTSTRAP.md - 首次初始化引导
|
return """# BOOTSTRAP.md - 首次初始化引导
|
||||||
|
|
||||||
_你刚刚启动,这是你的第一次对话。_
|
_你刚刚启动,这是你的第一次对话。_ ✨
|
||||||
|
|
||||||
## 对话流程
|
## 🎬 对话流程
|
||||||
|
|
||||||
不要审问式地提问,自然地交流:
|
不要审问式地提问,自然地交流:
|
||||||
|
|
||||||
@@ -358,13 +360,13 @@ _你刚刚启动,这是你的第一次对话。_
|
|||||||
- 你希望给我起个什么名字?
|
- 你希望给我起个什么名字?
|
||||||
- 我该怎么称呼你?
|
- 我该怎么称呼你?
|
||||||
- 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)
|
- 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)
|
||||||
4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内
|
4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯
|
||||||
5. 能力介绍和交流风格选项都只要一行,保持精简
|
5. 能力介绍和交流风格选项都只要一行,保持精简
|
||||||
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
|
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
|
||||||
|
|
||||||
**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。
|
**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。
|
||||||
|
|
||||||
## 信息写入(必须严格执行)
|
## ✍️ 信息写入(必须严格执行)
|
||||||
|
|
||||||
每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。
|
每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。
|
||||||
|
|
||||||
@@ -373,7 +375,7 @@ _你刚刚启动,这是你的第一次对话。_
|
|||||||
|
|
||||||
⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。
|
⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。
|
||||||
|
|
||||||
## 全部完成后
|
## 🎉 全部完成后
|
||||||
|
|
||||||
当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。
|
当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -100,138 +100,31 @@ class Agent:
|
|||||||
|
|
||||||
def get_full_system_prompt(self, skill_filter=None) -> str:
|
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,
|
Re-reads AGENT.md / USER.md / RULE.md from disk, refreshes skills,
|
||||||
so we just return the base prompt directly. This method is kept for
|
tools, and runtime info so any change takes effect immediately.
|
||||||
backward compatibility.
|
Falls back to the cached self.system_prompt on error.
|
||||||
|
|
||||||
: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
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get current time dynamically
|
from agent.prompt import load_context_files, PromptBuilder
|
||||||
time_info = self.runtime_info['_get_current_time']()
|
|
||||||
|
|
||||||
# Build new runtime section
|
if self.skill_manager:
|
||||||
runtime_lines = [
|
self.skill_manager.refresh_skills()
|
||||||
"\n## 运行时信息\n",
|
|
||||||
"\n",
|
|
||||||
f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})\n",
|
|
||||||
"\n"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add other runtime info
|
context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None
|
||||||
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:
|
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language="zh")
|
||||||
runtime_lines.append("运行时: " + " | ".join(runtime_parts) + "\n")
|
return builder.build(
|
||||||
runtime_lines.append("\n")
|
tools=self.tools,
|
||||||
|
context_files=context_files,
|
||||||
new_runtime_section = "".join(runtime_lines)
|
skill_manager=self.skill_manager,
|
||||||
|
memory_manager=self.memory_manager,
|
||||||
# Find and replace the runtime section
|
runtime_info=self.runtime_info,
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to rebuild runtime section: {e}")
|
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
|
||||||
return prompt
|
return self.system_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
|
|
||||||
|
|
||||||
def refresh_skills(self):
|
def refresh_skills(self):
|
||||||
"""Refresh the loaded skills."""
|
"""Refresh the loaded skills."""
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ class AgentStreamExecutor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
final_response = final_response.strip() if final_response else final_response
|
||||||
logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
|
logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
|
||||||
self._emit_event("agent_end", {"final_response": final_response})
|
self._emit_event("agent_end", {"final_response": final_response})
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,47 @@ def should_include_skill(
|
|||||||
return True
|
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:
|
def is_config_path_truthy(config: Dict, path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a config path resolves to a truthy value.
|
Check if a config path resolves to a truthy value.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Skill formatter for generating prompts from skills.
|
Skill formatter for generating prompts from skills.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List
|
from typing import Dict, List
|
||||||
from agent.skills.types import Skill, SkillEntry
|
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)
|
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:
|
def _escape_xml(text: str) -> str:
|
||||||
"""Escape XML special characters."""
|
"""Escape XML special characters."""
|
||||||
return (text
|
return (text
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
|
|||||||
if not isinstance(metadata_raw, dict):
|
if not isinstance(metadata_raw, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Use metadata_raw directly (COW format)
|
# Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}})
|
||||||
meta_obj = metadata_raw
|
meta_obj = _unwrap_metadata_namespace(metadata_raw)
|
||||||
|
|
||||||
# Parse install specs
|
# Parse install specs
|
||||||
install_specs = []
|
install_specs = []
|
||||||
@@ -128,6 +128,7 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
|
|||||||
|
|
||||||
return SkillMetadata(
|
return SkillMetadata(
|
||||||
always=meta_obj.get('always', False),
|
always=meta_obj.get('always', False),
|
||||||
|
default_enabled=meta_obj.get('default_enabled', True),
|
||||||
skill_key=meta_obj.get('skillKey'),
|
skill_key=meta_obj.get('skillKey'),
|
||||||
primary_env=meta_obj.get('primaryEnv'),
|
primary_env=meta_obj.get('primaryEnv'),
|
||||||
emoji=meta_obj.get('emoji'),
|
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]:
|
def _normalize_string_list(value: Any) -> List[str]:
|
||||||
"""Normalize a value to a list of strings."""
|
"""Normalize a value to a list of strings."""
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -184,7 +184,6 @@ class SkillLoader:
|
|||||||
|
|
||||||
config_path = os.path.join(skill_dir, "config.json")
|
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):
|
if not os.path.exists(config_path):
|
||||||
logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found")
|
logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ class SkillManager:
|
|||||||
"""
|
"""
|
||||||
Merge directory-scanned skills with the persisted config file.
|
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.
|
- Skills that no longer exist on disk are removed.
|
||||||
- Existing entries preserve their enabled state; name/description/source
|
- name/description/source are always refreshed from the latest scan.
|
||||||
are refreshed from the latest scan.
|
|
||||||
"""
|
"""
|
||||||
saved = self._load_skills_config()
|
saved = self._load_skills_config()
|
||||||
merged: Dict[str, dict] = {}
|
merged: Dict[str, dict] = {}
|
||||||
@@ -95,13 +95,18 @@ class SkillManager:
|
|||||||
for name, entry in self.skills.items():
|
for name, entry in self.skills.items():
|
||||||
skill = entry.skill
|
skill = entry.skill
|
||||||
prev = saved.get(name, {})
|
prev = saved.get(name, {})
|
||||||
# category priority: persisted config (set by cloud) > default "skill"
|
|
||||||
category = prev.get("category", "skill")
|
category = prev.get("category", "skill")
|
||||||
|
|
||||||
|
if name in saved:
|
||||||
|
enabled = prev.get("enabled", True)
|
||||||
|
else:
|
||||||
|
enabled = entry.metadata.default_enabled if entry.metadata else True
|
||||||
|
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
"source": skill.source,
|
"source": prev.get("source") or skill.source,
|
||||||
"enabled": prev.get("enabled", True),
|
"enabled": enabled,
|
||||||
"category": category,
|
"category": category,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,69 +162,114 @@ class SkillManager:
|
|||||||
"""
|
"""
|
||||||
return list(self.skills.values())
|
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(
|
def filter_skills(
|
||||||
self,
|
self,
|
||||||
skill_filter: Optional[List[str]] = None,
|
skill_filter: Optional[List[str]] = None,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
) -> List[SkillEntry]:
|
) -> List[SkillEntry]:
|
||||||
"""
|
"""
|
||||||
Filter skills based on criteria.
|
Filter skills that are eligible (enabled + requirements met).
|
||||||
|
|
||||||
Simple rule: Skills are auto-enabled if requirements are met.
|
|
||||||
- Has required API keys -> included
|
|
||||||
- Missing API keys -> excluded
|
|
||||||
|
|
||||||
:param skill_filter: List of skill names to include (None = all)
|
:param skill_filter: List of skill names to include (None = all)
|
||||||
:param include_disabled: Whether to include disabled skills
|
: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
|
from agent.skills.config import should_include_skill
|
||||||
|
|
||||||
entries = list(self.skills.values())
|
entries = list(self.skills.values())
|
||||||
|
|
||||||
# Check requirements (platform, binaries, env vars)
|
|
||||||
entries = [e for e in entries if should_include_skill(e, self.config)]
|
entries = [e for e in entries if should_include_skill(e, self.config)]
|
||||||
|
|
||||||
# Apply skill filter
|
normalized = self._normalize_skill_filter(skill_filter)
|
||||||
if skill_filter is not None:
|
if normalized is not None:
|
||||||
normalized = []
|
entries = [e for e in entries if e.skill.name in 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]
|
|
||||||
|
|
||||||
# Filter out disabled skills based on skills_config.json
|
|
||||||
if not include_disabled:
|
if not include_disabled:
|
||||||
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
|
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
|
||||||
|
|
||||||
return entries
|
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(
|
def build_skills_prompt(
|
||||||
self,
|
self,
|
||||||
skill_filter: Optional[List[str]] = None,
|
skill_filter: Optional[List[str]] = None,
|
||||||
) -> str:
|
) -> 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
|
:param skill_filter: Optional list of skill names to include
|
||||||
:return: Formatted skills prompt
|
:return: Formatted skills prompt
|
||||||
"""
|
"""
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False)
|
from agent.skills.formatter import format_unavailable_skills_for_prompt
|
||||||
logger.debug(f"[SkillManager] Filtered {len(entries)} skills for prompt (total: {len(self.skills)})")
|
|
||||||
if entries:
|
eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False)
|
||||||
skill_names = [e.skill.name for e in entries]
|
logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})")
|
||||||
logger.debug(f"[SkillManager] Skills to include: {skill_names}")
|
if eligible:
|
||||||
result = format_skill_entries_for_prompt(entries)
|
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)}")
|
logger.debug(f"[SkillManager] Generated prompt length: {len(result)}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class SkillInstallSpec:
|
|||||||
class SkillMetadata:
|
class SkillMetadata:
|
||||||
"""Metadata for a skill from frontmatter."""
|
"""Metadata for a skill from frontmatter."""
|
||||||
always: bool = False # Always include this skill
|
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
|
skill_key: Optional[str] = None # Override skill key
|
||||||
primary_env: Optional[str] = None # Primary environment variable
|
primary_env: Optional[str] = None # Primary environment variable
|
||||||
emoji: Optional[str] = None
|
emoji: Optional[str] = None
|
||||||
|
|||||||
@@ -87,25 +87,25 @@ FileSave = _optional_tools.get('FileSave')
|
|||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# Delayed import for BrowserTool
|
# BrowserTool (requires playwright)
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
# Return a placeholder class that will prompt the user to install dependencies when instantiated
|
logger.info(
|
||||||
class BrowserToolPlaceholder:
|
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
||||||
def __init__(self, *args, **kwargs):
|
f" To enable browser tool, run:\n"
|
||||||
raise ImportError(
|
f" pip install playwright\n"
|
||||||
"The 'browser-use' package is required to use BrowserTool. "
|
f" playwright install chromium"
|
||||||
"Please install it with 'pip install browser-use>=0.1.40'."
|
)
|
||||||
)
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
return BrowserToolPlaceholder
|
BrowserTool = _import_browser_tool()
|
||||||
|
|
||||||
|
|
||||||
# Dynamically set BrowserTool
|
|
||||||
# BrowserTool = _import_browser_tool()
|
|
||||||
|
|
||||||
# Export all tools (including optional ones that might be None)
|
# Export all tools (including optional ones that might be None)
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -124,8 +124,7 @@ __all__ = [
|
|||||||
'WebSearch',
|
'WebSearch',
|
||||||
'WebFetch',
|
'WebFetch',
|
||||||
'Vision',
|
'Vision',
|
||||||
# Optional tools (may be None if dependencies not available)
|
'BrowserTool',
|
||||||
# 'BrowserTool'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
3
agent/tools/browser/__init__.py
Normal file
3
agent/tools/browser/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
|
|
||||||
|
__all__ = ["BrowserTool"]
|
||||||
509
agent/tools/browser/browser_service.py
Normal file
509
agent/tools/browser/browser_service.py
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
"""
|
||||||
|
Browser service - Playwright wrapper managing browser lifecycle and page operations.
|
||||||
|
|
||||||
|
Lazily launches a Chromium instance on first use, reuses it across tool calls,
|
||||||
|
and cleans up on close(). Headless mode is auto-detected based on platform and
|
||||||
|
display availability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snapshot DOM helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Tags that typically carry useful content for an agent
|
||||||
|
_INTERACTIVE_TAGS = {
|
||||||
|
"a", "button", "input", "textarea", "select", "option",
|
||||||
|
"label", "details", "summary",
|
||||||
|
}
|
||||||
|
_SEMANTIC_TAGS = {
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"p", "li", "td", "th", "caption", "figcaption", "blockquote", "pre", "code",
|
||||||
|
"nav", "main", "article", "section", "header", "footer", "form", "table",
|
||||||
|
"img", "video", "audio",
|
||||||
|
}
|
||||||
|
_KEEP_TAGS = _INTERACTIVE_TAGS | _SEMANTIC_TAGS
|
||||||
|
|
||||||
|
_SNAPSHOT_JS = """
|
||||||
|
() => {
|
||||||
|
const KEEP = new Set(%s);
|
||||||
|
const INTERACTIVE = new Set(%s);
|
||||||
|
const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]);
|
||||||
|
let refCounter = 0;
|
||||||
|
const refMap = {};
|
||||||
|
|
||||||
|
function visible(el) {
|
||||||
|
if (!(el instanceof HTMLElement)) return true;
|
||||||
|
const st = window.getComputedStyle(el);
|
||||||
|
if (st.display === "none" || st.visibility === "hidden") return false;
|
||||||
|
if (parseFloat(st.opacity) === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const t = node.textContent.trim();
|
||||||
|
return t ? t : null;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||||
|
const tag = node.tagName.toLowerCase();
|
||||||
|
if (SKIP.has(tag)) return null;
|
||||||
|
if (!visible(node)) return null;
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
for (const ch of node.childNodes) {
|
||||||
|
const r = walk(ch);
|
||||||
|
if (r !== null) {
|
||||||
|
if (typeof r === "string") children.push(r);
|
||||||
|
else children.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keep = KEEP.has(tag);
|
||||||
|
if (!keep) {
|
||||||
|
// Unwrap: promote children
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
if (children.length === 1) return children[0];
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = { tag };
|
||||||
|
if (INTERACTIVE.has(tag)) {
|
||||||
|
refCounter++;
|
||||||
|
obj.ref = refCounter;
|
||||||
|
refMap[refCounter] = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
if (tag === "a" && node.href) obj.href = node.getAttribute("href");
|
||||||
|
if (tag === "img") {
|
||||||
|
obj.alt = node.alt || "";
|
||||||
|
obj.src = node.getAttribute("src") || "";
|
||||||
|
}
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||||
|
obj.type = node.type || "text";
|
||||||
|
obj.name = node.name || undefined;
|
||||||
|
obj.value = node.value || undefined;
|
||||||
|
obj.placeholder = node.placeholder || undefined;
|
||||||
|
if (node.disabled) obj.disabled = true;
|
||||||
|
if (tag === "input" && node.type === "checkbox") obj.checked = node.checked;
|
||||||
|
}
|
||||||
|
if (tag === "button") {
|
||||||
|
if (node.disabled) obj.disabled = true;
|
||||||
|
}
|
||||||
|
if (tag === "option") {
|
||||||
|
obj.value = node.value;
|
||||||
|
if (node.selected) obj.selected = true;
|
||||||
|
}
|
||||||
|
if (tag === "label" && node.htmlFor) obj.for = node.htmlFor;
|
||||||
|
|
||||||
|
// Role / aria-label
|
||||||
|
const role = node.getAttribute("role");
|
||||||
|
if (role) obj.role = role;
|
||||||
|
const ariaLabel = node.getAttribute("aria-label");
|
||||||
|
if (ariaLabel) obj.ariaLabel = ariaLabel;
|
||||||
|
|
||||||
|
// Children
|
||||||
|
if (children.length === 1 && typeof children[0] === "string") {
|
||||||
|
obj.text = children[0];
|
||||||
|
} else if (children.length > 0) {
|
||||||
|
obj.children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store refMap on window for later use by click/fill actions
|
||||||
|
const result = walk(document.body);
|
||||||
|
window.__cowRefMap = refMap;
|
||||||
|
return { tree: result, refCount: refCounter };
|
||||||
|
}
|
||||||
|
""" % (
|
||||||
|
str(list(_KEEP_TAGS)),
|
||||||
|
str(list(_INTERACTIVE_TAGS)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_use_headless() -> bool:
|
||||||
|
"""Decide headless mode: headless on Linux servers without display, headed elsewhere."""
|
||||||
|
if sys.platform in ("win32", "darwin"):
|
||||||
|
return False
|
||||||
|
# Linux: check for display
|
||||||
|
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_tree(node, indent=0) -> List[str]:
|
||||||
|
"""Convert snapshot tree to compact text lines for LLM consumption."""
|
||||||
|
if node is None:
|
||||||
|
return []
|
||||||
|
if isinstance(node, str):
|
||||||
|
return [" " * indent + node]
|
||||||
|
if isinstance(node, list):
|
||||||
|
lines = []
|
||||||
|
for child in node:
|
||||||
|
lines.extend(_flatten_tree(child, indent))
|
||||||
|
return lines
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
tag = node.get("tag", "?")
|
||||||
|
ref = node.get("ref")
|
||||||
|
parts = [tag]
|
||||||
|
if ref:
|
||||||
|
parts[0] = f"[{ref}] {tag}"
|
||||||
|
|
||||||
|
# Inline attributes
|
||||||
|
for attr in ("type", "name", "href", "alt", "role", "ariaLabel", "placeholder", "value"):
|
||||||
|
val = node.get(attr)
|
||||||
|
if val:
|
||||||
|
# Truncate long values
|
||||||
|
s = str(val)
|
||||||
|
if len(s) > 80:
|
||||||
|
s = s[:77] + "..."
|
||||||
|
parts.append(f'{attr}="{s}"')
|
||||||
|
|
||||||
|
for flag in ("disabled", "checked", "selected"):
|
||||||
|
if node.get(flag):
|
||||||
|
parts.append(flag)
|
||||||
|
|
||||||
|
prefix = " " * indent
|
||||||
|
header = prefix + " ".join(parts)
|
||||||
|
|
||||||
|
text = node.get("text")
|
||||||
|
if text:
|
||||||
|
# Truncate long text
|
||||||
|
if len(text) > 120:
|
||||||
|
text = text[:117] + "..."
|
||||||
|
header += f": {text}"
|
||||||
|
|
||||||
|
lines = [header]
|
||||||
|
children = node.get("children", [])
|
||||||
|
for child in children:
|
||||||
|
lines.extend(_flatten_tree(child, indent + 2))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserService:
|
||||||
|
"""Manages a single Playwright browser instance with page operations."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
|
self._config = config or {}
|
||||||
|
self._playwright: Optional[Playwright] = None
|
||||||
|
self._browser: Optional[Browser] = None
|
||||||
|
self._context: Optional[BrowserContext] = None
|
||||||
|
self._page: Optional[Page] = None
|
||||||
|
self._headless: Optional[bool] = None
|
||||||
|
self._screenshot_dir: Optional[str] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_browser(self):
|
||||||
|
"""Lazily launch browser on first use."""
|
||||||
|
if self._page and not self._page.is_closed():
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._headless is None:
|
||||||
|
headless_cfg = self._config.get("headless")
|
||||||
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
|
launch_args = ["--disable-dev-shm-usage"]
|
||||||
|
if self._headless:
|
||||||
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
|
extra_args = self._config.get("launch_args", [])
|
||||||
|
if extra_args:
|
||||||
|
launch_args.extend(extra_args)
|
||||||
|
|
||||||
|
viewport_w = self._config.get("viewport_width", 1280)
|
||||||
|
viewport_h = self._config.get("viewport_height", 720)
|
||||||
|
|
||||||
|
if not self._playwright:
|
||||||
|
self._playwright = sync_playwright().start()
|
||||||
|
|
||||||
|
logger.info(f"[Browser] Launching Chromium (headless={self._headless})")
|
||||||
|
self._browser = self._playwright.chromium.launch(
|
||||||
|
headless=self._headless,
|
||||||
|
args=launch_args,
|
||||||
|
)
|
||||||
|
self._context = self._browser.new_context(
|
||||||
|
viewport={"width": viewport_w, "height": viewport_h},
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._page = self._context.new_page()
|
||||||
|
logger.info("[Browser] Browser ready")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page(self) -> Page:
|
||||||
|
self._ensure_browser()
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Release all browser resources."""
|
||||||
|
try:
|
||||||
|
if self._context:
|
||||||
|
self._context.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] context close error: {e}")
|
||||||
|
try:
|
||||||
|
if self._browser:
|
||||||
|
self._browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] browser close error: {e}")
|
||||||
|
try:
|
||||||
|
if self._playwright:
|
||||||
|
self._playwright.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] playwright stop error: {e}")
|
||||||
|
self._page = None
|
||||||
|
self._context = None
|
||||||
|
self._browser = None
|
||||||
|
self._playwright = None
|
||||||
|
logger.info("[Browser] Browser closed")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
|
||||||
|
"""Navigate to a URL and return page info."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
||||||
|
status = resp.status if resp else None
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": page.url,
|
||||||
|
"title": page.title(),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot(self, selector: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Return a compact text representation of the page DOM for LLM consumption.
|
||||||
|
Interactive elements get numeric refs usable in click/fill actions.
|
||||||
|
"""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
target = selector or "body"
|
||||||
|
result = page.evaluate(_SNAPSHOT_JS)
|
||||||
|
except Exception as e:
|
||||||
|
return f"[Snapshot error: {e}]"
|
||||||
|
|
||||||
|
tree = result.get("tree")
|
||||||
|
ref_count = result.get("refCount", 0)
|
||||||
|
lines = _flatten_tree(tree)
|
||||||
|
|
||||||
|
header = f"Page: {page.title()} ({page.url})\nInteractive elements: {ref_count}\n---"
|
||||||
|
body = "\n".join(lines)
|
||||||
|
|
||||||
|
# Limit output size
|
||||||
|
max_chars = self._config.get("snapshot_max_chars", 30000)
|
||||||
|
if len(body) > max_chars:
|
||||||
|
body = body[:max_chars] + "\n... [snapshot truncated]"
|
||||||
|
|
||||||
|
return f"{header}\n{body}"
|
||||||
|
|
||||||
|
def screenshot(self, full_page: bool = False, cwd: str = "") -> str:
|
||||||
|
"""Take a screenshot and save to workspace/tmp. Returns file path."""
|
||||||
|
page = self.page
|
||||||
|
save_dir = self._get_screenshot_dir(cwd)
|
||||||
|
filename = f"screenshot_{uuid.uuid4().hex[:8]}.png"
|
||||||
|
filepath = os.path.join(save_dir, filename)
|
||||||
|
|
||||||
|
page.screenshot(path=filepath, full_page=full_page)
|
||||||
|
logger.info(f"[Browser] Screenshot saved: {filepath}")
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
def click(self, ref: Optional[int] = None, selector: Optional[str] = None,
|
||||||
|
timeout: int = 5000) -> Dict[str, Any]:
|
||||||
|
"""Click an element by snapshot ref or CSS selector."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
if ref is not None:
|
||||||
|
result = page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
|
||||||
|
if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
|
||||||
|
el.click();
|
||||||
|
return {{ clicked: true, tag: el.tagName.toLowerCase() }};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
if result.get("error"):
|
||||||
|
return result
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
return result
|
||||||
|
elif selector:
|
||||||
|
page.click(selector, timeout=timeout)
|
||||||
|
return {"clicked": True, "selector": selector}
|
||||||
|
else:
|
||||||
|
return {"error": "Provide either ref (from snapshot) or selector"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Click failed: {e}"}
|
||||||
|
|
||||||
|
def fill(self, text: str, ref: Optional[int] = None,
|
||||||
|
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
|
||||||
|
"""Fill text into an input/textarea by snapshot ref or CSS selector."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
if ref is not None:
|
||||||
|
result = page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
|
||||||
|
if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
|
||||||
|
el.focus();
|
||||||
|
el.value = "";
|
||||||
|
return {{ tag: el.tagName.toLowerCase(), name: el.name || "" }};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
if result.get("error"):
|
||||||
|
return result
|
||||||
|
page.keyboard.type(text)
|
||||||
|
return {"filled": True, "ref": ref, "text": text}
|
||||||
|
elif selector:
|
||||||
|
page.fill(selector, text, timeout=timeout)
|
||||||
|
return {"filled": True, "selector": selector, "text": text}
|
||||||
|
else:
|
||||||
|
return {"error": "Provide either ref (from snapshot) or selector"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Fill failed: {e}"}
|
||||||
|
|
||||||
|
def select(self, value: str, ref: Optional[int] = None,
|
||||||
|
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
|
||||||
|
"""Select an option in a <select> element."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
if ref is not None:
|
||||||
|
result = page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
|
||||||
|
if (!el || el.tagName.toLowerCase() !== "select")
|
||||||
|
return {{ error: "ref {ref} is not a <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]:
|
||||||
|
"""Scroll the page."""
|
||||||
|
page = self.page
|
||||||
|
delta_map = {
|
||||||
|
"down": (0, amount),
|
||||||
|
"up": (0, -amount),
|
||||||
|
"right": (amount, 0),
|
||||||
|
"left": (-amount, 0),
|
||||||
|
}
|
||||||
|
dx, dy = delta_map.get(direction, (0, amount))
|
||||||
|
try:
|
||||||
|
page.mouse.wheel(dx, dy)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
scroll_info = page.evaluate("""
|
||||||
|
() => ({
|
||||||
|
scrollX: window.scrollX,
|
||||||
|
scrollY: window.scrollY,
|
||||||
|
scrollHeight: document.documentElement.scrollHeight,
|
||||||
|
clientHeight: document.documentElement.clientHeight
|
||||||
|
})
|
||||||
|
""")
|
||||||
|
return {"scrolled": direction, "amount": amount, **scroll_info}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Scroll failed: {e}"}
|
||||||
|
|
||||||
|
def wait(self, selector: Optional[str] = None, timeout: int = 5000,
|
||||||
|
state: str = "visible") -> Dict[str, Any]:
|
||||||
|
"""Wait for a selector to appear or a fixed timeout."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
if selector:
|
||||||
|
page.wait_for_selector(selector, timeout=timeout, state=state)
|
||||||
|
return {"waited": True, "selector": selector, "state": state}
|
||||||
|
else:
|
||||||
|
page.wait_for_timeout(timeout)
|
||||||
|
return {"waited": True, "timeout_ms": timeout}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Wait failed: {e}"}
|
||||||
|
|
||||||
|
def go_back(self) -> Dict[str, Any]:
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
page.go_back(wait_until="domcontentloaded", timeout=10000)
|
||||||
|
return {"url": page.url, "title": page.title()}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Go back failed: {e}"}
|
||||||
|
|
||||||
|
def go_forward(self) -> Dict[str, Any]:
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
page.go_forward(wait_until="domcontentloaded", timeout=10000)
|
||||||
|
return {"url": page.url, "title": page.title()}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Go forward failed: {e}"}
|
||||||
|
|
||||||
|
def get_text(self, selector: str) -> Dict[str, Any]:
|
||||||
|
"""Get text content of an element."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
text = page.text_content(selector, timeout=5000)
|
||||||
|
return {"text": text or ""}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Get text failed: {e}"}
|
||||||
|
|
||||||
|
def evaluate(self, script: str) -> Dict[str, Any]:
|
||||||
|
"""Execute JavaScript in the page context."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
result = page.evaluate(script)
|
||||||
|
return {"result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Evaluate failed: {e}"}
|
||||||
|
|
||||||
|
def press(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""Press a keyboard key (e.g. Enter, Tab, Escape)."""
|
||||||
|
page = self.page
|
||||||
|
try:
|
||||||
|
page.keyboard.press(key)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
return {"pressed": key}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Press failed: {e}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_screenshot_dir(self, cwd: str = "") -> str:
|
||||||
|
if self._screenshot_dir and os.path.isdir(self._screenshot_dir):
|
||||||
|
return self._screenshot_dir
|
||||||
|
base = cwd or os.getcwd()
|
||||||
|
d = os.path.join(base, "tmp")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
self._screenshot_dir = d
|
||||||
|
return d
|
||||||
287
agent/tools/browser/browser_tool.py
Normal file
287
agent/tools/browser/browser_tool.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
Browser tool - Control a Chromium browser for web navigation and interaction.
|
||||||
|
|
||||||
|
Uses Playwright under the hood. Browser instance is lazily started on first
|
||||||
|
use, reused across tool calls within the same session, and cleaned up via
|
||||||
|
close().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.browser.browser_service import BrowserService
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTool(BaseTool):
|
||||||
|
"""Single tool exposing all browser actions via an 'action' parameter."""
|
||||||
|
|
||||||
|
name: str = "browser"
|
||||||
|
description: str = (
|
||||||
|
"Control a browser to navigate web pages, interact with elements, and extract content. "
|
||||||
|
"Actions: navigate, snapshot, click, fill, select, scroll, screenshot, wait, back, forward, "
|
||||||
|
"get_text, press, evaluate.\n\n"
|
||||||
|
"Workflow: navigate to a URL → snapshot to see the page (elements get numeric refs) → "
|
||||||
|
"use refs in click/fill/select actions → snapshot again to verify.\n\n"
|
||||||
|
"Use snapshot (not screenshot) as the primary way to read page content."
|
||||||
|
)
|
||||||
|
|
||||||
|
params: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"The browser action to perform. One of: "
|
||||||
|
"navigate, snapshot, click, fill, select, scroll, "
|
||||||
|
"screenshot, wait, back, forward, get_text, press, evaluate"
|
||||||
|
),
|
||||||
|
"enum": [
|
||||||
|
"navigate", "snapshot", "click", "fill", "select", "scroll",
|
||||||
|
"screenshot", "wait", "back", "forward", "get_text", "press",
|
||||||
|
"evaluate"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "URL to navigate to (for 'navigate' action)"
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Element ref number from snapshot (for click/fill/select)"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "CSS selector as fallback when ref is unavailable (for click/fill/select/wait/get_text)"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text to type (for 'fill' action)"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Option value (for 'select' action)"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key to press, e.g. Enter, Tab, Escape (for 'press' action)"
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Scroll direction: up, down, left, right (for 'scroll' action, default: down)"
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JavaScript code to execute (for 'evaluate' action)"
|
||||||
|
},
|
||||||
|
"full_page": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Capture full page screenshot (for 'screenshot' action, default: false)"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Timeout in milliseconds (optional, default varies by action)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
}
|
||||||
|
|
||||||
|
_shared_service: Optional[BrowserService] = None
|
||||||
|
|
||||||
|
def __init__(self, config: dict = None):
|
||||||
|
self.config = config or {}
|
||||||
|
self.cwd = self.config.get("cwd", os.getcwd())
|
||||||
|
self._service: Optional[BrowserService] = None
|
||||||
|
|
||||||
|
def _get_service(self) -> BrowserService:
|
||||||
|
"""Get or create the browser service, sharing across copies."""
|
||||||
|
if self._service is not None:
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
# Reuse shared service across tool copies within the same session
|
||||||
|
if BrowserTool._shared_service is not None:
|
||||||
|
self._service = BrowserTool._shared_service
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
self._service = BrowserService(self.config)
|
||||||
|
BrowserTool._shared_service = self._service
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
action = args.get("action", "").strip().lower()
|
||||||
|
if not action:
|
||||||
|
return ToolResult.fail("Error: 'action' parameter is required")
|
||||||
|
|
||||||
|
handler = self._ACTION_MAP.get(action)
|
||||||
|
if not handler:
|
||||||
|
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||||
|
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return handler(self, args)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Browser] Action '{action}' error: {e}")
|
||||||
|
return ToolResult.fail(f"Browser error ({action}): {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Action handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _do_navigate(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
url = args.get("url", "").strip()
|
||||||
|
if not url:
|
||||||
|
return ToolResult.fail("Error: 'url' is required for navigate action")
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
url = "https://" + url
|
||||||
|
timeout = args.get("timeout", 30000)
|
||||||
|
result = self._get_service().navigate(url, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(
|
||||||
|
f"Navigated to: {result['url']}\nTitle: {result['title']}\nStatus: {result['status']}\n\n"
|
||||||
|
f"Use action 'snapshot' to see the page content."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_snapshot(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
selector = args.get("selector")
|
||||||
|
text = self._get_service().snapshot(selector=selector)
|
||||||
|
return ToolResult.success(text)
|
||||||
|
|
||||||
|
def _do_click(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
ref = args.get("ref")
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
result = self._get_service().click(ref=ref, selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Clicked successfully. Use 'snapshot' to see updated page.")
|
||||||
|
|
||||||
|
def _do_fill(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
text = args.get("text", "")
|
||||||
|
ref = args.get("ref")
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
if not text and text != "":
|
||||||
|
return ToolResult.fail("Error: 'text' is required for fill action")
|
||||||
|
result = self._get_service().fill(text, ref=ref, selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Filled text into element. Use 'snapshot' to verify.")
|
||||||
|
|
||||||
|
def _do_select(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
value = args.get("value", "")
|
||||||
|
ref = args.get("ref")
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
if not value:
|
||||||
|
return ToolResult.fail("Error: 'value' is required for select action")
|
||||||
|
result = self._get_service().select(value, ref=ref, selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Selected option '{value}'.")
|
||||||
|
|
||||||
|
def _do_scroll(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
direction = args.get("direction", "down")
|
||||||
|
amount = args.get("timeout", 500) # reuse timeout field or default
|
||||||
|
if "amount" in args:
|
||||||
|
amount = args["amount"]
|
||||||
|
result = self._get_service().scroll(direction=direction, amount=amount)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
pos = f"scrollY={result.get('scrollY', '?')}/{result.get('scrollHeight', '?')}"
|
||||||
|
return ToolResult.success(f"Scrolled {direction}. Position: {pos}")
|
||||||
|
|
||||||
|
def _do_screenshot(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
full_page = args.get("full_page", False)
|
||||||
|
filepath = self._get_service().screenshot(full_page=full_page, cwd=self.cwd)
|
||||||
|
return ToolResult.success(f"Screenshot saved to: {filepath}")
|
||||||
|
|
||||||
|
def _do_wait(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
result = self._get_service().wait(selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Wait completed.")
|
||||||
|
|
||||||
|
def _do_back(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
result = self._get_service().go_back()
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Navigated back to: {result['url']}")
|
||||||
|
|
||||||
|
def _do_forward(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
result = self._get_service().go_forward()
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Navigated forward to: {result['url']}")
|
||||||
|
|
||||||
|
def _do_get_text(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
selector = args.get("selector", "").strip()
|
||||||
|
if not selector:
|
||||||
|
return ToolResult.fail("Error: 'selector' is required for get_text action")
|
||||||
|
result = self._get_service().get_text(selector)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(result["text"])
|
||||||
|
|
||||||
|
def _do_press(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
key = args.get("key", "").strip()
|
||||||
|
if not key:
|
||||||
|
return ToolResult.fail("Error: 'key' is required for press action")
|
||||||
|
result = self._get_service().press(key)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Pressed key: {key}")
|
||||||
|
|
||||||
|
def _do_evaluate(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
script = args.get("script", "").strip()
|
||||||
|
if not script:
|
||||||
|
return ToolResult.fail("Error: 'script' is required for evaluate action")
|
||||||
|
result = self._get_service().evaluate(script)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
val = result.get("result")
|
||||||
|
if isinstance(val, (dict, list)):
|
||||||
|
return ToolResult.success(json.dumps(val, ensure_ascii=False, indent=2))
|
||||||
|
return ToolResult.success(str(val) if val is not None else "(no return value)")
|
||||||
|
|
||||||
|
# Action dispatch table
|
||||||
|
_ACTION_MAP = {
|
||||||
|
"navigate": _do_navigate,
|
||||||
|
"snapshot": _do_snapshot,
|
||||||
|
"click": _do_click,
|
||||||
|
"fill": _do_fill,
|
||||||
|
"select": _do_select,
|
||||||
|
"scroll": _do_scroll,
|
||||||
|
"screenshot": _do_screenshot,
|
||||||
|
"wait": _do_wait,
|
||||||
|
"back": _do_back,
|
||||||
|
"forward": _do_forward,
|
||||||
|
"get_text": _do_get_text,
|
||||||
|
"press": _do_press,
|
||||||
|
"evaluate": _do_evaluate,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def copy(self):
|
||||||
|
"""Share browser instance across tool copies (avoids re-launching)."""
|
||||||
|
new_tool = BrowserTool(self.config)
|
||||||
|
new_tool.model = self.model
|
||||||
|
new_tool.context = getattr(self, "context", None)
|
||||||
|
new_tool.cwd = self.cwd
|
||||||
|
new_tool._service = self._service
|
||||||
|
return new_tool
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Release browser resources."""
|
||||||
|
if self._service:
|
||||||
|
self._service.close()
|
||||||
|
self._service = None
|
||||||
|
BrowserTool._shared_service = None
|
||||||
|
logger.info("[Browser] BrowserTool closed")
|
||||||
@@ -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
|
|
||||||
@@ -84,11 +84,11 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "browser-use" in error_msg or "browser_use" in error_msg:
|
if "playwright" in error_msg:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||||
f" To enable browser tool, run:\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"
|
f" playwright install chromium"
|
||||||
)
|
)
|
||||||
elif "markdownify" in error_msg:
|
elif "markdownify" in error_msg:
|
||||||
@@ -154,11 +154,11 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "browser-use" in error_msg or "browser_use" in error_msg:
|
if "playwright" in error_msg:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||||
f" To enable browser tool, run:\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"
|
f" playwright install chromium"
|
||||||
)
|
)
|
||||||
elif "markdownify" in error_msg:
|
elif "markdownify" in error_msg:
|
||||||
@@ -197,7 +197,7 @@ class ToolManager:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
||||||
f" To enable browser tool, run:\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"
|
f" playwright install chromium"
|
||||||
)
|
)
|
||||||
elif tool_name == "google_search":
|
elif tool_name == "google_search":
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class Vision(BaseTool):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _maybe_compress(path: str) -> str:
|
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)
|
file_size = os.path.getsize(path)
|
||||||
if file_size <= COMPRESS_THRESHOLD:
|
if file_size <= COMPRESS_THRESHOLD:
|
||||||
return path
|
return path
|
||||||
@@ -175,27 +175,47 @@ class Vision(BaseTool):
|
|||||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||||
tmp.close()
|
tmp.close()
|
||||||
|
|
||||||
try:
|
def _try_sips(max_dim: str, quality: str) -> bool:
|
||||||
# macOS: use sips
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sips", "-Z", "800", path, "--out", tmp.name],
|
["sips", "-Z", max_dim, "-s", "formatOptions", quality,
|
||||||
capture_output=True, check=True,
|
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
|
return True
|
||||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||||
pass
|
return False
|
||||||
|
|
||||||
try:
|
def _try_convert(max_dim: str, quality: str) -> bool:
|
||||||
# Linux: use ImageMagick convert
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["convert", path, "-resize", "800x800>", tmp.name],
|
["convert", path, "-resize", f"{max_dim}x{max_dim}>",
|
||||||
capture_output=True, check=True,
|
"-quality", quality, 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 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
|
return tmp.name
|
||||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
os.remove(tmp.name)
|
os.remove(tmp.name)
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class AgentLLMModel(LLMModel):
|
|||||||
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
|
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
|
||||||
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
|
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
|
||||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||||
("doubao", const.DOUBAO),
|
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||||||
@@ -115,8 +115,6 @@ class AgentLLMModel(LLMModel):
|
|||||||
return const.QWEN_DASHSCOPE
|
return const.QWEN_DASHSCOPE
|
||||||
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
||||||
return const.MOONSHOT
|
return const.MOONSHOT
|
||||||
if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]:
|
|
||||||
return const.OPENAI
|
|
||||||
for prefix, btype in self._MODEL_PREFIX_MAP:
|
for prefix, btype in self._MODEL_PREFIX_MAP:
|
||||||
if model_name.startswith(prefix):
|
if model_name.startswith(prefix):
|
||||||
return btype
|
return btype
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class Bridge(object):
|
|||||||
if model_type and model_type.startswith("doubao"):
|
if model_type and model_type.startswith("doubao"):
|
||||||
self.btype["chat"] = const.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]:
|
if model_type in [const.MODELSCOPE]:
|
||||||
self.btype["chat"] = const.MODELSCOPE
|
self.btype["chat"] = const.MODELSCOPE
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,8 @@
|
|||||||
<i class="fas fa-bars text-slate-600 dark:text-slate-300"></i>
|
<i class="fas fa-bars text-slate-600 dark:text-slate-300"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb (hidden on mobile) -->
|
||||||
<div class="flex items-center gap-2 text-sm min-w-0">
|
<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>
|
<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>
|
<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>
|
<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">
|
<div class="max-w-3xl mx-auto">
|
||||||
<!-- Attachment preview bar -->
|
<!-- Attachment preview bar -->
|
||||||
<div id="attachment-preview" class="attachment-preview hidden"></div>
|
<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">
|
<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
|
<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
|
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||||
@@ -287,6 +287,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="file" id="file-input" class="hidden" multiple
|
<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">
|
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"
|
<textarea id="chat-input"
|
||||||
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
|
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
|
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
|
||||||
@@ -295,7 +296,7 @@
|
|||||||
text-sm leading-relaxed"
|
text-sm leading-relaxed"
|
||||||
rows="1"
|
rows="1"
|
||||||
data-i18n-placeholder="input_placeholder"
|
data-i18n-placeholder="input_placeholder"
|
||||||
placeholder="Type a message..."></textarea>
|
placeholder="Type a message, or press / for commands"></textarea>
|
||||||
<button id="send-btn"
|
<button id="send-btn"
|
||||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||||
bg-primary-400 text-white hover:bg-primary-500
|
bg-primary-400 text-white hover:bg-primary-500
|
||||||
|
|||||||
@@ -79,6 +79,11 @@
|
|||||||
.msg-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; }
|
.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 { color: #35A85B; text-decoration: underline; }
|
||||||
.msg-content a:hover { color: #228547; }
|
.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; }
|
.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; }
|
||||||
.dark .msg-content hr { background: rgba(255,255,255,0.1); }
|
.dark .msg-content hr { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
@@ -446,3 +451,87 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
===================================================================== */
|
===================================================================== */
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Version — update this before each release
|
// Version — fetched from backend (single source: /VERSION file)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
const APP_VERSION = 'v2.0.4';
|
let APP_VERSION = '';
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// i18n
|
// i18n
|
||||||
@@ -21,7 +21,7 @@ const I18N = {
|
|||||||
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||||
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
||||||
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
|
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
|
||||||
input_placeholder: '输入消息...',
|
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||||
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
|
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
|
||||||
config_model: '模型配置', config_agent: 'Agent 配置',
|
config_model: '模型配置', config_agent: 'Agent 配置',
|
||||||
config_channel: '通道配置',
|
config_channel: '通道配置',
|
||||||
@@ -72,7 +72,7 @@ const I18N = {
|
|||||||
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
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_task_title: 'Skills', example_task_text: 'Show current tools and skills',
|
||||||
example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script',
|
example_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_title: 'Configuration', config_desc: 'Manage model and agent settings',
|
||||||
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
|
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
|
||||||
config_channel: 'Channel Configuration',
|
config_channel: 'Channel Configuration',
|
||||||
@@ -322,6 +322,11 @@ const attachmentPreview = document.getElementById('attachment-preview');
|
|||||||
let pendingAttachments = [];
|
let pendingAttachments = [];
|
||||||
let uploadingCount = 0;
|
let uploadingCount = 0;
|
||||||
|
|
||||||
|
// Input history (like terminal arrow-key recall)
|
||||||
|
const inputHistory = [];
|
||||||
|
let historyIdx = -1;
|
||||||
|
let historySavedDraft = '';
|
||||||
|
|
||||||
function updateSendBtnState() {
|
function updateSendBtnState() {
|
||||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||||
}
|
}
|
||||||
@@ -435,6 +440,99 @@ chatInput.addEventListener('paste', (e) => {
|
|||||||
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
|
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
|
||||||
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
|
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 = '';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
renderSlashItems();
|
||||||
|
slashMenu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSlashMenu() {
|
||||||
|
slashMenu.classList.add('hidden');
|
||||||
|
slashMenu.innerHTML = '';
|
||||||
|
slashFiltered = [];
|
||||||
|
slashActiveIdx = -1;
|
||||||
|
slashLastFilter = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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('');
|
||||||
|
|
||||||
|
slashMenu.querySelectorAll('.slash-menu-item').forEach(el => {
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
slashActiveIdx = parseInt(el.dataset.idx);
|
||||||
|
renderSlashItems();
|
||||||
|
});
|
||||||
|
el.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectSlashCommand(parseInt(el.dataset.idx));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeEl = slashMenu.querySelector('.slash-menu-item.active');
|
||||||
|
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSlashCommand(idx) {
|
||||||
|
if (idx < 0 || idx >= slashFiltered.length) return;
|
||||||
|
const chosen = slashFiltered[idx].cmd;
|
||||||
|
slashJustSelected = true;
|
||||||
|
chatInput.value = chosen;
|
||||||
|
chatInput.dispatchEvent(new Event('input'));
|
||||||
|
hideSlashMenu();
|
||||||
|
chatInput.focus();
|
||||||
|
chatInput.selectionStart = chatInput.selectionEnd = chosen.length;
|
||||||
|
}
|
||||||
|
|
||||||
chatInput.addEventListener('input', function() {
|
chatInput.addEventListener('input', function() {
|
||||||
this.style.height = '42px';
|
this.style.height = '42px';
|
||||||
const scrollH = this.scrollHeight;
|
const scrollH = this.scrollHeight;
|
||||||
@@ -442,11 +540,90 @@ chatInput.addEventListener('input', function() {
|
|||||||
this.style.height = newH + 'px';
|
this.style.height = newH + 'px';
|
||||||
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
|
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
|
||||||
updateSendBtnState();
|
updateSendBtnState();
|
||||||
|
|
||||||
|
const val = this.value;
|
||||||
|
if (slashJustSelected) {
|
||||||
|
slashJustSelected = false;
|
||||||
|
} else if (val.startsWith('/')) {
|
||||||
|
showSlashMenu(val);
|
||||||
|
} else {
|
||||||
|
hideSlashMenu();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
chatInput.addEventListener('keydown', function(e) {
|
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 (e.keyCode === 229 || e.isComposing || isComposing) return;
|
||||||
|
|
||||||
|
if (isSlashMenuVisible()) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
slashActiveIdx = Math.min(slashActiveIdx + 1, slashFiltered.length - 1);
|
||||||
|
renderSlashItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
slashActiveIdx = Math.max(slashActiveIdx - 1, 0);
|
||||||
|
renderSlashItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectSlashCommand(slashActiveIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
hideSlashMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectSlashCommand(slashActiveIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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') {
|
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
|
||||||
const start = this.selectionStart;
|
const start = this.selectionStart;
|
||||||
const end = this.selectionEnd;
|
const end = this.selectionEnd;
|
||||||
@@ -460,6 +637,10 @@ chatInput.addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatInput.addEventListener('blur', () => {
|
||||||
|
setTimeout(hideSlashMenu, 150);
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.example-card').forEach(card => {
|
document.querySelectorAll('.example-card').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
const textEl = card.querySelector('[data-i18n*="text"]');
|
const textEl = card.querySelector('[data-i18n*="text"]');
|
||||||
@@ -475,6 +656,12 @@ function sendMessage() {
|
|||||||
const text = chatInput.value.trim();
|
const text = chatInput.value.trim();
|
||||||
if (!text && pendingAttachments.length === 0) return;
|
if (!text && pendingAttachments.length === 0) return;
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
inputHistory.push(text);
|
||||||
|
historyIdx = -1;
|
||||||
|
historySavedDraft = '';
|
||||||
|
}
|
||||||
|
|
||||||
const ws = document.getElementById('welcome-screen');
|
const ws = document.getElementById('welcome-screen');
|
||||||
if (ws) ws.remove();
|
if (ws) ws.remove();
|
||||||
|
|
||||||
@@ -732,7 +919,7 @@ function createUserMessageEl(content, timestamp, attachments) {
|
|||||||
const textHtml = content ? renderMarkdown(content) : '';
|
const textHtml = content ? renderMarkdown(content) : '';
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="max-w-[75%] sm:max-w-[60%]">
|
<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}
|
${attachHtml}${textHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
||||||
@@ -2236,7 +2423,12 @@ navigateTo = function(viewId) {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
applyTheme();
|
applyTheme();
|
||||||
applyI18n();
|
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();
|
chatInput.focus();
|
||||||
|
|
||||||
// Re-enable color transition AFTER first paint so the theme applied in <head>
|
// Re-enable color transition AFTER first paint so the theme applied in <head>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
'/api/history', 'HistoryHandler',
|
'/api/history', 'HistoryHandler',
|
||||||
'/api/logs', 'LogsHandler',
|
'/api/logs', 'LogsHandler',
|
||||||
|
'/api/version', 'VersionHandler',
|
||||||
'/assets/(.*)', 'AssetsHandler',
|
'/assets/(.*)', 'AssetsHandler',
|
||||||
)
|
)
|
||||||
app = web.application(urls, globals(), autoreload=False)
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
@@ -493,8 +494,8 @@ class ChatHandler:
|
|||||||
class ConfigHandler:
|
class ConfigHandler:
|
||||||
|
|
||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
|
const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
|
||||||
const.GLM_5, const.GLM_4_7,
|
const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN3_MAX, const.QWEN35_PLUS,
|
const.QWEN3_MAX, const.QWEN35_PLUS,
|
||||||
const.KIMI_K2_5, const.KIMI_K2,
|
const.KIMI_K2_5, const.KIMI_K2,
|
||||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||||
@@ -510,14 +511,14 @@ class ConfigHandler:
|
|||||||
"api_key_field": "minimax_api_key",
|
"api_key_field": "minimax_api_key",
|
||||||
"api_base_key": None,
|
"api_base_key": None,
|
||||||
"api_base_default": 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", {
|
("zhipu", {
|
||||||
"label": "智谱AI",
|
"label": "智谱AI",
|
||||||
"api_key_field": "zhipu_ai_api_key",
|
"api_key_field": "zhipu_ai_api_key",
|
||||||
"api_base_key": "zhipu_ai_api_base",
|
"api_base_key": "zhipu_ai_api_base",
|
||||||
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
"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", {
|
("dashscope", {
|
||||||
"label": "通义千问",
|
"label": "通义千问",
|
||||||
@@ -563,9 +564,9 @@ class ConfigHandler:
|
|||||||
}),
|
}),
|
||||||
("deepseek", {
|
("deepseek", {
|
||||||
"label": "DeepSeek",
|
"label": "DeepSeek",
|
||||||
"api_key_field": "open_ai_api_key",
|
"api_key_field": "deepseek_api_key",
|
||||||
"api_base_key": None,
|
"api_base_key": "deepseek_api_base",
|
||||||
"api_base_default": None,
|
"api_base_default": "https://api.deepseek.com/v1",
|
||||||
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
|
"models": [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER],
|
||||||
}),
|
}),
|
||||||
("linkai", {
|
("linkai", {
|
||||||
@@ -579,9 +580,9 @@ class ConfigHandler:
|
|||||||
|
|
||||||
EDITABLE_KEYS = {
|
EDITABLE_KEYS = {
|
||||||
"model", "bot_type", "use_linkai",
|
"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",
|
"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",
|
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||||
"ark_api_key", "minimax_api_key", "linkai_api_key",
|
"ark_api_key", "minimax_api_key", "linkai_api_key",
|
||||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||||
@@ -1429,3 +1430,10 @@ class AssetsHandler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error serving static file: {e}", exc_info=True) # 添加更详细的错误信息
|
logger.error(f"Error serving static file: {e}", exc_info=True) # 添加更详细的错误信息
|
||||||
raise web.notfound()
|
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__})
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ class WecomBotChannel(ChatChannel):
|
|||||||
if req_id:
|
if req_id:
|
||||||
state = self._stream_states.pop(req_id, None)
|
state = self._stream_states.pop(req_id, None)
|
||||||
if state:
|
if state:
|
||||||
final_content = state["committed"]
|
final_content = state["committed"] or content
|
||||||
stream_id = state["stream_id"]
|
stream_id = state["stream_id"]
|
||||||
else:
|
else:
|
||||||
final_content = content
|
final_content = content
|
||||||
|
|||||||
@@ -172,10 +172,8 @@ class WeixinApi:
|
|||||||
|
|
||||||
def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
|
def get_upload_url(self, filekey: str, media_type: int, to_user_id: str,
|
||||||
rawsize: int, rawfilemd5: str, filesize: int,
|
rawsize: int, rawfilemd5: str, filesize: int,
|
||||||
aeskey: str,
|
aeskey: str) -> dict:
|
||||||
thumb_rawsize: int = 0, thumb_rawfilemd5: str = "",
|
return self._post("ilink/bot/getuploadurl", {
|
||||||
thumb_filesize: int = 0) -> dict:
|
|
||||||
body = {
|
|
||||||
"filekey": filekey,
|
"filekey": filekey,
|
||||||
"media_type": media_type,
|
"media_type": media_type,
|
||||||
"to_user_id": to_user_id,
|
"to_user_id": to_user_id,
|
||||||
@@ -183,14 +181,8 @@ class WeixinApi:
|
|||||||
"rawfilemd5": rawfilemd5,
|
"rawfilemd5": rawfilemd5,
|
||||||
"filesize": filesize,
|
"filesize": filesize,
|
||||||
"aeskey": aeskey,
|
"aeskey": aeskey,
|
||||||
}
|
"no_need_thumb": True,
|
||||||
if thumb_rawsize > 0:
|
})
|
||||||
body["thumb_rawsize"] = thumb_rawsize
|
|
||||||
body["thumb_rawfilemd5"] = thumb_rawfilemd5
|
|
||||||
body["thumb_filesize"] = thumb_filesize
|
|
||||||
else:
|
|
||||||
body["no_need_thumb"] = True
|
|
||||||
return self._post("ilink/bot/getuploadurl", body)
|
|
||||||
|
|
||||||
# ── getConfig / sendTyping ─────────────────────────────────────────
|
# ── getConfig / sendTyping ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -259,10 +251,18 @@ def _md5_bytes(data: bytes) -> str:
|
|||||||
return hashlib.md5(data).hexdigest()
|
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,
|
def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
||||||
media_type: int) -> dict:
|
media_type: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Upload a local file to the Weixin CDN.
|
Upload a local file to the Weixin CDN (matching official plugin protocol).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api: WeixinApi instance
|
api: WeixinApi instance
|
||||||
@@ -275,75 +275,74 @@ def upload_media_to_cdn(api: WeixinApi, file_path: str, to_user_id: str,
|
|||||||
"""
|
"""
|
||||||
aes_key = os.urandom(16)
|
aes_key = os.urandom(16)
|
||||||
aes_key_hex = aes_key.hex()
|
aes_key_hex = aes_key.hex()
|
||||||
|
filekey = uuid.uuid4().hex
|
||||||
|
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
raw_data = f.read()
|
raw_data = f.read()
|
||||||
|
|
||||||
raw_size = len(raw_data)
|
raw_size = len(raw_data)
|
||||||
raw_md5 = _md5_bytes(raw_data)
|
raw_md5 = _md5_bytes(raw_data)
|
||||||
|
cipher_size = _aes_ecb_padded_size(raw_size)
|
||||||
|
|
||||||
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
|
encrypted = _aes_ecb_encrypt(raw_data, aes_key)
|
||||||
cipher_size = len(encrypted)
|
|
||||||
filekey = uuid.uuid4().hex
|
|
||||||
|
|
||||||
thumb_rawsize = 0
|
from urllib.parse import quote
|
||||||
thumb_rawfilemd5 = ""
|
|
||||||
thumb_filesize = 0
|
|
||||||
|
|
||||||
if media_type == 1: # IMAGE - generate a tiny thumbnail
|
download_param = None
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(1, UPLOAD_MAX_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
if attempt > 1:
|
||||||
import io
|
filekey = uuid.uuid4().hex
|
||||||
img = Image.open(file_path)
|
resp = api.get_upload_url(
|
||||||
img.thumbnail((100, 100))
|
filekey=filekey,
|
||||||
buf = io.BytesIO()
|
media_type=media_type,
|
||||||
img.save(buf, format="JPEG", quality=60)
|
to_user_id=to_user_id,
|
||||||
thumb_raw = buf.getvalue()
|
rawsize=raw_size,
|
||||||
thumb_rawsize = len(thumb_raw)
|
rawfilemd5=raw_md5,
|
||||||
thumb_rawfilemd5 = _md5_bytes(thumb_raw)
|
filesize=cipher_size,
|
||||||
thumb_encrypted = _aes_ecb_encrypt(thumb_raw, aes_key)
|
aeskey=aes_key_hex,
|
||||||
thumb_filesize = len(thumb_encrypted)
|
)
|
||||||
except Exception as e:
|
upload_param = resp.get("upload_param", "")
|
||||||
logger.warning(f"[Weixin] Thumbnail generation failed, skipping: {e}")
|
if not upload_param:
|
||||||
|
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
|
||||||
|
|
||||||
resp = api.get_upload_url(
|
cdn_url = (f"{api.cdn_base_url}/upload"
|
||||||
filekey=filekey,
|
f"?encrypted_query_param={quote(upload_param)}"
|
||||||
media_type=media_type,
|
f"&filekey={quote(filekey)}")
|
||||||
to_user_id=to_user_id,
|
|
||||||
rawsize=raw_size,
|
|
||||||
rawfilemd5=raw_md5,
|
|
||||||
filesize=cipher_size,
|
|
||||||
aeskey=aes_key_hex,
|
|
||||||
thumb_rawsize=thumb_rawsize,
|
|
||||||
thumb_rawfilemd5=thumb_rawfilemd5,
|
|
||||||
thumb_filesize=thumb_filesize,
|
|
||||||
)
|
|
||||||
|
|
||||||
upload_param = resp.get("upload_param", "")
|
cdn_resp = requests.post(cdn_url, data=encrypted, headers={
|
||||||
if not upload_param:
|
|
||||||
raise RuntimeError(f"[Weixin] getUploadUrl returned no upload_param: {resp}")
|
|
||||||
|
|
||||||
cdn_url = api.cdn_base_url + "?" + upload_param
|
|
||||||
put_resp = requests.put(cdn_url, data=encrypted, headers={
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Length": str(cipher_size),
|
|
||||||
}, timeout=60)
|
|
||||||
put_resp.raise_for_status()
|
|
||||||
|
|
||||||
# Upload thumbnail if we have one
|
|
||||||
thumb_upload_param = resp.get("thumb_upload_param", "")
|
|
||||||
if thumb_upload_param and thumb_filesize > 0:
|
|
||||||
thumb_cdn_url = api.cdn_base_url + "?" + thumb_upload_param
|
|
||||||
try:
|
|
||||||
requests.put(thumb_cdn_url, data=thumb_encrypted, headers={
|
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
"Content-Length": str(thumb_filesize),
|
"Content-Length": str(len(encrypted)),
|
||||||
}, timeout=30)
|
}, timeout=120)
|
||||||
|
if 400 <= cdn_resp.status_code < 500:
|
||||||
|
err_msg = cdn_resp.headers.get("x-error-message", cdn_resp.text[:200])
|
||||||
|
raise RuntimeError(f"CDN client error {cdn_resp.status_code}: {err_msg}")
|
||||||
|
cdn_resp.raise_for_status()
|
||||||
|
download_param = cdn_resp.headers.get("x-encrypted-param", "")
|
||||||
|
if not download_param:
|
||||||
|
raise RuntimeError("CDN response missing x-encrypted-param header")
|
||||||
|
logger.debug(f"[Weixin] CDN upload success attempt={attempt} filekey={filekey}")
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[Weixin] Thumbnail upload failed (non-fatal): {e}")
|
last_error = e
|
||||||
|
if "client error" in str(e):
|
||||||
|
raise
|
||||||
|
if attempt < UPLOAD_MAX_RETRIES:
|
||||||
|
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 {
|
return {
|
||||||
"encrypt_query_param": upload_param,
|
"encrypt_query_param": download_param,
|
||||||
"aes_key_b64": base64.b64encode(aes_key).decode("utf-8"),
|
"aes_key_b64": aes_key_b64,
|
||||||
"ciphertext_size": cipher_size,
|
"ciphertext_size": cipher_size,
|
||||||
"raw_size": raw_size,
|
"raw_size": raw_size,
|
||||||
}
|
}
|
||||||
@@ -363,19 +362,30 @@ def download_media_from_cdn(cdn_base_url: str, encrypt_query_param: str,
|
|||||||
Returns:
|
Returns:
|
||||||
save_path on success
|
save_path on success
|
||||||
"""
|
"""
|
||||||
url = cdn_base_url + "?" + encrypt_query_param
|
from urllib.parse import quote
|
||||||
|
url = f"{cdn_base_url}/download?encrypted_query_param={quote(encrypt_query_param)}"
|
||||||
resp = requests.get(url, timeout=60)
|
resp = requests.get(url, timeout=60)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
# Determine key format (hex string or base64)
|
# Determine key format:
|
||||||
|
# 1) 32-char hex string → 16 raw bytes
|
||||||
|
# 2) base64 string → decode → if 32 bytes, treat as hex-encoded → 16 raw bytes
|
||||||
|
# 3) base64 string → decode → 16 raw bytes directly
|
||||||
try:
|
try:
|
||||||
key_bytes = bytes.fromhex(aes_key)
|
key_bytes = bytes.fromhex(aes_key)
|
||||||
if len(key_bytes) != 16:
|
if len(key_bytes) != 16:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
key_bytes = base64.b64decode(aes_key)
|
decoded = base64.b64decode(aes_key)
|
||||||
if len(key_bytes) != 16:
|
if len(decoded) == 32:
|
||||||
raise ValueError(f"Invalid AES key length: {len(key_bytes)}")
|
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)
|
decrypted = _aes_ecb_decrypt(resp.content, key_bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ BACKOFF_DELAY = 30
|
|||||||
RETRY_DELAY = 2
|
RETRY_DELAY = 2
|
||||||
SESSION_EXPIRED_ERRCODE = -14
|
SESSION_EXPIRED_ERRCODE = -14
|
||||||
TEXT_CHUNK_LIMIT = 4000
|
TEXT_CHUNK_LIMIT = 4000
|
||||||
|
QR_LOGIN_TIMEOUT_S = 480
|
||||||
|
QR_MAX_REFRESHES = 10
|
||||||
|
|
||||||
|
|
||||||
def _load_credentials(cred_path: str) -> dict:
|
def _load_credentials(cred_path: str) -> dict:
|
||||||
@@ -80,6 +82,8 @@ class WeixinChannel(ChatChannel):
|
|||||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def startup(self):
|
def startup(self):
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
|
base_url = conf().get("weixin_base_url", DEFAULT_BASE_URL)
|
||||||
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
||||||
token = conf().get("weixin_token", "")
|
token = conf().get("weixin_token", "")
|
||||||
@@ -95,17 +99,9 @@ class WeixinChannel(ChatChannel):
|
|||||||
base_url = creds["base_url"]
|
base_url = creds["base_url"]
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
logger.info("[Weixin] No token found, starting QR login...")
|
token, base_url = self._login_with_retry(base_url)
|
||||||
self.login_status = self.LOGIN_STATUS_WAITING
|
if not token:
|
||||||
login_result = self._qr_login(base_url)
|
|
||||||
if not login_result:
|
|
||||||
self.login_status = self.LOGIN_STATUS_IDLE
|
|
||||||
err = "[Weixin] QR login failed. Set weixin_token in config or run login again."
|
|
||||||
logger.error(err)
|
|
||||||
self.report_startup_error(err)
|
|
||||||
return
|
return
|
||||||
token = login_result["token"]
|
|
||||||
base_url = login_result.get("base_url", base_url)
|
|
||||||
|
|
||||||
self.api = WeixinApi(base_url=base_url, token=token, cdn_base_url=cdn_base_url)
|
self.api = WeixinApi(base_url=base_url, token=token, cdn_base_url=cdn_base_url)
|
||||||
self.login_status = self.LOGIN_STATUS_OK
|
self.login_status = self.LOGIN_STATUS_OK
|
||||||
@@ -114,9 +110,26 @@ class WeixinChannel(ChatChannel):
|
|||||||
f"如需重新扫码登录请删除该文件后重启")
|
f"如需重新扫码登录请删除该文件后重启")
|
||||||
self.report_startup_success()
|
self.report_startup_success()
|
||||||
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._poll_loop()
|
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):
|
def stop(self):
|
||||||
logger.info("[Weixin] stop() called")
|
logger.info("[Weixin] stop() called")
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
@@ -202,14 +215,21 @@ class WeixinChannel(ChatChannel):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
self._current_qr_url = qrcode_url
|
self._current_qr_url = qrcode_url
|
||||||
logger.info(f"[Weixin] QR code URL: {qrcode_url}")
|
logger.info(f"[Weixin] 微信二维码链接: {qrcode_url}")
|
||||||
self._print_qr(qrcode_url)
|
self._print_qr(qrcode_url)
|
||||||
self._notify_cloud_qrcode(qrcode_url)
|
self._notify_cloud_qrcode(qrcode_url)
|
||||||
print(" 等待扫码...\n")
|
print(" 等待扫码...\n")
|
||||||
|
|
||||||
scanned_printed = False
|
scanned_printed = False
|
||||||
|
refresh_count = 0
|
||||||
|
deadline = time.time() + QR_LOGIN_TIMEOUT_S
|
||||||
|
|
||||||
while not self._stop_event.is_set():
|
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:
|
try:
|
||||||
status_resp = api.poll_qr_status(qrcode)
|
status_resp = api.poll_qr_status(qrcode)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -226,14 +246,19 @@ class WeixinChannel(ChatChannel):
|
|||||||
print(" 已扫码,请在手机上确认...")
|
print(" 已扫码,请在手机上确认...")
|
||||||
scanned_printed = True
|
scanned_printed = True
|
||||||
elif status == "expired":
|
elif status == "expired":
|
||||||
print(" 二维码已过期,正在刷新...")
|
refresh_count += 1
|
||||||
|
if refresh_count >= QR_MAX_REFRESHES:
|
||||||
|
logger.warning(f"[Weixin] QR code refreshed {QR_MAX_REFRESHES} times, giving up")
|
||||||
|
print(f"\n 二维码已刷新 {QR_MAX_REFRESHES} 次仍未扫码,请重启后重试")
|
||||||
|
break
|
||||||
|
print(f" 二维码已过期,正在刷新({refresh_count}/{QR_MAX_REFRESHES})...")
|
||||||
try:
|
try:
|
||||||
qr_resp = api.fetch_qr_code()
|
qr_resp = api.fetch_qr_code()
|
||||||
qrcode = qr_resp.get("qrcode", "")
|
qrcode = qr_resp.get("qrcode", "")
|
||||||
qrcode_url = qr_resp.get("qrcode_img_content", "")
|
qrcode_url = qr_resp.get("qrcode_img_content", "")
|
||||||
scanned_printed = False
|
scanned_printed = False
|
||||||
self._current_qr_url = qrcode_url
|
self._current_qr_url = qrcode_url
|
||||||
logger.info(f"[Weixin] New QR code: {qrcode_url}")
|
logger.info(f"[Weixin] 微信二维码链接 ({refresh_count}/{QR_MAX_REFRESHES}): {qrcode_url}")
|
||||||
self._print_qr(qrcode_url)
|
self._print_qr(qrcode_url)
|
||||||
self._notify_cloud_qrcode(qrcode_url)
|
self._notify_cloud_qrcode(qrcode_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -267,8 +292,9 @@ class WeixinChannel(ChatChannel):
|
|||||||
|
|
||||||
self._stop_event.wait(1)
|
self._stop_event.wait(1)
|
||||||
|
|
||||||
logger.info("[Weixin] QR login cancelled by stop event")
|
|
||||||
self._current_qr_url = ""
|
self._current_qr_url = ""
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
logger.info("[Weixin] QR login cancelled by stop event")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# ── Long-poll loop ─────────────────────────────────────────────────
|
# ── Long-poll loop ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -184,12 +184,16 @@ class WeixinMessage(ChatMessage):
|
|||||||
logger.warning(f"[Weixin] Missing CDN params for media download (type={media_type})")
|
logger.warning(f"[Weixin] Missing CDN params for media download (type={media_type})")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
ext_map = {ITEM_IMAGE: ".jpg", ITEM_VIDEO: ".mp4", ITEM_FILE: "", ITEM_VOICE: ".silk"}
|
|
||||||
ext = ext_map.get(media_type, "")
|
|
||||||
if media_type == ITEM_FILE:
|
if media_type == ITEM_FILE:
|
||||||
ext = os.path.splitext(info.get("file_name", ""))[1] or ".bin"
|
original_name = info.get("file_name", "")
|
||||||
|
if original_name:
|
||||||
save_path = os.path.join(_get_tmp_dir(), f"wx_{self.msg_id}{ext}")
|
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:
|
try:
|
||||||
download_media_from_cdn(cdn_base_url, encrypt_param, aes_key, save_path)
|
download_media_from_cdn(cdn_base_url, encrypt_param, aes_key, save_path)
|
||||||
|
|||||||
1
cli/VERSION
Normal file
1
cli/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2.0.4
|
||||||
13
cli/__init__.py
Normal file
13
cli/__init__.py
Normal 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
4
cli/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Allow running as: python -m cli"""
|
||||||
|
from cli.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
76
cli/cli.py
Normal file
76
cli/cli.py
Normal 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
0
cli/commands/__init__.py
Normal file
29
cli/commands/context.py
Normal file
29
cli/commands/context.py
Normal 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")
|
||||||
63
cli/commands/install.py
Normal file
63
cli/commands/install.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""cow install-browser - Install Playwright + Chromium for the browser tool."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
def _has_display() -> bool:
|
||||||
|
"""Check if a graphical display is available (Linux only)."""
|
||||||
|
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_headless_linux() -> bool:
|
||||||
|
"""True when running on a Linux server without a display."""
|
||||||
|
return sys.platform == "linux" and not _has_display()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("install-browser")
|
||||||
|
def install_browser():
|
||||||
|
"""Install browser tool dependencies (Playwright + Chromium)."""
|
||||||
|
python = sys.executable
|
||||||
|
|
||||||
|
# Step 1: Install playwright package
|
||||||
|
click.echo(click.style("[1/3] Installing playwright Python package...", fg="yellow"))
|
||||||
|
ret = subprocess.call([python, "-m", "pip", "install", "playwright"])
|
||||||
|
if ret != 0:
|
||||||
|
click.echo(click.style("Failed to install playwright package.", fg="red"))
|
||||||
|
raise SystemExit(1)
|
||||||
|
click.echo(click.style("playwright package installed.", fg="green"))
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
# Step 2: System dependencies (Linux only)
|
||||||
|
if sys.platform == "linux":
|
||||||
|
click.echo(click.style("[2/3] Installing system dependencies (Linux)...", fg="yellow"))
|
||||||
|
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
||||||
|
if ret != 0:
|
||||||
|
click.echo(click.style(
|
||||||
|
"Could not auto-install system deps (may need sudo).\n"
|
||||||
|
f" Run manually: sudo {python} -m playwright install-deps chromium",
|
||||||
|
fg="yellow",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
click.echo(click.style(f"[2/3] Skipping system deps (not needed on {sys.platform}).", fg="yellow"))
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
# Step 3: Install Chromium (headless shell on Linux servers, full elsewhere)
|
||||||
|
click.echo(click.style("[3/3] Installing Chromium browser...", fg="yellow"))
|
||||||
|
cmd = [python, "-m", "playwright", "install", "chromium"]
|
||||||
|
if _is_headless_linux():
|
||||||
|
cmd.append("--only-shell")
|
||||||
|
click.echo(" (headless-only mode for Linux server)")
|
||||||
|
elif sys.platform == "linux":
|
||||||
|
click.echo(" (full browser for Linux desktop)")
|
||||||
|
|
||||||
|
ret = subprocess.call(cmd)
|
||||||
|
if ret != 0:
|
||||||
|
click.echo(click.style("Failed to install Chromium.", fg="red"))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
click.echo()
|
||||||
|
click.echo(click.style("Browser tool ready! Restart CowAgent to enable it.", fg="green"))
|
||||||
281
cli/commands/process.py
Normal file
281
cli/commands/process.py
Normal 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
|
||||||
|
click.echo("")
|
||||||
|
time.sleep(1)
|
||||||
|
ctx.invoke(start, no_logs=True)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
1243
cli/commands/skill.py
Normal file
1243
cli/commands/skill.py
Normal file
File diff suppressed because it is too large
Load Diff
62
cli/utils.py
Normal file
62
cli/utils.py
Normal 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"
|
||||||
@@ -222,7 +222,14 @@ class CloudClient(LinkAIClient):
|
|||||||
return
|
return
|
||||||
|
|
||||||
existing_ch = self.channel_mgr.get_channel(channel_type)
|
existing_ch = self.channel_mgr.get_channel(channel_type)
|
||||||
if existing_ch and not cred_changed:
|
skip_restart = existing_ch and not cred_changed
|
||||||
|
if skip_restart and channel_type in ("weixin", "wx"):
|
||||||
|
login_status = getattr(existing_ch, "login_status", "")
|
||||||
|
if login_status != "logged_in":
|
||||||
|
skip_restart = False
|
||||||
|
logger.info(f"[CloudClient] Channel '{channel_type}' not logged in "
|
||||||
|
f"(status={login_status}), forcing restart")
|
||||||
|
if skip_restart:
|
||||||
logger.info(f"[CloudClient] Channel '{channel_type}' already running with same config, "
|
logger.info(f"[CloudClient] Channel '{channel_type}' already running with same config, "
|
||||||
"skip restart, reporting status only")
|
"skip restart, reporting status only")
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
@@ -255,7 +262,14 @@ class CloudClient(LinkAIClient):
|
|||||||
).start()
|
).start()
|
||||||
else:
|
else:
|
||||||
existing_ch = self.channel_mgr.get_channel(channel_type)
|
existing_ch = self.channel_mgr.get_channel(channel_type)
|
||||||
if existing_ch and not cred_changed:
|
needs_restart = cred_changed or not existing_ch
|
||||||
|
if not needs_restart and channel_type in ("weixin", "wx"):
|
||||||
|
login_status = getattr(existing_ch, "login_status", "")
|
||||||
|
if login_status != "logged_in":
|
||||||
|
needs_restart = True
|
||||||
|
logger.info(f"[CloudClient] Channel '{channel_type}' not logged in "
|
||||||
|
f"(status={login_status}), forcing restart")
|
||||||
|
if existing_ch and not needs_restart:
|
||||||
logger.info(f"[CloudClient] Channel '{channel_type}' already running with same config, "
|
logger.info(f"[CloudClient] Channel '{channel_type}' already running with same config, "
|
||||||
"skip restart, reporting status only")
|
"skip restart, reporting status only")
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ RUN apt-get update \
|
|||||||
&& cp config-template.json config.json \
|
&& cp config-template.json config.json \
|
||||||
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
|
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
|
||||||
&& pip install --no-cache -r requirements.txt \
|
&& pip install --no-cache -r requirements.txt \
|
||||||
&& pip install --no-cache -r requirements-optional.txt \
|
&& pip install --no-cache -r requirements-optional.txt
|
||||||
&& pip install azure-cognitiveservices-speech
|
|
||||||
|
|
||||||
WORKDIR ${BUILD_PREFIX}
|
WORKDIR ${BUILD_PREFIX}
|
||||||
|
|
||||||
@@ -30,6 +29,4 @@ RUN chmod +x /entrypoint.sh \
|
|||||||
&& useradd -r -g agent -s /bin/bash -d /home/agent agent \
|
&& useradd -r -g agent -s /bin/bash -d /home/agent agent \
|
||||||
&& chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
|
&& chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
|
||||||
|
|
||||||
USER agent
|
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -43,9 +43,15 @@ fi
|
|||||||
# 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
|
cd $CHATGPT_ON_WECHAT_PREFIX
|
||||||
# excute
|
|
||||||
$CHATGPT_ON_WECHAT_EXEC
|
$CHATGPT_ON_WECHAT_EXEC
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
title: 微信
|
title: 微信
|
||||||
description: 将 CowAgent 接入个人微信
|
description: 将 CowAgent 接入个人微信(基于官方接口)
|
||||||
---
|
---
|
||||||
|
|
||||||
> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的收发。
|
> 接入个人微信,扫码登录即可使用,支持文本、图片、语音、文件、视频等消息的私聊收发。通过微信官方API进行接入,无安全风险,接入后会在会话中新增一个机器人助手,不影响当前账号的使用。
|
||||||
|
|
||||||
## 一、配置和运行
|
## 一、配置和运行
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,22 @@ title: DeepSeek
|
|||||||
description: DeepSeek model configuration
|
description: DeepSeek model configuration
|
||||||
---
|
---
|
||||||
|
|
||||||
Use OpenAI-compatible configuration:
|
Option 1: Native integration (recommended):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"deepseek_api_key": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `model` | `deepseek-chat` (DeepSeek-V3.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
|
```json
|
||||||
{
|
{
|
||||||
@@ -14,9 +29,4 @@ Use OpenAI-compatible configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `model` | `deepseek-chat` (DeepSeek-V3), `deepseek-reasoner` (DeepSeek-R1) |
|
|
||||||
| `bot_type` | Must be `openai` (OpenAI-compatible mode) |
|
|
||||||
| `open_ai_api_key` | Create at [DeepSeek Platform](https://platform.deepseek.com/api_keys) |
|
|
||||||
| `open_ai_api_base` | DeepSeek platform BASE URL |
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pip3 install -r requirements.txt
|
|||||||
pip3 install -r requirements-optional.txt
|
pip3 install -r requirements-optional.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 国内网络可使用镜像源加速:`pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple`
|
||||||
|
|
||||||
### 3. 配置
|
### 3. 配置
|
||||||
|
|
||||||
复制配置文件模板并编辑:
|
复制配置文件模板并编辑:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: CowAgent 的升级方式说明
|
|||||||
|
|
||||||
## 脚本升级(推荐)
|
## 脚本升级(推荐)
|
||||||
|
|
||||||
如果使用 `run.sh` 管理服务,执行以下命令即可一键升级:
|
如果使用 `run.sh` 管理服务,在项目根目录执行以下命令即可一键升级:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run.sh update
|
./run.sh update
|
||||||
|
|||||||
@@ -3,7 +3,22 @@ title: DeepSeek
|
|||||||
description: DeepSeekモデルの設定
|
description: DeepSeekモデルの設定
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenAI互換の設定を使用します:
|
方法1:公式接続(推奨):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"deepseek_api_key": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| パラメータ | 説明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `model` | `deepseek-chat`(DeepSeek-V3.2、非思考モード)、`deepseek-reasoner`(DeepSeek-R1、思考モード) |
|
||||||
|
| `deepseek_api_key` | [DeepSeek Platform](https://platform.deepseek.com/api_keys)で作成 |
|
||||||
|
| `deepseek_api_base` | オプション、デフォルトは `https://api.deepseek.com/v1`。サードパーティプロキシに変更可能 |
|
||||||
|
|
||||||
|
方法2:OpenAI互換方式:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -13,10 +28,3 @@ OpenAI互換の設定を使用します:
|
|||||||
"open_ai_api_base": "https://api.deepseek.com/v1"
|
"open_ai_api_base": "https://api.deepseek.com/v1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `model` | `deepseek-chat` (DeepSeek-V3)、`deepseek-reasoner` (DeepSeek-R1) |
|
|
||||||
| `bot_type` | `openai`を指定(OpenAI互換モード) |
|
|
||||||
| `open_ai_api_key` | [DeepSeek Platform](https://platform.deepseek.com/api_keys)で作成 |
|
|
||||||
| `open_ai_api_base` | DeepSeekプラットフォームのBASE URL |
|
|
||||||
|
|||||||
@@ -3,20 +3,29 @@ title: DeepSeek
|
|||||||
description: DeepSeek 模型配置
|
description: DeepSeek 模型配置
|
||||||
---
|
---
|
||||||
|
|
||||||
通过 OpenAI 兼容方式接入:
|
方式一:官方接入(推荐):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "deepseek-chat",
|
"model": "deepseek-chat",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"deepseek_api_key": "YOUR_API_KEY"
|
||||||
"open_ai_api_base": "https://api.deepseek.com/v1",
|
|
||||||
"bot_type": "openai"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `deepseek-chat`(DeepSeek-V3)、`deepseek-reasoner`(DeepSeek-R1) |
|
| `model` | `deepseek-chat`(DeepSeek-V3.2,非思考模式)、`deepseek-reasoner`(DeepSeek-R1,思考模式) |
|
||||||
| `bot_type` | 固定为 `openai`(OpenAI 兼容方式) |
|
| `deepseek_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
|
||||||
| `open_ai_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
|
| `deepseek_api_base` | 可选,默认为 `https://api.deepseek.com/v1`,可修改为第三方代理地址 |
|
||||||
| `open_ai_api_base` | DeepSeek 平台 BASE URL |
|
|
||||||
|
方式二:OpenAI 兼容方式接入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"bot_type": "openai",
|
||||||
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
|
"open_ai_api_base": "https://api.deepseek.com/v1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ def create_bot(bot_type):
|
|||||||
from models.baidu.baidu_wenxin import BaiduWenxinBot
|
from models.baidu.baidu_wenxin import BaiduWenxinBot
|
||||||
return BaiduWenxinBot()
|
return BaiduWenxinBot()
|
||||||
|
|
||||||
elif bot_type in (const.OPENAI, const.CHATGPT, const.DEEPSEEK): # OpenAI-compatible API
|
elif bot_type == const.DEEPSEEK:
|
||||||
|
from models.deepseek.deepseek_bot import DeepSeekBot
|
||||||
|
return DeepSeekBot()
|
||||||
|
|
||||||
|
elif bot_type in (const.OPENAI, const.CHATGPT): # OpenAI-compatible API
|
||||||
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||||
return ChatGPTBot()
|
return ChatGPTBot()
|
||||||
|
|
||||||
|
|||||||
0
models/deepseek/__init__.py
Normal file
0
models/deepseek/__init__.py
Normal file
160
models/deepseek/deepseek_bot.py
Normal file
160
models/deepseek/deepseek_bot.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
DeepSeek Bot — fully OpenAI-compatible, uses its own API key / base config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from models.bot import Bot
|
||||||
|
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||||
|
from models.session_manager import SessionManager
|
||||||
|
from bridge.context import ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from common import const
|
||||||
|
from common.log import logger
|
||||||
|
from config import conf, load_config
|
||||||
|
from .deepseek_session import DeepSeekSession
|
||||||
|
|
||||||
|
DEFAULT_API_BASE = "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekBot(Bot, OpenAICompatibleBot):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.sessions = SessionManager(
|
||||||
|
DeepSeekSession,
|
||||||
|
model=conf().get("model") or const.DEEPSEEK_CHAT,
|
||||||
|
)
|
||||||
|
conf_model = conf().get("model") or const.DEEPSEEK_CHAT
|
||||||
|
self.args = {
|
||||||
|
"model": conf_model,
|
||||||
|
"temperature": conf().get("temperature", 0.7),
|
||||||
|
"top_p": conf().get("top_p", 1.0),
|
||||||
|
"frequency_penalty": conf().get("frequency_penalty", 0.0),
|
||||||
|
"presence_penalty": conf().get("presence_penalty", 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- config helpers ----------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_key(self):
|
||||||
|
return conf().get("deepseek_api_key") or conf().get("open_ai_api_key")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_base(self):
|
||||||
|
url = (
|
||||||
|
conf().get("deepseek_api_base")
|
||||||
|
or conf().get("open_ai_api_base")
|
||||||
|
or DEFAULT_API_BASE
|
||||||
|
)
|
||||||
|
return url.rstrip("/")
|
||||||
|
|
||||||
|
def get_api_config(self):
|
||||||
|
"""OpenAICompatibleBot interface — used by call_with_tools()."""
|
||||||
|
return {
|
||||||
|
"api_key": self.api_key,
|
||||||
|
"api_base": self.api_base,
|
||||||
|
"model": conf().get("model", const.DEEPSEEK_CHAT),
|
||||||
|
"default_temperature": conf().get("temperature", 0.7),
|
||||||
|
"default_top_p": conf().get("top_p", 1.0),
|
||||||
|
"default_frequency_penalty": conf().get("frequency_penalty", 0.0),
|
||||||
|
"default_presence_penalty": conf().get("presence_penalty", 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- simple chat (non-agent mode) ----------
|
||||||
|
|
||||||
|
def reply(self, query, context=None):
|
||||||
|
if context.type == ContextType.TEXT:
|
||||||
|
logger.info("[DEEPSEEK] query={}".format(query))
|
||||||
|
|
||||||
|
session_id = context["session_id"]
|
||||||
|
reply = None
|
||||||
|
clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
|
||||||
|
if query in clear_memory_commands:
|
||||||
|
self.sessions.clear_session(session_id)
|
||||||
|
reply = Reply(ReplyType.INFO, "记忆已清除")
|
||||||
|
elif query == "#清除所有":
|
||||||
|
self.sessions.clear_all_session()
|
||||||
|
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
|
||||||
|
elif query == "#更新配置":
|
||||||
|
load_config()
|
||||||
|
reply = Reply(ReplyType.INFO, "配置已更新")
|
||||||
|
if reply:
|
||||||
|
return reply
|
||||||
|
|
||||||
|
session = self.sessions.session_query(query, session_id)
|
||||||
|
logger.debug("[DEEPSEEK] session query={}".format(session.messages))
|
||||||
|
|
||||||
|
new_args = self.args.copy()
|
||||||
|
reply_content = self.reply_text(session, args=new_args)
|
||||||
|
logger.debug(
|
||||||
|
"[DEEPSEEK] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
|
||||||
|
session.messages, session_id,
|
||||||
|
reply_content["content"], reply_content["completion_tokens"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
|
||||||
|
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||||
|
elif reply_content["completion_tokens"] > 0:
|
||||||
|
self.sessions.session_reply(
|
||||||
|
reply_content["content"], session_id, reply_content["total_tokens"],
|
||||||
|
)
|
||||||
|
reply = Reply(ReplyType.TEXT, reply_content["content"])
|
||||||
|
else:
|
||||||
|
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||||
|
logger.debug("[DEEPSEEK] reply {} used 0 tokens.".format(reply_content))
|
||||||
|
return reply
|
||||||
|
else:
|
||||||
|
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def reply_text(self, session, args=None, retry_count: int = 0) -> dict:
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + self.api_key,
|
||||||
|
}
|
||||||
|
body = args.copy()
|
||||||
|
body["messages"] = session.messages
|
||||||
|
|
||||||
|
res = requests.post(
|
||||||
|
f"{self.api_base}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
response = res.json()
|
||||||
|
return {
|
||||||
|
"total_tokens": response["usage"]["total_tokens"],
|
||||||
|
"completion_tokens": response["usage"]["completion_tokens"],
|
||||||
|
"content": response["choices"][0]["message"]["content"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
response = res.json()
|
||||||
|
error = response.get("error", {})
|
||||||
|
logger.error(
|
||||||
|
f"[DEEPSEEK] chat failed, status_code={res.status_code}, "
|
||||||
|
f"msg={error.get('message')}, type={error.get('type')}"
|
||||||
|
)
|
||||||
|
result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
|
||||||
|
need_retry = False
|
||||||
|
if res.status_code >= 500:
|
||||||
|
need_retry = retry_count < 2
|
||||||
|
elif res.status_code == 401:
|
||||||
|
result["content"] = "授权失败,请检查API Key是否正确"
|
||||||
|
elif res.status_code == 429:
|
||||||
|
result["content"] = "请求过于频繁,请稍后再试"
|
||||||
|
need_retry = retry_count < 2
|
||||||
|
|
||||||
|
if need_retry:
|
||||||
|
time.sleep(3)
|
||||||
|
return self.reply_text(session, args, retry_count + 1)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
if retry_count < 2:
|
||||||
|
return self.reply_text(session, args, retry_count + 1)
|
||||||
|
return {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
|
||||||
57
models/deepseek/deepseek_session.py
Normal file
57
models/deepseek/deepseek_session.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from models.session_manager import Session
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekSession(Session):
|
||||||
|
def __init__(self, session_id, system_prompt=None, model="deepseek-chat"):
|
||||||
|
super().__init__(session_id, system_prompt)
|
||||||
|
self.model = model
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def discard_exceeding(self, max_tokens, cur_tokens=None):
|
||||||
|
precise = True
|
||||||
|
try:
|
||||||
|
cur_tokens = self.calc_tokens()
|
||||||
|
except Exception as e:
|
||||||
|
precise = False
|
||||||
|
if cur_tokens is None:
|
||||||
|
raise e
|
||||||
|
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
|
||||||
|
while cur_tokens > max_tokens:
|
||||||
|
if len(self.messages) > 2:
|
||||||
|
self.messages.pop(1)
|
||||||
|
elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
|
||||||
|
self.messages.pop(1)
|
||||||
|
if precise:
|
||||||
|
cur_tokens = self.calc_tokens()
|
||||||
|
else:
|
||||||
|
cur_tokens = cur_tokens - max_tokens
|
||||||
|
break
|
||||||
|
elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
|
||||||
|
logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(
|
||||||
|
max_tokens, cur_tokens, len(self.messages)))
|
||||||
|
break
|
||||||
|
if precise:
|
||||||
|
cur_tokens = self.calc_tokens()
|
||||||
|
else:
|
||||||
|
cur_tokens = cur_tokens - max_tokens
|
||||||
|
return cur_tokens
|
||||||
|
|
||||||
|
def calc_tokens(self):
|
||||||
|
return num_tokens_from_messages(self.messages, self.model)
|
||||||
|
|
||||||
|
|
||||||
|
def num_tokens_from_messages(messages, model):
|
||||||
|
tokens = 0
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
tokens += len(content)
|
||||||
|
elif isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
tokens += len(block.get("text", ""))
|
||||||
|
return tokens
|
||||||
@@ -34,6 +34,13 @@ class GoogleGeminiBot(Bot):
|
|||||||
def api_key(self):
|
def api_key(self):
|
||||||
return conf().get("gemini_api_key")
|
return conf().get("gemini_api_key")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self):
|
||||||
|
model_name = conf().get("model") or "gemini-3.1-pro-preview"
|
||||||
|
if model_name == "gemini":
|
||||||
|
model_name = "gemini-3.1-pro-preview"
|
||||||
|
return model_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_base(self):
|
def api_base(self):
|
||||||
base = conf().get("gemini_api_base", "").strip()
|
base = conf().get("gemini_api_base", "").strip()
|
||||||
|
|||||||
1
plugins/cow_cli/__init__.py
Normal file
1
plugins/cow_cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .cow_cli import CowCliPlugin
|
||||||
799
plugins/cow_cli/cow_cli.py
Normal file
799
plugins/cow_cli/cow_cli.py
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
"""
|
||||||
|
CowCli plugin - Intercept cow/slash commands in chat messages.
|
||||||
|
|
||||||
|
Matches messages like:
|
||||||
|
cow skill list
|
||||||
|
cow context clear
|
||||||
|
/skill list
|
||||||
|
/context clear
|
||||||
|
/status
|
||||||
|
|
||||||
|
Does NOT match:
|
||||||
|
cow是什么
|
||||||
|
cow真好用
|
||||||
|
/开头但不是已知命令
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import plugins
|
||||||
|
from plugins import Plugin, Event, EventContext, EventAction
|
||||||
|
from bridge.context import ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from common.log import logger
|
||||||
|
from cli import __version__
|
||||||
|
|
||||||
|
|
||||||
|
# Known top-level subcommands that cow supports
|
||||||
|
KNOWN_COMMANDS = {
|
||||||
|
"help", "version", "status", "logs",
|
||||||
|
"start", "stop", "restart",
|
||||||
|
"skill", "context", "config",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Commands that can only run from the CLI (terminal), not in chat
|
||||||
|
CLI_ONLY_COMMANDS = {"start", "stop", "restart"}
|
||||||
|
|
||||||
|
# Commands that can only run from chat (need access to in-process memory)
|
||||||
|
CHAT_ONLY_COMMANDS = set() # context is allowed in both, but behaves differently
|
||||||
|
|
||||||
|
|
||||||
|
@plugins.register(
|
||||||
|
name="cow_cli",
|
||||||
|
desc="Handle cow/slash commands in chat messages",
|
||||||
|
version="0.1.0",
|
||||||
|
author="CowAgent",
|
||||||
|
desire_priority=1000,
|
||||||
|
)
|
||||||
|
class CowCliPlugin(Plugin):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
|
||||||
|
logger.debug("[CowCli] initialized")
|
||||||
|
|
||||||
|
def on_handle_context(self, e_context: EventContext):
|
||||||
|
if e_context["context"].type != ContextType.TEXT:
|
||||||
|
return
|
||||||
|
|
||||||
|
content = e_context["context"].content.strip()
|
||||||
|
parsed = self._parse_command(content)
|
||||||
|
if not parsed:
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd, args = parsed
|
||||||
|
logger.info(f"[CowCli] intercepted command: {cmd} {args}")
|
||||||
|
|
||||||
|
result = self._dispatch(cmd, args, e_context)
|
||||||
|
|
||||||
|
reply = Reply(ReplyType.TEXT, result)
|
||||||
|
e_context["reply"] = reply
|
||||||
|
e_context.action = EventAction.BREAK_PASS
|
||||||
|
|
||||||
|
def _parse_command(self, content: str):
|
||||||
|
"""
|
||||||
|
Parse cow command from message text.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
cow <command> [args...] e.g. "cow skill list"
|
||||||
|
/<command> [args...] e.g. "/skill list"
|
||||||
|
|
||||||
|
Returns (command, args_string) or None if not a cow command.
|
||||||
|
"""
|
||||||
|
parts = None
|
||||||
|
|
||||||
|
if content.startswith("/"):
|
||||||
|
rest = content[1:].strip()
|
||||||
|
if rest:
|
||||||
|
parts = rest.split(None, 1)
|
||||||
|
elif content.startswith("cow "):
|
||||||
|
rest = content[4:].strip()
|
||||||
|
if rest:
|
||||||
|
parts = rest.split(None, 1)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
if cmd not in KNOWN_COMMANDS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
return cmd, args
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Command dispatch
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _dispatch(self, cmd: str, args: str, e_context: EventContext) -> str:
|
||||||
|
if cmd in CLI_ONLY_COMMANDS:
|
||||||
|
return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}"
|
||||||
|
|
||||||
|
handler = getattr(self, f"_cmd_{cmd}", None)
|
||||||
|
if handler:
|
||||||
|
try:
|
||||||
|
return handler(args, e_context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CowCli] command '{cmd}' failed: {e}")
|
||||||
|
return f"命令执行失败: {e}"
|
||||||
|
|
||||||
|
return f"未知命令: {cmd}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# help / version
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cmd_help(self, args: str, e_context: EventContext) -> str:
|
||||||
|
lines = [
|
||||||
|
"📋 CowAgent 命令列表",
|
||||||
|
"",
|
||||||
|
" /help 显示此帮助",
|
||||||
|
" /version 查看版本",
|
||||||
|
" /status 查看运行状态",
|
||||||
|
" /logs [N] 查看最近N条日志 (默认20)",
|
||||||
|
" /context 查看当前对话上下文信息",
|
||||||
|
" /context clear 清除当前对话上下文",
|
||||||
|
" /skill list 查看已安装的技能",
|
||||||
|
" /skill list --remote 浏览技能广场",
|
||||||
|
" /skill search <关键词> 搜索技能",
|
||||||
|
" /skill install <名称> 安装技能",
|
||||||
|
" /skill info <名称> 查看技能详情",
|
||||||
|
" /config 查看当前配置",
|
||||||
|
" /config <key> 查看某项配置",
|
||||||
|
" /config <key> <val> 修改配置",
|
||||||
|
"",
|
||||||
|
"💡 也可以用 cow <command> 代替 /<command>",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _cmd_version(self, args: str, e_context: EventContext) -> str:
|
||||||
|
return f"CowAgent v{__version__}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# status
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cmd_status(self, args: str, e_context: EventContext) -> str:
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
cfg = conf()
|
||||||
|
lines = ["📊 CowAgent 运行状态", ""]
|
||||||
|
|
||||||
|
lines.append(f" 版本: v{__version__}")
|
||||||
|
lines.append(f" 进程: PID {os.getpid()}")
|
||||||
|
|
||||||
|
channel = cfg.get("channel_type", "unknown")
|
||||||
|
if isinstance(channel, list):
|
||||||
|
channel = ", ".join(channel)
|
||||||
|
lines.append(f" 通道: {channel}")
|
||||||
|
|
||||||
|
model_name = cfg.get("model", "unknown")
|
||||||
|
lines.append(f" 模型: {model_name}")
|
||||||
|
|
||||||
|
mode = "Agent" if cfg.get("agent") else "Chat"
|
||||||
|
lines.append(f" 模式: {mode}")
|
||||||
|
|
||||||
|
session_id = self._get_session_id(e_context)
|
||||||
|
agent = self._get_agent(session_id)
|
||||||
|
if agent:
|
||||||
|
lines.append("")
|
||||||
|
with agent.messages_lock:
|
||||||
|
msg_count = len(agent.messages)
|
||||||
|
lines.append(f" 会话消息数: {msg_count}")
|
||||||
|
|
||||||
|
if agent.skill_manager:
|
||||||
|
total = len(agent.skill_manager.skills)
|
||||||
|
enabled = sum(
|
||||||
|
1 for v in agent.skill_manager.skills_config.values()
|
||||||
|
if v.get("enabled", True)
|
||||||
|
)
|
||||||
|
lines.append(f" 已加载技能: {enabled}/{total}")
|
||||||
|
else:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f" Agent: 未初始化 (首次对话后自动创建)")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# logs
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cmd_logs(self, args: str, e_context: EventContext) -> str:
|
||||||
|
num_lines = 20
|
||||||
|
if args.strip().isdigit():
|
||||||
|
num_lines = min(int(args.strip()), 50)
|
||||||
|
|
||||||
|
log_file = self._find_log_file()
|
||||||
|
if not log_file:
|
||||||
|
return "未找到日志文件"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
tail = all_lines[-num_lines:]
|
||||||
|
content = "".join(tail).strip()
|
||||||
|
if not content:
|
||||||
|
return "日志为空"
|
||||||
|
return f"📄 最近 {len(tail)} 条日志:\n\n{content}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"读取日志失败: {e}"
|
||||||
|
|
||||||
|
def _find_log_file(self) -> str:
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
candidates = [
|
||||||
|
os.path.join(project_root, "nohup.out"),
|
||||||
|
os.path.join(project_root, "run.log"),
|
||||||
|
]
|
||||||
|
import glob as glob_mod
|
||||||
|
candidates.extend(sorted(glob_mod.glob(os.path.join(project_root, "logs", "*.log")), reverse=True))
|
||||||
|
for f in candidates:
|
||||||
|
if os.path.isfile(f) and os.path.getsize(f) > 0:
|
||||||
|
return f
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# context
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cmd_context(self, args: str, e_context: EventContext) -> str:
|
||||||
|
session_id = self._get_session_id(e_context)
|
||||||
|
agent = self._get_agent(session_id)
|
||||||
|
|
||||||
|
sub = args.strip().lower()
|
||||||
|
if sub == "clear":
|
||||||
|
return self._context_clear(agent, session_id)
|
||||||
|
else:
|
||||||
|
return self._context_info(agent, session_id)
|
||||||
|
|
||||||
|
def _context_info(self, agent, session_id: str) -> str:
|
||||||
|
if not agent:
|
||||||
|
return "⚠️ Agent 未初始化,暂无上下文信息"
|
||||||
|
|
||||||
|
with agent.messages_lock:
|
||||||
|
messages = agent.messages.copy()
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return "当前对话上下文为空"
|
||||||
|
|
||||||
|
user_msgs = sum(1 for m in messages if m.get("role") == "user")
|
||||||
|
assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
|
||||||
|
tool_msgs = sum(1 for m in messages if m.get("role") == "tool")
|
||||||
|
|
||||||
|
total_chars = sum(len(str(m.get("content", ""))) for m in messages)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"💬 当前对话上下文",
|
||||||
|
"",
|
||||||
|
f" 会话: {session_id or 'default'}",
|
||||||
|
f" 总消息数: {len(messages)}",
|
||||||
|
f" 用户消息: {user_msgs}",
|
||||||
|
f" 助手回复: {assistant_msgs}",
|
||||||
|
f" 工具调用: {tool_msgs}",
|
||||||
|
f" 内容总长度: ~{total_chars} 字符",
|
||||||
|
"",
|
||||||
|
" 发送 /context clear 可清除对话上下文",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _context_clear(self, agent, session_id: str) -> str:
|
||||||
|
if not agent:
|
||||||
|
return "⚠️ Agent 未初始化"
|
||||||
|
|
||||||
|
with agent.messages_lock:
|
||||||
|
count = len(agent.messages)
|
||||||
|
agent.messages.clear()
|
||||||
|
|
||||||
|
return f"✅ 已清除当前对话上下文 ({count} 条消息)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# config
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CONFIG_WRITABLE = {
|
||||||
|
"model",
|
||||||
|
"agent_max_context_tokens",
|
||||||
|
"agent_max_context_turns",
|
||||||
|
"agent_max_steps",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CONFIG_READABLE = _CONFIG_WRITABLE | {"channel_type"}
|
||||||
|
|
||||||
|
def _cmd_config(self, args: str, e_context: EventContext) -> str:
|
||||||
|
from config import conf, load_config
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
parts = args.strip().split(None, 1)
|
||||||
|
if not parts:
|
||||||
|
return self._config_show_all()
|
||||||
|
|
||||||
|
key = parts[0].lower()
|
||||||
|
if len(parts) == 1:
|
||||||
|
return self._config_get(key)
|
||||||
|
|
||||||
|
value_str = parts[1].strip()
|
||||||
|
return self._config_set(key, value_str)
|
||||||
|
|
||||||
|
def _config_show_all(self) -> str:
|
||||||
|
from config import conf
|
||||||
|
cfg = conf()
|
||||||
|
lines = ["⚙️ 当前配置", ""]
|
||||||
|
for key in sorted(self._CONFIG_READABLE):
|
||||||
|
val = cfg.get(key, "")
|
||||||
|
lines.append(f" {key}: {val}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
lines.append("💡 /config <key> 查看配置")
|
||||||
|
lines.append("💡 /config <key> <val> 修改配置")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _config_get(self, key: str) -> str:
|
||||||
|
from config import conf
|
||||||
|
if key not in self._CONFIG_READABLE:
|
||||||
|
available = ", ".join(sorted(self._CONFIG_READABLE))
|
||||||
|
return f"不支持查看 '{key}'\n\n可查看的配置项: {available}"
|
||||||
|
val = conf().get(key, "")
|
||||||
|
return f"⚙️ {key}: {val}"
|
||||||
|
|
||||||
|
def _config_set(self, key: str, value_str: str) -> str:
|
||||||
|
from config import conf, load_config
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
if key not in self._CONFIG_WRITABLE:
|
||||||
|
if key in self._CONFIG_READABLE:
|
||||||
|
return f"⚠️ '{key}' 为只读配置,不支持修改"
|
||||||
|
available = ", ".join(sorted(self._CONFIG_WRITABLE))
|
||||||
|
return f"不支持修改 '{key}'\n\n可修改的配置项: {available}"
|
||||||
|
|
||||||
|
old_val = conf().get(key, "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_val = _json.loads(value_str)
|
||||||
|
except (_json.JSONDecodeError, ValueError):
|
||||||
|
if value_str.lower() == "true":
|
||||||
|
new_val = True
|
||||||
|
elif value_str.lower() == "false":
|
||||||
|
new_val = False
|
||||||
|
else:
|
||||||
|
new_val = value_str
|
||||||
|
|
||||||
|
updates = {key: new_val}
|
||||||
|
|
||||||
|
if key == "model" and conf().get("bot_type"):
|
||||||
|
resolved = self._resolve_bot_type_for_model(str(new_val))
|
||||||
|
if resolved:
|
||||||
|
updates["bot_type"] = resolved
|
||||||
|
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
config_path = os.path.join(project_root, "config.json")
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
file_config = _json.load(f)
|
||||||
|
file_config.update(updates)
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
_json.dump(file_config, f, indent=4, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
return f"写入 config.json 失败: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
load_config()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[CowCli] config reload warning: {e}")
|
||||||
|
|
||||||
|
result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}"
|
||||||
|
if "bot_type" in updates and updates["bot_type"] != conf().get("bot_type"):
|
||||||
|
result += f"\n bot_type: → {updates['bot_type']}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_bot_type_for_model(model_name: str) -> str:
|
||||||
|
"""Resolve bot_type from model name, reusing AgentBridge mapping."""
|
||||||
|
from common import const
|
||||||
|
_EXACT = {
|
||||||
|
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
|
||||||
|
"xunfei": const.XUNFEI, const.QWEN: const.QWEN,
|
||||||
|
const.MODELSCOPE: const.MODELSCOPE,
|
||||||
|
const.MOONSHOT: const.MOONSHOT,
|
||||||
|
"moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT,
|
||||||
|
"moonshot-v1-128k": const.MOONSHOT,
|
||||||
|
}
|
||||||
|
_PREFIX = [
|
||||||
|
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE),
|
||||||
|
("qvq", const.QWEN_DASHSCOPE),
|
||||||
|
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI),
|
||||||
|
("claude", const.CLAUDEAPI),
|
||||||
|
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||||
|
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||||
|
]
|
||||||
|
if not model_name:
|
||||||
|
return const.OPENAI
|
||||||
|
if model_name in _EXACT:
|
||||||
|
return _EXACT[model_name]
|
||||||
|
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
||||||
|
return const.MiniMax
|
||||||
|
if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
|
||||||
|
return const.QWEN_DASHSCOPE
|
||||||
|
for prefix, btype in _PREFIX:
|
||||||
|
if model_name.startswith(prefix):
|
||||||
|
return btype
|
||||||
|
return const.OPENAI
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# skill
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cmd_skill(self, args: str, e_context: EventContext) -> str:
|
||||||
|
parts = args.strip().split(None, 1)
|
||||||
|
sub = parts[0].lower() if parts else ""
|
||||||
|
sub_args = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
if sub == "list":
|
||||||
|
return self._skill_list(sub_args)
|
||||||
|
elif sub == "search":
|
||||||
|
return self._skill_search(sub_args)
|
||||||
|
elif sub == "install":
|
||||||
|
return self._skill_install(sub_args, e_context)
|
||||||
|
elif sub == "uninstall":
|
||||||
|
return self._skill_uninstall(sub_args)
|
||||||
|
elif sub == "info":
|
||||||
|
return self._skill_info(sub_args)
|
||||||
|
elif sub == "enable":
|
||||||
|
return self._skill_set_enabled(sub_args, True)
|
||||||
|
elif sub == "disable":
|
||||||
|
return self._skill_set_enabled(sub_args, False)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
"用法: /skill <子命令>\n\n"
|
||||||
|
"子命令:\n"
|
||||||
|
" list [--remote] 查看技能列表\n"
|
||||||
|
" search <关键词> 搜索技能\n"
|
||||||
|
" install <名称> 安装技能\n"
|
||||||
|
" uninstall <名称> 卸载技能\n"
|
||||||
|
" info <名称> 查看技能详情\n"
|
||||||
|
" enable <名称> 启用技能\n"
|
||||||
|
" disable <名称> 禁用技能"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _skill_list_local(self) -> str:
|
||||||
|
from cli.utils import load_skills_config, get_skills_dir, get_builtin_skills_dir
|
||||||
|
config = load_skills_config()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
skills_dir = get_skills_dir()
|
||||||
|
builtin_dir = get_builtin_skills_dir()
|
||||||
|
entries = []
|
||||||
|
for d, source in [(builtin_dir, "builtin"), (skills_dir, "custom")]:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for name in sorted(os.listdir(d)):
|
||||||
|
skill_path = os.path.join(d, name)
|
||||||
|
if os.path.isdir(skill_path) and not name.startswith("."):
|
||||||
|
if os.path.exists(os.path.join(skill_path, "SKILL.md")):
|
||||||
|
entries.append({"name": name, "source": source, "enabled": True})
|
||||||
|
if not entries:
|
||||||
|
return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场"
|
||||||
|
config = {e["name"]: e for e in entries}
|
||||||
|
|
||||||
|
sorted_entries = sorted(config.values(), key=lambda e: e.get("name", ""))
|
||||||
|
enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True))
|
||||||
|
|
||||||
|
lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""]
|
||||||
|
for entry in sorted_entries:
|
||||||
|
name = entry.get("name", "")
|
||||||
|
enabled = entry.get("enabled", True)
|
||||||
|
source = entry.get("source", "")
|
||||||
|
icon = "✅" if enabled else "⏸️"
|
||||||
|
desc = entry.get("description", "")
|
||||||
|
if len(desc) > 50:
|
||||||
|
desc = desc[:47] + "…"
|
||||||
|
line = f"{icon} {name}"
|
||||||
|
if desc:
|
||||||
|
line += f"\n {desc}"
|
||||||
|
if source:
|
||||||
|
line += f"\n 来源: {source}"
|
||||||
|
lines.append(line)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
lines.append("💡 /skill list --remote 浏览技能广场")
|
||||||
|
lines.append("💡 /skill info <名称> 查看详情")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _skill_list(self, args: str) -> str:
|
||||||
|
parts = args.strip().split()
|
||||||
|
if "--remote" in parts or "-r" in parts:
|
||||||
|
page = 1
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p == "--page" and i + 1 < len(parts) and parts[i + 1].isdigit():
|
||||||
|
page = max(1, int(parts[i + 1]))
|
||||||
|
return self._skill_list_remote(page=page)
|
||||||
|
return self._skill_list_local()
|
||||||
|
|
||||||
|
_REMOTE_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
def _skill_list_remote(self, page: int = 1) -> str:
|
||||||
|
import requests
|
||||||
|
from cli.utils import SKILL_HUB_API, load_skills_config
|
||||||
|
page_size = self._REMOTE_PAGE_SIZE
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{SKILL_HUB_API}/skills",
|
||||||
|
params={"page": page, "limit": page_size},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
skills = data.get("skills", [])
|
||||||
|
total = data.get("total", len(skills))
|
||||||
|
except Exception as e:
|
||||||
|
return f"获取技能广场失败: {e}"
|
||||||
|
|
||||||
|
if not skills and page == 1:
|
||||||
|
return "技能广场暂无可用技能"
|
||||||
|
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
page = min(page, total_pages)
|
||||||
|
installed = set(load_skills_config().keys())
|
||||||
|
|
||||||
|
lines = [f"🌐 技能广场 (共 {total} 个技能)", ""]
|
||||||
|
for s in skills:
|
||||||
|
name = s.get("name", "")
|
||||||
|
display = s.get("display_name", "") or name
|
||||||
|
desc = s.get("description", "")
|
||||||
|
if len(desc) > 50:
|
||||||
|
desc = desc[:47] + "…"
|
||||||
|
badge = " [已安装]" if name in installed else ""
|
||||||
|
lines.append(f"📌 {display}{badge}")
|
||||||
|
lines.append(f" 名称: {name}")
|
||||||
|
if desc:
|
||||||
|
lines.append(f" {desc}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
lines.append(f"📄 第 {page}/{total_pages} 页")
|
||||||
|
if page < total_pages:
|
||||||
|
lines.append(f"💡 /skill list --remote --page {page + 1} 下一页")
|
||||||
|
if page > 1:
|
||||||
|
lines.append(f"💡 /skill list --remote --page {page - 1} 上一页")
|
||||||
|
lines.append("💡 /skill install <名称> 安装技能")
|
||||||
|
lines.append("💡 /skill search <关键词> 搜索技能")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _skill_search(self, query: str) -> str:
|
||||||
|
if not query:
|
||||||
|
return "请指定搜索关键词: /skill search <关键词>"
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cli.utils import SKILL_HUB_API, load_skills_config
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{SKILL_HUB_API}/skills/search", params={"q": query}, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
skills = resp.json().get("skills", [])
|
||||||
|
except Exception as e:
|
||||||
|
return f"搜索失败: {e}"
|
||||||
|
|
||||||
|
if not skills:
|
||||||
|
return f"未找到与「{query}」相关的技能"
|
||||||
|
|
||||||
|
installed = set(load_skills_config().keys())
|
||||||
|
lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""]
|
||||||
|
for s in skills:
|
||||||
|
name = s.get("name", "")
|
||||||
|
display = s.get("display_name", "") or name
|
||||||
|
desc = s.get("description", "")
|
||||||
|
if len(desc) > 50:
|
||||||
|
desc = desc[:47] + "…"
|
||||||
|
badge = " [已安装]" if name in installed else ""
|
||||||
|
lines.append(f"📌 {display}{badge}")
|
||||||
|
lines.append(f" 名称: {name}")
|
||||||
|
if desc:
|
||||||
|
lines.append(f" {desc}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
lines.append("💡 /skill install <名称> 安装技能")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _skill_install(self, name: str, e_context: EventContext) -> str:
|
||||||
|
if not name:
|
||||||
|
return "请指定要安装的技能: /skill install <名称>"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cli.commands.skill import install_skill
|
||||||
|
result = install_skill(name)
|
||||||
|
|
||||||
|
if result.error:
|
||||||
|
return f"安装失败: {result.error}"
|
||||||
|
|
||||||
|
if not result.installed:
|
||||||
|
return "\n".join(result.messages) if result.messages else "未找到可安装的技能"
|
||||||
|
|
||||||
|
return self._format_install_result(result)
|
||||||
|
except Exception as e:
|
||||||
|
return f"安装失败: {e}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_install_result(result) -> str:
|
||||||
|
"""Format InstallResult into a chat-friendly message."""
|
||||||
|
from cli.commands.skill import _read_skill_description
|
||||||
|
from cli.utils import get_skills_dir
|
||||||
|
skills_dir = get_skills_dir()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for skill_name in result.installed:
|
||||||
|
desc = _read_skill_description(os.path.join(skills_dir, skill_name))
|
||||||
|
lines.append(f"✅ {skill_name}")
|
||||||
|
if desc:
|
||||||
|
if len(desc) > 60:
|
||||||
|
desc = desc[:57] + "…"
|
||||||
|
lines.append(f" {desc}")
|
||||||
|
|
||||||
|
if len(result.installed) > 1:
|
||||||
|
lines.append(f"\n共安装 {len(result.installed)} 个技能")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _skill_uninstall(self, name: str) -> str:
|
||||||
|
if not name:
|
||||||
|
return "请指定要卸载的技能: /skill uninstall <名称>"
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
from cli.utils import get_skills_dir
|
||||||
|
|
||||||
|
skills_dir = get_skills_dir()
|
||||||
|
skill_dir = os.path.join(skills_dir, name)
|
||||||
|
|
||||||
|
if not os.path.exists(skill_dir):
|
||||||
|
skill_dir = self._resolve_skill_dir(name, skills_dir)
|
||||||
|
|
||||||
|
if not skill_dir:
|
||||||
|
return f"技能 '{name}' 未安装"
|
||||||
|
|
||||||
|
shutil.rmtree(skill_dir)
|
||||||
|
|
||||||
|
config_path = os.path.join(skills_dir, "skills_config.json")
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
config.pop(name, None)
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return f"✅ 技能 '{name}' 已卸载"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_skill_dir(name: str, skills_dir: str):
|
||||||
|
"""Find actual directory for a skill whose folder name may differ from its config name."""
|
||||||
|
if not os.path.isdir(skills_dir):
|
||||||
|
return None
|
||||||
|
for entry in os.listdir(skills_dir):
|
||||||
|
entry_path = os.path.join(skills_dir, entry)
|
||||||
|
if not os.path.isdir(entry_path) or entry.startswith("."):
|
||||||
|
continue
|
||||||
|
if entry == name or entry.startswith(name + "-") or entry.endswith("-" + name):
|
||||||
|
skill_md = os.path.join(entry_path, "SKILL.md")
|
||||||
|
if os.path.exists(skill_md):
|
||||||
|
return entry_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_frontmatter(content: str):
|
||||||
|
"""Strip YAML frontmatter and return (metadata_dict, body)."""
|
||||||
|
if not content.startswith("---"):
|
||||||
|
return {}, content
|
||||||
|
end = content.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}, content
|
||||||
|
fm_text = content[3:end].strip()
|
||||||
|
body = content[end + 4:].lstrip("\n")
|
||||||
|
meta = {}
|
||||||
|
for line in fm_text.split("\n"):
|
||||||
|
if ":" in line:
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
meta[key.strip()] = val.strip().strip('"').strip("'")
|
||||||
|
return meta, body
|
||||||
|
|
||||||
|
def _skill_info(self, name: str) -> str:
|
||||||
|
if not name:
|
||||||
|
return "请指定技能名称: /skill info <名称>"
|
||||||
|
|
||||||
|
from cli.utils import get_skills_dir, get_builtin_skills_dir
|
||||||
|
|
||||||
|
skills_dir = get_skills_dir()
|
||||||
|
builtin_dir = get_builtin_skills_dir()
|
||||||
|
|
||||||
|
skill_dir = None
|
||||||
|
source = None
|
||||||
|
for d, src in [(skills_dir, "custom"), (builtin_dir, "builtin")]:
|
||||||
|
candidate = os.path.join(d, name)
|
||||||
|
if os.path.isdir(candidate):
|
||||||
|
skill_dir = candidate
|
||||||
|
source = src
|
||||||
|
break
|
||||||
|
|
||||||
|
if not skill_dir:
|
||||||
|
resolved = self._resolve_skill_dir(name, skills_dir)
|
||||||
|
if resolved:
|
||||||
|
skill_dir = resolved
|
||||||
|
source = "custom"
|
||||||
|
|
||||||
|
if not skill_dir:
|
||||||
|
return f"技能 '{name}' 未找到"
|
||||||
|
|
||||||
|
skill_md = os.path.join(skill_dir, "SKILL.md")
|
||||||
|
if not os.path.exists(skill_md):
|
||||||
|
return f"技能 '{name}' 没有 SKILL.md 文件"
|
||||||
|
|
||||||
|
with open(skill_md, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
meta, body = self._strip_frontmatter(content)
|
||||||
|
|
||||||
|
header_lines = [f"📖 技能: {name} [{source}]", ""]
|
||||||
|
desc = meta.get("description", "")
|
||||||
|
if desc:
|
||||||
|
header_lines.append(f" {desc}")
|
||||||
|
header_lines.append("")
|
||||||
|
|
||||||
|
lines = body.split("\n")
|
||||||
|
preview = "\n".join(lines[:30])
|
||||||
|
result = "\n".join(header_lines) + preview
|
||||||
|
if len(lines) > 30:
|
||||||
|
result += f"\n\n... ({len(lines) - 30} more lines)"
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _skill_set_enabled(self, name: str, enabled: bool) -> str:
|
||||||
|
if not name:
|
||||||
|
action = "启用" if enabled else "禁用"
|
||||||
|
return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>"
|
||||||
|
|
||||||
|
import json
|
||||||
|
from cli.utils import get_skills_dir
|
||||||
|
|
||||||
|
skills_dir = get_skills_dir()
|
||||||
|
config_path = os.path.join(skills_dir, "skills_config.json")
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
return "技能配置文件不存在"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
return f"读取配置失败: {e}"
|
||||||
|
|
||||||
|
if name not in config:
|
||||||
|
return f"技能 '{name}' 未在配置中找到"
|
||||||
|
|
||||||
|
config[name]["enabled"] = enabled
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
action = "启用" if enabled else "禁用"
|
||||||
|
icon = "✅" if enabled else "⬚"
|
||||||
|
return f"{icon} 技能 '{name}' 已{action}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_session_id(self, e_context: EventContext) -> str:
|
||||||
|
context = e_context["context"]
|
||||||
|
return context.kwargs.get("session_id") or context.get("session_id", "")
|
||||||
|
|
||||||
|
def _get_agent(self, session_id: str):
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
bridge = Bridge()
|
||||||
|
if not bridge._agent_bridge:
|
||||||
|
return None
|
||||||
|
return bridge._agent_bridge.get_agent(session_id=session_id or None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_help_text(self, **kwargs):
|
||||||
|
return "在对话中使用 /help 或 cow help 查看可用命令"
|
||||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=45.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "cowagent"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "CowAgent - AI Agent on WeChat and more"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"click>=8.0",
|
||||||
|
"requests>=2.28.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
cow = "cli.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["cli*"]
|
||||||
@@ -2,14 +2,8 @@ tiktoken>=0.3.2 # openai calculate token
|
|||||||
|
|
||||||
#voice
|
#voice
|
||||||
pydub>=0.25.1 # need ffmpeg
|
pydub>=0.25.1 # need ffmpeg
|
||||||
SpeechRecognition # google speech to text
|
|
||||||
gTTS>=2.3.1 # google text to speech
|
gTTS>=2.3.1 # google text to speech
|
||||||
pyttsx3>=2.90 # pytsx text to speech
|
|
||||||
baidu_aip>=4.16.10 # baidu voice
|
|
||||||
azure-cognitiveservices-speech # azure voice
|
|
||||||
edge-tts # edge-tts
|
edge-tts # edge-tts
|
||||||
numpy<=1.24.2
|
|
||||||
langid # language detect
|
|
||||||
elevenlabs==1.0.3 # elevenlabs TTS
|
elevenlabs==1.0.3 # elevenlabs TTS
|
||||||
|
|
||||||
#install plugin
|
#install plugin
|
||||||
@@ -18,18 +12,9 @@ dulwich
|
|||||||
# xunfei spark
|
# xunfei spark
|
||||||
websocket-client==1.2.0
|
websocket-client==1.2.0
|
||||||
|
|
||||||
# claude API
|
|
||||||
anthropic==0.25.0
|
|
||||||
|
|
||||||
# tongyi qwen
|
|
||||||
broadscope_bailian
|
|
||||||
|
|
||||||
# google
|
# google
|
||||||
google-generativeai
|
google-generativeai
|
||||||
|
|
||||||
# tencentcloud sdk
|
|
||||||
tencentcloud-sdk-python>=3.0.0
|
|
||||||
|
|
||||||
# file parsing (web_fetch document support)
|
# file parsing (web_fetch document support)
|
||||||
pypdf
|
pypdf
|
||||||
python-docx
|
python-docx
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ agentmesh-sdk>=0.1.3
|
|||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
croniter>=2.0.0
|
croniter>=2.0.0
|
||||||
|
click>=8.0
|
||||||
qrcode
|
qrcode
|
||||||
|
|
||||||
# wechatcom & wechatmp
|
# wechatcom & wechatmp
|
||||||
|
|||||||
265
run.sh
265
run.sh
@@ -171,8 +171,11 @@ clone_project() {
|
|||||||
mv chatgpt-on-wechat-master chatgpt-on-wechat
|
mv chatgpt-on-wechat-master chatgpt-on-wechat
|
||||||
rm chatgpt-on-wechat.zip
|
rm chatgpt-on-wechat.zip
|
||||||
else
|
else
|
||||||
git clone https://github.com/zhayujie/chatgpt-on-wechat.git || \
|
GIT_HTTP_CONNECT_TIMEOUT=10 GIT_HTTP_LOW_SPEED_LIMIT=1024 GIT_HTTP_LOW_SPEED_TIME=15 \
|
||||||
git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git
|
git clone --depth 10 --progress https://github.com/zhayujie/chatgpt-on-wechat.git || {
|
||||||
|
echo -e "${YELLOW}⚠️ GitHub is slow, switching to Gitee mirror...${NC}"
|
||||||
|
git clone --depth 10 --progress https://gitee.com/zhayujie/chatgpt-on-wechat.git
|
||||||
|
}
|
||||||
if [[ $? -ne 0 ]]; then
|
if [[ $? -ne 0 ]]; then
|
||||||
echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}"
|
echo -e "${RED}❌ Project clone failed. Please check network connection.${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -195,7 +198,10 @@ clone_project() {
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
echo -e "${GREEN}📦 Installing dependencies...${NC}"
|
echo -e "${GREEN}📦 Installing dependencies...${NC}"
|
||||||
local PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
|
local PIP_MIRROR=""
|
||||||
|
if curl -s --connect-timeout 5 https://pypi.tuna.tsinghua.edu.cn/simple/ > /dev/null 2>&1; then
|
||||||
|
PIP_MIRROR="-i https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
|
fi
|
||||||
|
|
||||||
PIP_EXTRA_ARGS=""
|
PIP_EXTRA_ARGS=""
|
||||||
if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then
|
if $PYTHON_CMD -c "import sys; exit(0 if sys.version_info >= (3, 11) else 1)" 2>/dev/null; then
|
||||||
@@ -242,6 +248,17 @@ install_dependencies() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f /tmp/pip_install.log
|
rm -f /tmp/pip_install.log
|
||||||
|
|
||||||
|
# Register `cow` CLI command via editable install
|
||||||
|
echo -e "${YELLOW}Registering cow CLI...${NC}"
|
||||||
|
set +e
|
||||||
|
$PYTHON_CMD -m pip install -e . $PIP_EXTRA_ARGS $PIP_MIRROR > /dev/null 2>&1
|
||||||
|
if command -v cow &> /dev/null; then
|
||||||
|
echo -e "${GREEN}✅ cow CLI registered.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ cow CLI not in PATH, you can still use: $PYTHON_CMD -m cli.cli${NC}"
|
||||||
|
fi
|
||||||
|
set -e
|
||||||
}
|
}
|
||||||
|
|
||||||
# Select model
|
# Select model
|
||||||
@@ -527,23 +544,31 @@ start_project() {
|
|||||||
echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}"
|
echo -e "${GREEN}${EMOJI_ROCKET} Starting CowAgent...${NC}"
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
if [ ! -f "${BASE_DIR}/nohup.out" ]; then
|
local USE_COW=false
|
||||||
touch "${BASE_DIR}/nohup.out"
|
if command -v cow &> /dev/null; then
|
||||||
|
USE_COW=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
OS_TYPE=$(uname)
|
if $USE_COW; then
|
||||||
|
cd "${BASE_DIR}"
|
||||||
if [[ "$OS_TYPE" == "Linux" ]]; then
|
cow start --no-logs
|
||||||
# Linux: use setsid to detach from terminal
|
|
||||||
nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
|
|
||||||
echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
|
|
||||||
elif [[ "$OS_TYPE" == "Darwin" ]]; then
|
|
||||||
# macOS: use nohup to prevent SIGHUP
|
|
||||||
nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
|
|
||||||
echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
|
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
|
if [ ! -f "${BASE_DIR}/nohup.out" ]; then
|
||||||
exit 1
|
touch "${BASE_DIR}/nohup.out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OS_TYPE=$(uname)
|
||||||
|
|
||||||
|
if [[ "$OS_TYPE" == "Linux" ]]; then
|
||||||
|
nohup setsid $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
|
||||||
|
echo -e "${GREEN}${EMOJI_COW} CowAgent started on Linux (using $PYTHON_CMD)${NC}"
|
||||||
|
elif [[ "$OS_TYPE" == "Darwin" ]]; then
|
||||||
|
nohup $PYTHON_CMD "${BASE_DIR}/app.py" > "${BASE_DIR}/nohup.out" 2>&1 &
|
||||||
|
echo -e "${GREEN}${EMOJI_COW} CowAgent started on macOS (using $PYTHON_CMD)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Unsupported OS: ${OS_TYPE}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -554,11 +579,20 @@ start_project() {
|
|||||||
echo -e "${CYAN}$ACCESS_INFO${NC}"
|
echo -e "${CYAN}$ACCESS_INFO${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${CYAN}${BOLD}Management Commands:${NC}"
|
echo -e "${CYAN}${BOLD}Management Commands:${NC}"
|
||||||
echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
|
if $USE_COW; then
|
||||||
echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
|
echo -e " ${GREEN}cow stop${NC} Stop the service"
|
||||||
echo -e " ${GREEN}./run.sh status${NC} Check status"
|
echo -e " ${GREEN}cow restart${NC} Restart the service"
|
||||||
echo -e " ${GREEN}./run.sh logs${NC} View logs"
|
echo -e " ${GREEN}cow status${NC} Check status"
|
||||||
echo -e " ${GREEN}./run.sh update${NC} Update and restart"
|
echo -e " ${GREEN}cow logs${NC} View logs"
|
||||||
|
echo -e " ${GREEN}cow update${NC} Update and restart"
|
||||||
|
echo -e " ${GREEN}cow install-browser${NC} Install browser tool"
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}./run.sh stop${NC} Stop the service"
|
||||||
|
echo -e " ${GREEN}./run.sh restart${NC} Restart the service"
|
||||||
|
echo -e " ${GREEN}./run.sh status${NC} Check status"
|
||||||
|
echo -e " ${GREEN}./run.sh logs${NC} View logs"
|
||||||
|
echo -e " ${GREEN}./run.sh update${NC} Update and restart"
|
||||||
|
fi
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -603,7 +637,7 @@ ensure_python_cmd() {
|
|||||||
# Get service PID (empty string if not running)
|
# Get service PID (empty string if not running)
|
||||||
get_pid() {
|
get_pid() {
|
||||||
ensure_python_cmd > /dev/null 2>&1
|
ensure_python_cmd > /dev/null 2>&1
|
||||||
ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$'
|
ps ax | grep -i app.py | grep "${BASE_DIR}" | grep "$PYTHON_CMD" | grep -v grep | awk '{print $1}' | grep -E '^[0-9]+$' | head -1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if service is running
|
# Check if service is running
|
||||||
@@ -611,94 +645,122 @@ is_running() {
|
|||||||
[ -n "$(get_pid)" ]
|
[ -n "$(get_pid)" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if cow CLI is available
|
||||||
|
has_cow() {
|
||||||
|
command -v cow &> /dev/null
|
||||||
|
}
|
||||||
|
|
||||||
# Start service
|
# Start service
|
||||||
cmd_start() {
|
cmd_start() {
|
||||||
# Check if config.json exists
|
|
||||||
if [ ! -f "${BASE_DIR}/config.json" ]; then
|
if [ ! -f "${BASE_DIR}/config.json" ]; then
|
||||||
echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}"
|
echo -e "${RED}${EMOJI_CROSS} config.json not found${NC}"
|
||||||
echo -e "${YELLOW}Please run './run.sh' to configure first${NC}"
|
echo -e "${YELLOW}Please run './run.sh' to configure first${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if is_running; then
|
if has_cow; then
|
||||||
echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
|
cd "${BASE_DIR}"
|
||||||
echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
|
cow start
|
||||||
return
|
else
|
||||||
|
if is_running; then
|
||||||
|
echo -e "${YELLOW}${EMOJI_WARN} CowAgent is already running (PID: $(get_pid))${NC}"
|
||||||
|
echo -e "${YELLOW}Use './run.sh restart' to restart${NC}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
check_python_version
|
||||||
|
start_project
|
||||||
fi
|
fi
|
||||||
|
|
||||||
check_python_version
|
|
||||||
start_project
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stop service
|
# Stop service
|
||||||
cmd_stop() {
|
cmd_stop() {
|
||||||
echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
|
if has_cow; then
|
||||||
|
cd "${BASE_DIR}"
|
||||||
|
cow stop
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}${EMOJI_STOP} Stopping CowAgent...${NC}"
|
||||||
|
|
||||||
if ! is_running; then
|
if ! is_running; then
|
||||||
echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}"
|
echo -e "${YELLOW}${EMOJI_WARN} CowAgent is not running${NC}"
|
||||||
return
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
pid=$(get_pid)
|
||||||
|
if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then
|
||||||
|
echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Found running process (PID: ${pid})${NC}"
|
||||||
|
|
||||||
|
kill ${pid}
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
if ps -p ${pid} > /dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}"
|
||||||
|
kill -9 ${pid}
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pid=$(get_pid)
|
|
||||||
if [ -z "$pid" ] || ! echo "$pid" | grep -qE '^[0-9]+$'; then
|
|
||||||
echo -e "${RED}❌ Failed to get valid PID (got: ${pid})${NC}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}Found running process (PID: ${pid})${NC}"
|
|
||||||
|
|
||||||
kill ${pid}
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
if ps -p ${pid} > /dev/null 2>&1; then
|
|
||||||
echo -e "${YELLOW}⚠️ Process not stopped, forcing termination...${NC}"
|
|
||||||
kill -9 ${pid}
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}${EMOJI_CHECK} CowAgent stopped${NC}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Restart service
|
# Restart service
|
||||||
cmd_restart() {
|
cmd_restart() {
|
||||||
cmd_stop
|
if has_cow; then
|
||||||
sleep 1
|
cd "${BASE_DIR}"
|
||||||
cmd_start
|
cow restart
|
||||||
|
else
|
||||||
|
cmd_stop
|
||||||
|
sleep 1
|
||||||
|
cmd_start
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
cmd_status() {
|
cmd_status() {
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
if has_cow; then
|
||||||
echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
|
cd "${BASE_DIR}"
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
cow status
|
||||||
|
|
||||||
if is_running; then
|
|
||||||
pid=$(get_pid)
|
|
||||||
echo -e "${GREEN}Status:${NC} ✅ Running"
|
|
||||||
echo -e "${GREEN}PID:${NC} ${pid}"
|
|
||||||
if [ -f "${BASE_DIR}/nohup.out" ]; then
|
|
||||||
echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}Status:${NC} ⭐ Stopped"
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
fi
|
echo -e "${CYAN}${BOLD} ${EMOJI_COW} CowAgent Status${NC}"
|
||||||
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
|
|
||||||
if [ -f "${BASE_DIR}/config.json" ]; then
|
if is_running; then
|
||||||
model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
|
pid=$(get_pid)
|
||||||
channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
|
echo -e "${GREEN}Status:${NC} ✅ Running"
|
||||||
echo -e "${GREEN}Model:${NC} ${model}"
|
echo -e "${GREEN}PID:${NC} ${pid}"
|
||||||
echo -e "${GREEN}Channel:${NC} ${channel}"
|
if [ -f "${BASE_DIR}/nohup.out" ]; then
|
||||||
fi
|
echo -e "${GREEN}Logs:${NC} ${BASE_DIR}/nohup.out"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Status:${NC} ⭐ Stopped"
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
if [ -f "${BASE_DIR}/config.json" ]; then
|
||||||
|
model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
|
||||||
|
channel=$(grep -o '"channel_type"[[:space:]]*:[[:space:]]*"[^"]*"' "${BASE_DIR}/config.json" | cut -d'"' -f4)
|
||||||
|
echo -e "${GREEN}Model:${NC} ${model}"
|
||||||
|
echo -e "${GREEN}Channel:${NC} ${channel}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# View logs
|
# View logs
|
||||||
cmd_logs() {
|
cmd_logs() {
|
||||||
if [ -f "${BASE_DIR}/nohup.out" ]; then
|
if has_cow; then
|
||||||
echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
|
cd "${BASE_DIR}"
|
||||||
tail -f "${BASE_DIR}/nohup.out"
|
cow logs -f
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
|
if [ -f "${BASE_DIR}/nohup.out" ]; then
|
||||||
|
echo -e "${YELLOW}Viewing logs (Ctrl+C to exit):${NC}"
|
||||||
|
tail -f "${BASE_DIR}/nohup.out"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Log file not found: ${BASE_DIR}/nohup.out${NC}"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,23 +794,39 @@ cmd_update() {
|
|||||||
echo -e "${GREEN}${EMOJI_WRENCH} Updating CowAgent...${NC}"
|
echo -e "${GREEN}${EMOJI_WRENCH} Updating CowAgent...${NC}"
|
||||||
cd "${BASE_DIR}"
|
cd "${BASE_DIR}"
|
||||||
|
|
||||||
|
# Pull latest code first (service still running)
|
||||||
|
local pull_ok=false
|
||||||
|
if [ -d .git ]; then
|
||||||
|
echo -e "${GREEN}🔄 Pulling latest code...${NC}"
|
||||||
|
if git pull; then
|
||||||
|
pull_ok=true
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ git pull failed, trying Gitee mirror...${NC}"
|
||||||
|
git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
|
||||||
|
if git pull; then
|
||||||
|
pull_ok=true
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to pull code. Update aborted.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ Not a git repository, skipping code update${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-exec with the updated run.sh to pick up new logic
|
||||||
|
exec "$0" _post_update
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post-update: called by cmd_update after git pull to run with new code
|
||||||
|
cmd_post_update() {
|
||||||
|
cd "${BASE_DIR}"
|
||||||
|
|
||||||
# Stop service
|
# Stop service
|
||||||
if is_running; then
|
if is_running; then
|
||||||
cmd_stop
|
cmd_stop
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update code
|
|
||||||
if [ -d .git ]; then
|
|
||||||
echo -e "${GREEN}🔄 Pulling latest code...${NC}"
|
|
||||||
git pull || {
|
|
||||||
echo -e "${YELLOW}⚠️ GitHub failed, trying Gitee...${NC}"
|
|
||||||
git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
|
|
||||||
git pull
|
|
||||||
}
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠️ Not a git repository, skipping code update${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Reinstall dependencies
|
# Reinstall dependencies
|
||||||
check_python_version
|
check_python_version
|
||||||
install_dependencies
|
install_dependencies
|
||||||
@@ -822,7 +900,7 @@ require_project_dir() {
|
|||||||
# Main function
|
# Main function
|
||||||
main() {
|
main() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
start|stop|restart|status|logs|config|update)
|
start|stop|restart|status|logs|config|update|_post_update)
|
||||||
require_project_dir
|
require_project_dir
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -835,6 +913,7 @@ main() {
|
|||||||
logs) cmd_logs ;;
|
logs) cmd_logs ;;
|
||||||
config) cmd_config ;;
|
config) cmd_config ;;
|
||||||
update) cmd_update ;;
|
update) cmd_update ;;
|
||||||
|
_post_update) cmd_post_update ;;
|
||||||
help|--help|-h)
|
help|--help|-h)
|
||||||
show_usage
|
show_usage
|
||||||
;;
|
;;
|
||||||
|
|||||||
447
scripts/run.ps1
Normal file
447
scripts/run.ps1
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
CowAgent installer & management script for Windows.
|
||||||
|
.DESCRIPTION
|
||||||
|
One-liner install:
|
||||||
|
irm https://raw.githubusercontent.com/zhayujie/chatgpt-on-wechat/master/scripts/run.ps1 | iex
|
||||||
|
Or from a local clone:
|
||||||
|
.\scripts\run.ps1 # install / configure
|
||||||
|
.\scripts\run.ps1 start # start service (delegates to cow CLI)
|
||||||
|
.\scripts\run.ps1 stop|restart|status|logs|config|update|help
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
[string]$Command = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# ── colours ──────────────────────────────────────────────────────
|
||||||
|
function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
|
||||||
|
function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
|
||||||
|
function Write-Err { param([string]$M) Write-Host $M -ForegroundColor Red }
|
||||||
|
function Write-Info { param([string]$M) Write-Host $M -ForegroundColor Cyan }
|
||||||
|
|
||||||
|
# ── detect project directory ─────────────────────────────────────
|
||||||
|
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
|
||||||
|
$BaseDir = Split-Path $ScriptDir -Parent
|
||||||
|
|
||||||
|
$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json")
|
||||||
|
if (-not $IsProjectDir) {
|
||||||
|
$BaseDir = $PWD.Path
|
||||||
|
$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Python detection ─────────────────────────────────────────────
|
||||||
|
function Find-Python {
|
||||||
|
foreach ($cmd in @("python3", "python")) {
|
||||||
|
$bin = Get-Command $cmd -ErrorAction SilentlyContinue
|
||||||
|
if (-not $bin) { continue }
|
||||||
|
try {
|
||||||
|
$ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null
|
||||||
|
$parts = $ver -split '\.'
|
||||||
|
$major = [int]$parts[0]; $minor = [int]$parts[1]
|
||||||
|
if ($major -eq 3 -and $minor -ge 9 -and $minor -le 13) {
|
||||||
|
return $bin.Source
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$PythonCmd = Find-Python
|
||||||
|
function Assert-Python {
|
||||||
|
if (-not $PythonCmd) {
|
||||||
|
Write-Err "Python 3.9-3.13 not found. Please install from https://www.python.org/downloads/"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Cow "Found Python: $PythonCmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── clone project ────────────────────────────────────────────────
|
||||||
|
function Install-Project {
|
||||||
|
if (Test-Path "chatgpt-on-wechat") {
|
||||||
|
Write-Warn "Directory 'chatgpt-on-wechat' already exists."
|
||||||
|
$choice = Read-Host "Overwrite(o), backup(b), or quit(q)? [default: b]"
|
||||||
|
if (-not $choice) { $choice = "b" }
|
||||||
|
switch ($choice.ToLower()) {
|
||||||
|
"o" { Remove-Item -Recurse -Force "chatgpt-on-wechat" }
|
||||||
|
"b" {
|
||||||
|
$backup = "chatgpt-on-wechat_backup_$(Get-Date -Format 'yyyyMMddHHmmss')"
|
||||||
|
Rename-Item "chatgpt-on-wechat" $backup
|
||||||
|
Write-Cow "Backed up to '$backup'"
|
||||||
|
}
|
||||||
|
"q" { Write-Err "Installation cancelled."; exit 1 }
|
||||||
|
default { Write-Err "Invalid choice."; exit 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$gitBin = Get-Command git -ErrorAction SilentlyContinue
|
||||||
|
if (-not $gitBin) {
|
||||||
|
Write-Err "Git not found. Please install from https://git-scm.com/download/win"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Cow "Cloning CowAgent project..."
|
||||||
|
git clone https://github.com/zhayujie/chatgpt-on-wechat.git 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warn "GitHub failed, trying Gitee..."
|
||||||
|
git clone https://gitee.com/zhayujie/chatgpt-on-wechat.git
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Err "Clone failed. Check your network."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location "chatgpt-on-wechat"
|
||||||
|
$script:BaseDir = $PWD.Path
|
||||||
|
$script:IsProjectDir = $true
|
||||||
|
Write-Cow "Project cloned: $BaseDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── install dependencies ─────────────────────────────────────────
|
||||||
|
function Install-Dependencies {
|
||||||
|
Write-Cow "Installing dependencies..."
|
||||||
|
|
||||||
|
& $PythonCmd -m pip install --upgrade pip setuptools wheel 2>$null | Out-Null
|
||||||
|
|
||||||
|
& $PythonCmd -m pip install -r "$BaseDir\requirements.txt" 2>&1 | ForEach-Object { Write-Host $_ }
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warn "Some dependencies may have issues, but continuing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Cow "Registering cow CLI..."
|
||||||
|
& $PythonCmd -m pip install -e $BaseDir 2>$null | Out-Null
|
||||||
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
||||||
|
if ($cowBin) {
|
||||||
|
Write-Cow "cow CLI registered."
|
||||||
|
} else {
|
||||||
|
Write-Warn "cow CLI not in PATH. You can use: $PythonCmd -m cli.cli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── model selection ──────────────────────────────────────────────
|
||||||
|
$ModelChoices = @{
|
||||||
|
"1" = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Key = "MINIMAX_KEY" }
|
||||||
|
"2" = @{ Provider = "Zhipu AI"; Default = "glm-5-turbo"; Key = "ZHIPU_KEY" }
|
||||||
|
"3" = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.5"; Key = "MOONSHOT_KEY" }
|
||||||
|
"4" = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Key = "ARK_KEY" }
|
||||||
|
"5" = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.5-plus"; Key = "DASHSCOPE_KEY" }
|
||||||
|
"6" = @{ Provider = "Claude"; Default = "claude-sonnet-4-6"; Key = "CLAUDE_KEY"; Base = "https://api.anthropic.com/v1" }
|
||||||
|
"7" = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Key = "GEMINI_KEY"; Base = "https://generativelanguage.googleapis.com" }
|
||||||
|
"8" = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Key = "OPENAI_KEY"; Base = "https://api.openai.com/v1" }
|
||||||
|
"9" = @{ Provider = "LinkAI"; Default = "MiniMax-M2.7"; Key = "LINKAI_KEY" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select-Model {
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Info " Select AI Model"
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Host "1) MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)"
|
||||||
|
Write-Host "2) Zhipu AI (glm-5-turbo, glm-5, etc.)"
|
||||||
|
Write-Host "3) Kimi (kimi-k2.5, kimi-k2, etc.)"
|
||||||
|
Write-Host "4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)"
|
||||||
|
Write-Host "5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)"
|
||||||
|
Write-Host "6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)"
|
||||||
|
Write-Host "7) Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)"
|
||||||
|
Write-Host "8) OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)"
|
||||||
|
Write-Host "9) LinkAI (access multiple models via one API)"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
$choice = Read-Host "Enter your choice [default: 1 - MiniMax]"
|
||||||
|
if (-not $choice) { $choice = "1" }
|
||||||
|
} while ($choice -notmatch '^[1-9]$')
|
||||||
|
|
||||||
|
$m = $ModelChoices[$choice]
|
||||||
|
Write-Cow "Configuring $($m.Provider)..."
|
||||||
|
|
||||||
|
$script:ApiKey = Read-Host "Enter $($m.Provider) API Key"
|
||||||
|
$model = Read-Host "Enter model name [default: $($m.Default)]"
|
||||||
|
if (-not $model) { $model = $m.Default }
|
||||||
|
$script:ModelName = $model
|
||||||
|
$script:KeyName = $m.Key
|
||||||
|
$script:UseLinkai = ($choice -eq "9")
|
||||||
|
|
||||||
|
if ($m.Base) {
|
||||||
|
$base = Read-Host "Enter API Base URL [default: $($m.Base)]"
|
||||||
|
if (-not $base) { $base = $m.Base }
|
||||||
|
$script:ApiBase = $base
|
||||||
|
} else {
|
||||||
|
$script:ApiBase = ""
|
||||||
|
}
|
||||||
|
$script:ModelChoice = $choice
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── channel selection ────────────────────────────────────────────
|
||||||
|
function Select-Channel {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Info " Select Communication Channel"
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Host "1) Weixin"
|
||||||
|
Write-Host "2) Feishu"
|
||||||
|
Write-Host "3) DingTalk"
|
||||||
|
Write-Host "4) WeCom Bot"
|
||||||
|
Write-Host "5) QQ"
|
||||||
|
Write-Host "6) WeCom App"
|
||||||
|
Write-Host "7) Web"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
$choice = Read-Host "Enter your choice [default: 1 - Weixin]"
|
||||||
|
if (-not $choice) { $choice = "1" }
|
||||||
|
} while ($choice -notmatch '^[1-7]$')
|
||||||
|
|
||||||
|
$script:ChannelExtra = @{}
|
||||||
|
|
||||||
|
switch ($choice) {
|
||||||
|
"1" { $script:ChannelType = "weixin" }
|
||||||
|
"2" {
|
||||||
|
$script:ChannelType = "feishu"
|
||||||
|
$script:ChannelExtra["feishu_app_id"] = Read-Host "Enter Feishu App ID"
|
||||||
|
$script:ChannelExtra["feishu_app_secret"] = Read-Host "Enter Feishu App Secret"
|
||||||
|
}
|
||||||
|
"3" {
|
||||||
|
$script:ChannelType = "dingtalk"
|
||||||
|
$script:ChannelExtra["dingtalk_client_id"] = Read-Host "Enter DingTalk Client ID"
|
||||||
|
$script:ChannelExtra["dingtalk_client_secret"] = Read-Host "Enter DingTalk Client Secret"
|
||||||
|
}
|
||||||
|
"4" {
|
||||||
|
$script:ChannelType = "wecom_bot"
|
||||||
|
$script:ChannelExtra["wecom_bot_id"] = Read-Host "Enter WeCom Bot ID"
|
||||||
|
$script:ChannelExtra["wecom_bot_secret"] = Read-Host "Enter WeCom Bot Secret"
|
||||||
|
}
|
||||||
|
"5" {
|
||||||
|
$script:ChannelType = "qq"
|
||||||
|
$script:ChannelExtra["qq_app_id"] = Read-Host "Enter QQ App ID"
|
||||||
|
$script:ChannelExtra["qq_app_secret"] = Read-Host "Enter QQ App Secret"
|
||||||
|
}
|
||||||
|
"6" {
|
||||||
|
$script:ChannelType = "wechatcom_app"
|
||||||
|
$script:ChannelExtra["wechatcom_corp_id"] = Read-Host "Enter WeChat Corp ID"
|
||||||
|
$script:ChannelExtra["wechatcomapp_token"] = Read-Host "Enter WeChat Com App Token"
|
||||||
|
$script:ChannelExtra["wechatcomapp_secret"] = Read-Host "Enter WeChat Com App Secret"
|
||||||
|
$script:ChannelExtra["wechatcomapp_agent_id"] = Read-Host "Enter WeChat Com App Agent ID"
|
||||||
|
$script:ChannelExtra["wechatcomapp_aes_key"] = Read-Host "Enter WeChat Com App AES Key"
|
||||||
|
$port = Read-Host "Enter port [default: 9898]"
|
||||||
|
if (-not $port) { $port = "9898" }
|
||||||
|
$script:ChannelExtra["wechatcomapp_port"] = [int]$port
|
||||||
|
}
|
||||||
|
"7" {
|
||||||
|
$script:ChannelType = "web"
|
||||||
|
$port = Read-Host "Enter web port [default: 9899]"
|
||||||
|
if (-not $port) { $port = "9899" }
|
||||||
|
$script:ChannelExtra["web_port"] = [int]$port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── generate config.json ─────────────────────────────────────────
|
||||||
|
function New-ConfigFile {
|
||||||
|
Write-Cow "Generating config.json..."
|
||||||
|
|
||||||
|
$config = [ordered]@{
|
||||||
|
channel_type = $ChannelType
|
||||||
|
model = $ModelName
|
||||||
|
open_ai_api_key = ""
|
||||||
|
open_ai_api_base = "https://api.openai.com/v1"
|
||||||
|
claude_api_key = ""
|
||||||
|
claude_api_base = "https://api.anthropic.com/v1"
|
||||||
|
gemini_api_key = ""
|
||||||
|
gemini_api_base = "https://generativelanguage.googleapis.com"
|
||||||
|
zhipu_ai_api_key = ""
|
||||||
|
moonshot_api_key = ""
|
||||||
|
ark_api_key = ""
|
||||||
|
dashscope_api_key = ""
|
||||||
|
minimax_api_key = ""
|
||||||
|
voice_to_text = "openai"
|
||||||
|
text_to_voice = "openai"
|
||||||
|
voice_reply_voice = $false
|
||||||
|
speech_recognition = $true
|
||||||
|
group_speech_recognition = $false
|
||||||
|
use_linkai = $UseLinkai
|
||||||
|
linkai_api_key = ""
|
||||||
|
linkai_app_code = ""
|
||||||
|
agent = $true
|
||||||
|
agent_max_context_tokens = 40000
|
||||||
|
agent_max_context_turns = 30
|
||||||
|
agent_max_steps = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set the correct API key field
|
||||||
|
$keyMap = @{
|
||||||
|
OPENAI_KEY = "open_ai_api_key"
|
||||||
|
CLAUDE_KEY = "claude_api_key"
|
||||||
|
GEMINI_KEY = "gemini_api_key"
|
||||||
|
ZHIPU_KEY = "zhipu_ai_api_key"
|
||||||
|
MOONSHOT_KEY = "moonshot_api_key"
|
||||||
|
ARK_KEY = "ark_api_key"
|
||||||
|
DASHSCOPE_KEY = "dashscope_api_key"
|
||||||
|
MINIMAX_KEY = "minimax_api_key"
|
||||||
|
LINKAI_KEY = "linkai_api_key"
|
||||||
|
}
|
||||||
|
if ($keyMap.ContainsKey($KeyName)) {
|
||||||
|
$config[$keyMap[$KeyName]] = $ApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set API base if provided
|
||||||
|
$baseMap = @{
|
||||||
|
"6" = "claude_api_base"
|
||||||
|
"7" = "gemini_api_base"
|
||||||
|
"8" = "open_ai_api_base"
|
||||||
|
}
|
||||||
|
if ($ApiBase -and $baseMap.ContainsKey($ModelChoice)) {
|
||||||
|
$config[$baseMap[$ModelChoice]] = $ApiBase
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge channel-specific fields
|
||||||
|
foreach ($k in $ChannelExtra.Keys) {
|
||||||
|
$config[$k] = $ChannelExtra[$k]
|
||||||
|
}
|
||||||
|
|
||||||
|
$config | ConvertTo-Json -Depth 5 | Set-Content -Path "$BaseDir\config.json" -Encoding UTF8
|
||||||
|
Write-Cow "Configuration file created."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── start via cow CLI ─────────────────────────────────────────────
|
||||||
|
function Start-CowAgent {
|
||||||
|
Write-Cow "Starting CowAgent..."
|
||||||
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
||||||
|
if ($cowBin) {
|
||||||
|
& cow start
|
||||||
|
} else {
|
||||||
|
Write-Warn "cow CLI not found, starting directly..."
|
||||||
|
& $PythonCmd "$BaseDir\app.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── delegate management commands to cow CLI ──────────────────────
|
||||||
|
function Invoke-CowCommand {
|
||||||
|
param([string]$Cmd)
|
||||||
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
||||||
|
if ($cowBin) {
|
||||||
|
& cow $Cmd
|
||||||
|
} else {
|
||||||
|
Write-Err "cow CLI not found. Run this script without arguments first to install."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── usage ─────────────────────────────────────────────────────────
|
||||||
|
function Show-Usage {
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Info " CowAgent Management Script (Windows)"
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Usage:"
|
||||||
|
Write-Host " .\run.ps1 # Install / Configure"
|
||||||
|
Write-Host " .\run.ps1 <command> # Management command"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Commands:"
|
||||||
|
Write-Host " start Start the service"
|
||||||
|
Write-Host " stop Stop the service"
|
||||||
|
Write-Host " restart Restart the service"
|
||||||
|
Write-Host " status Check service status"
|
||||||
|
Write-Host " logs View logs"
|
||||||
|
Write-Host " config Reconfigure project"
|
||||||
|
Write-Host " update Update and restart"
|
||||||
|
Write-Host " help Show this message"
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── install mode ──────────────────────────────────────────────────
|
||||||
|
function Install-Mode {
|
||||||
|
Clear-Host
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Info " CowAgent Installation (Windows)"
|
||||||
|
Write-Info "========================================="
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($IsProjectDir) {
|
||||||
|
Write-Cow "Detected existing project directory."
|
||||||
|
if (Test-Path "$BaseDir\config.json") {
|
||||||
|
Write-Cow "Project already configured."
|
||||||
|
Write-Host ""
|
||||||
|
Show-Usage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Warn "No config.json found. Let's configure your project!"
|
||||||
|
Write-Host ""
|
||||||
|
Assert-Python
|
||||||
|
} else {
|
||||||
|
Assert-Python
|
||||||
|
Install-Project
|
||||||
|
}
|
||||||
|
|
||||||
|
Install-Dependencies
|
||||||
|
Select-Model
|
||||||
|
Select-Channel
|
||||||
|
New-ConfigFile
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
$startNow = Read-Host "Start CowAgent now? [Y/n]"
|
||||||
|
if ($startNow -ne "n" -and $startNow -ne "N") {
|
||||||
|
Start-CowAgent
|
||||||
|
} else {
|
||||||
|
Write-Cow "Installation complete!"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To start manually:"
|
||||||
|
Write-Host " cd $BaseDir"
|
||||||
|
Write-Host " cow start"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── update ────────────────────────────────────────────────────────
|
||||||
|
function Update-Project {
|
||||||
|
Write-Cow "Updating CowAgent..."
|
||||||
|
Set-Location $BaseDir
|
||||||
|
|
||||||
|
# Stop if running
|
||||||
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
||||||
|
if ($cowBin) { & cow stop 2>$null }
|
||||||
|
|
||||||
|
if (Test-Path "$BaseDir\.git") {
|
||||||
|
Write-Cow "Pulling latest code..."
|
||||||
|
git pull 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warn "GitHub failed, trying Gitee..."
|
||||||
|
git remote set-url origin https://gitee.com/zhayujie/chatgpt-on-wechat.git
|
||||||
|
git pull
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warn "Not a git repository, skipping code update."
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-Python
|
||||||
|
Install-Dependencies
|
||||||
|
Start-CowAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── main ──────────────────────────────────────────────────────────
|
||||||
|
switch ($Command.ToLower()) {
|
||||||
|
"" { Install-Mode }
|
||||||
|
"start" { Invoke-CowCommand "start" }
|
||||||
|
"stop" { Invoke-CowCommand "stop" }
|
||||||
|
"restart" { Invoke-CowCommand "restart" }
|
||||||
|
"status" { Invoke-CowCommand "status" }
|
||||||
|
"logs" { Invoke-CowCommand "logs" }
|
||||||
|
"config" {
|
||||||
|
Assert-Python
|
||||||
|
Install-Dependencies
|
||||||
|
Select-Model
|
||||||
|
Select-Channel
|
||||||
|
New-ConfigFile
|
||||||
|
$r = Read-Host "Restart service now? [Y/n]"
|
||||||
|
if ($r -ne "n" -and $r -ne "N") { Invoke-CowCommand "restart" }
|
||||||
|
}
|
||||||
|
"update" { Update-Project }
|
||||||
|
"help" { Show-Usage }
|
||||||
|
default {
|
||||||
|
Write-Err "Unknown command: $Command"
|
||||||
|
Show-Usage
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ description: Call LinkAI applications and workflows. Use bash with curl to invok
|
|||||||
homepage: https://link-ai.tech
|
homepage: https://link-ai.tech
|
||||||
metadata:
|
metadata:
|
||||||
emoji: 🤖
|
emoji: 🤖
|
||||||
|
default_enabled: false
|
||||||
requires:
|
requires:
|
||||||
bins: ["curl"]
|
bins: ["curl"]
|
||||||
env: ["LINKAI_API_KEY"]
|
env: ["LINKAI_API_KEY"]
|
||||||
|
|||||||
Reference in New Issue
Block a user