mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-03 19:17:10 +08:00
Compare commits
53 Commits
2.0.0
...
feat-web-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394853c0fb | ||
|
|
c0702c8b36 | ||
|
|
9082eec91d | ||
|
|
f1a1413b5f | ||
|
|
c1e7f9af9b | ||
|
|
1c71c4e38b | ||
|
|
5e3eccb3f6 | ||
|
|
e1dc037eb9 | ||
|
|
97e9b4c801 | ||
|
|
52d7cad735 | ||
|
|
c0b1d270ba | ||
|
|
e59a2892e4 | ||
|
|
5fa0376a49 | ||
|
|
05a33042c8 | ||
|
|
ce58f23cbc | ||
|
|
b6fc9fa370 | ||
|
|
00ae38faae | ||
|
|
ab28ee58ab | ||
|
|
48db538a2e | ||
|
|
46945942e1 | ||
|
|
a24b26a1ef | ||
|
|
6f8421cdd5 | ||
|
|
284cd9bca9 | ||
|
|
23fd6b8d2b | ||
|
|
4f0ea5d756 | ||
|
|
6c218331b1 | ||
|
|
cea7fb7490 | ||
|
|
8acf2dbdfe | ||
|
|
0542700f90 | ||
|
|
5264f7ce18 | ||
|
|
051ffd78a3 | ||
|
|
bea95d4fae | ||
|
|
fdf7bc312f | ||
|
|
5b094e1097 | ||
|
|
9ad3968084 | ||
|
|
3958b6aae1 | ||
|
|
eaa413caf0 | ||
|
|
9095225b5b | ||
|
|
c529f86dbc | ||
|
|
e4fcfa356a | ||
|
|
8218cff7c1 | ||
|
|
6949bbcf39 | ||
|
|
480c60c0a7 | ||
|
|
eec10cb5db | ||
|
|
02c83d8689 | ||
|
|
72b1cacea1 | ||
|
|
c72cda3386 | ||
|
|
867442155e | ||
|
|
229b14b6fc | ||
|
|
158c87ab8b | ||
|
|
cb303e6109 | ||
|
|
a77a8741b5 | ||
|
|
3d63459c25 |
282
README.md
282
README.md
@@ -12,13 +12,13 @@
|
|||||||
|
|
||||||
# 简介
|
# 简介
|
||||||
|
|
||||||
> 该项目既是一个可以开箱即用的超级AI助理,也是一个支持高FTS5 not available, using LIKE-based keyword searc度扩展的Agent框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下:
|
> 该项目既是一个可以开箱即用的超级AI助理,也是一个支持高扩展的Agent框架,可以通过为项目扩展大模型接口、接入渠道、内置工具、Skills系统来灵活实现各种定制需求。核心能力如下:
|
||||||
|
|
||||||
- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
|
- ✅ **复杂任务规划**:能够理解复杂任务并自主规划执行,持续思考和调用工具直到完成目标,支持通过工具操作访问文件、终端、浏览器、定时任务等系统资源
|
||||||
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
|
- ✅ **长期记忆:** 自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
|
||||||
- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
- ✅ **技能系统:** 实现了Skills创建和运行的引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
||||||
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
|
- ✅ **多模态消息:** 支持对文本、图片、语音、文件等多类型消息进行解析、处理、生成、发送等操作
|
||||||
- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、通义千问, Kimi等国内外主流模型厂商
|
- ✅ **多模型接入:** 支持OpenAI, Claude, Gemini, DeepSeek, MiniMax、GLM、Qwen、Kimi、Doubao等国内外主流模型厂商
|
||||||
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到网页、飞书、钉钉、微信公众号、企业微信应用中使用
|
- ✅ **多端部署:** 支持运行在本地计算机或服务器,可集成到网页、飞书、钉钉、微信公众号、企业微信应用中使用
|
||||||
- ✅ **知识库:** 集成企业知识库能力,让Agent成为专属数字员工,基于[LinkAI](https://link-ai.tech)平台实现
|
- ✅ **知识库:** 集成企业知识库能力,让Agent成为专属数字员工,基于[LinkAI](https://link-ai.tech)平台实现
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
|||||||
|
|
||||||
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
|
项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。
|
||||||
|
|
||||||
> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择: Claude(claude-sonnet-4-5、claude-sonnet-4-0)、Gemini(gemini-3-flash-preview、gemini-3-pro-preview)、GLM(glm-4.7)、MiniMAx(MiniMax-M2.1)、Qwen(qwen3-max)
|
> 注:Agent模式下推荐使用以下模型,可根据效果及成本综合选择:MiniMax-M2.5、glm-5、kimi-k2.5、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview
|
||||||
|
|
||||||
同时支持使用 **LinkAI平台** 接口,可灵活切换 OpenAI、Claude、Gemini、DeepSeek、Qwen、Kimi 等多种常用模型,并支持知识库、工作流、插件等Agent能力,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
|
同时支持使用 **LinkAI平台** 接口,可灵活切换 OpenAI、Claude、Gemini、DeepSeek、Qwen、Kimi 等多种常用模型,并支持知识库、工作流、插件等Agent能力,参考 [接口文档](https://docs.link-ai.tech/platform/api)。
|
||||||
|
|
||||||
@@ -136,16 +136,18 @@ pip3 install -r requirements-optional.txt
|
|||||||
# config.json 文件内容示例
|
# config.json 文件内容示例
|
||||||
{
|
{
|
||||||
"channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wechatcom_app,terminal,wechatmp,wechatmp_service
|
"channel_type": "web", # 接入渠道类型,默认为web,支持修改为:feishu,dingtalk,wechatcom_app,terminal,wechatmp,wechatmp_service
|
||||||
"model": "claude-sonnet-4-5", # 模型名称
|
"model": "MiniMax-M2.5", # 模型名称
|
||||||
|
"minimax_api_key": "", # MiniMax API Key
|
||||||
|
"zhipu_ai_api_key": "", # 智谱GLM API Key
|
||||||
|
"moonshot_api_key": "", # Kimi/Moonshot API Key
|
||||||
|
"ark_api_key": "", # 豆包(火山方舟) API Key
|
||||||
|
"dashscope_api_key": "", # 百炼(通义千问)API Key
|
||||||
"claude_api_key": "", # Claude API Key
|
"claude_api_key": "", # Claude API Key
|
||||||
"claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台
|
"claude_api_base": "https://api.anthropic.com/v1", # Claude API 地址,修改可接入三方代理平台
|
||||||
"open_ai_api_key": "", # OpenAI API Key
|
|
||||||
"open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址
|
|
||||||
"gemini_api_key": "", # Gemini API Key
|
"gemini_api_key": "", # Gemini API Key
|
||||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # Gemini API地址
|
"gemini_api_base": "https://generativelanguage.googleapis.com", # Gemini API地址
|
||||||
"zhipu_ai_api_key": "", # 智谱GLM API Key
|
"open_ai_api_key": "", # OpenAI API Key
|
||||||
"minimax_api_key": "", # MiniMax API Key
|
"open_ai_api_base": "https://api.openai.com/v1", # OpenAI API 地址
|
||||||
"dashscope_api_key": "", # 百炼(通义千问)API Key
|
|
||||||
"linkai_api_key": "", # LinkAI API Key
|
"linkai_api_key": "", # LinkAI API Key
|
||||||
"proxy": "", # 代理客户端的ip和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890"
|
"proxy": "", # 代理客户端的ip和端口,国内环境需要开启代理的可填写该项,如 "127.0.0.1:7890"
|
||||||
"speech_recognition": false, # 是否开启语音识别
|
"speech_recognition": false, # 是否开启语音识别
|
||||||
@@ -173,13 +175,13 @@ pip3 install -r requirements-optional.txt
|
|||||||
<details>
|
<details>
|
||||||
<summary>2. 其他配置</summary>
|
<summary>2. 其他配置</summary>
|
||||||
|
|
||||||
+ `model`: 模型名称,Agent模式下推荐使用 `claude-sonnet-4-5`、`claude-sonnet-4-0`、`gemini-3-flash-preview`、`gemini-3-pro-preview`、`glm-4.7`、`MiniMax-M2.1`、`qwen3-max`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
|
+ `model`: 模型名称,Agent模式下推荐使用 `MiniMax-M2.5`、`glm-5`、`kimi-k2.5`、`qwen3.5-plus`、`claude-sonnet-4-6`、`gemini-3.1-pro-preview`,全部模型名称参考[common/const.py](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/common/const.py)文件
|
||||||
+ `character_desc`:普通对话模式下的机器人系统提示词。在Agent模式下该配置不生效,由工作空间中的文件内容构成。
|
+ `character_desc`:普通对话模式下的机器人系统提示词。在Agent模式下该配置不生效,由工作空间中的文件内容构成。
|
||||||
+ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。
|
+ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>5. LinkAI配置</summary>
|
<summary>3. LinkAI配置</summary>
|
||||||
|
|
||||||
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台,使用知识库、工作流、插件等能力, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat)
|
+ `use_linkai`: 是否使用LinkAI接口,默认关闭,设置为true后可对接LinkAI平台,使用知识库、工作流、插件等能力, 参考[接口文档](https://docs.link-ai.tech/platform/api/chat)
|
||||||
+ `linkai_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建
|
+ `linkai_api_key`: LinkAI Api Key,可在 [控制台](https://link-ai.tech/console/interface) 创建
|
||||||
@@ -302,6 +304,140 @@ volumes:
|
|||||||
+ `model`: model字段填写空则直接使用智能体的模型,可在平台中灵活切换,[模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
|
+ `model`: model字段填写空则直接使用智能体的模型,可在平台中灵活切换,[模型列表](https://link-ai.tech/console/models)中的全部模型均可使用
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>MiniMax</summary>
|
||||||
|
|
||||||
|
方式一:官方接入,配置如下(推荐):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "MiniMax-M2.5",
|
||||||
|
"minimax_api_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `model`: 可填写 `MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
|
||||||
|
- `minimax_api_key`:MiniMax平台的API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
|
||||||
|
|
||||||
|
方式二:OpenAI兼容方式接入,配置如下:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "chatGPT",
|
||||||
|
"model": "MiniMax-M2.5",
|
||||||
|
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||||
|
"open_ai_api_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `bot_type`: OpenAI兼容方式
|
||||||
|
- `model`: 可填 `MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek)
|
||||||
|
- `open_ai_api_base`: MiniMax平台API的 BASE URL
|
||||||
|
- `open_ai_api_key`: MiniMax平台的API-KEY
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>智谱AI (GLM)</summary>
|
||||||
|
|
||||||
|
方式一:官方接入,配置如下(推荐):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "glm-5",
|
||||||
|
"zhipu_ai_api_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `model`: 可填 `glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等, 参考 [glm系列模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4)
|
||||||
|
- `zhipu_ai_api_key`: 智谱AI平台的 API KEY,在 [控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建
|
||||||
|
|
||||||
|
方式二:OpenAI兼容方式接入,配置如下:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "chatGPT",
|
||||||
|
"model": "glm-5",
|
||||||
|
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
"open_ai_api_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `bot_type`: OpenAI兼容方式
|
||||||
|
- `model`: 可填 `glm-5、glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等
|
||||||
|
- `open_ai_api_base`: 智谱AI平台的 BASE URL
|
||||||
|
- `open_ai_api_key`: 智谱AI平台的 API KEY
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>通义千问 (Qwen)</summary>
|
||||||
|
|
||||||
|
方式一:官方SDK接入,配置如下(推荐):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "qwen3.5-plus",
|
||||||
|
"dashscope_api_key": "sk-qVxxxxG"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `model`: 可填写 `qwen3.5-plus、qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等
|
||||||
|
- `dashscope_api_key`: 通义千问的 API-KEY,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) ,在 [控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建
|
||||||
|
|
||||||
|
方式二:OpenAI兼容方式接入,配置如下:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "chatGPT",
|
||||||
|
"model": "qwen3.5-plus",
|
||||||
|
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
"open_ai_api_key": "sk-qVxxxxG"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `bot_type`: OpenAI兼容方式
|
||||||
|
- `model`: 支持官方所有模型,参考[模型列表](https://help.aliyun.com/zh/model-studio/models?spm=a2c4g.11186623.0.0.78d84823Kth5on#9f8890ce29g5u)
|
||||||
|
- `open_ai_api_base`: 通义千问API的 BASE URL
|
||||||
|
- `open_ai_api_key`: 通义千问的 API-KEY
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Kimi (Moonshot)</summary>
|
||||||
|
|
||||||
|
方式一:官方接入,配置如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "kimi-k2.5",
|
||||||
|
"moonshot_api_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
|
||||||
|
- `moonshot_api_key`: Moonshot的API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
|
||||||
|
|
||||||
|
方式二:OpenAI兼容方式接入,配置如下:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "chatGPT",
|
||||||
|
"model": "kimi-k2.5",
|
||||||
|
"open_ai_api_base": "https://api.moonshot.cn/v1",
|
||||||
|
"open_ai_api_key": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `bot_type`: OpenAI兼容方式
|
||||||
|
- `model`: 可填写 `kimi-k2.5、kimi-k2、moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
|
||||||
|
- `open_ai_api_base`: Moonshot的 BASE URL
|
||||||
|
- `open_ai_api_key`: Moonshot的 API-KEY
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>豆包 (Doubao)</summary>
|
||||||
|
|
||||||
|
1. API Key创建:在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建API Key
|
||||||
|
|
||||||
|
2. 填写配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "doubao-seed-2-0-code-preview-260215",
|
||||||
|
"ark_api_key": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `model`: 可填写 `doubao-seed-2-0-code-preview-260215、doubao-seed-2-0-pro-260215、doubao-seed-2-0-lite-260215、doubao-seed-2-0-mini-260215` 等
|
||||||
|
- `ark_api_key`: 火山方舟平台的 API Key,在 [控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建
|
||||||
|
- `ark_base_url`: 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3`
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Claude</summary>
|
<summary>Claude</summary>
|
||||||
|
|
||||||
@@ -311,11 +447,11 @@ volumes:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-sonnet-4-5",
|
"model": "claude-sonnet-4-6",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `model`: 参考 [官方模型ID](https://docs.anthropic.com/en/docs/about-claude/models/overview#model-aliases) ,支持 `claude-sonnet-4-5、claude-sonnet-4-0、claude-opus-4-0、claude-3-5-sonnet-latest` 等
|
- `model`: 参考 [官方模型ID](https://docs.anthropic.com/en/docs/about-claude/models/overview#model-aliases) ,支持 `claude-sonnet-4-6、claude-opus-4-6、claude-sonnet-4-5、claude-sonnet-4-0、claude-opus-4-0、claude-3-5-sonnet-latest` 等
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -324,11 +460,11 @@ volumes:
|
|||||||
API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn) 创建API Key ,配置如下
|
API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn) 创建API Key ,配置如下
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gemini-3-flash-preview",
|
"model": "gemini-3.1-pro-preview",
|
||||||
"gemini_api_key": ""
|
"gemini_api_key": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `model`: 参考[官方文档-模型列表](https://ai.google.dev/gemini-api/docs/models?hl=zh-cn),支持 `gemini-3-flash-preview、gemini-3-pro-preview、gemini-2.5-pro、gemini-2.0-flash` 等
|
- `model`: 参考[官方文档-模型列表](https://ai.google.dev/gemini-api/docs/models?hl=zh-cn),支持 `gemini-3.1-pro-preview、gemini-3-flash-preview、gemini-3-pro-preview、gemini-2.5-pro、gemini-2.0-flash` 等
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -354,122 +490,6 @@ API Key创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn)
|
|||||||
- `open_ai_api_base`: DeepSeek平台 BASE URL
|
- `open_ai_api_base`: DeepSeek平台 BASE URL
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>通义千问 (Qwen)</summary>
|
|
||||||
|
|
||||||
方式一:官方SDK接入,配置如下(推荐):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "qwen3-max",
|
|
||||||
"dashscope_api_key": "sk-qVxxxxG"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `model`: 可填写 `qwen3-max、qwen-max、qwen-plus、qwen-turbo、qwen-long、qwq-plus` 等
|
|
||||||
- `dashscope_api_key`: 通义千问的 API-KEY,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) ,在 [控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建
|
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "chatGPT",
|
|
||||||
"model": "qwen3-max",
|
|
||||||
"open_ai_api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
||||||
"open_ai_api_key": "sk-qVxxxxG"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `bot_type`: OpenAI兼容方式
|
|
||||||
- `model`: 支持官方所有模型,参考[模型列表](https://help.aliyun.com/zh/model-studio/models?spm=a2c4g.11186623.0.0.78d84823Kth5on#9f8890ce29g5u)
|
|
||||||
- `open_ai_api_base`: 通义千问API的 BASE URL
|
|
||||||
- `open_ai_api_key`: 通义千问的 API-KEY
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>MiniMax</summary>
|
|
||||||
|
|
||||||
方式一:官方接入,配置如下(推荐):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "MiniMax-M2.1",
|
|
||||||
"minimax_api_key": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `model`: 可填写 `MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等
|
|
||||||
- `minimax_api_key`:MiniMax平台的API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建
|
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "chatGPT",
|
|
||||||
"model": "MiniMax-M2.1",
|
|
||||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
|
||||||
"open_ai_api_key": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `bot_type`: OpenAI兼容方式
|
|
||||||
- `model`: 可填 `MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek)
|
|
||||||
- `open_ai_api_base`: MiniMax平台API的 BASE URL
|
|
||||||
- `open_ai_api_key`: MiniMax平台的API-KEY
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>智谱AI (GLM)</summary>
|
|
||||||
|
|
||||||
方式一:官方接入,配置如下(推荐):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "glm-4.7",
|
|
||||||
"zhipu_ai_api_key": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `model`: 可填 `glm-4.7、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等, 参考 [glm-4系列模型编码](https://bigmodel.cn/dev/api/normal-model/glm-4)
|
|
||||||
- `zhipu_ai_api_key`: 智谱AI平台的 API KEY,在 [控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建
|
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "chatGPT",
|
|
||||||
"model": "glm-4.7",
|
|
||||||
"open_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
|
||||||
"open_ai_api_key": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `bot_type`: OpenAI兼容方式
|
|
||||||
- `model`: 可填 `glm-4.7、glm-4.6、glm-4-plus、glm-4-flash、glm-4-air、glm-4-airx、glm-4-long` 等
|
|
||||||
- `open_ai_api_base`: 智谱AI平台的 BASE URL
|
|
||||||
- `open_ai_api_key`: 智谱AI平台的 API KEY
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Kimi (Moonshot)</summary>
|
|
||||||
|
|
||||||
方式一:官方接入,配置如下:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"model": "moonshot-v1-128k",
|
|
||||||
"moonshot_api_key": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `model`: 可填写 `moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
|
|
||||||
- `moonshot_api_key`: Moonshot的API-KEY,在 [控制台](https://platform.moonshot.cn/console/api-keys) 创建
|
|
||||||
|
|
||||||
方式二:OpenAI兼容方式接入,配置如下:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "chatGPT",
|
|
||||||
"model": "moonshot-v1-128k",
|
|
||||||
"open_ai_api_base": "https://api.moonshot.cn/v1",
|
|
||||||
"open_ai_api_key": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `bot_type`: OpenAI兼容方式
|
|
||||||
- `model`: 可填写 `moonshot-v1-8k、moonshot-v1-32k、moonshot-v1-128k`
|
|
||||||
- `open_ai_api_base`: Moonshot的 BASE URL
|
|
||||||
- `open_ai_api_key`: Moonshot的 API-KEY
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Azure</summary>
|
<summary>Azure</summary>
|
||||||
|
|
||||||
|
|||||||
3
agent/chat/__init__.py
Normal file
3
agent/chat/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from agent.chat.service import ChatService
|
||||||
|
|
||||||
|
__all__ = ["ChatService"]
|
||||||
168
agent/chat/service.py
Normal file
168
agent/chat/service.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
ChatService - Wraps the Agent stream execution to produce CHAT protocol chunks.
|
||||||
|
|
||||||
|
Translates agent events (message_update, message_end, tool_execution_end, etc.)
|
||||||
|
into the CHAT socket protocol format (content chunks with segment_id, tool_calls chunks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
"""
|
||||||
|
High-level service that runs an Agent for a given query and streams
|
||||||
|
the results as CHAT protocol chunks via a callback.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
svc = ChatService(agent_bridge)
|
||||||
|
svc.run(query, session_id, send_chunk_fn)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, agent_bridge):
|
||||||
|
"""
|
||||||
|
:param agent_bridge: AgentBridge instance (manages agent lifecycle)
|
||||||
|
"""
|
||||||
|
self.agent_bridge = agent_bridge
|
||||||
|
|
||||||
|
def run(self, query: str, session_id: str, send_chunk_fn: Callable[[dict], None]):
|
||||||
|
"""
|
||||||
|
Run the agent for *query* and stream results back via *send_chunk_fn*.
|
||||||
|
|
||||||
|
The method blocks until the agent finishes. After it returns the SDK
|
||||||
|
will automatically send the final (streaming=false) message.
|
||||||
|
|
||||||
|
:param query: user query text
|
||||||
|
:param session_id: session identifier for agent isolation
|
||||||
|
:param send_chunk_fn: callable(chunk_data: dict) to send a streaming chunk
|
||||||
|
"""
|
||||||
|
agent = self.agent_bridge.get_agent(session_id=session_id)
|
||||||
|
if agent is None:
|
||||||
|
raise RuntimeError("Failed to initialise agent for the session")
|
||||||
|
|
||||||
|
# State shared between the event callback and this method
|
||||||
|
state = _StreamState()
|
||||||
|
|
||||||
|
def on_event(event: dict):
|
||||||
|
"""Translate agent events into CHAT protocol chunks."""
|
||||||
|
event_type = event.get("type")
|
||||||
|
data = event.get("data", {})
|
||||||
|
|
||||||
|
if event_type == "message_update":
|
||||||
|
# Incremental text delta
|
||||||
|
delta = data.get("delta", "")
|
||||||
|
if delta:
|
||||||
|
send_chunk_fn({
|
||||||
|
"chunk_type": "content",
|
||||||
|
"delta": delta,
|
||||||
|
"segment_id": state.segment_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event_type == "message_end":
|
||||||
|
# A content segment finished.
|
||||||
|
tool_calls = data.get("tool_calls", [])
|
||||||
|
if tool_calls:
|
||||||
|
# After tool_calls are executed the next content will be
|
||||||
|
# a new segment; collect tool results until turn_end.
|
||||||
|
state.pending_tool_results = []
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_end":
|
||||||
|
tool_name = data.get("tool_name", "")
|
||||||
|
arguments = data.get("arguments", {})
|
||||||
|
result = data.get("result", "")
|
||||||
|
status = data.get("status", "unknown")
|
||||||
|
execution_time = data.get("execution_time", 0)
|
||||||
|
elapsed_str = f"{execution_time:.2f}s"
|
||||||
|
|
||||||
|
# Serialise result to string if needed
|
||||||
|
if not isinstance(result, str):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
result = json.dumps(result, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
result = str(result)
|
||||||
|
|
||||||
|
tool_info = {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments,
|
||||||
|
"result": result,
|
||||||
|
"status": status,
|
||||||
|
"elapsed": elapsed_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.pending_tool_results is not None:
|
||||||
|
state.pending_tool_results.append(tool_info)
|
||||||
|
|
||||||
|
elif event_type == "turn_end":
|
||||||
|
has_tool_calls = data.get("has_tool_calls", False)
|
||||||
|
if has_tool_calls and state.pending_tool_results:
|
||||||
|
# Flush collected tool results as a single tool_calls chunk
|
||||||
|
send_chunk_fn({
|
||||||
|
"chunk_type": "tool_calls",
|
||||||
|
"tool_calls": state.pending_tool_results,
|
||||||
|
})
|
||||||
|
state.pending_tool_results = None
|
||||||
|
# Next content belongs to a new segment
|
||||||
|
state.segment_id += 1
|
||||||
|
|
||||||
|
# Run the agent with our event callback ---------------------------
|
||||||
|
logger.info(f"[ChatService] Starting agent run: session={session_id}, query={query[:80]}")
|
||||||
|
|
||||||
|
from config import conf
|
||||||
|
max_context_turns = conf().get("agent_max_context_turns", 30)
|
||||||
|
|
||||||
|
# Get full system prompt with skills
|
||||||
|
full_system_prompt = agent.get_full_system_prompt()
|
||||||
|
|
||||||
|
# Create a copy of messages for this execution
|
||||||
|
with agent.messages_lock:
|
||||||
|
messages_copy = agent.messages.copy()
|
||||||
|
original_length = len(agent.messages)
|
||||||
|
|
||||||
|
from agent.protocol.agent_stream import AgentStreamExecutor
|
||||||
|
|
||||||
|
executor = AgentStreamExecutor(
|
||||||
|
agent=agent,
|
||||||
|
model=agent.model,
|
||||||
|
system_prompt=full_system_prompt,
|
||||||
|
tools=agent.tools,
|
||||||
|
max_turns=agent.max_steps,
|
||||||
|
on_event=on_event,
|
||||||
|
messages=messages_copy,
|
||||||
|
max_context_turns=max_context_turns,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = executor.run_stream(query)
|
||||||
|
except Exception:
|
||||||
|
# If executor cleared messages (context overflow), sync back
|
||||||
|
if len(executor.messages) == 0:
|
||||||
|
with agent.messages_lock:
|
||||||
|
agent.messages.clear()
|
||||||
|
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Append only the NEW messages from this execution (thread-safe)
|
||||||
|
with agent.messages_lock:
|
||||||
|
new_messages = executor.messages[original_length:]
|
||||||
|
agent.messages.extend(new_messages)
|
||||||
|
|
||||||
|
# Store executor reference for files_to_send access
|
||||||
|
agent.stream_executor = executor
|
||||||
|
|
||||||
|
# Execute post-process tools
|
||||||
|
agent._execute_post_process_tools()
|
||||||
|
|
||||||
|
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamState:
|
||||||
|
"""Mutable state shared between the event callback and the run method."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.segment_id: int = 0
|
||||||
|
# None means we are not accumulating tool results right now.
|
||||||
|
# A list means we are in the middle of a tool-execution phase.
|
||||||
|
self.pending_tool_results: Optional[list] = None
|
||||||
@@ -11,12 +11,18 @@ from typing import Optional, List
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _default_workspace():
|
||||||
|
"""Get default workspace path with proper Windows support"""
|
||||||
|
from common.utils import expand_path
|
||||||
|
return expand_path("~/cow")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MemoryConfig:
|
class MemoryConfig:
|
||||||
"""Configuration for memory storage and search"""
|
"""Configuration for memory storage and search"""
|
||||||
|
|
||||||
# Storage paths (default: ~/cow)
|
# Storage paths (default: ~/cow)
|
||||||
workspace_root: str = field(default_factory=lambda: os.path.expanduser("~/cow"))
|
workspace_root: str = field(default_factory=_default_workspace)
|
||||||
|
|
||||||
# Embedding config
|
# Embedding config
|
||||||
embedding_provider: str = "openai" # "openai" | "local"
|
embedding_provider: str = "openai" # "openai" | "local"
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ class MemoryManager:
|
|||||||
):
|
):
|
||||||
"""Sync a single file"""
|
"""Sync a single file"""
|
||||||
# Compute file hash
|
# Compute file hash
|
||||||
content = file_path.read_text()
|
content = file_path.read_text(encoding='utf-8')
|
||||||
file_hash = MemoryStorage.compute_hash(content)
|
file_hash = MemoryStorage.compute_hash(content)
|
||||||
|
|
||||||
# Get relative path
|
# Get relative path
|
||||||
|
|||||||
167
agent/memory/service.py
Normal file
167
agent/memory/service.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Memory service for handling memory query operations via cloud protocol.
|
||||||
|
|
||||||
|
Provides a unified interface for listing and reading memory files,
|
||||||
|
callable from the cloud client (LinkAI) or a future web console.
|
||||||
|
|
||||||
|
Memory file layout (under workspace_root):
|
||||||
|
MEMORY.md -> type: global
|
||||||
|
memory/2026-02-20.md -> type: daily
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryService:
|
||||||
|
"""
|
||||||
|
High-level service for memory file queries.
|
||||||
|
Operates directly on the filesystem — no MemoryManager dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, workspace_root: str):
|
||||||
|
"""
|
||||||
|
:param workspace_root: Workspace root directory (e.g. ~/cow)
|
||||||
|
"""
|
||||||
|
self.workspace_root = workspace_root
|
||||||
|
self.memory_dir = os.path.join(workspace_root, "memory")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# list — paginated file metadata
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def list_files(self, page: int = 1, page_size: int = 20) -> dict:
|
||||||
|
"""
|
||||||
|
List all memory files with metadata (without content).
|
||||||
|
|
||||||
|
Returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 15,
|
||||||
|
"list": [
|
||||||
|
{"filename": "MEMORY.md", "type": "global", "size": 2048, "updated_at": "2026-02-20 10:00:00"},
|
||||||
|
{"filename": "2026-02-20.md", "type": "daily", "size": 512, "updated_at": "2026-02-20 09:30:00"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
files: List[dict] = []
|
||||||
|
|
||||||
|
# 1. Global memory — MEMORY.md in workspace root
|
||||||
|
global_path = os.path.join(self.workspace_root, "MEMORY.md")
|
||||||
|
if os.path.isfile(global_path):
|
||||||
|
files.append(self._file_info(global_path, "MEMORY.md", "global"))
|
||||||
|
|
||||||
|
# 2. Daily memory files — memory/*.md (sorted newest first)
|
||||||
|
if os.path.isdir(self.memory_dir):
|
||||||
|
daily_files = []
|
||||||
|
for name in os.listdir(self.memory_dir):
|
||||||
|
full = os.path.join(self.memory_dir, name)
|
||||||
|
if os.path.isfile(full) and name.endswith(".md"):
|
||||||
|
daily_files.append((name, full))
|
||||||
|
# Sort by filename descending (newest date first)
|
||||||
|
daily_files.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
for name, full in daily_files:
|
||||||
|
files.append(self._file_info(full, name, "daily"))
|
||||||
|
|
||||||
|
total = len(files)
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
page_items = files[start:end]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
"list": page_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# content — read a single file
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_content(self, filename: str) -> dict:
|
||||||
|
"""
|
||||||
|
Read the full content of a memory file.
|
||||||
|
|
||||||
|
:param filename: File name, e.g. ``MEMORY.md`` or ``2026-02-20.md``
|
||||||
|
:return: dict with ``filename`` and ``content``
|
||||||
|
:raises FileNotFoundError: if the file does not exist
|
||||||
|
"""
|
||||||
|
path = self._resolve_path(filename)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise FileNotFoundError(f"Memory file not found: {filename}")
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filename": filename,
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# dispatch — single entry point for protocol messages
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Dispatch a memory management action.
|
||||||
|
|
||||||
|
:param action: ``list`` or ``content``
|
||||||
|
:param payload: action-specific payload
|
||||||
|
:return: protocol-compatible response dict
|
||||||
|
"""
|
||||||
|
payload = payload or {}
|
||||||
|
try:
|
||||||
|
if action == "list":
|
||||||
|
page = payload.get("page", 1)
|
||||||
|
page_size = payload.get("page_size", 20)
|
||||||
|
result_payload = self.list_files(page=page, page_size=page_size)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
||||||
|
|
||||||
|
elif action == "content":
|
||||||
|
filename = payload.get("filename")
|
||||||
|
if not filename:
|
||||||
|
return {"action": action, "code": 400, "message": "filename is required", "payload": None}
|
||||||
|
result_payload = self.get_content(filename)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MemoryService] dispatch error: action={action}, error={e}")
|
||||||
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _resolve_path(self, filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Resolve a filename to its absolute path.
|
||||||
|
|
||||||
|
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
||||||
|
- ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md``
|
||||||
|
"""
|
||||||
|
if filename == "MEMORY.md":
|
||||||
|
return os.path.join(self.workspace_root, filename)
|
||||||
|
return os.path.join(self.memory_dir, filename)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _file_info(path: str, filename: str, file_type: str) -> dict:
|
||||||
|
"""Build a file metadata dict."""
|
||||||
|
stat = os.stat(path)
|
||||||
|
updated_at = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
return {
|
||||||
|
"filename": filename,
|
||||||
|
"type": file_type,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
}
|
||||||
@@ -157,96 +157,66 @@ def _build_identity_section(base_persona: Optional[str], language: str) -> List[
|
|||||||
|
|
||||||
|
|
||||||
def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
||||||
"""构建工具说明section"""
|
"""Build tooling section with concise tool list and call style guide."""
|
||||||
|
# One-line summaries for known tools (details are in the tool schema)
|
||||||
|
core_summaries = {
|
||||||
|
"read": "读取文件内容",
|
||||||
|
"write": "创建或覆盖文件",
|
||||||
|
"edit": "精确编辑文件",
|
||||||
|
"ls": "列出目录内容",
|
||||||
|
"grep": "搜索文件内容",
|
||||||
|
"find": "按模式查找文件",
|
||||||
|
"bash": "执行shell命令",
|
||||||
|
"terminal": "管理后台进程",
|
||||||
|
"web_search": "网络搜索",
|
||||||
|
"web_fetch": "获取URL内容",
|
||||||
|
"browser": "控制浏览器",
|
||||||
|
"memory_search": "搜索记忆",
|
||||||
|
"memory_get": "读取记忆内容",
|
||||||
|
"env_config": "管理API密钥和技能配置",
|
||||||
|
"scheduler": "管理定时任务和提醒",
|
||||||
|
"send": "发送文件给用户",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preferred display order
|
||||||
|
tool_order = [
|
||||||
|
"read", "write", "edit", "ls", "grep", "find",
|
||||||
|
"bash", "terminal",
|
||||||
|
"web_search", "web_fetch", "browser",
|
||||||
|
"memory_search", "memory_get",
|
||||||
|
"env_config", "scheduler", "send",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build name -> summary mapping for available tools
|
||||||
|
available = {}
|
||||||
|
for tool in tools:
|
||||||
|
name = tool.name if hasattr(tool, 'name') else str(tool)
|
||||||
|
available[name] = core_summaries.get(name, "")
|
||||||
|
|
||||||
|
# Generate tool lines: ordered tools first, then extras
|
||||||
|
tool_lines = []
|
||||||
|
for name in tool_order:
|
||||||
|
if name in available:
|
||||||
|
summary = available.pop(name)
|
||||||
|
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
|
||||||
|
for name in sorted(available):
|
||||||
|
summary = available[name]
|
||||||
|
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 工具系统",
|
"## 工具系统",
|
||||||
"",
|
"",
|
||||||
"你可以使用以下工具来完成任务。工具名称是大小写敏感的,请严格按照列表中的名称调用。",
|
"可用工具(名称大小写敏感,严格按列表调用):",
|
||||||
|
"\n".join(tool_lines),
|
||||||
"",
|
"",
|
||||||
"### 可用工具",
|
"工具调用风格:",
|
||||||
|
"",
|
||||||
|
"- 在多步骤任务、敏感操作或用户要求时简要解释决策过程",
|
||||||
|
"- 持续推进直到任务完成,完成后向用户报告结果。",
|
||||||
|
"- 回复中涉及密钥、令牌等敏感信息必须脱敏。",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 工具分类和排序
|
|
||||||
tool_categories = {
|
|
||||||
"文件操作": ["read", "write", "edit", "ls", "grep", "find"],
|
|
||||||
"命令执行": ["bash", "terminal"],
|
|
||||||
"网络搜索": ["web_search", "web_fetch", "browser"],
|
|
||||||
"记忆系统": ["memory_search", "memory_get"],
|
|
||||||
"其他": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# 构建工具映射
|
|
||||||
tool_map = {}
|
|
||||||
tool_descriptions = {
|
|
||||||
"read": "读取文件内容",
|
|
||||||
"write": "创建新文件或完全覆盖现有文件(会删除原内容!追加内容请用 edit)。注意:单次 write 内容不要超过 10KB,超大文件请分步创建",
|
|
||||||
"edit": "精确编辑文件(追加、修改、删除部分内容)",
|
|
||||||
"ls": "列出目录内容",
|
|
||||||
"grep": "在文件中搜索内容",
|
|
||||||
"find": "按照模式查找文件",
|
|
||||||
"bash": "执行shell命令",
|
|
||||||
"terminal": "管理后台进程",
|
|
||||||
"web_search": "网络搜索(使用搜索引擎)",
|
|
||||||
"web_fetch": "获取URL内容",
|
|
||||||
"browser": "控制浏览器",
|
|
||||||
"memory_search": "搜索记忆文件",
|
|
||||||
"memory_get": "获取记忆文件内容",
|
|
||||||
"calculator": "计算器",
|
|
||||||
"current_time": "获取当前时间",
|
|
||||||
}
|
|
||||||
|
|
||||||
for tool in tools:
|
|
||||||
tool_name = tool.name if hasattr(tool, 'name') else str(tool)
|
|
||||||
tool_desc = tool.description if hasattr(tool, 'description') else tool_descriptions.get(tool_name, "")
|
|
||||||
tool_map[tool_name] = tool_desc
|
|
||||||
|
|
||||||
# 按分类添加工具
|
|
||||||
for category, tool_names in tool_categories.items():
|
|
||||||
category_tools = [(name, tool_map.get(name, "")) for name in tool_names if name in tool_map]
|
|
||||||
if category_tools:
|
|
||||||
lines.append(f"**{category}**:")
|
|
||||||
for name, desc in category_tools:
|
|
||||||
if desc:
|
|
||||||
lines.append(f"- `{name}`: {desc}")
|
|
||||||
else:
|
|
||||||
lines.append(f"- `{name}`")
|
|
||||||
del tool_map[name] # 移除已添加的工具
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# 添加其他未分类的工具
|
|
||||||
if tool_map:
|
|
||||||
lines.append("**其他工具**:")
|
|
||||||
for name, desc in sorted(tool_map.items()):
|
|
||||||
if desc:
|
|
||||||
lines.append(f"- `{name}`: {desc}")
|
|
||||||
else:
|
|
||||||
lines.append(f"- `{name}`")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# 工具使用指南
|
|
||||||
lines.extend([
|
|
||||||
"### 工具调用风格",
|
|
||||||
"",
|
|
||||||
"默认规则: 对于常规、低风险的工具调用,直接调用即可,无需叙述。",
|
|
||||||
"",
|
|
||||||
"需要叙述的情况:",
|
|
||||||
"- 多步骤、复杂的任务",
|
|
||||||
"- 敏感操作(如删除文件)",
|
|
||||||
"- 用户明确要求解释过程",
|
|
||||||
"",
|
|
||||||
"叙述要求: 保持简洁、信息密度高,避免重复显而易见的步骤。",
|
|
||||||
"",
|
|
||||||
"完成标准:",
|
|
||||||
"- 确保用户的需求得到实际解决,而不仅仅是制定计划。",
|
|
||||||
"- 当任务需要多次工具调用时,持续推进直到完成, 解决完后向用户报告结果或回复用户的问题",
|
|
||||||
"- 每次工具调用后,评估是否已获得足够信息来推进或完成任务",
|
|
||||||
"- 避免重复调用相同的工具和相同参数获取相同的信息,除非用户明确要求",
|
|
||||||
"",
|
|
||||||
"**安全提醒**: 回复中涉及密钥、令牌、密码等敏感信息时,必须脱敏处理,禁止直接显示完整内容。",
|
|
||||||
"",
|
|
||||||
])
|
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
@@ -265,16 +235,17 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
|
|||||||
break
|
break
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"## 技能系统",
|
"## 技能系统(mandatory)",
|
||||||
"",
|
"",
|
||||||
"在回复之前:扫描下方 <available_skills> 中的 <description> 条目。",
|
"在回复之前:扫描下方 <available_skills> 中的 <description> 条目。",
|
||||||
"",
|
"",
|
||||||
f"- 如果恰好有一个技能明确适用:使用 `{read_tool_name}` 工具读取其 <location> 路径下的 SKILL.md 文件,然后遵循它",
|
f"- 如果恰好有一个技能(Skill)明确适用:使用 `{read_tool_name}` 读取其 <location> 处的 SKILL.md,然后严格遵循它",
|
||||||
"- 如果多个技能都适用:选择最具体的一个,然后读取并遵循",
|
"- 如果多个技能都适用则选择最匹配的一个,如果没有明确适用的则不要读取任何 SKILL.md",
|
||||||
"- 如果没有明确适用的:不要读取任何 SKILL.md",
|
"- 读取 SKILL.md 后直接按其指令执行,无需多余的预检查",
|
||||||
"",
|
"",
|
||||||
"**约束**: 永远不要一次性读取多个技能;只在选择后再读取。",
|
"**注意**: 永远不要一次性读取多个技能,只在选择后再读取。技能和工具不同,必须先读取其SKILL.md并按照文件内容运行。",
|
||||||
"",
|
"",
|
||||||
|
"以下是可用技能:"
|
||||||
]
|
]
|
||||||
|
|
||||||
# 添加技能列表(通过skill_manager获取)
|
# 添加技能列表(通过skill_manager获取)
|
||||||
@@ -461,7 +432,7 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
|
|||||||
|
|
||||||
|
|
||||||
def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]:
|
def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]:
|
||||||
"""构建运行时信息section"""
|
"""构建运行时信息section - 支持动态时间"""
|
||||||
if not runtime_info:
|
if not runtime_info:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -471,7 +442,17 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Add current time if available
|
# Add current time if available
|
||||||
if runtime_info.get("current_time"):
|
# Support dynamic time via callable function
|
||||||
|
if callable(runtime_info.get("_get_current_time")):
|
||||||
|
try:
|
||||||
|
time_info = runtime_info["_get_current_time"]()
|
||||||
|
time_line = f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})"
|
||||||
|
lines.append(time_line)
|
||||||
|
lines.append("")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[PromptBuilder] Failed to get dynamic time: {e}")
|
||||||
|
elif runtime_info.get("current_time"):
|
||||||
|
# Fallback to static time for backward compatibility
|
||||||
time_str = runtime_info["current_time"]
|
time_str = runtime_info["current_time"]
|
||||||
weekday = runtime_info.get("weekday", "")
|
weekday = runtime_info.get("weekday", "")
|
||||||
timezone = runtime_info.get("timezone", "")
|
timezone = runtime_info.get("timezone", "")
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
|
|||||||
# 创建memory子目录
|
# 创建memory子目录
|
||||||
os.makedirs(memory_dir, exist_ok=True)
|
os.makedirs(memory_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 创建skills子目录 (for workspace-level skills installed by agent)
|
||||||
|
skills_dir = os.path.join(workspace_dir, "skills")
|
||||||
|
os.makedirs(skills_dir, exist_ok=True)
|
||||||
|
|
||||||
# 如果需要,创建模板文件
|
# 如果需要,创建模板文件
|
||||||
if create_templates:
|
if create_templates:
|
||||||
_create_template_if_missing(agent_path, _get_agent_template())
|
_create_template_if_missing(agent_path, _get_agent_template())
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ class Agent:
|
|||||||
def __init__(self, system_prompt: str, description: str = "AI Agent", model: LLMModel = None,
|
def __init__(self, system_prompt: str, description: str = "AI Agent", model: LLMModel = None,
|
||||||
tools=None, output_mode="print", max_steps=100, max_context_tokens=None,
|
tools=None, output_mode="print", max_steps=100, max_context_tokens=None,
|
||||||
context_reserve_tokens=None, memory_manager=None, name: str = None,
|
context_reserve_tokens=None, memory_manager=None, name: str = None,
|
||||||
workspace_dir: str = None, skill_manager=None, enable_skills: bool = True):
|
workspace_dir: str = None, skill_manager=None, enable_skills: bool = True,
|
||||||
|
runtime_info: dict = None):
|
||||||
"""
|
"""
|
||||||
Initialize the Agent with system prompt, model, description.
|
Initialize the Agent with system prompt, model, description.
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ class Agent:
|
|||||||
:param workspace_dir: Optional workspace directory for workspace-specific skills
|
:param workspace_dir: Optional workspace directory for workspace-specific skills
|
||||||
:param skill_manager: Optional SkillManager instance (will be created if None and enable_skills=True)
|
:param skill_manager: Optional SkillManager instance (will be created if None and enable_skills=True)
|
||||||
:param enable_skills: Whether to enable skills support (default: True)
|
:param enable_skills: Whether to enable skills support (default: True)
|
||||||
|
:param runtime_info: Optional runtime info dict (with _get_current_time callable for dynamic time)
|
||||||
"""
|
"""
|
||||||
self.name = name or "Agent"
|
self.name = name or "Agent"
|
||||||
self.system_prompt = system_prompt
|
self.system_prompt = system_prompt
|
||||||
@@ -48,6 +51,7 @@ class Agent:
|
|||||||
self.memory_manager = memory_manager # Memory manager for auto memory flush
|
self.memory_manager = memory_manager # Memory manager for auto memory flush
|
||||||
self.workspace_dir = workspace_dir # Workspace directory
|
self.workspace_dir = workspace_dir # Workspace directory
|
||||||
self.enable_skills = enable_skills # Skills enabled flag
|
self.enable_skills = enable_skills # Skills enabled flag
|
||||||
|
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
||||||
|
|
||||||
# Initialize skill manager
|
# Initialize skill manager
|
||||||
self.skill_manager = None
|
self.skill_manager = None
|
||||||
@@ -58,7 +62,8 @@ class Agent:
|
|||||||
# Auto-create skill manager
|
# Auto-create skill manager
|
||||||
try:
|
try:
|
||||||
from agent.skills import SkillManager
|
from agent.skills import SkillManager
|
||||||
self.skill_manager = SkillManager(workspace_dir=workspace_dir)
|
custom_dir = os.path.join(workspace_dir, "skills") if workspace_dir else None
|
||||||
|
self.skill_manager = SkillManager(custom_dir=custom_dir)
|
||||||
logger.debug(f"Initialized SkillManager with {len(self.skill_manager.skills)} skills")
|
logger.debug(f"Initialized SkillManager with {len(self.skill_manager.skills)} skills")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to initialize SkillManager: {e}")
|
logger.warning(f"Failed to initialize SkillManager: {e}")
|
||||||
@@ -104,9 +109,89 @@ class Agent:
|
|||||||
:param skill_filter: Optional list of skill names to include (deprecated)
|
:param skill_filter: Optional list of skill names to include (deprecated)
|
||||||
:return: Complete system prompt
|
:return: Complete system prompt
|
||||||
"""
|
"""
|
||||||
# Skills are now included in system_prompt by PromptBuilder
|
prompt = self.system_prompt
|
||||||
# No need to append them here
|
|
||||||
return self.system_prompt
|
# Rebuild tool list section to reflect current self.tools
|
||||||
|
prompt = self._rebuild_tool_list_section(prompt)
|
||||||
|
|
||||||
|
# If runtime_info contains dynamic time function, rebuild runtime section
|
||||||
|
if self.runtime_info and callable(self.runtime_info.get('_get_current_time')):
|
||||||
|
prompt = self._rebuild_runtime_section(prompt)
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _rebuild_runtime_section(self, prompt: str) -> str:
|
||||||
|
"""
|
||||||
|
Rebuild runtime info section with current time.
|
||||||
|
|
||||||
|
This method dynamically updates the runtime info section by calling
|
||||||
|
the _get_current_time function from runtime_info.
|
||||||
|
|
||||||
|
:param prompt: Original system prompt
|
||||||
|
:return: Updated system prompt with current runtime info
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get current time dynamically
|
||||||
|
time_info = self.runtime_info['_get_current_time']()
|
||||||
|
|
||||||
|
# Build new runtime section
|
||||||
|
runtime_lines = [
|
||||||
|
"\n## 运行时信息\n",
|
||||||
|
"\n",
|
||||||
|
f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})\n",
|
||||||
|
"\n"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add other runtime info
|
||||||
|
runtime_parts = []
|
||||||
|
if self.runtime_info.get("model"):
|
||||||
|
runtime_parts.append(f"模型={self.runtime_info['model']}")
|
||||||
|
if self.runtime_info.get("workspace"):
|
||||||
|
# Replace backslashes with forward slashes for Windows paths
|
||||||
|
workspace_path = str(self.runtime_info['workspace']).replace('\\', '/')
|
||||||
|
runtime_parts.append(f"工作空间={workspace_path}")
|
||||||
|
if self.runtime_info.get("channel") and self.runtime_info.get("channel") != "web":
|
||||||
|
runtime_parts.append(f"渠道={self.runtime_info['channel']}")
|
||||||
|
|
||||||
|
if runtime_parts:
|
||||||
|
runtime_lines.append("运行时: " + " | ".join(runtime_parts) + "\n")
|
||||||
|
runtime_lines.append("\n")
|
||||||
|
|
||||||
|
new_runtime_section = "".join(runtime_lines)
|
||||||
|
|
||||||
|
# Find and replace the runtime section
|
||||||
|
import re
|
||||||
|
pattern = r'\n## 运行时信息\s*\n.*?(?=\n##|\Z)'
|
||||||
|
updated_prompt = re.sub(pattern, new_runtime_section.rstrip('\n'), prompt, flags=re.DOTALL)
|
||||||
|
|
||||||
|
return updated_prompt
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to rebuild runtime section: {e}")
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _rebuild_tool_list_section(self, prompt: str) -> str:
|
||||||
|
"""
|
||||||
|
Rebuild the tool list inside the '## 工具系统' section so that it
|
||||||
|
always reflects the current ``self.tools`` (handles dynamic add/remove
|
||||||
|
of conditional tools like web_search).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from agent.prompt.builder import _build_tooling_section
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not self.tools:
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
new_lines = _build_tooling_section(self.tools, "zh")
|
||||||
|
new_section = "\n".join(new_lines).rstrip("\n")
|
||||||
|
|
||||||
|
# Replace existing tooling section
|
||||||
|
pattern = r'## 工具系统\s*\n.*?(?=\n## |\Z)'
|
||||||
|
updated = re.sub(pattern, new_section, prompt, count=1, flags=re.DOTALL)
|
||||||
|
return updated
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to rebuild tool list section: {e}")
|
||||||
|
return prompt
|
||||||
|
|
||||||
def refresh_skills(self):
|
def refresh_skills(self):
|
||||||
"""Refresh the loaded skills."""
|
"""Refresh the loaded skills."""
|
||||||
@@ -193,27 +278,67 @@ class Agent:
|
|||||||
|
|
||||||
def _estimate_message_tokens(self, message: dict) -> int:
|
def _estimate_message_tokens(self, message: dict) -> int:
|
||||||
"""
|
"""
|
||||||
Estimate token count for a message using chars/4 heuristic.
|
Estimate token count for a message.
|
||||||
This is a conservative estimate (tends to overestimate).
|
|
||||||
|
Uses chars/3 for Chinese-heavy content and chars/4 for ASCII-heavy content,
|
||||||
|
plus per-block overhead for tool_use / tool_result structures.
|
||||||
|
|
||||||
:param message: Message dict with 'role' and 'content'
|
:param message: Message dict with 'role' and 'content'
|
||||||
:return: Estimated token count
|
:return: Estimated token count
|
||||||
"""
|
"""
|
||||||
content = message.get('content', '')
|
content = message.get('content', '')
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return max(1, len(content) // 4)
|
return max(1, self._estimate_text_tokens(content))
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
# Handle multi-part content (text + images)
|
total_tokens = 0
|
||||||
total_chars = 0
|
|
||||||
for part in content:
|
for part in content:
|
||||||
if isinstance(part, dict) and part.get('type') == 'text':
|
if not isinstance(part, dict):
|
||||||
total_chars += len(part.get('text', ''))
|
continue
|
||||||
elif isinstance(part, dict) and part.get('type') == 'image':
|
block_type = part.get('type', '')
|
||||||
# Estimate images as ~1200 tokens
|
if block_type == 'text':
|
||||||
total_chars += 4800
|
total_tokens += self._estimate_text_tokens(part.get('text', ''))
|
||||||
return max(1, total_chars // 4)
|
elif block_type == 'image':
|
||||||
|
total_tokens += 1200
|
||||||
|
elif block_type == 'tool_use':
|
||||||
|
# tool_use has id + name + input (JSON-encoded)
|
||||||
|
total_tokens += 50 # overhead for structure
|
||||||
|
input_data = part.get('input', {})
|
||||||
|
if isinstance(input_data, dict):
|
||||||
|
import json
|
||||||
|
input_str = json.dumps(input_data, ensure_ascii=False)
|
||||||
|
total_tokens += self._estimate_text_tokens(input_str)
|
||||||
|
elif block_type == 'tool_result':
|
||||||
|
# tool_result has tool_use_id + content
|
||||||
|
total_tokens += 30 # overhead for structure
|
||||||
|
result_content = part.get('content', '')
|
||||||
|
if isinstance(result_content, str):
|
||||||
|
total_tokens += self._estimate_text_tokens(result_content)
|
||||||
|
else:
|
||||||
|
# Unknown block type, estimate conservatively
|
||||||
|
total_tokens += 10
|
||||||
|
return max(1, total_tokens)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_text_tokens(text: str) -> int:
|
||||||
|
"""
|
||||||
|
Estimate token count for a text string.
|
||||||
|
|
||||||
|
Chinese / CJK characters typically use ~1.5 tokens each,
|
||||||
|
while ASCII uses ~0.25 tokens per char (4 chars/token).
|
||||||
|
We use a weighted average based on the character mix.
|
||||||
|
|
||||||
|
:param text: Input text
|
||||||
|
:return: Estimated token count
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
# Count non-ASCII characters (CJK, emoji, etc.)
|
||||||
|
non_ascii = sum(1 for c in text if ord(c) > 127)
|
||||||
|
ascii_count = len(text) - non_ascii
|
||||||
|
# CJK chars: ~1.5 tokens each; ASCII: ~0.25 tokens per char
|
||||||
|
return int(non_ascii * 1.5 + ascii_count * 0.25) + 1
|
||||||
|
|
||||||
def _find_tool(self, tool_name: str):
|
def _find_tool(self, tool_name: str):
|
||||||
"""Find and return a tool with the specified name"""
|
"""Find and return a tool with the specified name"""
|
||||||
for tool in self.tools:
|
for tool in self.tools:
|
||||||
@@ -370,7 +495,17 @@ class Agent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
response = executor.run_stream(user_message)
|
try:
|
||||||
|
response = executor.run_stream(user_message)
|
||||||
|
except Exception:
|
||||||
|
# If executor cleared its messages (context overflow / message format error),
|
||||||
|
# sync that back to the Agent's own message list so the next request
|
||||||
|
# starts fresh instead of hitting the same overflow forever.
|
||||||
|
if len(executor.messages) == 0:
|
||||||
|
with self.messages_lock:
|
||||||
|
self.messages.clear()
|
||||||
|
logger.info("[Agent] Cleared Agent message history after executor recovery")
|
||||||
|
raise
|
||||||
|
|
||||||
# Append only the NEW messages from this execution (thread-safe)
|
# Append only the NEW messages from this execution (thread-safe)
|
||||||
# This allows concurrent requests to both contribute to history
|
# This allows concurrent requests to both contribute to history
|
||||||
|
|||||||
@@ -77,6 +77,20 @@ class AgentStreamExecutor:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Event callback error: {e}")
|
logger.error(f"Event callback error: {e}")
|
||||||
|
|
||||||
|
def _filter_think_tags(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Remove <think> and </think> tags but keep the content inside.
|
||||||
|
Some LLM providers (e.g., MiniMax) may return thinking process wrapped in <think> tags.
|
||||||
|
We only remove the tags themselves, keeping the actual thinking content.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
import re
|
||||||
|
# Remove only the <think> and </think> tags, keep the content
|
||||||
|
text = re.sub(r'<think>', '', text)
|
||||||
|
text = re.sub(r'</think>', '', text)
|
||||||
|
return text
|
||||||
|
|
||||||
def _hash_args(self, args: dict) -> str:
|
def _hash_args(self, args: dict) -> str:
|
||||||
"""Generate a simple hash for tool arguments"""
|
"""Generate a simple hash for tool arguments"""
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -184,7 +198,7 @@ class AgentStreamExecutor:
|
|||||||
try:
|
try:
|
||||||
while turn < self.max_turns:
|
while turn < self.max_turns:
|
||||||
turn += 1
|
turn += 1
|
||||||
logger.debug(f"第 {turn} 轮")
|
logger.info(f"[Agent] 第 {turn} 轮")
|
||||||
self._emit_event("turn_start", {"turn": turn})
|
self._emit_event("turn_start", {"turn": turn})
|
||||||
|
|
||||||
# Check if memory flush is needed (before calling LLM)
|
# Check if memory flush is needed (before calling LLM)
|
||||||
@@ -336,6 +350,15 @@ class AgentStreamExecutor:
|
|||||||
# Fallback to full JSON
|
# Fallback to full JSON
|
||||||
result_content = json.dumps(result, ensure_ascii=False)
|
result_content = json.dumps(result, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Truncate excessively large tool results for the current turn
|
||||||
|
# Historical turns will be further truncated in _trim_messages()
|
||||||
|
MAX_CURRENT_TURN_RESULT_CHARS = 50000
|
||||||
|
if len(result_content) > MAX_CURRENT_TURN_RESULT_CHARS:
|
||||||
|
truncated_len = len(result_content)
|
||||||
|
result_content = result_content[:MAX_CURRENT_TURN_RESULT_CHARS] + \
|
||||||
|
f"\n\n[Output truncated: {truncated_len} chars total, showing first {MAX_CURRENT_TURN_RESULT_CHARS} chars]"
|
||||||
|
logger.info(f"📎 Truncated tool result for '{tool_call['name']}': {truncated_len} -> {MAX_CURRENT_TURN_RESULT_CHARS} chars")
|
||||||
|
|
||||||
tool_result_block = {
|
tool_result_block = {
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": tool_call["id"],
|
"tool_use_id": tool_call["id"],
|
||||||
@@ -447,7 +470,7 @@ class AgentStreamExecutor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
logger.debug(f"🏁 完成({turn}轮)")
|
logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
|
||||||
self._emit_event("agent_end", {"final_response": final_response})
|
self._emit_event("agent_end", {"final_response": final_response})
|
||||||
|
|
||||||
# 每轮对话结束后增加计数(用户消息+AI回复=1轮)
|
# 每轮对话结束后增加计数(用户消息+AI回复=1轮)
|
||||||
@@ -456,7 +479,8 @@ class AgentStreamExecutor:
|
|||||||
|
|
||||||
return final_response
|
return final_response
|
||||||
|
|
||||||
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3) -> Tuple[str, List[Dict]]:
|
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
|
||||||
|
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
|
||||||
"""
|
"""
|
||||||
Call LLM with streaming and automatic retry on errors
|
Call LLM with streaming and automatic retry on errors
|
||||||
|
|
||||||
@@ -464,6 +488,7 @@ class AgentStreamExecutor:
|
|||||||
retry_on_empty: Whether to retry once if empty response is received
|
retry_on_empty: Whether to retry once if empty response is received
|
||||||
retry_count: Current retry attempt (internal use)
|
retry_count: Current retry attempt (internal use)
|
||||||
max_retries: Maximum number of retries for API errors
|
max_retries: Maximum number of retries for API errors
|
||||||
|
_overflow_retry: Internal flag indicating this is a retry after context overflow
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(response_text, tool_calls)
|
(response_text, tool_calls)
|
||||||
@@ -558,11 +583,19 @@ class AgentStreamExecutor:
|
|||||||
if finish_reason:
|
if finish_reason:
|
||||||
stop_reason = finish_reason
|
stop_reason = finish_reason
|
||||||
|
|
||||||
|
# Skip reasoning_content (internal thinking from models like GLM-5)
|
||||||
|
reasoning_delta = delta.get("reasoning_content") or ""
|
||||||
|
# if reasoning_delta:
|
||||||
|
# logger.debug(f"🧠 [thinking] {reasoning_delta[:100]}...")
|
||||||
|
|
||||||
# Handle text content
|
# Handle text content
|
||||||
content_delta = delta.get("content") or ""
|
content_delta = delta.get("content") or ""
|
||||||
if content_delta:
|
if content_delta:
|
||||||
full_content += content_delta
|
# Filter out <think> tags from content
|
||||||
self._emit_event("message_update", {"delta": content_delta})
|
filtered_delta = self._filter_think_tags(content_delta)
|
||||||
|
full_content += filtered_delta
|
||||||
|
if filtered_delta: # Only emit if there's content after filtering
|
||||||
|
self._emit_event("message_update", {"delta": filtered_delta})
|
||||||
|
|
||||||
# Handle tool calls
|
# Handle tool calls
|
||||||
if "tool_calls" in delta and delta["tool_calls"]:
|
if "tool_calls" in delta and delta["tool_calls"]:
|
||||||
@@ -612,10 +645,23 @@ class AgentStreamExecutor:
|
|||||||
if is_context_overflow or is_message_format_error:
|
if is_context_overflow or is_message_format_error:
|
||||||
error_type = "context overflow" if is_context_overflow else "message format error"
|
error_type = "context overflow" if is_context_overflow else "message format error"
|
||||||
logger.error(f"💥 {error_type} detected: {e}")
|
logger.error(f"💥 {error_type} detected: {e}")
|
||||||
# Clear message history to recover
|
|
||||||
|
# Strategy: try aggressive trimming first, only clear as last resort
|
||||||
|
if is_context_overflow and not _overflow_retry:
|
||||||
|
trimmed = self._aggressive_trim_for_overflow()
|
||||||
|
if trimmed:
|
||||||
|
logger.warning("🔄 Aggressively trimmed context, retrying...")
|
||||||
|
return self._call_llm_stream(
|
||||||
|
retry_on_empty=retry_on_empty,
|
||||||
|
retry_count=retry_count,
|
||||||
|
max_retries=max_retries,
|
||||||
|
_overflow_retry=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggressive trim didn't help or this is a message format error
|
||||||
|
# -> clear everything
|
||||||
logger.warning("🔄 Clearing conversation history to recover")
|
logger.warning("🔄 Clearing conversation history to recover")
|
||||||
self.messages.clear()
|
self.messages.clear()
|
||||||
# Raise special exception with user-friendly message
|
|
||||||
if is_context_overflow:
|
if is_context_overflow:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
"抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。"
|
"抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。"
|
||||||
@@ -661,6 +707,13 @@ class AgentStreamExecutor:
|
|||||||
tool_calls = []
|
tool_calls = []
|
||||||
for idx in sorted(tool_calls_buffer.keys()):
|
for idx in sorted(tool_calls_buffer.keys()):
|
||||||
tc = tool_calls_buffer[idx]
|
tc = tool_calls_buffer[idx]
|
||||||
|
|
||||||
|
# Ensure tool call has a valid ID (some providers return empty/None IDs)
|
||||||
|
tool_id = tc.get("id") or ""
|
||||||
|
if not tool_id:
|
||||||
|
import uuid
|
||||||
|
tool_id = f"call_{uuid.uuid4().hex[:24]}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Safely get arguments, handle None case
|
# Safely get arguments, handle None case
|
||||||
args_str = tc.get("arguments") or ""
|
args_str = tc.get("arguments") or ""
|
||||||
@@ -677,7 +730,7 @@ class AgentStreamExecutor:
|
|||||||
# Return a clear error message to the LLM instead of empty dict
|
# Return a clear error message to the LLM instead of empty dict
|
||||||
# This helps the LLM understand what went wrong
|
# This helps the LLM understand what went wrong
|
||||||
tool_calls.append({
|
tool_calls.append({
|
||||||
"id": tc["id"],
|
"id": tool_id,
|
||||||
"name": tc["name"],
|
"name": tc["name"],
|
||||||
"arguments": {},
|
"arguments": {},
|
||||||
"_parse_error": f"Invalid JSON in tool arguments: {args_preview}... Error: {str(e)}. Tip: For large content, consider splitting into smaller chunks or using a different approach."
|
"_parse_error": f"Invalid JSON in tool arguments: {args_preview}... Error: {str(e)}. Tip: For large content, consider splitting into smaller chunks or using a different approach."
|
||||||
@@ -685,7 +738,7 @@ class AgentStreamExecutor:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
tool_calls.append({
|
tool_calls.append({
|
||||||
"id": tc["id"],
|
"id": tool_id,
|
||||||
"name": tc["name"],
|
"name": tc["name"],
|
||||||
"arguments": arguments
|
"arguments": arguments
|
||||||
})
|
})
|
||||||
@@ -706,6 +759,9 @@ class AgentStreamExecutor:
|
|||||||
max_retries=max_retries
|
max_retries=max_retries
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter full_content one more time (in case tags were split across chunks)
|
||||||
|
full_content = self._filter_think_tags(full_content)
|
||||||
|
|
||||||
# Add assistant message to history (Claude format uses content blocks)
|
# Add assistant message to history (Claude format uses content blocks)
|
||||||
assistant_msg = {"role": "assistant", "content": []}
|
assistant_msg = {"role": "assistant", "content": []}
|
||||||
|
|
||||||
@@ -926,6 +982,160 @@ class AgentStreamExecutor:
|
|||||||
for msg in turn['messages']
|
for msg in turn['messages']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _truncate_historical_tool_results(self):
|
||||||
|
"""
|
||||||
|
Truncate tool_result content in historical messages to reduce context size.
|
||||||
|
|
||||||
|
Current turn results are kept at 30K chars (truncated at creation time).
|
||||||
|
Historical turn results are further truncated to 10K chars here.
|
||||||
|
This runs before token-based trimming so that we first shrink oversized
|
||||||
|
results, potentially avoiding the need to drop entire turns.
|
||||||
|
"""
|
||||||
|
MAX_HISTORY_RESULT_CHARS = 20000
|
||||||
|
|
||||||
|
if len(self.messages) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find where the last user text message starts (= current turn boundary)
|
||||||
|
# We skip the current turn's messages to preserve their full content
|
||||||
|
current_turn_start = len(self.messages)
|
||||||
|
for i in range(len(self.messages) - 1, -1, -1):
|
||||||
|
msg = self.messages[i]
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if isinstance(content, list) and any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "text" for b in content
|
||||||
|
):
|
||||||
|
current_turn_start = i
|
||||||
|
break
|
||||||
|
elif isinstance(content, str):
|
||||||
|
current_turn_start = i
|
||||||
|
break
|
||||||
|
|
||||||
|
truncated_count = 0
|
||||||
|
for i in range(current_turn_start):
|
||||||
|
msg = self.messages[i]
|
||||||
|
if msg.get("role") != "user":
|
||||||
|
continue
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
||||||
|
continue
|
||||||
|
result_str = block.get("content", "")
|
||||||
|
if isinstance(result_str, str) and len(result_str) > MAX_HISTORY_RESULT_CHARS:
|
||||||
|
original_len = len(result_str)
|
||||||
|
block["content"] = result_str[:MAX_HISTORY_RESULT_CHARS] + \
|
||||||
|
f"\n\n[Historical output truncated: {original_len} -> {MAX_HISTORY_RESULT_CHARS} chars]"
|
||||||
|
truncated_count += 1
|
||||||
|
|
||||||
|
if truncated_count > 0:
|
||||||
|
logger.info(f"📎 Truncated {truncated_count} historical tool result(s) to {MAX_HISTORY_RESULT_CHARS} chars")
|
||||||
|
|
||||||
|
def _aggressive_trim_for_overflow(self) -> bool:
|
||||||
|
"""
|
||||||
|
Aggressively trim context when a real overflow error is returned by the API.
|
||||||
|
|
||||||
|
This method goes beyond normal _trim_messages by:
|
||||||
|
1. Truncating all tool results (including current turn) to a small limit
|
||||||
|
2. Keeping only the last 5 complete conversation turns
|
||||||
|
3. Truncating overly long user messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if messages were trimmed (worth retrying), False if nothing left to trim
|
||||||
|
"""
|
||||||
|
if not self.messages:
|
||||||
|
return False
|
||||||
|
|
||||||
|
original_count = len(self.messages)
|
||||||
|
|
||||||
|
# Step 1: Aggressively truncate ALL tool results to 5K chars
|
||||||
|
AGGRESSIVE_LIMIT = 10000
|
||||||
|
truncated = 0
|
||||||
|
for msg in self.messages:
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
# Truncate tool_result blocks
|
||||||
|
if block.get("type") == "tool_result":
|
||||||
|
result_str = block.get("content", "")
|
||||||
|
if isinstance(result_str, str) and len(result_str) > AGGRESSIVE_LIMIT:
|
||||||
|
block["content"] = (
|
||||||
|
result_str[:AGGRESSIVE_LIMIT]
|
||||||
|
+ f"\n\n[Truncated for context recovery: "
|
||||||
|
f"{len(result_str)} -> {AGGRESSIVE_LIMIT} chars]"
|
||||||
|
)
|
||||||
|
truncated += 1
|
||||||
|
# Truncate tool_use input blocks (e.g. large write content)
|
||||||
|
if block.get("type") == "tool_use" and isinstance(block.get("input"), dict):
|
||||||
|
input_str = json.dumps(block["input"], ensure_ascii=False)
|
||||||
|
if len(input_str) > AGGRESSIVE_LIMIT:
|
||||||
|
# Keep only a summary of the input
|
||||||
|
for key, val in block["input"].items():
|
||||||
|
if isinstance(val, str) and len(val) > 1000:
|
||||||
|
block["input"][key] = (
|
||||||
|
val[:1000]
|
||||||
|
+ f"... [truncated {len(val)} chars]"
|
||||||
|
)
|
||||||
|
truncated += 1
|
||||||
|
|
||||||
|
# Step 2: Truncate overly long user text messages (e.g. pasted content)
|
||||||
|
USER_MSG_LIMIT = 10000
|
||||||
|
for msg in self.messages:
|
||||||
|
if msg.get("role") != "user":
|
||||||
|
continue
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
text = block.get("text", "")
|
||||||
|
if len(text) > USER_MSG_LIMIT:
|
||||||
|
block["text"] = (
|
||||||
|
text[:USER_MSG_LIMIT]
|
||||||
|
+ f"\n\n[Message truncated for context recovery: "
|
||||||
|
f"{len(text)} -> {USER_MSG_LIMIT} chars]"
|
||||||
|
)
|
||||||
|
truncated += 1
|
||||||
|
elif isinstance(content, str) and len(content) > USER_MSG_LIMIT:
|
||||||
|
msg["content"] = (
|
||||||
|
content[:USER_MSG_LIMIT]
|
||||||
|
+ f"\n\n[Message truncated for context recovery: "
|
||||||
|
f"{len(content)} -> {USER_MSG_LIMIT} chars]"
|
||||||
|
)
|
||||||
|
truncated += 1
|
||||||
|
|
||||||
|
# Step 3: Keep only the last 5 complete turns
|
||||||
|
turns = self._identify_complete_turns()
|
||||||
|
if len(turns) > 5:
|
||||||
|
kept_turns = turns[-5:]
|
||||||
|
new_messages = []
|
||||||
|
for turn in kept_turns:
|
||||||
|
new_messages.extend(turn["messages"])
|
||||||
|
removed = len(turns) - 5
|
||||||
|
self.messages[:] = new_messages
|
||||||
|
logger.info(
|
||||||
|
f"🔧 Aggressive trim: removed {removed} old turns, "
|
||||||
|
f"truncated {truncated} large blocks, "
|
||||||
|
f"{original_count} -> {len(self.messages)} messages"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if truncated > 0:
|
||||||
|
logger.info(
|
||||||
|
f"🔧 Aggressive trim: truncated {truncated} large blocks "
|
||||||
|
f"(no turns removed, only {len(turns)} turn(s) left)"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Nothing left to trim
|
||||||
|
logger.warning("🔧 Aggressive trim: nothing to trim, will clear history")
|
||||||
|
return False
|
||||||
|
|
||||||
def _trim_messages(self):
|
def _trim_messages(self):
|
||||||
"""
|
"""
|
||||||
智能清理消息历史,保持对话完整性
|
智能清理消息历史,保持对话完整性
|
||||||
@@ -938,6 +1148,9 @@ class AgentStreamExecutor:
|
|||||||
if not self.messages or not self.agent:
|
if not self.messages or not self.agent:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Step 0: Truncate large tool results in historical turns (30K -> 10K)
|
||||||
|
self._truncate_historical_tool_results()
|
||||||
|
|
||||||
# Step 1: 识别完整轮次
|
# Step 1: 识别完整轮次
|
||||||
turns = self._identify_complete_turns()
|
turns = self._identify_complete_turns()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from agent.skills.types import (
|
|||||||
)
|
)
|
||||||
from agent.skills.loader import SkillLoader
|
from agent.skills.loader import SkillLoader
|
||||||
from agent.skills.manager import SkillManager
|
from agent.skills.manager import SkillManager
|
||||||
|
from agent.skills.service import SkillService
|
||||||
from agent.skills.formatter import format_skills_for_prompt
|
from agent.skills.formatter import format_skills_for_prompt
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -25,5 +26,6 @@ __all__ = [
|
|||||||
"LoadSkillsResult",
|
"LoadSkillsResult",
|
||||||
"SkillLoader",
|
"SkillLoader",
|
||||||
"SkillManager",
|
"SkillManager",
|
||||||
|
"SkillService",
|
||||||
"format_skills_for_prompt",
|
"format_skills_for_prompt",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ def format_skills_for_prompt(skills: List[Skill]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"\n\nThe following skills provide specialized instructions for specific tasks.",
|
|
||||||
"Use the read tool to load a skill's file when the task matches its description.",
|
|
||||||
"",
|
"",
|
||||||
"<available_skills>",
|
"<available_skills>",
|
||||||
]
|
]
|
||||||
@@ -34,7 +32,6 @@ def format_skills_for_prompt(skills: List[Skill]) -> str:
|
|||||||
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
|
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
|
||||||
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
|
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
|
||||||
lines.append(f" <location>{_escape_xml(skill.file_path)}</location>")
|
lines.append(f" <location>{_escape_xml(skill.file_path)}</location>")
|
||||||
lines.append(f" <base_dir>{_escape_xml(skill.base_dir)}</base_dir>")
|
|
||||||
lines.append(" </skill>")
|
lines.append(" </skill>")
|
||||||
|
|
||||||
lines.append("</available_skills>")
|
lines.append("</available_skills>")
|
||||||
|
|||||||
@@ -13,13 +13,8 @@ from agent.skills.frontmatter import parse_frontmatter, parse_metadata, parse_bo
|
|||||||
class SkillLoader:
|
class SkillLoader:
|
||||||
"""Loads skills from various directories."""
|
"""Loads skills from various directories."""
|
||||||
|
|
||||||
def __init__(self, workspace_dir: Optional[str] = None):
|
def __init__(self):
|
||||||
"""
|
pass
|
||||||
Initialize the skill loader.
|
|
||||||
|
|
||||||
:param workspace_dir: Agent workspace directory (for workspace-specific skills)
|
|
||||||
"""
|
|
||||||
self.workspace_dir = workspace_dir
|
|
||||||
|
|
||||||
def load_skills_from_dir(self, dir_path: str, source: str) -> LoadSkillsResult:
|
def load_skills_from_dir(self, dir_path: str, source: str) -> LoadSkillsResult:
|
||||||
"""
|
"""
|
||||||
@@ -30,7 +25,7 @@ class SkillLoader:
|
|||||||
- Recursive SKILL.md files under subdirectories
|
- Recursive SKILL.md files under subdirectories
|
||||||
|
|
||||||
:param dir_path: Directory path to scan
|
:param dir_path: Directory path to scan
|
||||||
:param source: Source identifier (e.g., 'managed', 'workspace', 'bundled')
|
:param source: Source identifier ('builtin' or 'custom')
|
||||||
:return: LoadSkillsResult with skills and diagnostics
|
:return: LoadSkillsResult with skills and diagnostics
|
||||||
"""
|
"""
|
||||||
skills = []
|
skills = []
|
||||||
@@ -188,16 +183,14 @@ class SkillLoader:
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
config_path = os.path.join(skill_dir, "config.json")
|
config_path = os.path.join(skill_dir, "config.json")
|
||||||
template_path = os.path.join(skill_dir, "config.json.template")
|
|
||||||
|
|
||||||
# Try to load config.json or fallback to template
|
# Without config.json, skip this skill entirely (return empty to trigger exclusion)
|
||||||
config_file = config_path if os.path.exists(config_path) else template_path
|
if not os.path.exists(config_path):
|
||||||
|
logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found")
|
||||||
if not os.path.exists(config_file):
|
return ""
|
||||||
return default_description
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'r', encoding='utf-8') as f:
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
|
|
||||||
apps = config.get("apps", [])
|
apps = config.get("apps", [])
|
||||||
@@ -218,48 +211,36 @@ class SkillLoader:
|
|||||||
|
|
||||||
def load_all_skills(
|
def load_all_skills(
|
||||||
self,
|
self,
|
||||||
managed_dir: Optional[str] = None,
|
builtin_dir: Optional[str] = None,
|
||||||
workspace_skills_dir: Optional[str] = None,
|
custom_dir: Optional[str] = None,
|
||||||
extra_dirs: Optional[List[str]] = None,
|
|
||||||
) -> Dict[str, SkillEntry]:
|
) -> Dict[str, SkillEntry]:
|
||||||
"""
|
"""
|
||||||
Load skills from all configured locations with precedence.
|
Load skills from builtin and custom directories.
|
||||||
|
|
||||||
Precedence (lowest to highest):
|
Precedence (lowest to highest):
|
||||||
1. Extra directories
|
1. builtin — project root ``skills/``, shipped with the codebase
|
||||||
2. Managed skills directory
|
2. custom — workspace ``skills/``, installed via cloud console or skill creator
|
||||||
3. Workspace skills directory
|
|
||||||
|
|
||||||
:param managed_dir: Managed skills directory (e.g., ~/.cow/skills)
|
Same-name custom skills override builtin ones.
|
||||||
:param workspace_skills_dir: Workspace skills directory (e.g., workspace/skills)
|
|
||||||
:param extra_dirs: Additional directories to load skills from
|
:param builtin_dir: Built-in skills directory
|
||||||
|
:param custom_dir: Custom skills directory
|
||||||
:return: Dictionary mapping skill name to SkillEntry
|
:return: Dictionary mapping skill name to SkillEntry
|
||||||
"""
|
"""
|
||||||
skill_map: Dict[str, SkillEntry] = {}
|
skill_map: Dict[str, SkillEntry] = {}
|
||||||
all_diagnostics = []
|
all_diagnostics = []
|
||||||
|
|
||||||
# Load from extra directories (lowest precedence)
|
# Load builtin skills (lower precedence)
|
||||||
if extra_dirs:
|
if builtin_dir and os.path.exists(builtin_dir):
|
||||||
for extra_dir in extra_dirs:
|
result = self.load_skills_from_dir(builtin_dir, source='builtin')
|
||||||
if not os.path.exists(extra_dir):
|
|
||||||
continue
|
|
||||||
result = self.load_skills_from_dir(extra_dir, source='extra')
|
|
||||||
all_diagnostics.extend(result.diagnostics)
|
|
||||||
for skill in result.skills:
|
|
||||||
entry = self._create_skill_entry(skill)
|
|
||||||
skill_map[skill.name] = entry
|
|
||||||
|
|
||||||
# Load from managed directory
|
|
||||||
if managed_dir and os.path.exists(managed_dir):
|
|
||||||
result = self.load_skills_from_dir(managed_dir, source='managed')
|
|
||||||
all_diagnostics.extend(result.diagnostics)
|
all_diagnostics.extend(result.diagnostics)
|
||||||
for skill in result.skills:
|
for skill in result.skills:
|
||||||
entry = self._create_skill_entry(skill)
|
entry = self._create_skill_entry(skill)
|
||||||
skill_map[skill.name] = entry
|
skill_map[skill.name] = entry
|
||||||
|
|
||||||
# Load from workspace directory (highest precedence)
|
# Load custom skills (higher precedence, overrides builtin)
|
||||||
if workspace_skills_dir and os.path.exists(workspace_skills_dir):
|
if custom_dir and os.path.exists(custom_dir):
|
||||||
result = self.load_skills_from_dir(workspace_skills_dir, source='workspace')
|
result = self.load_skills_from_dir(custom_dir, source='custom')
|
||||||
all_diagnostics.extend(result.diagnostics)
|
all_diagnostics.extend(result.diagnostics)
|
||||||
for skill in result.skills:
|
for skill in result.skills:
|
||||||
entry = self._create_skill_entry(skill)
|
entry = self._create_skill_entry(skill)
|
||||||
@@ -268,10 +249,10 @@ class SkillLoader:
|
|||||||
# Log diagnostics
|
# Log diagnostics
|
||||||
if all_diagnostics:
|
if all_diagnostics:
|
||||||
logger.debug(f"Skill loading diagnostics: {len(all_diagnostics)} issues")
|
logger.debug(f"Skill loading diagnostics: {len(all_diagnostics)} issues")
|
||||||
for diag in all_diagnostics[:5]: # Log first 5
|
for diag in all_diagnostics[:5]:
|
||||||
logger.debug(f" - {diag}")
|
logger.debug(f" - {diag}")
|
||||||
|
|
||||||
logger.debug(f"Loaded {len(skill_map)} skills from all sources")
|
logger.debug(f"Loaded {len(skill_map)} skills total")
|
||||||
|
|
||||||
return skill_map
|
return skill_map
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Skill manager for managing skill lifecycle and operations.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -10,57 +11,132 @@ from agent.skills.types import Skill, SkillEntry, SkillSnapshot
|
|||||||
from agent.skills.loader import SkillLoader
|
from agent.skills.loader import SkillLoader
|
||||||
from agent.skills.formatter import format_skill_entries_for_prompt
|
from agent.skills.formatter import format_skill_entries_for_prompt
|
||||||
|
|
||||||
|
SKILLS_CONFIG_FILE = "skills_config.json"
|
||||||
|
|
||||||
|
|
||||||
class SkillManager:
|
class SkillManager:
|
||||||
"""Manages skills for an agent."""
|
"""Manages skills for an agent."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
workspace_dir: Optional[str] = None,
|
builtin_dir: Optional[str] = None,
|
||||||
managed_skills_dir: Optional[str] = None,
|
custom_dir: Optional[str] = None,
|
||||||
extra_dirs: Optional[List[str]] = None,
|
|
||||||
config: Optional[Dict] = None,
|
config: Optional[Dict] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the skill manager.
|
Initialize the skill manager.
|
||||||
|
|
||||||
:param workspace_dir: Agent workspace directory
|
:param builtin_dir: Built-in skills directory (project root ``skills/``)
|
||||||
:param managed_skills_dir: Managed skills directory (e.g., ~/.cow/skills)
|
:param custom_dir: Custom skills directory (workspace ``skills/``)
|
||||||
:param extra_dirs: Additional skill directories
|
|
||||||
:param config: Configuration dictionary
|
:param config: Configuration dictionary
|
||||||
"""
|
"""
|
||||||
self.workspace_dir = workspace_dir
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
self.managed_skills_dir = managed_skills_dir or self._get_default_managed_dir()
|
self.builtin_dir = builtin_dir or os.path.join(project_root, 'skills')
|
||||||
self.extra_dirs = extra_dirs or []
|
self.custom_dir = custom_dir or os.path.join(project_root, 'workspace', 'skills')
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
|
self._skills_config_path = os.path.join(self.custom_dir, SKILLS_CONFIG_FILE)
|
||||||
|
|
||||||
self.loader = SkillLoader(workspace_dir=workspace_dir)
|
# skills_config: full skill metadata keyed by name
|
||||||
|
# { "web-fetch": {"name": ..., "description": ..., "source": ..., "enabled": true}, ... }
|
||||||
|
self.skills_config: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
self.loader = SkillLoader()
|
||||||
self.skills: Dict[str, SkillEntry] = {}
|
self.skills: Dict[str, SkillEntry] = {}
|
||||||
|
|
||||||
# Load skills on initialization
|
# Load skills on initialization
|
||||||
self.refresh_skills()
|
self.refresh_skills()
|
||||||
|
|
||||||
def _get_default_managed_dir(self) -> str:
|
|
||||||
"""Get the default managed skills directory."""
|
|
||||||
# Use project root skills directory as default
|
|
||||||
import os
|
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
return os.path.join(project_root, 'skills')
|
|
||||||
|
|
||||||
def refresh_skills(self):
|
def refresh_skills(self):
|
||||||
"""Reload all skills from configured directories."""
|
"""Reload all skills from builtin and custom directories, then sync config."""
|
||||||
workspace_skills_dir = None
|
|
||||||
if self.workspace_dir:
|
|
||||||
workspace_skills_dir = os.path.join(self.workspace_dir, 'skills')
|
|
||||||
|
|
||||||
self.skills = self.loader.load_all_skills(
|
self.skills = self.loader.load_all_skills(
|
||||||
managed_dir=self.managed_skills_dir,
|
builtin_dir=self.builtin_dir,
|
||||||
workspace_skills_dir=workspace_skills_dir,
|
custom_dir=self.custom_dir,
|
||||||
extra_dirs=self.extra_dirs,
|
|
||||||
)
|
)
|
||||||
|
self._sync_skills_config()
|
||||||
logger.debug(f"SkillManager: Loaded {len(self.skills)} skills")
|
logger.debug(f"SkillManager: Loaded {len(self.skills)} skills")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# skills_config.json management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _load_skills_config(self) -> Dict[str, dict]:
|
||||||
|
"""Load skills_config.json from custom_dir. Returns empty dict if not found."""
|
||||||
|
if not os.path.exists(self._skills_config_path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(self._skills_config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[SkillManager] Failed to load {SKILLS_CONFIG_FILE}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_skills_config(self):
|
||||||
|
"""Persist skills_config to custom_dir/skills_config.json."""
|
||||||
|
os.makedirs(self.custom_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(self._skills_config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.skills_config, f, indent=4, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SkillManager] Failed to save {SKILLS_CONFIG_FILE}: {e}")
|
||||||
|
|
||||||
|
def _sync_skills_config(self):
|
||||||
|
"""
|
||||||
|
Merge directory-scanned skills with the persisted config file.
|
||||||
|
|
||||||
|
- New skills discovered on disk are added with enabled=True.
|
||||||
|
- Skills that no longer exist on disk are removed.
|
||||||
|
- Existing entries preserve their enabled state; name/description/source
|
||||||
|
are refreshed from the latest scan.
|
||||||
|
"""
|
||||||
|
saved = self._load_skills_config()
|
||||||
|
merged: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
for name, entry in self.skills.items():
|
||||||
|
skill = entry.skill
|
||||||
|
prev = saved.get(name, {})
|
||||||
|
merged[name] = {
|
||||||
|
"name": name,
|
||||||
|
"description": skill.description,
|
||||||
|
"source": skill.source,
|
||||||
|
"enabled": prev.get("enabled", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.skills_config = merged
|
||||||
|
self._save_skills_config()
|
||||||
|
|
||||||
|
def is_skill_enabled(self, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a skill is enabled according to skills_config.
|
||||||
|
|
||||||
|
:param name: skill name
|
||||||
|
:return: True if enabled (default True if not in config)
|
||||||
|
"""
|
||||||
|
entry = self.skills_config.get(name)
|
||||||
|
if entry is None:
|
||||||
|
return True
|
||||||
|
return entry.get("enabled", True)
|
||||||
|
|
||||||
|
def set_skill_enabled(self, name: str, enabled: bool):
|
||||||
|
"""
|
||||||
|
Set a skill's enabled state and persist.
|
||||||
|
|
||||||
|
:param name: skill name
|
||||||
|
:param enabled: True to enable, False to disable
|
||||||
|
"""
|
||||||
|
if name not in self.skills_config:
|
||||||
|
raise ValueError(f"skill '{name}' not found in config")
|
||||||
|
self.skills_config[name]["enabled"] = enabled
|
||||||
|
self._save_skills_config()
|
||||||
|
|
||||||
|
def get_skills_config(self) -> Dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Return the full skills_config dict (for query API).
|
||||||
|
|
||||||
|
:return: copy of skills_config
|
||||||
|
"""
|
||||||
|
return dict(self.skills_config)
|
||||||
|
|
||||||
def get_skill(self, name: str) -> Optional[SkillEntry]:
|
def get_skill(self, name: str) -> Optional[SkillEntry]:
|
||||||
"""
|
"""
|
||||||
Get a skill by name.
|
Get a skill by name.
|
||||||
@@ -87,11 +163,11 @@ class SkillManager:
|
|||||||
Filter skills based on criteria.
|
Filter skills based on criteria.
|
||||||
|
|
||||||
Simple rule: Skills are auto-enabled if requirements are met.
|
Simple rule: Skills are auto-enabled if requirements are met.
|
||||||
- Has required API keys → included
|
- Has required API keys -> included
|
||||||
- Missing API keys → excluded
|
- Missing API keys -> excluded
|
||||||
|
|
||||||
:param skill_filter: List of skill names to include (None = all)
|
:param skill_filter: List of skill names to include (None = all)
|
||||||
:param include_disabled: Whether to include skills with disable_model_invocation=True
|
:param include_disabled: Whether to include disabled skills
|
||||||
:return: Filtered list of skill entries
|
:return: Filtered list of skill entries
|
||||||
"""
|
"""
|
||||||
from agent.skills.config import should_include_skill
|
from agent.skills.config import should_include_skill
|
||||||
@@ -103,7 +179,6 @@ class SkillManager:
|
|||||||
|
|
||||||
# Apply skill filter
|
# Apply skill filter
|
||||||
if skill_filter is not None:
|
if skill_filter is not None:
|
||||||
# Flatten and normalize skill names (handle both strings and nested lists)
|
|
||||||
normalized = []
|
normalized = []
|
||||||
for item in skill_filter:
|
for item in skill_filter:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
@@ -111,19 +186,17 @@ class SkillManager:
|
|||||||
if name:
|
if name:
|
||||||
normalized.append(name)
|
normalized.append(name)
|
||||||
elif isinstance(item, list):
|
elif isinstance(item, list):
|
||||||
# Handle nested lists
|
|
||||||
for subitem in item:
|
for subitem in item:
|
||||||
if isinstance(subitem, str):
|
if isinstance(subitem, str):
|
||||||
name = subitem.strip()
|
name = subitem.strip()
|
||||||
if name:
|
if name:
|
||||||
normalized.append(name)
|
normalized.append(name)
|
||||||
|
|
||||||
if normalized:
|
if normalized:
|
||||||
entries = [e for e in entries if e.skill.name in normalized]
|
entries = [e for e in entries if e.skill.name in normalized]
|
||||||
|
|
||||||
# Filter out disabled skills unless explicitly requested
|
# Filter out disabled skills based on skills_config.json
|
||||||
if not include_disabled:
|
if not include_disabled:
|
||||||
entries = [e for e in entries if not e.skill.disable_model_invocation]
|
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|||||||
204
agent/skills/service.py
Normal file
204
agent/skills/service.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Skill service for handling skill CRUD operations.
|
||||||
|
|
||||||
|
This service provides a unified interface for managing skills, which can be
|
||||||
|
called from the cloud control client (LinkAI), the local web console, or any
|
||||||
|
other management entry point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from common.log import logger
|
||||||
|
from agent.skills.types import Skill, SkillEntry
|
||||||
|
from agent.skills.manager import SkillManager
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
requests = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillService:
|
||||||
|
"""
|
||||||
|
High-level service for skill lifecycle management.
|
||||||
|
Wraps SkillManager and provides network-aware operations such as
|
||||||
|
downloading skill files from remote URLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, skill_manager: SkillManager):
|
||||||
|
"""
|
||||||
|
:param skill_manager: The SkillManager instance to operate on
|
||||||
|
"""
|
||||||
|
self.manager = skill_manager
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# query
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def query(self) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Query all skills and return a serialisable list.
|
||||||
|
Reads from skills_config.json (refreshes from disk if needed).
|
||||||
|
|
||||||
|
:return: list of skill info dicts
|
||||||
|
"""
|
||||||
|
self.manager.refresh_skills()
|
||||||
|
config = self.manager.get_skills_config()
|
||||||
|
result = list(config.values())
|
||||||
|
logger.info(f"[SkillService] query: {len(result)} skills found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# add / install
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def add(self, payload: dict) -> None:
|
||||||
|
"""
|
||||||
|
Add (install) a skill from a remote payload.
|
||||||
|
|
||||||
|
The payload follows the socket protocol::
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "web_search",
|
||||||
|
"type": "url",
|
||||||
|
"enabled": true,
|
||||||
|
"files": [
|
||||||
|
{"url": "https://...", "path": "README.md"},
|
||||||
|
{"url": "https://...", "path": "scripts/main.py"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Files are downloaded and saved under the custom skills directory
|
||||||
|
using *name* as the sub-directory.
|
||||||
|
|
||||||
|
:param payload: skill add payload from server
|
||||||
|
"""
|
||||||
|
name = payload.get("name")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
|
||||||
|
files = payload.get("files", [])
|
||||||
|
if not files:
|
||||||
|
raise ValueError("skill files list is empty")
|
||||||
|
|
||||||
|
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||||
|
os.makedirs(skill_dir, exist_ok=True)
|
||||||
|
|
||||||
|
for file_info in files:
|
||||||
|
url = file_info.get("url")
|
||||||
|
rel_path = file_info.get("path")
|
||||||
|
if not url or not rel_path:
|
||||||
|
logger.warning(f"[SkillService] add: skip invalid file entry {file_info}")
|
||||||
|
continue
|
||||||
|
dest = os.path.join(skill_dir, rel_path)
|
||||||
|
self._download_file(url, dest)
|
||||||
|
|
||||||
|
# Reload to pick up the new skill and sync config
|
||||||
|
self.manager.refresh_skills()
|
||||||
|
logger.info(f"[SkillService] add: skill '{name}' installed ({len(files)} files)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# open / close (enable / disable)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def open(self, payload: dict) -> None:
|
||||||
|
"""
|
||||||
|
Enable a skill by name.
|
||||||
|
|
||||||
|
:param payload: {"name": "skill_name"}
|
||||||
|
"""
|
||||||
|
name = payload.get("name")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
self.manager.set_skill_enabled(name, enabled=True)
|
||||||
|
logger.info(f"[SkillService] open: skill '{name}' enabled")
|
||||||
|
|
||||||
|
def close(self, payload: dict) -> None:
|
||||||
|
"""
|
||||||
|
Disable a skill by name.
|
||||||
|
|
||||||
|
:param payload: {"name": "skill_name"}
|
||||||
|
"""
|
||||||
|
name = payload.get("name")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
self.manager.set_skill_enabled(name, enabled=False)
|
||||||
|
logger.info(f"[SkillService] close: skill '{name}' disabled")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# delete
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def delete(self, payload: dict) -> None:
|
||||||
|
"""
|
||||||
|
Delete a skill by removing its directory entirely.
|
||||||
|
|
||||||
|
:param payload: {"name": "skill_name"}
|
||||||
|
"""
|
||||||
|
name = payload.get("name")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
|
||||||
|
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||||
|
if os.path.exists(skill_dir):
|
||||||
|
shutil.rmtree(skill_dir)
|
||||||
|
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[SkillService] delete: skill directory not found: {skill_dir}")
|
||||||
|
|
||||||
|
# Refresh will remove the deleted skill from config automatically
|
||||||
|
self.manager.refresh_skills()
|
||||||
|
logger.info(f"[SkillService] delete: skill '{name}' deleted")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# dispatch - single entry point for protocol messages
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Dispatch a skill management action and return a protocol-compatible
|
||||||
|
response dict.
|
||||||
|
|
||||||
|
:param action: one of query / add / open / close / delete
|
||||||
|
:param payload: action-specific payload (may be None for query)
|
||||||
|
:return: dict with action, code, message, payload
|
||||||
|
"""
|
||||||
|
payload = payload or {}
|
||||||
|
try:
|
||||||
|
if action == "query":
|
||||||
|
result_payload = self.query()
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
||||||
|
elif action == "add":
|
||||||
|
self.add(payload)
|
||||||
|
elif action == "open":
|
||||||
|
self.open(payload)
|
||||||
|
elif action == "close":
|
||||||
|
self.close(payload)
|
||||||
|
elif action == "delete":
|
||||||
|
self.delete(payload)
|
||||||
|
else:
|
||||||
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SkillService] dispatch error: action={action}, error={e}")
|
||||||
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _download_file(url: str, dest: str):
|
||||||
|
"""
|
||||||
|
Download a file from *url* and save to *dest*.
|
||||||
|
|
||||||
|
:param url: remote file URL
|
||||||
|
:param dest: local destination path
|
||||||
|
"""
|
||||||
|
if requests is None:
|
||||||
|
raise RuntimeError("requests library is required for downloading skill files")
|
||||||
|
|
||||||
|
dest_dir = os.path.dirname(dest)
|
||||||
|
if dest_dir:
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
|
||||||
|
resp = requests.get(url, timeout=60)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
logger.debug(f"[SkillService] downloaded {url} -> {dest}")
|
||||||
@@ -45,7 +45,7 @@ class Skill:
|
|||||||
description: str
|
description: str
|
||||||
file_path: str
|
file_path: str
|
||||||
base_dir: str
|
base_dir: str
|
||||||
source: str # managed, workspace, bundled, etc.
|
source: str # builtin or custom
|
||||||
content: str # Full markdown content
|
content: str # Full markdown content
|
||||||
disable_model_invocation: bool = False
|
disable_model_invocation: bool = False
|
||||||
frontmatter: Dict[str, Any] = field(default_factory=dict)
|
frontmatter: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ def _import_optional_tools():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] Scheduler tool failed to load: {e}")
|
logger.error(f"[Tools] Scheduler tool failed to load: {e}")
|
||||||
|
|
||||||
|
# WebSearch Tool (conditionally loaded based on API key availability at init time)
|
||||||
|
try:
|
||||||
|
from agent.tools.web_search.web_search import WebSearch
|
||||||
|
tools['WebSearch'] = WebSearch
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(f"[Tools] WebSearch not loaded - missing dependency: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tools] WebSearch failed to load: {e}")
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
@@ -53,6 +61,7 @@ def _import_optional_tools():
|
|||||||
_optional_tools = _import_optional_tools()
|
_optional_tools = _import_optional_tools()
|
||||||
EnvConfig = _optional_tools.get('EnvConfig')
|
EnvConfig = _optional_tools.get('EnvConfig')
|
||||||
SchedulerTool = _optional_tools.get('SchedulerTool')
|
SchedulerTool = _optional_tools.get('SchedulerTool')
|
||||||
|
WebSearch = _optional_tools.get('WebSearch')
|
||||||
GoogleSearch = _optional_tools.get('GoogleSearch')
|
GoogleSearch = _optional_tools.get('GoogleSearch')
|
||||||
FileSave = _optional_tools.get('FileSave')
|
FileSave = _optional_tools.get('FileSave')
|
||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
@@ -92,6 +101,7 @@ __all__ = [
|
|||||||
'MemoryGetTool',
|
'MemoryGetTool',
|
||||||
'EnvConfig',
|
'EnvConfig',
|
||||||
'SchedulerTool',
|
'SchedulerTool',
|
||||||
|
'WebSearch',
|
||||||
# Optional tools (may be None if dependencies not available)
|
# Optional tools (may be None if dependencies not available)
|
||||||
# 'BrowserTool'
|
# 'BrowserTool'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Dict, Any
|
|||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.utils.truncate import truncate_tail, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
|
from agent.tools.utils.truncate import truncate_tail, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
class Bash(BaseTool):
|
class Bash(BaseTool):
|
||||||
@@ -19,10 +20,11 @@ class Bash(BaseTool):
|
|||||||
name: str = "bash"
|
name: str = "bash"
|
||||||
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
||||||
|
|
||||||
IMPORTANT SAFETY GUIDELINES:
|
ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly.
|
||||||
- You can freely create, modify, and delete files within the current workspace
|
|
||||||
- For operations outside the workspace or potentially destructive commands (rm -rf, system commands, etc.), always explain what you're about to do and ask for user confirmation first
|
SAFETY:
|
||||||
- When in doubt, describe the command's purpose and ask for permission before executing"""
|
- Freely create/modify/delete files within the workspace
|
||||||
|
- For destructive and out-of-workspace commands, explain and confirm first"""
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -80,7 +82,7 @@ IMPORTANT SAFETY GUIDELINES:
|
|||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|
||||||
# Load environment variables from ~/.cow/.env if it exists
|
# Load environment variables from ~/.cow/.env if it exists
|
||||||
env_file = os.path.expanduser("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
try:
|
try:
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
@@ -92,13 +94,11 @@ IMPORTANT SAFETY GUIDELINES:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[Bash] Failed to load .env: {e}")
|
logger.debug(f"[Bash] Failed to load .env: {e}")
|
||||||
|
|
||||||
# Debug logging
|
# getuid() only exists on Unix-like systems
|
||||||
logger.debug(f"[Bash] CWD: {self.cwd}")
|
if hasattr(os, 'getuid'):
|
||||||
logger.debug(f"[Bash] Command: {command[:500]}")
|
logger.debug(f"[Bash] Process UID: {os.getuid()}")
|
||||||
logger.debug(f"[Bash] OPENAI_API_KEY in env: {'OPENAI_API_KEY' in env}")
|
else:
|
||||||
logger.debug(f"[Bash] SHELL: {env.get('SHELL', 'not set')}")
|
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
||||||
logger.debug(f"[Bash] Python executable: {sys.executable}")
|
|
||||||
logger.debug(f"[Bash] Process UID: {os.getuid()}")
|
|
||||||
|
|
||||||
# Execute command with inherited environment variables
|
# Execute command with inherited environment variables
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from common.utils import expand_path
|
||||||
from agent.tools.utils.diff import (
|
from agent.tools.utils.diff import (
|
||||||
strip_bom,
|
strip_bom,
|
||||||
detect_line_ending,
|
detect_line_ending,
|
||||||
@@ -178,7 +179,7 @@ class Edit(BaseTool):
|
|||||||
:return: Absolute path
|
:return: Absolute path
|
||||||
"""
|
"""
|
||||||
# Expand ~ to user home directory
|
# Expand ~ to user home directory
|
||||||
path = os.path.expanduser(path)
|
path = expand_path(path)
|
||||||
if os.path.isabs(path):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, path))
|
return os.path.abspath(os.path.join(self.cwd, path))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
# API Key 知识库:常见的环境变量及其描述
|
# API Key 知识库:常见的环境变量及其描述
|
||||||
@@ -66,7 +67,7 @@ class EnvConfig(BaseTool):
|
|||||||
def __init__(self, config: dict = None):
|
def __init__(self, config: dict = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
# Store env config in ~/.cow directory (outside workspace for security)
|
# Store env config in ~/.cow directory (outside workspace for security)
|
||||||
self.env_dir = os.path.expanduser("~/.cow")
|
self.env_dir = expand_path("~/.cow")
|
||||||
self.env_path = os.path.join(self.env_dir, '.env')
|
self.env_path = os.path.join(self.env_dir, '.env')
|
||||||
self.agent_bridge = self.config.get("agent_bridge") # Reference to AgentBridge for hot reload
|
self.agent_bridge = self.config.get("agent_bridge") # Reference to AgentBridge for hot reload
|
||||||
# Don't create .env file in __init__ to avoid issues during tool discovery
|
# Don't create .env file in __init__ to avoid issues during tool discovery
|
||||||
@@ -201,7 +202,8 @@ class EnvConfig(BaseTool):
|
|||||||
"key": key,
|
"key": key,
|
||||||
"value": self._mask_value(value),
|
"value": self._mask_value(value),
|
||||||
"description": description,
|
"description": description,
|
||||||
"exists": True
|
"exists": True,
|
||||||
|
"note": f"Value is masked for security. In bash, use ${key} directly — it is auto-injected."
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Dict, Any
|
|||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_BYTES
|
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_BYTES
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LIMIT = 500
|
DEFAULT_LIMIT = 500
|
||||||
@@ -51,7 +52,7 @@ class Ls(BaseTool):
|
|||||||
absolute_path = self._resolve_path(path)
|
absolute_path = self._resolve_path(path)
|
||||||
|
|
||||||
# Security check: Prevent accessing sensitive config directory
|
# Security check: Prevent accessing sensitive config directory
|
||||||
env_config_dir = os.path.expanduser("~/.cow")
|
env_config_dir = expand_path("~/.cow")
|
||||||
if os.path.abspath(absolute_path) == os.path.abspath(env_config_dir):
|
if os.path.abspath(absolute_path) == os.path.abspath(env_config_dir):
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||||
@@ -133,7 +134,7 @@ class Ls(BaseTool):
|
|||||||
def _resolve_path(self, path: str) -> str:
|
def _resolve_path(self, path: str) -> str:
|
||||||
"""Resolve path to absolute path"""
|
"""Resolve path to absolute path"""
|
||||||
# Expand ~ to user home directory
|
# Expand ~ to user home directory
|
||||||
path = os.path.expanduser(path)
|
path = expand_path(path)
|
||||||
if os.path.isabs(path):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, path))
|
return os.path.abspath(os.path.join(self.cwd, path))
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class MemoryGetTool(BaseTool):
|
|||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return ToolResult.fail(f"Error: File not found: {path}")
|
return ToolResult.fail(f"Error: File not found: {path}")
|
||||||
|
|
||||||
content = file_path.read_text()
|
content = file_path.read_text(encoding='utf-8')
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
|
|
||||||
# Handle line range
|
# Handle line range
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
|
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
class Read(BaseTool):
|
class Read(BaseTool):
|
||||||
@@ -66,7 +67,9 @@ class Read(BaseTool):
|
|||||||
:param args: Contains file path and optional offset/limit parameters
|
:param args: Contains file path and optional offset/limit parameters
|
||||||
:return: File content or error message
|
:return: File content or error message
|
||||||
"""
|
"""
|
||||||
path = args.get("path", "").strip()
|
# Support 'location' as alias for 'path' (LLM may use it from skill listing)
|
||||||
|
path = args.get("path", "") or args.get("location", "")
|
||||||
|
path = path.strip() if isinstance(path, str) else ""
|
||||||
offset = args.get("offset")
|
offset = args.get("offset")
|
||||||
limit = args.get("limit")
|
limit = args.get("limit")
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ class Read(BaseTool):
|
|||||||
absolute_path = self._resolve_path(path)
|
absolute_path = self._resolve_path(path)
|
||||||
|
|
||||||
# Security check: Prevent reading sensitive config files
|
# Security check: Prevent reading sensitive config files
|
||||||
env_config_path = os.path.expanduser("~/.cow/.env")
|
env_config_path = expand_path("~/.cow/.env")
|
||||||
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
|
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||||
@@ -129,7 +132,7 @@ class Read(BaseTool):
|
|||||||
:return: Absolute path
|
:return: Absolute path
|
||||||
"""
|
"""
|
||||||
# Expand ~ to user home directory
|
# Expand ~ to user home directory
|
||||||
path = os.path.expanduser(path)
|
path = expand_path(path)
|
||||||
if os.path.isabs(path):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, path))
|
return os.path.abspath(os.path.join(self.cwd, path))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from config import conf
|
from config import conf
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from common.utils import expand_path
|
||||||
from bridge.context import Context, ContextType
|
from bridge.context import Context, ContextType
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ def init_scheduler(agent_bridge) -> bool:
|
|||||||
from agent.tools.scheduler.scheduler_service import SchedulerService
|
from agent.tools.scheduler.scheduler_service import SchedulerService
|
||||||
|
|
||||||
# Get workspace from config
|
# Get workspace from config
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
|
||||||
# Create task store
|
# Create task store
|
||||||
@@ -112,11 +113,15 @@ def _execute_agent_task(task: dict, agent_bridge):
|
|||||||
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']}: Executing agent task '{task_description}'")
|
logger.info(f"[Scheduler] Task {task['id']}: Executing agent task '{task_description}'")
|
||||||
|
|
||||||
|
# Create a unique session_id for this scheduled task to avoid polluting user's conversation
|
||||||
|
# Format: scheduler_<receiver>_<task_id> to ensure isolation
|
||||||
|
scheduler_session_id = f"scheduler_{receiver}_{task['id']}"
|
||||||
|
|
||||||
# Create context for Agent
|
# Create context for Agent
|
||||||
context = Context(ContextType.TEXT, task_description)
|
context = Context(ContextType.TEXT, task_description)
|
||||||
context["receiver"] = receiver
|
context["receiver"] = receiver
|
||||||
context["isgroup"] = is_group
|
context["isgroup"] = is_group
|
||||||
context["session_id"] = receiver
|
context["session_id"] = scheduler_session_id
|
||||||
|
|
||||||
# Channel-specific setup
|
# Channel-specific setup
|
||||||
if channel_type == "web":
|
if channel_type == "web":
|
||||||
@@ -140,7 +145,8 @@ def _execute_agent_task(task: dict, agent_bridge):
|
|||||||
context["is_scheduled_task"] = True
|
context["is_scheduled_task"] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=True)
|
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
|
||||||
|
reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=False)
|
||||||
|
|
||||||
if reply and reply.content:
|
if reply and reply.content:
|
||||||
# Send the reply via channel
|
# Send the reply via channel
|
||||||
@@ -378,6 +384,10 @@ def _execute_skill_call(task: dict, agent_bridge):
|
|||||||
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}")
|
logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}")
|
||||||
|
|
||||||
|
# Create a unique session_id for this scheduled task to avoid polluting user's conversation
|
||||||
|
# Format: scheduler_<receiver>_<task_id> to ensure isolation
|
||||||
|
scheduler_session_id = f"scheduler_{receiver}_{task['id']}"
|
||||||
|
|
||||||
# Build a natural language query for the Agent to execute the skill
|
# Build a natural language query for the Agent to execute the skill
|
||||||
# Format: "Use skill-name to do something with params"
|
# Format: "Use skill-name to do something with params"
|
||||||
param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()])
|
param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()])
|
||||||
@@ -389,7 +399,7 @@ def _execute_skill_call(task: dict, agent_bridge):
|
|||||||
context = Context(ContextType.TEXT, query)
|
context = Context(ContextType.TEXT, query)
|
||||||
context["receiver"] = receiver
|
context["receiver"] = receiver
|
||||||
context["isgroup"] = is_group
|
context["isgroup"] = is_group
|
||||||
context["session_id"] = receiver
|
context["session_id"] = scheduler_session_id
|
||||||
|
|
||||||
# Channel-specific setup
|
# Channel-specific setup
|
||||||
if channel_type == "web":
|
if channel_type == "web":
|
||||||
@@ -402,7 +412,8 @@ def _execute_skill_call(task: dict, agent_bridge):
|
|||||||
|
|
||||||
# Use Agent to execute the skill
|
# Use Agent to execute the skill
|
||||||
try:
|
try:
|
||||||
reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=True)
|
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
|
||||||
|
reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=False)
|
||||||
|
|
||||||
if reply and reply.content:
|
if reply and reply.content:
|
||||||
content = reply.content
|
content = reply.content
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class SchedulerTool(BaseTool):
|
|||||||
|
|
||||||
name: str = "scheduler"
|
name: str = "scheduler"
|
||||||
description: str = (
|
description: str = (
|
||||||
"创建、查询和管理定时任务。支持固定消息和AI任务两种类型。\n\n"
|
"创建、查询和管理定时任务(提醒、周期性任务等)。\n\n"
|
||||||
|
"⚠️ 重要:仅当需要「定时/提醒/每天/每周/X分钟后/X点」等延迟或周期执行时才使用此工具。"
|
||||||
"使用方法:\n"
|
"使用方法:\n"
|
||||||
"- 创建:action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n"
|
"- 创建:action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n"
|
||||||
"- 查询:action='list' / action='get', task_id='任务ID'\n"
|
"- 查询:action='list' / action='get', task_id='任务ID'\n"
|
||||||
@@ -53,7 +54,7 @@ class SchedulerTool(BaseTool):
|
|||||||
},
|
},
|
||||||
"ai_task": {
|
"ai_task": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "AI任务描述 (与message二选一),如'搜索今日新闻'、'查询天气'"
|
"description": "AI任务描述 (与message二选一),用于定时让AI执行的任务"
|
||||||
},
|
},
|
||||||
"schedule_type": {
|
"schedule_type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import threading
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
class TaskStore:
|
class TaskStore:
|
||||||
@@ -24,7 +25,7 @@ class TaskStore:
|
|||||||
"""
|
"""
|
||||||
if store_path is None:
|
if store_path is None:
|
||||||
# Default to ~/cow/scheduler/tasks.json
|
# Default to ~/cow/scheduler/tasks.json
|
||||||
home = os.path.expanduser("~")
|
home = expand_path("~")
|
||||||
store_path = os.path.join(home, "cow", "scheduler", "tasks.json")
|
store_path = os.path.join(home, "cow", "scheduler", "tasks.json")
|
||||||
|
|
||||||
self.store_path = store_path
|
self.store_path = store_path
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Dict, Any
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
class Send(BaseTool):
|
class Send(BaseTool):
|
||||||
@@ -102,7 +103,7 @@ class Send(BaseTool):
|
|||||||
|
|
||||||
def _resolve_path(self, path: str) -> str:
|
def _resolve_path(self, path: str) -> str:
|
||||||
"""Resolve path to absolute path"""
|
"""Resolve path to absolute path"""
|
||||||
path = os.path.expanduser(path)
|
path = expand_path(path)
|
||||||
if os.path.isabs(path):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, path))
|
return os.path.abspath(os.path.join(self.cwd, path))
|
||||||
|
|||||||
3
agent/tools/web_search/__init__.py
Normal file
3
agent/tools/web_search/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from agent.tools.web_search.web_search import WebSearch
|
||||||
|
|
||||||
|
__all__ = ["WebSearch"]
|
||||||
322
agent/tools/web_search/web_search.py
Normal file
322
agent/tools/web_search/web_search.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Web Search tool - Search the web using Bocha or LinkAI search API.
|
||||||
|
Supports two backends with unified response format:
|
||||||
|
1. Bocha Search (primary, requires BOCHA_API_KEY)
|
||||||
|
2. LinkAI Search (fallback, requires LINKAI_API_KEY)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Default timeout for API requests (seconds)
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearch(BaseTool):
|
||||||
|
"""Tool for searching the web using Bocha or LinkAI search API"""
|
||||||
|
|
||||||
|
name: str = "web_search"
|
||||||
|
description: str = (
|
||||||
|
"Search the web for current information, news, research topics, or any real-time data. "
|
||||||
|
"Returns web page titles, URLs, snippets, and optional summaries. "
|
||||||
|
"Use this when the user asks about recent events, needs fact-checking, or wants up-to-date information."
|
||||||
|
)
|
||||||
|
|
||||||
|
params: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search query string"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of results to return (1-50, default: 10)"
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Time range filter. Options: "
|
||||||
|
"'noLimit' (default), 'oneDay', 'oneWeek', 'oneMonth', 'oneYear', "
|
||||||
|
"or date range like '2025-01-01..2025-02-01'"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to include text summary for each result (default: false)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, config: dict = None):
|
||||||
|
self.config = config or {}
|
||||||
|
self._backend = None # Will be resolved on first execute
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_available() -> bool:
|
||||||
|
"""Check if web search is available (at least one API key is configured)"""
|
||||||
|
return bool(os.environ.get("BOCHA_API_KEY") or os.environ.get("LINKAI_API_KEY"))
|
||||||
|
|
||||||
|
def _resolve_backend(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Determine which search backend to use.
|
||||||
|
Priority: Bocha > LinkAI
|
||||||
|
|
||||||
|
:return: 'bocha', 'linkai', or None
|
||||||
|
"""
|
||||||
|
if os.environ.get("BOCHA_API_KEY"):
|
||||||
|
return "bocha"
|
||||||
|
if os.environ.get("LINKAI_API_KEY"):
|
||||||
|
return "linkai"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Execute web search
|
||||||
|
|
||||||
|
:param args: Search parameters (query, count, freshness, summary)
|
||||||
|
:return: Search results
|
||||||
|
"""
|
||||||
|
query = args.get("query", "").strip()
|
||||||
|
if not query:
|
||||||
|
return ToolResult.fail("Error: 'query' parameter is required")
|
||||||
|
|
||||||
|
count = args.get("count", 10)
|
||||||
|
freshness = args.get("freshness", "noLimit")
|
||||||
|
summary = args.get("summary", False)
|
||||||
|
|
||||||
|
# Validate count
|
||||||
|
if not isinstance(count, int) or count < 1 or count > 50:
|
||||||
|
count = 10
|
||||||
|
|
||||||
|
# Resolve backend
|
||||||
|
backend = self._resolve_backend()
|
||||||
|
if not backend:
|
||||||
|
return ToolResult.fail(
|
||||||
|
"Error: No search API key configured. "
|
||||||
|
"Please set BOCHA_API_KEY or LINKAI_API_KEY using env_config tool.\n"
|
||||||
|
" - Bocha Search: https://open.bocha.cn\n"
|
||||||
|
" - LinkAI Search: https://link-ai.tech"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if backend == "bocha":
|
||||||
|
return self._search_bocha(query, count, freshness, summary)
|
||||||
|
else:
|
||||||
|
return self._search_linkai(query, count, freshness)
|
||||||
|
except requests.Timeout:
|
||||||
|
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
|
||||||
|
except requests.ConnectionError:
|
||||||
|
return ToolResult.fail("Error: Failed to connect to search API")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebSearch] Unexpected error: {e}", exc_info=True)
|
||||||
|
return ToolResult.fail(f"Error: Search failed - {str(e)}")
|
||||||
|
|
||||||
|
def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Search using Bocha API
|
||||||
|
|
||||||
|
:param query: Search query
|
||||||
|
:param count: Number of results
|
||||||
|
:param freshness: Time range filter
|
||||||
|
:param summary: Whether to include summary
|
||||||
|
:return: Formatted search results
|
||||||
|
"""
|
||||||
|
api_key = os.environ.get("BOCHA_API_KEY", "")
|
||||||
|
url = "https://api.bocha.cn/v1/web-search"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"count": count,
|
||||||
|
"freshness": freshness,
|
||||||
|
"summary": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"[WebSearch] Bocha search: query='{query}', count={count}")
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid BOCHA_API_KEY. Please check your API key.")
|
||||||
|
if response.status_code == 403:
|
||||||
|
return ToolResult.fail("Error: Bocha API - insufficient balance. Please top up at https://open.bocha.cn")
|
||||||
|
if response.status_code == 429:
|
||||||
|
return ToolResult.fail("Error: Bocha API rate limit reached. Please try again later.")
|
||||||
|
if response.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: Bocha API returned HTTP {response.status_code}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Check API-level error code
|
||||||
|
api_code = data.get("code")
|
||||||
|
if api_code is not None and api_code != 200:
|
||||||
|
msg = data.get("msg") or "Unknown error"
|
||||||
|
return ToolResult.fail(f"Error: Bocha API error (code={api_code}): {msg}")
|
||||||
|
|
||||||
|
# Extract and format results
|
||||||
|
return self._format_bocha_results(data, query)
|
||||||
|
|
||||||
|
def _format_bocha_results(self, data: dict, query: str) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Format Bocha API response into unified result structure
|
||||||
|
|
||||||
|
:param data: Raw API response
|
||||||
|
:param query: Original query
|
||||||
|
:return: Formatted ToolResult
|
||||||
|
"""
|
||||||
|
search_data = data.get("data", {})
|
||||||
|
web_pages = search_data.get("webPages", {})
|
||||||
|
pages = web_pages.get("value", [])
|
||||||
|
|
||||||
|
if not pages:
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query,
|
||||||
|
"backend": "bocha",
|
||||||
|
"total": 0,
|
||||||
|
"results": [],
|
||||||
|
"message": "No results found"
|
||||||
|
})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for page in pages:
|
||||||
|
result = {
|
||||||
|
"title": page.get("name", ""),
|
||||||
|
"url": page.get("url", ""),
|
||||||
|
"snippet": page.get("snippet", ""),
|
||||||
|
"siteName": page.get("siteName", ""),
|
||||||
|
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
|
||||||
|
}
|
||||||
|
# Include summary only if present
|
||||||
|
if page.get("summary"):
|
||||||
|
result["summary"] = page["summary"]
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
total = web_pages.get("totalEstimatedMatches", len(results))
|
||||||
|
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query,
|
||||||
|
"backend": "bocha",
|
||||||
|
"total": total,
|
||||||
|
"count": len(results),
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Search using LinkAI plugin API
|
||||||
|
|
||||||
|
:param query: Search query
|
||||||
|
:param count: Number of results
|
||||||
|
:param freshness: Time range filter
|
||||||
|
:return: Formatted search results
|
||||||
|
"""
|
||||||
|
api_key = os.environ.get("LINKAI_API_KEY", "")
|
||||||
|
url = "https://api.link-ai.tech/v1/plugin/execute"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"code": "web-search",
|
||||||
|
"args": {
|
||||||
|
"query": query,
|
||||||
|
"count": count,
|
||||||
|
"freshness": freshness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"[WebSearch] LinkAI search: query='{query}', count={count}")
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid LINKAI_API_KEY. Please check your API key.")
|
||||||
|
if response.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: LinkAI API returned HTTP {response.status_code}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if not data.get("success"):
|
||||||
|
msg = data.get("message") or "Unknown error"
|
||||||
|
return ToolResult.fail(f"Error: LinkAI search failed: {msg}")
|
||||||
|
|
||||||
|
return self._format_linkai_results(data, query)
|
||||||
|
|
||||||
|
def _format_linkai_results(self, data: dict, query: str) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Format LinkAI API response into unified result structure.
|
||||||
|
LinkAI returns the search data in data.data field, which follows
|
||||||
|
the same Bing-compatible format as Bocha.
|
||||||
|
|
||||||
|
:param data: Raw API response
|
||||||
|
:param query: Original query
|
||||||
|
:return: Formatted ToolResult
|
||||||
|
"""
|
||||||
|
raw_data = data.get("data", "")
|
||||||
|
|
||||||
|
# LinkAI may return data as a JSON string
|
||||||
|
if isinstance(raw_data, str):
|
||||||
|
try:
|
||||||
|
raw_data = json.loads(raw_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
# If data is plain text, return it as a single result
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query,
|
||||||
|
"backend": "linkai",
|
||||||
|
"total": 1,
|
||||||
|
"count": 1,
|
||||||
|
"results": [{"content": raw_data}]
|
||||||
|
})
|
||||||
|
|
||||||
|
# If the response follows Bing-compatible structure
|
||||||
|
if isinstance(raw_data, dict):
|
||||||
|
web_pages = raw_data.get("webPages", {})
|
||||||
|
pages = web_pages.get("value", [])
|
||||||
|
|
||||||
|
if pages:
|
||||||
|
results = []
|
||||||
|
for page in pages:
|
||||||
|
result = {
|
||||||
|
"title": page.get("name", ""),
|
||||||
|
"url": page.get("url", ""),
|
||||||
|
"snippet": page.get("snippet", ""),
|
||||||
|
"siteName": page.get("siteName", ""),
|
||||||
|
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
|
||||||
|
}
|
||||||
|
if page.get("summary"):
|
||||||
|
result["summary"] = page["summary"]
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
total = web_pages.get("totalEstimatedMatches", len(results))
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query,
|
||||||
|
"backend": "linkai",
|
||||||
|
"total": total,
|
||||||
|
"count": len(results),
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fallback: return raw data
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query,
|
||||||
|
"backend": "linkai",
|
||||||
|
"total": 1,
|
||||||
|
"count": 1,
|
||||||
|
"results": [{"content": str(raw_data)}]
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ from typing import Dict, Any
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
class Write(BaseTool):
|
class Write(BaseTool):
|
||||||
@@ -90,7 +91,7 @@ class Write(BaseTool):
|
|||||||
:return: Absolute path
|
:return: Absolute path
|
||||||
"""
|
"""
|
||||||
# Expand ~ to user home directory
|
# Expand ~ to user home directory
|
||||||
path = os.path.expanduser(path)
|
path = expand_path(path)
|
||||||
if os.path.isabs(path):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, path))
|
return os.path.abspath(os.path.join(self.cwd, path))
|
||||||
|
|||||||
162
app.py
162
app.py
@@ -7,11 +7,152 @@ import time
|
|||||||
|
|
||||||
from channel import channel_factory
|
from channel import channel_factory
|
||||||
from common import const
|
from common import const
|
||||||
from config import load_config
|
from common.log import logger
|
||||||
|
from config import load_config, conf
|
||||||
from plugins import *
|
from plugins import *
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
# Global channel manager for restart support
|
||||||
|
_channel_mgr = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_manager():
|
||||||
|
return _channel_mgr
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelManager:
|
||||||
|
"""
|
||||||
|
Manage the lifecycle of a channel, supporting restart from sub-threads.
|
||||||
|
The channel.startup() runs in a daemon thread so that the main thread
|
||||||
|
remains available and a new channel can be started at any time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._channel = None
|
||||||
|
self._channel_thread = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel(self):
|
||||||
|
return self._channel
|
||||||
|
|
||||||
|
def start(self, channel_name: str, first_start: bool = False):
|
||||||
|
"""
|
||||||
|
Create and start a channel in a sub-thread.
|
||||||
|
If first_start is True, plugins and linkai client will also be initialized.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
channel = channel_factory.create_channel(channel_name)
|
||||||
|
self._channel = channel
|
||||||
|
|
||||||
|
if first_start:
|
||||||
|
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "web",
|
||||||
|
"wechatmp_service", "wechatcom_app", "wework",
|
||||||
|
const.FEISHU, const.DINGTALK]:
|
||||||
|
PluginManager().load_plugins()
|
||||||
|
|
||||||
|
if conf().get("use_linkai"):
|
||||||
|
try:
|
||||||
|
from common import cloud_client
|
||||||
|
threading.Thread(target=cloud_client.start, args=(channel, self), daemon=True).start()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Run channel.startup() in a daemon thread so we can restart later
|
||||||
|
self._channel_thread = threading.Thread(
|
||||||
|
target=self._run_channel, args=(channel,), daemon=True
|
||||||
|
)
|
||||||
|
self._channel_thread.start()
|
||||||
|
logger.debug(f"[ChannelManager] Channel '{channel_name}' started in sub-thread")
|
||||||
|
|
||||||
|
def _run_channel(self, channel):
|
||||||
|
try:
|
||||||
|
channel.startup()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ChannelManager] Channel startup error: {e}")
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""
|
||||||
|
Stop the current channel. Since most channel startup() methods block
|
||||||
|
on an HTTP server or stream client, we stop by terminating the thread.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._channel is None:
|
||||||
|
return
|
||||||
|
channel_type = getattr(self._channel, 'channel_type', 'unknown')
|
||||||
|
logger.info(f"[ChannelManager] Stopping channel '{channel_type}'...")
|
||||||
|
|
||||||
|
# Try graceful stop if channel implements it
|
||||||
|
try:
|
||||||
|
if hasattr(self._channel, 'stop'):
|
||||||
|
self._channel.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ChannelManager] Error during channel stop: {e}")
|
||||||
|
|
||||||
|
self._channel = None
|
||||||
|
self._channel_thread = None
|
||||||
|
|
||||||
|
def restart(self, new_channel_name: str):
|
||||||
|
"""
|
||||||
|
Restart the channel with a new channel type.
|
||||||
|
Can be called from any thread (e.g. linkai config callback).
|
||||||
|
"""
|
||||||
|
logger.info(f"[ChannelManager] Restarting channel to '{new_channel_name}'...")
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
# Clear singleton cache so a fresh channel instance is created
|
||||||
|
_clear_singleton_cache(new_channel_name)
|
||||||
|
|
||||||
|
time.sleep(1) # Brief pause to allow resources to release
|
||||||
|
self.start(new_channel_name, first_start=False)
|
||||||
|
logger.info(f"[ChannelManager] Channel restarted to '{new_channel_name}' successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_singleton_cache(channel_name: str):
|
||||||
|
"""
|
||||||
|
Clear the singleton cache for the channel class so that
|
||||||
|
a new instance can be created with updated config.
|
||||||
|
"""
|
||||||
|
cls_map = {
|
||||||
|
"wx": "channel.wechat.wechat_channel.WechatChannel",
|
||||||
|
"wxy": "channel.wechat.wechaty_channel.WechatyChannel",
|
||||||
|
"wcf": "channel.wechat.wcf_channel.WechatfChannel",
|
||||||
|
"web": "channel.web.web_channel.WebChannel",
|
||||||
|
"wechatmp": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
|
||||||
|
"wechatmp_service": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
|
||||||
|
"wechatcom_app": "channel.wechatcom.wechatcomapp_channel.WechatComAppChannel",
|
||||||
|
"wework": "channel.wework.wework_channel.WeworkChannel",
|
||||||
|
const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel",
|
||||||
|
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||||
|
}
|
||||||
|
module_path = cls_map.get(channel_name)
|
||||||
|
if not module_path:
|
||||||
|
return
|
||||||
|
# The singleton decorator stores instances in a closure dict keyed by class.
|
||||||
|
# We need to find the actual class and clear it from the closure.
|
||||||
|
try:
|
||||||
|
parts = module_path.rsplit(".", 1)
|
||||||
|
module_name, class_name = parts[0], parts[1]
|
||||||
|
import importlib
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
# The module-level name is the wrapper function from @singleton
|
||||||
|
wrapper = getattr(module, class_name, None)
|
||||||
|
if wrapper and hasattr(wrapper, '__closure__') and wrapper.__closure__:
|
||||||
|
for cell in wrapper.__closure__:
|
||||||
|
try:
|
||||||
|
cell_contents = cell.cell_contents
|
||||||
|
if isinstance(cell_contents, dict):
|
||||||
|
cell_contents.clear()
|
||||||
|
logger.debug(f"[ChannelManager] Cleared singleton cache for {class_name}")
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ChannelManager] Failed to clear singleton cache: {e}")
|
||||||
|
|
||||||
|
|
||||||
def sigterm_handler_wrap(_signo):
|
def sigterm_handler_wrap(_signo):
|
||||||
old_handler = signal.getsignal(_signo)
|
old_handler = signal.getsignal(_signo)
|
||||||
|
|
||||||
@@ -25,22 +166,8 @@ def sigterm_handler_wrap(_signo):
|
|||||||
signal.signal(_signo, func)
|
signal.signal(_signo, func)
|
||||||
|
|
||||||
|
|
||||||
def start_channel(channel_name: str):
|
|
||||||
channel = channel_factory.create_channel(channel_name)
|
|
||||||
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "web", "wechatmp_service", "wechatcom_app", "wework",
|
|
||||||
const.FEISHU, const.DINGTALK]:
|
|
||||||
PluginManager().load_plugins()
|
|
||||||
|
|
||||||
if conf().get("use_linkai"):
|
|
||||||
try:
|
|
||||||
from common import linkai_client
|
|
||||||
threading.Thread(target=linkai_client.start, args=(channel,)).start()
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
channel.startup()
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
global _channel_mgr
|
||||||
try:
|
try:
|
||||||
# load config
|
# load config
|
||||||
load_config()
|
load_config()
|
||||||
@@ -58,7 +185,8 @@ def run():
|
|||||||
if channel_name == "wxy":
|
if channel_name == "wxy":
|
||||||
os.environ["WECHATY_LOG"] = "warn"
|
os.environ["WECHATY_LOG"] = "warn"
|
||||||
|
|
||||||
start_channel(channel_name)
|
_channel_mgr = ChannelManager()
|
||||||
|
_channel_mgr.start(channel_name, first_start=True)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from bridge.context import Context
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from common import const
|
from common import const
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from common.utils import expand_path
|
||||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ def add_openai_compatible_support(bot_instance):
|
|||||||
"""
|
"""
|
||||||
if hasattr(bot_instance, 'call_with_tools'):
|
if hasattr(bot_instance, 'call_with_tools'):
|
||||||
# Bot already has tool calling support (e.g., ZHIPUAIBot)
|
# Bot already has tool calling support (e.g., ZHIPUAIBot)
|
||||||
logger.info(f"[AgentBridge] {type(bot_instance).__name__} already has native tool calling support")
|
logger.debug(f"[AgentBridge] {type(bot_instance).__name__} already has native tool calling support")
|
||||||
return bot_instance
|
return bot_instance
|
||||||
|
|
||||||
# Create a temporary mixin class that combines the bot with OpenAI compatibility
|
# Create a temporary mixin class that combines the bot with OpenAI compatibility
|
||||||
@@ -233,7 +234,8 @@ class AgentBridge:
|
|||||||
enable_skills=kwargs.get("enable_skills", True), # Enable skills by default
|
enable_skills=kwargs.get("enable_skills", True), # Enable skills by default
|
||||||
memory_manager=kwargs.get("memory_manager"), # Pass memory manager
|
memory_manager=kwargs.get("memory_manager"), # Pass memory manager
|
||||||
max_context_tokens=kwargs.get("max_context_tokens"),
|
max_context_tokens=kwargs.get("max_context_tokens"),
|
||||||
context_reserve_tokens=kwargs.get("context_reserve_tokens")
|
context_reserve_tokens=kwargs.get("context_reserve_tokens"),
|
||||||
|
runtime_info=kwargs.get("runtime_info") # Pass runtime_info for dynamic time updates
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log skill loading details
|
# Log skill loading details
|
||||||
@@ -420,7 +422,7 @@ class AgentBridge:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Use fixed secure location for .env file
|
# Use fixed secure location for .env file
|
||||||
env_file = os.path.expanduser("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
|
|
||||||
# Read existing env vars from .env file
|
# Read existing env vars from .env file
|
||||||
existing_env_vars = {}
|
existing_env_vars = {}
|
||||||
@@ -492,8 +494,8 @@ class AgentBridge:
|
|||||||
|
|
||||||
def refresh_all_skills(self) -> int:
|
def refresh_all_skills(self) -> int:
|
||||||
"""
|
"""
|
||||||
Refresh skills in all agent instances after environment variable changes.
|
Refresh skills and conditional tools in all agent instances after
|
||||||
This allows hot-reload of skills without restarting the agent.
|
environment variable changes. This allows hot-reload without restarting.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of agent instances refreshed
|
Number of agent instances refreshed
|
||||||
@@ -503,7 +505,7 @@ class AgentBridge:
|
|||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
# Reload environment variables from .env file
|
# Reload environment variables from .env file
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
env_file = os.path.join(workspace_root, '.env')
|
env_file = os.path.join(workspace_root, '.env')
|
||||||
|
|
||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
@@ -512,19 +514,50 @@ class AgentBridge:
|
|||||||
|
|
||||||
refreshed_count = 0
|
refreshed_count = 0
|
||||||
|
|
||||||
# Refresh default agent
|
# Collect all agent instances to refresh
|
||||||
if self.default_agent and hasattr(self.default_agent, 'skill_manager'):
|
agents_to_refresh = []
|
||||||
self.default_agent.skill_manager.refresh_skills()
|
if self.default_agent:
|
||||||
refreshed_count += 1
|
agents_to_refresh.append(("default", self.default_agent))
|
||||||
logger.info("[AgentBridge] Refreshed skills in default agent")
|
|
||||||
|
|
||||||
# Refresh all session agents
|
|
||||||
for session_id, agent in self.agents.items():
|
for session_id, agent in self.agents.items():
|
||||||
if hasattr(agent, 'skill_manager'):
|
agents_to_refresh.append((session_id, agent))
|
||||||
|
|
||||||
|
for label, agent in agents_to_refresh:
|
||||||
|
# Refresh skills
|
||||||
|
if hasattr(agent, 'skill_manager') and agent.skill_manager:
|
||||||
agent.skill_manager.refresh_skills()
|
agent.skill_manager.refresh_skills()
|
||||||
refreshed_count += 1
|
|
||||||
|
# Refresh conditional tools (e.g. web_search depends on API keys)
|
||||||
|
self._refresh_conditional_tools(agent)
|
||||||
|
|
||||||
|
refreshed_count += 1
|
||||||
|
|
||||||
if refreshed_count > 0:
|
if refreshed_count > 0:
|
||||||
logger.info(f"[AgentBridge] Refreshed skills in {refreshed_count} agent instance(s)")
|
logger.info(f"[AgentBridge] Refreshed skills & tools in {refreshed_count} agent instance(s)")
|
||||||
|
|
||||||
return refreshed_count
|
return refreshed_count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh_conditional_tools(agent):
|
||||||
|
"""
|
||||||
|
Add or remove conditional tools based on current environment variables.
|
||||||
|
For example, web_search should only be present when BOCHA_API_KEY or
|
||||||
|
LINKAI_API_KEY is set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.tools.web_search.web_search import WebSearch
|
||||||
|
|
||||||
|
has_tool = any(t.name == "web_search" for t in agent.tools)
|
||||||
|
available = WebSearch.is_available()
|
||||||
|
|
||||||
|
if available and not has_tool:
|
||||||
|
# API key was added - inject the tool
|
||||||
|
tool = WebSearch()
|
||||||
|
tool.model = agent.model
|
||||||
|
agent.tools.append(tool)
|
||||||
|
logger.info("[AgentBridge] web_search tool added (API key now available)")
|
||||||
|
elif not available and has_tool:
|
||||||
|
# API key was removed - remove the tool
|
||||||
|
agent.tools = [t for t in agent.tools if t.name != "web_search"]
|
||||||
|
logger.info("[AgentBridge] web_search tool removed (API key no longer available)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}")
|
||||||
@@ -74,7 +74,7 @@ class AgentEventHandler:
|
|||||||
# Only send thinking process if followed by tool calls
|
# Only send thinking process if followed by tool calls
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
if self.current_thinking.strip():
|
if self.current_thinking.strip():
|
||||||
logger.debug(f"💭 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
|
logger.info(f"💭 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
|
||||||
# Send thinking process to channel
|
# Send thinking process to channel
|
||||||
self._send_to_channel(f"{self.current_thinking.strip()}")
|
self._send_to_channel(f"{self.current_thinking.strip()}")
|
||||||
else:
|
else:
|
||||||
@@ -94,15 +94,15 @@ class AgentEventHandler:
|
|||||||
|
|
||||||
def _send_to_channel(self, message):
|
def _send_to_channel(self, message):
|
||||||
"""
|
"""
|
||||||
Try to send message to channel
|
Try to send intermediate message to channel.
|
||||||
|
Skipped in SSE mode because thinking text is already streamed via on_event.
|
||||||
Args:
|
|
||||||
message: Message to send
|
|
||||||
"""
|
"""
|
||||||
|
if self.context and self.context.get("on_event"):
|
||||||
|
return
|
||||||
|
|
||||||
if self.channel:
|
if self.channel:
|
||||||
try:
|
try:
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
# Create a Reply object for the message
|
|
||||||
reply = Reply(ReplyType.TEXT, message)
|
reply = Reply(ReplyType.TEXT, message)
|
||||||
self.channel._send(reply, self.context)
|
self.channel._send(reply, self.context)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Optional, List
|
|||||||
from agent.protocol import Agent
|
from agent.protocol import Agent
|
||||||
from agent.tools import ToolManager
|
from agent.tools import ToolManager
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
class AgentInitializer:
|
class AgentInitializer:
|
||||||
@@ -46,7 +47,7 @@ class AgentInitializer:
|
|||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
# Get workspace from config
|
# Get workspace from config
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
|
||||||
# Migrate API keys
|
# Migrate API keys
|
||||||
self._migrate_config_to_env(workspace_root)
|
self._migrate_config_to_env(workspace_root)
|
||||||
@@ -110,7 +111,8 @@ class AgentInitializer:
|
|||||||
workspace_dir=workspace_root,
|
workspace_dir=workspace_root,
|
||||||
skill_manager=skill_manager,
|
skill_manager=skill_manager,
|
||||||
enable_skills=True,
|
enable_skills=True,
|
||||||
max_context_tokens=max_context_tokens
|
max_context_tokens=max_context_tokens,
|
||||||
|
runtime_info=runtime_info # Pass runtime_info for dynamic time updates
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attach memory manager
|
# Attach memory manager
|
||||||
@@ -121,7 +123,7 @@ class AgentInitializer:
|
|||||||
|
|
||||||
def _load_env_file(self):
|
def _load_env_file(self):
|
||||||
"""Load environment variables from .env file"""
|
"""Load environment variables from .env file"""
|
||||||
env_file = os.path.expanduser("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -217,6 +219,13 @@ class AgentInitializer:
|
|||||||
|
|
||||||
for tool_name in tool_manager.tool_classes.keys():
|
for tool_name in tool_manager.tool_classes.keys():
|
||||||
try:
|
try:
|
||||||
|
# Skip web_search if no API key is available
|
||||||
|
if tool_name == "web_search":
|
||||||
|
from agent.tools.web_search.web_search import WebSearch
|
||||||
|
if not WebSearch.is_available():
|
||||||
|
logger.debug("[AgentInitializer] WebSearch skipped - no BOCHA_API_KEY or LINKAI_API_KEY")
|
||||||
|
continue
|
||||||
|
|
||||||
# Special handling for EnvConfig tool
|
# Special handling for EnvConfig tool
|
||||||
if tool_name == "env_config":
|
if tool_name == "env_config":
|
||||||
from agent.tools import EnvConfig
|
from agent.tools import EnvConfig
|
||||||
@@ -282,41 +291,47 @@ class AgentInitializer:
|
|||||||
"""Initialize skill manager"""
|
"""Initialize skill manager"""
|
||||||
try:
|
try:
|
||||||
from agent.skills import SkillManager
|
from agent.skills import SkillManager
|
||||||
skill_manager = SkillManager(workspace_dir=workspace_root)
|
skill_manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||||
return skill_manager
|
return skill_manager
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentInitializer] Failed to initialize SkillManager: {e}")
|
logger.warning(f"[AgentInitializer] Failed to initialize SkillManager: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_runtime_info(self, workspace_root: str):
|
def _get_runtime_info(self, workspace_root: str):
|
||||||
"""Get runtime information"""
|
"""Get runtime information with dynamic time support"""
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
now = datetime.datetime.now()
|
def get_current_time():
|
||||||
|
"""Get current time dynamically - called each time system prompt is accessed"""
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
|
||||||
# Get timezone info
|
# Get timezone info
|
||||||
try:
|
try:
|
||||||
offset = -time.timezone if not time.daylight else -time.altzone
|
offset = -time.timezone if not time.daylight else -time.altzone
|
||||||
hours = offset // 3600
|
hours = offset // 3600
|
||||||
minutes = (offset % 3600) // 60
|
minutes = (offset % 3600) // 60
|
||||||
timezone_name = f"UTC{hours:+03d}:{minutes:02d}" if minutes else f"UTC{hours:+03d}"
|
timezone_name = f"UTC{hours:+03d}:{minutes:02d}" if minutes else f"UTC{hours:+03d}"
|
||||||
except Exception:
|
except Exception:
|
||||||
timezone_name = "UTC"
|
timezone_name = "UTC"
|
||||||
|
|
||||||
# Chinese weekday mapping
|
# Chinese weekday mapping
|
||||||
weekday_map = {
|
weekday_map = {
|
||||||
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
|
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
|
||||||
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日'
|
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日'
|
||||||
}
|
}
|
||||||
weekday_zh = weekday_map.get(now.strftime("%A"), now.strftime("%A"))
|
weekday_zh = weekday_map.get(now.strftime("%A"), now.strftime("%A"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'time': now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
'weekday': weekday_zh,
|
||||||
|
'timezone': timezone_name
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model": conf().get("model", "unknown"),
|
"model": conf().get("model", "unknown"),
|
||||||
"workspace": workspace_root,
|
"workspace": workspace_root,
|
||||||
"channel": conf().get("channel_type", "unknown"),
|
"channel": conf().get("channel_type", "unknown"),
|
||||||
"current_time": now.strftime("%Y-%m-%d %H:%M:%S"),
|
"_get_current_time": get_current_time # Dynamic time function
|
||||||
"weekday": weekday_zh,
|
|
||||||
"timezone": timezone_name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _migrate_config_to_env(self, workspace_root: str):
|
def _migrate_config_to_env(self, workspace_root: str):
|
||||||
@@ -331,7 +346,7 @@ class AgentInitializer:
|
|||||||
"linkai_api_key": "LINKAI_API_KEY",
|
"linkai_api_key": "LINKAI_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
env_file = os.path.expanduser("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
|
|
||||||
# Read existing env vars
|
# Read existing env vars
|
||||||
existing_env_vars = {}
|
existing_env_vars = {}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ class Bridge(object):
|
|||||||
self.btype["chat"] = bot_type
|
self.btype["chat"] = bot_type
|
||||||
else:
|
else:
|
||||||
model_type = conf().get("model") or const.GPT_41_MINI
|
model_type = conf().get("model") or const.GPT_41_MINI
|
||||||
|
|
||||||
|
# Ensure model_type is string to prevent AttributeError when using startswith()
|
||||||
|
# This handles cases where numeric model names (e.g., "1") are parsed as integers from YAML
|
||||||
|
if not isinstance(model_type, str):
|
||||||
|
logger.warning(f"[Bridge] model_type is not a string: {model_type} (type: {type(model_type).__name__}), converting to string")
|
||||||
|
model_type = str(model_type)
|
||||||
|
|
||||||
if model_type in ["text-davinci-003"]:
|
if model_type in ["text-davinci-003"]:
|
||||||
self.btype["chat"] = const.OPEN_AI
|
self.btype["chat"] = const.OPEN_AI
|
||||||
if conf().get("use_azure_chatgpt", False):
|
if conf().get("use_azure_chatgpt", False):
|
||||||
@@ -48,6 +55,11 @@ class Bridge(object):
|
|||||||
|
|
||||||
if model_type in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
if model_type in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
||||||
self.btype["chat"] = const.MOONSHOT
|
self.btype["chat"] = const.MOONSHOT
|
||||||
|
if model_type and model_type.startswith("kimi"):
|
||||||
|
self.btype["chat"] = const.MOONSHOT
|
||||||
|
|
||||||
|
if model_type and model_type.startswith("doubao"):
|
||||||
|
self.btype["chat"] = const.DOUBAO
|
||||||
|
|
||||||
if model_type in [const.MODELSCOPE]:
|
if model_type in [const.MODELSCOPE]:
|
||||||
self.btype["chat"] = const.MODELSCOPE
|
self.btype["chat"] = const.MODELSCOPE
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ class Channel(object):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""
|
||||||
|
stop channel gracefully, called before restart
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def handle_text(self, msg):
|
def handle_text(self, msg):
|
||||||
"""
|
"""
|
||||||
process received msg
|
process received msg
|
||||||
@@ -51,11 +57,14 @@ class Channel(object):
|
|||||||
if context and "channel_type" not in context:
|
if context and "channel_type" not in context:
|
||||||
context["channel_type"] = self.channel_type
|
context["channel_type"] = self.channel_type
|
||||||
|
|
||||||
|
# Read on_event callback injected by the channel (e.g. web SSE)
|
||||||
|
on_event = context.get("on_event") if context else None
|
||||||
|
|
||||||
# Use agent bridge to handle the query
|
# Use agent bridge to handle the query
|
||||||
return Bridge().fetch_agent_reply(
|
return Bridge().fetch_agent_reply(
|
||||||
query=query,
|
query=query,
|
||||||
context=context,
|
context=context,
|
||||||
on_event=None,
|
on_event=on_event,
|
||||||
clear_history=False
|
clear_history=False
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from dingtalk_stream.card_replier import CardReplier
|
|||||||
from bridge.context import Context, ContextType
|
from bridge.context import Context, ContextType
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel
|
from channel.chat_channel import ChatChannel
|
||||||
|
from common.utils import expand_path
|
||||||
from channel.dingtalk.dingtalk_message import DingTalkMessage
|
from channel.dingtalk.dingtalk_message import DingTalkMessage
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -89,13 +90,9 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
dingtalk_client_secret = conf().get('dingtalk_client_secret')
|
dingtalk_client_secret = conf().get('dingtalk_client_secret')
|
||||||
|
|
||||||
def setup_logger(self):
|
def setup_logger(self):
|
||||||
logger = logging.getLogger()
|
# Suppress verbose logs from dingtalk_stream SDK
|
||||||
handler = logging.StreamHandler()
|
logging.getLogger("dingtalk_stream").setLevel(logging.WARNING)
|
||||||
handler.setFormatter(
|
return logging.getLogger("DingTalk")
|
||||||
logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]'))
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
return logger
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -103,6 +100,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
self.logger = self.setup_logger()
|
self.logger = self.setup_logger()
|
||||||
# 历史消息id暂存,用于幂等控制
|
# 历史消息id暂存,用于幂等控制
|
||||||
self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600))
|
self.receivedMsgs = ExpiredDict(conf().get("expires_in_seconds", 3600))
|
||||||
|
self._stream_client = None
|
||||||
logger.debug("[DingTalk] client_id={}, client_secret={} ".format(
|
logger.debug("[DingTalk] client_id={}, client_secret={} ".format(
|
||||||
self.dingtalk_client_id, self.dingtalk_client_secret))
|
self.dingtalk_client_id, self.dingtalk_client_secret))
|
||||||
# 无需群校验和前缀
|
# 无需群校验和前缀
|
||||||
@@ -118,10 +116,20 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
def startup(self):
|
def startup(self):
|
||||||
credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret)
|
credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret)
|
||||||
client = dingtalk_stream.DingTalkStreamClient(credential)
|
client = dingtalk_stream.DingTalkStreamClient(credential)
|
||||||
|
self._stream_client = client
|
||||||
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
|
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
|
||||||
logger.info("[DingTalk] ✅ Stream connected, ready to receive messages")
|
logger.info("[DingTalk] ✅ Stream connected, ready to receive messages")
|
||||||
client.start_forever()
|
client.start_forever()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._stream_client:
|
||||||
|
try:
|
||||||
|
self._stream_client.stop()
|
||||||
|
logger.info("[DingTalk] Stream client stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[DingTalk] Error stopping stream client: {e}")
|
||||||
|
self._stream_client = None
|
||||||
|
|
||||||
def get_access_token(self):
|
def get_access_token(self):
|
||||||
"""
|
"""
|
||||||
获取企业内部应用的 access_token
|
获取企业内部应用的 access_token
|
||||||
@@ -276,7 +284,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
|
|
||||||
# 保存到临时文件
|
# 保存到临时文件
|
||||||
file_name = os.path.basename(file_path) or f"media_{uuid.uuid4()}"
|
file_name = os.path.basename(file_path) or f"media_{uuid.uuid4()}"
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
tmp_dir = os.path.join(workspace_root, "tmp")
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
temp_file = os.path.join(tmp_dir, file_name)
|
temp_file = os.path.join(tmp_dir, file_name)
|
||||||
@@ -607,7 +615,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
|
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
logger.info(f"[DingTalk] send() called with reply.type={reply.type}, content_length={len(str(reply.content))}")
|
logger.debug(f"[DingTalk] send() called with reply.type={reply.type}, content_length={len(str(reply.content))}")
|
||||||
receiver = context["receiver"]
|
receiver = context["receiver"]
|
||||||
|
|
||||||
# Check if msg exists (for scheduled tasks, msg might be None)
|
# Check if msg exists (for scheduled tasks, msg might be None)
|
||||||
@@ -647,7 +655,7 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
robot_code = msg.robot_code
|
robot_code = msg.robot_code
|
||||||
if robot_code and robot_code != self._robot_code:
|
if robot_code and robot_code != self._robot_code:
|
||||||
self._robot_code = robot_code
|
self._robot_code = robot_code
|
||||||
logger.info(f"[DingTalk] Cached robot_code: {robot_code}")
|
logger.debug(f"[DingTalk] Cached robot_code: {robot_code}")
|
||||||
|
|
||||||
isgroup = msg.is_group
|
isgroup = msg.is_group
|
||||||
incoming_message = msg.incoming_message
|
incoming_message = msg.incoming_message
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from channel.chat_message import ChatMessage
|
|||||||
# -*- coding=utf-8 -*-
|
# -*- coding=utf-8 -*-
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.tmp_dir import TmpDir
|
from common.tmp_dir import TmpDir
|
||||||
|
from common.utils import expand_path
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ class DingTalkMessage(ChatMessage):
|
|||||||
download_url = image_download_handler.get_image_download_url(download_code)
|
download_url = image_download_handler.get_image_download_url(download_code)
|
||||||
|
|
||||||
# 下载到工作空间 tmp 目录
|
# 下载到工作空间 tmp 目录
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
tmp_dir = os.path.join(workspace_root, "tmp")
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ class DingTalkMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.TEXT
|
self.ctype = ContextType.TEXT
|
||||||
|
|
||||||
# 下载到工作空间 tmp 目录
|
# 下载到工作空间 tmp 目录
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
tmp_dir = os.path.join(workspace_root, "tmp")
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,23 @@ python3 app.py
|
|||||||
|
|
||||||
**解决**: 安装依赖 `pip install lark-oapi`
|
**解决**: 安装依赖 `pip install lark-oapi`
|
||||||
|
|
||||||
|
### SSL证书验证失败
|
||||||
|
|
||||||
|
```
|
||||||
|
[Lark][ERROR] connect failed, err:[SSL:CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**: 网络环境中存在自签名证书或SSL中间人代理(如企业代理、VPN等)
|
||||||
|
|
||||||
|
**解决**: 程序会自动检测SSL证书验证失败,并自动重试禁用证书验证的连接。无需手动配置。
|
||||||
|
|
||||||
|
当遇到证书错误时,日志会显示:
|
||||||
|
```
|
||||||
|
[FeiShu] SSL certificate verification disabled due to certificate error. This may happen when using corporate proxy or self-signed certificates.
|
||||||
|
```
|
||||||
|
|
||||||
|
这是正常现象,程序会自动处理并继续运行。
|
||||||
|
|
||||||
### Webhook模式端口被占用
|
### Webhook模式端口被占用
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
# -*- coding=utf-8 -*-
|
# -*- coding=utf-8 -*-
|
||||||
import uuid
|
import uuid
|
||||||
@@ -31,6 +33,9 @@ from common.log import logger
|
|||||||
from common.singleton import singleton
|
from common.singleton import singleton
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
|
# Suppress verbose logs from Lark SDK
|
||||||
|
logging.getLogger("Lark").setLevel(logging.WARNING)
|
||||||
|
|
||||||
URL_VERIFICATION = "url_verification"
|
URL_VERIFICATION = "url_verification"
|
||||||
|
|
||||||
# 尝试导入飞书SDK,如果未安装则websocket模式不可用
|
# 尝试导入飞书SDK,如果未安装则websocket模式不可用
|
||||||
@@ -55,6 +60,7 @@ class FeiShuChanel(ChatChannel):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
# 历史消息id暂存,用于幂等控制
|
# 历史消息id暂存,用于幂等控制
|
||||||
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
||||||
|
self._http_server = None
|
||||||
logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format(
|
logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format(
|
||||||
self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode))
|
self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode))
|
||||||
# 无需群校验和前缀
|
# 无需群校验和前缀
|
||||||
@@ -72,6 +78,15 @@ class FeiShuChanel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
self._startup_webhook()
|
self._startup_webhook()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._http_server:
|
||||||
|
try:
|
||||||
|
self._http_server.stop()
|
||||||
|
logger.info("[FeiShu] HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Error stopping HTTP server: {e}")
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
def _startup_webhook(self):
|
def _startup_webhook(self):
|
||||||
"""启动HTTP服务器接收事件(webhook模式)"""
|
"""启动HTTP服务器接收事件(webhook模式)"""
|
||||||
logger.debug("[FeiShu] Starting in webhook mode...")
|
logger.debug("[FeiShu] Starting in webhook mode...")
|
||||||
@@ -80,7 +95,14 @@ class FeiShuChanel(ChatChannel):
|
|||||||
)
|
)
|
||||||
app = web.application(urls, globals(), autoreload=False)
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
port = conf().get("feishu_port", 9891)
|
port = conf().get("feishu_port", 9891)
|
||||||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
|
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||||
|
func = web.httpserver.LogMiddleware(func)
|
||||||
|
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||||
|
self._http_server = server
|
||||||
|
try:
|
||||||
|
server.start()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
server.stop()
|
||||||
|
|
||||||
def _startup_websocket(self):
|
def _startup_websocket(self):
|
||||||
"""启动长连接接收事件(websocket模式)"""
|
"""启动长连接接收事件(websocket模式)"""
|
||||||
@@ -107,23 +129,65 @@ class FeiShuChanel(ChatChannel):
|
|||||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
.register_p2_im_message_receive_v1(handle_message_event) \
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
# 创建长连接客户端
|
# 尝试连接,如果遇到SSL错误则自动禁用证书验证
|
||||||
ws_client = lark.ws.Client(
|
def start_client_with_retry():
|
||||||
self.feishu_app_id,
|
"""启动websocket客户端,自动处理SSL证书错误"""
|
||||||
self.feishu_app_secret,
|
# 全局禁用SSL证书验证(在导入lark_oapi之前设置)
|
||||||
event_handler=event_handler,
|
import ssl as ssl_module
|
||||||
log_level=lark.LogLevel.DEBUG if conf().get("debug") else lark.LogLevel.INFO
|
|
||||||
)
|
# 保存原始的SSL上下文创建方法
|
||||||
|
original_create_default_context = ssl_module.create_default_context
|
||||||
|
|
||||||
|
def create_unverified_context(*args, **kwargs):
|
||||||
|
"""创建一个不验证证书的SSL上下文"""
|
||||||
|
context = original_create_default_context(*args, **kwargs)
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
return context
|
||||||
|
|
||||||
|
# 尝试正常连接,如果失败则禁用SSL验证
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
if attempt == 1:
|
||||||
|
# 第二次尝试:禁用SSL验证
|
||||||
|
logger.warning("[FeiShu] SSL certificate verification disabled due to certificate error. "
|
||||||
|
"This may happen when using corporate proxy or self-signed certificates.")
|
||||||
|
ssl_module.create_default_context = create_unverified_context
|
||||||
|
ssl_module._create_unverified_context = create_unverified_context
|
||||||
|
|
||||||
|
ws_client = lark.ws.Client(
|
||||||
|
self.feishu_app_id,
|
||||||
|
self.feishu_app_secret,
|
||||||
|
event_handler=event_handler,
|
||||||
|
log_level=lark.LogLevel.DEBUG if conf().get("debug") else lark.LogLevel.WARNING
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("[FeiShu] Websocket client starting...")
|
||||||
|
ws_client.start()
|
||||||
|
# 如果成功启动,跳出循环
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
# 检查是否是SSL证书验证错误
|
||||||
|
is_ssl_error = "CERTIFICATE_VERIFY_FAILED" in error_msg or "certificate verify failed" in error_msg.lower()
|
||||||
|
|
||||||
|
if is_ssl_error and attempt == 0:
|
||||||
|
# 第一次遇到SSL错误,记录日志并继续循环(下次会禁用验证)
|
||||||
|
logger.warning(f"[FeiShu] SSL certificate verification failed: {error_msg}")
|
||||||
|
logger.info("[FeiShu] Retrying connection with SSL verification disabled...")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 其他错误或禁用验证后仍失败,抛出异常
|
||||||
|
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
||||||
|
# 恢复原始方法
|
||||||
|
ssl_module.create_default_context = original_create_default_context
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 注意:不恢复原始方法,因为ws_client.start()会持续运行
|
||||||
|
|
||||||
# 在新线程中启动客户端,避免阻塞主线程
|
# 在新线程中启动客户端,避免阻塞主线程
|
||||||
def start_client():
|
ws_thread = threading.Thread(target=start_client_with_retry, daemon=True)
|
||||||
try:
|
|
||||||
logger.debug("[FeiShu] Websocket client starting...")
|
|
||||||
ws_client.start()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
|
||||||
|
|
||||||
ws_thread = threading.Thread(target=start_client, daemon=True)
|
|
||||||
ws_thread.start()
|
ws_thread.start()
|
||||||
|
|
||||||
# 保持主线程运行
|
# 保持主线程运行
|
||||||
@@ -277,7 +341,8 @@ class FeiShuChanel(ChatChannel):
|
|||||||
# 错误码 230055 说明:上传 mp4 时必须使用 msg_type="media"
|
# 错误码 230055 说明:上传 mp4 时必须使用 msg_type="media"
|
||||||
msg_type = "media"
|
msg_type = "media"
|
||||||
reply_content = upload_data # 完整的上传响应数据(包含file_key和duration)
|
reply_content = upload_data # 完整的上传响应数据(包含file_key和duration)
|
||||||
logger.info(f"[FeiShu] Sending video: file_key={upload_data.get('file_key')}, duration={upload_data.get('duration')}ms")
|
logger.info(
|
||||||
|
f"[FeiShu] Sending video: file_key={upload_data.get('file_key')}, duration={upload_data.get('duration')}ms")
|
||||||
content_key = None # 直接序列化整个对象
|
content_key = None # 直接序列化整个对象
|
||||||
else:
|
else:
|
||||||
# 其他文件使用 file 类型
|
# 其他文件使用 file 类型
|
||||||
@@ -320,7 +385,6 @@ class FeiShuChanel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
|
|
||||||
def fetch_access_token(self) -> str:
|
def fetch_access_token(self) -> str:
|
||||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
|
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -342,7 +406,6 @@ class FeiShuChanel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] fetch token error, res={response}")
|
logger.error(f"[FeiShu] fetch token error, res={response}")
|
||||||
|
|
||||||
|
|
||||||
def _upload_image_url(self, img_url, access_token):
|
def _upload_image_url(self, img_url, access_token):
|
||||||
logger.debug(f"[FeiShu] start process image, img_url={img_url}")
|
logger.debug(f"[FeiShu] start process image, img_url={img_url}")
|
||||||
|
|
||||||
@@ -501,14 +564,16 @@ class FeiShuChanel(ChatChannel):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=(5, 60)
|
timeout=(5, 60)
|
||||||
)
|
)
|
||||||
logger.info(f"[FeiShu] upload video response, status={upload_response.status_code}, res={upload_response.content}")
|
logger.info(
|
||||||
|
f"[FeiShu] upload video response, status={upload_response.status_code}, res={upload_response.content}")
|
||||||
|
|
||||||
response_data = upload_response.json()
|
response_data = upload_response.json()
|
||||||
if response_data.get("code") == 0:
|
if response_data.get("code") == 0:
|
||||||
# Add duration to the response data (API doesn't return it)
|
# Add duration to the response data (API doesn't return it)
|
||||||
upload_data = response_data.get("data")
|
upload_data = response_data.get("data")
|
||||||
upload_data['duration'] = duration # Add our calculated duration
|
upload_data['duration'] = duration # Add our calculated duration
|
||||||
logger.info(f"[FeiShu] Upload complete: file_key={upload_data.get('file_key')}, duration={duration}ms")
|
logger.info(
|
||||||
|
f"[FeiShu] Upload complete: file_key={upload_data.get('file_key')}, duration={duration}ms")
|
||||||
return upload_data
|
return upload_data
|
||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] upload video failed: {response_data}")
|
logger.error(f"[FeiShu] upload video failed: {response_data}")
|
||||||
@@ -572,7 +637,8 @@ class FeiShuChanel(ChatChannel):
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=(5, 30) # 5s connect, 30s read timeout
|
timeout=(5, 30) # 5s connect, 30s read timeout
|
||||||
)
|
)
|
||||||
logger.info(f"[FeiShu] upload file response, status={upload_response.status_code}, res={upload_response.content}")
|
logger.info(
|
||||||
|
f"[FeiShu] upload file response, status={upload_response.status_code}, res={upload_response.content}")
|
||||||
|
|
||||||
response_data = upload_response.json()
|
response_data = upload_response.json()
|
||||||
if response_data.get("code") == 0:
|
if response_data.get("code") == 0:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import requests
|
|||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.tmp_dir import TmpDir
|
from common.tmp_dir import TmpDir
|
||||||
from common import utils
|
from common import utils
|
||||||
|
from common.utils import expand_path
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ class FeishuMessage(ChatMessage):
|
|||||||
image_key = content.get("image_key")
|
image_key = content.get("image_key")
|
||||||
|
|
||||||
# 下载图片到工作空间临时目录
|
# 下载图片到工作空间临时目录
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
tmp_dir = os.path.join(workspace_root, "tmp")
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
image_path = os.path.join(tmp_dir, f"{image_key}.png")
|
image_path = os.path.join(tmp_dir, f"{image_key}.png")
|
||||||
@@ -97,7 +98,7 @@ class FeishuMessage(ChatMessage):
|
|||||||
|
|
||||||
if image_keys:
|
if image_keys:
|
||||||
# 如果包含图片,下载并在文本中引用本地路径
|
# 如果包含图片,下载并在文本中引用本地路径
|
||||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
tmp_dir = os.path.join(workspace_root, "tmp")
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
239
channel/web/static/css/console.css
Normal file
239
channel/web/static/css/console.css
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
CowAgent Console Styles
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes pulseDot {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: #94a3b8 transparent; }
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||||
|
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.sidebar-item.active .item-icon { color: #4ABE6E; }
|
||||||
|
|
||||||
|
/* Menu Groups */
|
||||||
|
.menu-group-items { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out; }
|
||||||
|
.menu-group.open .menu-group-items { max-height: 500px; transition: max-height 0.35s ease-in; }
|
||||||
|
.menu-group .chevron { transition: transform 0.25s ease; }
|
||||||
|
.menu-group.open .chevron { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* View Switching */
|
||||||
|
.view { display: none; height: 100%; }
|
||||||
|
.view.active { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* Markdown Content */
|
||||||
|
.msg-content p { margin: 0.5em 0; line-height: 1.7; }
|
||||||
|
.msg-content p:first-child { margin-top: 0; }
|
||||||
|
.msg-content p:last-child { margin-bottom: 0; }
|
||||||
|
.msg-content h1, .msg-content h2, .msg-content h3,
|
||||||
|
.msg-content h4, .msg-content h5, .msg-content h6 {
|
||||||
|
margin-top: 1.2em; margin-bottom: 0.6em; font-weight: 600; line-height: 1.3;
|
||||||
|
}
|
||||||
|
.msg-content h1 { font-size: 1.4em; }
|
||||||
|
.msg-content h2 { font-size: 1.25em; }
|
||||||
|
.msg-content h3 { font-size: 1.1em; }
|
||||||
|
.msg-content ul, .msg-content ol { margin: 0.5em 0; padding-left: 1.8em; }
|
||||||
|
.msg-content li { margin: 0.25em 0; }
|
||||||
|
.msg-content pre {
|
||||||
|
border-radius: 8px; overflow-x: auto; margin: 0.8em 0;
|
||||||
|
background: #f1f5f9; padding: 1em;
|
||||||
|
}
|
||||||
|
.dark .msg-content pre { background: #111111; }
|
||||||
|
.msg-content code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.msg-content :not(pre) > code {
|
||||||
|
background: rgba(74, 190, 110, 0.1); color: #1C6B3B;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dark .msg-content :not(pre) > code {
|
||||||
|
background: rgba(74, 190, 110, 0.15); color: #74E9A4;
|
||||||
|
}
|
||||||
|
.msg-content pre code { background: transparent; padding: 0; color: inherit; }
|
||||||
|
.msg-content blockquote {
|
||||||
|
border-left: 3px solid #4ABE6E; padding: 0.5em 1em;
|
||||||
|
margin: 0.8em 0; background: rgba(74, 190, 110, 0.05); border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
.dark .msg-content blockquote { background: rgba(74, 190, 110, 0.08); }
|
||||||
|
.msg-content table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||||
|
.msg-content th, .msg-content td {
|
||||||
|
border: 1px solid #e2e8f0; padding: 8px 12px; text-align: left;
|
||||||
|
}
|
||||||
|
.dark .msg-content th, .dark .msg-content td { border-color: rgba(255,255,255,0.1); }
|
||||||
|
.msg-content th { background: #f1f5f9; font-weight: 600; }
|
||||||
|
.dark .msg-content th { background: #111111; }
|
||||||
|
.msg-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; }
|
||||||
|
.msg-content a { color: #35A85B; text-decoration: underline; }
|
||||||
|
.msg-content a:hover { color: #228547; }
|
||||||
|
.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; }
|
||||||
|
.dark .msg-content hr { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
/* SSE Streaming cursor */
|
||||||
|
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||||
|
.sse-streaming::after {
|
||||||
|
content: '▋';
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 2px;
|
||||||
|
color: #4ABE6E;
|
||||||
|
animation: blink 0.9s step-end infinite;
|
||||||
|
font-size: 0.85em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent steps (thinking summaries + tool indicators) */
|
||||||
|
.agent-steps:empty { display: none; }
|
||||||
|
.agent-steps:not(:empty) {
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.dark .agent-steps:not(:empty) { border-bottom-color: rgba(255, 255, 255, 0.08); }
|
||||||
|
|
||||||
|
.agent-step {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.agent-step:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Thinking step - collapsible */
|
||||||
|
.agent-thinking-step .thinking-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.agent-thinking-step .thinking-header.no-toggle { cursor: default; }
|
||||||
|
.agent-thinking-step .thinking-header:not(.no-toggle):hover { color: #64748b; }
|
||||||
|
.dark .agent-thinking-step .thinking-header:not(.no-toggle):hover { color: #cbd5e1; }
|
||||||
|
.agent-thinking-step .thinking-header i:first-child { font-size: 0.625rem; margin-top: 1px; }
|
||||||
|
.agent-thinking-step .thinking-chevron {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.agent-thinking-step.expanded .thinking-chevron { transform: rotate(90deg); }
|
||||||
|
.agent-thinking-step .thinking-full {
|
||||||
|
display: none;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #94a3b8;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.dark .agent-thinking-step .thinking-full {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.agent-thinking-step.expanded .thinking-full { display: block; }
|
||||||
|
.agent-thinking-step .thinking-full p { margin: 0.25em 0; }
|
||||||
|
.agent-thinking-step .thinking-full p:first-child { margin-top: 0; }
|
||||||
|
.agent-thinking-step .thinking-full p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Tool step - collapsible */
|
||||||
|
.agent-tool-step .tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.agent-tool-step .tool-header:hover { color: #64748b; }
|
||||||
|
.dark .agent-tool-step .tool-header:hover { color: #cbd5e1; }
|
||||||
|
.agent-tool-step .tool-icon { font-size: 0.625rem; }
|
||||||
|
.agent-tool-step .tool-chevron {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.agent-tool-step.expanded .tool-chevron { transform: rotate(90deg); }
|
||||||
|
.agent-tool-step .tool-time {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool detail panel */
|
||||||
|
.agent-tool-step .tool-detail {
|
||||||
|
display: none;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.dark .agent-tool-step .tool-detail {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.agent-tool-step.expanded .tool-detail { display: block; }
|
||||||
|
.tool-detail-section { margin-bottom: 0.375rem; }
|
||||||
|
.tool-detail-section:last-child { margin-bottom: 0; }
|
||||||
|
.tool-detail-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
.tool-detail-content {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.tool-error-text { color: #f87171; }
|
||||||
|
|
||||||
|
/* Tool failed state */
|
||||||
|
.agent-tool-step.tool-failed .tool-name { color: #f87171; }
|
||||||
|
|
||||||
|
/* Chat Input */
|
||||||
|
#chat-input {
|
||||||
|
resize: none; height: 42px; max-height: 180px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder Cards */
|
||||||
|
.placeholder-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.placeholder-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
971
channel/web/static/js/console.js
Normal file
971
channel/web/static/js/console.js
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
CowAgent Console - Main Application Script
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Version — update this before each release
|
||||||
|
// =====================================================================
|
||||||
|
const APP_VERSION = 'v2.0.1';
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// i18n
|
||||||
|
// =====================================================================
|
||||||
|
const I18N = {
|
||||||
|
zh: {
|
||||||
|
console: '控制台',
|
||||||
|
nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控',
|
||||||
|
menu_chat: '对话', menu_config: '配置', menu_skills: '技能',
|
||||||
|
menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时',
|
||||||
|
menu_logs: '日志',
|
||||||
|
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆<br>不断成长',
|
||||||
|
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||||
|
example_task_title: '智能任务', example_task_text: '提醒我5分钟后查看服务器情况',
|
||||||
|
example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本',
|
||||||
|
input_placeholder: '输入消息...',
|
||||||
|
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
|
||||||
|
config_model: '模型配置', config_agent: 'Agent 配置',
|
||||||
|
config_channel: '通道配置',
|
||||||
|
config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token',
|
||||||
|
config_max_turns: '最大轮次', config_max_steps: '最大步数',
|
||||||
|
config_channel_type: '通道类型',
|
||||||
|
config_coming_soon: '完整编辑功能即将推出,当前为只读展示。',
|
||||||
|
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能',
|
||||||
|
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
|
||||||
|
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
|
||||||
|
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
|
||||||
|
memory_back: '返回列表',
|
||||||
|
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间',
|
||||||
|
channels_title: '通道管理', channels_desc: '查看和管理消息通道',
|
||||||
|
channels_coming: '即将推出', channels_coming_desc: '通道管理功能即将在此提供',
|
||||||
|
tasks_title: '定时任务', tasks_desc: '查看和管理定时任务',
|
||||||
|
tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供',
|
||||||
|
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||||
|
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||||
|
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
console: 'Console',
|
||||||
|
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
|
||||||
|
menu_chat: 'Chat', menu_config: 'Config', menu_skills: 'Skills',
|
||||||
|
menu_memory: 'Memory', menu_channels: 'Channels', menu_tasks: 'Tasks',
|
||||||
|
menu_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_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',
|
||||||
|
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
|
||||||
|
config_channel: 'Channel Configuration',
|
||||||
|
config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens',
|
||||||
|
config_max_turns: 'Max Turns', config_max_steps: 'Max Steps',
|
||||||
|
config_channel_type: 'Channel Type',
|
||||||
|
config_coming_soon: 'Full editing capability coming soon. Currently displaying read-only configuration.',
|
||||||
|
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills',
|
||||||
|
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
|
||||||
|
memory_title: 'Memory', memory_desc: 'View agent memory files and contents',
|
||||||
|
memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here',
|
||||||
|
memory_back: 'Back to list',
|
||||||
|
memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated',
|
||||||
|
channels_title: 'Channels', channels_desc: 'View and manage messaging channels',
|
||||||
|
channels_coming: 'Coming Soon', channels_coming_desc: 'Channel management will be available here',
|
||||||
|
tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks',
|
||||||
|
tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here',
|
||||||
|
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||||
|
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||||
|
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLang = localStorage.getItem('cow_lang') || 'zh';
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyI18n() {
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
el.textContent = t(el.dataset.i18n);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-html]').forEach(el => {
|
||||||
|
el.innerHTML = t(el.dataset.i18nHtml);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
|
el.placeholder = t(el.dataset['i18nPlaceholder']);
|
||||||
|
});
|
||||||
|
document.getElementById('lang-label').textContent = currentLang === 'zh' ? 'EN' : '中文';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLanguage() {
|
||||||
|
currentLang = currentLang === 'zh' ? 'en' : 'zh';
|
||||||
|
localStorage.setItem('cow_lang', currentLang);
|
||||||
|
applyI18n();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Theme
|
||||||
|
// =====================================================================
|
||||||
|
let currentTheme = localStorage.getItem('cow_theme') || 'dark';
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
document.getElementById('theme-icon').className = 'fas fa-sun';
|
||||||
|
document.getElementById('hljs-light').disabled = true;
|
||||||
|
document.getElementById('hljs-dark').disabled = false;
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
document.getElementById('theme-icon').className = 'fas fa-moon';
|
||||||
|
document.getElementById('hljs-light').disabled = false;
|
||||||
|
document.getElementById('hljs-dark').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('cow_theme', currentTheme);
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Sidebar & Navigation
|
||||||
|
// =====================================================================
|
||||||
|
const VIEW_META = {
|
||||||
|
chat: { group: 'nav_chat', page: 'menu_chat' },
|
||||||
|
config: { group: 'nav_manage', page: 'menu_config' },
|
||||||
|
skills: { group: 'nav_manage', page: 'menu_skills' },
|
||||||
|
memory: { group: 'nav_manage', page: 'menu_memory' },
|
||||||
|
channels: { group: 'nav_manage', page: 'menu_channels' },
|
||||||
|
tasks: { group: 'nav_manage', page: 'menu_tasks' },
|
||||||
|
logs: { group: 'nav_monitor', page: 'menu_logs' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentView = 'chat';
|
||||||
|
|
||||||
|
function navigateTo(viewId) {
|
||||||
|
if (!VIEW_META[viewId]) return;
|
||||||
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||||
|
const target = document.getElementById('view-' + viewId);
|
||||||
|
if (target) target.classList.add('active');
|
||||||
|
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.view === viewId);
|
||||||
|
});
|
||||||
|
const meta = VIEW_META[viewId];
|
||||||
|
document.getElementById('breadcrumb-group').textContent = t(meta.group);
|
||||||
|
document.getElementById('breadcrumb-group').dataset.i18n = meta.group;
|
||||||
|
document.getElementById('breadcrumb-page').textContent = t(meta.page);
|
||||||
|
document.getElementById('breadcrumb-page').dataset.i18n = meta.page;
|
||||||
|
currentView = viewId;
|
||||||
|
if (window.innerWidth < 1024) closeSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebar-overlay');
|
||||||
|
const isOpen = !sidebar.classList.contains('-translate-x-full');
|
||||||
|
if (isOpen) {
|
||||||
|
closeSidebar();
|
||||||
|
} else {
|
||||||
|
sidebar.classList.remove('-translate-x-full');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
document.getElementById('sidebar').classList.add('-translate-x-full');
|
||||||
|
document.getElementById('sidebar-overlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.menu-group > button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
btn.parentElement.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => navigateTo(item.dataset.view));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (window.innerWidth >= 1024) {
|
||||||
|
document.getElementById('sidebar').classList.remove('-translate-x-full');
|
||||||
|
document.getElementById('sidebar-overlay').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
if (!document.getElementById('sidebar').classList.contains('-translate-x-full')) {
|
||||||
|
closeSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Markdown Renderer
|
||||||
|
// =====================================================================
|
||||||
|
function createMd() {
|
||||||
|
const md = window.markdownit({
|
||||||
|
html: false, breaks: true, linkify: true, typographer: true,
|
||||||
|
highlight: function(str, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try { return hljs.highlight(str, { language: lang }).value; } catch (_) {}
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(str).value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options);
|
||||||
|
};
|
||||||
|
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||||||
|
tokens[idx].attrPush(['target', '_blank']);
|
||||||
|
tokens[idx].attrPush(['rel', 'noopener noreferrer']);
|
||||||
|
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||||
|
};
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = createMd();
|
||||||
|
|
||||||
|
function renderMarkdown(text) {
|
||||||
|
try { return md.render(text); }
|
||||||
|
catch (e) { return text.replace(/\n/g, '<br>'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Chat Module
|
||||||
|
// =====================================================================
|
||||||
|
let sessionId = generateSessionId();
|
||||||
|
let isPolling = false;
|
||||||
|
let loadingContainers = {};
|
||||||
|
let activeStreams = {}; // request_id -> EventSource
|
||||||
|
let isComposing = false;
|
||||||
|
let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' };
|
||||||
|
|
||||||
|
function generateSessionId() {
|
||||||
|
return 'session_' + ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||||
|
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/config').then(r => r.json()).then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
appConfig = data;
|
||||||
|
const title = data.title || 'CowAgent';
|
||||||
|
document.getElementById('welcome-title').textContent = title;
|
||||||
|
document.getElementById('cfg-model').textContent = data.model || '--';
|
||||||
|
document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled';
|
||||||
|
document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--';
|
||||||
|
document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--';
|
||||||
|
document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
|
||||||
|
document.getElementById('cfg-channel').textContent = data.channel_type || '--';
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const messagesDiv = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
chatInput.addEventListener('compositionstart', () => { isComposing = true; });
|
||||||
|
chatInput.addEventListener('compositionend', () => { isComposing = false; });
|
||||||
|
|
||||||
|
chatInput.addEventListener('input', function() {
|
||||||
|
this.style.height = '42px';
|
||||||
|
const scrollH = this.scrollHeight;
|
||||||
|
const newH = Math.min(scrollH, 180);
|
||||||
|
this.style.height = newH + 'px';
|
||||||
|
this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden';
|
||||||
|
sendBtn.disabled = !this.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
chatInput.addEventListener('keydown', function(e) {
|
||||||
|
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
|
||||||
|
const start = this.selectionStart;
|
||||||
|
const end = this.selectionEnd;
|
||||||
|
this.value = this.value.substring(0, start) + '\n' + this.value.substring(end);
|
||||||
|
this.selectionStart = this.selectionEnd = start + 1;
|
||||||
|
this.dispatchEvent(new Event('input'));
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !isComposing) {
|
||||||
|
sendMessage();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.example-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const textEl = card.querySelector('[data-i18n*="text"]');
|
||||||
|
if (textEl) {
|
||||||
|
chatInput.value = textEl.textContent;
|
||||||
|
chatInput.dispatchEvent(new Event('input'));
|
||||||
|
chatInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const text = chatInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const ws = document.getElementById('welcome-screen');
|
||||||
|
if (ws) ws.remove();
|
||||||
|
|
||||||
|
const timestamp = new Date();
|
||||||
|
addUserMessage(text, timestamp);
|
||||||
|
|
||||||
|
const loadingEl = addLoadingIndicator();
|
||||||
|
|
||||||
|
chatInput.value = '';
|
||||||
|
chatInput.style.height = '42px';
|
||||||
|
chatInput.style.overflowY = 'hidden';
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
if (data.stream) {
|
||||||
|
startSSE(data.request_id, loadingEl, timestamp);
|
||||||
|
} else {
|
||||||
|
loadingContainers[data.request_id] = loadingEl;
|
||||||
|
if (!isPolling) startPolling();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadingEl.remove();
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
loadingEl.remove();
|
||||||
|
addBotMessage(err.name === 'AbortError' ? t('error_timeout') : t('error_send'), new Date());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSSE(requestId, loadingEl, timestamp) {
|
||||||
|
const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`);
|
||||||
|
activeStreams[requestId] = es;
|
||||||
|
|
||||||
|
let botEl = null;
|
||||||
|
let stepsEl = null; // .agent-steps (thinking summaries + tool indicators)
|
||||||
|
let contentEl = null; // .answer-content (final streaming answer)
|
||||||
|
let accumulatedText = '';
|
||||||
|
let currentToolEl = null;
|
||||||
|
|
||||||
|
function ensureBotEl() {
|
||||||
|
if (botEl) return;
|
||||||
|
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||||
|
botEl = document.createElement('div');
|
||||||
|
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3';
|
||||||
|
botEl.dataset.requestId = requestId;
|
||||||
|
botEl.innerHTML = `
|
||||||
|
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||||||
|
<div class="min-w-0 flex-1 max-w-[85%]">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
|
||||||
|
<div class="agent-steps"></div>
|
||||||
|
<div class="answer-content sse-streaming"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5">${formatTime(timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesDiv.appendChild(botEl);
|
||||||
|
stepsEl = botEl.querySelector('.agent-steps');
|
||||||
|
contentEl = botEl.querySelector('.answer-content');
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
let item;
|
||||||
|
try { item = JSON.parse(e.data); } catch (_) { return; }
|
||||||
|
|
||||||
|
if (item.type === 'delta') {
|
||||||
|
ensureBotEl();
|
||||||
|
accumulatedText += item.content;
|
||||||
|
contentEl.innerHTML = renderMarkdown(accumulatedText);
|
||||||
|
scrollChatToBottom();
|
||||||
|
|
||||||
|
} else if (item.type === 'tool_start') {
|
||||||
|
ensureBotEl();
|
||||||
|
|
||||||
|
// Save current thinking as a collapsible step
|
||||||
|
if (accumulatedText.trim()) {
|
||||||
|
const fullText = accumulatedText.trim();
|
||||||
|
const oneLine = fullText.replace(/\n+/g, ' ');
|
||||||
|
const needsTruncate = oneLine.length > 80;
|
||||||
|
const stepEl = document.createElement('div');
|
||||||
|
stepEl.className = 'agent-step agent-thinking-step' + (needsTruncate ? '' : ' no-expand');
|
||||||
|
if (needsTruncate) {
|
||||||
|
const truncated = oneLine.substring(0, 80) + '…';
|
||||||
|
stepEl.innerHTML = `
|
||||||
|
<div class="thinking-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||||
|
<i class="fas fa-lightbulb text-amber-400 flex-shrink-0"></i>
|
||||||
|
<span class="thinking-summary">${escapeHtml(truncated)}</span>
|
||||||
|
<i class="fas fa-chevron-right thinking-chevron"></i>
|
||||||
|
</div>
|
||||||
|
<div class="thinking-full">${renderMarkdown(fullText)}</div>`;
|
||||||
|
} else {
|
||||||
|
stepEl.innerHTML = `
|
||||||
|
<div class="thinking-header no-toggle">
|
||||||
|
<i class="fas fa-lightbulb text-amber-400 flex-shrink-0"></i>
|
||||||
|
<span>${escapeHtml(oneLine)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
stepsEl.appendChild(stepEl);
|
||||||
|
}
|
||||||
|
accumulatedText = '';
|
||||||
|
contentEl.innerHTML = '';
|
||||||
|
|
||||||
|
// Add tool execution indicator (collapsible)
|
||||||
|
currentToolEl = document.createElement('div');
|
||||||
|
currentToolEl.className = 'agent-step agent-tool-step';
|
||||||
|
const argsStr = formatToolArgs(item.arguments || {});
|
||||||
|
currentToolEl.innerHTML = `
|
||||||
|
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||||
|
<i class="fas fa-cog fa-spin text-primary-400 flex-shrink-0 tool-icon"></i>
|
||||||
|
<span class="tool-name">${item.tool}</span>
|
||||||
|
<i class="fas fa-chevron-right tool-chevron"></i>
|
||||||
|
</div>
|
||||||
|
<div class="tool-detail">
|
||||||
|
<div class="tool-detail-section">
|
||||||
|
<div class="tool-detail-label">Input</div>
|
||||||
|
<pre class="tool-detail-content">${argsStr}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="tool-detail-section tool-output-section"></div>
|
||||||
|
</div>`;
|
||||||
|
stepsEl.appendChild(currentToolEl);
|
||||||
|
|
||||||
|
scrollChatToBottom();
|
||||||
|
|
||||||
|
} else if (item.type === 'tool_end') {
|
||||||
|
if (currentToolEl) {
|
||||||
|
const isError = item.status !== 'success';
|
||||||
|
const icon = currentToolEl.querySelector('.tool-icon');
|
||||||
|
icon.className = isError
|
||||||
|
? 'fas fa-times text-red-400 flex-shrink-0 tool-icon'
|
||||||
|
: 'fas fa-check text-primary-400 flex-shrink-0 tool-icon';
|
||||||
|
|
||||||
|
// Show execution time
|
||||||
|
const nameEl = currentToolEl.querySelector('.tool-name');
|
||||||
|
if (item.execution_time !== undefined) {
|
||||||
|
nameEl.innerHTML += ` <span class="tool-time">${item.execution_time}s</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill output section
|
||||||
|
const outputSection = currentToolEl.querySelector('.tool-output-section');
|
||||||
|
if (outputSection && item.result) {
|
||||||
|
outputSection.innerHTML = `
|
||||||
|
<div class="tool-detail-label">${isError ? 'Error' : 'Output'}</div>
|
||||||
|
<pre class="tool-detail-content ${isError ? 'tool-error-text' : ''}">${escapeHtml(String(item.result))}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) currentToolEl.classList.add('tool-failed');
|
||||||
|
currentToolEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (item.type === 'done') {
|
||||||
|
es.close();
|
||||||
|
delete activeStreams[requestId];
|
||||||
|
|
||||||
|
const finalText = item.content || accumulatedText;
|
||||||
|
|
||||||
|
if (!botEl && finalText) {
|
||||||
|
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||||
|
addBotMessage(finalText, new Date((item.timestamp || Date.now() / 1000) * 1000), requestId);
|
||||||
|
} else if (botEl) {
|
||||||
|
contentEl.classList.remove('sse-streaming');
|
||||||
|
if (finalText) contentEl.innerHTML = renderMarkdown(finalText);
|
||||||
|
applyHighlighting(botEl);
|
||||||
|
}
|
||||||
|
scrollChatToBottom();
|
||||||
|
|
||||||
|
} else if (item.type === 'error') {
|
||||||
|
es.close();
|
||||||
|
delete activeStreams[requestId];
|
||||||
|
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = function() {
|
||||||
|
es.close();
|
||||||
|
delete activeStreams[requestId];
|
||||||
|
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||||
|
if (!botEl) {
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
} else if (accumulatedText) {
|
||||||
|
contentEl.classList.remove('sse-streaming');
|
||||||
|
contentEl.innerHTML = renderMarkdown(accumulatedText);
|
||||||
|
applyHighlighting(botEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (isPolling) return;
|
||||||
|
isPolling = true;
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
if (!isPolling) return;
|
||||||
|
if (document.hidden) { setTimeout(poll, 5000); return; }
|
||||||
|
|
||||||
|
fetch('/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success' && data.has_content) {
|
||||||
|
const rid = data.request_id;
|
||||||
|
if (loadingContainers[rid]) {
|
||||||
|
loadingContainers[rid].remove();
|
||||||
|
delete loadingContainers[rid];
|
||||||
|
}
|
||||||
|
addBotMessage(data.content, new Date(data.timestamp * 1000), rid);
|
||||||
|
scrollChatToBottom();
|
||||||
|
}
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
})
|
||||||
|
.catch(() => { setTimeout(poll, 3000); });
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUserMessage(content, timestamp) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex justify-end px-4 sm:px-6 py-3';
|
||||||
|
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)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesDiv.appendChild(el);
|
||||||
|
scrollChatToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBotMessage(content, timestamp, requestId) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
|
||||||
|
if (requestId) el.dataset.requestId = requestId;
|
||||||
|
el.innerHTML = `
|
||||||
|
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||||||
|
<div class="min-w-0 flex-1 max-w-[85%]">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3 text-sm leading-relaxed msg-content text-slate-700 dark:text-slate-200">
|
||||||
|
${renderMarkdown(content)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5">${formatTime(timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesDiv.appendChild(el);
|
||||||
|
applyHighlighting(el);
|
||||||
|
scrollChatToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLoadingIndicator() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
|
||||||
|
el.innerHTML = `
|
||||||
|
<img src="assets/logo.jpg" alt="CowAgent" class="w-8 h-8 rounded-lg flex-shrink-0">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-2xl px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style="animation-delay: 0s"></span>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style="animation-delay: 0.2s"></span>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style="animation-delay: 0.4s"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesDiv.appendChild(el);
|
||||||
|
scrollChatToBottom();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChat() {
|
||||||
|
// Close all active SSE connections for the current session
|
||||||
|
Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} });
|
||||||
|
activeStreams = {};
|
||||||
|
|
||||||
|
sessionId = generateSessionId();
|
||||||
|
isPolling = false;
|
||||||
|
loadingContainers = {};
|
||||||
|
messagesDiv.innerHTML = '';
|
||||||
|
const ws = document.createElement('div');
|
||||||
|
ws.id = 'welcome-screen';
|
||||||
|
ws.className = 'flex flex-col items-center justify-center h-full px-6 py-12';
|
||||||
|
ws.innerHTML = `
|
||||||
|
<img src="assets/logo.jpg" alt="CowAgent" class="w-16 h-16 rounded-2xl mb-6 shadow-lg shadow-primary-500/20">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">${appConfig.title || 'CowAgent'}</h1>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 text-center max-w-lg mb-10 leading-relaxed" data-i18n="welcome_subtitle">${t('welcome_subtitle')}</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
|
||||||
|
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<i class="fas fa-folder-open text-blue-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_sys_title">${t('example_sys_title')}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_sys_text">${t('example_sys_text')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<i class="fas fa-clock text-amber-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_task_title">${t('example_task_title')}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_task_text">${t('example_task_text')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="example-card group bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-xl p-4 cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-md transition-all duration-200">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center">
|
||||||
|
<i class="fas fa-code text-emerald-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200" data-i18n="example_code_title">${t('example_code_title')}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed" data-i18n="example_code_text">${t('example_code_text')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messagesDiv.appendChild(ws);
|
||||||
|
ws.querySelectorAll('.example-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const textEl = card.querySelector('[data-i18n*="text"]');
|
||||||
|
if (textEl) {
|
||||||
|
chatInput.value = textEl.textContent;
|
||||||
|
chatInput.dispatchEvent(new Event('input'));
|
||||||
|
chatInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (currentView !== 'chat') navigateTo('chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Utilities
|
||||||
|
// =====================================================================
|
||||||
|
function formatTime(date) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(str));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolArgs(args) {
|
||||||
|
if (!args || Object.keys(args).length === 0) return '(none)';
|
||||||
|
try {
|
||||||
|
return escapeHtml(JSON.stringify(args, null, 2));
|
||||||
|
} catch (_) {
|
||||||
|
return escapeHtml(String(args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollChatToBottom() {
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHighlighting(container) {
|
||||||
|
const root = container || document;
|
||||||
|
setTimeout(() => {
|
||||||
|
root.querySelectorAll('pre code').forEach(block => {
|
||||||
|
if (!block.classList.contains('hljs')) {
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Config View
|
||||||
|
// =====================================================================
|
||||||
|
function loadConfigView() {
|
||||||
|
fetch('/config').then(r => r.json()).then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
document.getElementById('cfg-model').textContent = data.model || '--';
|
||||||
|
document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled';
|
||||||
|
document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--';
|
||||||
|
document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--';
|
||||||
|
document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--';
|
||||||
|
document.getElementById('cfg-channel').textContent = data.channel_type || '--';
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Skills View
|
||||||
|
// =====================================================================
|
||||||
|
let skillsLoaded = false;
|
||||||
|
function loadSkillsView() {
|
||||||
|
if (skillsLoaded) return;
|
||||||
|
fetch('/api/skills').then(r => r.json()).then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
const emptyEl = document.getElementById('skills-empty');
|
||||||
|
const listEl = document.getElementById('skills-list');
|
||||||
|
const skills = data.skills || [];
|
||||||
|
if (skills.length === 0) {
|
||||||
|
emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.classList.add('hidden');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
const builtins = skills.filter(s => s.source === 'builtin');
|
||||||
|
const customs = skills.filter(s => s.source !== 'builtin');
|
||||||
|
|
||||||
|
function renderGroup(title, items) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'sm:col-span-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mt-2';
|
||||||
|
header.textContent = title;
|
||||||
|
listEl.appendChild(header);
|
||||||
|
items.forEach(sk => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start gap-3';
|
||||||
|
const iconColor = sk.enabled ? 'text-primary-400' : 'text-slate-300 dark:text-slate-600';
|
||||||
|
const statusDot = sk.enabled
|
||||||
|
? '<span class="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0 mt-1"></span>'
|
||||||
|
: '<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 flex-shrink-0 mt-1"></span>';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="w-9 h-9 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-bolt ${iconColor} text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200 truncate">${escapeHtml(sk.name)}</span>
|
||||||
|
${statusDot}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1 line-clamp-2">${escapeHtml(sk.description || '--')}</p>
|
||||||
|
</div>`;
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderGroup(currentLang === 'zh' ? '内置技能' : 'Built-in Skills', builtins);
|
||||||
|
renderGroup(currentLang === 'zh' ? '自定义技能' : 'Custom Skills', customs);
|
||||||
|
skillsLoaded = true;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Memory View
|
||||||
|
// =====================================================================
|
||||||
|
let memoryPage = 1;
|
||||||
|
const memoryPageSize = 10;
|
||||||
|
|
||||||
|
function loadMemoryView(page) {
|
||||||
|
page = page || 1;
|
||||||
|
memoryPage = page;
|
||||||
|
fetch(`/api/memory?page=${page}&page_size=${memoryPageSize}`).then(r => r.json()).then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
const emptyEl = document.getElementById('memory-empty');
|
||||||
|
const listEl = document.getElementById('memory-list');
|
||||||
|
const files = data.list || [];
|
||||||
|
const total = data.total || 0;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files';
|
||||||
|
emptyEl.classList.remove('hidden');
|
||||||
|
listEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.classList.add('hidden');
|
||||||
|
listEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
const tbody = document.getElementById('memory-table-body');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
files.forEach(f => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors';
|
||||||
|
tr.onclick = () => openMemoryFile(f.filename);
|
||||||
|
const typeLabel = f.type === 'global'
|
||||||
|
? '<span class="px-2 py-0.5 rounded-full text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">Global</span>'
|
||||||
|
: '<span class="px-2 py-0.5 rounded-full text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">Daily</span>';
|
||||||
|
const sizeStr = f.size < 1024 ? f.size + ' B' : (f.size / 1024).toFixed(1) + ' KB';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="px-4 py-3 text-sm font-mono text-slate-700 dark:text-slate-200">${escapeHtml(f.filename)}</td>
|
||||||
|
<td class="px-4 py-3 text-sm">${typeLabel}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">${sizeStr}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">${escapeHtml(f.updated_at)}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalPages = Math.ceil(total / memoryPageSize);
|
||||||
|
const pagEl = document.getElementById('memory-pagination');
|
||||||
|
if (totalPages <= 1) { pagEl.innerHTML = ''; return; }
|
||||||
|
let pagHtml = `<span>${page} / ${totalPages}</span><div class="flex gap-2">`;
|
||||||
|
if (page > 1) pagHtml += `<button onclick="loadMemoryView(${page - 1})" class="px-3 py-1 rounded-lg border border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-xs">Prev</button>`;
|
||||||
|
if (page < totalPages) pagHtml += `<button onclick="loadMemoryView(${page + 1})" class="px-3 py-1 rounded-lg border border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-xs">Next</button>`;
|
||||||
|
pagHtml += '</div>';
|
||||||
|
pagEl.innerHTML = pagHtml;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMemoryFile(filename) {
|
||||||
|
fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}`).then(r => r.json()).then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
document.getElementById('memory-panel-list').classList.add('hidden');
|
||||||
|
const panel = document.getElementById('memory-panel-viewer');
|
||||||
|
document.getElementById('memory-viewer-title').textContent = filename;
|
||||||
|
document.getElementById('memory-viewer-content').innerHTML = renderMarkdown(data.content || '');
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
applyHighlighting(panel);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMemoryViewer() {
|
||||||
|
document.getElementById('memory-panel-viewer').classList.add('hidden');
|
||||||
|
document.getElementById('memory-panel-list').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Channels View
|
||||||
|
// =====================================================================
|
||||||
|
function loadChannelsView() {
|
||||||
|
const container = document.getElementById('channels-content');
|
||||||
|
const channelType = appConfig.channel_type || 'web';
|
||||||
|
const channelMap = {
|
||||||
|
web: { name: 'Web', icon: 'fa-globe', color: 'primary' },
|
||||||
|
terminal: { name: 'Terminal', icon: 'fa-terminal', color: 'slate' },
|
||||||
|
feishu: { name: 'Feishu', icon: 'fa-paper-plane', color: 'blue' },
|
||||||
|
dingtalk: { name: 'DingTalk', icon: 'fa-comments', color: 'blue' },
|
||||||
|
wechatcom_app: { name: 'WeCom', icon: 'fa-building', color: 'emerald' },
|
||||||
|
wechatmp: { name: 'WeChat MP', icon: 'fa-comment-dots', color: 'emerald' },
|
||||||
|
wechatmp_service: { name: 'WeChat Service', icon: 'fa-comment-dots', color: 'emerald' },
|
||||||
|
};
|
||||||
|
const info = channelMap[channelType] || { name: channelType, icon: 'fa-tower-broadcast', color: 'sky' };
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6 flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-${info.color}-50 dark:bg-${info.color}-900/20 flex items-center justify-center">
|
||||||
|
<i class="fas ${info.icon} text-${info.color}-500 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold text-slate-800 dark:text-slate-100">${info.name}</span>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||||
|
<span class="text-xs text-primary-500">Active</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 font-mono">${escapeHtml(channelType)}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Scheduler View
|
||||||
|
// =====================================================================
|
||||||
|
let tasksLoaded = false;
|
||||||
|
function loadTasksView() {
|
||||||
|
if (tasksLoaded) return;
|
||||||
|
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
const emptyEl = document.getElementById('tasks-empty');
|
||||||
|
const listEl = document.getElementById('tasks-list');
|
||||||
|
const allTasks = data.tasks || [];
|
||||||
|
// Only show active (enabled) tasks
|
||||||
|
const tasks = allTasks.filter(t => t.enabled !== false);
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无定时任务' : 'No scheduled tasks';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.classList.add('hidden');
|
||||||
|
listEl.classList.remove('hidden');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4';
|
||||||
|
const typeLabel = task.type === 'cron'
|
||||||
|
? `<span class="text-xs font-mono text-slate-400">${escapeHtml(task.cron || '')}</span>`
|
||||||
|
: `<span class="text-xs text-slate-400">${escapeHtml(task.type || 'once')}</span>`;
|
||||||
|
let nextRun = '--';
|
||||||
|
if (task.next_run_at) {
|
||||||
|
// next_run_at is an ISO string, not a Unix timestamp
|
||||||
|
const d = new Date(task.next_run_at);
|
||||||
|
if (!isNaN(d.getTime())) nextRun = d.toLocaleString();
|
||||||
|
}
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-primary-400"></span>
|
||||||
|
<span class="font-medium text-sm text-slate-700 dark:text-slate-200">${escapeHtml(task.name || task.id || '--')}</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
${typeLabel}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 mb-2 line-clamp-2">${escapeHtml(task.prompt || task.description || '')}</p>
|
||||||
|
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||||||
|
</div>`;
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
tasksLoaded = true;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Logs View
|
||||||
|
// =====================================================================
|
||||||
|
let logEventSource = null;
|
||||||
|
|
||||||
|
function startLogStream() {
|
||||||
|
if (logEventSource) return;
|
||||||
|
const output = document.getElementById('log-output');
|
||||||
|
output.innerHTML = '';
|
||||||
|
|
||||||
|
logEventSource = new EventSource('/api/logs');
|
||||||
|
logEventSource.onmessage = function(e) {
|
||||||
|
let item;
|
||||||
|
try { item = JSON.parse(e.data); } catch (_) { return; }
|
||||||
|
|
||||||
|
if (item.type === 'init') {
|
||||||
|
output.textContent = item.content || '';
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
} else if (item.type === 'line') {
|
||||||
|
output.textContent += item.content;
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
} else if (item.type === 'error') {
|
||||||
|
output.textContent = item.message || 'Error loading logs';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
logEventSource.onerror = function() {
|
||||||
|
logEventSource.close();
|
||||||
|
logEventSource = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLogStream() {
|
||||||
|
if (logEventSource) {
|
||||||
|
logEventSource.close();
|
||||||
|
logEventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// View Navigation Hook
|
||||||
|
// =====================================================================
|
||||||
|
const _origNavigateTo = navigateTo;
|
||||||
|
navigateTo = function(viewId) {
|
||||||
|
// Stop log stream when leaving logs view
|
||||||
|
if (currentView === 'logs' && viewId !== 'logs') stopLogStream();
|
||||||
|
|
||||||
|
_origNavigateTo(viewId);
|
||||||
|
|
||||||
|
// Lazy-load view data
|
||||||
|
if (viewId === 'skills') loadSkillsView();
|
||||||
|
else if (viewId === 'memory') {
|
||||||
|
// Always start from the list panel when navigating to memory
|
||||||
|
document.getElementById('memory-panel-viewer').classList.add('hidden');
|
||||||
|
document.getElementById('memory-panel-list').classList.remove('hidden');
|
||||||
|
loadMemoryView(1);
|
||||||
|
}
|
||||||
|
else if (viewId === 'channels') loadChannelsView();
|
||||||
|
else if (viewId === 'tasks') loadTasksView();
|
||||||
|
else if (viewId === 'logs') startLogStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Initialization
|
||||||
|
// =====================================================================
|
||||||
|
applyTheme();
|
||||||
|
applyI18n();
|
||||||
|
document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`;
|
||||||
|
chatInput.focus();
|
||||||
@@ -3,7 +3,6 @@ import time
|
|||||||
import web
|
import web
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import io
|
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from bridge.context import *
|
from bridge.context import *
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
@@ -13,7 +12,7 @@ from common.log import logger
|
|||||||
from common.singleton import singleton
|
from common.singleton import singleton
|
||||||
from config import conf
|
from config import conf
|
||||||
import os
|
import os
|
||||||
import mimetypes # 添加这行来处理MIME类型
|
import mimetypes
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -47,9 +46,11 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.msg_id_counter = 0 # 添加消息ID计数器
|
self.msg_id_counter = 0
|
||||||
self.session_queues = {} # 存储session_id到队列的映射
|
self.session_queues = {} # session_id -> Queue (fallback polling)
|
||||||
self.request_to_session = {} # 存储request_id到session_id的映射
|
self.request_to_session = {} # request_id -> session_id
|
||||||
|
self.sse_queues = {} # request_id -> Queue (SSE streaming)
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
|
|
||||||
def _generate_msg_id(self):
|
def _generate_msg_id(self):
|
||||||
@@ -70,22 +71,30 @@ class WebChannel(ChatChannel):
|
|||||||
if reply.type == ReplyType.IMAGE_URL:
|
if reply.type == ReplyType.IMAGE_URL:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
# 获取请求ID和会话ID
|
|
||||||
request_id = context.get("request_id", None)
|
request_id = context.get("request_id", None)
|
||||||
|
|
||||||
if not request_id:
|
if not request_id:
|
||||||
logger.error("No request_id found in context, cannot send message")
|
logger.error("No request_id found in context, cannot send message")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 通过request_id获取session_id
|
|
||||||
session_id = self.request_to_session.get(request_id)
|
session_id = self.request_to_session.get(request_id)
|
||||||
if not session_id:
|
if not session_id:
|
||||||
logger.error(f"No session_id found for request {request_id}")
|
logger.error(f"No session_id found for request {request_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 检查是否有会话队列
|
# SSE mode: push done event to SSE queue
|
||||||
|
if request_id in self.sse_queues:
|
||||||
|
content = reply.content if reply.content is not None else ""
|
||||||
|
self.sse_queues[request_id].put({
|
||||||
|
"type": "done",
|
||||||
|
"content": content,
|
||||||
|
"request_id": request_id,
|
||||||
|
"timestamp": time.time()
|
||||||
|
})
|
||||||
|
logger.debug(f"SSE done sent for request {request_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback: polling mode
|
||||||
if session_id in self.session_queues:
|
if session_id in self.session_queues:
|
||||||
# 创建响应数据,包含请求ID以区分不同请求的响应
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"type": str(reply.type),
|
"type": str(reply.type),
|
||||||
"content": reply.content,
|
"content": reply.content,
|
||||||
@@ -93,69 +102,133 @@ class WebChannel(ChatChannel):
|
|||||||
"request_id": request_id
|
"request_id": request_id
|
||||||
}
|
}
|
||||||
self.session_queues[session_id].put(response_data)
|
self.session_queues[session_id].put(response_data)
|
||||||
logger.debug(f"Response sent to queue for session {session_id}, request {request_id}")
|
logger.debug(f"Response sent to poll queue for session {session_id}, request {request_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No response queue found for session {session_id}, response dropped")
|
logger.warning(f"No response queue found for session {session_id}, response dropped")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in send method: {e}")
|
logger.error(f"Error in send method: {e}")
|
||||||
|
|
||||||
|
def _make_sse_callback(self, request_id: str):
|
||||||
|
"""Build an on_event callback that pushes agent stream events into the SSE queue."""
|
||||||
|
def on_event(event: dict):
|
||||||
|
if request_id not in self.sse_queues:
|
||||||
|
return
|
||||||
|
q = self.sse_queues[request_id]
|
||||||
|
event_type = event.get("type")
|
||||||
|
data = event.get("data", {})
|
||||||
|
|
||||||
|
if event_type == "message_update":
|
||||||
|
delta = data.get("delta", "")
|
||||||
|
if delta:
|
||||||
|
q.put({"type": "delta", "content": delta})
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_start":
|
||||||
|
tool_name = data.get("tool_name", "tool")
|
||||||
|
arguments = data.get("arguments", {})
|
||||||
|
q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments})
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_end":
|
||||||
|
tool_name = data.get("tool_name", "tool")
|
||||||
|
status = data.get("status", "success")
|
||||||
|
result = data.get("result", "")
|
||||||
|
exec_time = data.get("execution_time", 0)
|
||||||
|
# Truncate long results to avoid huge SSE payloads
|
||||||
|
result_str = str(result)
|
||||||
|
if len(result_str) > 2000:
|
||||||
|
result_str = result_str[:2000] + "…"
|
||||||
|
q.put({
|
||||||
|
"type": "tool_end",
|
||||||
|
"tool": tool_name,
|
||||||
|
"status": status,
|
||||||
|
"result": result_str,
|
||||||
|
"execution_time": round(exec_time, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
def post_message(self):
|
def post_message(self):
|
||||||
"""
|
"""
|
||||||
Handle incoming messages from users via POST request.
|
Handle incoming messages from users via POST request.
|
||||||
Returns a request_id for tracking this specific request.
|
Returns a request_id for tracking this specific request.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = web.data() # 获取原始POST数据
|
data = web.data()
|
||||||
json_data = json.loads(data)
|
json_data = json.loads(data)
|
||||||
session_id = json_data.get('session_id', f'session_{int(time.time())}')
|
session_id = json_data.get('session_id', f'session_{int(time.time())}')
|
||||||
prompt = json_data.get('message', '')
|
prompt = json_data.get('message', '')
|
||||||
|
use_sse = json_data.get('stream', True)
|
||||||
|
|
||||||
# 生成请求ID
|
|
||||||
request_id = self._generate_request_id()
|
request_id = self._generate_request_id()
|
||||||
|
|
||||||
# 将请求ID与会话ID关联
|
|
||||||
self.request_to_session[request_id] = session_id
|
self.request_to_session[request_id] = session_id
|
||||||
|
|
||||||
# 确保会话队列存在
|
|
||||||
if session_id not in self.session_queues:
|
if session_id not in self.session_queues:
|
||||||
self.session_queues[session_id] = Queue()
|
self.session_queues[session_id] = Queue()
|
||||||
|
|
||||||
# Web channel 不需要前缀,确保消息能通过前缀检查
|
if use_sse:
|
||||||
|
self.sse_queues[request_id] = Queue()
|
||||||
|
|
||||||
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
||||||
if check_prefix(prompt, trigger_prefixs) is None:
|
if check_prefix(prompt, trigger_prefixs) is None:
|
||||||
# 如果没有匹配到前缀,给消息加上第一个前缀
|
|
||||||
if trigger_prefixs:
|
if trigger_prefixs:
|
||||||
prompt = trigger_prefixs[0] + prompt
|
prompt = trigger_prefixs[0] + prompt
|
||||||
logger.debug(f"[WebChannel] Added prefix to message: {prompt}")
|
logger.debug(f"[WebChannel] Added prefix to message: {prompt}")
|
||||||
|
|
||||||
# 创建消息对象
|
|
||||||
msg = WebMessage(self._generate_msg_id(), prompt)
|
msg = WebMessage(self._generate_msg_id(), prompt)
|
||||||
msg.from_user_id = session_id # 使用会话ID作为用户ID
|
msg.from_user_id = session_id
|
||||||
|
|
||||||
# 创建上下文,明确指定 isgroup=False
|
|
||||||
context = self._compose_context(ContextType.TEXT, prompt, msg=msg, isgroup=False)
|
context = self._compose_context(ContextType.TEXT, prompt, msg=msg, isgroup=False)
|
||||||
|
|
||||||
# 检查 context 是否为 None(可能被插件过滤等)
|
|
||||||
if context is None:
|
if context is None:
|
||||||
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
|
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
|
||||||
|
if request_id in self.sse_queues:
|
||||||
|
del self.sse_queues[request_id]
|
||||||
return json.dumps({"status": "error", "message": "Message was filtered"})
|
return json.dumps({"status": "error", "message": "Message was filtered"})
|
||||||
|
|
||||||
# 覆盖必要的字段(_compose_context 会设置默认值,但我们需要使用实际的 session_id)
|
|
||||||
context["session_id"] = session_id
|
context["session_id"] = session_id
|
||||||
context["receiver"] = session_id
|
context["receiver"] = session_id
|
||||||
context["request_id"] = request_id
|
context["request_id"] = request_id
|
||||||
|
|
||||||
# 异步处理消息 - 只传递上下文
|
if use_sse:
|
||||||
|
context["on_event"] = self._make_sse_callback(request_id)
|
||||||
|
|
||||||
threading.Thread(target=self.produce, args=(context,)).start()
|
threading.Thread(target=self.produce, args=(context,)).start()
|
||||||
|
|
||||||
# 返回请求ID
|
return json.dumps({"status": "success", "request_id": request_id, "stream": use_sse})
|
||||||
return json.dumps({"status": "success", "request_id": request_id})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing message: {e}")
|
logger.error(f"Error processing message: {e}")
|
||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
def stream_response(self, request_id: str):
|
||||||
|
"""
|
||||||
|
SSE generator for a given request_id.
|
||||||
|
Yields UTF-8 encoded bytes to avoid WSGI Latin-1 mangling.
|
||||||
|
"""
|
||||||
|
if request_id not in self.sse_queues:
|
||||||
|
yield b"data: {\"type\": \"error\", \"message\": \"invalid request_id\"}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
q = self.sse_queues[request_id]
|
||||||
|
timeout = 300 # 5 minutes max
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
|
||||||
|
try:
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
item = q.get(timeout=1)
|
||||||
|
except Empty:
|
||||||
|
yield b": keepalive\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = json.dumps(item, ensure_ascii=False)
|
||||||
|
yield f"data: {payload}\n\n".encode("utf-8")
|
||||||
|
|
||||||
|
if item.get("type") == "done":
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
self.sse_queues.pop(request_id, None)
|
||||||
|
|
||||||
def poll_response(self):
|
def poll_response(self):
|
||||||
"""
|
"""
|
||||||
Poll for responses using the session_id.
|
Poll for responses using the session_id.
|
||||||
@@ -208,8 +281,8 @@ class WebChannel(ChatChannel):
|
|||||||
logger.info("[WebChannel] 5. wechatcom_app - 企微自建应用")
|
logger.info("[WebChannel] 5. wechatcom_app - 企微自建应用")
|
||||||
logger.info("[WebChannel] 6. wechatmp - 个人公众号")
|
logger.info("[WebChannel] 6. wechatmp - 个人公众号")
|
||||||
logger.info("[WebChannel] 7. wechatmp_service - 企业公众号")
|
logger.info("[WebChannel] 7. wechatmp_service - 企业公众号")
|
||||||
logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}/chat")
|
logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}")
|
||||||
logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port}/chat (请将YOUR_IP替换为服务器IP)")
|
logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (请将YOUR_IP替换为服务器IP)")
|
||||||
logger.info("[WebChannel] ✅ Web对话网页已运行")
|
logger.info("[WebChannel] ✅ Web对话网页已运行")
|
||||||
|
|
||||||
# 确保静态文件目录存在
|
# 确保静态文件目录存在
|
||||||
@@ -222,8 +295,14 @@ class WebChannel(ChatChannel):
|
|||||||
'/', 'RootHandler',
|
'/', 'RootHandler',
|
||||||
'/message', 'MessageHandler',
|
'/message', 'MessageHandler',
|
||||||
'/poll', 'PollHandler',
|
'/poll', 'PollHandler',
|
||||||
|
'/stream', 'StreamHandler',
|
||||||
'/chat', 'ChatHandler',
|
'/chat', 'ChatHandler',
|
||||||
'/config', 'ConfigHandler',
|
'/config', 'ConfigHandler',
|
||||||
|
'/api/skills', 'SkillsHandler',
|
||||||
|
'/api/memory', 'MemoryHandler',
|
||||||
|
'/api/memory/content', 'MemoryContentHandler',
|
||||||
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
|
'/api/logs', 'LogsHandler',
|
||||||
'/assets/(.*)', 'AssetsHandler',
|
'/assets/(.*)', 'AssetsHandler',
|
||||||
)
|
)
|
||||||
app = web.application(urls, globals(), autoreload=False)
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
@@ -235,13 +314,24 @@ class WebChannel(ChatChannel):
|
|||||||
logging.getLogger("web").setLevel(logging.ERROR)
|
logging.getLogger("web").setLevel(logging.ERROR)
|
||||||
logging.getLogger("web.httpserver").setLevel(logging.ERROR)
|
logging.getLogger("web.httpserver").setLevel(logging.ERROR)
|
||||||
|
|
||||||
# 抑制 web.py 默认的服务器启动消息
|
# Build WSGI app with middleware (same as runsimple but without print)
|
||||||
old_stdout = sys.stdout
|
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||||
sys.stdout = io.StringIO()
|
func = web.httpserver.LogMiddleware(func)
|
||||||
|
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||||
|
self._http_server = server
|
||||||
try:
|
try:
|
||||||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
|
server.start()
|
||||||
finally:
|
except (KeyboardInterrupt, SystemExit):
|
||||||
sys.stdout = old_stdout
|
server.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._http_server:
|
||||||
|
try:
|
||||||
|
self._http_server.stop()
|
||||||
|
logger.info("[WebChannel] HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WebChannel] Error stopping HTTP server: {e}")
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
|
|
||||||
class RootHandler:
|
class RootHandler:
|
||||||
@@ -260,6 +350,21 @@ class PollHandler:
|
|||||||
return WebChannel().poll_response()
|
return WebChannel().poll_response()
|
||||||
|
|
||||||
|
|
||||||
|
class StreamHandler:
|
||||||
|
def GET(self):
|
||||||
|
params = web.input(request_id='')
|
||||||
|
request_id = params.request_id
|
||||||
|
if not request_id:
|
||||||
|
raise web.badrequest()
|
||||||
|
|
||||||
|
web.header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
web.header('Cache-Control', 'no-cache')
|
||||||
|
web.header('X-Accel-Buffering', 'no')
|
||||||
|
web.header('Access-Control-Allow-Origin', '*')
|
||||||
|
|
||||||
|
return WebChannel().stream_response(request_id)
|
||||||
|
|
||||||
|
|
||||||
class ChatHandler:
|
class ChatHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
# 正常返回聊天页面
|
# 正常返回聊天页面
|
||||||
@@ -270,28 +375,150 @@ class ChatHandler:
|
|||||||
|
|
||||||
class ConfigHandler:
|
class ConfigHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
"""返回前端需要的配置信息"""
|
"""Return configuration info for the web console."""
|
||||||
try:
|
try:
|
||||||
use_agent = conf().get("agent", False)
|
local_config = conf()
|
||||||
|
use_agent = local_config.get("agent", False)
|
||||||
|
|
||||||
if use_agent:
|
if use_agent:
|
||||||
title = "CowAgent"
|
title = "CowAgent"
|
||||||
subtitle = "我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆不断成长"
|
|
||||||
else:
|
else:
|
||||||
title = "AI 助手"
|
title = "AI Assistant"
|
||||||
subtitle = "我可以回答问题、提供信息或者帮助您完成各种任务"
|
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"use_agent": use_agent,
|
"use_agent": use_agent,
|
||||||
"title": title,
|
"title": title,
|
||||||
"subtitle": subtitle
|
"model": local_config.get("model", ""),
|
||||||
|
"channel_type": local_config.get("channel_type", ""),
|
||||||
|
"agent_max_context_tokens": local_config.get("agent_max_context_tokens", ""),
|
||||||
|
"agent_max_context_turns": local_config.get("agent_max_context_turns", ""),
|
||||||
|
"agent_max_steps": local_config.get("agent_max_steps", ""),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting config: {e}")
|
logger.error(f"Error getting config: {e}")
|
||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def _get_workspace_root():
|
||||||
|
"""Resolve the agent workspace directory."""
|
||||||
|
from common.utils import expand_path
|
||||||
|
return expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsHandler:
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
from agent.skills.service import SkillService
|
||||||
|
from agent.skills.manager import SkillManager
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||||
|
service = SkillService(manager)
|
||||||
|
skills = service.query()
|
||||||
|
return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Skills API error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryHandler:
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
from agent.memory.service import MemoryService
|
||||||
|
params = web.input(page='1', page_size='20')
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
service = MemoryService(workspace_root)
|
||||||
|
result = service.list_files(page=int(params.page), page_size=int(params.page_size))
|
||||||
|
return json.dumps({"status": "success", **result}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Memory API error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryContentHandler:
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
from agent.memory.service import MemoryService
|
||||||
|
params = web.input(filename='')
|
||||||
|
if not params.filename:
|
||||||
|
return json.dumps({"status": "error", "message": "filename required"})
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
service = MemoryService(workspace_root)
|
||||||
|
result = service.get_content(params.filename)
|
||||||
|
return json.dumps({"status": "success", **result}, ensure_ascii=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return json.dumps({"status": "error", "message": "file not found"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Memory content API error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerHandler:
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
tasks = store.list_tasks()
|
||||||
|
return json.dumps({"status": "success", "tasks": tasks}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler API error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class LogsHandler:
|
||||||
|
def GET(self):
|
||||||
|
"""Stream the last N lines of run.log as SSE, then tail new lines."""
|
||||||
|
web.header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
web.header('Cache-Control', 'no-cache')
|
||||||
|
web.header('X-Accel-Buffering', 'no')
|
||||||
|
|
||||||
|
from config import get_root
|
||||||
|
log_path = os.path.join(get_root(), "run.log")
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
if not os.path.isfile(log_path):
|
||||||
|
yield b"data: {\"type\": \"error\", \"message\": \"run.log not found\"}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read last 200 lines for initial display
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
tail_lines = lines[-200:]
|
||||||
|
chunk = ''.join(tail_lines)
|
||||||
|
payload = json.dumps({"type": "init", "content": chunk}, ensure_ascii=False)
|
||||||
|
yield f"data: {payload}\n\n".encode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {{\"type\": \"error\", \"message\": \"{e}\"}}\n\n".encode('utf-8')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Tail new lines
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
f.seek(0, 2) # seek to end
|
||||||
|
deadline = time.time() + 600 # 10 min max
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = f.readline()
|
||||||
|
if line:
|
||||||
|
payload = json.dumps({"type": "line", "content": line}, ensure_ascii=False)
|
||||||
|
yield f"data: {payload}\n\n".encode('utf-8')
|
||||||
|
else:
|
||||||
|
yield b": keepalive\n\n"
|
||||||
|
time.sleep(1)
|
||||||
|
except GeneratorExit:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
return generate()
|
||||||
|
|
||||||
|
|
||||||
class AssetsHandler:
|
class AssetsHandler:
|
||||||
def GET(self, file_path): # 修改默认参数
|
def GET(self, file_path): # 修改默认参数
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class WechatChannel(ChatChannel):
|
|||||||
|
|
||||||
def exitCallback(self):
|
def exitCallback(self):
|
||||||
try:
|
try:
|
||||||
from common.linkai_client import chat_client
|
from common.cloud_client import chat_client
|
||||||
if chat_client.client_id and conf().get("use_linkai"):
|
if chat_client.client_id and conf().get("use_linkai"):
|
||||||
_send_logout()
|
_send_logout()
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -283,7 +283,7 @@ class WechatChannel(ChatChannel):
|
|||||||
|
|
||||||
def _send_login_success():
|
def _send_login_success():
|
||||||
try:
|
try:
|
||||||
from common.linkai_client import chat_client
|
from common.cloud_client import chat_client
|
||||||
if chat_client.client_id:
|
if chat_client.client_id:
|
||||||
chat_client.send_login_success()
|
chat_client.send_login_success()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -292,7 +292,7 @@ def _send_login_success():
|
|||||||
|
|
||||||
def _send_logout():
|
def _send_logout():
|
||||||
try:
|
try:
|
||||||
from common.linkai_client import chat_client
|
from common.cloud_client import chat_client
|
||||||
if chat_client.client_id:
|
if chat_client.client_id:
|
||||||
chat_client.send_logout()
|
chat_client.send_logout()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -301,7 +301,7 @@ def _send_logout():
|
|||||||
|
|
||||||
def _send_qr_code(qrcode_list: list):
|
def _send_qr_code(qrcode_list: list):
|
||||||
try:
|
try:
|
||||||
from common.linkai_client import chat_client
|
from common.cloud_client import chat_client
|
||||||
if chat_client.client_id:
|
if chat_client.client_id:
|
||||||
chat_client.send_qrcode(qrcode_list)
|
chat_client.send_qrcode(qrcode_list)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class WechatComAppChannel(ChatChannel):
|
|||||||
self.agent_id = conf().get("wechatcomapp_agent_id")
|
self.agent_id = conf().get("wechatcomapp_agent_id")
|
||||||
self.token = conf().get("wechatcomapp_token")
|
self.token = conf().get("wechatcomapp_token")
|
||||||
self.aes_key = conf().get("wechatcomapp_aes_key")
|
self.aes_key = conf().get("wechatcomapp_aes_key")
|
||||||
|
self._http_server = None
|
||||||
logger.info(
|
logger.info(
|
||||||
"[wechatcom] Initializing WeCom app channel, corp_id: {}, agent_id: {}".format(self.corp_id, self.agent_id)
|
"[wechatcom] Initializing WeCom app channel, corp_id: {}, agent_id: {}".format(self.corp_id, self.agent_id)
|
||||||
)
|
)
|
||||||
@@ -51,13 +52,24 @@ class WechatComAppChannel(ChatChannel):
|
|||||||
logger.info("[wechatcom] 📡 Listening on http://0.0.0.0:{}/wxcomapp/".format(port))
|
logger.info("[wechatcom] 📡 Listening on http://0.0.0.0:{}/wxcomapp/".format(port))
|
||||||
logger.info("[wechatcom] 🤖 Ready to receive messages")
|
logger.info("[wechatcom] 🤖 Ready to receive messages")
|
||||||
|
|
||||||
# Suppress web.py's default server startup message
|
# Build WSGI app with middleware (same as runsimple but without print)
|
||||||
old_stdout = sys.stdout
|
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||||
sys.stdout = io.StringIO()
|
func = web.httpserver.LogMiddleware(func)
|
||||||
|
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||||
|
self._http_server = server
|
||||||
try:
|
try:
|
||||||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
|
server.start()
|
||||||
finally:
|
except (KeyboardInterrupt, SystemExit):
|
||||||
sys.stdout = old_stdout
|
server.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._http_server:
|
||||||
|
try:
|
||||||
|
self._http_server.stop()
|
||||||
|
logger.info("[wechatcom] HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[wechatcom] Error stopping HTTP server: {e}")
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
receiver = context["receiver"]
|
receiver = context["receiver"]
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class WechatMPChannel(ChatChannel):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.passive_reply = passive_reply
|
self.passive_reply = passive_reply
|
||||||
self.NOT_SUPPORT_REPLYTYPE = []
|
self.NOT_SUPPORT_REPLYTYPE = []
|
||||||
|
self._http_server = None
|
||||||
appid = conf().get("wechatmp_app_id")
|
appid = conf().get("wechatmp_app_id")
|
||||||
secret = conf().get("wechatmp_app_secret")
|
secret = conf().get("wechatmp_app_secret")
|
||||||
token = conf().get("wechatmp_token")
|
token = conf().get("wechatmp_token")
|
||||||
@@ -69,7 +70,23 @@ class WechatMPChannel(ChatChannel):
|
|||||||
urls = ("/wx", "channel.wechatmp.active_reply.Query")
|
urls = ("/wx", "channel.wechatmp.active_reply.Query")
|
||||||
app = web.application(urls, globals(), autoreload=False)
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
port = conf().get("wechatmp_port", 8080)
|
port = conf().get("wechatmp_port", 8080)
|
||||||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
|
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||||
|
func = web.httpserver.LogMiddleware(func)
|
||||||
|
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||||
|
self._http_server = server
|
||||||
|
try:
|
||||||
|
server.start()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._http_server:
|
||||||
|
try:
|
||||||
|
self._http_server.stop()
|
||||||
|
logger.info("[wechatmp] HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[wechatmp] Error stopping HTTP server: {e}")
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
def start_loop(self, loop):
|
def start_loop(self, loop):
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from common.utils import compress_imgfile, fsize
|
|||||||
from config import conf
|
from config import conf
|
||||||
from channel.wework.run import wework
|
from channel.wework.run import wework
|
||||||
from channel.wework import run
|
from channel.wework import run
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
|
|
||||||
def get_wxid_by_name(room_members, group_wxid, name):
|
def get_wxid_by_name(room_members, group_wxid, name):
|
||||||
@@ -55,6 +54,7 @@ def download_and_compress_image(url, filename, quality=30):
|
|||||||
image_storage.seek(0)
|
image_storage.seek(0)
|
||||||
|
|
||||||
# 读取并保存图片
|
# 读取并保存图片
|
||||||
|
from PIL import Image
|
||||||
image = Image.open(image_storage)
|
image = Image.open(image_storage)
|
||||||
image_path = os.path.join(directory, f"{filename}.png")
|
image_path = os.path.join(directory, f"{filename}.png")
|
||||||
image.save(image_path, "png")
|
image.save(image_path, "png")
|
||||||
|
|||||||
375
common/cloud_client.py
Normal file
375
common/cloud_client.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
Cloud management client for connecting to the LinkAI control console.
|
||||||
|
|
||||||
|
Handles remote configuration sync, message push, and skill management
|
||||||
|
via the LinkAI socket protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from common.log import logger
|
||||||
|
from linkai import LinkAIClient, PushMsg
|
||||||
|
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root
|
||||||
|
from plugins import PluginManager
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
chat_client: LinkAIClient
|
||||||
|
|
||||||
|
|
||||||
|
class CloudClient(LinkAIClient):
|
||||||
|
def __init__(self, api_key: str, channel, host: str = ""):
|
||||||
|
super().__init__(api_key, host)
|
||||||
|
self.channel = channel
|
||||||
|
self.client_type = channel.channel_type
|
||||||
|
self.channel_mgr = None
|
||||||
|
self._skill_service = None
|
||||||
|
self._memory_service = None
|
||||||
|
self._chat_service = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skill_service(self):
|
||||||
|
"""Lazy-init SkillService so it is available once SkillManager exists."""
|
||||||
|
if self._skill_service is None:
|
||||||
|
try:
|
||||||
|
from agent.skills.manager import SkillManager
|
||||||
|
from agent.skills.service import SkillService
|
||||||
|
from config import conf
|
||||||
|
from common.utils import expand_path
|
||||||
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||||
|
self._skill_service = SkillService(manager)
|
||||||
|
logger.debug("[CloudClient] SkillService initialised")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CloudClient] Failed to init SkillService: {e}")
|
||||||
|
return self._skill_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def memory_service(self):
|
||||||
|
"""Lazy-init MemoryService."""
|
||||||
|
if self._memory_service is None:
|
||||||
|
try:
|
||||||
|
from agent.memory.service import MemoryService
|
||||||
|
from config import conf
|
||||||
|
from common.utils import expand_path
|
||||||
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
self._memory_service = MemoryService(workspace_root)
|
||||||
|
logger.debug("[CloudClient] MemoryService initialised")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CloudClient] Failed to init MemoryService: {e}")
|
||||||
|
return self._memory_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chat_service(self):
|
||||||
|
"""Lazy-init ChatService (requires AgentBridge via Bridge singleton)."""
|
||||||
|
if self._chat_service is None:
|
||||||
|
try:
|
||||||
|
from agent.chat.service import ChatService
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
agent_bridge = Bridge().get_agent_bridge()
|
||||||
|
self._chat_service = ChatService(agent_bridge)
|
||||||
|
logger.debug("[CloudClient] ChatService initialised")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CloudClient] Failed to init ChatService: {e}")
|
||||||
|
return self._chat_service
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# message push callback
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def on_message(self, push_msg: PushMsg):
|
||||||
|
session_id = push_msg.session_id
|
||||||
|
msg_content = push_msg.msg_content
|
||||||
|
logger.info(f"receive msg push, session_id={session_id}, msg_content={msg_content}")
|
||||||
|
context = Context()
|
||||||
|
context.type = ContextType.TEXT
|
||||||
|
context["receiver"] = session_id
|
||||||
|
context["isgroup"] = push_msg.is_group
|
||||||
|
self.channel.send(Reply(ReplyType.TEXT, content=msg_content), context)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# config callback
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def on_config(self, config: dict):
|
||||||
|
if not self.client_id:
|
||||||
|
return
|
||||||
|
logger.info(f"[CloudClient] Loading remote config: {config}")
|
||||||
|
if config.get("enabled") != "Y":
|
||||||
|
return
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
need_restart_channel = False
|
||||||
|
|
||||||
|
for key in config.keys():
|
||||||
|
if key in available_setting and config.get(key) is not None:
|
||||||
|
local_config[key] = config.get(key)
|
||||||
|
|
||||||
|
# Voice settings
|
||||||
|
reply_voice_mode = config.get("reply_voice_mode")
|
||||||
|
if reply_voice_mode:
|
||||||
|
if reply_voice_mode == "voice_reply_voice":
|
||||||
|
local_config["voice_reply_voice"] = True
|
||||||
|
local_config["always_reply_voice"] = False
|
||||||
|
elif reply_voice_mode == "always_reply_voice":
|
||||||
|
local_config["always_reply_voice"] = True
|
||||||
|
local_config["voice_reply_voice"] = True
|
||||||
|
elif reply_voice_mode == "no_reply_voice":
|
||||||
|
local_config["always_reply_voice"] = False
|
||||||
|
local_config["voice_reply_voice"] = False
|
||||||
|
|
||||||
|
# Model configuration
|
||||||
|
if config.get("model"):
|
||||||
|
local_config["model"] = config.get("model")
|
||||||
|
|
||||||
|
# Channel configuration
|
||||||
|
if config.get("channelType"):
|
||||||
|
if local_config.get("channel_type") != config.get("channelType"):
|
||||||
|
local_config["channel_type"] = config.get("channelType")
|
||||||
|
need_restart_channel = True
|
||||||
|
|
||||||
|
# Channel-specific app credentials
|
||||||
|
current_channel_type = local_config.get("channel_type", "")
|
||||||
|
|
||||||
|
if config.get("app_id") is not None:
|
||||||
|
if current_channel_type == "feishu":
|
||||||
|
if local_config.get("feishu_app_id") != config.get("app_id"):
|
||||||
|
local_config["feishu_app_id"] = config.get("app_id")
|
||||||
|
need_restart_channel = True
|
||||||
|
elif current_channel_type == "dingtalk":
|
||||||
|
if local_config.get("dingtalk_client_id") != config.get("app_id"):
|
||||||
|
local_config["dingtalk_client_id"] = config.get("app_id")
|
||||||
|
need_restart_channel = True
|
||||||
|
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
||||||
|
if local_config.get("wechatmp_app_id") != config.get("app_id"):
|
||||||
|
local_config["wechatmp_app_id"] = config.get("app_id")
|
||||||
|
need_restart_channel = True
|
||||||
|
elif current_channel_type == "wechatcom_app":
|
||||||
|
if local_config.get("wechatcomapp_agent_id") != config.get("app_id"):
|
||||||
|
local_config["wechatcomapp_agent_id"] = config.get("app_id")
|
||||||
|
need_restart_channel = True
|
||||||
|
|
||||||
|
if config.get("app_secret"):
|
||||||
|
if current_channel_type == "feishu":
|
||||||
|
if local_config.get("feishu_app_secret") != config.get("app_secret"):
|
||||||
|
local_config["feishu_app_secret"] = config.get("app_secret")
|
||||||
|
need_restart_channel = True
|
||||||
|
elif current_channel_type == "dingtalk":
|
||||||
|
if local_config.get("dingtalk_client_secret") != config.get("app_secret"):
|
||||||
|
local_config["dingtalk_client_secret"] = config.get("app_secret")
|
||||||
|
need_restart_channel = True
|
||||||
|
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
||||||
|
if local_config.get("wechatmp_app_secret") != config.get("app_secret"):
|
||||||
|
local_config["wechatmp_app_secret"] = config.get("app_secret")
|
||||||
|
need_restart_channel = True
|
||||||
|
elif current_channel_type == "wechatcom_app":
|
||||||
|
if local_config.get("wechatcomapp_secret") != config.get("app_secret"):
|
||||||
|
local_config["wechatcomapp_secret"] = config.get("app_secret")
|
||||||
|
need_restart_channel = True
|
||||||
|
|
||||||
|
if config.get("admin_password"):
|
||||||
|
if not pconf("Godcmd"):
|
||||||
|
write_plugin_config({"Godcmd": {"password": config.get("admin_password"), "admin_users": []}})
|
||||||
|
else:
|
||||||
|
pconf("Godcmd")["password"] = config.get("admin_password")
|
||||||
|
PluginManager().instances["GODCMD"].reload()
|
||||||
|
|
||||||
|
if config.get("group_app_map") and pconf("linkai"):
|
||||||
|
local_group_map = {}
|
||||||
|
for mapping in config.get("group_app_map"):
|
||||||
|
local_group_map[mapping.get("group_name")] = mapping.get("app_code")
|
||||||
|
pconf("linkai")["group_app_map"] = local_group_map
|
||||||
|
PluginManager().instances["LINKAI"].reload()
|
||||||
|
|
||||||
|
if config.get("text_to_image") and config.get("text_to_image") == "midjourney" and pconf("linkai"):
|
||||||
|
if pconf("linkai")["midjourney"]:
|
||||||
|
pconf("linkai")["midjourney"]["enabled"] = True
|
||||||
|
pconf("linkai")["midjourney"]["use_image_create_prefix"] = True
|
||||||
|
elif config.get("text_to_image") and config.get("text_to_image") in ["dall-e-2", "dall-e-3"]:
|
||||||
|
if pconf("linkai")["midjourney"]:
|
||||||
|
pconf("linkai")["midjourney"]["use_image_create_prefix"] = False
|
||||||
|
|
||||||
|
# Save configuration to config.json file
|
||||||
|
self._save_config_to_file(local_config)
|
||||||
|
|
||||||
|
if need_restart_channel:
|
||||||
|
self._restart_channel(local_config.get("channel_type", ""))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# skill callback
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def on_skill(self, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Handle SKILL messages from the cloud console.
|
||||||
|
Delegates to SkillService.dispatch for the actual operations.
|
||||||
|
|
||||||
|
:param data: message data with 'action', 'clientId', 'payload'
|
||||||
|
:return: response dict
|
||||||
|
"""
|
||||||
|
action = data.get("action", "")
|
||||||
|
payload = data.get("payload")
|
||||||
|
logger.info(f"[CloudClient] on_skill: action={action}")
|
||||||
|
|
||||||
|
svc = self.skill_service
|
||||||
|
if svc is None:
|
||||||
|
return {"action": action, "code": 500, "message": "SkillService not available", "payload": None}
|
||||||
|
|
||||||
|
return svc.dispatch(action, payload)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# memory callback
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def on_memory(self, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Handle MEMORY messages from the cloud console.
|
||||||
|
Delegates to MemoryService.dispatch for the actual operations.
|
||||||
|
|
||||||
|
:param data: message data with 'action', 'clientId', 'payload'
|
||||||
|
:return: response dict
|
||||||
|
"""
|
||||||
|
action = data.get("action", "")
|
||||||
|
payload = data.get("payload")
|
||||||
|
logger.info(f"[CloudClient] on_memory: action={action}")
|
||||||
|
|
||||||
|
svc = self.memory_service
|
||||||
|
if svc is None:
|
||||||
|
return {"action": action, "code": 500, "message": "MemoryService not available", "payload": None}
|
||||||
|
|
||||||
|
return svc.dispatch(action, payload)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# chat callback
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def on_chat(self, data: dict, send_chunk_fn):
|
||||||
|
"""
|
||||||
|
Handle CHAT messages from the cloud console.
|
||||||
|
Runs the agent in streaming mode and sends chunks back via send_chunk_fn.
|
||||||
|
|
||||||
|
:param data: message data with 'action' and 'payload' (query, session_id)
|
||||||
|
:param send_chunk_fn: callable(chunk_data: dict) to send one streaming chunk
|
||||||
|
"""
|
||||||
|
payload = data.get("payload", {})
|
||||||
|
query = payload.get("query", "")
|
||||||
|
session_id = payload.get("session_id", "cloud_console")
|
||||||
|
logger.info(f"[CloudClient] on_chat: session={session_id}, query={query[:80]}")
|
||||||
|
|
||||||
|
svc = self.chat_service
|
||||||
|
if svc is None:
|
||||||
|
raise RuntimeError("ChatService not available")
|
||||||
|
|
||||||
|
svc.run(query=query, session_id=session_id, send_chunk_fn=send_chunk_fn)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# channel restart helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _restart_channel(self, new_channel_type: str):
|
||||||
|
"""
|
||||||
|
Restart the channel via ChannelManager when channel type changes.
|
||||||
|
"""
|
||||||
|
if self.channel_mgr:
|
||||||
|
logger.info(f"[CloudClient] Restarting channel to '{new_channel_type}'...")
|
||||||
|
threading.Thread(target=self._do_restart_channel, args=(self.channel_mgr, new_channel_type), daemon=True).start()
|
||||||
|
else:
|
||||||
|
logger.warning("[CloudClient] ChannelManager not available, please restart the application manually")
|
||||||
|
|
||||||
|
def _do_restart_channel(self, mgr, new_channel_type: str):
|
||||||
|
"""
|
||||||
|
Perform the channel restart in a separate thread to avoid blocking the config callback.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mgr.restart(new_channel_type)
|
||||||
|
# Update the client's channel reference
|
||||||
|
if mgr.channel:
|
||||||
|
self.channel = mgr.channel
|
||||||
|
self.client_type = mgr.channel.channel_type
|
||||||
|
logger.info(f"[CloudClient] Channel reference updated to '{new_channel_type}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CloudClient] Channel restart failed: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# config persistence
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _save_config_to_file(self, local_config: dict):
|
||||||
|
"""
|
||||||
|
Save configuration to config.json file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_path = os.path.join(get_root(), "config.json")
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
logger.warning(f"[CloudClient] config.json not found at {config_path}, skip saving")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
file_config = json.load(f)
|
||||||
|
|
||||||
|
file_config.update(dict(local_config))
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(file_config, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info("[CloudClient] Configuration saved to config.json successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CloudClient] Failed to save configuration to config.json: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def start(channel, channel_mgr=None):
|
||||||
|
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
|
||||||
|
chat_client.config = _build_config()
|
||||||
|
chat_client.start()
|
||||||
|
time.sleep(1.5)
|
||||||
|
if chat_client.client_id:
|
||||||
|
logger.info("[CloudClient] Console: https://link-ai.tech/console/clients")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_config():
|
||||||
|
local_conf = conf()
|
||||||
|
config = {
|
||||||
|
"linkai_app_code": local_conf.get("linkai_app_code"),
|
||||||
|
"single_chat_prefix": local_conf.get("single_chat_prefix"),
|
||||||
|
"single_chat_reply_prefix": local_conf.get("single_chat_reply_prefix"),
|
||||||
|
"single_chat_reply_suffix": local_conf.get("single_chat_reply_suffix"),
|
||||||
|
"group_chat_prefix": local_conf.get("group_chat_prefix"),
|
||||||
|
"group_chat_reply_prefix": local_conf.get("group_chat_reply_prefix"),
|
||||||
|
"group_chat_reply_suffix": local_conf.get("group_chat_reply_suffix"),
|
||||||
|
"group_name_white_list": local_conf.get("group_name_white_list"),
|
||||||
|
"nick_name_black_list": local_conf.get("nick_name_black_list"),
|
||||||
|
"speech_recognition": "Y" if local_conf.get("speech_recognition") else "N",
|
||||||
|
"text_to_image": local_conf.get("text_to_image"),
|
||||||
|
"image_create_prefix": local_conf.get("image_create_prefix"),
|
||||||
|
"model": local_conf.get("model"),
|
||||||
|
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
||||||
|
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
||||||
|
"agent_max_steps": local_conf.get("agent_max_steps"),
|
||||||
|
"channelType": local_conf.get("channel_type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_conf.get("always_reply_voice"):
|
||||||
|
config["reply_voice_mode"] = "always_reply_voice"
|
||||||
|
elif local_conf.get("voice_reply_voice"):
|
||||||
|
config["reply_voice_mode"] = "voice_reply_voice"
|
||||||
|
|
||||||
|
if pconf("linkai"):
|
||||||
|
config["group_app_map"] = pconf("linkai").get("group_app_map")
|
||||||
|
|
||||||
|
if plugin_config.get("Godcmd"):
|
||||||
|
config["admin_password"] = plugin_config.get("Godcmd").get("password")
|
||||||
|
|
||||||
|
# Add channel-specific app credentials
|
||||||
|
current_channel_type = local_conf.get("channel_type", "")
|
||||||
|
if current_channel_type == "feishu":
|
||||||
|
config["app_id"] = local_conf.get("feishu_app_id")
|
||||||
|
config["app_secret"] = local_conf.get("feishu_app_secret")
|
||||||
|
elif current_channel_type == "dingtalk":
|
||||||
|
config["app_id"] = local_conf.get("dingtalk_client_id")
|
||||||
|
config["app_secret"] = local_conf.get("dingtalk_client_secret")
|
||||||
|
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
||||||
|
config["app_id"] = local_conf.get("wechatmp_app_id")
|
||||||
|
config["app_secret"] = local_conf.get("wechatmp_app_secret")
|
||||||
|
elif current_channel_type == "wechatcom_app":
|
||||||
|
config["app_id"] = local_conf.get("wechatcomapp_agent_id")
|
||||||
|
config["app_secret"] = local_conf.get("wechatcomapp_secret")
|
||||||
|
|
||||||
|
return config
|
||||||
@@ -25,8 +25,10 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名
|
|||||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
|
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
|
||||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0 - Agent推荐模型
|
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 - Agent推荐模型
|
||||||
|
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型
|
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型
|
||||||
|
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent推荐模型
|
||||||
|
|
||||||
# Gemini (Google)
|
# Gemini (Google)
|
||||||
GEMINI_PRO = "gemini-1.0-pro"
|
GEMINI_PRO = "gemini-1.0-pro"
|
||||||
@@ -34,10 +36,11 @@ GEMINI_15_flash = "gemini-1.5-flash"
|
|||||||
GEMINI_15_PRO = "gemini-1.5-pro"
|
GEMINI_15_PRO = "gemini-1.5-pro"
|
||||||
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
|
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
|
||||||
GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型
|
GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型
|
||||||
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20" # preview为预览版模型,主要是新能力体验
|
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
||||||
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
||||||
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent推荐模型
|
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent推荐模型
|
||||||
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview - Agent推荐模型
|
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
|
||||||
|
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
GPT35 = "gpt-3.5-turbo"
|
GPT35 = "gpt-3.5-turbo"
|
||||||
@@ -79,15 +82,18 @@ QWEN_PLUS = "qwen-plus"
|
|||||||
QWEN_MAX = "qwen-max"
|
QWEN_MAX = "qwen-max"
|
||||||
QWEN_LONG = "qwen-long"
|
QWEN_LONG = "qwen-long"
|
||||||
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
|
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
|
||||||
|
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
||||||
QWQ_PLUS = "qwq-plus"
|
QWQ_PLUS = "qwq-plus"
|
||||||
|
|
||||||
# MiniMax
|
# MiniMax
|
||||||
|
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5 - Latest
|
||||||
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1 - Agent推荐模型
|
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1 - Agent推荐模型
|
||||||
MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版
|
MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版
|
||||||
MINIMAX_M2 = "MiniMax-M2" # MiniMax M2
|
MINIMAX_M2 = "MiniMax-M2" # MiniMax M2
|
||||||
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# GLM (智谱AI)
|
||||||
|
GLM_5 = "glm-5" # 智谱 GLM-5 - Latest
|
||||||
GLM_4 = "glm-4"
|
GLM_4 = "glm-4"
|
||||||
GLM_4_PLUS = "glm-4-plus"
|
GLM_4_PLUS = "glm-4-plus"
|
||||||
GLM_4_flash = "glm-4-flash"
|
GLM_4_flash = "glm-4-flash"
|
||||||
@@ -100,6 +106,15 @@ GLM_4_7 = "glm-4.7" # 智谱 GLM-4.7 - Agent推荐模型
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
|
KIMI_K2 = "kimi-k2"
|
||||||
|
KIMI_K2_5 = "kimi-k2.5"
|
||||||
|
|
||||||
|
# Doubao (Volcengine Ark)
|
||||||
|
DOUBAO = "doubao"
|
||||||
|
DOUBAO_SEED_2_CODE = "doubao-seed-2-0-code-preview-260215"
|
||||||
|
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
||||||
|
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||||
|
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
|
||||||
|
|
||||||
# 其他模型
|
# 其他模型
|
||||||
WEN_XIN = "wenxin"
|
WEN_XIN = "wenxin"
|
||||||
@@ -120,12 +135,12 @@ MODELSCOPE_MODEL_LIST = ["LLM-Research/c4ai-command-r-plus-08-2024","mistralai/M
|
|||||||
|
|
||||||
MODEL_LIST = [
|
MODEL_LIST = [
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE3, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
CLAUDE3, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
# Gemini
|
# Gemini
|
||||||
GEMINI_3_PRO_PRE, GEMINI_3_FLASH_PRE, GEMINI_25_PRO_PRE, GEMINI_25_FLASH_PRE,
|
GEMINI_31_PRO_PRE, GEMINI_3_PRO_PRE, GEMINI_3_FLASH_PRE, GEMINI_25_PRO_PRE, GEMINI_25_FLASH_PRE,
|
||||||
GEMINI_20_FLASH, GEMINI_20_flash_exp, GEMINI_15_PRO, GEMINI_15_flash, GEMINI_PRO, GEMINI,
|
GEMINI_20_FLASH, GEMINI_20_flash_exp, GEMINI_15_PRO, GEMINI_15_flash, GEMINI_PRO, GEMINI,
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
@@ -141,17 +156,21 @@ MODEL_LIST = [
|
|||||||
DEEPSEEK_CHAT, DEEPSEEK_REASONER,
|
DEEPSEEK_CHAT, DEEPSEEK_REASONER,
|
||||||
|
|
||||||
# Qwen
|
# Qwen
|
||||||
QWEN, QWEN_TURBO, QWEN_PLUS, QWEN_MAX, QWEN_LONG, QWEN3_MAX,
|
QWEN, QWEN_TURBO, QWEN_PLUS, QWEN_MAX, QWEN_LONG, QWEN3_MAX, QWEN35_PLUS,
|
||||||
|
|
||||||
# MiniMax
|
# MiniMax
|
||||||
MiniMax, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
|
MiniMax, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
|
||||||
|
|
||||||
# GLM
|
# GLM
|
||||||
ZHIPU_AI, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
|
ZHIPU_AI, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
|
||||||
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
||||||
|
|
||||||
# Kimi
|
# Kimi
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||||
|
KIMI_K2, KIMI_K2_5,
|
||||||
|
|
||||||
|
# Doubao
|
||||||
|
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||||
|
|
||||||
# 其他模型
|
# 其他模型
|
||||||
WEN_XIN, WEN_XIN_4, XUNFEI,
|
WEN_XIN, WEN_XIN_4, XUNFEI,
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
from bridge.context import Context, ContextType
|
|
||||||
from bridge.reply import Reply, ReplyType
|
|
||||||
from common.log import logger
|
|
||||||
from linkai import LinkAIClient, PushMsg
|
|
||||||
from config import conf, pconf, plugin_config, available_setting, write_plugin_config
|
|
||||||
from plugins import PluginManager
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
chat_client: LinkAIClient
|
|
||||||
|
|
||||||
|
|
||||||
class ChatClient(LinkAIClient):
|
|
||||||
def __init__(self, api_key, host, channel):
|
|
||||||
super().__init__(api_key, host)
|
|
||||||
self.channel = channel
|
|
||||||
self.client_type = channel.channel_type
|
|
||||||
|
|
||||||
def on_message(self, push_msg: PushMsg):
|
|
||||||
session_id = push_msg.session_id
|
|
||||||
msg_content = push_msg.msg_content
|
|
||||||
logger.info(f"receive msg push, session_id={session_id}, msg_content={msg_content}")
|
|
||||||
context = Context()
|
|
||||||
context.type = ContextType.TEXT
|
|
||||||
context["receiver"] = session_id
|
|
||||||
context["isgroup"] = push_msg.is_group
|
|
||||||
self.channel.send(Reply(ReplyType.TEXT, content=msg_content), context)
|
|
||||||
|
|
||||||
def on_config(self, config: dict):
|
|
||||||
if not self.client_id:
|
|
||||||
return
|
|
||||||
logger.info(f"[LinkAI] 从客户端管理加载远程配置: {config}")
|
|
||||||
if config.get("enabled") != "Y":
|
|
||||||
return
|
|
||||||
|
|
||||||
local_config = conf()
|
|
||||||
for key in config.keys():
|
|
||||||
if key in available_setting and config.get(key) is not None:
|
|
||||||
local_config[key] = config.get(key)
|
|
||||||
# 语音配置
|
|
||||||
reply_voice_mode = config.get("reply_voice_mode")
|
|
||||||
if reply_voice_mode:
|
|
||||||
if reply_voice_mode == "voice_reply_voice":
|
|
||||||
local_config["voice_reply_voice"] = True
|
|
||||||
local_config["always_reply_voice"] = False
|
|
||||||
elif reply_voice_mode == "always_reply_voice":
|
|
||||||
local_config["always_reply_voice"] = True
|
|
||||||
local_config["voice_reply_voice"] = True
|
|
||||||
elif reply_voice_mode == "no_reply_voice":
|
|
||||||
local_config["always_reply_voice"] = False
|
|
||||||
local_config["voice_reply_voice"] = False
|
|
||||||
|
|
||||||
if config.get("admin_password"):
|
|
||||||
if not pconf("Godcmd"):
|
|
||||||
write_plugin_config({"Godcmd": {"password": config.get("admin_password"), "admin_users": []} })
|
|
||||||
else:
|
|
||||||
pconf("Godcmd")["password"] = config.get("admin_password")
|
|
||||||
PluginManager().instances["GODCMD"].reload()
|
|
||||||
|
|
||||||
if config.get("group_app_map") and pconf("linkai"):
|
|
||||||
local_group_map = {}
|
|
||||||
for mapping in config.get("group_app_map"):
|
|
||||||
local_group_map[mapping.get("group_name")] = mapping.get("app_code")
|
|
||||||
pconf("linkai")["group_app_map"] = local_group_map
|
|
||||||
PluginManager().instances["LINKAI"].reload()
|
|
||||||
|
|
||||||
if config.get("text_to_image") and config.get("text_to_image") == "midjourney" and pconf("linkai"):
|
|
||||||
if pconf("linkai")["midjourney"]:
|
|
||||||
pconf("linkai")["midjourney"]["enabled"] = True
|
|
||||||
pconf("linkai")["midjourney"]["use_image_create_prefix"] = True
|
|
||||||
elif config.get("text_to_image") and config.get("text_to_image") in ["dall-e-2", "dall-e-3"]:
|
|
||||||
if pconf("linkai")["midjourney"]:
|
|
||||||
pconf("linkai")["midjourney"]["use_image_create_prefix"] = False
|
|
||||||
|
|
||||||
|
|
||||||
def start(channel):
|
|
||||||
global chat_client
|
|
||||||
chat_client = ChatClient(api_key=conf().get("linkai_api_key"), host="", channel=channel)
|
|
||||||
chat_client.config = _build_config()
|
|
||||||
chat_client.start()
|
|
||||||
time.sleep(1.5)
|
|
||||||
if chat_client.client_id:
|
|
||||||
logger.info("[LinkAI] 可前往控制台进行线上登录和配置:https://link-ai.tech/console/clients")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_config():
|
|
||||||
local_conf = conf()
|
|
||||||
config = {
|
|
||||||
"linkai_app_code": local_conf.get("linkai_app_code"),
|
|
||||||
"single_chat_prefix": local_conf.get("single_chat_prefix"),
|
|
||||||
"single_chat_reply_prefix": local_conf.get("single_chat_reply_prefix"),
|
|
||||||
"single_chat_reply_suffix": local_conf.get("single_chat_reply_suffix"),
|
|
||||||
"group_chat_prefix": local_conf.get("group_chat_prefix"),
|
|
||||||
"group_chat_reply_prefix": local_conf.get("group_chat_reply_prefix"),
|
|
||||||
"group_chat_reply_suffix": local_conf.get("group_chat_reply_suffix"),
|
|
||||||
"group_name_white_list": local_conf.get("group_name_white_list"),
|
|
||||||
"nick_name_black_list": local_conf.get("nick_name_black_list"),
|
|
||||||
"speech_recognition": "Y" if local_conf.get("speech_recognition") else "N",
|
|
||||||
"text_to_image": local_conf.get("text_to_image"),
|
|
||||||
"image_create_prefix": local_conf.get("image_create_prefix")
|
|
||||||
}
|
|
||||||
if local_conf.get("always_reply_voice"):
|
|
||||||
config["reply_voice_mode"] = "always_reply_voice"
|
|
||||||
elif local_conf.get("voice_reply_voice"):
|
|
||||||
config["reply_voice_mode"] = "voice_reply_voice"
|
|
||||||
if pconf("linkai"):
|
|
||||||
config["group_app_map"] = pconf("linkai").get("group_app_map")
|
|
||||||
if plugin_config.get("Godcmd"):
|
|
||||||
config["admin_password"] = plugin_config.get("Godcmd").get("password")
|
|
||||||
return config
|
|
||||||
@@ -2,7 +2,6 @@ import io
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from PIL import Image
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
|
||||||
def fsize(file):
|
def fsize(file):
|
||||||
@@ -23,6 +22,7 @@ def fsize(file):
|
|||||||
def compress_imgfile(file, max_size):
|
def compress_imgfile(file, max_size):
|
||||||
if fsize(file) <= max_size:
|
if fsize(file) <= max_size:
|
||||||
return file
|
return file
|
||||||
|
from PIL import Image
|
||||||
file.seek(0)
|
file.seek(0)
|
||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
rgb_image = img.convert("RGB")
|
rgb_image = img.convert("RGB")
|
||||||
@@ -76,3 +76,42 @@ def remove_markdown_symbol(text: str):
|
|||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
return re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
return re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Expand user path with proper Windows support.
|
||||||
|
|
||||||
|
On Windows, os.path.expanduser('~') may not work properly in some shells (like PowerShell).
|
||||||
|
This function provides a more robust path expansion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path string that may contain ~
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Expanded absolute path
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Try standard expansion first
|
||||||
|
expanded = os.path.expanduser(path)
|
||||||
|
|
||||||
|
# If expansion didn't work (path still starts with ~), use HOME or USERPROFILE
|
||||||
|
if expanded.startswith('~'):
|
||||||
|
import platform
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
# On Windows, try USERPROFILE first, then HOME
|
||||||
|
home = os.environ.get('USERPROFILE') or os.environ.get('HOME')
|
||||||
|
else:
|
||||||
|
# On Unix-like systems, use HOME
|
||||||
|
home = os.environ.get('HOME')
|
||||||
|
|
||||||
|
if home:
|
||||||
|
# Replace ~ with home directory
|
||||||
|
if path == '~':
|
||||||
|
expanded = home
|
||||||
|
elif path.startswith('~/') or path.startswith('~\\'):
|
||||||
|
expanded = os.path.join(home, path[2:])
|
||||||
|
|
||||||
|
return expanded
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"channel_type": "web",
|
"channel_type": "web",
|
||||||
"model": "claude-sonnet-4-5",
|
"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_key": "",
|
||||||
"claude_api_base": "https://api.anthropic.com/v1",
|
"claude_api_base": "https://api.anthropic.com/v1",
|
||||||
"open_ai_api_key": "",
|
"open_ai_api_key": "",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1",
|
"open_ai_api_base": "https://api.openai.com/v1",
|
||||||
"gemini_api_key": "",
|
"gemini_api_key": "",
|
||||||
"gemini_api_base": "https://generativelanguage.googleapis.com",
|
"gemini_api_base": "https://generativelanguage.googleapis.com",
|
||||||
"zhipu_ai_api_key": "",
|
|
||||||
"minimax_api_key": "",
|
|
||||||
"dashscope_api_key": "",
|
|
||||||
"voice_to_text": "openai",
|
"voice_to_text": "openai",
|
||||||
"text_to_voice": "openai",
|
"text_to_voice": "openai",
|
||||||
"voice_reply_voice": false,
|
"voice_reply_voice": false,
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ available_setting = {
|
|||||||
"zhipu_ai_api_key": "",
|
"zhipu_ai_api_key": "",
|
||||||
"zhipu_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
"zhipu_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"moonshot_api_key": "",
|
"moonshot_api_key": "",
|
||||||
"moonshot_base_url": "https://api.moonshot.cn/v1/chat/completions",
|
"moonshot_base_url": "https://api.moonshot.cn/v1",
|
||||||
|
# 豆包(火山方舟) 平台配置
|
||||||
|
"ark_api_key": "",
|
||||||
|
"ark_base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
#魔搭社区 平台配置
|
#魔搭社区 平台配置
|
||||||
"modelscope_api_key": "",
|
"modelscope_api_key": "",
|
||||||
"modelscope_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
|
"modelscope_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
|
||||||
@@ -245,9 +248,9 @@ class Config(dict):
|
|||||||
self.user_datas = pickle.load(f)
|
self.user_datas = pickle.load(f)
|
||||||
logger.debug("[Config] User datas loaded.")
|
logger.debug("[Config] User datas loaded.")
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
logger.info("[Config] User datas file not found, ignore.")
|
logger.debug("[Config] User datas file not found, ignore.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info("[Config] User datas error: {}".format(e))
|
logger.warning("[Config] User datas error: {}".format(e))
|
||||||
self.user_datas = {}
|
self.user_datas = {}
|
||||||
|
|
||||||
def save_user_datas(self):
|
def save_user_datas(self):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Cow项目从简单的聊天机器人全面升级为超级智能助理 **CowAgent
|
|||||||
- **工具系统**:内置实现10+种工具,包括文件读写、bash终端、浏览器、定时任务、记忆管理等,通过Agent管理你的计算机或服务器
|
- **工具系统**:内置实现10+种工具,包括文件读写、bash终端、浏览器、定时任务、记忆管理等,通过Agent管理你的计算机或服务器
|
||||||
- **长期记忆**:自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
|
- **长期记忆**:自动将对话记忆持久化至本地文件和数据库中,包括全局记忆和天级记忆,支持关键词及向量检索
|
||||||
- **Skills系统**:新增Skill运行引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
- **Skills系统**:新增Skill运行引擎,内置多种技能,并支持通过自然语言对话完成自定义Skills开发
|
||||||
- **多渠道和多模型支持**:支持在Web、飞书、钉钉、企微等多渠道与Agent交互,支持Claude、Gemini、OpenAI、GLM、MiniMax、Qwen 等多种国内外主流模型
|
- **多渠道和多模型支持**:支持在Web、飞书、钉钉、企微等多渠道与Agent交互,支持Claude、Gemini、OpenAI、GLM、MiniMax、Qwen、Kimi、Doubao 等多种国内外主流模型
|
||||||
- **安全和成本**:通过秘钥管理工具、提示词控制、系统权限等手段控制Agent的访问安全;通过最大记忆轮次、最大上下文token、工具执行步数对token成本进行限制
|
- **安全和成本**:通过秘钥管理工具、提示词控制、系统权限等手段控制Agent的访问安全;通过最大记忆轮次、最大上下文token、工具执行步数对token成本进行限制
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ Cow项目从简单的聊天机器人全面升级为超级智能助理 **CowAgent
|
|||||||
|
|
||||||
- **内置技能:** 在项目的`skills`目录下,包含技能创造器、网络搜索、图像识别(openai-image-vision)、LinkAI智能体、网页抓取等。内置Skill根据依赖条件 (API Key、系统命令等) 自动判断是否启用。通过技能创造器可以快速创建自定义技能。
|
- **内置技能:** 在项目的`skills`目录下,包含技能创造器、网络搜索、图像识别(openai-image-vision)、LinkAI智能体、网页抓取等。内置Skill根据依赖条件 (API Key、系统命令等) 自动判断是否启用。通过技能创造器可以快速创建自定义技能。
|
||||||
|
|
||||||
- **自定义技能:** 由用户通过对话创建,存放在工作空间中 (`~/cow/skills/`),基于自定义技能可以实现任何
|
- **自定义技能:** 由用户通过对话创建,存放在工作空间中 (`~/cow/skills/`),基于自定义技能可以实现任何复杂的业务流程和第三方系统对接。
|
||||||
|
|
||||||
|
|
||||||
#### 3.1 创建技能
|
#### 3.1 创建技能
|
||||||
@@ -82,7 +82,7 @@ Cow项目从简单的聊天机器人全面升级为超级智能助理 **CowAgent
|
|||||||
|
|
||||||
#### 3.2 搜索和图像识别
|
#### 3.2 搜索和图像识别
|
||||||
|
|
||||||
- **搜索技能:** 系统内置实现了 `bocha-search`(博查搜索)的Skill,依赖环境变量 `BOCHA_SEARCH_API_KEY`,可在[控制台]()进行创建,并发送给Agent完成配置
|
- **搜索技能:** 系统内置实现了 `bocha-search`(博查搜索)的Skill,依赖环境变量 `BOCHA_SEARCH_API_KEY`,可在[控制台](https://open.bochaai.com/)进行创建,并发送给Agent完成配置
|
||||||
- **图像识别技能:** 实现了 `openai-image-vision` 插件,可使用 gpt-4.1-mini、gpt-4.1 等图像识别模型。依赖秘钥 `OPENAI_API_KEY`,可通过config.json或env_config工具进行维护。
|
- **图像识别技能:** 实现了 `openai-image-vision` 插件,可使用 gpt-4.1-mini、gpt-4.1 等图像识别模型。依赖秘钥 `OPENAI_API_KEY`,可通过config.json或env_config工具进行维护。
|
||||||
|
|
||||||
<img width="800" src="https://cdn.link-ai.tech/doc/20260202213219.png">
|
<img width="800" src="https://cdn.link-ai.tech/doc/20260202213219.png">
|
||||||
@@ -137,11 +137,13 @@ bash <(curl -sS https://cdn.link-ai.tech/code/cow/run.sh)
|
|||||||
|
|
||||||
Agent模式推荐使用以下模型,可根据效果及成本综合选择:
|
Agent模式推荐使用以下模型,可根据效果及成本综合选择:
|
||||||
|
|
||||||
- **Claude**: `claude-sonnet-4-5`、`claude-sonnet-4-0`
|
- **MiniMax**: `MiniMax-M2.5`
|
||||||
- **Gemini**: `gemini-3-flash-preview`、`gemini-3-pro-preview`
|
- **GLM**: `glm-5`
|
||||||
- **GLM**: `glm-4.7`
|
- **Kimi**: `kimi-k2.5`
|
||||||
- **MiniMax**: `MiniMax-M2.1`
|
- **Doubao**: `doubao-seed-2-0-code-preview-260215`
|
||||||
- **Qwen**: `qwen3-max`
|
- **Qwen**: `qwen3.5-plus`
|
||||||
|
- **Claude**: `claude-sonnet-4-6`
|
||||||
|
- **Gemini**: `gemini-3.1-pro-preview`
|
||||||
|
|
||||||
详细模型配置方式参考 [README.md 模型说明](../README.md#模型说明)
|
详细模型配置方式参考 [README.md 模型说明](../README.md#模型说明)
|
||||||
|
|
||||||
|
|||||||
@@ -69,5 +69,8 @@ def create_bot(bot_type):
|
|||||||
from models.modelscope.modelscope_bot import ModelScopeBot
|
from models.modelscope.modelscope_bot import ModelScopeBot
|
||||||
return ModelScopeBot()
|
return ModelScopeBot()
|
||||||
|
|
||||||
|
elif bot_type == const.DOUBAO:
|
||||||
|
from models.doubao.doubao_bot import DoubaoBot
|
||||||
|
return DoubaoBot()
|
||||||
|
|
||||||
raise RuntimeError
|
raise RuntimeError
|
||||||
|
|||||||
@@ -125,10 +125,100 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
|
|||||||
else:
|
else:
|
||||||
reply = Reply(ReplyType.ERROR, retstring)
|
reply = Reply(ReplyType.ERROR, retstring)
|
||||||
return reply
|
return reply
|
||||||
|
elif context.type == ContextType.IMAGE:
|
||||||
|
logger.info("[CHATGPT] Image message received")
|
||||||
|
reply = self.reply_image(context)
|
||||||
|
return reply
|
||||||
else:
|
else:
|
||||||
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
def reply_image(self, context):
|
||||||
|
"""
|
||||||
|
Process image message using OpenAI Vision API
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_path = context.content
|
||||||
|
logger.info(f"[CHATGPT] Processing image: {image_path}")
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.error(f"[CHATGPT] Image file not found: {image_path}")
|
||||||
|
return Reply(ReplyType.ERROR, "图片文件不存在")
|
||||||
|
|
||||||
|
# Read and encode image
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
image_base64 = base64.b64encode(image_data).decode("utf-8")
|
||||||
|
|
||||||
|
# Detect image format
|
||||||
|
extension = os.path.splitext(image_path)[1].lower()
|
||||||
|
mime_type_map = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp"
|
||||||
|
}
|
||||||
|
mime_type = mime_type_map.get(extension, "image/jpeg")
|
||||||
|
|
||||||
|
# Get model and API config
|
||||||
|
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
|
||||||
|
api_key = context.get("openai_api_key") or conf().get("open_ai_api_key")
|
||||||
|
api_base = conf().get("open_ai_api_base")
|
||||||
|
|
||||||
|
# Build vision request
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "请描述这张图片的内容"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:{mime_type};base64,{image_base64}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"[CHATGPT] Calling vision API with model: {model}")
|
||||||
|
|
||||||
|
# Call OpenAI API
|
||||||
|
kwargs = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": 1000
|
||||||
|
}
|
||||||
|
if api_key:
|
||||||
|
kwargs["api_key"] = api_key
|
||||||
|
if api_base:
|
||||||
|
kwargs["api_base"] = api_base
|
||||||
|
|
||||||
|
response = openai.ChatCompletion.create(**kwargs)
|
||||||
|
|
||||||
|
content = response.choices[0]["message"]["content"]
|
||||||
|
logger.info(f"[CHATGPT] Vision API response: {content[:100]}...")
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
try:
|
||||||
|
os.remove(image_path)
|
||||||
|
logger.debug(f"[CHATGPT] Removed temp image file: {image_path}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Reply(ReplyType.TEXT, content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CHATGPT] Image processing error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return Reply(ReplyType.ERROR, f"图片识别失败: {str(e)}")
|
||||||
|
|
||||||
def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_count=0) -> dict:
|
def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_count=0) -> dict:
|
||||||
"""
|
"""
|
||||||
call openai's ChatCompletion to get the answer
|
call openai's ChatCompletion to get the answer
|
||||||
|
|||||||
@@ -10,25 +10,26 @@ from config import conf, load_config
|
|||||||
from .dashscope_session import DashscopeSession
|
from .dashscope_session import DashscopeSession
|
||||||
import os
|
import os
|
||||||
import dashscope
|
import dashscope
|
||||||
|
from dashscope import MultiModalConversation
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy model name mapping for older dashscope SDK constants.
|
||||||
|
# New models don't need to be added here — they use their name string directly.
|
||||||
dashscope_models = {
|
dashscope_models = {
|
||||||
"qwen-turbo": dashscope.Generation.Models.qwen_turbo,
|
"qwen-turbo": dashscope.Generation.Models.qwen_turbo,
|
||||||
"qwen-plus": dashscope.Generation.Models.qwen_plus,
|
"qwen-plus": dashscope.Generation.Models.qwen_plus,
|
||||||
"qwen-max": dashscope.Generation.Models.qwen_max,
|
"qwen-max": dashscope.Generation.Models.qwen_max,
|
||||||
"qwen-bailian-v1": dashscope.Generation.Models.bailian_v1,
|
"qwen-bailian-v1": dashscope.Generation.Models.bailian_v1,
|
||||||
# Qwen3 series models - use string directly as model name
|
|
||||||
"qwen3-max": "qwen3-max",
|
|
||||||
"qwen3-plus": "qwen3-plus",
|
|
||||||
"qwen3-turbo": "qwen3-turbo",
|
|
||||||
# Other new models
|
|
||||||
"qwen-long": "qwen-long",
|
|
||||||
"qwq-32b-preview": "qwq-32b-preview",
|
|
||||||
"qvq-72b-preview": "qvq-72b-preview"
|
|
||||||
}
|
}
|
||||||
# ZhipuAI对话模型API
|
|
||||||
|
# Model name prefixes that require MultiModalConversation API instead of Generation API.
|
||||||
|
# Qwen3.5+ series are omni models that only support MultiModalConversation.
|
||||||
|
MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-",)
|
||||||
|
|
||||||
|
|
||||||
|
# Qwen对话模型API
|
||||||
class DashscopeBot(Bot):
|
class DashscopeBot(Bot):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -39,6 +40,11 @@ class DashscopeBot(Bot):
|
|||||||
os.environ["DASHSCOPE_API_KEY"] = self.api_key
|
os.environ["DASHSCOPE_API_KEY"] = self.api_key
|
||||||
self.client = dashscope.Generation
|
self.client = dashscope.Generation
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_multimodal_model(model_name: str) -> bool:
|
||||||
|
"""Check if the model requires MultiModalConversation API"""
|
||||||
|
return model_name.startswith(MULTIMODAL_MODEL_PREFIXES)
|
||||||
|
|
||||||
def reply(self, query, context=None):
|
def reply(self, query, context=None):
|
||||||
# acquire reply content
|
# acquire reply content
|
||||||
if context.type == ContextType.TEXT:
|
if context.type == ContextType.TEXT:
|
||||||
@@ -93,16 +99,33 @@ class DashscopeBot(Bot):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
dashscope.api_key = self.api_key
|
dashscope.api_key = self.api_key
|
||||||
response = self.client.call(
|
model = dashscope_models.get(self.model_name, self.model_name)
|
||||||
dashscope_models[self.model_name],
|
if self._is_multimodal_model(self.model_name):
|
||||||
messages=session.messages,
|
mm_messages = self._prepare_messages_for_multimodal(session.messages)
|
||||||
result_format="message"
|
response = MultiModalConversation.call(
|
||||||
)
|
model=model,
|
||||||
|
messages=mm_messages,
|
||||||
|
result_format="message"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = self.client.call(
|
||||||
|
model,
|
||||||
|
messages=session.messages,
|
||||||
|
result_format="message"
|
||||||
|
)
|
||||||
if response.status_code == HTTPStatus.OK:
|
if response.status_code == HTTPStatus.OK:
|
||||||
content = response.output.choices[0]["message"]["content"]
|
resp_dict = self._response_to_dict(response)
|
||||||
|
choice = resp_dict["output"]["choices"][0]
|
||||||
|
content = choice.get("message", {}).get("content", "")
|
||||||
|
# Multimodal models may return content as a list of blocks
|
||||||
|
if isinstance(content, list):
|
||||||
|
content = "".join(
|
||||||
|
item.get("text", "") for item in content if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
usage = resp_dict.get("usage", {})
|
||||||
return {
|
return {
|
||||||
"total_tokens": response.usage["total_tokens"],
|
"total_tokens": usage.get("total_tokens", 0),
|
||||||
"completion_tokens": response.usage["output_tokens"],
|
"completion_tokens": usage.get("output_tokens", 0),
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
@@ -232,36 +255,54 @@ class DashscopeBot(Bot):
|
|||||||
try:
|
try:
|
||||||
# Set API key before calling
|
# Set API key before calling
|
||||||
dashscope.api_key = self.api_key
|
dashscope.api_key = self.api_key
|
||||||
|
model = dashscope_models.get(model_name, model_name)
|
||||||
|
|
||||||
response = dashscope.Generation.call(
|
if self._is_multimodal_model(model_name):
|
||||||
model=dashscope_models.get(model_name, model_name),
|
messages = self._prepare_messages_for_multimodal(messages)
|
||||||
messages=messages,
|
response = MultiModalConversation.call(
|
||||||
**parameters
|
model=model,
|
||||||
)
|
messages=messages,
|
||||||
|
**parameters
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = dashscope.Generation.call(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
**parameters
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code == HTTPStatus.OK:
|
if response.status_code == HTTPStatus.OK:
|
||||||
# Convert DashScope response to OpenAI-compatible format
|
# Convert response to dict to avoid DashScope object KeyError issues
|
||||||
choice = response.output.choices[0]
|
resp_dict = self._response_to_dict(response)
|
||||||
|
choice = resp_dict["output"]["choices"][0]
|
||||||
|
message = choice.get("message", {})
|
||||||
|
content = message.get("content", "")
|
||||||
|
# Multimodal models may return content as a list of blocks
|
||||||
|
if isinstance(content, list):
|
||||||
|
content = "".join(
|
||||||
|
item.get("text", "") for item in content if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
usage = resp_dict.get("usage", {})
|
||||||
return {
|
return {
|
||||||
"id": response.request_id,
|
"id": resp_dict.get("request_id"),
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
"created": 0,
|
"created": 0,
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
"choices": [{
|
"choices": [{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": {
|
"message": {
|
||||||
"role": choice.message.role,
|
"role": message.get("role", "assistant"),
|
||||||
"content": choice.message.content,
|
"content": content,
|
||||||
"tool_calls": self._convert_tool_calls_to_openai_format(
|
"tool_calls": self._convert_tool_calls_to_openai_format(
|
||||||
choice.message.get("tool_calls")
|
message.get("tool_calls")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
"finish_reason": choice.finish_reason
|
"finish_reason": choice.get("finish_reason")
|
||||||
}],
|
}],
|
||||||
"usage": {
|
"usage": {
|
||||||
"prompt_tokens": response.usage.input_tokens,
|
"prompt_tokens": usage.get("input_tokens", 0),
|
||||||
"completion_tokens": response.usage.output_tokens,
|
"completion_tokens": usage.get("output_tokens", 0),
|
||||||
"total_tokens": response.usage.total_tokens
|
"total_tokens": usage.get("total_tokens", 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
@@ -285,48 +326,52 @@ class DashscopeBot(Bot):
|
|||||||
try:
|
try:
|
||||||
# Set API key before calling
|
# Set API key before calling
|
||||||
dashscope.api_key = self.api_key
|
dashscope.api_key = self.api_key
|
||||||
|
model = dashscope_models.get(model_name, model_name)
|
||||||
|
|
||||||
responses = dashscope.Generation.call(
|
if self._is_multimodal_model(model_name):
|
||||||
model=dashscope_models.get(model_name, model_name),
|
messages = self._prepare_messages_for_multimodal(messages)
|
||||||
messages=messages,
|
responses = MultiModalConversation.call(
|
||||||
stream=True,
|
model=model,
|
||||||
**parameters
|
messages=messages,
|
||||||
)
|
stream=True,
|
||||||
|
**parameters
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
responses = dashscope.Generation.call(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
stream=True,
|
||||||
|
**parameters
|
||||||
|
)
|
||||||
|
|
||||||
# Stream chunks to caller, converting to OpenAI format
|
# Stream chunks to caller, converting to OpenAI format
|
||||||
for response in responses:
|
for response in responses:
|
||||||
if response.status_code != HTTPStatus.OK:
|
# Convert to dict first to avoid DashScope proxy object KeyError
|
||||||
logger.error(f"[DASHSCOPE] Stream error: {response.code} - {response.message}")
|
resp_dict = self._response_to_dict(response)
|
||||||
|
status_code = resp_dict.get("status_code", 200)
|
||||||
|
|
||||||
|
if status_code != HTTPStatus.OK:
|
||||||
|
err_code = resp_dict.get("code", "")
|
||||||
|
err_msg = resp_dict.get("message", "Unknown error")
|
||||||
|
logger.error(f"[DASHSCOPE] Stream error: {err_code} - {err_msg}")
|
||||||
yield {
|
yield {
|
||||||
"error": True,
|
"error": True,
|
||||||
"message": response.message,
|
"message": err_msg,
|
||||||
"status_code": response.status_code
|
"status_code": status_code
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get choice - use try-except because DashScope raises KeyError on hasattr()
|
choices = resp_dict.get("output", {}).get("choices", [])
|
||||||
try:
|
if not choices:
|
||||||
if isinstance(response.output, dict):
|
|
||||||
choice = response.output['choices'][0]
|
|
||||||
else:
|
|
||||||
choice = response.output.choices[0]
|
|
||||||
except (KeyError, AttributeError, IndexError) as e:
|
|
||||||
logger.warning(f"[DASHSCOPE] Cannot get choice: {e}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get finish_reason safely
|
choice = choices[0]
|
||||||
finish_reason = None
|
finish_reason = choice.get("finish_reason")
|
||||||
try:
|
message = choice.get("message", {})
|
||||||
if isinstance(choice, dict):
|
|
||||||
finish_reason = choice.get('finish_reason')
|
|
||||||
else:
|
|
||||||
finish_reason = choice.finish_reason
|
|
||||||
except (KeyError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Convert to OpenAI-compatible format
|
# Convert to OpenAI-compatible format
|
||||||
openai_chunk = {
|
openai_chunk = {
|
||||||
"id": response.request_id,
|
"id": resp_dict.get("request_id"),
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
"created": 0,
|
"created": 0,
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
@@ -337,65 +382,89 @@ class DashscopeBot(Bot):
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get message safely - use try-except
|
# Add role
|
||||||
message = {}
|
role = message.get("role")
|
||||||
try:
|
|
||||||
if isinstance(choice, dict):
|
|
||||||
message = choice.get('message', {})
|
|
||||||
else:
|
|
||||||
message = choice.message
|
|
||||||
except (KeyError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Add role if present
|
|
||||||
role = None
|
|
||||||
try:
|
|
||||||
if isinstance(message, dict):
|
|
||||||
role = message.get('role')
|
|
||||||
else:
|
|
||||||
role = message.role
|
|
||||||
except (KeyError, AttributeError):
|
|
||||||
pass
|
|
||||||
if role:
|
if role:
|
||||||
openai_chunk["choices"][0]["delta"]["role"] = role
|
openai_chunk["choices"][0]["delta"]["role"] = role
|
||||||
|
|
||||||
# Add content if present
|
# Add reasoning_content (thinking process from models like qwen3.5)
|
||||||
content = None
|
reasoning_content = message.get("reasoning_content")
|
||||||
try:
|
if reasoning_content:
|
||||||
if isinstance(message, dict):
|
openai_chunk["choices"][0]["delta"]["reasoning_content"] = reasoning_content
|
||||||
content = message.get('content')
|
|
||||||
else:
|
# Add content (multimodal models may return list of blocks)
|
||||||
content = message.content
|
content = message.get("content")
|
||||||
except (KeyError, AttributeError):
|
if isinstance(content, list):
|
||||||
pass
|
content = "".join(
|
||||||
|
item.get("text", "") for item in content if isinstance(item, dict)
|
||||||
|
)
|
||||||
if content:
|
if content:
|
||||||
openai_chunk["choices"][0]["delta"]["content"] = content
|
openai_chunk["choices"][0]["delta"]["content"] = content
|
||||||
|
|
||||||
# Add tool_calls if present
|
# Add tool_calls
|
||||||
# DashScope's response object raises KeyError on hasattr() if attr doesn't exist
|
tool_calls = message.get("tool_calls")
|
||||||
# So we use try-except instead
|
|
||||||
tool_calls = None
|
|
||||||
try:
|
|
||||||
if isinstance(message, dict):
|
|
||||||
tool_calls = message.get('tool_calls')
|
|
||||||
else:
|
|
||||||
tool_calls = message.tool_calls
|
|
||||||
except (KeyError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
openai_chunk["choices"][0]["delta"]["tool_calls"] = self._convert_tool_calls_to_openai_format(tool_calls)
|
openai_chunk["choices"][0]["delta"]["tool_calls"] = self._convert_tool_calls_to_openai_format(tool_calls)
|
||||||
|
|
||||||
yield openai_chunk
|
yield openai_chunk
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[DASHSCOPE] stream response error: {e}")
|
logger.error(f"[DASHSCOPE] stream response error: {e}", exc_info=True)
|
||||||
yield {
|
yield {
|
||||||
"error": True,
|
"error": True,
|
||||||
"message": str(e),
|
"message": str(e),
|
||||||
"status_code": 500
|
"status_code": 500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _response_to_dict(response) -> dict:
|
||||||
|
"""
|
||||||
|
Convert DashScope response object to a plain dict.
|
||||||
|
|
||||||
|
DashScope SDK wraps responses in proxy objects whose __getattr__
|
||||||
|
delegates to __getitem__, raising KeyError (not AttributeError)
|
||||||
|
when an attribute is missing. Standard hasattr / getattr only
|
||||||
|
catch AttributeError, so we must use try-except everywhere.
|
||||||
|
"""
|
||||||
|
_SENTINEL = object()
|
||||||
|
|
||||||
|
def _safe_getattr(obj, name, default=_SENTINEL):
|
||||||
|
"""getattr that also catches KeyError from DashScope proxy objects."""
|
||||||
|
try:
|
||||||
|
return getattr(obj, name)
|
||||||
|
except (AttributeError, KeyError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _has_attr(obj, name):
|
||||||
|
return _safe_getattr(obj, name) is not _SENTINEL
|
||||||
|
|
||||||
|
def _to_dict(obj):
|
||||||
|
if isinstance(obj, (str, int, float, bool, type(None))):
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _to_dict(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_to_dict(i) for i in obj]
|
||||||
|
# DashScope response objects behave like dicts (have .keys())
|
||||||
|
if _has_attr(obj, "keys"):
|
||||||
|
try:
|
||||||
|
return {k: _to_dict(obj[k]) for k in obj.keys()}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return obj
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
# Extract known top-level fields safely
|
||||||
|
for attr in ("request_id", "status_code", "code", "message", "output", "usage"):
|
||||||
|
val = _safe_getattr(response, attr)
|
||||||
|
if val is _SENTINEL:
|
||||||
|
try:
|
||||||
|
val = response[attr]
|
||||||
|
except (KeyError, TypeError, IndexError):
|
||||||
|
continue
|
||||||
|
result[attr] = _to_dict(val)
|
||||||
|
return result
|
||||||
|
|
||||||
def _convert_tools_to_dashscope_format(self, tools):
|
def _convert_tools_to_dashscope_format(self, tools):
|
||||||
"""
|
"""
|
||||||
Convert tools from Claude format to DashScope format
|
Convert tools from Claude format to DashScope format
|
||||||
@@ -424,6 +493,37 @@ class DashscopeBot(Bot):
|
|||||||
|
|
||||||
return dashscope_tools
|
return dashscope_tools
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepare_messages_for_multimodal(messages: list) -> list:
|
||||||
|
"""
|
||||||
|
Ensure messages are compatible with MultiModalConversation API.
|
||||||
|
|
||||||
|
MultiModalConversation._preprocess_messages iterates every message
|
||||||
|
with ``content = message["content"]; for elem in content: ...``,
|
||||||
|
which means:
|
||||||
|
1. Every message MUST have a 'content' key.
|
||||||
|
2. 'content' MUST be an iterable (list), not a plain string.
|
||||||
|
The expected format is [{"text": "..."}, ...].
|
||||||
|
|
||||||
|
Meanwhile the DashScope API requires role='tool' messages to follow
|
||||||
|
assistant tool_calls, so we must NOT convert them to role='user'.
|
||||||
|
We just ensure they have a list-typed 'content'.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for msg in messages:
|
||||||
|
msg = dict(msg) # shallow copy
|
||||||
|
|
||||||
|
# Normalize content to list format [{"text": "..."}]
|
||||||
|
content = msg.get("content")
|
||||||
|
if content is None or (isinstance(content, str) and content == ""):
|
||||||
|
msg["content"] = [{"text": ""}]
|
||||||
|
elif isinstance(content, str):
|
||||||
|
msg["content"] = [{"text": content}]
|
||||||
|
# If content is already a list, keep as-is (already in multimodal format)
|
||||||
|
|
||||||
|
result.append(msg)
|
||||||
|
return result
|
||||||
|
|
||||||
def _convert_messages_to_dashscope_format(self, messages):
|
def _convert_messages_to_dashscope_format(self, messages):
|
||||||
"""
|
"""
|
||||||
Convert messages from Claude format to DashScope format
|
Convert messages from Claude format to DashScope format
|
||||||
|
|||||||
0
models/doubao/__init__.py
Normal file
0
models/doubao/__init__.py
Normal file
520
models/doubao/doubao_bot.py
Normal file
520
models/doubao/doubao_bot.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from models.bot import Bot
|
||||||
|
from models.session_manager import SessionManager
|
||||||
|
from bridge.context import ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from common.log import logger
|
||||||
|
from config import conf, load_config
|
||||||
|
from .doubao_session import DoubaoSession
|
||||||
|
|
||||||
|
|
||||||
|
# Doubao (火山方舟 / Volcengine Ark) API Bot
|
||||||
|
class DoubaoBot(Bot):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.sessions = SessionManager(DoubaoSession, model=conf().get("model") or "doubao-seed-2-0-pro-260215")
|
||||||
|
model = conf().get("model") or "doubao-seed-2-0-pro-260215"
|
||||||
|
self.args = {
|
||||||
|
"model": model,
|
||||||
|
"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("/")
|
||||||
|
|
||||||
|
def reply(self, query, context=None):
|
||||||
|
# acquire reply content
|
||||||
|
if context.type == ContextType.TEXT:
|
||||||
|
logger.info("[DOUBAO] query={}".format(query))
|
||||||
|
|
||||||
|
session_id = context["session_id"]
|
||||||
|
reply = None
|
||||||
|
clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
|
||||||
|
if query in clear_memory_commands:
|
||||||
|
self.sessions.clear_session(session_id)
|
||||||
|
reply = Reply(ReplyType.INFO, "记忆已清除")
|
||||||
|
elif query == "#清除所有":
|
||||||
|
self.sessions.clear_all_session()
|
||||||
|
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
|
||||||
|
elif query == "#更新配置":
|
||||||
|
load_config()
|
||||||
|
reply = Reply(ReplyType.INFO, "配置已更新")
|
||||||
|
if reply:
|
||||||
|
return reply
|
||||||
|
session = self.sessions.session_query(query, session_id)
|
||||||
|
logger.debug("[DOUBAO] session query={}".format(session.messages))
|
||||||
|
|
||||||
|
model = context.get("doubao_model")
|
||||||
|
new_args = self.args.copy()
|
||||||
|
if model:
|
||||||
|
new_args["model"] = model
|
||||||
|
|
||||||
|
reply_content = self.reply_text(session, args=new_args)
|
||||||
|
logger.debug(
|
||||||
|
"[DOUBAO] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
|
||||||
|
session.messages,
|
||||||
|
session_id,
|
||||||
|
reply_content["content"],
|
||||||
|
reply_content["completion_tokens"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
|
||||||
|
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||||
|
elif reply_content["completion_tokens"] > 0:
|
||||||
|
self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"])
|
||||||
|
reply = Reply(ReplyType.TEXT, reply_content["content"])
|
||||||
|
else:
|
||||||
|
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||||
|
logger.debug("[DOUBAO] reply {} used 0 tokens.".format(reply_content))
|
||||||
|
return reply
|
||||||
|
else:
|
||||||
|
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def reply_text(self, session: DoubaoSession, args=None, retry_count: int = 0) -> dict:
|
||||||
|
"""
|
||||||
|
Call Doubao chat completion API to get the answer
|
||||||
|
:param session: a conversation session
|
||||||
|
:param args: model args
|
||||||
|
:param retry_count: retry count
|
||||||
|
:return: {}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + self.api_key
|
||||||
|
}
|
||||||
|
body = args.copy()
|
||||||
|
body["messages"] = session.messages
|
||||||
|
# Disable thinking by default for better efficiency
|
||||||
|
body["thinking"] = {"type": "disabled"}
|
||||||
|
res = requests.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=body
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
response = res.json()
|
||||||
|
return {
|
||||||
|
"total_tokens": response["usage"]["total_tokens"],
|
||||||
|
"completion_tokens": response["usage"]["completion_tokens"],
|
||||||
|
"content": response["choices"][0]["message"]["content"]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
response = res.json()
|
||||||
|
error = response.get("error", {})
|
||||||
|
logger.error(f"[DOUBAO] chat failed, status_code={res.status_code}, "
|
||||||
|
f"msg={error.get('message')}, type={error.get('type')}")
|
||||||
|
|
||||||
|
result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
|
||||||
|
need_retry = False
|
||||||
|
if res.status_code >= 500:
|
||||||
|
logger.warn(f"[DOUBAO] do retry, times={retry_count}")
|
||||||
|
need_retry = retry_count < 2
|
||||||
|
elif res.status_code == 401:
|
||||||
|
result["content"] = "授权失败,请检查API Key是否正确"
|
||||||
|
elif res.status_code == 429:
|
||||||
|
result["content"] = "请求过于频繁,请稍后再试"
|
||||||
|
need_retry = retry_count < 2
|
||||||
|
else:
|
||||||
|
need_retry = False
|
||||||
|
|
||||||
|
if need_retry:
|
||||||
|
time.sleep(3)
|
||||||
|
return self.reply_text(session, args, retry_count + 1)
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
need_retry = retry_count < 2
|
||||||
|
result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
|
||||||
|
if need_retry:
|
||||||
|
return self.reply_text(session, args, retry_count + 1)
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ==================== Agent mode support ====================
|
||||||
|
|
||||||
|
def call_with_tools(self, messages, tools=None, stream: bool = False, **kwargs):
|
||||||
|
"""
|
||||||
|
Call Doubao API with tool support for agent integration.
|
||||||
|
|
||||||
|
This method handles:
|
||||||
|
1. Format conversion (Claude format -> OpenAI format)
|
||||||
|
2. System prompt injection
|
||||||
|
3. Streaming SSE response with tool_calls
|
||||||
|
4. Thinking (reasoning) is disabled by default for efficiency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of messages (may be in Claude format from agent)
|
||||||
|
tools: List of tool definitions (may be in Claude format from agent)
|
||||||
|
stream: Whether to use streaming
|
||||||
|
**kwargs: Additional parameters (max_tokens, temperature, system, model, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generator yielding OpenAI-format chunks (for streaming)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert messages from Claude format to OpenAI format
|
||||||
|
converted_messages = self._convert_messages_to_openai_format(messages)
|
||||||
|
|
||||||
|
# Inject system prompt if provided
|
||||||
|
system_prompt = kwargs.pop("system", None)
|
||||||
|
if system_prompt:
|
||||||
|
if not converted_messages or converted_messages[0].get("role") != "system":
|
||||||
|
converted_messages.insert(0, {"role": "system", "content": system_prompt})
|
||||||
|
else:
|
||||||
|
converted_messages[0] = {"role": "system", "content": system_prompt}
|
||||||
|
|
||||||
|
# Convert tools from Claude format to OpenAI format
|
||||||
|
converted_tools = None
|
||||||
|
if tools:
|
||||||
|
converted_tools = self._convert_tools_to_openai_format(tools)
|
||||||
|
|
||||||
|
# Resolve model / temperature
|
||||||
|
model = kwargs.pop("model", None) or self.args["model"]
|
||||||
|
max_tokens = kwargs.pop("max_tokens", None)
|
||||||
|
# Don't pop temperature, just ignore it - let API use default
|
||||||
|
kwargs.pop("temperature", None)
|
||||||
|
|
||||||
|
# Build request body (omit temperature, let the API use its own default)
|
||||||
|
request_body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": converted_messages,
|
||||||
|
"stream": stream,
|
||||||
|
}
|
||||||
|
if max_tokens is not None:
|
||||||
|
request_body["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
# Add tools
|
||||||
|
if converted_tools:
|
||||||
|
request_body["tools"] = converted_tools
|
||||||
|
request_body["tool_choice"] = "auto"
|
||||||
|
|
||||||
|
# Explicitly disable thinking to avoid reasoning_content issues
|
||||||
|
# in multi-turn tool calls
|
||||||
|
request_body["thinking"] = {"type": "disabled"}
|
||||||
|
|
||||||
|
logger.debug(f"[DOUBAO] API call: model={model}, "
|
||||||
|
f"tools={len(converted_tools) if converted_tools else 0}, stream={stream}")
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
return self._handle_stream_response(request_body)
|
||||||
|
else:
|
||||||
|
return self._handle_sync_response(request_body)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DOUBAO] call_with_tools error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
def error_generator():
|
||||||
|
yield {"error": True, "message": str(e), "status_code": 500}
|
||||||
|
return error_generator()
|
||||||
|
|
||||||
|
# -------------------- streaming --------------------
|
||||||
|
|
||||||
|
def _handle_stream_response(self, request_body: dict):
|
||||||
|
"""Handle streaming SSE response from Doubao API and yield OpenAI-format chunks."""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
response = requests.post(url, headers=headers, json=request_body, stream=True, timeout=120)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = response.text
|
||||||
|
logger.error(f"[DOUBAO] API error: status={response.status_code}, msg={error_msg}")
|
||||||
|
yield {"error": True, "message": error_msg, "status_code": response.status_code}
|
||||||
|
return
|
||||||
|
|
||||||
|
current_tool_calls = {}
|
||||||
|
finish_reason = None
|
||||||
|
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = line.decode("utf-8")
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_str = line[6:] # Remove "data: " prefix
|
||||||
|
if data_str.strip() == "[DONE]":
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"[DOUBAO] JSON decode error: {e}, data: {data_str[:200]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for error in chunk
|
||||||
|
if chunk.get("error"):
|
||||||
|
error_data = chunk["error"]
|
||||||
|
error_msg = error_data.get("message", "Unknown error") if isinstance(error_data, dict) else str(error_data)
|
||||||
|
logger.error(f"[DOUBAO] stream error: {error_msg}")
|
||||||
|
yield {"error": True, "message": error_msg, "status_code": 500}
|
||||||
|
return
|
||||||
|
|
||||||
|
if not chunk.get("choices"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice = chunk["choices"][0]
|
||||||
|
delta = choice.get("delta", {})
|
||||||
|
|
||||||
|
# Skip reasoning_content (thinking) - don't log or forward
|
||||||
|
if delta.get("reasoning_content"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle text content
|
||||||
|
if "content" in delta and delta["content"]:
|
||||||
|
yield {
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": delta["content"]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle tool_calls (streamed incrementally)
|
||||||
|
if "tool_calls" in delta:
|
||||||
|
for tool_call_chunk in delta["tool_calls"]:
|
||||||
|
index = tool_call_chunk.get("index", 0)
|
||||||
|
if index not in current_tool_calls:
|
||||||
|
current_tool_calls[index] = {
|
||||||
|
"id": tool_call_chunk.get("id", ""),
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": tool_call_chunk.get("function", {}).get("name", ""),
|
||||||
|
"input": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Accumulate arguments
|
||||||
|
if "function" in tool_call_chunk and "arguments" in tool_call_chunk["function"]:
|
||||||
|
current_tool_calls[index]["input"] += tool_call_chunk["function"]["arguments"]
|
||||||
|
|
||||||
|
# Yield OpenAI-format tool call delta
|
||||||
|
yield {
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [tool_call_chunk]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capture finish_reason
|
||||||
|
if choice.get("finish_reason"):
|
||||||
|
finish_reason = choice["finish_reason"]
|
||||||
|
|
||||||
|
# Final chunk with finish_reason
|
||||||
|
yield {
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {},
|
||||||
|
"finish_reason": finish_reason
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("[DOUBAO] Request timeout")
|
||||||
|
yield {"error": True, "message": "Request timeout", "status_code": 500}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DOUBAO] stream response error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
yield {"error": True, "message": str(e), "status_code": 500}
|
||||||
|
|
||||||
|
# -------------------- sync --------------------
|
||||||
|
|
||||||
|
def _handle_sync_response(self, request_body: dict):
|
||||||
|
"""Handle synchronous API response and yield a single result dict."""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
request_body.pop("stream", None)
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
response = requests.post(url, headers=headers, json=request_body, timeout=120)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = response.text
|
||||||
|
logger.error(f"[DOUBAO] API error: status={response.status_code}, msg={error_msg}")
|
||||||
|
yield {"error": True, "message": error_msg, "status_code": response.status_code}
|
||||||
|
return
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
message = result["choices"][0]["message"]
|
||||||
|
finish_reason = result["choices"][0]["finish_reason"]
|
||||||
|
|
||||||
|
response_data = {"role": "assistant", "content": []}
|
||||||
|
|
||||||
|
# Add text content
|
||||||
|
if message.get("content"):
|
||||||
|
response_data["content"].append({
|
||||||
|
"type": "text",
|
||||||
|
"text": message["content"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add tool calls
|
||||||
|
if message.get("tool_calls"):
|
||||||
|
for tool_call in message["tool_calls"]:
|
||||||
|
response_data["content"].append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tool_call["id"],
|
||||||
|
"name": tool_call["function"]["name"],
|
||||||
|
"input": json.loads(tool_call["function"]["arguments"])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Map finish_reason
|
||||||
|
if finish_reason == "tool_calls":
|
||||||
|
response_data["stop_reason"] = "tool_use"
|
||||||
|
elif finish_reason == "stop":
|
||||||
|
response_data["stop_reason"] = "end_turn"
|
||||||
|
else:
|
||||||
|
response_data["stop_reason"] = finish_reason
|
||||||
|
|
||||||
|
yield response_data
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("[DOUBAO] Request timeout")
|
||||||
|
yield {"error": True, "message": "Request timeout", "status_code": 500}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DOUBAO] sync response error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
yield {"error": True, "message": str(e), "status_code": 500}
|
||||||
|
|
||||||
|
# -------------------- format conversion --------------------
|
||||||
|
|
||||||
|
def _convert_messages_to_openai_format(self, messages):
|
||||||
|
"""
|
||||||
|
Convert messages from Claude format to OpenAI format.
|
||||||
|
|
||||||
|
Claude format uses content blocks: tool_use / tool_result / text
|
||||||
|
OpenAI format uses tool_calls in assistant, role=tool for results
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
converted = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content")
|
||||||
|
|
||||||
|
# Already a simple string - pass through
|
||||||
|
if isinstance(content, str):
|
||||||
|
converted.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(content, list):
|
||||||
|
converted.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
text_parts = []
|
||||||
|
tool_results = []
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
elif block.get("type") == "tool_result":
|
||||||
|
tool_call_id = block.get("tool_use_id") or ""
|
||||||
|
result_content = block.get("content", "")
|
||||||
|
if not isinstance(result_content, str):
|
||||||
|
result_content = json.dumps(result_content, ensure_ascii=False)
|
||||||
|
tool_results.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"content": result_content
|
||||||
|
})
|
||||||
|
|
||||||
|
# Tool results first (must come right after assistant with tool_calls)
|
||||||
|
for tr in tool_results:
|
||||||
|
converted.append(tr)
|
||||||
|
|
||||||
|
if text_parts:
|
||||||
|
converted.append({"role": "user", "content": "\n".join(text_parts)})
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
openai_msg = {"role": "assistant"}
|
||||||
|
text_parts = []
|
||||||
|
tool_calls = []
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
elif block.get("type") == "tool_use":
|
||||||
|
tool_calls.append({
|
||||||
|
"id": block.get("id"),
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": block.get("name"),
|
||||||
|
"arguments": json.dumps(block.get("input", {}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if text_parts:
|
||||||
|
openai_msg["content"] = "\n".join(text_parts)
|
||||||
|
elif not tool_calls:
|
||||||
|
openai_msg["content"] = ""
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
openai_msg["tool_calls"] = tool_calls
|
||||||
|
if not text_parts:
|
||||||
|
openai_msg["content"] = None
|
||||||
|
|
||||||
|
converted.append(openai_msg)
|
||||||
|
else:
|
||||||
|
converted.append(msg)
|
||||||
|
|
||||||
|
return converted
|
||||||
|
|
||||||
|
def _convert_tools_to_openai_format(self, tools):
|
||||||
|
"""
|
||||||
|
Convert tools from Claude format to OpenAI format.
|
||||||
|
|
||||||
|
Claude: {name, description, input_schema}
|
||||||
|
OpenAI: {type: "function", function: {name, description, parameters}}
|
||||||
|
"""
|
||||||
|
if not tools:
|
||||||
|
return None
|
||||||
|
|
||||||
|
converted = []
|
||||||
|
for tool in tools:
|
||||||
|
# Already in OpenAI format
|
||||||
|
if "type" in tool and tool["type"] == "function":
|
||||||
|
converted.append(tool)
|
||||||
|
else:
|
||||||
|
converted.append({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool.get("name"),
|
||||||
|
"description": tool.get("description"),
|
||||||
|
"parameters": tool.get("input_schema", {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return converted
|
||||||
51
models/doubao/doubao_session.py
Normal file
51
models/doubao/doubao_session.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from models.session_manager import Session
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DoubaoSession(Session):
|
||||||
|
def __init__(self, session_id, system_prompt=None, model="doubao-seed-2-0-pro-260215"):
|
||||||
|
super().__init__(session_id, system_prompt)
|
||||||
|
self.model = model
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def discard_exceeding(self, max_tokens, cur_tokens=None):
|
||||||
|
precise = True
|
||||||
|
try:
|
||||||
|
cur_tokens = self.calc_tokens()
|
||||||
|
except Exception as e:
|
||||||
|
precise = False
|
||||||
|
if cur_tokens is None:
|
||||||
|
raise e
|
||||||
|
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
|
||||||
|
while cur_tokens > max_tokens:
|
||||||
|
if len(self.messages) > 2:
|
||||||
|
self.messages.pop(1)
|
||||||
|
elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
|
||||||
|
self.messages.pop(1)
|
||||||
|
if precise:
|
||||||
|
cur_tokens = self.calc_tokens()
|
||||||
|
else:
|
||||||
|
cur_tokens = cur_tokens - max_tokens
|
||||||
|
break
|
||||||
|
elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
|
||||||
|
logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(
|
||||||
|
max_tokens, cur_tokens, len(self.messages)))
|
||||||
|
break
|
||||||
|
if precise:
|
||||||
|
cur_tokens = self.calc_tokens()
|
||||||
|
else:
|
||||||
|
cur_tokens = cur_tokens - max_tokens
|
||||||
|
return cur_tokens
|
||||||
|
|
||||||
|
def calc_tokens(self):
|
||||||
|
return num_tokens_from_messages(self.messages, self.model)
|
||||||
|
|
||||||
|
|
||||||
|
def num_tokens_from_messages(messages, model):
|
||||||
|
tokens = 0
|
||||||
|
for msg in messages:
|
||||||
|
tokens += len(msg["content"])
|
||||||
|
return tokens
|
||||||
@@ -6,11 +6,14 @@ Google gemini bot
|
|||||||
"""
|
"""
|
||||||
# encoding:utf-8
|
# encoding:utf-8
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
from models.bot import Bot
|
from models.bot import Bot
|
||||||
import google.generativeai as genai
|
|
||||||
from models.session_manager import SessionManager
|
from models.session_manager import SessionManager
|
||||||
from bridge.context import ContextType, Context
|
from bridge.context import ContextType, Context
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
@@ -18,7 +21,6 @@ from common.log import logger
|
|||||||
from config import conf
|
from config import conf
|
||||||
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
from models.chatgpt.chat_gpt_session import ChatGPTSession
|
||||||
from models.baidu.baidu_wenxin_session import BaiduWenxinSession
|
from models.baidu.baidu_wenxin_session import BaiduWenxinSession
|
||||||
from google.generativeai.types import HarmCategory, HarmBlockThreshold
|
|
||||||
|
|
||||||
|
|
||||||
# OpenAI对话模型API (可用)
|
# OpenAI对话模型API (可用)
|
||||||
@@ -43,6 +45,7 @@ class GoogleGeminiBot(Bot):
|
|||||||
self.api_base = "https://generativelanguage.googleapis.com"
|
self.api_base = "https://generativelanguage.googleapis.com"
|
||||||
|
|
||||||
def reply(self, query, context: Context = None) -> Reply:
|
def reply(self, query, context: Context = None) -> Reply:
|
||||||
|
session_id = None
|
||||||
try:
|
try:
|
||||||
if context.type != ContextType.TEXT:
|
if context.type != ContextType.TEXT:
|
||||||
logger.warn(f"[Gemini] Unsupported message type, type={context.type}")
|
logger.warn(f"[Gemini] Unsupported message type, type={context.type}")
|
||||||
@@ -50,43 +53,47 @@ class GoogleGeminiBot(Bot):
|
|||||||
logger.info(f"[Gemini] query={query}")
|
logger.info(f"[Gemini] query={query}")
|
||||||
session_id = context["session_id"]
|
session_id = context["session_id"]
|
||||||
session = self.sessions.session_query(query, session_id)
|
session = self.sessions.session_query(query, session_id)
|
||||||
gemini_messages = self._convert_to_gemini_messages(self.filter_messages(session.messages))
|
filtered_messages = self.filter_messages(session.messages)
|
||||||
logger.debug(f"[Gemini] messages={gemini_messages}")
|
logger.debug(f"[Gemini] messages={filtered_messages}")
|
||||||
genai.configure(api_key=self.api_key)
|
|
||||||
model = genai.GenerativeModel(self.model)
|
|
||||||
|
|
||||||
# 添加安全设置
|
response = self.call_with_tools(
|
||||||
safety_settings = {
|
messages=filtered_messages,
|
||||||
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
|
tools=None,
|
||||||
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
|
stream=False,
|
||||||
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
|
model=self.model
|
||||||
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 生成回复,包含安全设置
|
|
||||||
response = model.generate_content(
|
|
||||||
gemini_messages,
|
|
||||||
safety_settings=safety_settings
|
|
||||||
)
|
)
|
||||||
if response.candidates and response.candidates[0].content:
|
|
||||||
reply_text = response.candidates[0].content.parts[0].text
|
if isinstance(response, dict) and response.get("error"):
|
||||||
logger.info(f"[Gemini] reply={reply_text}")
|
error_message = response.get("message", "Failed to invoke [Gemini] api!")
|
||||||
self.sessions.session_reply(reply_text, session_id)
|
logger.error(f"[Gemini] API error: {error_message}")
|
||||||
return Reply(ReplyType.TEXT, reply_text)
|
|
||||||
else:
|
|
||||||
# 没有有效响应内容,可能内容被屏蔽,输出安全评分
|
|
||||||
logger.warning("[Gemini] No valid response generated. Checking safety ratings.")
|
|
||||||
if hasattr(response, 'candidates') and response.candidates:
|
|
||||||
for rating in response.candidates[0].safety_ratings:
|
|
||||||
logger.warning(f"Safety rating: {rating.category} - {rating.probability}")
|
|
||||||
error_message = "No valid response generated due to safety constraints."
|
|
||||||
self.sessions.session_reply(error_message, session_id)
|
self.sessions.session_reply(error_message, session_id)
|
||||||
return Reply(ReplyType.ERROR, error_message)
|
return Reply(ReplyType.ERROR, error_message)
|
||||||
|
|
||||||
|
choices = response.get("choices", []) if isinstance(response, dict) else []
|
||||||
|
if choices and choices[0].get("message"):
|
||||||
|
reply_text = choices[0]["message"].get("content")
|
||||||
|
if reply_text:
|
||||||
|
logger.info(f"[Gemini] reply={reply_text}")
|
||||||
|
self.sessions.session_reply(reply_text, session_id)
|
||||||
|
return Reply(ReplyType.TEXT, reply_text)
|
||||||
|
|
||||||
|
logger.warning("[Gemini] No valid response generated. Checking safety ratings.")
|
||||||
|
safety_ratings = response.get("safety_ratings", []) if isinstance(response, dict) else []
|
||||||
|
if safety_ratings:
|
||||||
|
for rating in safety_ratings:
|
||||||
|
category = rating.get("category", "UNKNOWN")
|
||||||
|
probability = rating.get("probability", "UNKNOWN")
|
||||||
|
logger.warning(f"[Gemini] Safety rating: {category} - {probability}")
|
||||||
|
|
||||||
|
error_message = "No valid response generated due to safety constraints."
|
||||||
|
self.sessions.session_reply(error_message, session_id)
|
||||||
|
return Reply(ReplyType.ERROR, error_message)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Gemini] Error generating response: {str(e)}", exc_info=True)
|
logger.error(f"[Gemini] Error generating response: {str(e)}", exc_info=True)
|
||||||
error_message = "Failed to invoke [Gemini] api!"
|
error_message = "Failed to invoke [Gemini] api!"
|
||||||
self.sessions.session_reply(error_message, session_id)
|
if session_id:
|
||||||
|
self.sessions.session_reply(error_message, session_id)
|
||||||
return Reply(ReplyType.ERROR, error_message)
|
return Reply(ReplyType.ERROR, error_message)
|
||||||
|
|
||||||
def _convert_to_gemini_messages(self, messages: list):
|
def _convert_to_gemini_messages(self, messages: list):
|
||||||
@@ -127,6 +134,93 @@ class GoogleGeminiBot(Bot):
|
|||||||
turn = "user"
|
turn = "user"
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_image_paths_from_text(content: str):
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return "", []
|
||||||
|
pattern = r"\[图片:\s*([^\]]+)\]"
|
||||||
|
image_paths = [m.strip().strip("'\"") for m in re.findall(pattern, content) if m.strip()]
|
||||||
|
cleaned_text = re.sub(pattern, "", content)
|
||||||
|
cleaned_text = re.sub(r"\n{3,}", "\n\n", cleaned_text).strip()
|
||||||
|
return cleaned_text, image_paths
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_image_inline_part(image_path: str):
|
||||||
|
if not image_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if image_path.startswith("file://"):
|
||||||
|
image_path = image_path[7:]
|
||||||
|
|
||||||
|
image_path = os.path.expanduser(image_path)
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.warning(f"[Gemini] Image file not found: {image_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
mime_type = mimetypes.guess_type(image_path)[0] or "image/png"
|
||||||
|
if not mime_type.startswith("image/"):
|
||||||
|
mime_type = "image/png"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": mime_type,
|
||||||
|
"data": base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Gemini] Failed to build inline image part from path={image_path}, err={e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_inline_part_from_image_url(image_url):
|
||||||
|
if not image_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(image_url, dict):
|
||||||
|
image_url = image_url.get("url")
|
||||||
|
if not image_url or not isinstance(image_url, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if image_url.startswith("data:"):
|
||||||
|
match = re.match(r"^data:([^;]+);base64,(.+)$", image_url, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
logger.warning("[Gemini] Invalid data URL for image block")
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": match.group(1),
|
||||||
|
"data": match.group(2).strip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if image_url.startswith("file://") or os.path.exists(os.path.expanduser(image_url)):
|
||||||
|
return GoogleGeminiBot._build_image_inline_part(image_url)
|
||||||
|
|
||||||
|
if image_url.startswith("http://") or image_url.startswith("https://"):
|
||||||
|
try:
|
||||||
|
response = requests.get(image_url, timeout=20)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"[Gemini] Failed to fetch remote image: status={response.status_code}, url={image_url}")
|
||||||
|
return None
|
||||||
|
mime_type = response.headers.get("Content-Type", "image/png").split(";")[0].strip()
|
||||||
|
if not mime_type.startswith("image/"):
|
||||||
|
mime_type = "image/png"
|
||||||
|
return {
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": mime_type,
|
||||||
|
"data": base64.b64encode(response.content).decode("utf-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Gemini] Failed to download remote image: url={image_url}, err={e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.warning(f"[Gemini] Unsupported image URL format: {image_url[:120]}")
|
||||||
|
return None
|
||||||
|
|
||||||
def call_with_tools(self, messages, tools=None, stream=False, **kwargs):
|
def call_with_tools(self, messages, tools=None, stream=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Call Gemini API with tool support using REST API (following official docs)
|
Call Gemini API with tool support using REST API (following official docs)
|
||||||
@@ -145,6 +239,15 @@ class GoogleGeminiBot(Bot):
|
|||||||
|
|
||||||
# Build REST API payload
|
# Build REST API payload
|
||||||
payload = {"contents": []}
|
payload = {"contents": []}
|
||||||
|
inline_image_count = 0
|
||||||
|
|
||||||
|
# Keep legacy behavior: disable Gemini safety blocking like old SDK path.
|
||||||
|
payload["safetySettings"] = [
|
||||||
|
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||||||
|
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||||
|
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||||||
|
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||||||
|
]
|
||||||
|
|
||||||
# Extract and set system instruction
|
# Extract and set system instruction
|
||||||
system_prompt = kwargs.get("system", "")
|
system_prompt = kwargs.get("system", "")
|
||||||
@@ -174,8 +277,19 @@ class GoogleGeminiBot(Bot):
|
|||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
# Simple text content
|
# Text with optional [图片: /path/to/file] markers
|
||||||
parts.append({"text": content})
|
cleaned_text, image_paths = self._extract_image_paths_from_text(content)
|
||||||
|
if cleaned_text:
|
||||||
|
parts.append({"text": cleaned_text})
|
||||||
|
image_added = False
|
||||||
|
for image_path in image_paths:
|
||||||
|
image_part = self._build_image_inline_part(image_path)
|
||||||
|
if image_part:
|
||||||
|
parts.append(image_part)
|
||||||
|
image_added = True
|
||||||
|
inline_image_count += 1
|
||||||
|
if not cleaned_text and not image_added and content:
|
||||||
|
parts.append({"text": content})
|
||||||
|
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
# List of content blocks (Claude format)
|
# List of content blocks (Claude format)
|
||||||
@@ -188,8 +302,39 @@ class GoogleGeminiBot(Bot):
|
|||||||
block_type = block.get("type")
|
block_type = block.get("type")
|
||||||
|
|
||||||
if block_type == "text":
|
if block_type == "text":
|
||||||
# Text block
|
# Text block with optional image markers
|
||||||
parts.append({"text": block.get("text", "")})
|
block_text = block.get("text", "")
|
||||||
|
cleaned_text, image_paths = self._extract_image_paths_from_text(block_text)
|
||||||
|
if cleaned_text:
|
||||||
|
parts.append({"text": cleaned_text})
|
||||||
|
for image_path in image_paths:
|
||||||
|
image_part = self._build_image_inline_part(image_path)
|
||||||
|
if image_part:
|
||||||
|
parts.append(image_part)
|
||||||
|
|
||||||
|
elif block_type in ["image", "image_url"]:
|
||||||
|
# OpenAI format: {"type":"image_url","image_url":{"url":"..."}}
|
||||||
|
# Claude format: {"type":"image","source":{"type":"base64","media_type":"...","data":"..."}}
|
||||||
|
image_part = None
|
||||||
|
if block_type == "image":
|
||||||
|
source = block.get("source", {})
|
||||||
|
if isinstance(source, dict) and source.get("type") == "base64" and source.get("data"):
|
||||||
|
image_part = {
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": source.get("media_type", "image/png"),
|
||||||
|
"data": source.get("data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif block.get("image_url"):
|
||||||
|
image_part = self._build_inline_part_from_image_url(block.get("image_url"))
|
||||||
|
else:
|
||||||
|
image_part = self._build_inline_part_from_image_url(block.get("image_url"))
|
||||||
|
|
||||||
|
if image_part:
|
||||||
|
parts.append(image_part)
|
||||||
|
inline_image_count += 1
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Gemini] Skip invalid image block: {str(block)[:200]}")
|
||||||
|
|
||||||
elif block_type == "tool_result":
|
elif block_type == "tool_result":
|
||||||
# Convert Claude tool_result to Gemini functionResponse
|
# Convert Claude tool_result to Gemini functionResponse
|
||||||
@@ -238,6 +383,9 @@ class GoogleGeminiBot(Bot):
|
|||||||
"parts": parts
|
"parts": parts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if inline_image_count > 0:
|
||||||
|
logger.info(f"[Gemini] Multimodal request includes {inline_image_count} image part(s)")
|
||||||
|
|
||||||
# Generation config
|
# Generation config
|
||||||
gen_config = {}
|
gen_config = {}
|
||||||
if kwargs.get("temperature") is not None:
|
if kwargs.get("temperature") is not None:
|
||||||
@@ -363,15 +511,18 @@ class GoogleGeminiBot(Bot):
|
|||||||
candidates = data.get("candidates", [])
|
candidates = data.get("candidates", [])
|
||||||
if not candidates:
|
if not candidates:
|
||||||
logger.warning("[Gemini] No candidates in response")
|
logger.warning("[Gemini] No candidates in response")
|
||||||
|
prompt_feedback = data.get("promptFeedback", {})
|
||||||
return {
|
return {
|
||||||
"error": True,
|
"error": True,
|
||||||
"message": "No candidates in response",
|
"message": "No candidates in response",
|
||||||
"status_code": 500
|
"status_code": 500,
|
||||||
|
"safety_ratings": prompt_feedback.get("safetyRatings", [])
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate = candidates[0]
|
candidate = candidates[0]
|
||||||
content = candidate.get("content", {})
|
content = candidate.get("content", {})
|
||||||
parts = content.get("parts", [])
|
parts = content.get("parts", [])
|
||||||
|
safety_ratings = candidate.get("safetyRatings", [])
|
||||||
|
|
||||||
logger.debug(f"[Gemini] Candidate parts count: {len(parts)}")
|
logger.debug(f"[Gemini] Candidate parts count: {len(parts)}")
|
||||||
|
|
||||||
@@ -419,7 +570,8 @@ class GoogleGeminiBot(Bot):
|
|||||||
"message": message_dict,
|
"message": message_dict,
|
||||||
"finish_reason": "tool_calls" if tool_calls else "stop"
|
"finish_reason": "tool_calls" if tool_calls else "stop"
|
||||||
}],
|
}],
|
||||||
"usage": data.get("usageMetadata", {})
|
"usage": data.get("usageMetadata", {}),
|
||||||
|
"safety_ratings": safety_ratings
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -628,10 +628,29 @@ def _handle_linkai_stream_response(self, base_url, headers, body):
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
chunk = json.loads(line)
|
chunk = json.loads(line)
|
||||||
yield chunk
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check for error responses within the stream
|
||||||
|
# Some providers (e.g., MiniMax via LinkAI) return errors as:
|
||||||
|
# {'type': 'error', 'error': {'type': '...', 'message': '...', 'http_code': '400'}}
|
||||||
|
if chunk.get("type") == "error" or (
|
||||||
|
isinstance(chunk.get("error"), dict) and "message" in chunk.get("error", {})
|
||||||
|
):
|
||||||
|
error_data = chunk.get("error", {})
|
||||||
|
error_msg = error_data.get("message", "Unknown error") if isinstance(error_data, dict) else str(error_data)
|
||||||
|
http_code = error_data.get("http_code", "") if isinstance(error_data, dict) else ""
|
||||||
|
status_code = int(http_code) if http_code and str(http_code).isdigit() else 400
|
||||||
|
logger.error(f"[LinkAI] stream error: {error_msg} (http_code={http_code})")
|
||||||
|
yield {
|
||||||
|
"error": True,
|
||||||
|
"message": error_msg,
|
||||||
|
"status_code": status_code
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
yield chunk
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[LinkAI] stream response error: {e}")
|
logger.error(f"[LinkAI] stream response error: {e}")
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
from pydantic.types import T
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from models.bot import Bot
|
from models.bot import Bot
|
||||||
@@ -276,7 +277,7 @@ class MinimaxBot(Bot):
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
# Extract text from content blocks
|
# Extract text from content blocks
|
||||||
text_parts = []
|
text_parts = []
|
||||||
tool_result = None
|
tool_results = []
|
||||||
|
|
||||||
for block in content:
|
for block in content:
|
||||||
if isinstance(block, dict):
|
if isinstance(block, dict):
|
||||||
@@ -284,11 +285,17 @@ class MinimaxBot(Bot):
|
|||||||
text_parts.append(block.get("text", ""))
|
text_parts.append(block.get("text", ""))
|
||||||
elif block.get("type") == "tool_result":
|
elif block.get("type") == "tool_result":
|
||||||
# Tool result should be a separate message with role="tool"
|
# Tool result should be a separate message with role="tool"
|
||||||
tool_result = {
|
tool_call_id = block.get("tool_use_id") or ""
|
||||||
|
if not tool_call_id:
|
||||||
|
logger.warning(f"[MINIMAX] tool_result missing tool_use_id")
|
||||||
|
result_content = block.get("content", "")
|
||||||
|
if not isinstance(result_content, str):
|
||||||
|
result_content = json.dumps(result_content, ensure_ascii=False)
|
||||||
|
tool_results.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": block.get("tool_use_id"),
|
"tool_call_id": tool_call_id,
|
||||||
"content": str(block.get("content", ""))
|
"content": result_content
|
||||||
}
|
})
|
||||||
|
|
||||||
if text_parts:
|
if text_parts:
|
||||||
converted.append({
|
converted.append({
|
||||||
@@ -296,7 +303,8 @@ class MinimaxBot(Bot):
|
|||||||
"content": "\n".join(text_parts)
|
"content": "\n".join(text_parts)
|
||||||
})
|
})
|
||||||
|
|
||||||
if tool_result:
|
# Add all tool results (not just the last one)
|
||||||
|
for tool_result in tool_results:
|
||||||
converted.append(tool_result)
|
converted.append(tool_result)
|
||||||
else:
|
else:
|
||||||
# Simple text content
|
# Simple text content
|
||||||
@@ -546,16 +554,14 @@ class MinimaxBot(Bot):
|
|||||||
|
|
||||||
# Optionally yield thinking as visible content
|
# Optionally yield thinking as visible content
|
||||||
if show_thinking:
|
if show_thinking:
|
||||||
# Format thinking text for display
|
# Yield thinking text as-is (without emoji decoration)
|
||||||
formatted_thinking = f"💭 {reasoning_text}"
|
# The reasoning text will be displayed to users
|
||||||
|
|
||||||
# Yield as OpenAI-format content delta
|
|
||||||
yield {
|
yield {
|
||||||
"choices": [{
|
"choices": [{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"delta": {
|
"delta": {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": formatted_thinking
|
"content": reasoning_text
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# encoding:utf-8
|
# encoding:utf-8
|
||||||
|
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import openai
|
import requests
|
||||||
import openai.error
|
|
||||||
from models.bot import Bot
|
from models.bot import Bot
|
||||||
from models.session_manager import SessionManager
|
from models.session_manager import SessionManager
|
||||||
from bridge.context import ContextType
|
from bridge.context import ContextType
|
||||||
@@ -11,10 +11,9 @@ from bridge.reply import Reply, ReplyType
|
|||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf, load_config
|
from config import conf, load_config
|
||||||
from .moonshot_session import MoonshotSession
|
from .moonshot_session import MoonshotSession
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
# ZhipuAI对话模型API
|
# Moonshot (Kimi) API Bot
|
||||||
class MoonshotBot(Bot):
|
class MoonshotBot(Bot):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -23,17 +22,22 @@ class MoonshotBot(Bot):
|
|||||||
if model == "moonshot":
|
if model == "moonshot":
|
||||||
model = "moonshot-v1-32k"
|
model = "moonshot-v1-32k"
|
||||||
self.args = {
|
self.args = {
|
||||||
"model": model, # 对话模型的名称
|
"model": model,
|
||||||
"temperature": conf().get("temperature", 0.3), # 如果设置,值域须为 [0, 1] 我们推荐 0.3,以达到较合适的效果。
|
"temperature": conf().get("temperature", 0.3),
|
||||||
"top_p": conf().get("top_p", 1.0), # 使用默认值
|
"top_p": conf().get("top_p", 1.0),
|
||||||
}
|
}
|
||||||
self.api_key = conf().get("moonshot_api_key")
|
self.api_key = conf().get("moonshot_api_key")
|
||||||
self.base_url = conf().get("moonshot_base_url", "https://api.moonshot.cn/v1/chat/completions")
|
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("/")
|
||||||
|
|
||||||
def reply(self, query, context=None):
|
def reply(self, query, context=None):
|
||||||
# acquire reply content
|
# acquire reply content
|
||||||
if context.type == ContextType.TEXT:
|
if context.type == ContextType.TEXT:
|
||||||
logger.info("[MOONSHOT_AI] query={}".format(query))
|
logger.info("[MOONSHOT] query={}".format(query))
|
||||||
|
|
||||||
session_id = context["session_id"]
|
session_id = context["session_id"]
|
||||||
reply = None
|
reply = None
|
||||||
@@ -50,19 +54,16 @@ class MoonshotBot(Bot):
|
|||||||
if reply:
|
if reply:
|
||||||
return reply
|
return reply
|
||||||
session = self.sessions.session_query(query, session_id)
|
session = self.sessions.session_query(query, session_id)
|
||||||
logger.debug("[MOONSHOT_AI] session query={}".format(session.messages))
|
logger.debug("[MOONSHOT] session query={}".format(session.messages))
|
||||||
|
|
||||||
model = context.get("moonshot_model")
|
model = context.get("moonshot_model")
|
||||||
new_args = self.args.copy()
|
new_args = self.args.copy()
|
||||||
if model:
|
if model:
|
||||||
new_args["model"] = model
|
new_args["model"] = model
|
||||||
# if context.get('stream'):
|
|
||||||
# # reply in stream
|
|
||||||
# return self.reply_text_stream(query, new_query, session_id)
|
|
||||||
|
|
||||||
reply_content = self.reply_text(session, args=new_args)
|
reply_content = self.reply_text(session, args=new_args)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[MOONSHOT_AI] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
|
"[MOONSHOT] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
|
||||||
session.messages,
|
session.messages,
|
||||||
session_id,
|
session_id,
|
||||||
reply_content["content"],
|
reply_content["content"],
|
||||||
@@ -76,17 +77,17 @@ class MoonshotBot(Bot):
|
|||||||
reply = Reply(ReplyType.TEXT, reply_content["content"])
|
reply = Reply(ReplyType.TEXT, reply_content["content"])
|
||||||
else:
|
else:
|
||||||
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
reply = Reply(ReplyType.ERROR, reply_content["content"])
|
||||||
logger.debug("[MOONSHOT_AI] reply {} used 0 tokens.".format(reply_content))
|
logger.debug("[MOONSHOT] reply {} used 0 tokens.".format(reply_content))
|
||||||
return reply
|
return reply
|
||||||
else:
|
else:
|
||||||
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
def reply_text(self, session: MoonshotSession, args=None, retry_count=0) -> dict:
|
def reply_text(self, session: MoonshotSession, args=None, retry_count: int = 0) -> dict:
|
||||||
"""
|
"""
|
||||||
call openai's ChatCompletion to get the answer
|
Call Moonshot chat completion API to get the answer
|
||||||
:param session: a conversation session
|
:param session: a conversation session
|
||||||
:param session_id: session id
|
:param args: model args
|
||||||
:param retry_count: retry count
|
:param retry_count: retry count
|
||||||
:return: {}
|
:return: {}
|
||||||
"""
|
"""
|
||||||
@@ -97,10 +98,8 @@ class MoonshotBot(Bot):
|
|||||||
}
|
}
|
||||||
body = args
|
body = args
|
||||||
body["messages"] = session.messages
|
body["messages"] = session.messages
|
||||||
# logger.debug("[MOONSHOT_AI] response={}".format(response))
|
|
||||||
# logger.info("[MOONSHOT_AI] reply={}, total_tokens={}".format(response.choices[0]['message']['content'], response["usage"]["total_tokens"]))
|
|
||||||
res = requests.post(
|
res = requests.post(
|
||||||
self.base_url,
|
f"{self.base_url}/chat/completions",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=body
|
json=body
|
||||||
)
|
)
|
||||||
@@ -114,14 +113,13 @@ class MoonshotBot(Bot):
|
|||||||
else:
|
else:
|
||||||
response = res.json()
|
response = res.json()
|
||||||
error = response.get("error")
|
error = response.get("error")
|
||||||
logger.error(f"[MOONSHOT_AI] chat failed, status_code={res.status_code}, "
|
logger.error(f"[MOONSHOT] chat failed, status_code={res.status_code}, "
|
||||||
f"msg={error.get('message')}, type={error.get('type')}")
|
f"msg={error.get('message')}, type={error.get('type')}")
|
||||||
|
|
||||||
result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
|
result = {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
|
||||||
need_retry = False
|
need_retry = False
|
||||||
if res.status_code >= 500:
|
if res.status_code >= 500:
|
||||||
# server error, need retry
|
logger.warn(f"[MOONSHOT] do retry, times={retry_count}")
|
||||||
logger.warn(f"[MOONSHOT_AI] do retry, times={retry_count}")
|
|
||||||
need_retry = retry_count < 2
|
need_retry = retry_count < 2
|
||||||
elif res.status_code == 401:
|
elif res.status_code == 401:
|
||||||
result["content"] = "授权失败,请检查API Key是否正确"
|
result["content"] = "授权失败,请检查API Key是否正确"
|
||||||
@@ -144,3 +142,380 @@ class MoonshotBot(Bot):
|
|||||||
return self.reply_text(session, args, retry_count + 1)
|
return self.reply_text(session, args, retry_count + 1)
|
||||||
else:
|
else:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# ==================== Agent mode support ====================
|
||||||
|
|
||||||
|
def call_with_tools(self, messages, tools=None, stream: bool = False, **kwargs):
|
||||||
|
"""
|
||||||
|
Call Moonshot API with tool support for agent integration.
|
||||||
|
|
||||||
|
This method handles:
|
||||||
|
1. Format conversion (Claude format -> OpenAI format)
|
||||||
|
2. System prompt injection
|
||||||
|
3. Streaming SSE response with tool_calls
|
||||||
|
4. Thinking (reasoning) is disabled by default to avoid tool_choice conflicts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of messages (may be in Claude format from agent)
|
||||||
|
tools: List of tool definitions (may be in Claude format from agent)
|
||||||
|
stream: Whether to use streaming
|
||||||
|
**kwargs: Additional parameters (max_tokens, temperature, system, model, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generator yielding OpenAI-format chunks (for streaming)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert messages from Claude format to OpenAI format
|
||||||
|
converted_messages = self._convert_messages_to_openai_format(messages)
|
||||||
|
|
||||||
|
# Inject system prompt if provided
|
||||||
|
system_prompt = kwargs.pop("system", None)
|
||||||
|
if system_prompt:
|
||||||
|
if not converted_messages or converted_messages[0].get("role") != "system":
|
||||||
|
converted_messages.insert(0, {"role": "system", "content": system_prompt})
|
||||||
|
else:
|
||||||
|
converted_messages[0] = {"role": "system", "content": system_prompt}
|
||||||
|
|
||||||
|
# Convert tools from Claude format to OpenAI format
|
||||||
|
converted_tools = None
|
||||||
|
if tools:
|
||||||
|
converted_tools = self._convert_tools_to_openai_format(tools)
|
||||||
|
|
||||||
|
# Resolve model / temperature
|
||||||
|
model = kwargs.pop("model", None) or self.args["model"]
|
||||||
|
max_tokens = kwargs.pop("max_tokens", None)
|
||||||
|
# Don't pop temperature, just ignore it
|
||||||
|
kwargs.pop("temperature", None)
|
||||||
|
|
||||||
|
# Build request body (omit temperature, let the API use its own default)
|
||||||
|
request_body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": converted_messages,
|
||||||
|
"stream": stream,
|
||||||
|
}
|
||||||
|
if max_tokens is not None:
|
||||||
|
request_body["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
# Add tools
|
||||||
|
if converted_tools:
|
||||||
|
request_body["tools"] = converted_tools
|
||||||
|
request_body["tool_choice"] = "auto"
|
||||||
|
|
||||||
|
# Explicitly disable thinking to avoid reasoning_content issues in multi-turn tool calls.
|
||||||
|
# kimi-k2.5 may enable thinking by default; without preserving reasoning_content
|
||||||
|
# in conversation history the API will reject subsequent requests.
|
||||||
|
request_body["thinking"] = {"type": "disabled"}
|
||||||
|
|
||||||
|
logger.debug(f"[MOONSHOT] API call: model={model}, "
|
||||||
|
f"tools={len(converted_tools) if converted_tools else 0}, stream={stream}")
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
return self._handle_stream_response(request_body)
|
||||||
|
else:
|
||||||
|
return self._handle_sync_response(request_body)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MOONSHOT] call_with_tools error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
def error_generator():
|
||||||
|
yield {"error": True, "message": str(e), "status_code": 500}
|
||||||
|
return error_generator()
|
||||||
|
|
||||||
|
# -------------------- streaming --------------------
|
||||||
|
|
||||||
|
def _handle_stream_response(self, request_body: dict):
|
||||||
|
"""Handle streaming SSE response from Moonshot API and yield OpenAI-format chunks."""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
response = requests.post(url, headers=headers, json=request_body, stream=True, timeout=120)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = response.text
|
||||||
|
logger.error(f"[MOONSHOT] API error: status={response.status_code}, msg={error_msg}")
|
||||||
|
yield {"error": True, "message": error_msg, "status_code": response.status_code}
|
||||||
|
return
|
||||||
|
|
||||||
|
current_tool_calls = {}
|
||||||
|
finish_reason = None
|
||||||
|
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = line.decode("utf-8")
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_str = line[6:] # Remove "data: " prefix
|
||||||
|
if data_str.strip() == "[DONE]":
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"[MOONSHOT] JSON decode error: {e}, data: {data_str[:200]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for error in chunk
|
||||||
|
if chunk.get("error"):
|
||||||
|
error_data = chunk["error"]
|
||||||
|
error_msg = error_data.get("message", "Unknown error") if isinstance(error_data, dict) else str(error_data)
|
||||||
|
logger.error(f"[MOONSHOT] stream error: {error_msg}")
|
||||||
|
yield {"error": True, "message": error_msg, "status_code": 500}
|
||||||
|
return
|
||||||
|
|
||||||
|
if not chunk.get("choices"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice = chunk["choices"][0]
|
||||||
|
delta = choice.get("delta", {})
|
||||||
|
|
||||||
|
# Skip reasoning_content (thinking) – don't log or forward
|
||||||
|
if delta.get("reasoning_content"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle text content
|
||||||
|
if "content" in delta and delta["content"]:
|
||||||
|
yield {
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": delta["content"]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle tool_calls (streamed incrementally)
|
||||||
|
if "tool_calls" in delta:
|
||||||
|
for tool_call_chunk in delta["tool_calls"]:
|
||||||
|
index = tool_call_chunk.get("index", 0)
|
||||||
|
if index not in current_tool_calls:
|
||||||
|
current_tool_calls[index] = {
|
||||||
|
"id": tool_call_chunk.get("id", ""),
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": tool_call_chunk.get("function", {}).get("name", ""),
|
||||||
|
"input": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Accumulate arguments
|
||||||
|
if "function" in tool_call_chunk and "arguments" in tool_call_chunk["function"]:
|
||||||
|
current_tool_calls[index]["input"] += tool_call_chunk["function"]["arguments"]
|
||||||
|
|
||||||
|
# Yield OpenAI-format tool call delta
|
||||||
|
yield {
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [tool_call_chunk]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capture finish_reason
|
||||||
|
if choice.get("finish_reason"):
|
||||||
|
finish_reason = choice["finish_reason"]
|
||||||
|
|
||||||
|
# Final chunk with finish_reason
|
||||||
|
yield {
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {},
|
||||||
|
"finish_reason": finish_reason
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("[MOONSHOT] Request timeout")
|
||||||
|
yield {"error": True, "message": "Request timeout", "status_code": 500}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MOONSHOT] stream response error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
yield {"error": True, "message": str(e), "status_code": 500}
|
||||||
|
|
||||||
|
# -------------------- sync --------------------
|
||||||
|
|
||||||
|
def _handle_sync_response(self, request_body: dict):
|
||||||
|
"""Handle synchronous API response and yield a single result dict."""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
request_body.pop("stream", None)
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
response = requests.post(url, headers=headers, json=request_body, timeout=120)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = response.text
|
||||||
|
logger.error(f"[MOONSHOT] API error: status={response.status_code}, msg={error_msg}")
|
||||||
|
yield {"error": True, "message": error_msg, "status_code": response.status_code}
|
||||||
|
return
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
message = result["choices"][0]["message"]
|
||||||
|
finish_reason = result["choices"][0]["finish_reason"]
|
||||||
|
|
||||||
|
response_data = {"role": "assistant", "content": []}
|
||||||
|
|
||||||
|
# Add text content
|
||||||
|
if message.get("content"):
|
||||||
|
response_data["content"].append({
|
||||||
|
"type": "text",
|
||||||
|
"text": message["content"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add tool calls
|
||||||
|
if message.get("tool_calls"):
|
||||||
|
for tool_call in message["tool_calls"]:
|
||||||
|
response_data["content"].append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tool_call["id"],
|
||||||
|
"name": tool_call["function"]["name"],
|
||||||
|
"input": json.loads(tool_call["function"]["arguments"])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Map finish_reason
|
||||||
|
if finish_reason == "tool_calls":
|
||||||
|
response_data["stop_reason"] = "tool_use"
|
||||||
|
elif finish_reason == "stop":
|
||||||
|
response_data["stop_reason"] = "end_turn"
|
||||||
|
else:
|
||||||
|
response_data["stop_reason"] = finish_reason
|
||||||
|
|
||||||
|
yield response_data
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("[MOONSHOT] Request timeout")
|
||||||
|
yield {"error": True, "message": "Request timeout", "status_code": 500}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MOONSHOT] sync response error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
yield {"error": True, "message": str(e), "status_code": 500}
|
||||||
|
|
||||||
|
# -------------------- format conversion --------------------
|
||||||
|
|
||||||
|
def _convert_messages_to_openai_format(self, messages):
|
||||||
|
"""
|
||||||
|
Convert messages from Claude format to OpenAI format.
|
||||||
|
|
||||||
|
Claude format uses content blocks: tool_use / tool_result / text
|
||||||
|
OpenAI format uses tool_calls in assistant, role=tool for results
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
converted = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content")
|
||||||
|
|
||||||
|
# Already a simple string – pass through
|
||||||
|
if isinstance(content, str):
|
||||||
|
converted.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(content, list):
|
||||||
|
converted.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
text_parts = []
|
||||||
|
tool_results = []
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
elif block.get("type") == "tool_result":
|
||||||
|
tool_call_id = block.get("tool_use_id") or ""
|
||||||
|
result_content = block.get("content", "")
|
||||||
|
if not isinstance(result_content, str):
|
||||||
|
result_content = json.dumps(result_content, ensure_ascii=False)
|
||||||
|
tool_results.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"content": result_content
|
||||||
|
})
|
||||||
|
|
||||||
|
# Tool results first (must come right after assistant with tool_calls)
|
||||||
|
for tr in tool_results:
|
||||||
|
converted.append(tr)
|
||||||
|
|
||||||
|
if text_parts:
|
||||||
|
converted.append({"role": "user", "content": "\n".join(text_parts)})
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
openai_msg = {"role": "assistant"}
|
||||||
|
text_parts = []
|
||||||
|
tool_calls = []
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
elif block.get("type") == "tool_use":
|
||||||
|
tool_calls.append({
|
||||||
|
"id": block.get("id"),
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": block.get("name"),
|
||||||
|
"arguments": json.dumps(block.get("input", {}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if text_parts:
|
||||||
|
openai_msg["content"] = "\n".join(text_parts)
|
||||||
|
elif not tool_calls:
|
||||||
|
openai_msg["content"] = ""
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
openai_msg["tool_calls"] = tool_calls
|
||||||
|
if not text_parts:
|
||||||
|
openai_msg["content"] = None
|
||||||
|
|
||||||
|
converted.append(openai_msg)
|
||||||
|
else:
|
||||||
|
converted.append(msg)
|
||||||
|
|
||||||
|
return converted
|
||||||
|
|
||||||
|
def _convert_tools_to_openai_format(self, tools):
|
||||||
|
"""
|
||||||
|
Convert tools from Claude format to OpenAI format.
|
||||||
|
|
||||||
|
Claude: {name, description, input_schema}
|
||||||
|
OpenAI: {type: "function", function: {name, description, parameters}}
|
||||||
|
"""
|
||||||
|
if not tools:
|
||||||
|
return None
|
||||||
|
|
||||||
|
converted = []
|
||||||
|
for tool in tools:
|
||||||
|
# Already in OpenAI format
|
||||||
|
if "type" in tool and tool["type"] == "function":
|
||||||
|
converted.append(tool)
|
||||||
|
else:
|
||||||
|
converted.append({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool.get("name"),
|
||||||
|
"description": tool.get("description"),
|
||||||
|
"parameters": tool.get("input_schema", {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return converted
|
||||||
|
|||||||
@@ -230,14 +230,37 @@ class OpenAICompatibleBot:
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
# Check if this is a tool result message (user role with tool_result blocks)
|
# Check if this is a tool result message (user role with tool_result blocks)
|
||||||
if role == "user" and any(block.get("type") == "tool_result" for block in content):
|
if role == "user" and any(block.get("type") == "tool_result" for block in content):
|
||||||
# Convert each tool_result block to a separate tool message
|
# Separate text content and tool_result blocks
|
||||||
|
text_parts = []
|
||||||
|
tool_results = []
|
||||||
|
|
||||||
for block in content:
|
for block in content:
|
||||||
if block.get("type") == "tool_result":
|
if block.get("type") == "text":
|
||||||
openai_messages.append({
|
text_parts.append(block.get("text", ""))
|
||||||
"role": "tool",
|
elif block.get("type") == "tool_result":
|
||||||
"tool_call_id": block.get("tool_use_id"),
|
tool_results.append(block)
|
||||||
"content": block.get("content", "")
|
|
||||||
})
|
# First, add tool result messages (must come immediately after assistant with tool_calls)
|
||||||
|
for block in tool_results:
|
||||||
|
tool_call_id = block.get("tool_use_id") or ""
|
||||||
|
if not tool_call_id:
|
||||||
|
logger.warning(f"[OpenAICompatible] tool_result missing tool_use_id, using empty string")
|
||||||
|
# Ensure content is a string (some providers require string content)
|
||||||
|
result_content = block.get("content", "")
|
||||||
|
if not isinstance(result_content, str):
|
||||||
|
result_content = json.dumps(result_content, ensure_ascii=False)
|
||||||
|
openai_messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
|
"content": result_content
|
||||||
|
})
|
||||||
|
|
||||||
|
# Then, add text content as a separate user message if present
|
||||||
|
if text_parts:
|
||||||
|
openai_messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": " ".join(text_parts)
|
||||||
|
})
|
||||||
|
|
||||||
# Check if this is an assistant message with tool_use blocks
|
# Check if this is an assistant message with tool_use blocks
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
@@ -249,8 +272,11 @@ class OpenAICompatibleBot:
|
|||||||
if block.get("type") == "text":
|
if block.get("type") == "text":
|
||||||
text_parts.append(block.get("text", ""))
|
text_parts.append(block.get("text", ""))
|
||||||
elif block.get("type") == "tool_use":
|
elif block.get("type") == "tool_use":
|
||||||
|
tool_id = block.get("id") or ""
|
||||||
|
if not tool_id:
|
||||||
|
logger.warning(f"[OpenAICompatible] tool_use missing id for '{block.get('name')}'")
|
||||||
tool_calls.append({
|
tool_calls.append({
|
||||||
"id": block.get("id"),
|
"id": tool_id,
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": block.get("name"),
|
"name": block.get("name"),
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ from config import conf
|
|||||||
class ZhipuAIImage(object):
|
class ZhipuAIImage(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
from zai import ZhipuAiClient
|
from zai import ZhipuAiClient
|
||||||
self.client = ZhipuAiClient(api_key=conf().get("zhipu_ai_api_key"))
|
# 初始化客户端,支持自定义 API base URL(例如智谱国际版 z.ai)
|
||||||
|
api_key = conf().get("zhipu_ai_api_key")
|
||||||
|
api_base = conf().get("zhipu_ai_api_base")
|
||||||
|
|
||||||
|
if api_base:
|
||||||
|
self.client = ZhipuAiClient(api_key=api_key, base_url=api_base)
|
||||||
|
logger.info(f"[ZHIPU_AI_IMAGE] 使用自定义 API Base URL: {api_base}")
|
||||||
|
else:
|
||||||
|
self.client = ZhipuAiClient(api_key=api_key)
|
||||||
|
logger.info("[ZHIPU_AI_IMAGE] 使用默认 API Base URL")
|
||||||
|
|
||||||
def create_img(self, query, retry_count=0, api_key=None, api_base=None):
|
def create_img(self, query, retry_count=0, api_key=None, api_base=None):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ class ZHIPUAIBot(Bot, ZhipuAIImage):
|
|||||||
"temperature": conf().get("temperature", 0.9), # 值在(0,1)之间(智谱AI 的温度不能取 0 或者 1)
|
"temperature": conf().get("temperature", 0.9), # 值在(0,1)之间(智谱AI 的温度不能取 0 或者 1)
|
||||||
"top_p": conf().get("top_p", 0.7), # 值在(0,1)之间(智谱AI 的 top_p 不能取 0 或者 1)
|
"top_p": conf().get("top_p", 0.7), # 值在(0,1)之间(智谱AI 的 top_p 不能取 0 或者 1)
|
||||||
}
|
}
|
||||||
self.client = ZhipuAiClient(api_key=conf().get("zhipu_ai_api_key"))
|
# 初始化客户端,支持自定义 API base URL(例如智谱国际版 z.ai)
|
||||||
|
api_key = conf().get("zhipu_ai_api_key")
|
||||||
|
api_base = conf().get("zhipu_ai_api_base")
|
||||||
|
|
||||||
|
if api_base:
|
||||||
|
self.client = ZhipuAiClient(api_key=api_key, base_url=api_base)
|
||||||
|
logger.info(f"[ZHIPU_AI] 使用自定义 API Base URL: {api_base}")
|
||||||
|
else:
|
||||||
|
self.client = ZhipuAiClient(api_key=api_key)
|
||||||
|
logger.info("[ZHIPU_AI] 使用默认 API Base URL")
|
||||||
|
|
||||||
def reply(self, query, context=None):
|
def reply(self, query, context=None):
|
||||||
# acquire reply content
|
# acquire reply content
|
||||||
@@ -205,7 +214,7 @@ class ZHIPUAIBot(Bot, ZhipuAIImage):
|
|||||||
request_params["thinking"] = thinking
|
request_params["thinking"] = thinking
|
||||||
elif "glm-4.7" in request_params["model"]:
|
elif "glm-4.7" in request_params["model"]:
|
||||||
# Enable thinking by default for GLM-4.7
|
# Enable thinking by default for GLM-4.7
|
||||||
request_params["thinking"] = {"type": "enabled"}
|
request_params["thinking"] = {"type": "disabled"}
|
||||||
|
|
||||||
# Make API call with ZhipuAI SDK
|
# Make API call with ZhipuAI SDK
|
||||||
if stream:
|
if stream:
|
||||||
@@ -301,13 +310,9 @@ class ZHIPUAIBot(Bot, ZhipuAIImage):
|
|||||||
if hasattr(delta, 'content') and delta.content:
|
if hasattr(delta, 'content') and delta.content:
|
||||||
openai_chunk["choices"][0]["delta"]["content"] = delta.content
|
openai_chunk["choices"][0]["delta"]["content"] = delta.content
|
||||||
|
|
||||||
# Add reasoning_content if present (GLM-4.7 specific)
|
# Add reasoning_content as separate field if present (GLM-5/GLM-4.7 thinking)
|
||||||
if hasattr(delta, 'reasoning_content') and delta.reasoning_content:
|
if hasattr(delta, 'reasoning_content') and delta.reasoning_content:
|
||||||
# Store reasoning in content or metadata
|
openai_chunk["choices"][0]["delta"]["reasoning_content"] = delta.reasoning_content
|
||||||
if "content" not in openai_chunk["choices"][0]["delta"]:
|
|
||||||
openai_chunk["choices"][0]["delta"]["content"] = ""
|
|
||||||
# Prepend reasoning to content
|
|
||||||
openai_chunk["choices"][0]["delta"]["content"] = delta.reasoning_content + openai_chunk["choices"][0]["delta"].get("content", "")
|
|
||||||
|
|
||||||
# Add tool_calls if present
|
# Add tool_calls if present
|
||||||
if hasattr(delta, 'tool_calls') and delta.tool_calls:
|
if hasattr(delta, 'tool_calls') and delta.tool_calls:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AgentPlugin(Plugin):
|
|||||||
"""Load configuration from config.yaml file."""
|
"""Load configuration from config.yaml file."""
|
||||||
config_path = os.path.join(self.path, "config.yaml")
|
config_path = os.path.join(self.path, "config.yaml")
|
||||||
if not os.path.exists(config_path):
|
if not os.path.exists(config_path):
|
||||||
logger.warning(f"Config file not found at {config_path}")
|
logger.debug(f"Config file not found at {config_path}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
with open(config_path, 'r', encoding='utf-8') as f:
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class Banwords(Plugin):
|
|||||||
self.reply_action = conf.get("reply_action", "ignore")
|
self.reply_action = conf.get("reply_action", "ignore")
|
||||||
logger.debug("[Banwords] inited")
|
logger.debug("[Banwords] inited")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warn("[Banwords] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/banwords .")
|
logger.debug("[Banwords] init failed, ignore or see https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/banwords .")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def on_handle_context(self, e_context: EventContext):
|
def on_handle_context(self, e_context: EventContext):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
openai==0.27.8
|
openai==0.27.8
|
||||||
|
aiohttp>=3.8.6,<3.10
|
||||||
HTMLParser>=0.0.2
|
HTMLParser>=0.0.2
|
||||||
PyQRCode==1.2.1
|
PyQRCode==1.2.1
|
||||||
qrcode==7.4.2
|
qrcode==7.4.2
|
||||||
|
|||||||
164
run.sh
164
run.sh
@@ -14,6 +14,15 @@ CYAN='\033[0;36m'
|
|||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Emojis
|
||||||
|
EMOJI_ROCKET="🚀"
|
||||||
|
EMOJI_COW="🐄"
|
||||||
|
EMOJI_CHECK="✅"
|
||||||
|
EMOJI_CROSS="❌"
|
||||||
|
EMOJI_WARN="⚠️"
|
||||||
|
EMOJI_STOP="🛑"
|
||||||
|
EMOJI_WRENCH="🔧"
|
||||||
|
|
||||||
# Check if using Bash
|
# Check if using Bash
|
||||||
if [ -z "$BASH_VERSION" ]; then
|
if [ -z "$BASH_VERSION" ]; then
|
||||||
echo -e "${RED}❌ Please run this script with Bash.${NC}"
|
echo -e "${RED}❌ Please run this script with Bash.${NC}"
|
||||||
@@ -123,7 +132,8 @@ clone_project() {
|
|||||||
|
|
||||||
if [ -d "chatgpt-on-wechat" ]; then
|
if [ -d "chatgpt-on-wechat" ]; then
|
||||||
echo -e "${YELLOW}⚠️ Directory 'chatgpt-on-wechat' already exists.${NC}"
|
echo -e "${YELLOW}⚠️ Directory 'chatgpt-on-wechat' already exists.${NC}"
|
||||||
read -p "Choose action: overwrite(o), backup(b), or quit(q)? [o/b/q]: " choice
|
read -p "Choose action: overwrite(o), backup(b), or quit(q)? [press Enter for default: b]: " choice
|
||||||
|
choice=${choice:-b}
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
o|O)
|
o|O)
|
||||||
echo -e "${YELLOW}🗑️ Overwriting 'chatgpt-on-wechat' directory...${NC}"
|
echo -e "${YELLOW}🗑️ Overwriting 'chatgpt-on-wechat' directory...${NC}"
|
||||||
@@ -260,23 +270,26 @@ select_model() {
|
|||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
echo -e "${CYAN}${BOLD} Select AI Model${NC}"
|
echo -e "${CYAN}${BOLD} Select AI Model${NC}"
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
||||||
echo -e "${YELLOW}1) Claude (claude-sonnet-4-5, claude-opus-4-0, etc.)${NC}"
|
echo -e "${YELLOW}1) MiniMax (MiniMax-M2.5, MiniMax-M2.1, etc.)${NC}"
|
||||||
echo -e "${YELLOW}2) Zhipu AI (glm-4.7, glm-4.6, etc.)${NC}"
|
echo -e "${YELLOW}2) Zhipu AI (glm-5, glm-4.7, etc.)${NC}"
|
||||||
echo -e "${YELLOW}3) Gemini (gemini-3-flash-preview, gemini-2.5-pro, etc.)${NC}"
|
echo -e "${YELLOW}3) Kimi (kimi-k2.5, kimi-k2, etc.)${NC}"
|
||||||
echo -e "${YELLOW}4) OpenAI GPT (gpt-5.2, gpt-4.1, etc.)${NC}"
|
echo -e "${YELLOW}4) Doubao (doubao-seed-2-0-code-preview-260215, etc.)${NC}"
|
||||||
echo -e "${YELLOW}5) Qwen (qwen3-max, qwen-plus, qwq-plus, etc.)${NC}"
|
echo -e "${YELLOW}5) Qwen (qwen3.5-plus, qwen3-max, qwq-plus, etc.)${NC}"
|
||||||
echo -e "${YELLOW}6) MiniMax (MiniMax-M2.1, MiniMax-M2.1-lightning, etc.)${NC}"
|
echo -e "${YELLOW}6) Claude (claude-sonnet-4-6, claude-opus-4-6, etc.)${NC}"
|
||||||
echo -e "${YELLOW}7) LinkAI (access multiple models via one API)${NC}"
|
echo -e "${YELLOW}7) Gemini (gemini-3.1-pro-preview, gemini-3-flash-preview, etc.)${NC}"
|
||||||
|
echo -e "${YELLOW}8) OpenAI GPT (gpt-5.2, gpt-4.1, etc.)${NC}"
|
||||||
|
echo -e "${YELLOW}9) LinkAI (access multiple models via one API)${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
read -p "Enter your choice [1-7]: " model_choice
|
read -p "Enter your choice [press Enter for default: 1 - MiniMax]: " model_choice
|
||||||
|
model_choice=${model_choice:-1}
|
||||||
case "$model_choice" in
|
case "$model_choice" in
|
||||||
1|2|3|4|5|6|7)
|
1|2|3|4|5|6|7|8|9)
|
||||||
break
|
break
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${RED}Invalid choice. Please enter 1-7.${NC}"
|
echo -e "${RED}Invalid choice. Please enter 1-9.${NC}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -286,11 +299,61 @@ select_model() {
|
|||||||
configure_model() {
|
configure_model() {
|
||||||
case "$model_choice" in
|
case "$model_choice" in
|
||||||
1)
|
1)
|
||||||
|
# MiniMax
|
||||||
|
echo -e "${GREEN}Configuring MiniMax...${NC}"
|
||||||
|
read -p "Enter MiniMax API Key: " minimax_key
|
||||||
|
read -p "Enter model name [press Enter for default: MiniMax-M2.5]: " model_name
|
||||||
|
model_name=${model_name:-MiniMax-M2.5}
|
||||||
|
|
||||||
|
MODEL_NAME="$model_name"
|
||||||
|
MINIMAX_KEY="$minimax_key"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
# Zhipu AI
|
||||||
|
echo -e "${GREEN}Configuring Zhipu AI...${NC}"
|
||||||
|
read -p "Enter Zhipu AI API Key: " zhipu_key
|
||||||
|
read -p "Enter model name [press Enter for default: glm-5]: " model_name
|
||||||
|
model_name=${model_name:-glm-5}
|
||||||
|
|
||||||
|
MODEL_NAME="$model_name"
|
||||||
|
ZHIPU_KEY="$zhipu_key"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
# Kimi (Moonshot)
|
||||||
|
echo -e "${GREEN}Configuring Kimi (Moonshot)...${NC}"
|
||||||
|
read -p "Enter Moonshot API Key: " moonshot_key
|
||||||
|
read -p "Enter model name [press Enter for default: kimi-k2.5]: " model_name
|
||||||
|
model_name=${model_name:-kimi-k2.5}
|
||||||
|
|
||||||
|
MODEL_NAME="$model_name"
|
||||||
|
MOONSHOT_KEY="$moonshot_key"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
# Doubao (Volcengine Ark)
|
||||||
|
echo -e "${GREEN}Configuring Doubao (Volcengine Ark)...${NC}"
|
||||||
|
read -p "Enter Ark API Key: " ark_key
|
||||||
|
read -p "Enter model name [press Enter for default: doubao-seed-2-0-code-preview-260215]: " model_name
|
||||||
|
model_name=${model_name:-doubao-seed-2-0-code-preview-260215}
|
||||||
|
|
||||||
|
MODEL_NAME="$model_name"
|
||||||
|
ARK_KEY="$ark_key"
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
# Qwen (DashScope)
|
||||||
|
echo -e "${GREEN}Configuring Qwen (DashScope)...${NC}"
|
||||||
|
read -p "Enter DashScope API Key: " dashscope_key
|
||||||
|
read -p "Enter model name [press Enter for default: qwen3.5-plus]: " model_name
|
||||||
|
model_name=${model_name:-qwen3.5-plus}
|
||||||
|
|
||||||
|
MODEL_NAME="$model_name"
|
||||||
|
DASHSCOPE_KEY="$dashscope_key"
|
||||||
|
;;
|
||||||
|
6)
|
||||||
# Claude
|
# Claude
|
||||||
echo -e "${GREEN}Configuring Claude...${NC}"
|
echo -e "${GREEN}Configuring Claude...${NC}"
|
||||||
read -p "Enter Claude API Key: " claude_key
|
read -p "Enter Claude API Key: " claude_key
|
||||||
read -p "Enter model name [press Enter for default: claude-sonnet-4-5]: " model_name
|
read -p "Enter model name [press Enter for default: claude-sonnet-4-6]: " model_name
|
||||||
model_name=${model_name:-claude-sonnet-4-5}
|
model_name=${model_name:-claude-sonnet-4-6}
|
||||||
read -p "Enter API Base URL [press Enter for default: https://api.anthropic.com/v1]: " api_base
|
read -p "Enter API Base URL [press Enter for default: https://api.anthropic.com/v1]: " api_base
|
||||||
api_base=${api_base:-https://api.anthropic.com/v1}
|
api_base=${api_base:-https://api.anthropic.com/v1}
|
||||||
|
|
||||||
@@ -298,22 +361,12 @@ configure_model() {
|
|||||||
CLAUDE_KEY="$claude_key"
|
CLAUDE_KEY="$claude_key"
|
||||||
CLAUDE_BASE="$api_base"
|
CLAUDE_BASE="$api_base"
|
||||||
;;
|
;;
|
||||||
2)
|
7)
|
||||||
# Zhipu AI
|
|
||||||
echo -e "${GREEN}Configuring Zhipu AI...${NC}"
|
|
||||||
read -p "Enter Zhipu AI API Key: " zhipu_key
|
|
||||||
read -p "Enter model name [press Enter for default: glm-4.7]: " model_name
|
|
||||||
model_name=${model_name:-glm-4.7}
|
|
||||||
|
|
||||||
MODEL_NAME="$model_name"
|
|
||||||
ZHIPU_KEY="$zhipu_key"
|
|
||||||
;;
|
|
||||||
3)
|
|
||||||
# Gemini
|
# Gemini
|
||||||
echo -e "${GREEN}Configuring Gemini...${NC}"
|
echo -e "${GREEN}Configuring Gemini...${NC}"
|
||||||
read -p "Enter Gemini API Key: " gemini_key
|
read -p "Enter Gemini API Key: " gemini_key
|
||||||
read -p "Enter model name [press Enter for default: gemini-3-flash-preview]: " model_name
|
read -p "Enter model name [press Enter for default: gemini-3.1-pro-preview]: " model_name
|
||||||
model_name=${model_name:-gemini-3-flash-preview}
|
model_name=${model_name:-gemini-3.1-pro-preview}
|
||||||
read -p "Enter API Base URL [press Enter for default: https://generativelanguage.googleapis.com]: " api_base
|
read -p "Enter API Base URL [press Enter for default: https://generativelanguage.googleapis.com]: " api_base
|
||||||
api_base=${api_base:-https://generativelanguage.googleapis.com}
|
api_base=${api_base:-https://generativelanguage.googleapis.com}
|
||||||
|
|
||||||
@@ -321,7 +374,7 @@ configure_model() {
|
|||||||
GEMINI_KEY="$gemini_key"
|
GEMINI_KEY="$gemini_key"
|
||||||
GEMINI_BASE="$api_base"
|
GEMINI_BASE="$api_base"
|
||||||
;;
|
;;
|
||||||
4)
|
8)
|
||||||
# OpenAI
|
# OpenAI
|
||||||
echo -e "${GREEN}Configuring OpenAI GPT...${NC}"
|
echo -e "${GREEN}Configuring OpenAI GPT...${NC}"
|
||||||
read -p "Enter OpenAI API Key: " openai_key
|
read -p "Enter OpenAI API Key: " openai_key
|
||||||
@@ -334,32 +387,12 @@ configure_model() {
|
|||||||
OPENAI_KEY="$openai_key"
|
OPENAI_KEY="$openai_key"
|
||||||
OPENAI_BASE="$api_base"
|
OPENAI_BASE="$api_base"
|
||||||
;;
|
;;
|
||||||
5)
|
9)
|
||||||
# Qwen (DashScope)
|
|
||||||
echo -e "${GREEN}Configuring Qwen (DashScope)...${NC}"
|
|
||||||
read -p "Enter DashScope API Key: " dashscope_key
|
|
||||||
read -p "Enter model name [press Enter for default: qwen3-max]: " model_name
|
|
||||||
model_name=${model_name:-qwen3-max}
|
|
||||||
|
|
||||||
MODEL_NAME="$model_name"
|
|
||||||
DASHSCOPE_KEY="$dashscope_key"
|
|
||||||
;;
|
|
||||||
6)
|
|
||||||
# MiniMax
|
|
||||||
echo -e "${GREEN}Configuring MiniMax...${NC}"
|
|
||||||
read -p "Enter MiniMax API Key: " minimax_key
|
|
||||||
read -p "Enter model name [press Enter for default: MiniMax-M2.1]: " model_name
|
|
||||||
model_name=${model_name:-MiniMax-M2.1}
|
|
||||||
|
|
||||||
MODEL_NAME="$model_name"
|
|
||||||
MINIMAX_KEY="$minimax_key"
|
|
||||||
;;
|
|
||||||
7)
|
|
||||||
# LinkAI
|
# LinkAI
|
||||||
echo -e "${GREEN}Configuring LinkAI...${NC}"
|
echo -e "${GREEN}Configuring LinkAI...${NC}"
|
||||||
read -p "Enter LinkAI API Key: " linkai_key
|
read -p "Enter LinkAI API Key: " linkai_key
|
||||||
read -p "Enter model name [press Enter for default: claude-sonnet-4-5]: " model_name
|
read -p "Enter model name [press Enter for default: MiniMax-M2.5]: " model_name
|
||||||
model_name=${model_name:-claude-sonnet-4-5}
|
model_name=${model_name:-MiniMax-M2.5}
|
||||||
|
|
||||||
MODEL_NAME="$model_name"
|
MODEL_NAME="$model_name"
|
||||||
USE_LINKAI="true"
|
USE_LINKAI="true"
|
||||||
@@ -381,7 +414,7 @@ select_channel() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
read -p "Enter your choice [1-4, default: 1]: " channel_choice
|
read -p "Enter your choice [press Enter for default: 1 - Feishu]: " channel_choice
|
||||||
channel_choice=${channel_choice:-1}
|
channel_choice=${channel_choice:-1}
|
||||||
case "$channel_choice" in
|
case "$channel_choice" in
|
||||||
1|2|3|4)
|
1|2|3|4)
|
||||||
@@ -472,6 +505,8 @@ create_config_file() {
|
|||||||
"gemini_api_key": "${GEMINI_KEY:-}",
|
"gemini_api_key": "${GEMINI_KEY:-}",
|
||||||
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
||||||
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
||||||
|
"moonshot_api_key": "${MOONSHOT_KEY:-}",
|
||||||
|
"ark_api_key": "${ARK_KEY:-}",
|
||||||
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
||||||
"minimax_api_key": "${MINIMAX_KEY:-}",
|
"minimax_api_key": "${MINIMAX_KEY:-}",
|
||||||
"voice_to_text": "openai",
|
"voice_to_text": "openai",
|
||||||
@@ -498,7 +533,7 @@ EOF
|
|||||||
cat > config.json <<EOF
|
cat > config.json <<EOF
|
||||||
{
|
{
|
||||||
"channel_type": "web",
|
"channel_type": "web",
|
||||||
"web_port": 9899,
|
"web_port": ${WEB_PORT},
|
||||||
"model": "${MODEL_NAME}",
|
"model": "${MODEL_NAME}",
|
||||||
"open_ai_api_key": "${OPENAI_KEY:-}",
|
"open_ai_api_key": "${OPENAI_KEY:-}",
|
||||||
"open_ai_api_base": "${OPENAI_BASE:-https://api.openai.com/v1}",
|
"open_ai_api_base": "${OPENAI_BASE:-https://api.openai.com/v1}",
|
||||||
@@ -507,6 +542,8 @@ EOF
|
|||||||
"gemini_api_key": "${GEMINI_KEY:-}",
|
"gemini_api_key": "${GEMINI_KEY:-}",
|
||||||
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
||||||
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
||||||
|
"moonshot_api_key": "${MOONSHOT_KEY:-}",
|
||||||
|
"ark_api_key": "${ARK_KEY:-}",
|
||||||
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
||||||
"minimax_api_key": "${MINIMAX_KEY:-}",
|
"minimax_api_key": "${MINIMAX_KEY:-}",
|
||||||
"voice_to_text": "openai",
|
"voice_to_text": "openai",
|
||||||
@@ -541,6 +578,8 @@ EOF
|
|||||||
"gemini_api_key": "${GEMINI_KEY:-}",
|
"gemini_api_key": "${GEMINI_KEY:-}",
|
||||||
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
||||||
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
||||||
|
"moonshot_api_key": "${MOONSHOT_KEY:-}",
|
||||||
|
"ark_api_key": "${ARK_KEY:-}",
|
||||||
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
||||||
"minimax_api_key": "${MINIMAX_KEY:-}",
|
"minimax_api_key": "${MINIMAX_KEY:-}",
|
||||||
"voice_to_text": "openai",
|
"voice_to_text": "openai",
|
||||||
@@ -581,6 +620,8 @@ EOF
|
|||||||
"gemini_api_key": "${GEMINI_KEY:-}",
|
"gemini_api_key": "${GEMINI_KEY:-}",
|
||||||
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
"gemini_api_base": "${GEMINI_BASE:-https://generativelanguage.googleapis.com}",
|
||||||
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
"zhipu_ai_api_key": "${ZHIPU_KEY:-}",
|
||||||
|
"moonshot_api_key": "${MOONSHOT_KEY:-}",
|
||||||
|
"ark_api_key": "${ARK_KEY:-}",
|
||||||
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
"dashscope_api_key": "${DASHSCOPE_KEY:-}",
|
||||||
"minimax_api_key": "${MINIMAX_KEY:-}",
|
"minimax_api_key": "${MINIMAX_KEY:-}",
|
||||||
"voice_to_text": "openai",
|
"voice_to_text": "openai",
|
||||||
@@ -876,13 +917,20 @@ install_mode() {
|
|||||||
configure_channel
|
configure_channel
|
||||||
create_config_file
|
create_config_file
|
||||||
|
|
||||||
# Show completion message
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
read -p "Start CowAgent now? [Y/n]: " start_now
|
||||||
echo -e "${GREEN}${BOLD}✅ Installation Complete!${NC}"
|
if [[ ! $start_now == [Nn]* ]]; then
|
||||||
echo -e "${CYAN}${BOLD}=========================================${NC}"
|
start_project
|
||||||
echo ""
|
else
|
||||||
show_usage
|
echo -e "${GREEN}✅ Installation complete!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}${BOLD}To start manually:${NC}"
|
||||||
|
echo -e "${YELLOW} cd ${BASE_DIR}${NC}"
|
||||||
|
echo -e "${YELLOW} ./run.sh start${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Or use nohup directly:${NC}"
|
||||||
|
echo -e "${YELLOW} nohup $PYTHON_CMD app.py > nohup.out 2>&1 & tail -f nohup.out${NC}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main function
|
# Main function
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
---
|
|
||||||
name: bocha-search
|
|
||||||
description: High-quality web search with AI-optimized results. Use when user needs to search the internet for current information, news, or research topics.
|
|
||||||
homepage: https://open.bocha.cn/
|
|
||||||
metadata:
|
|
||||||
emoji: 🔍
|
|
||||||
requires:
|
|
||||||
bins: ["curl"]
|
|
||||||
env: ["BOCHA_API_KEY"]
|
|
||||||
primaryEnv: "BOCHA_API_KEY"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Bocha Search
|
|
||||||
|
|
||||||
High-quality web search powered by Bocha AI, optimized for AI consumption. Returns web pages, images, and detailed metadata.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
This skill requires a Bocha API key. If not configured:
|
|
||||||
|
|
||||||
1. Visit https://open.bocha.cn to get an API key
|
|
||||||
2. Set the key using: `env_config(action="set", key="BOCHA_API_KEY", value="your-key")`
|
|
||||||
3. Or manually add to `~/cow/.env`: `BOCHA_API_KEY=your-key`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
**Important**: Scripts are located relative to this skill's base directory.
|
|
||||||
|
|
||||||
When you see this skill in `<available_skills>`, note the `<base_dir>` path.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# General pattern:
|
|
||||||
bash "<base_dir>/scripts/search.sh" "<query>" [count] [freshness] [summary]
|
|
||||||
|
|
||||||
# Parameters:
|
|
||||||
# - query: Search query (required)
|
|
||||||
# - count: Number of results (1-50, default: 10)
|
|
||||||
# - freshness: Time range filter (default: noLimit)
|
|
||||||
# Options: noLimit, oneDay, oneWeek, oneMonth, oneYear, YYYY-MM-DD..YYYY-MM-DD
|
|
||||||
# - summary: Include text summary (true/false, default: false)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Basic search
|
|
||||||
```bash
|
|
||||||
bash "<base_dir>/scripts/search.sh" "latest AI news"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search with more results
|
|
||||||
```bash
|
|
||||||
bash "<base_dir>/scripts/search.sh" "Python tutorials" 20
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search recent content with summary
|
|
||||||
```bash
|
|
||||||
bash "<base_dir>/scripts/search.sh" "阿里巴巴ESG报告" 10 oneWeek true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search specific date range
|
|
||||||
```bash
|
|
||||||
bash "<base_dir>/scripts/search.sh" "tech news" 15 "2025-01-01..2025-02-01"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response Format
|
|
||||||
|
|
||||||
The API returns structured data compatible with Bing Search API:
|
|
||||||
|
|
||||||
**Web Pages** (in `data.webPages.value`):
|
|
||||||
- `name`: Page title
|
|
||||||
- `url`: Page URL
|
|
||||||
- `snippet`: Short description
|
|
||||||
- `summary`: Full text summary (if requested)
|
|
||||||
- `siteName`: Website name
|
|
||||||
- `siteIcon`: Website icon URL
|
|
||||||
- `datePublished`: Publication date (UTC+8)
|
|
||||||
- `language`: Page language
|
|
||||||
|
|
||||||
**Images** (in `data.images.value`):
|
|
||||||
- `contentUrl`: Image URL
|
|
||||||
- `hostPageUrl`: Source page URL
|
|
||||||
- `width`, `height`: Image dimensions
|
|
||||||
- `thumbnailUrl`: Thumbnail URL
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Optimized for AI**: Results include summaries and structured metadata
|
|
||||||
- **Time range**: Use `noLimit` for best results (algorithm auto-optimizes time range)
|
|
||||||
- **Timeout**: 30 seconds
|
|
||||||
- **Rate limits**: Check your API plan at https://open.bocha.cn
|
|
||||||
- **Response format**: Compatible with Bing Search API for easy integration
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Bocha Web Search API wrapper
|
|
||||||
# API Docs: https://open.bocha.cn/
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
query="${1:-}"
|
|
||||||
count="${2:-10}"
|
|
||||||
freshness="${3:-noLimit}"
|
|
||||||
summary="${4:-false}"
|
|
||||||
|
|
||||||
if [ -z "$query" ]; then
|
|
||||||
echo '{"error": "Query is required", "usage": "bash search.sh <query> [count] [freshness] [summary]"}'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${BOCHA_API_KEY:-}" ]; then
|
|
||||||
echo '{"error": "BOCHA_API_KEY environment variable is not set", "help": "Visit https://open.bocha.cn to get an API key"}'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate count (1-50)
|
|
||||||
if ! [[ "$count" =~ ^[0-9]+$ ]] || [ "$count" -lt 1 ] || [ "$count" -gt 50 ]; then
|
|
||||||
count=10
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build JSON request body
|
|
||||||
request_body=$(cat <<EOF
|
|
||||||
{
|
|
||||||
"query": "$query",
|
|
||||||
"count": $count,
|
|
||||||
"freshness": "$freshness",
|
|
||||||
"summary": $summary
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call Bocha API
|
|
||||||
response=$(curl -sS --max-time 30 \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: Bearer $BOCHA_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Accept: application/json" \
|
|
||||||
-d "$request_body" \
|
|
||||||
"https://api.bocha.cn/v1/web-search" 2>&1)
|
|
||||||
|
|
||||||
curl_exit_code=$?
|
|
||||||
|
|
||||||
if [ $curl_exit_code -ne 0 ]; then
|
|
||||||
echo "{\"error\": \"Failed to call Bocha API\", \"details\": \"$response\"}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Simple JSON validation - check if response starts with { or [
|
|
||||||
if [[ ! "$response" =~ ^[[:space:]]*[\{\[] ]]; then
|
|
||||||
echo "{\"error\": \"Invalid JSON response from API\", \"response\": \"$response\"}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract API code using grep and sed (basic JSON parsing)
|
|
||||||
api_code=$(echo "$response" | grep -o '"code"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*' | head -1)
|
|
||||||
|
|
||||||
# If code extraction failed or code is not 200, check for error
|
|
||||||
if [ -n "$api_code" ] && [ "$api_code" != "200" ]; then
|
|
||||||
# Try to extract error message
|
|
||||||
api_msg=$(echo "$response" | grep -o '"msg"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/"msg"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/' | head -1)
|
|
||||||
if [ -z "$api_msg" ]; then
|
|
||||||
api_msg="Unknown error"
|
|
||||||
fi
|
|
||||||
echo "{\"error\": \"API returned error\", \"code\": $api_code, \"message\": \"$api_msg\"}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Return the full response
|
|
||||||
echo "$response"
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: skill-creator
|
name: skill-creator
|
||||||
description: Create or update skills. Use when designing, structuring, or packaging skills with scripts, references, and assets. COW simplified version - skills are used locally in workspace.
|
description: Create, install, or update skills in the workspace. Use when (1) installing a skill from a URL or remote source, (2) creating a new skill from scratch, (3) updating or restructuring existing skills. Always use this skill for any skill installation or creation task.
|
||||||
license: Complete terms in LICENSE.txt
|
license: Complete terms in LICENSE.txt
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,9 +93,16 @@ Do NOT create auxiliary documentation files:
|
|||||||
|
|
||||||
**Critical Rule**: Only create files that the agent will actually execute (scripts) or that are too large for SKILL.md (references). Documentation, examples, and guides ALL belong in SKILL.md.
|
**Critical Rule**: Only create files that the agent will actually execute (scripts) or that are too large for SKILL.md (references). Documentation, examples, and guides ALL belong in SKILL.md.
|
||||||
|
|
||||||
## Skill Creation Process
|
## Installing a Skill from URL
|
||||||
|
|
||||||
**COW Simplified Version** - Skills are used locally, no packaging/sharing needed.
|
1. Fetch the URL content (curl or web-fetch skill)
|
||||||
|
2. Extract `name` from YAML frontmatter
|
||||||
|
3. Create directory `<workspace>/skills/<name>/` and save content as `SKILL.md`
|
||||||
|
4. Check the saved SKILL.md for an installation/setup section — if it defines additional steps (e.g., downloading scripts, installing dependencies), execute them; otherwise installation is complete
|
||||||
|
|
||||||
|
The `<workspace>` is the working directory from the "工作空间" section.
|
||||||
|
|
||||||
|
## Skill Creation Process (from scratch)
|
||||||
|
|
||||||
1. **Understand** - Clarify use cases with concrete examples
|
1. **Understand** - Clarify use cases with concrete examples
|
||||||
2. **Plan** - Identify needed scripts, references, assets
|
2. **Plan** - Identify needed scripts, references, assets
|
||||||
@@ -181,11 +188,13 @@ scripts/init_skill.py <skill-name> --path <output-directory> [--resources script
|
|||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/init_skill.py my-skill --path ~/cow/skills
|
scripts/init_skill.py my-skill --path <workspace>/skills
|
||||||
scripts/init_skill.py my-skill --path ~/cow/skills --resources scripts,references
|
scripts/init_skill.py my-skill --path <workspace>/skills --resources scripts,references
|
||||||
scripts/init_skill.py my-skill --path ~/cow/skills --resources scripts --examples
|
scripts/init_skill.py my-skill --path <workspace>/skills --resources scripts --examples
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Where `<workspace>` is your workspace directory shown in the "工作空间" section of the system prompt.
|
||||||
|
|
||||||
The script:
|
The script:
|
||||||
|
|
||||||
- Creates the skill directory at the specified path
|
- Creates the skill directory at the specified path
|
||||||
@@ -195,7 +204,7 @@ The script:
|
|||||||
|
|
||||||
After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files.
|
After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files.
|
||||||
|
|
||||||
**Important**: Always create skills in workspace directory (`~/cow/skills`), NOT in project directory.
|
**Important**: Always create skills in workspace skills directory (`<workspace>/skills`), NOT in project directory. Check the "工作空间" section for the actual workspace path.
|
||||||
|
|
||||||
### Step 4: Edit the Skill
|
### Step 4: Edit the Skill
|
||||||
|
|
||||||
@@ -335,7 +344,7 @@ scripts/quick_validate.py <path/to/skill-folder>
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/quick_validate.py ~/cow/skills/weather-api
|
scripts/quick_validate.py <workspace>/skills/weather-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Validation checks:
|
Validation checks:
|
||||||
|
|||||||
Reference in New Issue
Block a user