mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 18:17:11 +08:00
Compare commits
21 Commits
feat-wecom
...
2.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb16c48c0 | ||
|
|
c6384363f9 | ||
|
|
8993e8ad3e | ||
|
|
289989d9f7 | ||
|
|
dc2ae0e6f1 | ||
|
|
9c966c152d | ||
|
|
4efae41048 | ||
|
|
b8437032e9 | ||
|
|
2d339ca81b | ||
|
|
d53abc9696 | ||
|
|
446c886d38 | ||
|
|
30c6d9b5ae | ||
|
|
5e42996b36 | ||
|
|
ceca7b85bf | ||
|
|
a4d54f58c8 | ||
|
|
005a0e1bad | ||
|
|
46d97fd57d | ||
|
|
72a26b6353 | ||
|
|
89a4033fbf | ||
|
|
39a5dc64bd | ||
|
|
2f5ba87280 |
120
README.md
120
README.md
@@ -7,12 +7,13 @@
|
||||
[中文] | [<a href="docs/en/README.md">English</a>]
|
||||
</p>
|
||||
|
||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、企业微信应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。
|
||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企微智能机器人、QQ、企微自建应用、微信公众号中使用,7*24小时运行于你的个人电脑或服务器中。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cowagent.ai/">🌐 官网</a> ·
|
||||
<a href="https://docs.cowagent.ai/">📖 文档中心</a> ·
|
||||
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 快速开始</a>
|
||||
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 快速开始</a> ·
|
||||
<a href="https://link-ai.tech/cowagent/create">☁️ 在线体验</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -26,8 +27,7 @@
|
||||
- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
||||
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
|
||||
- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
|
||||
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到网页、飞书、钉钉、微信公众号、企业微信应用中使用
|
||||
- ✅ **知识库:** 集成企业知识库能力,让Agent成为专属数字员工,基于[LinkAI](https://link-ai.tech)平台实现
|
||||
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到飞书、钉钉、企业微信、QQ、微信公众号、网页中使用
|
||||
|
||||
## 声明
|
||||
|
||||
@@ -37,9 +37,11 @@
|
||||
|
||||
## 演示
|
||||
|
||||
使用说明(Agent模式):[CowAgent介绍](https://docs.cowagent.ai/intro/features)
|
||||
- 使用说明(Agent模式):[CowAgent介绍](https://docs.cowagent.ai/intro/features)
|
||||
|
||||
DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
||||
- 免部署在线体验:[CowAgent](https://link-ai.tech/cowagent/create)
|
||||
|
||||
- DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
||||
|
||||
## 社区
|
||||
|
||||
@@ -51,9 +53,9 @@ DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
||||
|
||||
# 企业服务
|
||||
|
||||
<a href="https://link-ai.tech" target="_blank"><img width="720" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
||||
|
||||
> [LinkAI](https://link-ai.tech/) 是面向企业和开发者的一站式AI智能体平台,聚合多模态大模型、知识库、Agent 插件、工作流等能力,支持一键接入主流平台并进行管理,支持SaaS、私有化部署等多种模式。
|
||||
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式AI智能体平台,聚合多模态大模型、知识库、技能、工作流等能力,支持一键接入主流平台并管理,支持SaaS、私有化部署等多种模式,可免部署在线运行[CowAgent助理](https://link-ai.tech/cowagent/create)。
|
||||
>
|
||||
> LinkAI 目前已在智能客服、私域运营、企业效率助手等场景积累了丰富的AI解决方案,在消费、健康、文教、科技制造等各行业沉淀了大模型落地应用的最佳实践,致力于帮助更多企业和开发者拥抱 AI 生产力。
|
||||
|
||||
@@ -65,6 +67,8 @@ DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
||||
|
||||
# 🏷 更新日志
|
||||
|
||||
>**2026.03.18:** [2.0.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.3),新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级。
|
||||
|
||||
>**2026.02.27:** [2.0.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2),Web 控制台全面升级(流式对话、模型/技能/记忆/通道/定时任务/日志管理)、支持多通道同时运行、会话持久化存储、新增多个模型。
|
||||
|
||||
>**2026.02.13:** [2.0.1版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.1),内置 Web Search 工具、智能上下文裁剪策略、运行时信息动态更新、Windows 兼容性适配,修复定时任务记忆丢失、飞书连接等多项问题。
|
||||
@@ -86,7 +90,7 @@ DEMO视频(对话模式):https://cdn.link-ai.tech/doc/cow_demo.mp4
|
||||
在终端执行以下命令:
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
脚本使用说明:[一键运行脚本](https://docs.cowagent.ai/guide/quick-start)
|
||||
@@ -98,9 +102,9 @@ bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
|
||||
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
|
||||
|
||||
> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.5、glm-5、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4
|
||||
> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.5、glm-5、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini
|
||||
|
||||
同时支持使用 **LinkAI平台** 接口,可灵活切换 OpenAI、Claude、Gemini、DeepSeek、Qwen、Kimi 等多种常用模型,并支持知识库、工作流、插件等Agent能力,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
|
||||
同时支持使用 **LinkAI平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等Agent技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
|
||||
|
||||
### 2.环境安装
|
||||
|
||||
@@ -143,7 +147,7 @@ pip3 install -r requirements-optional.txt
|
||||
```bash
|
||||
# config.json 文件内容示例
|
||||
{
|
||||
"channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,wechatcom_app,wechatmp_service,wechatmp,terminal
|
||||
"channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wecom_bot,qq,wechatcom_app,wechatmp_service,wechatmp,terminal
|
||||
"model": "MiniMax-M2.5", # 模型名称
|
||||
"minimax_api_key": "", # MiniMax API Key
|
||||
"zhipu_ai_api_key": "", # 智谱GLM API Key
|
||||
@@ -161,7 +165,7 @@ pip3 install -r requirements-optional.txt
|
||||
"speech_recognition": false, # 是否开启语音识别
|
||||
"group_speech_recognition": false, # 是否开启群组语音识别
|
||||
"voice_reply_voice": false, # 是否使用语音回复语音
|
||||
"use_linkai": false, # 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台接口
|
||||
"use_linkai": false, # 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台模型
|
||||
"agent": true, # 是否启用Agent模式,启用后拥有多轮工具决策、长期记忆、Skills能力等
|
||||
"agent_workspace": "~/cow", # Agent的工作空间路径,用于存储memory、skills、系统设定等
|
||||
"agent_max_context_tokens": 40000, # Agent模式下最大上下文tokens,超出将自动丢弃最早的上下文
|
||||
@@ -191,9 +195,8 @@ pip3 install -r requirements-optional.txt
|
||||
<details>
|
||||
<summary>3. LinkAI配置</summary>
|
||||
|
||||
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台,使用知识库、工作流、插件等能力, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat)
|
||||
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台,使用模型、知识库、工作流、插件等技能, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat)
|
||||
+ `linkai_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建
|
||||
+ `linkai_app_code`: LinkAI 应用或工作流的code,选填,普通对话模式中使用。
|
||||
</details>
|
||||
|
||||
注:全部配置项说明可在 [`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py) 文件中查看。
|
||||
@@ -223,8 +226,9 @@ nohup python3 app.py & tail -f nohup.out
|
||||
|
||||
执行后程序运行于服务器后台,可通过 `ctrl+c` 关闭日志,不会影响后台程序的运行。使用 `ps -ef | grep app.py | grep -v grep` 命令可查看运行于后台的进程,如果想要重新启动程序可以先 `kill` 掉对应的进程。 日志关闭后如果想要再次打开只需输入 `tail -f nohup.out`。
|
||||
|
||||
此外,项目的 `scripts` 目录下有一键运行、关闭程序的脚本供使用。 运行后默认channel为web,通过可以通过修改配置文件进行切换。
|
||||
此外,项目根目录下的 `run.sh` 脚本支持一键启动和管理服务,包括 `./run.sh start`、`./run.sh stop`、`./run.sh restart`、`./run.sh logs` 等命令,执行 `./run.sh help` 可查看全部用法。
|
||||
|
||||
> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。
|
||||
|
||||
### 3.Docker部署
|
||||
|
||||
@@ -235,7 +239,7 @@ nohup python3 app.py & tail -f nohup.out
|
||||
**(1) 下载 docker-compose.yml 文件**
|
||||
|
||||
```bash
|
||||
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
```
|
||||
|
||||
下载完成后打开 `docker-compose.yml` 填写所需配置,例如 `CHANNEL_TYPE`、`OPEN_AI_API_KEY` 和等配置。
|
||||
@@ -254,17 +258,7 @@ sudo docker compose up -d # 若docker-compose为 1.X 版本,则执行
|
||||
sudo docker logs -f chatgpt-on-wechat
|
||||
```
|
||||
|
||||
**(3) 插件使用**
|
||||
|
||||
如果需要在docker容器中修改插件配置,可通过挂载的方式完成,将 [插件配置文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/config.json.template)
|
||||
重命名为 `config.json`,放置于 `docker-compose.yml` 相同目录下,并在 `docker-compose.yml` 中的 `chatgpt-on-wechat` 部分下添加 `volumes` 映射:
|
||||
|
||||
```
|
||||
volumes:
|
||||
- ./config.json:/app/plugins/config.json
|
||||
```
|
||||
**注**:使用docker方式部署的详细教程可以参考:[docker部署CoW项目](https://www.wangpc.cc/ai/docker-deploy-cow/)
|
||||
|
||||
> 如果需要通过浏览器访问Web控制台,请确保服务器的 `9899` 端口已在防火墙或安全组中放行,建议仅对指定IP开放以保证安全。
|
||||
|
||||
## 模型说明
|
||||
|
||||
@@ -282,13 +276,13 @@ volumes:
|
||||
"model": "gpt-5.4",
|
||||
"open_ai_api_key": "YOUR_API_KEY",
|
||||
"open_ai_api_base": "https://api.openai.com/v1",
|
||||
"bot_type": "chatGPT"
|
||||
"bot_type": "openai"
|
||||
}
|
||||
```
|
||||
|
||||
- `model`: 与OpenAI接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、o系列、gpt-4.1等模型,Agent模式推荐使用 `gpt-5.4`
|
||||
- `model`: 与OpenAI接口的 [model参数](https://platform.openai.com/docs/models) 一致,支持包括 gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、o系列、gpt-4.1等模型,Agent模式推荐使用 `gpt-5.4`、`gpt-5.4-mini`
|
||||
- `open_ai_api_base`: 如果需要接入第三方代理接口,可通过修改该参数进行接入
|
||||
- `bot_type`: 使用OpenAI相关模型时无需填写。当使用第三方代理接口接入Claude等非OpenAI官方模型时,该参数设为 `chatGPT`
|
||||
- `bot_type`: 使用OpenAI相关模型时无需填写。当使用第三方代理接口接入Claude等非OpenAI官方模型时,该参数设为 `openai`
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -300,16 +294,15 @@ volumes:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-5.4-mini",
|
||||
"use_linkai": true,
|
||||
"linkai_api_key": "YOUR API KEY",
|
||||
"linkai_app_code": "YOUR APP CODE"
|
||||
"linkai_api_key": "YOUR API KEY"
|
||||
}
|
||||
```
|
||||
|
||||
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台的智能体,使用知识库、工作流、数据库、MCP插件等丰富的Agent能力
|
||||
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台的模型,并使用知识库、工作流、数据库、插件等丰富的Agent技能
|
||||
+ `linkai_api_key`: LinkAI平台的API Key,可在 [控制台](https://link-ai.tech/console/interface) 中创建
|
||||
+ `linkai_app_code`: LinkAI智能体 (应用或工作流) 的code,选填,普通对话模式可用。智能体创建可参考 [说明文档](https://docs.link-ai.tech/platform/quick-start)
|
||||
+ `model`: model字段填写空则直接使用智能体的模型,可在平台中灵活切换,[模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
|
||||
+ `model`: [模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -329,7 +322,7 @@ volumes:
|
||||
方式二:OpenAI兼容方式接入,配置如下:
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": ""
|
||||
@@ -358,7 +351,7 @@ volumes:
|
||||
方式二:OpenAI兼容方式接入,配置如下:
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "glm-5",
|
||||
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"open_ai_api_key": ""
|
||||
@@ -387,7 +380,7 @@ volumes:
|
||||
方式二:OpenAI兼容方式接入,配置如下:
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "qwen3.5-plus",
|
||||
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"open_ai_api_key": "sk-qVxxxxG"
|
||||
@@ -416,7 +409,7 @@ volumes:
|
||||
方式二:OpenAI兼容方式接入,配置如下:
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "kimi-k2.5",
|
||||
"open_ai_api_base": "https://api.moonshot.cn/v1",
|
||||
"open_ai_api_key": ""
|
||||
@@ -486,8 +479,8 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
{
|
||||
"model": "deepseek-chat",
|
||||
"open_ai_api_key": "sk-xxxxxxxxxxx",
|
||||
"open_ai_api_base": "https://api.deepseek.com/v1",
|
||||
"bot_type": "chatGPT"
|
||||
"open_ai_api_base": "https://api.deepseek.com/v1",
|
||||
"bot_type": "openai"
|
||||
|
||||
}
|
||||
```
|
||||
@@ -542,7 +535,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
方式二:OpenAI兼容方式接入,配置如下:
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "ERNIE-4.0-Turbo-8K",
|
||||
"open_ai_api_base": "https://qianfan.baidubce.com/v2",
|
||||
"open_ai_api_key": "bce-v3/ALTxxxxxxd2b"
|
||||
@@ -578,7 +571,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
方式二:OpenAI兼容方式接入,配置如下:
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "4.0Ultra",
|
||||
"open_ai_api_base": "https://spark-api-open.xf-yun.com/v1",
|
||||
"open_ai_api_key": ""
|
||||
@@ -610,6 +603,23 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
- `text_to_image`: 图像生成模型,参考[模型列表](https://www.modelscope.cn/models?filter=inference_type&page=1)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Coding Plan</summary>
|
||||
|
||||
Coding Plan 是各厂商推出的编程包月套餐,所有厂商均可通过 OpenAI 兼容方式接入:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "模型名称",
|
||||
"open_ai_api_base": "厂商 Coding Plan API Base",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
目前支持阿里云、MiniMax、智谱GLM、Kimi、火山引擎等厂商,各厂商详细配置请参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
|
||||
</details>
|
||||
|
||||
|
||||
## 通道说明
|
||||
|
||||
@@ -702,7 +712,23 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>5. WeCom App - 企业微信应用</summary>
|
||||
<summary>5. QQ - QQ 机器人</summary>
|
||||
|
||||
QQ 机器人使用 WebSocket 长连接模式,无需公网 IP 和域名,支持 QQ 单聊、群聊和频道消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "qq",
|
||||
"qq_app_id": "YOUR_APP_ID",
|
||||
"qq_app_secret": "YOUR_APP_SECRET"
|
||||
}
|
||||
```
|
||||
详细步骤和参数说明参考 [QQ 机器人接入](https://docs.cowagent.ai/channels/qq)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>6. WeCom App - 企业微信应用</summary>
|
||||
|
||||
企业微信自建应用接入需在后台创建应用并启用消息回调,配置示例:
|
||||
|
||||
@@ -722,7 +748,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>6. WeChat MP - 微信公众号</summary>
|
||||
<summary>7. WeChat MP - 微信公众号</summary>
|
||||
|
||||
本项目支持订阅号和服务号两种公众号,通过服务号(`wechatmp_service`)体验更佳。
|
||||
|
||||
@@ -757,7 +783,7 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>7. Terminal - 终端</summary>
|
||||
<summary>8. Terminal - 终端</summary>
|
||||
|
||||
修改 `config.json` 中的 `channel_type` 字段:
|
||||
|
||||
|
||||
@@ -376,7 +376,7 @@ def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
|
||||
"",
|
||||
"以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
|
||||
"",
|
||||
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定。当用户修改你的名字、性格或交流风格时,用 `edit` 更新此文件",
|
||||
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
|
||||
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
|
||||
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则",
|
||||
"",
|
||||
@@ -423,7 +423,8 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
|
||||
]
|
||||
|
||||
if has_agent:
|
||||
lines.append("如果存在 `AGENT.md`,请体现其中定义的人格和语气。避免僵硬、模板化的回复;遵循其指导,除非有更高优先级的指令覆盖它。")
|
||||
lines.append("**`AGENT.md` 是你的灵魂文件**:严格体现其中定义的人格、语气和设定,避免僵硬、模板化的回复。")
|
||||
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
|
||||
lines.append("")
|
||||
|
||||
# 添加每个文件的内容
|
||||
|
||||
@@ -609,14 +609,14 @@ class AgentStreamExecutor:
|
||||
"arguments": ""
|
||||
}
|
||||
|
||||
if "id" in tc_delta:
|
||||
if tc_delta.get("id"):
|
||||
tool_calls_buffer[index]["id"] = tc_delta["id"]
|
||||
|
||||
if "function" in tc_delta:
|
||||
func = tc_delta["function"]
|
||||
if "name" in func:
|
||||
if func.get("name"):
|
||||
tool_calls_buffer[index]["name"] = func["name"]
|
||||
if "arguments" in func:
|
||||
if func.get("arguments"):
|
||||
tool_calls_buffer[index]["arguments"] += func["arguments"]
|
||||
|
||||
# Preserve _gemini_raw_parts for Gemini thoughtSignature round-trip
|
||||
@@ -720,9 +720,9 @@ class AgentStreamExecutor:
|
||||
)
|
||||
else:
|
||||
if retry_count >= max_retries:
|
||||
logger.error(f"❌ LLM API error after {max_retries} retries: {e}")
|
||||
logger.error(f"❌ LLM API error after {max_retries} retries: {e}", exc_info=True)
|
||||
else:
|
||||
logger.error(f"❌ LLM call error (non-retryable): {e}")
|
||||
logger.error(f"❌ LLM call error (non-retryable): {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Parse tool calls
|
||||
@@ -875,7 +875,7 @@ class AgentStreamExecutor:
|
||||
try:
|
||||
tool = self.tools.get(tool_name)
|
||||
if not tool:
|
||||
raise ValueError(f"Tool '{tool_name}' not found")
|
||||
raise ValueError(self._build_tool_not_found_message(tool_name))
|
||||
|
||||
# Set tool context
|
||||
tool.model = self.model
|
||||
@@ -929,6 +929,47 @@ class AgentStreamExecutor:
|
||||
})
|
||||
return error_result
|
||||
|
||||
def _build_tool_not_found_message(self, tool_name: str) -> str:
|
||||
"""Build a helpful error message when a tool is not found.
|
||||
|
||||
If a skill with the same name exists in skill_manager, read its
|
||||
SKILL.md and include the content so the LLM knows how to use it.
|
||||
"""
|
||||
available_tools = list(self.tools.keys())
|
||||
base_msg = f"Tool '{tool_name}' not found. Available tools: {available_tools}"
|
||||
|
||||
skill_manager = getattr(self.agent, 'skill_manager', None)
|
||||
if not skill_manager:
|
||||
return base_msg
|
||||
|
||||
skill_entry = skill_manager.get_skill(tool_name)
|
||||
if not skill_entry:
|
||||
return base_msg
|
||||
|
||||
skill = skill_entry.skill
|
||||
skill_md_path = skill.file_path
|
||||
skill_content = ""
|
||||
try:
|
||||
with open(skill_md_path, 'r', encoding='utf-8') as f:
|
||||
skill_content = f.read()
|
||||
except Exception:
|
||||
skill_content = skill.description
|
||||
|
||||
logger.info(
|
||||
f"[Agent] Tool '{tool_name}' not found, but matched skill '{skill.name}'. "
|
||||
f"Guiding LLM to use the skill instead."
|
||||
)
|
||||
|
||||
return (
|
||||
f"Tool '{tool_name}' is not a built-in tool, but a matching skill "
|
||||
f"'{skill.name}' is available. You should use existing tools (e.g. bash with curl) "
|
||||
f"to accomplish this task following the skill instructions below:\n\n"
|
||||
f"--- SKILL: {skill.name} (path: {skill_md_path}) ---\n"
|
||||
f"{skill_content}\n"
|
||||
f"--- END SKILL ---\n\n"
|
||||
f"Available tools: {available_tools}"
|
||||
)
|
||||
|
||||
def _validate_and_fix_messages(self):
|
||||
"""Delegate to the shared sanitizer (see message_sanitizer.py)."""
|
||||
sanitize_claude_messages(self.messages)
|
||||
|
||||
@@ -91,7 +91,7 @@ class SkillLoader:
|
||||
continue
|
||||
|
||||
# Check if this is a skill file
|
||||
is_root_md = include_root_files and entry.endswith('.md')
|
||||
is_root_md = include_root_files and entry.endswith('.md') and entry.upper() != 'README.MD'
|
||||
is_skill_md = not include_root_files and entry == 'SKILL.md'
|
||||
|
||||
if not (is_root_md or is_skill_md):
|
||||
|
||||
@@ -48,7 +48,8 @@ class Read(BaseTool):
|
||||
self.binary_extensions = {'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite'}
|
||||
self.archive_extensions = {'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz'}
|
||||
self.pdf_extensions = {'.pdf'}
|
||||
|
||||
self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'}
|
||||
|
||||
# Readable text formats (will be read with truncation)
|
||||
self.text_extensions = {
|
||||
'.txt', '.md', '.markdown', '.rst', '.log', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
||||
@@ -57,7 +58,6 @@ class Read(BaseTool):
|
||||
'.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd',
|
||||
'.sql', '.r', '.m', '.swift', '.kt', '.scala', '.clj', '.erl', '.ex',
|
||||
'.dockerfile', '.makefile', '.cmake', '.gradle', '.properties', '.ini', '.conf', '.cfg',
|
||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx' # Office documents
|
||||
}
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
@@ -120,7 +120,11 @@ class Read(BaseTool):
|
||||
# Check if PDF
|
||||
if file_ext in self.pdf_extensions:
|
||||
return self._read_pdf(absolute_path, path, offset, limit)
|
||||
|
||||
|
||||
# Check if Office document (.docx, .xlsx, .pptx, etc.)
|
||||
if file_ext in self.office_extensions:
|
||||
return self._read_office(absolute_path, path, file_ext, offset, limit)
|
||||
|
||||
# Read text file (with truncation for large files)
|
||||
return self._read_text(absolute_path, path, offset, limit)
|
||||
|
||||
@@ -337,6 +341,116 @@ class Read(BaseTool):
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error reading file: {str(e)}")
|
||||
|
||||
def _read_office(self, absolute_path: str, display_path: str, file_ext: str,
|
||||
offset: int = None, limit: int = None) -> ToolResult:
|
||||
"""Read Office documents (.docx, .xlsx, .pptx) using python-docx / openpyxl / python-pptx."""
|
||||
try:
|
||||
text = self._extract_office_text(absolute_path, file_ext)
|
||||
except ImportError as e:
|
||||
return ToolResult.fail(str(e))
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error reading Office document: {e}")
|
||||
|
||||
if not text or not text.strip():
|
||||
return ToolResult.success({
|
||||
"content": f"[Office file {Path(absolute_path).name}: no text content could be extracted]",
|
||||
})
|
||||
|
||||
all_lines = text.split('\n')
|
||||
total_lines = len(all_lines)
|
||||
|
||||
start_line = 0
|
||||
if offset is not None:
|
||||
if offset < 0:
|
||||
start_line = max(0, total_lines + offset)
|
||||
else:
|
||||
start_line = max(0, offset - 1)
|
||||
if start_line >= total_lines:
|
||||
return ToolResult.fail(
|
||||
f"Error: Offset {offset} is beyond end of content ({total_lines} lines total)"
|
||||
)
|
||||
|
||||
selected_content = text
|
||||
user_limited_lines = None
|
||||
if limit is not None:
|
||||
end_line = min(start_line + limit, total_lines)
|
||||
selected_content = '\n'.join(all_lines[start_line:end_line])
|
||||
user_limited_lines = end_line - start_line
|
||||
elif offset is not None:
|
||||
selected_content = '\n'.join(all_lines[start_line:])
|
||||
|
||||
truncation = truncate_head(selected_content)
|
||||
start_line_display = start_line + 1
|
||||
output_text = ""
|
||||
|
||||
if truncation.truncated:
|
||||
end_line_display = start_line_display + truncation.output_lines - 1
|
||||
next_offset = end_line_display + 1
|
||||
output_text = truncation.content
|
||||
output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
|
||||
elif user_limited_lines is not None and start_line + user_limited_lines < total_lines:
|
||||
remaining = total_lines - (start_line + user_limited_lines)
|
||||
next_offset = start_line + user_limited_lines + 1
|
||||
output_text = truncation.content
|
||||
output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
|
||||
else:
|
||||
output_text = truncation.content
|
||||
|
||||
return ToolResult.success({
|
||||
"content": output_text,
|
||||
"total_lines": total_lines,
|
||||
"start_line": start_line_display,
|
||||
"output_lines": truncation.output_lines,
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _extract_office_text(absolute_path: str, file_ext: str) -> str:
|
||||
"""Extract plain text from an Office document."""
|
||||
if file_ext in ('.docx', '.doc'):
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
raise ImportError("Error: python-docx library not installed. Install with: pip install python-docx")
|
||||
doc = Document(absolute_path)
|
||||
paragraphs = [p.text for p in doc.paragraphs]
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
paragraphs.append('\t'.join(cell.text for cell in row.cells))
|
||||
return '\n'.join(paragraphs)
|
||||
|
||||
if file_ext in ('.xlsx', '.xls'):
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
raise ImportError("Error: openpyxl library not installed. Install with: pip install openpyxl")
|
||||
wb = load_workbook(absolute_path, read_only=True, data_only=True)
|
||||
parts = []
|
||||
for ws in wb.worksheets:
|
||||
parts.append(f"--- Sheet: {ws.title} ---")
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
parts.append('\t'.join(str(c) if c is not None else '' for c in row))
|
||||
wb.close()
|
||||
return '\n'.join(parts)
|
||||
|
||||
if file_ext in ('.pptx', '.ppt'):
|
||||
try:
|
||||
from pptx import Presentation
|
||||
except ImportError:
|
||||
raise ImportError("Error: python-pptx library not installed. Install with: pip install python-pptx")
|
||||
prs = Presentation(absolute_path)
|
||||
parts = []
|
||||
for i, slide in enumerate(prs.slides, 1):
|
||||
parts.append(f"--- Slide {i} ---")
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
for para in shape.text_frame.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return '\n'.join(parts)
|
||||
|
||||
return ""
|
||||
|
||||
def _read_pdf(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult:
|
||||
"""
|
||||
Read PDF file content
|
||||
|
||||
@@ -237,6 +237,8 @@ def _execute_send_message(task: dict, agent_bridge):
|
||||
logger.warning(f"[Scheduler] Task {task['id']}: DingTalk single chat message missing sender_staff_id")
|
||||
elif channel_type == "wecom_bot":
|
||||
context["msg"] = None
|
||||
elif channel_type == "qq":
|
||||
context["msg"] = None
|
||||
|
||||
# Create reply
|
||||
reply = Reply(ReplyType.TEXT, content)
|
||||
|
||||
@@ -61,8 +61,7 @@ class SchedulerService:
|
||||
self._check_and_execute_tasks()
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] Error in scheduler loop: {e}")
|
||||
|
||||
# Sleep for 30 seconds between checks
|
||||
|
||||
time.sleep(30)
|
||||
|
||||
def _check_and_execute_tasks(self):
|
||||
@@ -85,12 +84,9 @@ class SchedulerService:
|
||||
"last_run_at": now.isoformat()
|
||||
})
|
||||
else:
|
||||
# One-time task, disable it
|
||||
self.task_store.update_task(task['id'], {
|
||||
"enabled": False,
|
||||
"last_run_at": now.isoformat()
|
||||
})
|
||||
logger.info(f"[Scheduler] One-time task completed and disabled: {task['id']}")
|
||||
# One-time task completed, remove it
|
||||
self.task_store.delete_task(task['id'])
|
||||
logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
||||
|
||||
@@ -127,14 +123,11 @@ class SchedulerService:
|
||||
if time_diff > 300: # 5 minutes
|
||||
logger.warning(f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, skipping and scheduling next run")
|
||||
|
||||
# For one-time tasks, disable them
|
||||
# For one-time tasks, remove them directly
|
||||
schedule = task.get("schedule", {})
|
||||
if schedule.get("type") == "once":
|
||||
self.task_store.update_task(task['id'], {
|
||||
"enabled": False,
|
||||
"last_run_at": now.isoformat()
|
||||
})
|
||||
logger.info(f"[Scheduler] One-time task {task['id']} expired, disabled")
|
||||
self.task_store.delete_task(task['id'])
|
||||
logger.info(f"[Scheduler] One-time task {task['id']} expired, removed")
|
||||
return False
|
||||
|
||||
# For recurring tasks, calculate next run from now
|
||||
|
||||
@@ -35,7 +35,7 @@ class Vision(BaseTool):
|
||||
|
||||
name: str = "vision"
|
||||
description: str = (
|
||||
"Analyze an image (local file or URL) using Vision API. "
|
||||
"Analyze a local image or image URL (jpg/jpeg/png) using Vision API. "
|
||||
"Can describe content, extract text, identify objects, colors, etc. "
|
||||
"Requires OPENAI_API_KEY or LINKAI_API_KEY."
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ class WebFetch(BaseTool):
|
||||
|
||||
name: str = "web_fetch"
|
||||
description: str = (
|
||||
"Fetch content from a URL. For web pages, extracts readable text. "
|
||||
"Fetch content from a http/https URL. For web pages, extracts readable text. "
|
||||
"For document files (PDF, Word, TXT, Markdown, Excel, PPT), downloads and parses the file content. "
|
||||
"Supported file types: .pdf, .docx, .txt, .md, .csv, .xls, .xlsx, .ppt, .pptx"
|
||||
)
|
||||
|
||||
1
app.py
1
app.py
@@ -228,6 +228,7 @@ def _clear_singleton_cache(channel_name: str):
|
||||
const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel",
|
||||
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
||||
const.QQ: "channel.qq.qq_channel.QQChannel",
|
||||
}
|
||||
module_path = cls_map.get(channel_name)
|
||||
if not module_path:
|
||||
|
||||
@@ -106,7 +106,7 @@ class AgentLLMModel(LLMModel):
|
||||
return configured_bot_type
|
||||
|
||||
if not model_name or not isinstance(model_name, str):
|
||||
return const.CHATGPT
|
||||
return const.OPENAI
|
||||
if model_name in self._MODEL_BOT_TYPE_MAP:
|
||||
return self._MODEL_BOT_TYPE_MAP[model_name]
|
||||
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
||||
@@ -116,11 +116,11 @@ class AgentLLMModel(LLMModel):
|
||||
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
||||
return const.MOONSHOT
|
||||
if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]:
|
||||
return const.CHATGPT
|
||||
return const.OPENAI
|
||||
for prefix, btype in self._MODEL_PREFIX_MAP:
|
||||
if model_name.startswith(prefix):
|
||||
return btype
|
||||
return const.CHATGPT
|
||||
return const.OPENAI
|
||||
|
||||
@property
|
||||
def bot(self):
|
||||
@@ -278,12 +278,13 @@ class AgentBridge:
|
||||
tools=tools,
|
||||
max_steps=kwargs.get("max_steps", 15),
|
||||
output_mode=kwargs.get("output_mode", "logger"),
|
||||
workspace_dir=kwargs.get("workspace_dir"), # Pass workspace for skills loading
|
||||
enable_skills=kwargs.get("enable_skills", True), # Enable skills by default
|
||||
memory_manager=kwargs.get("memory_manager"), # Pass memory manager
|
||||
workspace_dir=kwargs.get("workspace_dir"),
|
||||
skill_manager=kwargs.get("skill_manager"),
|
||||
enable_skills=kwargs.get("enable_skills", True),
|
||||
memory_manager=kwargs.get("memory_manager"),
|
||||
max_context_tokens=kwargs.get("max_context_tokens"),
|
||||
context_reserve_tokens=kwargs.get("context_reserve_tokens"),
|
||||
runtime_info=kwargs.get("runtime_info") # Pass runtime_info for dynamic time updates
|
||||
runtime_info=kwargs.get("runtime_info"),
|
||||
)
|
||||
|
||||
# Log skill loading details
|
||||
|
||||
@@ -13,7 +13,7 @@ from voice.factory import create_voice
|
||||
class Bridge(object):
|
||||
def __init__(self):
|
||||
self.btype = {
|
||||
"chat": const.CHATGPT,
|
||||
"chat": const.OPENAI,
|
||||
"voice_to_text": conf().get("voice_to_text", "openai"),
|
||||
"text_to_voice": conf().get("text_to_voice", "google"),
|
||||
"translate": conf().get("translate", "baidu"),
|
||||
|
||||
@@ -36,6 +36,9 @@ def create_channel(channel_type) -> Channel:
|
||||
elif channel_type == const.WECOM_BOT:
|
||||
from channel.wecom_bot.wecom_bot_channel import WecomBotChannel
|
||||
ch = WecomBotChannel()
|
||||
elif channel_type == const.QQ:
|
||||
from channel.qq.qq_channel import QQChannel
|
||||
ch = QQChannel()
|
||||
else:
|
||||
raise RuntimeError
|
||||
ch.channel_type = channel_type
|
||||
|
||||
0
channel/qq/__init__.py
Normal file
0
channel/qq/__init__.py
Normal file
735
channel/qq/qq_channel.py
Normal file
735
channel/qq/qq_channel.py
Normal file
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
QQ Bot channel via WebSocket long connection.
|
||||
|
||||
Supports:
|
||||
- Group chat (@bot), single chat (C2C), guild channel, guild DM
|
||||
- Text / image / file message send & receive
|
||||
- Heartbeat keep-alive and auto-reconnect with session resume
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
import websocket
|
||||
|
||||
from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.qq.qq_message import QQMessage
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
|
||||
# Rich media file_type constants
|
||||
QQ_FILE_TYPE_IMAGE = 1
|
||||
QQ_FILE_TYPE_VIDEO = 2
|
||||
QQ_FILE_TYPE_VOICE = 3
|
||||
QQ_FILE_TYPE_FILE = 4
|
||||
|
||||
QQ_API_BASE = "https://api.sgroup.qq.com"
|
||||
|
||||
# Intents: GROUP_AND_C2C_EVENT(1<<25) | PUBLIC_GUILD_MESSAGES(1<<30)
|
||||
DEFAULT_INTENTS = (1 << 25) | (1 << 30)
|
||||
|
||||
# OpCode constants
|
||||
OP_DISPATCH = 0
|
||||
OP_HEARTBEAT = 1
|
||||
OP_IDENTIFY = 2
|
||||
OP_RESUME = 6
|
||||
OP_RECONNECT = 7
|
||||
OP_INVALID_SESSION = 9
|
||||
OP_HELLO = 10
|
||||
OP_HEARTBEAT_ACK = 11
|
||||
|
||||
# Resumable error codes
|
||||
RESUMABLE_CLOSE_CODES = {4008, 4009}
|
||||
|
||||
|
||||
@singleton
|
||||
class QQChannel(ChatChannel):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.app_id = ""
|
||||
self.app_secret = ""
|
||||
|
||||
self._access_token = ""
|
||||
self._token_expires_at = 0
|
||||
|
||||
self._ws = None
|
||||
self._ws_thread = None
|
||||
self._heartbeat_thread = None
|
||||
self._connected = False
|
||||
self._stop_event = threading.Event()
|
||||
self._token_lock = threading.Lock()
|
||||
|
||||
self._session_id = None
|
||||
self._last_seq = None
|
||||
self._heartbeat_interval = 45000
|
||||
self._can_resume = False
|
||||
|
||||
self.received_msgs = ExpiredDict(60 * 60 * 7.1)
|
||||
self._msg_seq_counter = {}
|
||||
|
||||
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||
conf()["single_chat_prefix"] = [""]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def startup(self):
|
||||
self.app_id = conf().get("qq_app_id", "")
|
||||
self.app_secret = conf().get("qq_app_secret", "")
|
||||
|
||||
if not self.app_id or not self.app_secret:
|
||||
err = "[QQ] qq_app_id and qq_app_secret are required"
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
|
||||
self._refresh_access_token()
|
||||
if not self._access_token:
|
||||
err = "[QQ] Failed to get initial access_token"
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._start_ws()
|
||||
|
||||
def stop(self):
|
||||
logger.info("[QQ] stop() called")
|
||||
self._stop_event.set()
|
||||
if self._ws:
|
||||
try:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Access Token
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_access_token(self):
|
||||
try:
|
||||
resp = requests.post(
|
||||
"https://bots.qq.com/app/getAppAccessToken",
|
||||
json={"appId": self.app_id, "clientSecret": self.app_secret},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._access_token = data.get("access_token", "")
|
||||
expires_in = int(data.get("expires_in", 7200))
|
||||
self._token_expires_at = time.time() + expires_in - 60
|
||||
logger.debug(f"[QQ] Access token refreshed, expires_in={expires_in}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to refresh access_token: {e}")
|
||||
|
||||
def _get_access_token(self) -> str:
|
||||
with self._token_lock:
|
||||
if time.time() >= self._token_expires_at:
|
||||
self._refresh_access_token()
|
||||
return self._access_token
|
||||
|
||||
def _get_auth_headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": f"QQBot {self._get_access_token()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket connection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_ws_url(self) -> str:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{QQ_API_BASE}/gateway",
|
||||
headers=self._get_auth_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
url = resp.json().get("url", "")
|
||||
logger.debug(f"[QQ] Gateway URL: {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to get gateway URL: {e}")
|
||||
return ""
|
||||
|
||||
def _start_ws(self):
|
||||
ws_url = self._get_ws_url()
|
||||
if not ws_url:
|
||||
logger.error("[QQ] Cannot start WebSocket without gateway URL")
|
||||
self.report_startup_error("Failed to get gateway URL")
|
||||
return
|
||||
|
||||
def _on_open(ws):
|
||||
logger.debug("[QQ] WebSocket connected, waiting for Hello...")
|
||||
|
||||
def _on_message(ws, raw):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
self._handle_ws_message(data)
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to handle ws message: {e}", exc_info=True)
|
||||
|
||||
def _on_error(ws, error):
|
||||
logger.error(f"[QQ] WebSocket error: {error}")
|
||||
|
||||
def _on_close(ws, close_status_code, close_msg):
|
||||
logger.warning(f"[QQ] WebSocket closed: status={close_status_code}, msg={close_msg}")
|
||||
self._connected = False
|
||||
if not self._stop_event.is_set():
|
||||
if close_status_code in RESUMABLE_CLOSE_CODES and self._session_id:
|
||||
self._can_resume = True
|
||||
logger.info("[QQ] Will attempt resume in 3s...")
|
||||
time.sleep(3)
|
||||
else:
|
||||
self._can_resume = False
|
||||
logger.info("[QQ] Will reconnect in 5s...")
|
||||
time.sleep(5)
|
||||
if not self._stop_event.is_set():
|
||||
self._start_ws()
|
||||
|
||||
self._ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_open=_on_open,
|
||||
on_message=_on_message,
|
||||
on_error=_on_error,
|
||||
on_close=_on_close,
|
||||
)
|
||||
|
||||
def run_forever():
|
||||
try:
|
||||
self._ws.run_forever(ping_interval=0, reconnect=0)
|
||||
except (SystemExit, KeyboardInterrupt):
|
||||
logger.info("[QQ] WebSocket thread interrupted")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] WebSocket run_forever error: {e}")
|
||||
|
||||
self._ws_thread = threading.Thread(target=run_forever, daemon=True)
|
||||
self._ws_thread.start()
|
||||
self._ws_thread.join()
|
||||
|
||||
def _ws_send(self, data: dict):
|
||||
if self._ws:
|
||||
self._ws.send(json.dumps(data, ensure_ascii=False))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identify & Resume & Heartbeat
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send_identify(self):
|
||||
self._ws_send({
|
||||
"op": OP_IDENTIFY,
|
||||
"d": {
|
||||
"token": f"QQBot {self._get_access_token()}",
|
||||
"intents": DEFAULT_INTENTS,
|
||||
"shard": [0, 1],
|
||||
"properties": {
|
||||
"$os": "linux",
|
||||
"$browser": "chatgpt-on-wechat",
|
||||
"$device": "chatgpt-on-wechat",
|
||||
},
|
||||
},
|
||||
})
|
||||
logger.debug(f"[QQ] Identify sent with intents={DEFAULT_INTENTS}")
|
||||
|
||||
def _send_resume(self):
|
||||
self._ws_send({
|
||||
"op": OP_RESUME,
|
||||
"d": {
|
||||
"token": f"QQBot {self._get_access_token()}",
|
||||
"session_id": self._session_id,
|
||||
"seq": self._last_seq,
|
||||
},
|
||||
})
|
||||
logger.debug(f"[QQ] Resume sent: session_id={self._session_id}, seq={self._last_seq}")
|
||||
|
||||
def _start_heartbeat(self, interval_ms: int):
|
||||
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
||||
return
|
||||
self._heartbeat_interval = interval_ms
|
||||
interval_sec = interval_ms / 1000.0
|
||||
|
||||
def heartbeat_loop():
|
||||
while not self._stop_event.is_set() and self._connected:
|
||||
try:
|
||||
self._ws_send({
|
||||
"op": OP_HEARTBEAT,
|
||||
"d": self._last_seq,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"[QQ] Heartbeat send failed: {e}")
|
||||
break
|
||||
self._stop_event.wait(interval_sec)
|
||||
|
||||
self._heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True)
|
||||
self._heartbeat_thread.start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Incoming message dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_ws_message(self, data: dict):
|
||||
op = data.get("op")
|
||||
d = data.get("d")
|
||||
t = data.get("t")
|
||||
s = data.get("s")
|
||||
|
||||
if s is not None:
|
||||
self._last_seq = s
|
||||
|
||||
if op == OP_HELLO:
|
||||
heartbeat_interval = d.get("heartbeat_interval", 45000) if d else 45000
|
||||
logger.debug(f"[QQ] Received Hello, heartbeat_interval={heartbeat_interval}ms")
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
if self._can_resume and self._session_id:
|
||||
self._send_resume()
|
||||
else:
|
||||
self._send_identify()
|
||||
|
||||
elif op == OP_HEARTBEAT_ACK:
|
||||
pass
|
||||
|
||||
elif op == OP_HEARTBEAT:
|
||||
self._ws_send({"op": OP_HEARTBEAT, "d": self._last_seq})
|
||||
|
||||
elif op == OP_RECONNECT:
|
||||
logger.warning("[QQ] Server requested reconnect")
|
||||
self._can_resume = True
|
||||
if self._ws:
|
||||
self._ws.close()
|
||||
|
||||
elif op == OP_INVALID_SESSION:
|
||||
logger.warning("[QQ] Invalid session, re-identifying...")
|
||||
self._session_id = None
|
||||
self._can_resume = False
|
||||
time.sleep(2)
|
||||
self._send_identify()
|
||||
|
||||
elif op == OP_DISPATCH:
|
||||
if t == "READY":
|
||||
self._session_id = d.get("session_id", "")
|
||||
user = d.get("user", {})
|
||||
bot_name = user.get('username', '')
|
||||
logger.info(f"[QQ] ✅ Connected successfully (bot={bot_name})")
|
||||
self._connected = True
|
||||
self._can_resume = False
|
||||
self._start_heartbeat(self._heartbeat_interval)
|
||||
self.report_startup_success()
|
||||
|
||||
elif t == "RESUMED":
|
||||
logger.info("[QQ] Session resumed successfully")
|
||||
self._connected = True
|
||||
self._can_resume = False
|
||||
self._start_heartbeat(self._heartbeat_interval)
|
||||
|
||||
elif t in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE",
|
||||
"AT_MESSAGE_CREATE", "DIRECT_MESSAGE_CREATE"):
|
||||
self._handle_msg_event(d, t)
|
||||
|
||||
elif t in ("GROUP_ADD_ROBOT", "FRIEND_ADD"):
|
||||
logger.info(f"[QQ] Event: {t}")
|
||||
|
||||
else:
|
||||
logger.debug(f"[QQ] Dispatch event: {t}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message event handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_msg_event(self, event_data: dict, event_type: str):
|
||||
msg_id = event_data.get("id", "")
|
||||
if self.received_msgs.get(msg_id):
|
||||
logger.debug(f"[QQ] Duplicate msg filtered: {msg_id}")
|
||||
return
|
||||
self.received_msgs[msg_id] = True
|
||||
|
||||
try:
|
||||
qq_msg = QQMessage(event_data, event_type)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"[QQ] {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to parse message: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
is_group = qq_msg.is_group
|
||||
|
||||
from channel.file_cache import get_file_cache
|
||||
file_cache = get_file_cache()
|
||||
|
||||
if is_group:
|
||||
session_id = qq_msg.other_user_id
|
||||
else:
|
||||
session_id = qq_msg.from_user_id
|
||||
|
||||
if qq_msg.ctype == ContextType.IMAGE:
|
||||
if hasattr(qq_msg, "image_path") and qq_msg.image_path:
|
||||
file_cache.add(session_id, qq_msg.image_path, file_type="image")
|
||||
logger.info(f"[QQ] Image cached for session {session_id}")
|
||||
return
|
||||
|
||||
if qq_msg.ctype == ContextType.TEXT:
|
||||
cached_files = file_cache.get(session_id)
|
||||
if cached_files:
|
||||
file_refs = []
|
||||
for fi in cached_files:
|
||||
ftype = fi["type"]
|
||||
fpath = fi["path"]
|
||||
if ftype == "image":
|
||||
file_refs.append(f"[图片: {fpath}]")
|
||||
elif ftype == "video":
|
||||
file_refs.append(f"[视频: {fpath}]")
|
||||
else:
|
||||
file_refs.append(f"[文件: {fpath}]")
|
||||
qq_msg.content = qq_msg.content + "\n" + "\n".join(file_refs)
|
||||
logger.info(f"[QQ] Attached {len(cached_files)} cached file(s)")
|
||||
file_cache.clear(session_id)
|
||||
|
||||
context = self._compose_context(
|
||||
qq_msg.ctype,
|
||||
qq_msg.content,
|
||||
isgroup=is_group,
|
||||
msg=qq_msg,
|
||||
no_need_at=True,
|
||||
)
|
||||
if context:
|
||||
self.produce(context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _compose_context
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||
context = Context(ctype, content)
|
||||
context.kwargs = kwargs
|
||||
if "channel_type" not in context:
|
||||
context["channel_type"] = self.channel_type
|
||||
if "origin_ctype" not in context:
|
||||
context["origin_ctype"] = ctype
|
||||
|
||||
cmsg = context["msg"]
|
||||
|
||||
if cmsg.is_group:
|
||||
context["session_id"] = cmsg.other_user_id
|
||||
else:
|
||||
context["session_id"] = cmsg.from_user_id
|
||||
|
||||
context["receiver"] = cmsg.other_user_id
|
||||
|
||||
if ctype == ContextType.TEXT:
|
||||
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
|
||||
if img_match_prefix:
|
||||
content = content.replace(img_match_prefix, "", 1)
|
||||
context.type = ContextType.IMAGE_CREATE
|
||||
else:
|
||||
context.type = ContextType.TEXT
|
||||
context.content = content.strip()
|
||||
|
||||
return context
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Send reply
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
msg = context.get("msg")
|
||||
is_group = context.get("isgroup", False)
|
||||
receiver = context.get("receiver", "")
|
||||
|
||||
if not msg:
|
||||
# Active send (e.g. scheduled tasks), no original message to reply to
|
||||
self._active_send_text(reply.content if reply.type == ReplyType.TEXT else str(reply.content),
|
||||
receiver, is_group)
|
||||
return
|
||||
|
||||
event_type = getattr(msg, "event_type", "")
|
||||
msg_id = getattr(msg, "msg_id", "")
|
||||
|
||||
if reply.type == ReplyType.TEXT:
|
||||
self._send_text(reply.content, msg, event_type, msg_id)
|
||||
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||
self._send_image(reply.content, msg, event_type, msg_id)
|
||||
elif reply.type == ReplyType.FILE:
|
||||
if hasattr(reply, "text_content") and reply.text_content:
|
||||
self._send_text(reply.text_content, msg, event_type, msg_id)
|
||||
time.sleep(0.3)
|
||||
self._send_file(reply.content, msg, event_type, msg_id)
|
||||
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL):
|
||||
self._send_media(reply.content, msg, event_type, msg_id, QQ_FILE_TYPE_VIDEO)
|
||||
else:
|
||||
logger.warning(f"[QQ] Unsupported reply type: {reply.type}, falling back to text")
|
||||
self._send_text(str(reply.content), msg, event_type, msg_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Send helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_next_msg_seq(self, msg_id: str) -> int:
|
||||
seq = self._msg_seq_counter.get(msg_id, 1)
|
||||
self._msg_seq_counter[msg_id] = seq + 1
|
||||
return seq
|
||||
|
||||
def _build_msg_url_and_base_body(self, msg: QQMessage, event_type: str, msg_id: str):
|
||||
"""Build the API URL and base body dict for sending a message."""
|
||||
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||
group_openid = msg._rawmsg.get("group_openid", "")
|
||||
url = f"{QQ_API_BASE}/v2/groups/{group_openid}/messages"
|
||||
body = {
|
||||
"msg_id": msg_id,
|
||||
"msg_seq": self._get_next_msg_seq(msg_id),
|
||||
}
|
||||
return url, body, "group", group_openid
|
||||
|
||||
elif event_type == "C2C_MESSAGE_CREATE":
|
||||
user_openid = msg._rawmsg.get("author", {}).get("user_openid", "") or msg.from_user_id
|
||||
url = f"{QQ_API_BASE}/v2/users/{user_openid}/messages"
|
||||
body = {
|
||||
"msg_id": msg_id,
|
||||
"msg_seq": self._get_next_msg_seq(msg_id),
|
||||
}
|
||||
return url, body, "c2c", user_openid
|
||||
|
||||
elif event_type == "AT_MESSAGE_CREATE":
|
||||
channel_id = msg._rawmsg.get("channel_id", "")
|
||||
url = f"{QQ_API_BASE}/channels/{channel_id}/messages"
|
||||
body = {"msg_id": msg_id}
|
||||
return url, body, "channel", channel_id
|
||||
|
||||
elif event_type == "DIRECT_MESSAGE_CREATE":
|
||||
guild_id = msg._rawmsg.get("guild_id", "")
|
||||
url = f"{QQ_API_BASE}/dms/{guild_id}/messages"
|
||||
body = {"msg_id": msg_id}
|
||||
return url, body, "dm", guild_id
|
||||
|
||||
return None, None, None, None
|
||||
|
||||
def _post_message(self, url: str, body: dict, event_type: str):
|
||||
try:
|
||||
resp = requests.post(url, json=body, headers=self._get_auth_headers(), timeout=10)
|
||||
if resp.status_code in (200, 201, 202, 204):
|
||||
logger.info(f"[QQ] Message sent successfully: event_type={event_type}")
|
||||
else:
|
||||
logger.error(f"[QQ] Failed to send message: status={resp.status_code}, "
|
||||
f"body={resp.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Send message error: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Active send (no original message, e.g. scheduled tasks)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _active_send_text(self, content: str, receiver: str, is_group: bool):
|
||||
"""Send text without an original message (active push). QQ limits active messages to 4/month per user."""
|
||||
if not receiver:
|
||||
logger.warning("[QQ] No receiver for active send")
|
||||
return
|
||||
if is_group:
|
||||
url = f"{QQ_API_BASE}/v2/groups/{receiver}/messages"
|
||||
else:
|
||||
url = f"{QQ_API_BASE}/v2/users/{receiver}/messages"
|
||||
body = {
|
||||
"content": content,
|
||||
"msg_type": 0,
|
||||
}
|
||||
event_label = "GROUP_ACTIVE" if is_group else "C2C_ACTIVE"
|
||||
self._post_message(url, body, event_label)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Send text
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send_text(self, content: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||
url, body, _, _ = self._build_msg_url_and_base_body(msg, event_type, msg_id)
|
||||
if not url:
|
||||
logger.warning(f"[QQ] Cannot send reply for event_type: {event_type}")
|
||||
return
|
||||
body["content"] = content
|
||||
body["msg_type"] = 0
|
||||
self._post_message(url, body, event_type)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rich media upload & send (image / video / file)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _upload_rich_media(self, file_url: str, file_type: int, msg: QQMessage,
|
||||
event_type: str) -> str:
|
||||
"""
|
||||
Upload media via QQ rich media API and return file_info.
|
||||
For group: POST /v2/groups/{group_openid}/files
|
||||
For c2c: POST /v2/users/{openid}/files
|
||||
"""
|
||||
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||
group_openid = msg._rawmsg.get("group_openid", "")
|
||||
upload_url = f"{QQ_API_BASE}/v2/groups/{group_openid}/files"
|
||||
elif event_type == "C2C_MESSAGE_CREATE":
|
||||
user_openid = (msg._rawmsg.get("author", {}).get("user_openid", "")
|
||||
or msg.from_user_id)
|
||||
upload_url = f"{QQ_API_BASE}/v2/users/{user_openid}/files"
|
||||
else:
|
||||
logger.warning(f"[QQ] Rich media upload not supported for event_type: {event_type}")
|
||||
return ""
|
||||
|
||||
upload_body = {
|
||||
"file_type": file_type,
|
||||
"url": file_url,
|
||||
"srv_send_msg": False,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
upload_url, json=upload_body,
|
||||
headers=self._get_auth_headers(), timeout=30,
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
data = resp.json()
|
||||
file_info = data.get("file_info", "")
|
||||
logger.info(f"[QQ] Rich media uploaded: file_type={file_type}, "
|
||||
f"file_uuid={data.get('file_uuid', '')}")
|
||||
return file_info
|
||||
else:
|
||||
logger.error(f"[QQ] Rich media upload failed: status={resp.status_code}, "
|
||||
f"body={resp.text}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Rich media upload error: {e}")
|
||||
return ""
|
||||
|
||||
def _upload_rich_media_base64(self, file_path: str, file_type: int, msg: QQMessage,
|
||||
event_type: str) -> str:
|
||||
"""Upload local file via base64 file_data field."""
|
||||
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||
group_openid = msg._rawmsg.get("group_openid", "")
|
||||
upload_url = f"{QQ_API_BASE}/v2/groups/{group_openid}/files"
|
||||
elif event_type == "C2C_MESSAGE_CREATE":
|
||||
user_openid = (msg._rawmsg.get("author", {}).get("user_openid", "")
|
||||
or msg.from_user_id)
|
||||
upload_url = f"{QQ_API_BASE}/v2/users/{user_openid}/files"
|
||||
else:
|
||||
logger.warning(f"[QQ] Rich media upload not supported for event_type: {event_type}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
file_data = base64.b64encode(f.read()).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to read file for upload: {e}")
|
||||
return ""
|
||||
|
||||
upload_body = {
|
||||
"file_type": file_type,
|
||||
"file_data": file_data,
|
||||
"srv_send_msg": False,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
upload_url, json=upload_body,
|
||||
headers=self._get_auth_headers(), timeout=30,
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
data = resp.json()
|
||||
file_info = data.get("file_info", "")
|
||||
logger.info(f"[QQ] Rich media uploaded (base64): file_type={file_type}, "
|
||||
f"file_uuid={data.get('file_uuid', '')}")
|
||||
return file_info
|
||||
else:
|
||||
logger.error(f"[QQ] Rich media upload (base64) failed: status={resp.status_code}, "
|
||||
f"body={resp.text}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Rich media upload (base64) error: {e}")
|
||||
return ""
|
||||
|
||||
def _send_media_msg(self, file_info: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||
"""Send a message with msg_type=7 (rich media) using file_info."""
|
||||
url, body, _, _ = self._build_msg_url_and_base_body(msg, event_type, msg_id)
|
||||
if not url:
|
||||
return
|
||||
body["msg_type"] = 7
|
||||
body["media"] = {"file_info": file_info}
|
||||
self._post_message(url, body, event_type)
|
||||
|
||||
def _send_image(self, img_path_or_url: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||
"""Send image reply. Supports URL and local file path."""
|
||||
if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"):
|
||||
self._send_text(str(img_path_or_url), msg, event_type, msg_id)
|
||||
return
|
||||
|
||||
if img_path_or_url.startswith("file://"):
|
||||
img_path_or_url = img_path_or_url[7:]
|
||||
|
||||
if img_path_or_url.startswith(("http://", "https://")):
|
||||
file_info = self._upload_rich_media(
|
||||
img_path_or_url, QQ_FILE_TYPE_IMAGE, msg, event_type)
|
||||
elif os.path.exists(img_path_or_url):
|
||||
file_info = self._upload_rich_media_base64(
|
||||
img_path_or_url, QQ_FILE_TYPE_IMAGE, msg, event_type)
|
||||
else:
|
||||
logger.error(f"[QQ] Image not found: {img_path_or_url}")
|
||||
self._send_text("[Image send failed]", msg, event_type, msg_id)
|
||||
return
|
||||
|
||||
if file_info:
|
||||
self._send_media_msg(file_info, msg, event_type, msg_id)
|
||||
else:
|
||||
self._send_text("[Image upload failed]", msg, event_type, msg_id)
|
||||
|
||||
def _send_file(self, file_path_or_url: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||
"""Send file reply."""
|
||||
if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"):
|
||||
self._send_text(str(file_path_or_url), msg, event_type, msg_id)
|
||||
return
|
||||
|
||||
if file_path_or_url.startswith("file://"):
|
||||
file_path_or_url = file_path_or_url[7:]
|
||||
|
||||
if file_path_or_url.startswith(("http://", "https://")):
|
||||
file_info = self._upload_rich_media(
|
||||
file_path_or_url, QQ_FILE_TYPE_FILE, msg, event_type)
|
||||
elif os.path.exists(file_path_or_url):
|
||||
file_info = self._upload_rich_media_base64(
|
||||
file_path_or_url, QQ_FILE_TYPE_FILE, msg, event_type)
|
||||
else:
|
||||
logger.error(f"[QQ] File not found: {file_path_or_url}")
|
||||
self._send_text("[File send failed]", msg, event_type, msg_id)
|
||||
return
|
||||
|
||||
if file_info:
|
||||
self._send_media_msg(file_info, msg, event_type, msg_id)
|
||||
else:
|
||||
self._send_text("[File upload failed]", msg, event_type, msg_id)
|
||||
|
||||
def _send_media(self, path_or_url: str, msg: QQMessage, event_type: str,
|
||||
msg_id: str, file_type: int):
|
||||
"""Generic media send for video/voice etc."""
|
||||
if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"):
|
||||
self._send_text(str(path_or_url), msg, event_type, msg_id)
|
||||
return
|
||||
|
||||
if path_or_url.startswith("file://"):
|
||||
path_or_url = path_or_url[7:]
|
||||
|
||||
if path_or_url.startswith(("http://", "https://")):
|
||||
file_info = self._upload_rich_media(path_or_url, file_type, msg, event_type)
|
||||
elif os.path.exists(path_or_url):
|
||||
file_info = self._upload_rich_media_base64(path_or_url, file_type, msg, event_type)
|
||||
else:
|
||||
logger.error(f"[QQ] Media not found: {path_or_url}")
|
||||
return
|
||||
|
||||
if file_info:
|
||||
self._send_media_msg(file_info, msg, event_type, msg_id)
|
||||
else:
|
||||
logger.error(f"[QQ] Media upload failed: {path_or_url}")
|
||||
123
channel/qq/qq_message.py
Normal file
123
channel/qq/qq_message.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import os
|
||||
import requests
|
||||
|
||||
from bridge.context import ContextType
|
||||
from channel.chat_message import ChatMessage
|
||||
from common.log import logger
|
||||
from common.utils import expand_path
|
||||
from config import conf
|
||||
|
||||
|
||||
def _get_tmp_dir() -> str:
|
||||
"""Return the workspace tmp directory (absolute path), creating it if needed."""
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
tmp_dir = os.path.join(ws_root, "tmp")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
return tmp_dir
|
||||
|
||||
|
||||
class QQMessage(ChatMessage):
|
||||
"""Message wrapper for QQ Bot (websocket long-connection mode)."""
|
||||
|
||||
def __init__(self, event_data: dict, event_type: str):
|
||||
super().__init__(event_data)
|
||||
self.msg_id = event_data.get("id", "")
|
||||
self.create_time = event_data.get("timestamp", "")
|
||||
self.is_group = event_type in ("GROUP_AT_MESSAGE_CREATE",)
|
||||
self.event_type = event_type
|
||||
|
||||
author = event_data.get("author", {})
|
||||
from_user_id = author.get("member_openid", "") or author.get("id", "")
|
||||
group_openid = event_data.get("group_openid", "")
|
||||
|
||||
content = event_data.get("content", "").strip()
|
||||
|
||||
attachments = event_data.get("attachments", [])
|
||||
has_image = any(
|
||||
a.get("content_type", "").startswith("image/") for a in attachments
|
||||
) if attachments else False
|
||||
|
||||
if has_image and not content:
|
||||
self.ctype = ContextType.IMAGE
|
||||
img_attachment = next(
|
||||
a for a in attachments if a.get("content_type", "").startswith("image/")
|
||||
)
|
||||
img_url = img_attachment.get("url", "")
|
||||
if img_url and not img_url.startswith("http"):
|
||||
img_url = "https://" + img_url
|
||||
tmp_dir = _get_tmp_dir()
|
||||
image_path = os.path.join(tmp_dir, f"qq_{self.msg_id}.png")
|
||||
try:
|
||||
resp = requests.get(img_url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
self.content = image_path
|
||||
self.image_path = image_path
|
||||
logger.info(f"[QQ] Image downloaded: {image_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to download image: {e}")
|
||||
self.content = "[Image download failed]"
|
||||
self.image_path = None
|
||||
elif has_image and content:
|
||||
self.ctype = ContextType.TEXT
|
||||
image_paths = []
|
||||
tmp_dir = _get_tmp_dir()
|
||||
for idx, att in enumerate(attachments):
|
||||
if not att.get("content_type", "").startswith("image/"):
|
||||
continue
|
||||
img_url = att.get("url", "")
|
||||
if img_url and not img_url.startswith("http"):
|
||||
img_url = "https://" + img_url
|
||||
img_path = os.path.join(tmp_dir, f"qq_{self.msg_id}_{idx}.png")
|
||||
try:
|
||||
resp = requests.get(img_url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
with open(img_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
image_paths.append(img_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[QQ] Failed to download mixed image: {e}")
|
||||
content_parts = [content]
|
||||
for p in image_paths:
|
||||
content_parts.append(f"[图片: {p}]")
|
||||
self.content = "\n".join(content_parts)
|
||||
else:
|
||||
self.ctype = ContextType.TEXT
|
||||
self.content = content
|
||||
|
||||
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||
self.from_user_id = from_user_id
|
||||
self.to_user_id = ""
|
||||
self.other_user_id = group_openid
|
||||
self.actual_user_id = from_user_id
|
||||
self.actual_user_nickname = from_user_id
|
||||
|
||||
elif event_type == "C2C_MESSAGE_CREATE":
|
||||
user_openid = author.get("user_openid", "") or from_user_id
|
||||
self.from_user_id = user_openid
|
||||
self.to_user_id = ""
|
||||
self.other_user_id = user_openid
|
||||
self.actual_user_id = user_openid
|
||||
|
||||
elif event_type == "AT_MESSAGE_CREATE":
|
||||
self.from_user_id = from_user_id
|
||||
self.to_user_id = ""
|
||||
channel_id = event_data.get("channel_id", "")
|
||||
self.other_user_id = channel_id
|
||||
self.actual_user_id = from_user_id
|
||||
self.actual_user_nickname = author.get("username", from_user_id)
|
||||
|
||||
elif event_type == "DIRECT_MESSAGE_CREATE":
|
||||
self.from_user_id = from_user_id
|
||||
self.to_user_id = ""
|
||||
guild_id = event_data.get("guild_id", "")
|
||||
self.other_user_id = f"dm_{guild_id}_{from_user_id}"
|
||||
self.actual_user_id = from_user_id
|
||||
self.actual_user_nickname = author.get("username", from_user_id)
|
||||
|
||||
else:
|
||||
raise NotImplementedError(f"Unsupported QQ event type: {event_type}")
|
||||
|
||||
logger.debug(f"[QQ] Message parsed: type={event_type}, ctype={self.ctype}, "
|
||||
f"from={self.from_user_id}, content_len={len(self.content)}")
|
||||
@@ -267,30 +267,44 @@
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="flex-shrink-0 border-t border-slate-200 dark:border-white/10 bg-white dark:bg-[#1A1A1A] px-4 py-3">
|
||||
<div class="max-w-3xl mx-auto flex items-center gap-2">
|
||||
<button id="new-chat-btn" class="flex-shrink-0 w-10 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
|
||||
cursor-pointer transition-colors duration-150" title="New Chat"
|
||||
onclick="newChat()">
|
||||
<i class="fas fa-plus text-base"></i>
|
||||
</button>
|
||||
<textarea id="chat-input"
|
||||
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500
|
||||
focus:outline-none focus:ring-0 focus:border-primary-600
|
||||
text-sm leading-relaxed"
|
||||
rows="1"
|
||||
data-i18n-placeholder="input_placeholder"
|
||||
placeholder="Type a message..."></textarea>
|
||||
<button id="send-btn"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
disabled:bg-slate-300 dark:disabled:bg-slate-600
|
||||
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
||||
disabled onclick="sendMessage()">
|
||||
<i class="fas fa-paper-plane text-sm"></i>
|
||||
</button>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Attachment preview bar -->
|
||||
<div id="attachment-preview" class="attachment-preview hidden"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
<button id="new-chat-btn" class="w-9 h-10 flex items-center justify-center rounded-lg
|
||||
text-slate-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
cursor-pointer transition-colors duration-150" title="New Chat"
|
||||
onclick="newChat()">
|
||||
<i class="fas fa-plus text-base"></i>
|
||||
</button>
|
||||
<button id="attach-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
|
||||
cursor-pointer transition-colors duration-150"
|
||||
title="Attach file" onclick="document.getElementById('file-input').click()">
|
||||
<i class="fas fa-paperclip text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" id="file-input" class="hidden" multiple
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.rar,.7z,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md">
|
||||
<textarea id="chat-input"
|
||||
class="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-slate-800 dark:text-slate-100
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500
|
||||
focus:outline-none focus:ring-0 focus:border-primary-600
|
||||
text-sm leading-relaxed"
|
||||
rows="1"
|
||||
data-i18n-placeholder="input_placeholder"
|
||||
placeholder="Type a message..."></textarea>
|
||||
<button id="send-btn"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
disabled:bg-slate-300 dark:disabled:bg-slate-600
|
||||
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
||||
disabled onclick="sendMessage()">
|
||||
<i class="fas fa-paper-plane text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,6 +344,100 @@
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Attachment Preview Bar */
|
||||
.attachment-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.attachment-preview.hidden { display: none; }
|
||||
|
||||
.att-thumb {
|
||||
position: relative;
|
||||
width: 64px; height: 64px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dark .att-thumb { border-color: rgba(255,255,255,0.1); }
|
||||
.att-thumb img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.att-chip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 28px 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
max-width: 180px;
|
||||
}
|
||||
.dark .att-chip { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); color: #94a3b8; }
|
||||
.att-uploading { opacity: 0.6; pointer-events: none; }
|
||||
.att-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.att-remove {
|
||||
position: absolute;
|
||||
top: -4px; right: -4px;
|
||||
width: 18px; height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.att-thumb:hover .att-remove,
|
||||
.att-chip:hover .att-remove { opacity: 1; }
|
||||
|
||||
/* Drag-over highlight */
|
||||
.drag-over {
|
||||
background: rgba(74, 190, 110, 0.08) !important;
|
||||
border-color: #4ABE6E !important;
|
||||
}
|
||||
|
||||
/* User message attachments */
|
||||
.user-msg-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.user-msg-image {
|
||||
max-width: 200px;
|
||||
max-height: 160px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-msg-image:hover { opacity: 0.9; }
|
||||
.user-msg-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Placeholder Cards */
|
||||
.placeholder-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// =====================================================================
|
||||
// Version — update this before each release
|
||||
// =====================================================================
|
||||
const APP_VERSION = 'v2.0.2';
|
||||
const APP_VERSION = 'v2.0.3';
|
||||
|
||||
// =====================================================================
|
||||
// i18n
|
||||
@@ -19,7 +19,7 @@ const I18N = {
|
||||
menu_logs: '日志',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆<br>不断成长',
|
||||
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
example_task_title: '智能任务', example_task_text: '提醒我5分钟后查看服务器情况',
|
||||
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
||||
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
|
||||
input_placeholder: '输入消息...',
|
||||
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
|
||||
@@ -65,7 +65,7 @@ const I18N = {
|
||||
menu_logs: 'Logs',
|
||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory.',
|
||||
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
||||
example_task_title: 'Smart Task', example_task_text: 'Remind me to check the server in 5 minutes',
|
||||
example_task_title: 'Skills', example_task_text: 'Show current tools and skills',
|
||||
example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script',
|
||||
input_placeholder: 'Type a message...',
|
||||
config_title: 'Configuration', config_desc: 'Manage model and agent settings',
|
||||
@@ -304,6 +304,123 @@ fetch('/config').then(r => r.json()).then(data => {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const attachmentPreview = document.getElementById('attachment-preview');
|
||||
|
||||
// Pending attachments: [{file_path, file_name, file_type, preview_url}]
|
||||
// Items with _uploading=true are still in flight.
|
||||
let pendingAttachments = [];
|
||||
let uploadingCount = 0;
|
||||
|
||||
function updateSendBtnState() {
|
||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||
}
|
||||
|
||||
function renderAttachmentPreview() {
|
||||
if (pendingAttachments.length === 0) {
|
||||
attachmentPreview.classList.add('hidden');
|
||||
attachmentPreview.innerHTML = '';
|
||||
updateSendBtnState();
|
||||
return;
|
||||
}
|
||||
attachmentPreview.classList.remove('hidden');
|
||||
attachmentPreview.innerHTML = pendingAttachments.map((att, idx) => {
|
||||
if (att._uploading) {
|
||||
return `<div class="att-chip att-uploading" data-idx="${idx}">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span class="att-name">${escapeHtml(att.file_name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (att.file_type === 'image') {
|
||||
return `<div class="att-thumb" data-idx="${idx}">
|
||||
<img src="${att.preview_url}" alt="${escapeHtml(att.file_name)}">
|
||||
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
||||
</div>`;
|
||||
}
|
||||
const icon = att.file_type === 'video' ? 'fa-film' : 'fa-file-alt';
|
||||
return `<div class="att-chip" data-idx="${idx}">
|
||||
<i class="fas ${icon}"></i>
|
||||
<span class="att-name">${escapeHtml(att.file_name)}</span>
|
||||
<button class="att-remove" onclick="removeAttachment(${idx})">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
updateSendBtnState();
|
||||
}
|
||||
|
||||
function removeAttachment(idx) {
|
||||
if (pendingAttachments[idx]?._uploading) return;
|
||||
pendingAttachments.splice(idx, 1);
|
||||
renderAttachmentPreview();
|
||||
}
|
||||
|
||||
async function handleFileSelect(files) {
|
||||
if (!files || files.length === 0) return;
|
||||
const tasks = [];
|
||||
for (const file of files) {
|
||||
const placeholder = { file_name: file.name, file_type: 'file', _uploading: true };
|
||||
pendingAttachments.push(placeholder);
|
||||
uploadingCount++;
|
||||
renderAttachmentPreview();
|
||||
|
||||
tasks.push((async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('session_id', sessionId);
|
||||
try {
|
||||
const resp = await fetch('/upload', { method: 'POST', body: formData });
|
||||
const data = await resp.json();
|
||||
if (data.status === 'success') {
|
||||
placeholder.file_path = data.file_path;
|
||||
placeholder.file_name = data.file_name;
|
||||
placeholder.file_type = data.file_type;
|
||||
placeholder.preview_url = data.preview_url;
|
||||
delete placeholder._uploading;
|
||||
} else {
|
||||
const i = pendingAttachments.indexOf(placeholder);
|
||||
if (i !== -1) pendingAttachments.splice(i, 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e);
|
||||
const i = pendingAttachments.indexOf(placeholder);
|
||||
if (i !== -1) pendingAttachments.splice(i, 1);
|
||||
}
|
||||
uploadingCount--;
|
||||
renderAttachmentPreview();
|
||||
})());
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
handleFileSelect(this.files);
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
// Drag-and-drop support on chat input area
|
||||
const chatInputArea = chatInput.closest('.flex-shrink-0');
|
||||
chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
|
||||
chatInputArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); });
|
||||
chatInputArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
chatInputArea.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
// Paste image support
|
||||
chatInput.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
files.push(item.getAsFile());
|
||||
}
|
||||
}
|
||||
if (files.length) {
|
||||
e.preventDefault();
|
||||
handleFileSelect(files);
|
||||
}
|
||||
});
|
||||
|
||||
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
|
||||
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
|
||||
@@ -314,7 +431,7 @@ chatInput.addEventListener('input', function() {
|
||||
const newH = Math.min(scrollH, 180);
|
||||
this.style.height = newH + 'px';
|
||||
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
|
||||
sendBtn.disabled = !this.value.trim();
|
||||
updateSendBtnState();
|
||||
});
|
||||
|
||||
chatInput.addEventListener('keydown', function(e) {
|
||||
@@ -346,25 +463,37 @@ document.querySelectorAll('.example-card').forEach(card => {
|
||||
|
||||
function sendMessage() {
|
||||
const text = chatInput.value.trim();
|
||||
if (!text) return;
|
||||
if (!text && pendingAttachments.length === 0) return;
|
||||
|
||||
const ws = document.getElementById('welcome-screen');
|
||||
if (ws) ws.remove();
|
||||
|
||||
const timestamp = new Date();
|
||||
addUserMessage(text, timestamp);
|
||||
const attachments = [...pendingAttachments];
|
||||
addUserMessage(text, timestamp, attachments);
|
||||
|
||||
const loadingEl = addLoadingIndicator();
|
||||
|
||||
chatInput.value = '';
|
||||
chatInput.style.height = '42px';
|
||||
chatInput.style.overflowY = 'hidden';
|
||||
pendingAttachments = [];
|
||||
renderAttachmentPreview();
|
||||
sendBtn.disabled = true;
|
||||
|
||||
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() };
|
||||
if (attachments.length > 0) {
|
||||
body.attachments = attachments.map(a => ({
|
||||
file_path: a.file_path,
|
||||
file_name: a.file_name,
|
||||
file_type: a.file_type,
|
||||
}));
|
||||
}
|
||||
|
||||
fetch('/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() })
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -574,13 +703,27 @@ function startPolling() {
|
||||
poll();
|
||||
}
|
||||
|
||||
function createUserMessageEl(content, timestamp) {
|
||||
function createUserMessageEl(content, timestamp, attachments) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'flex justify-end px-4 sm:px-6 py-3';
|
||||
|
||||
let attachHtml = '';
|
||||
if (attachments && attachments.length > 0) {
|
||||
const items = attachments.map(a => {
|
||||
if (a.file_type === 'image') {
|
||||
return `<img src="${a.preview_url}" alt="${escapeHtml(a.file_name)}" class="user-msg-image">`;
|
||||
}
|
||||
const icon = a.file_type === 'video' ? 'fa-film' : 'fa-file-alt';
|
||||
return `<div class="user-msg-file"><i class="fas ${icon}"></i> ${escapeHtml(a.file_name)}</div>`;
|
||||
}).join('');
|
||||
attachHtml = `<div class="user-msg-attachments">${items}</div>`;
|
||||
}
|
||||
|
||||
const textHtml = content ? renderMarkdown(content) : '';
|
||||
el.innerHTML = `
|
||||
<div class="max-w-[75%] sm:max-w-[60%]">
|
||||
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content">
|
||||
${renderMarkdown(content)}
|
||||
${attachHtml}${textHtml}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
||||
</div>
|
||||
@@ -635,8 +778,8 @@ function createBotMessageEl(content, timestamp, requestId, toolCalls) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function addUserMessage(content, timestamp) {
|
||||
const el = createUserMessageEl(content, timestamp);
|
||||
function addUserMessage(content, timestamp, attachments) {
|
||||
const el = createUserMessageEl(content, timestamp, attachments);
|
||||
messagesDiv.appendChild(el);
|
||||
scrollChatToBottom();
|
||||
}
|
||||
@@ -1069,9 +1212,6 @@ function saveModelConfig() {
|
||||
const updates = { model: model };
|
||||
const p = configProviders[cfgProviderValue];
|
||||
updates.use_linkai = (cfgProviderValue === 'linkai');
|
||||
// Save bot_type for bot_factory routing.
|
||||
// Most providers use their key directly as bot_type.
|
||||
// linkai uses use_linkai flag instead of bot_type.
|
||||
if (cfgProviderValue === 'linkai') {
|
||||
updates.bot_type = '';
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,17 @@ from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
||||
|
||||
|
||||
def _get_upload_dir() -> str:
|
||||
from common.utils import expand_path
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
tmp_dir = os.path.join(ws_root, "tmp")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
return tmp_dir
|
||||
|
||||
|
||||
class WebMessage(ChatMessage):
|
||||
def __init__(
|
||||
@@ -152,10 +163,53 @@ class WebChannel(ChatChannel):
|
||||
|
||||
return on_event
|
||||
|
||||
def upload_file(self):
|
||||
"""Handle file upload via multipart/form-data. Save to workspace/tmp/ and return metadata."""
|
||||
try:
|
||||
params = web.input(file={}, session_id="")
|
||||
file_obj = params.get("file")
|
||||
session_id = params.get("session_id", "")
|
||||
if file_obj is None or not hasattr(file_obj, "filename") or not file_obj.filename:
|
||||
return json.dumps({"status": "error", "message": "No file uploaded"})
|
||||
|
||||
upload_dir = _get_upload_dir()
|
||||
|
||||
original_name = file_obj.filename
|
||||
ext = os.path.splitext(original_name)[1].lower()
|
||||
safe_name = f"web_{uuid.uuid4().hex[:8]}{ext}"
|
||||
save_path = os.path.join(upload_dir, safe_name)
|
||||
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(file_obj.read() if hasattr(file_obj, "read") else file_obj.value)
|
||||
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
file_type = "image"
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
file_type = "video"
|
||||
else:
|
||||
file_type = "file"
|
||||
|
||||
preview_url = f"/uploads/{safe_name}"
|
||||
|
||||
logger.info(f"[WebChannel] File uploaded: {original_name} -> {save_path} ({file_type})")
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"file_path": save_path,
|
||||
"file_name": original_name,
|
||||
"file_type": file_type,
|
||||
"preview_url": preview_url,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] File upload error: {e}", exc_info=True)
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def post_message(self):
|
||||
"""
|
||||
Handle incoming messages from users via POST request.
|
||||
Returns a request_id for tracking this specific request.
|
||||
Supports optional attachments (file paths from /upload).
|
||||
"""
|
||||
try:
|
||||
data = web.data()
|
||||
@@ -163,6 +217,25 @@ class WebChannel(ChatChannel):
|
||||
session_id = json_data.get('session_id', f'session_{int(time.time())}')
|
||||
prompt = json_data.get('message', '')
|
||||
use_sse = json_data.get('stream', True)
|
||||
attachments = json_data.get('attachments', [])
|
||||
|
||||
# Append file references to the prompt (same format as QQ channel)
|
||||
if attachments:
|
||||
file_refs = []
|
||||
for att in attachments:
|
||||
ftype = att.get("file_type", "file")
|
||||
fpath = att.get("file_path", "")
|
||||
if not fpath:
|
||||
continue
|
||||
if ftype == "image":
|
||||
file_refs.append(f"[图片: {fpath}]")
|
||||
elif ftype == "video":
|
||||
file_refs.append(f"[视频: {fpath}]")
|
||||
else:
|
||||
file_refs.append(f"[文件: {fpath}]")
|
||||
if file_refs:
|
||||
prompt = prompt + "\n" + "\n".join(file_refs)
|
||||
logger.info(f"[WebChannel] Attached {len(file_refs)} file(s) to message")
|
||||
|
||||
request_id = self._generate_request_id()
|
||||
self.request_to_session[request_id] = session_id
|
||||
@@ -300,6 +373,8 @@ class WebChannel(ChatChannel):
|
||||
urls = (
|
||||
'/', 'RootHandler',
|
||||
'/message', 'MessageHandler',
|
||||
'/upload', 'UploadHandler',
|
||||
'/uploads/(.*)', 'UploadsHandler',
|
||||
'/poll', 'PollHandler',
|
||||
'/stream', 'StreamHandler',
|
||||
'/chat', 'ChatHandler',
|
||||
@@ -356,6 +431,34 @@ class MessageHandler:
|
||||
return WebChannel().post_message()
|
||||
|
||||
|
||||
class UploadHandler:
|
||||
def POST(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
return WebChannel().upload_file()
|
||||
|
||||
|
||||
class UploadsHandler:
|
||||
def GET(self, file_name):
|
||||
"""Serve uploaded files from workspace/tmp/ for preview."""
|
||||
try:
|
||||
upload_dir = _get_upload_dir()
|
||||
full_path = os.path.normpath(os.path.join(upload_dir, file_name))
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(upload_dir)):
|
||||
raise web.notfound()
|
||||
if not os.path.isfile(full_path):
|
||||
raise web.notfound()
|
||||
content_type = mimetypes.guess_type(full_path)[0] or "application/octet-stream"
|
||||
web.header('Content-Type', content_type)
|
||||
web.header('Cache-Control', 'public, max-age=86400')
|
||||
with open(full_path, 'rb') as f:
|
||||
return f.read()
|
||||
except web.HTTPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Error serving upload: {e}")
|
||||
raise web.notfound()
|
||||
|
||||
|
||||
class PollHandler:
|
||||
def POST(self):
|
||||
return WebChannel().poll_response()
|
||||
@@ -394,7 +497,7 @@ class ConfigHandler:
|
||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||
const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
|
||||
const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_54, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
|
||||
]
|
||||
|
||||
@@ -448,12 +551,12 @@ class ConfigHandler:
|
||||
"api_base_default": "https://generativelanguage.googleapis.com",
|
||||
"models": [const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||
}),
|
||||
("chatGPT", {
|
||||
("openai", {
|
||||
"label": "OpenAI",
|
||||
"api_key_field": "open_ai_api_key",
|
||||
"api_base_key": "open_ai_api_base",
|
||||
"api_base_default": "https://api.openai.com/v1",
|
||||
"models": [const.GPT_54, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
"models": [const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
}),
|
||||
("deepseek", {
|
||||
"label": "DeepSeek",
|
||||
@@ -522,7 +625,7 @@ class ConfigHandler:
|
||||
"use_agent": use_agent,
|
||||
"title": title,
|
||||
"model": local_config.get("model", ""),
|
||||
"bot_type": local_config.get("bot_type", ""),
|
||||
"bot_type": "openai" if local_config.get("bot_type") == "chatGPT" else local_config.get("bot_type", ""),
|
||||
"use_linkai": bool(local_config.get("use_linkai", False)),
|
||||
"channel_type": local_config.get("channel_type", ""),
|
||||
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", 50000),
|
||||
@@ -611,6 +714,15 @@ class ChannelsHandler:
|
||||
{"key": "wecom_bot_secret", "label": "Secret", "type": "secret"},
|
||||
],
|
||||
}),
|
||||
("qq", {
|
||||
"label": {"zh": "QQ 机器人", "en": "QQ Bot"},
|
||||
"icon": "fa-comment",
|
||||
"color": "blue",
|
||||
"fields": [
|
||||
{"key": "qq_app_id", "label": "App ID", "type": "text"},
|
||||
{"key": "qq_app_secret", "label": "App Secret", "type": "secret"},
|
||||
],
|
||||
}),
|
||||
("wechatcom_app", {
|
||||
"label": {"zh": "企微自建应用", "en": "WeCom App"},
|
||||
"icon": "fa-building",
|
||||
|
||||
@@ -26,6 +26,8 @@ CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"}
|
||||
CREDENTIAL_MAP = {
|
||||
"feishu": ("feishu_app_id", "feishu_app_secret"),
|
||||
"dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"),
|
||||
"wecom_bot": ("wecom_bot_id", "wecom_bot_secret"),
|
||||
"qq": ("qq_app_id", "qq_app_secret"),
|
||||
"wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||
"wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||
"wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"),
|
||||
@@ -254,6 +256,8 @@ class CloudClient(LinkAIClient):
|
||||
app_id, app_secret) -> bool:
|
||||
"""
|
||||
Write app_id / app_secret into the correct config keys for *channel_type*.
|
||||
Also syncs the values to environment variables (upper-cased key) so that
|
||||
skills that rely on env-based checks (e.g. has_env_var) work immediately.
|
||||
Returns True if any value actually changed.
|
||||
"""
|
||||
cred = CREDENTIAL_MAP.get(channel_type)
|
||||
@@ -263,10 +267,14 @@ class CloudClient(LinkAIClient):
|
||||
changed = False
|
||||
if app_id is not None and local_config.get(id_key) != app_id:
|
||||
local_config[id_key] = app_id
|
||||
os.environ[id_key.upper()] = str(app_id)
|
||||
changed = True
|
||||
if app_secret is not None and local_config.get(secret_key) != app_secret:
|
||||
local_config[secret_key] = app_secret
|
||||
os.environ[secret_key.upper()] = str(app_secret)
|
||||
changed = True
|
||||
if changed:
|
||||
logger.info(f"[CloudClient] Synced {channel_type} credentials to conf and env")
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
@@ -277,6 +285,8 @@ class CloudClient(LinkAIClient):
|
||||
id_key, secret_key = cred
|
||||
local_config.pop(id_key, None)
|
||||
local_config.pop(secret_key, None)
|
||||
os.environ.pop(id_key.upper(), None)
|
||||
os.environ.pop(secret_key.upper(), None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# channel_type list helpers
|
||||
@@ -592,6 +602,9 @@ def build_website_prompt(workspace_dir: str) -> list:
|
||||
]
|
||||
|
||||
def start(channel, channel_mgr=None):
|
||||
if not get_deployment_id():
|
||||
return
|
||||
|
||||
global chat_client
|
||||
chat_client = CloudClient(api_key=conf().get("linkai_api_key"), host=conf().get("cloud_host", ""), channel=channel)
|
||||
chat_client.channel_mgr = channel_mgr
|
||||
@@ -661,6 +674,12 @@ def _build_config():
|
||||
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
||||
config["app_id"] = local_conf.get("wechatmp_app_id")
|
||||
config["app_secret"] = local_conf.get("wechatmp_app_secret")
|
||||
elif current_channel_type == "wecom_bot":
|
||||
config["app_id"] = local_conf.get("wecom_bot_id")
|
||||
config["app_secret"] = local_conf.get("wecom_bot_secret")
|
||||
elif current_channel_type == "qq":
|
||||
config["app_id"] = local_conf.get("qq_app_id")
|
||||
config["app_secret"] = local_conf.get("qq_app_secret")
|
||||
elif current_channel_type == "wechatcom_app":
|
||||
config["app_id"] = local_conf.get("wechatcomapp_agent_id")
|
||||
config["app_secret"] = local_conf.get("wechatcomapp_secret")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 厂商类型
|
||||
OPEN_AI = "openAI"
|
||||
CHATGPT = "chatGPT"
|
||||
OPENAI = "openai"
|
||||
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
||||
BAIDU = "baidu"
|
||||
XUNFEI = "xunfei"
|
||||
CHATGPTONAZURE = "chatGPTOnAzure"
|
||||
@@ -68,6 +69,8 @@ GPT_5 = "gpt-5"
|
||||
GPT_5_MINI = "gpt-5-mini"
|
||||
GPT_5_NANO = "gpt-5-nano"
|
||||
GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
||||
GPT_54_MINI = "gpt-5.4-mini"
|
||||
GPT_54_NANO = "gpt-5.4-nano"
|
||||
O1 = "o1-preview"
|
||||
O1_MINI = "o1-mini"
|
||||
WHISPER_1 = "whisper-1"
|
||||
@@ -153,7 +156,7 @@ MODEL_LIST = [
|
||||
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
||||
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
||||
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
||||
GPT_54,
|
||||
GPT_54, GPT_54_MINI, GPT_54_NANO,
|
||||
O1, O1_MINI,
|
||||
|
||||
# DeepSeek
|
||||
@@ -187,3 +190,4 @@ MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
||||
FEISHU = "feishu"
|
||||
DINGTALK = "dingtalk"
|
||||
WECOM_BOT = "wecom_bot"
|
||||
QQ = "qq"
|
||||
|
||||
13
config.py
13
config.py
@@ -20,7 +20,7 @@ available_setting = {
|
||||
"proxy": "", # openai使用的代理
|
||||
# chatgpt模型, 当use_azure_chatgpt为true时,其名称为Azure上model deployment名称
|
||||
"model": "gpt-3.5-turbo", # 可选择: gpt-4o, pt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini等模型,全部可选模型详见common/const.py文件
|
||||
"bot_type": "", # 可选配置,使用兼容openai格式的三方服务时候,需填"chatGPT"。bot具体名称详见common/const.py文件列出的bot_type,如不填根据model名称判断,
|
||||
"bot_type": "", # 可选配置,使用兼容openai格式的三方服务时候,需填"openai"(历史值"chatGPT"仍兼容)。bot具体名称详见common/const.py文件,如不填根据model名称判断
|
||||
"use_azure_chatgpt": False, # 是否使用azure的chatgpt
|
||||
"azure_deployment_id": "", # azure 模型部署名称
|
||||
"azure_api_version": "", # azure api版本
|
||||
@@ -372,6 +372,17 @@ def load_config():
|
||||
"moonshot_api_base": "MOONSHOT_API_BASE",
|
||||
"ark_api_key": "ARK_API_KEY",
|
||||
"ark_api_base": "ARK_API_BASE",
|
||||
# Channel credentials (used by skills that check env vars)
|
||||
"feishu_app_id": "FEISHU_APP_ID",
|
||||
"feishu_app_secret": "FEISHU_APP_SECRET",
|
||||
"dingtalk_client_id": "DINGTALK_CLIENT_ID",
|
||||
"dingtalk_client_secret": "DINGTALK_CLIENT_SECRET",
|
||||
"wechatmp_app_id": "WECHATMP_APP_ID",
|
||||
"wechatmp_app_secret": "WECHATMP_APP_SECRET",
|
||||
"wechatcomapp_agent_id": "WECHATCOMAPP_AGENT_ID",
|
||||
"wechatcomapp_secret": "WECHATCOMAPP_SECRET",
|
||||
"qq_app_id": "QQ_APP_ID",
|
||||
"qq_app_secret": "QQ_APP_SECRET"
|
||||
}
|
||||
injected = 0
|
||||
for conf_key, env_key in _CONFIG_TO_ENV.items():
|
||||
|
||||
@@ -25,11 +25,11 @@ WORKDIR ${BUILD_PREFIX}
|
||||
ADD docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& mkdir -p /home/noroot \
|
||||
&& groupadd -r noroot \
|
||||
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
|
||||
&& chown -R noroot:noroot /home/noroot ${BUILD_PREFIX} /usr/local/lib
|
||||
&& mkdir -p /home/agent/cow \
|
||||
&& groupadd -r agent \
|
||||
&& useradd -r -g agent -s /bin/bash -d /home/agent agent \
|
||||
&& chown -R agent:agent /home/agent ${BUILD_PREFIX} /usr/local/lib
|
||||
|
||||
USER noroot
|
||||
USER agent
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -5,22 +5,39 @@ services:
|
||||
container_name: chatgpt-on-wechat
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
ports:
|
||||
- "9899:9899"
|
||||
environment:
|
||||
CHANNEL_TYPE: 'web'
|
||||
OPEN_AI_API_KEY: 'YOUR API KEY'
|
||||
MODEL: ''
|
||||
PROXY: ''
|
||||
SINGLE_CHAT_PREFIX: '["bot", "@bot"]'
|
||||
SINGLE_CHAT_REPLY_PREFIX: '"[bot] "'
|
||||
GROUP_CHAT_PREFIX: '["@bot"]'
|
||||
GROUP_NAME_WHITE_LIST: '["ChatGPT测试群", "ChatGPT测试群2"]'
|
||||
IMAGE_CREATE_PREFIX: '["画", "看", "找"]'
|
||||
CONVERSATION_MAX_TOKENS: 1000
|
||||
SPEECH_RECOGNITION: 'False'
|
||||
CHARACTER_DESC: '你是基于大语言模型的AI智能助手,旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。'
|
||||
EXPIRES_IN_SECONDS: 3600
|
||||
USE_GLOBAL_PLUGIN_CONFIG: 'True'
|
||||
MODEL: 'MiniMax-M2.5'
|
||||
MINIMAX_API_KEY: ''
|
||||
ZHIPU_AI_API_KEY: ''
|
||||
ARK_API_KEY: ''
|
||||
MOONSHOT_API_KEY: ''
|
||||
DASHSCOPE_API_KEY: ''
|
||||
CLAUDE_API_KEY: ''
|
||||
CLAUDE_API_BASE: 'https://api.anthropic.com/v1'
|
||||
OPEN_AI_API_KEY: ''
|
||||
OPEN_AI_API_BASE: 'https://api.openai.com/v1'
|
||||
GEMINI_API_KEY: ''
|
||||
GEMINI_API_BASE: 'https://generativelanguage.googleapis.com'
|
||||
VOICE_TO_TEXT: 'openai'
|
||||
TEXT_TO_VOICE: 'openai'
|
||||
VOICE_REPLY_VOICE: 'False'
|
||||
SPEECH_RECOGNITION: 'True'
|
||||
GROUP_SPEECH_RECOGNITION: 'False'
|
||||
USE_LINKAI: 'False'
|
||||
AGENT: 'True'
|
||||
LINKAI_API_KEY: ''
|
||||
LINKAI_APP_CODE: ''
|
||||
FEISHU_APP_ID: ''
|
||||
FEISHU_APP_SECRET: ''
|
||||
DINGTALK_CLIENT_ID: ''
|
||||
DINGTALK_CLIENT_SECRET: ''
|
||||
WECOM_BOT_ID: ''
|
||||
WECOM_BOT_SECRET: ''
|
||||
AGENT: 'True'
|
||||
AGENT_MAX_CONTEXT_TOKENS: 40000
|
||||
AGENT_MAX_CONTEXT_TURNS: 20
|
||||
AGENT_MAX_STEPS: 15
|
||||
volumes:
|
||||
- ./cow:/home/agent/cow
|
||||
|
||||
@@ -127,7 +127,7 @@ Agent可根据智能体的名称和描述进行决策,并通过 app_code 调
|
||||
在命令行中执行:
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
详细说明及后续程序管理参考:[项目启动脚本](https://github.com/zhayujie/chatgpt-on-wechat/wiki/CowAgentQuickStart)
|
||||
@@ -179,5 +179,7 @@ Agent支持在多种渠道中使用,只需修改 `config.json` 中的 `channel
|
||||
- **飞书接入**:[飞书接入文档](https://docs.link-ai.tech/cow/multi-platform/feishu)
|
||||
- **钉钉接入**:[钉钉接入文档](https://docs.link-ai.tech/cow/multi-platform/dingtalk)
|
||||
- **企业微信应用接入**:[企微应用文档](https://docs.link-ai.tech/cow/multi-platform/wechat-com)
|
||||
- **企微智能机器人**:[企微智能机器人文档](https://docs.link-ai.tech/cow/multi-platform/wecom-bot)
|
||||
- **QQ机器人**:[QQ机器人文档](https://docs.link-ai.tech/cow/multi-platform/qq)
|
||||
|
||||
更多渠道配置参考:[通道说明](../README.md#通道说明)
|
||||
|
||||
88
docs/channels/qq.mdx
Normal file
88
docs/channels/qq.mdx
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: QQ 机器人
|
||||
description: 将 CowAgent 接入 QQ 机器人(WebSocket 长连接模式)
|
||||
---
|
||||
|
||||
> 通过 QQ 开放平台的机器人接口接入 CowAgent,支持 QQ 单聊、QQ 群聊(@机器人)、频道消息和频道私信,无需公网 IP,使用 WebSocket 长连接模式。
|
||||
|
||||
<Note>
|
||||
QQ 机器人通过 QQ 开放平台创建,使用 WebSocket 长连接接收消息,通过 OpenAPI 发送消息,无需公网 IP 和域名。
|
||||
</Note>
|
||||
|
||||
## 一、创建 QQ 机器人
|
||||
|
||||
> 进入[QQ 开放平台](https://q.qq.com),QQ扫码登录,如果未注册开放平台账号,请先完成[账号注册](https://q.qq.com/#/register)。
|
||||
|
||||
1.在 [QQ开放平台-机器人列表页](https://q.qq.com/#/apps),点击创建机器人:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317162900.png" width="800"/>
|
||||
|
||||
2.填写机器人名称、头像等基本信息,完成创建:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317163005.png" width="800"/>
|
||||
|
||||
3.点击进入机器人配置页面,选择**开发管理**菜单,完成以下步骤:
|
||||
|
||||
- 复制并记录 **AppID**(机器人ID)
|
||||
- 生成并记录 **AppSecret**(机器人秘钥)
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317164955.png" width="800"/>
|
||||
|
||||
## 二、配置和运行
|
||||
|
||||
### 方式一:Web 控制台接入
|
||||
|
||||
启动 Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **QQ 机器人**,填写上一步保存的 AppID 和 AppSecret,点击接入即可。
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317165425.png" width="800"/>
|
||||
|
||||
### 方式二:配置文件接入
|
||||
|
||||
在 `config.json` 中添加以下配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "qq",
|
||||
"qq_app_id": "YOUR_APP_ID",
|
||||
"qq_app_secret": "YOUR_APP_SECRET"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `qq_app_id` | QQ 机器人的 AppID,在开放平台开发管理中获取 |
|
||||
| `qq_app_secret` | QQ 机器人的 AppSecret,在开放平台开发管理中获取 |
|
||||
|
||||
配置完成后启动程序,日志显示 `[QQ] ✅ Connected successfully` 即表示连接成功。
|
||||
|
||||
|
||||
## 三、使用
|
||||
|
||||
在 QQ开放平台 - 管理 - **使用范围和人员** 菜单中,使用QQ客户端扫描 "添加到群和消息列表" 的二维码,即可开始与QQ机器人的聊天:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317165947.png" width="800"/>
|
||||
|
||||
对话效果:
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317171508.png" width="800"/>
|
||||
|
||||
## 四、功能说明
|
||||
|
||||
> 注意:若需在群聊及频道中使用QQ机器人,需完成发布上架审核并在使用范围配置权限使用范围。
|
||||
|
||||
| 功能 | 支持情况 |
|
||||
| --- | --- |
|
||||
| QQ 单聊 | ✅ |
|
||||
| QQ 群聊(@机器人) | ✅ |
|
||||
| 频道消息(@机器人) | ✅ |
|
||||
| 频道私信 | ✅ |
|
||||
| 文本消息 | ✅ 收发 |
|
||||
| 图片消息 | ✅ 收发(群聊和单聊) |
|
||||
| 文件消息 | ✅ 发送(群聊和单聊) |
|
||||
| 定时任务 | ✅ 主动推送(每月每用户限 4 条) |
|
||||
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
- **被动消息限制**:QQ 单聊被动消息有效期为 60 分钟,每条消息最多回复 5 次;QQ 群聊被动消息有效期为 5 分钟。
|
||||
- **主动消息限制**:单聊和群聊每月主动消息上限为 4 条,在使用定时任务功能时需要注意这个限制
|
||||
- **事件权限**:默认订阅 `GROUP_AND_C2C_EVENT`(QQ群/单聊)和 `PUBLIC_GUILD_MESSAGES`(频道公域消息),如需其他事件类型请在开放平台申请权限。
|
||||
@@ -29,7 +29,7 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
|
||||
|
||||
### 方式一:Web 控制台接入
|
||||
|
||||
启动程序后打开 Web 控制台 (本地连接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **企微智能机器人**,填写上一步保存的 Bot ID 和 Secret,点击接入即可。
|
||||
启动Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **企微智能机器人**,填写上一步保存的 Bot ID 和 Secret,点击接入即可。
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260316181711.png" width="800"/>
|
||||
|
||||
|
||||
@@ -88,3 +88,11 @@ description: 将 CowAgent 接入企业微信自建应用
|
||||
如需让外部个人微信用户使用,可在 **我的企业 → 微信插件** 中分享邀请关注二维码,个人微信扫码关注后即可与应用对话:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260228103232.png" width="520"/>
|
||||
|
||||
## 常见问题
|
||||
|
||||
需要确保已安装以下依赖:
|
||||
|
||||
```bash
|
||||
pip install websocket-client pycryptodome
|
||||
```
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"group": "安装部署",
|
||||
"pages": [
|
||||
"guide/quick-start",
|
||||
"guide/manual-install"
|
||||
"guide/manual-install",
|
||||
"guide/upgrade"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -80,7 +81,8 @@
|
||||
"models/gemini",
|
||||
"models/openai",
|
||||
"models/deepseek",
|
||||
"models/linkai"
|
||||
"models/linkai",
|
||||
"models/coding-plan"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -157,6 +159,7 @@
|
||||
"channels/feishu",
|
||||
"channels/dingtalk",
|
||||
"channels/wecom-bot",
|
||||
"channels/qq",
|
||||
"channels/wecom",
|
||||
"channels/wechatmp"
|
||||
]
|
||||
@@ -170,6 +173,7 @@
|
||||
"group": "发布记录",
|
||||
"pages": [
|
||||
"releases/overview",
|
||||
"releases/v2.0.3",
|
||||
"releases/v2.0.2",
|
||||
"releases/v2.0.1",
|
||||
"releases/v2.0.0"
|
||||
@@ -223,7 +227,8 @@
|
||||
"en/models/gemini",
|
||||
"en/models/openai",
|
||||
"en/models/deepseek",
|
||||
"en/models/linkai"
|
||||
"en/models/linkai",
|
||||
"en/models/coding-plan"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -300,6 +305,7 @@
|
||||
"en/channels/feishu",
|
||||
"en/channels/dingtalk",
|
||||
"en/channels/wecom-bot",
|
||||
"en/channels/qq",
|
||||
"en/channels/wecom",
|
||||
"en/channels/wechatmp"
|
||||
]
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
<p align="center">
|
||||
<a href="https://cowagent.ai/">🌐 Website</a> ·
|
||||
<a href="https://docs.cowagent.ai/en/intro/index">📖 Docs</a> ·
|
||||
<a href="https://docs.cowagent.ai/en/guide/quick-start">🚀 Quick Start</a>
|
||||
<a href="https://docs.cowagent.ai/en/guide/quick-start">🚀 Quick Start</a> ·
|
||||
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
@@ -33,6 +34,10 @@
|
||||
2. Agent mode consumes more tokens than normal chat mode. Choose models based on effectiveness and cost. Agent has access to the host OS — please deploy in trusted environments.
|
||||
3. CowAgent focuses on open-source development and does not participate in, authorize, or issue any cryptocurrency.
|
||||
|
||||
## Demo
|
||||
|
||||
Try online (no deployment needed): [CowAgent](https://link-ai.tech/cowagent/create)
|
||||
|
||||
## Changelog
|
||||
|
||||
> **2026.02.27:** [v2.0.2](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/2.0.2) — Web console overhaul (streaming chat, model/skill/memory/channel/scheduler/log management), multi-channel concurrent running, session persistence, new models including Gemini 3.1 Pro / Claude 4.6 Sonnet / Qwen3.5 Plus.
|
||||
@@ -56,7 +61,7 @@ Full changelog: [Release Notes](https://docs.cowagent.ai/en/releases/overview)
|
||||
The project provides a one-click script for installation, configuration, startup, and management:
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
After running, the Web service starts by default. Access `http://localhost:9899/chat` to chat.
|
||||
@@ -102,7 +107,7 @@ nohup python3 app.py & tail -f nohup.out
|
||||
### Docker Deployment
|
||||
|
||||
```bash
|
||||
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
# Edit docker-compose.yml with your config
|
||||
sudo docker compose up -d
|
||||
sudo docker logs -f chatgpt-on-wechat
|
||||
@@ -128,6 +133,28 @@ Supports mainstream model providers. Recommended models for Agent mode:
|
||||
|
||||
For detailed configuration of each model, see the [Models documentation](https://docs.cowagent.ai/en/models/index).
|
||||
|
||||
### Coding Plan
|
||||
|
||||
Coding Plan is a monthly subscription package offered by various providers, ideal for high-frequency Agent usage. All providers can be accessed via OpenAI-compatible mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MODEL_NAME",
|
||||
"open_ai_api_base": "PROVIDER_CODING_PLAN_API_BASE",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
- `bot_type`: Must be `openai`
|
||||
- `model`: Model name supported by the provider
|
||||
- `open_ai_api_base`: Provider's Coding Plan API Base (different from standard pay-as-you-go)
|
||||
- `open_ai_api_key`: Provider's Coding Plan API Key
|
||||
|
||||
> Note: Coding Plan API Base and API Key are usually separate from standard pay-as-you-go ones. Please obtain them from each provider's platform.
|
||||
|
||||
Supported providers include Alibaba Cloud, MiniMax, Zhipu GLM, Kimi, Volcengine, and more. For detailed configuration of each provider, see the [Coding Plan documentation](https://docs.cowagent.ai/en/models/coding-plan).
|
||||
|
||||
<br/>
|
||||
|
||||
## Channels
|
||||
|
||||
88
docs/en/channels/qq.mdx
Normal file
88
docs/en/channels/qq.mdx
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: QQ Bot
|
||||
description: Connect CowAgent to QQ Bot (WebSocket long connection)
|
||||
---
|
||||
|
||||
> Connect CowAgent via QQ Open Platform's bot API, supporting QQ direct messages, group chats (@bot), guild channel messages, and guild DMs. No public IP required — uses WebSocket long connection.
|
||||
|
||||
<Note>
|
||||
QQ Bot is created through the QQ Open Platform. It uses WebSocket long connection to receive messages and OpenAPI to send messages. No public IP or domain is required.
|
||||
</Note>
|
||||
|
||||
## 1. Create a QQ Bot
|
||||
|
||||
> Visit the [QQ Open Platform](https://q.qq.com), sign in with QQ. If you haven't registered, please complete [account registration](https://q.qq.com/#/register) first.
|
||||
|
||||
1.Go to the [QQ Open Platform - Bot List](https://q.qq.com/#/apps), and click **Create Bot**:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317162900.png" width="800"/>
|
||||
|
||||
2.Fill in the bot name, avatar, and other basic information to complete the creation:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317163005.png" width="800"/>
|
||||
|
||||
3.Enter the bot configuration page, go to **Development Management**, and complete the following steps:
|
||||
|
||||
- Copy and save the **AppID** (Bot ID)
|
||||
- Generate and save the **AppSecret** (Bot Secret)
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317164955.png" width="800"/>
|
||||
|
||||
## 2. Configuration and Running
|
||||
|
||||
### Option A: Web Console
|
||||
|
||||
Start the program and open the Web console (local access: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **QQ Bot**, fill in the AppID and AppSecret from the previous step, and click Connect.
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317165425.png" width="800"/>
|
||||
|
||||
### Option B: Config File
|
||||
|
||||
Add the following to your `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_type": "qq",
|
||||
"qq_app_id": "YOUR_APP_ID",
|
||||
"qq_app_secret": "YOUR_APP_SECRET"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `qq_app_id` | AppID of the QQ Bot, found in Development Management on the open platform |
|
||||
| `qq_app_secret` | AppSecret of the QQ Bot, found in Development Management on the open platform |
|
||||
|
||||
After configuration, start the program. The log message `[QQ] ✅ Connected successfully` indicates a successful connection.
|
||||
|
||||
|
||||
## 3. Usage
|
||||
|
||||
In the QQ Open Platform, go to **Management → Usage Scope & Members**, scan the "Add to group and message list" QR code with your QQ client to start chatting with the bot:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317165947.png" width="800"/>
|
||||
|
||||
Chat example:
|
||||
<img src="https://cdn.link-ai.tech/doc/20260317171508.png" width="800"/>
|
||||
|
||||
## 4. Supported Features
|
||||
|
||||
> Note: To use the QQ bot in group chats and guild channels, you need to complete the publishing review and configure usage scope permissions.
|
||||
|
||||
| Feature | Status |
|
||||
| --- | --- |
|
||||
| QQ Direct Messages | ✅ |
|
||||
| QQ Group Chat (@bot) | ✅ |
|
||||
| Guild Channel (@bot) | ✅ |
|
||||
| Guild DM | ✅ |
|
||||
| Text Messages | ✅ Send & Receive |
|
||||
| Image Messages | ✅ Send & Receive (group & direct) |
|
||||
| File Messages | ✅ Send (group & direct) |
|
||||
| Scheduled Tasks | ✅ Active push (4 per user per month) |
|
||||
|
||||
|
||||
## 5. Notes
|
||||
|
||||
- **Passive message limits**: QQ direct message replies are valid for 60 minutes (max 5 replies per message); group chat replies are valid for 5 minutes.
|
||||
- **Active message limits**: Both direct and group chats have a monthly limit of 4 active messages. Keep this in mind when using the scheduled tasks feature.
|
||||
- **Event permissions**: By default, `GROUP_AND_C2C_EVENT` (QQ group/direct) and `PUBLIC_GUILD_MESSAGES` (guild public messages) are subscribed. Apply for additional permissions on the open platform if needed.
|
||||
@@ -88,3 +88,11 @@ Search for the app name you just created in WeCom to start chatting directly. Yo
|
||||
To allow external personal WeChat users to use the app, go to **My Enterprise → WeChat Plugin**, share the invite QR code. After scanning and following, personal WeChat users can join and chat with the app:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260228103232.png" width="520"/>
|
||||
|
||||
## FAQ
|
||||
|
||||
Make sure the following dependencies are installed:
|
||||
|
||||
```bash
|
||||
pip install websocket-client pycryptodome
|
||||
```
|
||||
|
||||
@@ -67,7 +67,7 @@ Docker deployment does not require cloning source code or installing dependencie
|
||||
**1. Download config**
|
||||
|
||||
```bash
|
||||
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
```
|
||||
|
||||
Edit `docker-compose.yml` with your configuration.
|
||||
|
||||
@@ -10,7 +10,7 @@ Supports Linux, macOS, and Windows. Requires Python 3.7-3.12 (3.9 recommended).
|
||||
## Install Command
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
The script automatically performs these steps:
|
||||
|
||||
@@ -41,7 +41,7 @@ CowAgent can proactively think and plan tasks, operate computers and external re
|
||||
Run the following command in your terminal for one-click install, configuration, and startup:
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
By default, the Web service starts after running. Access `http://localhost:9899/chat` to chat in the web interface.
|
||||
|
||||
139
docs/en/models/coding-plan.mdx
Normal file
139
docs/en/models/coding-plan.mdx
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Coding Plan
|
||||
description: Coding Plan model configuration
|
||||
---
|
||||
|
||||
> Coding Plan is a monthly subscription package offered by various providers, ideal for high-frequency Agent usage. CowAgent supports all Coding Plan providers via OpenAI-compatible mode.
|
||||
|
||||
<Note>
|
||||
Coding Plan API Base and API Key are usually separate from the standard pay-as-you-go ones. Please obtain them from each provider's platform.
|
||||
</Note>
|
||||
|
||||
## General Configuration
|
||||
|
||||
All providers can be accessed via the OpenAI-compatible protocol, and can be quickly configured through the web console. Set the model provider to **OpenAI**, select a custom model and enter the model code, then fill in the corresponding provider's API Base and API Key:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260318113134.png" width="800"/>
|
||||
|
||||
You can also configure directly in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MODEL_NAME",
|
||||
"open_ai_api_base": "PROVIDER_CODING_PLAN_API_BASE",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `bot_type` | Must be `openai` (OpenAI-compatible mode) |
|
||||
| `model` | Model name supported by the provider |
|
||||
| `open_ai_api_base` | Provider's Coding Plan API Base URL |
|
||||
| `open_ai_api_key` | Provider's Coding Plan API Key |
|
||||
|
||||
---
|
||||
|
||||
## Alibaba Cloud
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "qwen3.5-plus",
|
||||
"open_ai_api_base": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `qwen3.5-plus`, `qwen3-max-2026-01-23`, `qwen3-coder-next`, `qwen3-coder-plus`, `glm-5`, `glm-4.7`, `kimi-k2.5`, `MiniMax-M2.5` |
|
||||
| `open_ai_api_base` | `https://coding.dashscope.aliyuncs.com/v1` |
|
||||
| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) |
|
||||
|
||||
Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-quickstart?spm=a2c4g.11186623.help-menu-2400256.d_0_2_1.70115203zi5Igc), [Model List](https://help.aliyun.com/zh/model-studio/coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## MiniMax
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2` |
|
||||
| `open_ai_api_base` | China: `https://api.minimaxi.com/v1`; Global: `https://api.minimax.io/v1` |
|
||||
| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) |
|
||||
|
||||
Reference: [China Key](https://platform.minimaxi.com/docs/coding-plan/quickstart), [Model List](https://platform.minimaxi.com/docs/guides/pricing-coding-plan), [Global Key](https://platform.minimax.io/docs/coding-plan/quickstart)
|
||||
|
||||
---
|
||||
|
||||
## Zhipu GLM
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "glm-4.7",
|
||||
"open_ai_api_base": "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `glm-5`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` |
|
||||
| `open_ai_api_base` | China: `https://open.bigmodel.cn/api/coding/paas/v4`; Global: `https://api.z.ai/api/coding/paas/v4` |
|
||||
| `open_ai_api_key` | Shared with standard API |
|
||||
|
||||
Reference: [China Quick Start](https://docs.bigmodel.cn/cn/coding-plan/quick-start), [Global Quick Start](https://docs.z.ai/devpack/quick-start)
|
||||
|
||||
---
|
||||
|
||||
## Kimi
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "kimi-for-coding",
|
||||
"open_ai_api_base": "https://api.kimi.com/coding/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `kimi-for-coding` |
|
||||
| `open_ai_api_base` | `https://api.kimi.com/coding/v1` |
|
||||
| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) |
|
||||
|
||||
Reference: [Key & Docs](https://www.kimi.com/code/docs/)
|
||||
|
||||
---
|
||||
|
||||
## Volcengine
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "Doubao-Seed-2.0-Code",
|
||||
"open_ai_api_base": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `Doubao-Seed-2.0-Code`, `Doubao-Seed-2.0-pro`, `Doubao-Seed-2.0-lite`, `Doubao-Seed-Code`, `MiniMax-M2.5`, `Kimi-K2.5`, `GLM-4.7`, `DeepSeek-V3.2` |
|
||||
| `open_ai_api_base` | `https://ark.cn-beijing.volces.com/api/coding/v3` |
|
||||
| `open_ai_api_key` | Shared with standard API |
|
||||
|
||||
Reference: [Quick Start](https://www.volcengine.com/docs/82379/1928261?lang=zh)
|
||||
@@ -8,7 +8,7 @@ Use OpenAI-compatible configuration:
|
||||
```json
|
||||
{
|
||||
"model": "deepseek-chat",
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"open_ai_api_key": "YOUR_API_KEY",
|
||||
"open_ai_api_base": "https://api.deepseek.com/v1"
|
||||
}
|
||||
@@ -17,6 +17,6 @@ Use OpenAI-compatible configuration:
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `deepseek-chat` (DeepSeek-V3), `deepseek-reasoner` (DeepSeek-R1) |
|
||||
| `bot_type` | Must be `chatGPT` (OpenAI-compatible mode) |
|
||||
| `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 |
|
||||
|
||||
@@ -19,7 +19,7 @@ OpenAI-compatible configuration is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "glm-5",
|
||||
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -11,7 +11,7 @@ CowAgent supports mainstream LLMs from domestic and international providers. Mod
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure the model name and API key in `config.json` according to your chosen model. Each model also supports OpenAI-compatible access by setting `bot_type` to `chatGPT` and configuring `open_ai_api_base` and `open_ai_api_key`.
|
||||
Configure the model name and API key in `config.json` according to your chosen model. Each model also supports OpenAI-compatible access by setting `bot_type` to `openai` and configuring `open_ai_api_base` and `open_ai_api_key`.
|
||||
|
||||
You can also use the [LinkAI](https://link-ai.tech) platform interface to flexibly switch between multiple models with support for knowledge base, workflows, and other Agent capabilities.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ OpenAI-compatible configuration is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "kimi-k2.5",
|
||||
"open_ai_api_base": "https://api.moonshot.cn/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -19,7 +19,7 @@ OpenAI-compatible configuration is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -16,4 +16,4 @@ description: OpenAI model configuration
|
||||
| `model` | Matches the [model parameter](https://platform.openai.com/docs/models) of the OpenAI API. Supports o-series, gpt-5.4, gpt-5 series, gpt-4.1, etc. Recommended for Agent mode: `gpt-5.4` |
|
||||
| `open_ai_api_key` | Create at [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||
| `open_ai_api_base` | Optional. Change to use third-party proxy |
|
||||
| `bot_type` | Not required for official OpenAI models. Set to `chatGPT` when using Claude or other non-OpenAI models via proxy |
|
||||
| `bot_type` | Not required for official OpenAI models. Set to `openai` when using Claude or other non-OpenAI models via proxy |
|
||||
|
||||
@@ -19,7 +19,7 @@ OpenAI-compatible configuration is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "qwen3.5-plus",
|
||||
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -56,9 +56,13 @@ python3 app.py
|
||||
nohup python3 app.py & tail -f nohup.out
|
||||
```
|
||||
|
||||
<Tip>
|
||||
如果在服务器上部署,需要在防火墙或安全组中放行 `9899` 端口才能通过浏览器访问 Web 控制台,建议仅对指定IP开放以保证安全。
|
||||
</Tip>
|
||||
|
||||
## Docker 部署
|
||||
|
||||
使用 Docker 部署无需下载源码和安装依赖。Agent 模式下更推荐使用源码部署以获得更多系统访问能力。
|
||||
使用 Docker 部署无需下载源码和安装依赖。Agent模式下更推荐使用源码部署以获得更多系统访问能力。
|
||||
|
||||
<Note>
|
||||
需要安装 [Docker](https://docs.docker.com/engine/install/) 和 docker-compose。
|
||||
@@ -67,7 +71,7 @@ nohup python3 app.py & tail -f nohup.out
|
||||
**1. 下载配置文件**
|
||||
|
||||
```bash
|
||||
wget https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||
```
|
||||
|
||||
打开 `docker-compose.yml` 填写所需配置。
|
||||
@@ -84,6 +88,10 @@ sudo docker compose up -d
|
||||
sudo docker logs -f chatgpt-on-wechat
|
||||
```
|
||||
|
||||
<Tip>
|
||||
如果在服务器上部署,需要在防火墙或安全组中放行 `9899` 端口才能通过浏览器访问 Web 控制台,建议仅对指定IP开放以保证安全。
|
||||
</Tip>
|
||||
|
||||
## 核心配置项
|
||||
|
||||
```json
|
||||
|
||||
@@ -10,7 +10,7 @@ description: 使用脚本一键安装和管理 CowAgent
|
||||
## 安装命令
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
脚本自动执行以下流程:
|
||||
|
||||
52
docs/guide/upgrade.mdx
Normal file
52
docs/guide/upgrade.mdx
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: 更新升级
|
||||
description: CowAgent 的升级方式说明
|
||||
---
|
||||
|
||||
## 脚本升级(推荐)
|
||||
|
||||
如果使用 `run.sh` 管理服务,执行以下命令即可一键升级:
|
||||
|
||||
```bash
|
||||
./run.sh update
|
||||
```
|
||||
|
||||
该命令会自动完成以下流程:
|
||||
|
||||
1. 停止当前运行的服务
|
||||
2. 拉取最新代码
|
||||
3. 重新检查依赖
|
||||
4. 启动服务
|
||||
|
||||
## 手动升级
|
||||
|
||||
在项目根目录下执行:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
更新完成后重启服务:
|
||||
|
||||
```bash
|
||||
# 如果使用 run.sh 管理
|
||||
./run.sh restart
|
||||
|
||||
# 如果使用 nohup 直接运行
|
||||
kill $(ps -ef | grep app.py | grep -v grep | awk '{print $2}')
|
||||
nohup python3 app.py & tail -f nohup.out
|
||||
```
|
||||
|
||||
## Docker 升级
|
||||
|
||||
在 `docker-compose.yml` 所在目录下执行:
|
||||
|
||||
```bash
|
||||
sudo docker compose pull
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
<Tip>
|
||||
升级前建议备份 `config.json` 配置文件。Docker 环境下如需保留数据,可通过 volume 挂载持久化工作空间目录。
|
||||
</Tip>
|
||||
@@ -3,15 +3,20 @@ title: 项目介绍
|
||||
description: CowAgent - 基于大模型的超级AI助理
|
||||
---
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/78c5dd674e2c828642ecc0406669fed7.png" alt="CowAgent" width="500px"/>
|
||||
<img src="https://cdn.link-ai.tech/doc/78c5dd674e2c828642ecc0406669fed7.png" alt="CowAgent" width="450px"/>
|
||||
|
||||
**CowAgent** 是基于大模型的超级AI助理,能够主动思考和任务规划、操作计算机和外部资源、创造和执行Skills、拥有长期记忆并不断成长。
|
||||
|
||||
CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、文件等多模态消息,可接入网页、飞书、钉钉、企业微信应用、微信公众号中使用,7×24小时运行于你的个人电脑或服务器中。
|
||||
|
||||
<Card title="GitHub" icon="github" href="https://github.com/zhayujie/chatgpt-on-wechat">
|
||||
github.com/zhayujie/chatgpt-on-wechat
|
||||
</Card>
|
||||
<CardGroup cols={2}>
|
||||
<Card title="GitHub" icon="github" href="https://github.com/zhayujie/chatgpt-on-wechat">
|
||||
开源代码仓库,欢迎 Star 和贡献
|
||||
</Card>
|
||||
<Card title="免部署在线体验" icon="cloud" href="https://link-ai.tech/cowagent/create">
|
||||
无需安装,立即在线体验 CowAgent
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 核心能力
|
||||
|
||||
@@ -41,7 +46,7 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
|
||||
在终端执行以下命令,即可一键安装、配置、启动 CowAgent:
|
||||
|
||||
```bash
|
||||
bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||
```
|
||||
|
||||
运行后默认会启动 Web 服务,通过访问 `http://localhost:9899/chat` 在网页端对话。
|
||||
|
||||
140
docs/models/coding-plan.mdx
Normal file
140
docs/models/coding-plan.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Coding Plan
|
||||
description: Coding Plan 模式模型配置
|
||||
---
|
||||
|
||||
> Coding Plan 是各厂商推出的编程包月套餐,适合高频使用 Agent 的场景。CowAgent 支持通过 OpenAI 兼容方式接入各厂商的 Coding Plan 接口。
|
||||
|
||||
<Note>
|
||||
Coding Plan 的 API Base 和 API Key 通常与普通按量计费接口不通用,请在各厂商平台单独获取。
|
||||
</Note>
|
||||
|
||||
## 通用配置格式
|
||||
|
||||
所有厂商均可使用 OpenAI 兼容协议接入,可在web控制台快速配置。设置模型厂商为**OpenAI**,选择自定义模型并填入模型编码,最后填写对应厂商的API Base 和 API Key:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/20260318113134.png" width="800"/>
|
||||
|
||||
也可通过 `config.json` 配置文件直接修改:
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "模型名称",
|
||||
"open_ai_api_base": "厂商 Coding Plan API Base",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `bot_type` | 固定为 `openai`(OpenAI 兼容方式) |
|
||||
| `model` | 各厂商支持的模型名称 |
|
||||
| `open_ai_api_base` | 各厂商 Coding Plan 专用 API Base |
|
||||
| `open_ai_api_key` | 各厂商 Coding Plan 专用 API Key |
|
||||
|
||||
---
|
||||
|
||||
## 阿里云
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "qwen3.5-plus",
|
||||
"open_ai_api_base": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `qwen3.5-plus`、`qwen3-max-2026-01-23`、`qwen3-coder-next`、`qwen3-coder-plus`、`glm-5`、`glm-4.7`、`kimi-k2.5`、`MiniMax-M2.5` |
|
||||
| `open_ai_api_base` | `https://coding.dashscope.aliyuncs.com/v1` |
|
||||
| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) |
|
||||
|
||||
官方文档:[快速开始](https://help.aliyun.com/zh/model-studio/coding-plan-quickstart?spm=a2c4g.11186623.help-menu-2400256.d_0_2_1.70115203zi5Igc)、[模型列表](https://help.aliyun.com/zh/model-studio/coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## MiniMax
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` |
|
||||
| `open_ai_api_base` | 国内:`https://api.minimaxi.com/v1`;海外:`https://api.minimax.io/v1` |
|
||||
| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) |
|
||||
|
||||
官方文档:[国内 Key 获取](https://platform.minimaxi.com/docs/coding-plan/quickstart)、[模型列表](https://platform.minimaxi.com/docs/guides/pricing-coding-plan)、[国际 Key 获取](https://platform.minimax.io/docs/coding-plan/quickstart)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 智谱 GLM
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "glm-4.7",
|
||||
"open_ai_api_base": "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `glm-5`、`glm-4.7`、`glm-4.6`、`glm-4.5`、`glm-4.5-air` |
|
||||
| `open_ai_api_base` | 中国区:`https://open.bigmodel.cn/api/coding/paas/v4`;全球区:`https://api.z.ai/api/coding/paas/v4` |
|
||||
| `open_ai_api_key` | API Key 与普通接口通用 |
|
||||
|
||||
官方文档:[国内版快速开始](https://docs.bigmodel.cn/cn/coding-plan/quick-start)、[国际版快速开始](https://docs.z.ai/devpack/quick-start)
|
||||
|
||||
---
|
||||
|
||||
## Kimi
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "kimi-for-coding",
|
||||
"open_ai_api_base": "https://api.kimi.com/coding/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `kimi-for-coding` |
|
||||
| `open_ai_api_base` | `https://api.kimi.com/coding/v1` |
|
||||
| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) |
|
||||
|
||||
官方文档:[Key 获取](https://www.kimi.com/code/docs/)
|
||||
|
||||
---
|
||||
|
||||
## 火山引擎
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "Doubao-Seed-2.0-Code",
|
||||
"open_ai_api_base": "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `Doubao-Seed-2.0-Code`、`Doubao-Seed-2.0-pro`、`Doubao-Seed-2.0-lite`、`Doubao-Seed-Code`、`MiniMax-M2.5`、`Kimi-K2.5`、`GLM-4.7`、`DeepSeek-V3.2` |
|
||||
| `open_ai_api_base` | `https://ark.cn-beijing.volces.com/api/coding/v3` |
|
||||
| `open_ai_api_key` | API Key 与普通接口通用 |
|
||||
|
||||
官方文档:[快速开始](https://www.volcengine.com/docs/82379/1928261?lang=zh)
|
||||
@@ -10,13 +10,13 @@ description: DeepSeek 模型配置
|
||||
"model": "deepseek-chat",
|
||||
"open_ai_api_key": "YOUR_API_KEY",
|
||||
"open_ai_api_base": "https://api.deepseek.com/v1",
|
||||
"bot_type": "chatGPT"
|
||||
"bot_type": "openai"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `deepseek-chat`(DeepSeek-V3)、`deepseek-reasoner`(DeepSeek-R1) |
|
||||
| `bot_type` | 固定为 `chatGPT`(OpenAI 兼容方式) |
|
||||
| `bot_type` | 固定为 `openai`(OpenAI 兼容方式) |
|
||||
| `open_ai_api_key` | 在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 创建 |
|
||||
| `open_ai_api_base` | DeepSeek 平台 BASE URL |
|
||||
|
||||
@@ -19,7 +19,7 @@ description: 智谱AI GLM 模型配置
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "glm-5",
|
||||
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -11,9 +11,13 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
||||
|
||||
## 配置方式
|
||||
|
||||
根据所选模型,在 `config.json` 中填写对应的模型名称和 API Key 即可。每个模型也支持 OpenAI 兼容方式接入,将 `bot_type` 设为 `chatGPT`,配置 `open_ai_api_base` 和 `open_ai_api_key`。
|
||||
根据所选模型,在 `config.json` 中填写对应的模型名称和 API Key 即可。每个模型也支持 OpenAI 兼容方式接入,将 `bot_type` 设为 `openai`,配置 `open_ai_api_base` 和 `open_ai_api_key`。
|
||||
|
||||
同时支持使用 [LinkAI](https://link-ai.tech) 平台接口,可灵活切换多种模型并支持知识库、工作流等 Agent 能力。
|
||||
同时支持使用 [LinkAI](https://link-ai.tech) 平台接口,可灵活切换多种模型,并支持知识库、工作流、插件等 Agent 能力。
|
||||
|
||||
也可以通过 [Web 控制台](/channels/web) 在线管理模型配置,无需手动编辑配置文件:
|
||||
|
||||
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173811.png" />
|
||||
|
||||
## 支持的模型
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ description: Kimi (Moonshot) 模型配置
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "kimi-k2.5",
|
||||
"open_ai_api_base": "https://api.moonshot.cn/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -19,7 +19,7 @@ description: MiniMax 模型配置
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -13,7 +13,7 @@ description: OpenAI 模型配置
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 o 系列、gpt-5.4、gpt-5 系列、gpt-4.1 等,Agent 模式推荐使用 `gpt-5.4` |
|
||||
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 o 系列、gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、gpt-5 系列、gpt-4.1 等,Agent 模式推荐使用 `gpt-5.4` |
|
||||
| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
||||
| `open_ai_api_base` | 可选,修改可接入第三方代理接口 |
|
||||
| `bot_type` | 使用 OpenAI 官方模型时无需填写。当通过代理接口使用 Claude 等非 OpenAI 模型时,设为 `chatGPT` |
|
||||
| `bot_type` | 使用 OpenAI 官方模型时无需填写。当通过代理接口使用 Claude 等非 OpenAI 模型时,设为 `openai` |
|
||||
|
||||
@@ -19,7 +19,7 @@ description: 通义千问模型配置
|
||||
|
||||
```json
|
||||
{
|
||||
"bot_type": "chatGPT",
|
||||
"bot_type": "openai",
|
||||
"model": "qwen3.5-plus",
|
||||
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
|
||||
@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| [2.0.3](/releases/v2.0.3) | 2026.03.18 | 新增企微智能机器人和 QQ 通道、支持Coding Plan、新增多个模型、Web端文件处理、记忆系统升级 |
|
||||
| [2.0.2](/releases/v2.0.2) | 2026.02.27 | Web 控制台升级、多通道同时运行、会话持久化 |
|
||||
| [2.0.1](/releases/v2.0.1) | 2026.02.13 | 内置 Web Search 工具、智能上下文管理、多项修复 |
|
||||
| [2.0.0](/releases/v2.0.0) | 2026.02.03 | 全面升级为超级 Agent 助理 |
|
||||
|
||||
91
docs/releases/v2.0.3.mdx
Normal file
91
docs/releases/v2.0.3.mdx
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: v2.0.3
|
||||
description: CowAgent 2.0.3 - 新增企微智能机器人和 QQ 通道、Web 控制台文件处理、记忆系统升级
|
||||
---
|
||||
|
||||
## 🔌 新增接入通道
|
||||
|
||||
### 企业微信智能机器人
|
||||
|
||||
新增企业微信智能机器人(`wecom_bot`)通道,支持流式卡片消息输出,支持文本和图片消息的接收与回复,可在 Web 控制台中进行通道配置和管理。
|
||||
|
||||
接入文档:[企微智能机器人接入](https://docs.cowagent.ai/channels/wecom-bot)。
|
||||
|
||||
相关提交:[d4480b6](https://github.com/zhayujie/chatgpt-on-wechat/commit/d4480b6), [a42f31f](https://github.com/zhayujie/chatgpt-on-wechat/commit/a42f31f), [4ecd4df](https://github.com/zhayujie/chatgpt-on-wechat/commit/4ecd4df), [8b45d6c](https://github.com/zhayujie/chatgpt-on-wechat/commit/8b45d6c)
|
||||
|
||||
### QQ 通道
|
||||
|
||||
新增 QQ 官方机器人(`qq`)通道,支持文本和图片消息的接收与回复,支持私聊和群聊场景。
|
||||
|
||||
接入文档参考:[QQ机器人接入](https://docs.cowagent.ai/channels/qq)。
|
||||
|
||||
相关提交:[005a0e1](https://github.com/zhayujie/chatgpt-on-wechat/commit/005a0e1), [a4d54f5](https://github.com/zhayujie/chatgpt-on-wechat/commit/a4d54f5)
|
||||
|
||||
## 🖥️ Web 控制台支持文件输入和处理
|
||||
|
||||
Web 控制台对话界面支持文件和图片上传,可直接发送文件给 Agent 进行处理。同时 Read 工具新增对 Office 文档(Word、Excel、PPT)的解析能力。
|
||||
|
||||
相关提交:[30c6d9b](https://github.com/zhayujie/chatgpt-on-wechat/commit/30c6d9b)
|
||||
|
||||
## 🤖 新增模型
|
||||
|
||||
- **GPT-5.4 系列**:新增 `gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano` 模型支持 ([1623deb](https://github.com/zhayujie/chatgpt-on-wechat/commit/1623deb))
|
||||
- **Gemini 3.1 Flash Lite Preview**:新增 `gemini-3.1-flash-lite-preview` 模型支持 ([ba915f2](https://github.com/zhayujie/chatgpt-on-wechat/commit/ba915f2))
|
||||
|
||||
## 💰 Coding Plan 支持
|
||||
|
||||
新增各厂商 Coding Plan(编程包月套餐)的接入支持,通过 OpenAI 兼容方式统一接入。目前已支持阿里云、MiniMax、智谱 GLM、Kimi、火山引擎等厂商。
|
||||
|
||||
详细配置参考 [Coding Plan 文档](https://docs.cowagent.ai/models/coding-plan)。
|
||||
|
||||
## 🧠 记忆系统升级
|
||||
|
||||
记忆写入(Memory Flush)升级:
|
||||
|
||||
- 使用 LLM 对超出上下文窗口的对话内容进行智能摘要,生成精炼的每日记忆条目
|
||||
- 摘要在后台线程异步执行,不阻塞回复
|
||||
- 优化上下文批量裁剪策略,降低冲刷频率
|
||||
- 新增每日定时冲刷兜底机制,避免低活跃场景下记忆丢失
|
||||
- 修复上下文记忆丢失问题
|
||||
|
||||
相关提交:[022c13f](https://github.com/zhayujie/chatgpt-on-wechat/commit/022c13f), [c116235](https://github.com/zhayujie/chatgpt-on-wechat/commit/c116235)
|
||||
|
||||
## 🔧 工具重构
|
||||
|
||||
- **图片识别**:将图片识别(Image Vision)从 Skill 重构为内置 Tool,新增独立的图片视觉提供方(Vision Provider)配置,提升稳定性和可维护性 ([a50fafa](https://github.com/zhayujie/chatgpt-on-wechat/commit/a50fafa), [3b8b562](https://github.com/zhayujie/chatgpt-on-wechat/commit/3b8b562))
|
||||
- **网页抓取**:将网页抓取(Web Fetch)从 Skill 重构为内置 Tool,支持远程文档文件(PDF、Word、Excel、PPT)的下载和解析 ([ccb9030](https://github.com/zhayujie/chatgpt-on-wechat/commit/ccb9030), [fa61744](https://github.com/zhayujie/chatgpt-on-wechat/commit/fa61744))
|
||||
|
||||
## 🐳 Docker 部署优化
|
||||
|
||||
- **配置模板对齐**:`docker-compose.yml` 环境变量与 `config-template.json` 对齐,补充完整的模型 API Key 和 Agent 等配置项
|
||||
- **Web 控制台端口映射**:新增 `9899` 端口映射,Docker 部署后可通过浏览器访问 Web 控制台
|
||||
- **配置热更新**:各模型 Bot 的 API Key 和 API Base 改为实时读取,通过 Web 控制台修改配置后无需重启即可生效
|
||||
- **工作空间持久化**:新增 `./cow` Volume 挂载,Agent 工作空间数据(记忆、人格、技能等)持久化到宿主机,容器重建或升级不丢失
|
||||
|
||||
## ⚡ 性能优化
|
||||
|
||||
- **启动加速**:飞书通道采用懒加载方式导入依赖,避免 4-10 秒的启动延迟 ([924dc79](https://github.com/zhayujie/chatgpt-on-wechat/commit/924dc79))
|
||||
- **通道稳定性**:优化通道连接稳定性,支持通道配置通过环境变量设置 ([f1c04bc](https://github.com/zhayujie/chatgpt-on-wechat/commit/f1c04bc), [46d97fd](https://github.com/zhayujie/chatgpt-on-wechat/commit/46d97fd))
|
||||
|
||||
## 🐛 问题修复
|
||||
|
||||
- **bot_type 配置**:修复 Agent 模式下 `bot_type` 配置传递问题 ([#2691](https://github.com/zhayujie/chatgpt-on-wechat/pull/2691)) Thanks [@Weikjssss](https://github.com/Weikjssss)
|
||||
- **bot_type 优先级**:调整 Agent 模式下 `bot_type` 的解析优先级 ([#2692](https://github.com/zhayujie/chatgpt-on-wechat/pull/2692)) Thanks [@6vision](https://github.com/6vision)
|
||||
- **智谱模型配置**:修复智谱 `bot_type` 命名、Web 控制台持久化及正则转义问题 ([#2693](https://github.com/zhayujie/chatgpt-on-wechat/pull/2693)) Thanks [@6vision](https://github.com/6vision)
|
||||
- **OpenAI 兼容层**:使用 `openai_compat` 层统一错误处理 ([#2688](https://github.com/zhayujie/chatgpt-on-wechat/pull/2688)) Thanks [@JasonOA888](https://github.com/JasonOA888)
|
||||
- **OpenAI 兼容迁移**:完成所有模型 Bot 的 `openai_compat` 迁移 ([#2689](https://github.com/zhayujie/chatgpt-on-wechat/pull/2689))
|
||||
- **Gemini 工具调用**:修复 Gemini 模型的工具调用匹配问题 ([eda82ba](https://github.com/zhayujie/chatgpt-on-wechat/commit/eda82ba))
|
||||
- **会话并发**:修复会话并发场景下的竞态条件问题 ([9879878](https://github.com/zhayujie/chatgpt-on-wechat/commit/9879878))
|
||||
- **历史消息恢复**:修复历史会话消息不完整问题,仅恢复 user/assistant 文本消息,剥离工具调用 ([b788a3d](https://github.com/zhayujie/chatgpt-on-wechat/commit/b788a3d), [a33ce97](https://github.com/zhayujie/chatgpt-on-wechat/commit/a33ce97))
|
||||
- **飞书群聊**:移除飞书群聊场景下对 `bot_name` 的依赖 ([b641bff](https://github.com/zhayujie/chatgpt-on-wechat/commit/b641bff))
|
||||
- **Safari 兼容**:修复 Safari 浏览器 IME 回车键误触发消息发送问题 ([0687916](https://github.com/zhayujie/chatgpt-on-wechat/commit/0687916))
|
||||
- **Windows 兼容**:修复 Windows 下 bash 风格 `$VAR` 环境变量转换为 `%VAR%` 的问题 ([7c67513](https://github.com/zhayujie/chatgpt-on-wechat/commit/7c67513))
|
||||
- **MiniMax 参数**:增加 MiniMax 模型的 `max_tokens` 限制 ([1767413](https://github.com/zhayujie/chatgpt-on-wechat/commit/1767413))
|
||||
- **.gitignore 更新**:添加 Python 目录忽略规则 ([#2683](https://github.com/zhayujie/chatgpt-on-wechat/pull/2683)) Thanks [@pelioo](https://github.com/pelioo)
|
||||
- **AGENT.md 主动演进**:优化系统提示词中对 AGENT.md 的更新引导,从被动的"用户修改时更新"改为主动识别对话中的性格、风格变化并自动更新
|
||||
|
||||
## 📦 升级方式
|
||||
|
||||
源码部署可执行 `./run.sh update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/guide/upgrade)。
|
||||
|
||||
**发布日期**:2026.03.18 | [Full Changelog](https://github.com/zhayujie/chatgpt-on-wechat/compare/2.0.2...master)
|
||||
@@ -17,7 +17,7 @@ def create_bot(bot_type):
|
||||
from models.baidu.baidu_wenxin import BaiduWenxinBot
|
||||
return BaiduWenxinBot()
|
||||
|
||||
elif bot_type in (const.CHATGPT, const.DEEPSEEK): # DeepSeek uses OpenAI-compatible API
|
||||
elif bot_type in (const.OPENAI, const.CHATGPT, const.DEEPSEEK): # OpenAI-compatible API
|
||||
from models.chatgpt.chat_gpt_bot import ChatGPTBot
|
||||
return ChatGPTBot()
|
||||
|
||||
|
||||
@@ -30,11 +30,20 @@ user_session = dict()
|
||||
class ClaudeAPIBot(Bot, OpenAIImage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.api_key = conf().get("claude_api_key")
|
||||
self.api_base = conf().get("claude_api_base") or "https://api.anthropic.com/v1"
|
||||
self.proxy = conf().get("proxy", None)
|
||||
self.sessions = SessionManager(BaiduWenxinSession, model=conf().get("model") or "text-davinci-003")
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return conf().get("claude_api_key")
|
||||
|
||||
@property
|
||||
def api_base(self):
|
||||
return conf().get("claude_api_base") or "https://api.anthropic.com/v1"
|
||||
|
||||
@property
|
||||
def proxy(self):
|
||||
return conf().get("proxy", None)
|
||||
|
||||
def reply(self, query, context=None):
|
||||
# acquire reply content
|
||||
if context and context.type:
|
||||
|
||||
@@ -35,10 +35,14 @@ class DashscopeBot(Bot):
|
||||
super().__init__()
|
||||
self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen-plus")
|
||||
self.model_name = conf().get("model") or "qwen-plus"
|
||||
self.api_key = conf().get("dashscope_api_key")
|
||||
if self.api_key:
|
||||
os.environ["DASHSCOPE_API_KEY"] = self.api_key
|
||||
self.client = dashscope.Generation
|
||||
api_key = conf().get("dashscope_api_key")
|
||||
if api_key:
|
||||
os.environ["DASHSCOPE_API_KEY"] = api_key
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return conf().get("dashscope_api_key")
|
||||
|
||||
@staticmethod
|
||||
def _is_multimodal_model(model_name: str) -> bool:
|
||||
|
||||
@@ -24,13 +24,17 @@ class DoubaoBot(Bot):
|
||||
"temperature": conf().get("temperature", 0.8),
|
||||
"top_p": conf().get("top_p", 1.0),
|
||||
}
|
||||
self.api_key = conf().get("ark_api_key")
|
||||
self.base_url = conf().get("ark_base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
# Ensure base_url does not end with /chat/completions
|
||||
if self.base_url.endswith("/chat/completions"):
|
||||
self.base_url = self.base_url.rsplit("/chat/completions", 1)[0]
|
||||
if self.base_url.endswith("/"):
|
||||
self.base_url = self.base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return conf().get("ark_api_key")
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
url = conf().get("ark_base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
if url.endswith("/chat/completions"):
|
||||
url = url.rsplit("/chat/completions", 1)[0]
|
||||
return url.rstrip("/")
|
||||
|
||||
def reply(self, query, context=None):
|
||||
# acquire reply content
|
||||
|
||||
@@ -28,21 +28,18 @@ class GoogleGeminiBot(Bot):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.api_key = conf().get("gemini_api_key")
|
||||
# 复用chatGPT的token计算方式
|
||||
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
|
||||
self.model = conf().get("model") or "gemini-pro"
|
||||
if self.model == "gemini":
|
||||
self.model = "gemini-pro"
|
||||
|
||||
# 支持自定义API base地址
|
||||
self.api_base = conf().get("gemini_api_base", "").strip()
|
||||
if self.api_base:
|
||||
# 移除末尾的斜杠
|
||||
self.api_base = self.api_base.rstrip('/')
|
||||
logger.info(f"[Gemini] Using custom API base: {self.api_base}")
|
||||
else:
|
||||
self.api_base = "https://generativelanguage.googleapis.com"
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return conf().get("gemini_api_key")
|
||||
|
||||
@property
|
||||
def api_base(self):
|
||||
base = conf().get("gemini_api_base", "").strip()
|
||||
if base:
|
||||
return base.rstrip('/')
|
||||
return "https://generativelanguage.googleapis.com"
|
||||
|
||||
def reply(self, query, context: Context = None) -> Reply:
|
||||
session_id = None
|
||||
|
||||
@@ -24,21 +24,19 @@ class MinimaxBot(Bot):
|
||||
"temperature": conf().get("temperature", 0.3),
|
||||
"top_p": conf().get("top_p", 0.95),
|
||||
}
|
||||
# Use unified key name: minimax_api_key
|
||||
self.api_key = conf().get("minimax_api_key")
|
||||
if not self.api_key:
|
||||
# Fallback to old key name for backward compatibility
|
||||
self.api_key = conf().get("Minimax_api_key")
|
||||
if self.api_key:
|
||||
logger.warning("[MINIMAX] 'Minimax_api_key' is deprecated, please use 'minimax_api_key' instead")
|
||||
|
||||
# REST API endpoint
|
||||
# Use Chinese endpoint by default, users can override in config
|
||||
# International users should set: "minimax_api_base": "https://api.minimax.io/v1"
|
||||
self.api_base = conf().get("minimax_api_base", "https://api.minimaxi.com/v1")
|
||||
|
||||
self.sessions = SessionManager(MinimaxSession, model=const.MiniMax)
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
key = conf().get("minimax_api_key")
|
||||
if not key:
|
||||
key = conf().get("Minimax_api_key")
|
||||
return key
|
||||
|
||||
@property
|
||||
def api_base(self):
|
||||
return conf().get("minimax_api_base", "https://api.minimaxi.com/v1")
|
||||
|
||||
def reply(self, query, context: Context = None) -> Reply:
|
||||
# acquire reply content
|
||||
logger.info("[MINIMAX] query={}".format(query))
|
||||
|
||||
@@ -26,8 +26,14 @@ class ModelScopeBot(Bot):
|
||||
"temperature": conf().get("temperature", 0.3), # 如果设置,值域须为 [0, 1] 我们推荐 0.3,以达到较合适的效果。
|
||||
"top_p": conf().get("top_p", 1.0), # 使用默认值
|
||||
}
|
||||
self.api_key = conf().get("modelscope_api_key")
|
||||
self.base_url = conf().get("modelscope_base_url", "https://api-inference.modelscope.cn/v1/chat/completions")
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return conf().get("modelscope_api_key")
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
return conf().get("modelscope_base_url", "https://api-inference.modelscope.cn/v1/chat/completions")
|
||||
"""
|
||||
需要获取ModelScope支持API-inference的模型名称列表,请到魔搭社区官网模型中心查看 https://modelscope.cn/models?filter=inference_type&page=1。
|
||||
或者使用命令 curl https://api-inference.modelscope.cn/v1/models 对模型列表和ID进行获取。查看commend/const.py文件也可以获取模型列表。
|
||||
|
||||
@@ -26,13 +26,17 @@ class MoonshotBot(Bot):
|
||||
"temperature": conf().get("temperature", 0.3),
|
||||
"top_p": conf().get("top_p", 1.0),
|
||||
}
|
||||
self.api_key = conf().get("moonshot_api_key")
|
||||
self.base_url = conf().get("moonshot_base_url", "https://api.moonshot.cn/v1")
|
||||
# Ensure base_url does not end with /chat/completions (backward compat)
|
||||
if self.base_url.endswith("/chat/completions"):
|
||||
self.base_url = self.base_url.rsplit("/chat/completions", 1)[0]
|
||||
if self.base_url.endswith("/"):
|
||||
self.base_url = self.base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def api_key(self):
|
||||
return conf().get("moonshot_api_key")
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
url = conf().get("moonshot_base_url", "https://api.moonshot.cn/v1")
|
||||
if url.endswith("/chat/completions"):
|
||||
url = url.rsplit("/chat/completions", 1)[0]
|
||||
return url.rstrip("/")
|
||||
|
||||
def reply(self, query, context=None):
|
||||
# acquire reply content
|
||||
|
||||
@@ -64,7 +64,7 @@ class Dungeon(Plugin):
|
||||
if e_context["context"].type != ContextType.TEXT:
|
||||
return
|
||||
bottype = Bridge().get_bot_type("chat")
|
||||
if bottype not in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI]:
|
||||
if bottype not in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI]:
|
||||
return
|
||||
bot = Bridge().get_bot("chat")
|
||||
content = e_context["context"].content[:]
|
||||
|
||||
@@ -315,7 +315,7 @@ class Godcmd(Plugin):
|
||||
except Exception as e:
|
||||
ok, result = False, "你没有设置私有GPT模型"
|
||||
elif cmd == "reset":
|
||||
if bottype in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI, const.CLAUDEAPI]:
|
||||
if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI, const.CLAUDEAPI]:
|
||||
bot.sessions.clear_session(session_id)
|
||||
if Bridge().chat_bots.get(bottype):
|
||||
Bridge().chat_bots.get(bottype).sessions.clear_session(session_id)
|
||||
@@ -340,7 +340,7 @@ class Godcmd(Plugin):
|
||||
load_config()
|
||||
ok, result = True, "配置已重载"
|
||||
elif cmd == "resetall":
|
||||
if bottype in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI,
|
||||
if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI,
|
||||
const.BAIDU, const.XUNFEI, const.QWEN, const.GEMINI, const.ZHIPU_AI, const.MOONSHOT,
|
||||
const.MODELSCOPE]:
|
||||
channel.cancel_all_session()
|
||||
|
||||
@@ -99,7 +99,7 @@ class Role(Plugin):
|
||||
if e_context["context"].type != ContextType.TEXT:
|
||||
return
|
||||
btype = Bridge().get_bot_type("chat")
|
||||
if btype not in [const.OPEN_AI, const.CHATGPT, const.CHATGPTONAZURE, const.QWEN_DASHSCOPE, const.XUNFEI, const.BAIDU, const.ZHIPU_AI, const.MOONSHOT, const.MiniMax, const.LINKAI,const.MODELSCOPE]:
|
||||
if btype not in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.QWEN_DASHSCOPE, const.XUNFEI, const.BAIDU, const.ZHIPU_AI, const.MOONSHOT, const.MiniMax, const.LINKAI, const.MODELSCOPE]:
|
||||
logger.debug(f'不支持的bot: {btype}')
|
||||
return
|
||||
bot = Bridge().get_bot("chat")
|
||||
|
||||
@@ -52,6 +52,7 @@ class Tool(Plugin):
|
||||
|
||||
# 暂时不支持未来扩展的bot
|
||||
if Bridge().get_bot_type("chat") not in (
|
||||
const.OPENAI,
|
||||
const.CHATGPT,
|
||||
const.OPEN_AI,
|
||||
const.CHATGPTONAZURE,
|
||||
|
||||
106
run.sh
106
run.sh
@@ -409,19 +409,21 @@ select_channel() {
|
||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||
echo -e "${YELLOW}1) Feishu (飞书)${NC}"
|
||||
echo -e "${YELLOW}2) DingTalk (钉钉)${NC}"
|
||||
echo -e "${YELLOW}3) WeCom (企微应用)${NC}"
|
||||
echo -e "${YELLOW}4) Web (网页)${NC}"
|
||||
echo -e "${YELLOW}3) WeCom Bot (企微智能机器人)${NC}"
|
||||
echo -e "${YELLOW}4) QQ (QQ 机器人)${NC}"
|
||||
echo -e "${YELLOW}5) WeCom App (企微自建应用)${NC}"
|
||||
echo -e "${YELLOW}6) Web (网页)${NC}"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
read -p "Enter your choice [press Enter for default: 1 - Feishu]: " channel_choice
|
||||
channel_choice=${channel_choice:-1}
|
||||
case "$channel_choice" in
|
||||
1|2|3|4)
|
||||
1|2|3|4|5|6)
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice. Please enter 1-4.${NC}"
|
||||
echo -e "${RED}Invalid choice. Please enter 1-6.${NC}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -456,9 +458,31 @@ configure_channel() {
|
||||
ACCESS_INFO="DingTalk channel configured"
|
||||
;;
|
||||
3)
|
||||
# WeCom
|
||||
# WeCom Bot
|
||||
CHANNEL_TYPE="wecom_bot"
|
||||
echo -e "${GREEN}Configure WeCom Bot...${NC}"
|
||||
read -p "Enter WeCom Bot ID: " wecom_bot_id
|
||||
read -p "Enter WeCom Bot Secret: " wecom_bot_secret
|
||||
|
||||
WECOM_BOT_ID="$wecom_bot_id"
|
||||
WECOM_BOT_SECRET="$wecom_bot_secret"
|
||||
ACCESS_INFO="WeCom Bot channel configured"
|
||||
;;
|
||||
4)
|
||||
# QQ
|
||||
CHANNEL_TYPE="qq"
|
||||
echo -e "${GREEN}Configure QQ Bot...${NC}"
|
||||
read -p "Enter QQ App ID: " qq_app_id
|
||||
read -p "Enter QQ App Secret: " qq_app_secret
|
||||
|
||||
QQ_APP_ID="$qq_app_id"
|
||||
QQ_APP_SECRET="$qq_app_secret"
|
||||
ACCESS_INFO="QQ Bot channel configured"
|
||||
;;
|
||||
5)
|
||||
# WeCom App
|
||||
CHANNEL_TYPE="wechatcom_app"
|
||||
echo -e "${GREEN}Configure WeCom...${NC}"
|
||||
echo -e "${GREEN}Configure WeCom App...${NC}"
|
||||
read -p "Enter WeChat Corp ID: " corp_id
|
||||
read -p "Enter WeChat Com App Token: " com_token
|
||||
read -p "Enter WeChat Com App Secret: " com_secret
|
||||
@@ -473,9 +497,9 @@ configure_channel() {
|
||||
WECHATCOM_AGENT_ID="$com_agent_id"
|
||||
WECHATCOM_AES_KEY="$com_aes_key"
|
||||
WECHATCOM_PORT="$com_port"
|
||||
ACCESS_INFO="WeCom channel configured on port ${com_port}"
|
||||
ACCESS_INFO="WeCom App channel configured on port ${com_port}"
|
||||
;;
|
||||
4)
|
||||
6)
|
||||
# Web
|
||||
CHANNEL_TYPE="web"
|
||||
read -p "Enter web port [press Enter for default: 9899]: " web_port
|
||||
@@ -600,6 +624,72 @@ EOF
|
||||
"agent_max_context_turns": 30,
|
||||
"agent_max_steps": 15
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
wecom_bot)
|
||||
cat > config.json <<EOF
|
||||
{
|
||||
"channel_type": "wecom_bot",
|
||||
"wecom_bot_id": "${WECOM_BOT_ID}",
|
||||
"wecom_bot_secret": "${WECOM_BOT_SECRET}",
|
||||
"model": "${MODEL_NAME}",
|
||||
"open_ai_api_key": "${OPENAI_KEY:-}",
|
||||
"open_ai_api_base": "${OPENAI_BASE:-https://api.openai.com/v1}",
|
||||
"claude_api_key": "${CLAUDE_KEY:-}",
|
||||
"claude_api_base": "${CLAUDE_BASE:-https://api.anthropic.com/v1}",
|
||||
"gemini_api_key": "${GEMINI_KEY:-}",
|
||||
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
||||
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
||||
"moonshot_api_key": "${MOONSHOT_KEY:-}",
|
||||
"ark_api_key": "${ARK_KEY:-}",
|
||||
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
||||
"minimax_api_key": "${MINIMAX_KEY:-}",
|
||||
"voice_to_text": "openai",
|
||||
"text_to_voice": "openai",
|
||||
"voice_reply_voice": false,
|
||||
"speech_recognition": true,
|
||||
"group_speech_recognition": false,
|
||||
"use_linkai": ${USE_LINKAI:-false},
|
||||
"linkai_api_key": "${LINKAI_KEY:-}",
|
||||
"linkai_app_code": "",
|
||||
"agent": true,
|
||||
"agent_max_context_tokens": 40000,
|
||||
"agent_max_context_turns": 30,
|
||||
"agent_max_steps": 15
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
qq)
|
||||
cat > config.json <<EOF
|
||||
{
|
||||
"channel_type": "qq",
|
||||
"qq_app_id": "${QQ_APP_ID}",
|
||||
"qq_app_secret": "${QQ_APP_SECRET}",
|
||||
"model": "${MODEL_NAME}",
|
||||
"open_ai_api_key": "${OPENAI_KEY:-}",
|
||||
"open_ai_api_base": "${OPENAI_BASE:-https://api.openai.com/v1}",
|
||||
"claude_api_key": "${CLAUDE_KEY:-}",
|
||||
"claude_api_base": "${CLAUDE_BASE:-https://api.anthropic.com/v1}",
|
||||
"gemini_api_key": "${GEMINI_KEY:-}",
|
||||
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
||||
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
||||
"moonshot_api_key": "${MOONSHOT_KEY:-}",
|
||||
"ark_api_key": "${ARK_KEY:-}",
|
||||
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
||||
"minimax_api_key": "${MINIMAX_KEY:-}",
|
||||
"voice_to_text": "openai",
|
||||
"text_to_voice": "openai",
|
||||
"voice_reply_voice": false,
|
||||
"speech_recognition": true,
|
||||
"group_speech_recognition": false,
|
||||
"use_linkai": ${USE_LINKAI:-false},
|
||||
"linkai_api_key": "${LINKAI_KEY:-}",
|
||||
"linkai_app_code": "",
|
||||
"agent": true,
|
||||
"agent_max_context_tokens": 40000,
|
||||
"agent_max_context_turns": 30,
|
||||
"agent_max_steps": 15
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
wechatcom_app)
|
||||
|
||||
Reference in New Issue
Block a user