mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
39 Commits
feat-i18n
...
feat-self-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff584f8421 | ||
|
|
ca4a8253a1 | ||
|
|
157374401a | ||
|
|
ba777ed706 | ||
|
|
0e4da1d1c5 | ||
|
|
72847e0711 | ||
|
|
3c19614c74 | ||
|
|
a2e4955116 | ||
|
|
c62175c06b | ||
|
|
fde4b6f590 | ||
|
|
3d7c68bac6 | ||
|
|
72a477f10c | ||
|
|
2a16c562a8 | ||
|
|
2b670e73f3 | ||
|
|
3994594019 | ||
|
|
39c9386b54 | ||
|
|
4cc57cc08d | ||
|
|
639a3eac1e | ||
|
|
79323358e5 | ||
|
|
cdb093c74a | ||
|
|
f6f3ce5f05 | ||
|
|
4805f3d4d3 | ||
|
|
1d797cdaf5 | ||
|
|
4d8458669c | ||
|
|
92ec9653e5 | ||
|
|
e861d98007 | ||
|
|
a97eeb1fd9 | ||
|
|
cd88b23b5d | ||
|
|
33eabf937b | ||
|
|
beb5df16a3 | ||
|
|
7fa743f01a | ||
|
|
1f6859d78f | ||
|
|
2853735472 | ||
|
|
feaa9076b0 | ||
|
|
ce0249706e | ||
|
|
af2c839231 | ||
|
|
2b2d24ed25 | ||
|
|
04d28f9d2d | ||
|
|
1dbf41f384 |
145
.github/ISSUE_TEMPLATE/1.bug.yml
vendored
145
.github/ISSUE_TEMPLATE/1.bug.yml
vendored
@@ -1,131 +1,46 @@
|
||||
name: Bug report 🐛
|
||||
description: 项目运行中遇到的Bug或问题。
|
||||
description: Report a bug or unexpected behavior.
|
||||
title: "[Bug] "
|
||||
labels: ['status: needs check']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### ⚠️ 前置确认
|
||||
1. 网络能够访问openai接口
|
||||
2. python 已安装:版本在 3.7 ~ 3.10 之间
|
||||
3. `git pull` 拉取最新代码
|
||||
4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足
|
||||
5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足
|
||||
6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
|
||||
> 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 前置确认
|
||||
label: Self check
|
||||
options:
|
||||
- label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。
|
||||
- label: I'm on the latest version and searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: ⚠️ 搜索issues中是否已存在类似问题
|
||||
description: >
|
||||
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索你的问题
|
||||
或相关日志的关键词来查找是否存在类似问题。
|
||||
options:
|
||||
- label: 我已经搜索过issues和disscussions,没有跟我遇到的问题相关的issue
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请在上方的`title`中填写你对你所遇到问题的简略总结,这将帮助其他人更好的找到相似问题,谢谢❤️。
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 操作系统类型?
|
||||
description: >
|
||||
请选择你运行程序的操作系统类型。
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- MacOS
|
||||
- Docker
|
||||
- Railway
|
||||
- Windows Subsystem for Linux (WSL)
|
||||
- Other (请在问题中说明)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 运行的python版本是?
|
||||
description: |
|
||||
请选择你运行程序的`python`版本。
|
||||
注意:在`python 3.7`中,有部分可选依赖无法安装。
|
||||
经过长时间的观察,我们认为`python 3.8`是兼容性最好的版本。
|
||||
`python 3.7`~`python 3.10`以外版本的issue,将视情况直接关闭。
|
||||
options:
|
||||
- python 3.7
|
||||
- python 3.8
|
||||
- python 3.9
|
||||
- python 3.10
|
||||
- other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 使用的chatgpt-on-wechat版本是?
|
||||
description: |
|
||||
请确保你使用的是 [releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) 中的最新版本。
|
||||
如果你使用git, 请使用`git branch`命令来查看分支。
|
||||
options:
|
||||
- Latest Release
|
||||
- Master (branch)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 运行的`channel`类型是?
|
||||
description: |
|
||||
请确保你正确配置了该`channel`所需的配置项,所有可选的配置项都写在了[该文件中](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py),请将所需配置项填写在根目录下的`config.json`文件中。
|
||||
options:
|
||||
- wechatmp(公众号, 订阅号)
|
||||
- wechatmp_service(公众号, 服务号)
|
||||
- terminal
|
||||
- other
|
||||
label: Environment
|
||||
description: "Version (`cow status`), OS, Python version, install method, model & channel."
|
||||
placeholder: |
|
||||
Version: v1.2.0
|
||||
OS: macOS / Linux / Windows / Docker
|
||||
Python: 3.11
|
||||
Install: installer / Docker / source
|
||||
Model & channel: deepseek-v4-flash, web
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 复现步骤 🕹
|
||||
description: |
|
||||
**⚠️ 不能复现将会关闭issue.**
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 问题描述 😯
|
||||
description: 详细描述出现的问题,或提供有关截图。
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 终端日志 📒
|
||||
description: |
|
||||
在此处粘贴终端日志,可在主目录下`run.log`文件中找到,这会帮助我们更好的分析问题,注意隐去你的API key。
|
||||
如果在配置文件中加入`"debug": true`,打印出的日志会更有帮助。
|
||||
label: What happened?
|
||||
description: "Steps to reproduce, what you expected, and what happened instead. Screenshots welcome."
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
<details>
|
||||
<summary><i>示例</i></summary>
|
||||
```log
|
||||
[DEBUG][2023-04-16 00:23:22][plugin_manager.py:157] - Plugin SUMMARY triggered by event Event.ON_HANDLE_CONTEXT
|
||||
[DEBUG][2023-04-16 00:23:22][main.py:221] - [Summary] on_handle_context. content: $总结前100条消息
|
||||
[DEBUG][2023-04-16 00:23:24][main.py:240] - [Summary] limit: 100, duration: -1 seconds
|
||||
[ERROR][2023-04-16 00:23:24][chat_channel.py:244] - Worker return exception: name 'start_date' is not defined
|
||||
Traceback (most recent call last):
|
||||
File "C:\ProgramData\Anaconda3\lib\concurrent\futures\thread.py", line 57, in run
|
||||
result = self.fn(*self.args, **self.kwargs)
|
||||
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 132, in _handle
|
||||
reply = self._generate_reply(context)
|
||||
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 142, in _generate_reply
|
||||
e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, {
|
||||
File "D:\project\chatgpt-on-wechat\plugins\plugin_manager.py", line 159, in emit_event
|
||||
instance.handlers[e_context.event](e_context, *args, **kwargs)
|
||||
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 255, in on_handle_context
|
||||
records = self._get_records(session_id, start_time, limit)
|
||||
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 96, in _get_records
|
||||
c.execute("SELECT * FROM chat_records WHERE sessionid=? and timestamp>? ORDER BY timestamp DESC LIMIT ?", (session_id, start_date, limit))
|
||||
NameError: name 'start_date' is not defined
|
||||
[INFO][2023-04-16 00:23:36][app.py:14] - signal 2 received, exiting...
|
||||
```
|
||||
</details>
|
||||
value: |
|
||||
```log
|
||||
<此处粘贴终端日志>
|
||||
```
|
||||
Expected: ...
|
||||
Actual: ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Logs
|
||||
description: "Relevant logs from `run.log` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys."
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
31
.github/ISSUE_TEMPLATE/2.feature.yml
vendored
31
.github/ISSUE_TEMPLATE/2.feature.yml
vendored
@@ -1,28 +1,33 @@
|
||||
name: Feature request 🚀
|
||||
description: 提出你对项目的新想法或建议。
|
||||
description: Suggest a new idea or improvement.
|
||||
title: "[Feature] "
|
||||
labels: ['status: needs check']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请在上方的`title`中填写简略总结,谢谢❤️。
|
||||
> 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: ⚠️ 搜索是否存在类似issue
|
||||
description: >
|
||||
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索关键词查找是否存在相似issue。
|
||||
label: Self check
|
||||
options:
|
||||
- label: 我已经搜索过issues和disscussions,没有发现相似issue
|
||||
- label: I searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate.
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 总结
|
||||
description: 描述feature的功能。
|
||||
label: What's the problem?
|
||||
description: "The pain point or what's not working for you right now."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 举例
|
||||
description: 提供聊天示例,草图或相关网址。
|
||||
- type: textarea
|
||||
label: What would you like?
|
||||
description: "How you'd expect it to work. Examples, sketches, or links welcome."
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 动机
|
||||
description: 描述你提出该feature的动机,比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。
|
||||
label: Contribution
|
||||
options:
|
||||
- label: I'd be interested in helping implement this.
|
||||
required: false
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📖 Documentation
|
||||
url: https://docs.cowagent.ai
|
||||
about: Setup guides, configuration, and FAQ.
|
||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
Thanks for your contribution! Please write this PR in English.
|
||||
推荐使用英文填写,感谢 ❤️
|
||||
-->
|
||||
|
||||
## What does this PR do?
|
||||
|
||||
<!-- A short description of the change and why it's needed. -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Docs
|
||||
- [ ] Refactor / chore
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md)
|
||||
- [ ] I tested this change locally
|
||||
- [ ] Code comments and docs are in English
|
||||
- [ ] Linked related issue (if any): closes #
|
||||
61
CONTRIBUTING.md
Normal file
61
CONTRIBUTING.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Contributing to CowAgent
|
||||
|
||||
Thanks for taking the time to contribute! 🎉 CowAgent is built by a global
|
||||
community, and contributions of all sizes are welcome — from typo fixes to new
|
||||
features.
|
||||
|
||||
## Language policy
|
||||
|
||||
To keep the project accessible to a global community, **please write issues,
|
||||
pull requests, code comments, and commit messages in English.**
|
||||
|
||||
> 为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与
|
||||
> commit message。不必担心英文不完美——表达清楚即可,工具翻译也完全没问题。感谢理解 ❤️
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Found a bug or have an idea? [Open an issue](https://github.com/zhayujie/CowAgent/issues/new/choose).
|
||||
|
||||
Before opening one, please search existing issues (including closed ones) to
|
||||
avoid duplicates, and make sure you're on the latest version.
|
||||
|
||||
## Submitting a pull request
|
||||
|
||||
1. **Fork** the repo and create a branch from `master`
|
||||
(e.g. `feat/web-search`, `fix/telegram-reconnect`).
|
||||
2. Make your change. Keep it focused — one logical change per PR.
|
||||
3. Follow the existing code style. Write comments and docstrings in English.
|
||||
4. Run the app locally to confirm your change works.
|
||||
5. Open a PR with a clear title and a short description of **what** and **why**.
|
||||
|
||||
We keep the bar friendly: clear, focused, and working is enough. Maintainers are
|
||||
happy to help polish details during review.
|
||||
|
||||
### Commit & PR titles
|
||||
|
||||
Use a short, imperative summary. The [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
style is preferred but not required:
|
||||
|
||||
```
|
||||
feat: add web search tool
|
||||
fix: reconnect Telegram websocket on timeout
|
||||
docs: clarify Docker setup
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
See the [Install from Source](https://docs.cowagent.ai/guide/manual-install)
|
||||
guide. In short:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zhayujie/CowAgent.git
|
||||
cd CowAgent
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
cow start
|
||||
```
|
||||
|
||||
## Code of conduct
|
||||
|
||||
Be respectful and constructive. We want CowAgent to be a welcoming place for
|
||||
everyone.
|
||||
24
README.md
24
README.md
@@ -1,9 +1,17 @@
|
||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/CowAgent" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square" alt="Stars"></a> <br/>
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/"><img src="https://img.shields.io/badge/Docs-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
@@ -98,11 +106,11 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -190,6 +198,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
||||
|
||||
## 🏷 Changelog
|
||||
|
||||
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo).
|
||||
|
||||
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening.
|
||||
|
||||
> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu channel overhaul (voice, streaming, QR onboarding), DeepSeek V4 and Baidu Qianfan support, scheduler tool upgrades.
|
||||
@@ -236,9 +246,9 @@ For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https:/
|
||||
|
||||
## 🛠️ Development & Contributing
|
||||
|
||||
Contributions are welcome — add a new channel by following the [Feishu channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit).
|
||||
All kinds of contributions are welcome — new features, bug fixes, performance improvements, docs, or sharing your own skills on the [Skill Hub](https://skills.cowagent.ai/submit). See [CONTRIBUTING.md](/CONTRIBUTING.md) to get started, then open an Issue to discuss or send a PR directly.
|
||||
|
||||
⭐ Star the project to follow updates, and feel free to open PRs and Issues.
|
||||
⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome.
|
||||
|
||||
## 🌟 Contributors
|
||||
|
||||
|
||||
@@ -171,6 +171,12 @@ class ChatService:
|
||||
|
||||
from agent.protocol.agent_stream import AgentStreamExecutor
|
||||
|
||||
# Register a cancel token so /cancel can abort this in-flight run.
|
||||
# IM channels key on session_id (no per-turn request_id here).
|
||||
from agent.protocol import get_cancel_registry
|
||||
registry = get_cancel_registry()
|
||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||
|
||||
executor = AgentStreamExecutor(
|
||||
agent=agent,
|
||||
model=agent.model,
|
||||
@@ -180,6 +186,7 @@ class ChatService:
|
||||
on_event=on_event,
|
||||
messages=messages_copy,
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -191,6 +198,13 @@ class ChatService:
|
||||
agent.messages.clear()
|
||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||
raise
|
||||
finally:
|
||||
# Release cancel token to keep the registry bounded.
|
||||
if session_id:
|
||||
try:
|
||||
registry.unregister(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sync executor messages back to agent (thread-safe).
|
||||
# The executor may have trimmed context, making its list shorter than
|
||||
|
||||
19
agent/evolution/__init__.py
Normal file
19
agent/evolution/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Self-evolution subsystem for CowAgent.
|
||||
|
||||
Runs a lightweight, isolated review pass after a conversation goes idle to
|
||||
decide whether anything is worth durably learning (memory / skill) or whether
|
||||
an unfinished task can be pushed forward. Conservative by design: most
|
||||
conversations should produce no change at all.
|
||||
|
||||
Public entry points:
|
||||
from agent.evolution import get_evolution_config
|
||||
from agent.evolution.trigger import start_evolution_trigger, note_user_turn
|
||||
"""
|
||||
|
||||
from agent.evolution.config import EvolutionConfig, get_evolution_config
|
||||
|
||||
__all__ = [
|
||||
"EvolutionConfig",
|
||||
"get_evolution_config",
|
||||
]
|
||||
102
agent/evolution/backup.py
Normal file
102
agent/evolution/backup.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""File backup / rollback support for self-evolution.
|
||||
|
||||
Before the evolution agent edits MEMORY.md or a skill file, we snapshot the
|
||||
current state into ``memory/.evolution_backups/<backup_id>/`` so a later "undo"
|
||||
can restore it. File-level restore only — simple and reliable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
_BACKUP_DIRNAME = ".evolution_backups"
|
||||
_MANIFEST_NAME = "manifest.json"
|
||||
# Keep only the most recent N backups to bound disk usage.
|
||||
_MAX_BACKUPS = 10
|
||||
|
||||
|
||||
def _backups_root(workspace_dir: Path) -> Path:
|
||||
return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME
|
||||
|
||||
|
||||
def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]:
|
||||
"""Snapshot ``files`` (those that exist) under a new backup id.
|
||||
|
||||
Returns the backup_id, or None when there is nothing to back up.
|
||||
"""
|
||||
existing = [Path(f) for f in files if Path(f).exists()]
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000)
|
||||
root = _backups_root(workspace_dir)
|
||||
target = root / backup_id
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
ws = Path(workspace_dir)
|
||||
manifest = []
|
||||
for idx, src in enumerate(existing):
|
||||
# Store under a flat index plus the relative path so restore knows
|
||||
# where it came from, even for nested skill files.
|
||||
try:
|
||||
rel = str(src.relative_to(ws))
|
||||
except ValueError:
|
||||
rel = src.name
|
||||
dst = target / f"{idx}.bak"
|
||||
shutil.copy2(src, dst)
|
||||
manifest.append({"rel": rel, "bak": f"{idx}.bak"})
|
||||
(target / _MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
_prune_old_backups(root)
|
||||
# Caller logs a combined backup+review line; keep this at debug.
|
||||
logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))")
|
||||
return backup_id
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to create backup: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def restore_backup(workspace_dir: Path, backup_id: str) -> bool:
|
||||
"""Restore all files captured under ``backup_id``. Returns success."""
|
||||
if not backup_id:
|
||||
return False
|
||||
target = _backups_root(workspace_dir) / backup_id
|
||||
manifest_path = target / _MANIFEST_NAME
|
||||
if not manifest_path.exists():
|
||||
logger.warning(f"[Evolution] Backup not found: {backup_id}")
|
||||
return False
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
ws = Path(workspace_dir)
|
||||
for entry in manifest:
|
||||
bak = target / entry["bak"]
|
||||
dst = ws / entry["rel"]
|
||||
if bak.exists():
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(bak, dst)
|
||||
logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _prune_old_backups(root: Path) -> None:
|
||||
"""Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological)."""
|
||||
try:
|
||||
dirs = sorted(
|
||||
[d for d in root.iterdir() if d.is_dir()],
|
||||
key=lambda p: p.name,
|
||||
)
|
||||
for old in dirs[:-_MAX_BACKUPS]:
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Backup prune skipped: {e}")
|
||||
76
agent/evolution/config.py
Normal file
76
agent/evolution/config.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Configuration for the self-evolution subsystem.
|
||||
|
||||
Reads flat ``self_evolution_*`` keys from config.json. All fields have safe
|
||||
defaults so the feature degrades gracefully when keys are absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Defaults — conservative (see executor module docstring). Disabled by default
|
||||
# until release; enable via ``self_evolution_enabled``.
|
||||
DEFAULT_ENABLED = False
|
||||
DEFAULT_IDLE_MINUTES = 15
|
||||
DEFAULT_MIN_TURNS = 6
|
||||
# Max review steps for the isolated evolution agent. Kept small (not exposed as
|
||||
# config): the review is meant to be cheap and focused, not a long autonomous run.
|
||||
DEFAULT_MAX_STEPS = 12
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvolutionConfig:
|
||||
"""Resolved self-evolution settings."""
|
||||
|
||||
enabled: bool = DEFAULT_ENABLED
|
||||
idle_minutes: int = DEFAULT_IDLE_MINUTES
|
||||
min_turns: int = DEFAULT_MIN_TURNS
|
||||
max_steps: int = DEFAULT_MAX_STEPS
|
||||
|
||||
@property
|
||||
def idle_seconds(self) -> int:
|
||||
return max(60, self.idle_minutes * 60)
|
||||
|
||||
|
||||
def _as_bool(value: Any, fallback: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if v in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return fallback
|
||||
|
||||
|
||||
def _as_pos_int(value: Any, fallback: int) -> int:
|
||||
try:
|
||||
n = int(value)
|
||||
return n if n > 0 else fallback
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def get_evolution_config() -> EvolutionConfig:
|
||||
"""Build EvolutionConfig from the live config.json ``self_evolution_*`` keys."""
|
||||
try:
|
||||
from config import conf
|
||||
c = conf()
|
||||
except Exception:
|
||||
c = {}
|
||||
|
||||
def _get(key, default):
|
||||
try:
|
||||
return c.get(key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
return EvolutionConfig(
|
||||
enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED),
|
||||
idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES),
|
||||
min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS),
|
||||
max_steps=DEFAULT_MAX_STEPS,
|
||||
)
|
||||
449
agent/evolution/executor.py
Normal file
449
agent/evolution/executor.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""Self-evolution executor.
|
||||
|
||||
Runs an isolated review agent over an idle conversation's transcript and, if a
|
||||
clear signal is found, lets it edit memory / skills via a restricted toolset.
|
||||
Conservative by design: most runs return ``[SILENT]`` and change nothing.
|
||||
|
||||
Flow:
|
||||
1. Build a transcript from the session's new (since last pass) messages.
|
||||
2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id.
|
||||
3. Run an isolated agent (same model, restricted tools, evolution prompt).
|
||||
4. If output is [SILENT], or no workspace file actually changed -> done.
|
||||
5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into
|
||||
the user session (so the main agent can honor "undo"), and push the
|
||||
summary to the user's channel.
|
||||
|
||||
Reuses existing infrastructure (AgentBridge.create_agent, ToolManager,
|
||||
remember_scheduled_output, channel_factory) rather than introducing a fork.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
from agent.evolution.backup import create_backup
|
||||
from agent.evolution.config import get_evolution_config
|
||||
from agent.evolution.prompts import (
|
||||
EVOLUTION_MARKER,
|
||||
EVOLUTION_SYSTEM_PROMPT,
|
||||
SILENT_TOKEN,
|
||||
build_review_user_message,
|
||||
)
|
||||
from agent.evolution.record import append_session_evolution
|
||||
|
||||
# Tools the isolated evolution agent is allowed to use. Everything else is
|
||||
# withheld so a review pass can only read context and edit memory/skill files.
|
||||
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"}
|
||||
|
||||
# Cap concurrent evolution passes so a burst of idle sessions can't spawn many
|
||||
# background model runs at once. Extra sessions simply wait for the next scan.
|
||||
_MAX_CONCURRENT = 2
|
||||
_running_lock = threading.Lock()
|
||||
_running_count = 0
|
||||
|
||||
|
||||
def _builtin_skill_names() -> set:
|
||||
"""Names of skills shipped with the product (project-root ``skills/``).
|
||||
|
||||
These are protected: the evolution agent must never edit them, even though
|
||||
a same-named copy exists in the workspace at runtime. The project dir is the
|
||||
authoritative list of what counts as built-in.
|
||||
"""
|
||||
try:
|
||||
# executor.py -> agent/evolution -> agent -> project root
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
builtin_dir = project_root / "skills"
|
||||
if not builtin_dir.is_dir():
|
||||
return set()
|
||||
names = set()
|
||||
for entry in builtin_dir.iterdir():
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
names.add(entry.name)
|
||||
return names
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str:
|
||||
"""Render the session messages into a compact text transcript."""
|
||||
lines: List[str] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
text = _extract_text(content)
|
||||
if not text.strip():
|
||||
continue
|
||||
speaker = "User" if role == "user" else "Assistant"
|
||||
lines.append(f"{speaker}: {text.strip()}")
|
||||
transcript = "\n".join(lines)
|
||||
# Keep the most RECENT context if oversized (tail is most relevant).
|
||||
if len(transcript) > max_chars:
|
||||
transcript = "...(earlier omitted)...\n" + transcript[-max_chars:]
|
||||
return transcript
|
||||
|
||||
|
||||
def _extract_text(content) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _select_tools(all_tools: list) -> list:
|
||||
return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS]
|
||||
|
||||
|
||||
# Tools whose writes must be confined to the workspace during evolution.
|
||||
_WRITE_TOOLS = {"write", "edit"}
|
||||
|
||||
|
||||
class _WorkspaceWriteGuard:
|
||||
"""Wraps a write/edit tool so it can ONLY write inside the workspace.
|
||||
|
||||
Hard engineering guard (not prompt-based): any write resolving outside the
|
||||
workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This
|
||||
protects built-in skills regardless of what the model attempts.
|
||||
"""
|
||||
|
||||
def __init__(self, inner, workspace_dir: str):
|
||||
self._inner = inner
|
||||
self._ws = Path(workspace_dir).resolve()
|
||||
# Mirror the attributes the agent runtime reads off a tool.
|
||||
self.name = inner.name
|
||||
self.description = inner.description
|
||||
self.params = inner.params
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._inner, item)
|
||||
|
||||
def execute_tool(self, params):
|
||||
# The agent runtime calls execute_tool (not execute); route it through
|
||||
# our guarded execute so the path checks always run.
|
||||
try:
|
||||
return self.execute(params)
|
||||
except Exception as e:
|
||||
logger.error(f"[Evolution] guarded tool error: {e}")
|
||||
from agent.tools.base_tool import ToolResult
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
|
||||
def execute(self, args):
|
||||
path = (args.get("path") or "").strip()
|
||||
if path:
|
||||
try:
|
||||
resolved = Path(self._inner._resolve_path(path)).resolve()
|
||||
from agent.tools.base_tool import ToolResult
|
||||
# Confine writes to the workspace. This protects the product's
|
||||
# bundled skills (which live outside the workspace) from ever
|
||||
# being modified, no matter what path the model attempts.
|
||||
if self._ws not in resolved.parents and resolved != self._ws:
|
||||
return ToolResult.fail(
|
||||
"Error: evolution may only write inside the workspace; "
|
||||
f"path '{path}' is outside and was blocked."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return self._inner.execute(args)
|
||||
|
||||
|
||||
def _guard_tools(tools: list, workspace_dir: str) -> list:
|
||||
"""Wrap write/edit tools with the workspace guard; leave others as-is."""
|
||||
guarded = []
|
||||
for t in tools:
|
||||
if getattr(t, "name", None) in _WRITE_TOOLS:
|
||||
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
|
||||
else:
|
||||
guarded.append(t)
|
||||
return guarded
|
||||
|
||||
|
||||
# Workspace subtrees worth watching for evolution-induced changes.
|
||||
_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output")
|
||||
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
|
||||
# dream diary, none of which count as a user-facing change signal.
|
||||
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
|
||||
|
||||
|
||||
def _workspace_snapshot(workspace_dir) -> dict:
|
||||
"""Map relative path -> (mtime, size) for watched files. Cheap, no reads."""
|
||||
ws = Path(workspace_dir)
|
||||
snap: dict = {}
|
||||
for name in _WATCH_SUBDIRS:
|
||||
root = ws / name
|
||||
if root.is_file():
|
||||
try:
|
||||
st = root.stat()
|
||||
snap[name] = (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for p in root.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Watch the daily memory files (memory/*.md and per-user dailies) since
|
||||
# evolution now records learnings there. Skip backups/dreams bookkeeping.
|
||||
mem_dir = ws / "memory"
|
||||
if mem_dir.is_dir():
|
||||
for p in mem_dir.rglob("*.md"):
|
||||
rel_parts = p.relative_to(mem_dir).parts
|
||||
if rel_parts and rel_parts[0] in _MEMORY_IGNORE:
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _workspace_changed(workspace_dir, pre: dict) -> bool:
|
||||
"""True if any watched file was added, removed, or modified since ``pre``."""
|
||||
return _workspace_snapshot(workspace_dir) != pre
|
||||
|
||||
|
||||
def run_evolution_for_session(
|
||||
agent_bridge,
|
||||
session_id: str,
|
||||
channel_type: str = "",
|
||||
receiver: str = "",
|
||||
user_id: Optional[str] = None,
|
||||
idle_minutes: float = 0.0,
|
||||
) -> bool:
|
||||
"""Run one evolution pass for a session. Returns True if it changed anything.
|
||||
|
||||
Safe to call from a background thread. All failures are swallowed and
|
||||
logged — evolution must never disrupt the main pipeline.
|
||||
"""
|
||||
cfg = get_evolution_config()
|
||||
if not cfg.enabled:
|
||||
return False
|
||||
|
||||
# Concurrency gate: bound how many evolution passes run at once.
|
||||
global _running_count
|
||||
with _running_lock:
|
||||
if _running_count >= _MAX_CONCURRENT:
|
||||
logger.info(
|
||||
f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); "
|
||||
f"skipping session={session_id} this scan"
|
||||
)
|
||||
return False
|
||||
_running_count += 1
|
||||
|
||||
try:
|
||||
agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent
|
||||
if not agent:
|
||||
return False
|
||||
|
||||
with agent.messages_lock:
|
||||
all_messages = list(agent.messages)
|
||||
total_msgs = len(all_messages)
|
||||
# In-memory evolution cursor: only review messages added since the last
|
||||
# pass so a long session doesn't re-judge (and re-write) old content.
|
||||
# Stored on the agent instance; lost on restart (acceptable — at worst
|
||||
# one redundant pass right after a restart, gated by the file-change
|
||||
# check downstream so it won't double-write identical memory).
|
||||
done = int(getattr(agent, "_evo_done_msg_count", 0))
|
||||
if done > total_msgs:
|
||||
done = 0 # history was trimmed/reset; start fresh
|
||||
new_messages = all_messages[done:]
|
||||
transcript = _build_transcript(new_messages)
|
||||
if not transcript.strip():
|
||||
logger.info(f"[Evolution] session={session_id}: no new messages, skip")
|
||||
# Advance the cursor anyway so we don't re-scan the same tail.
|
||||
agent._evo_done_msg_count = total_msgs
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[Evolution] ▶ Reviewing session={session_id} "
|
||||
f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, "
|
||||
f"~{len(transcript)} chars)"
|
||||
)
|
||||
|
||||
# Resolve workspace + files to snapshot for undo.
|
||||
from agent.memory.config import get_default_memory_config
|
||||
mem_cfg = get_default_memory_config()
|
||||
workspace_dir = mem_cfg.get_workspace()
|
||||
if user_id:
|
||||
memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md"
|
||||
else:
|
||||
memory_file = Path(workspace_dir) / "MEMORY.md"
|
||||
skills_dir = mem_cfg.get_skills_dir()
|
||||
|
||||
# Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected
|
||||
# built-in skills are excluded from backup because they must never be
|
||||
# edited in the first place.
|
||||
protected_names = _builtin_skill_names()
|
||||
# Back up both MEMORY.md and today's daily file: evolution now writes to
|
||||
# the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe
|
||||
# if the model ever edits it.
|
||||
today_daily = Path(workspace_dir) / "memory" / (
|
||||
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||
)
|
||||
if user_id:
|
||||
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
||||
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||
)
|
||||
backup_files = [Path(memory_file), today_daily]
|
||||
if skills_dir.exists():
|
||||
for skill_md in skills_dir.rglob("SKILL.md"):
|
||||
# The skill dir is the SKILL.md's parent (or an ancestor for
|
||||
# collections); guard by checking the immediate top-level dir.
|
||||
try:
|
||||
top = skill_md.relative_to(skills_dir).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if top in protected_names:
|
||||
continue
|
||||
backup_files.append(skill_md)
|
||||
backup_id = create_backup(workspace_dir, backup_files)
|
||||
_backup_n = sum(1 for f in backup_files if Path(f).exists())
|
||||
|
||||
# Snapshot the whole workspace (path -> mtime/size) so we can reliably
|
||||
# detect ANY file change — including new output files written when
|
||||
# finishing an unfinished task, which are not in backup_files.
|
||||
pre_snapshot = _workspace_snapshot(workspace_dir)
|
||||
|
||||
# Build the isolated review agent: same model, restricted tools, with a
|
||||
# hard guard that confines all writes to the workspace (protects the
|
||||
# project's bundled skills from ever being modified).
|
||||
review_tools = _guard_tools(
|
||||
_select_tools(list(getattr(agent, "tools", []) or [])),
|
||||
str(workspace_dir),
|
||||
)
|
||||
review_agent = agent_bridge.create_agent(
|
||||
system_prompt=EVOLUTION_SYSTEM_PROMPT,
|
||||
tools=review_tools,
|
||||
description="Self-evolution review agent",
|
||||
max_steps=cfg.max_steps,
|
||||
workspace_dir=str(workspace_dir),
|
||||
skill_manager=getattr(agent, "skill_manager", None),
|
||||
memory_manager=getattr(agent, "memory_manager", None),
|
||||
enable_skills=False,
|
||||
)
|
||||
# Reuse the live model so it follows the user's configured model.
|
||||
review_agent.model = agent.model
|
||||
|
||||
logger.info(
|
||||
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
|
||||
)
|
||||
user_msg = build_review_user_message(transcript, protected_skills=list(protected_names))
|
||||
result = review_agent.run_stream(user_msg, clear_history=True)
|
||||
result = (result or "").strip()
|
||||
|
||||
# These messages are now reviewed; advance the cursor so the next pass
|
||||
# only looks at messages added after this point (silent or not).
|
||||
agent._evo_done_msg_count = total_msgs
|
||||
|
||||
if not result or SILENT_TOKEN in result:
|
||||
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||
return False
|
||||
|
||||
# Hard gate: an evolution only counts (and only notifies) if a workspace
|
||||
# file ACTUALLY changed. If the model did real work (wrote memory /
|
||||
# patched a skill / finished a task) the user is told; if it merely
|
||||
# produced text without changing anything, we stay silent. This is the
|
||||
# key anti-nag rule — no notification unless something was actually done.
|
||||
if not _workspace_changed(workspace_dir, pre_snapshot):
|
||||
logger.info(
|
||||
f"[Evolution] ✗ session={session_id}: model produced text but "
|
||||
f"changed no file — treating as silent"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
|
||||
append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id)
|
||||
# Inject an [EVOLUTION] note so the main agent can honor "undo".
|
||||
_inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id)
|
||||
|
||||
# Push the summary to the user's channel. The "did a file actually
|
||||
# change" gate above is the only throttle we need: real evolutions are
|
||||
# rare, so no extra opt-in switch or daily-count limit is required.
|
||||
if channel_type and receiver:
|
||||
_notify_user(channel_type, receiver, result)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Run failed for session={session_id}: {e}")
|
||||
return False
|
||||
finally:
|
||||
with _running_lock:
|
||||
_running_count -= 1
|
||||
|
||||
|
||||
def _inject_evolution_record(
|
||||
agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str]
|
||||
) -> None:
|
||||
"""Add an [EVOLUTION] note to the user session so the main agent can undo."""
|
||||
try:
|
||||
note = f"{EVOLUTION_MARKER} {summary}"
|
||||
if backup_id:
|
||||
note += f"\n(backup_id: {backup_id}; to undo, restore this backup)"
|
||||
# Reuse the scheduler-output injection path: isolated execution, only a
|
||||
# compact record lands in the user session.
|
||||
agent_bridge.remember_scheduled_output(
|
||||
session_id=session_id,
|
||||
content=note,
|
||||
channel_type=channel_type,
|
||||
task_description="self-evolution",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Failed to inject evolution record: {e}")
|
||||
|
||||
|
||||
def _notify_user(channel_type: str, receiver: str, summary: str) -> None:
|
||||
"""Push the evolution summary to the user's channel as a new message."""
|
||||
try:
|
||||
from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.channel_factory import create_channel
|
||||
|
||||
context = Context(ContextType.TEXT, summary)
|
||||
context["receiver"] = receiver
|
||||
context["isgroup"] = False
|
||||
context["session_id"] = receiver
|
||||
# Channels that reply to an original message need msg=None for a fresh push.
|
||||
if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"):
|
||||
context["msg"] = None
|
||||
if channel_type == "feishu":
|
||||
context["receive_id_type"] = "open_id"
|
||||
|
||||
channel = create_channel(channel_type)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
# Web is request-response: a background push needs a synthetic request_id
|
||||
# plus a request->session mapping so the channel can route the message to
|
||||
# the user's polling queue (same approach the scheduler uses).
|
||||
if channel_type == "web":
|
||||
import uuid
|
||||
request_id = f"evolution_{uuid.uuid4().hex[:8]}"
|
||||
context["request_id"] = request_id
|
||||
if hasattr(channel, "request_to_session"):
|
||||
channel.request_to_session[request_id] = receiver
|
||||
|
||||
channel.send(Reply(ReplyType.TEXT, summary), context)
|
||||
logger.info(f"[Evolution] Notified user via {channel_type}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to notify user: {e}")
|
||||
163
agent/evolution/prompts.py
Normal file
163
agent/evolution/prompts.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Prompts for the self-evolution review agent.
|
||||
|
||||
The system prompt is intentionally English-only: it governs the agent's
|
||||
internal reasoning and is more stable / cheaper to maintain in one language.
|
||||
The user-facing summary the agent produces should follow the user's own
|
||||
language (instructed at the end of the prompt).
|
||||
|
||||
Design goals (see ref/hermes-agent background_review for inspiration):
|
||||
- Default to doing NOTHING. Evolution is the exception, not the rule.
|
||||
- Three signal types: memory, skill, unfinished task.
|
||||
- An explicit "do NOT capture" list to avoid self-poisoning over time.
|
||||
- Generic examples only — never bake in domain-specific business terms.
|
||||
"""
|
||||
|
||||
# Sentinel the agent emits when there is nothing worth evolving.
|
||||
SILENT_TOKEN = "[SILENT]"
|
||||
|
||||
# Marker prefix for the evolution record injected into the user session, so the
|
||||
# main chat agent can recognize past evolutions and honor an "undo" request.
|
||||
EVOLUTION_MARKER = "[EVOLUTION]"
|
||||
|
||||
|
||||
EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant.
|
||||
|
||||
You are given a transcript of a conversation that just went idle. Your job is to
|
||||
decide whether anything from it is worth durably learning so future
|
||||
conversations go better — and if so, to make that change.
|
||||
|
||||
# Top principle: default to doing NOTHING
|
||||
|
||||
Most ordinary conversations need no evolution. Only act when there is a CLEAR
|
||||
signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying
|
||||
silent is the normal, correct outcome — not a failure.
|
||||
|
||||
Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual
|
||||
chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not
|
||||
explore files, do not write a summary, do not be polite. Just `[SILENT]`.
|
||||
|
||||
IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a
|
||||
tool (write/edit) in this pass. If you did not change any file, you MUST output
|
||||
exactly `[SILENT]` — never describe a change you only intended to make.
|
||||
|
||||
# Signals worth acting on (act only if at least one clearly appears)
|
||||
|
||||
SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles
|
||||
them. When their signal is clear, act; do not be shy here.
|
||||
|
||||
1. SKILL — two cases:
|
||||
a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a
|
||||
missing step/section, a wrong or outdated detail, an error in its
|
||||
content), or its OUTPUT repeatedly misses something the user flagged. Read
|
||||
the relevant skill file under the skills directory and make a small
|
||||
incremental edit so it never recurs.
|
||||
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
|
||||
no existing skill covers and the user is likely to want again. To create
|
||||
one, follow the `skill-creator` skill's conventions (read its SKILL.md for
|
||||
the required structure) and write the new skill under the workspace
|
||||
`skills/` directory. Only create when the workflow is genuinely reusable —
|
||||
not for a one-off task.
|
||||
|
||||
CRITICAL — fix the SOURCE, do not just remember the symptom: when the root
|
||||
cause of a problem lives IN a skill file itself (its instructions, content,
|
||||
or configuration are wrong/outdated), the correct action is to EDIT that
|
||||
skill so the problem cannot recur. Recording the corrected fact in memory
|
||||
does NOT prevent recurrence — only fixing the skill does. Never log "skill X
|
||||
has wrong detail Y" as a memory note in place of editing skill X.
|
||||
|
||||
2. UNFINISHED TASK — a specific deliverable you promised but didn't produce,
|
||||
AND you already have everything needed to finish it. DO IT now with the
|
||||
available tools and produce the result (e.g. write the file you said you'd
|
||||
write). If key info is missing, or the task is merely waiting on the user's
|
||||
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
|
||||
You only ever notify the user as a side effect of having actually done work.
|
||||
|
||||
3. MEMORY — LAST resort, and you are only a SAFETY NET here, not the primary
|
||||
writer. The main assistant already writes memory DURING the conversation, and
|
||||
a nightly pass consolidates daily notes into long-term memory. Prefer fixing
|
||||
a skill (above) over writing memory whenever the fact belongs in a skill.
|
||||
Act ONLY on something the main assistant clearly MISSED that does not belong
|
||||
in any skill.
|
||||
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
|
||||
conversation. Treat it as precious: writing here is RARE and reserved for
|
||||
CORRECTING a wrong fact already in MEMORY.md (edit that line in place).
|
||||
Do NOT append new entries to MEMORY.md — that is the nightly pass's job.
|
||||
- For a genuinely important NEW durable fact the chat missed, append ONE
|
||||
short bullet to today's `memory/YYYY-MM-DD.md` (not MEMORY.md). When unsure,
|
||||
the daily file is the safe place — but first ask whether this really
|
||||
belongs in a skill instead.
|
||||
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
|
||||
conversation, never copy what the main assistant already recorded.
|
||||
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
||||
first), do NOTHING.
|
||||
|
||||
# Do NOT capture (these poison future behavior)
|
||||
|
||||
- Environment failures: missing binaries, unset credentials, uninstalled
|
||||
packages, "command not found". The user can fix these; they are not durable
|
||||
rules.
|
||||
- Negative claims about tools or features ("tool X does not work"). These
|
||||
harden into refusals the agent cites against itself later.
|
||||
- One-off task narratives (e.g. summarizing today's content). Not a class of
|
||||
reusable work.
|
||||
- Transient errors that resolved on retry within the conversation.
|
||||
|
||||
# Execution constraints
|
||||
|
||||
- Before changing memory or a skill, READ the current content first and make a
|
||||
small INCREMENTAL edit. Never fabricate, never rewrite large sections.
|
||||
- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's
|
||||
daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded
|
||||
in EITHER (even if worded differently), do NOT add it again. The main
|
||||
assistant likely already wrote it during the chat — only add what is
|
||||
genuinely new or a correction not yet reflected anywhere.
|
||||
- You may only edit files inside the workspace. Built-in skills shipped with
|
||||
the product live outside it and are write-protected; do not try to edit them.
|
||||
- Make at most the few edits the signals justify; do not go looking for work.
|
||||
|
||||
# Output
|
||||
|
||||
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
||||
- Otherwise, after performing the edits, output a short user-facing summary in
|
||||
the SAME LANGUAGE the user used in the conversation. Tell the user, briefly:
|
||||
1) that you just did a self-learning pass,
|
||||
2) what you learned and what you changed (in plain terms — no need to cite
|
||||
exact file paths; "remembered X" / "improved the weekly-report skill" is
|
||||
enough).
|
||||
Keep it to 1-3 lines. Generic shape (do not copy domain words):
|
||||
"I just did a self-learning pass.
|
||||
- Learned: <what you learned>
|
||||
- Changed: <remembered it / improved the <name> skill / finished <task>>
|
||||
Reply 'undo the last learning' if this is wrong."
|
||||
"""
|
||||
|
||||
|
||||
def build_review_user_message(transcript: str, protected_skills: list = None) -> str:
|
||||
"""Wrap the conversation transcript as the review agent's user message.
|
||||
|
||||
``protected_skills`` lists skill names that must never be edited (built-in
|
||||
skills shipped with the product). Surfaced so the agent avoids them.
|
||||
"""
|
||||
from datetime import datetime
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
protected_note = ""
|
||||
if protected_skills:
|
||||
names = ", ".join(sorted(protected_skills))
|
||||
protected_note = (
|
||||
"\n\nPROTECTED skills (built-in — never edit these): "
|
||||
f"{names}\n"
|
||||
)
|
||||
return (
|
||||
"Here is the conversation transcript that just went idle. Review it per "
|
||||
"your instructions and act on any clear signal. Prefer fixing a skill at "
|
||||
"its source over writing memory whenever the fact belongs in a skill.\n"
|
||||
f"Today is {today}. Only if a fact genuinely belongs in memory (and not "
|
||||
f"in a skill): append one short bullet to the daily file "
|
||||
f"`memory/{today}.md` for a new fact, or edit MEMORY.md in place to "
|
||||
f"correct an existing wrong fact."
|
||||
f"{protected_note}\n"
|
||||
"<transcript>\n"
|
||||
f"{transcript}\n"
|
||||
"</transcript>"
|
||||
)
|
||||
55
agent/evolution/record.py
Normal file
55
agent/evolution/record.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Self-evolution record log.
|
||||
|
||||
Session-level evolutions are appended to their OWN per-day file under
|
||||
``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary
|
||||
in ``memory/dreams/``). Each day's file accumulates one short section per
|
||||
evolution pass — tagged with a timestamp and a backup id for undo — so the
|
||||
memory UI can surface "what the agent learned/changed today" on one timeline
|
||||
without ever mixing into the dream diary or the main conversation memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path:
|
||||
base = Path(workspace_dir) / "memory"
|
||||
if user_id:
|
||||
return base / "users" / user_id / "evolution"
|
||||
return base / "evolution"
|
||||
|
||||
|
||||
def append_session_evolution(
|
||||
workspace_dir: Path,
|
||||
summary: str,
|
||||
backup_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Append a session-evolution entry to today's evolution log."""
|
||||
if not summary or not summary.strip():
|
||||
return
|
||||
try:
|
||||
evo_dir = _evolution_dir(workspace_dir, user_id)
|
||||
evo_dir.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
log_file = evo_dir / f"{today}.md"
|
||||
|
||||
ts = datetime.now().strftime("%H:%M")
|
||||
header = f"## {ts}"
|
||||
body = summary.strip()
|
||||
if backup_id:
|
||||
body += f"\n\n_backup_id: {backup_id}_"
|
||||
|
||||
# Create with a title if the file is new, otherwise append a section.
|
||||
if not log_file.exists():
|
||||
log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"\n{header}\n\n{body}\n")
|
||||
logger.info(f"[Evolution] Recorded session evolution to {log_file.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to record session evolution: {e}")
|
||||
133
agent/evolution/trigger.py
Normal file
133
agent/evolution/trigger.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Idle-based evolution trigger.
|
||||
|
||||
A single background thread periodically scans live agent sessions and runs an
|
||||
evolution pass for any session that is idle for >= idle_minutes AND has enough
|
||||
accumulated signal, where "enough signal" is EITHER:
|
||||
- >= min_turns user turns since the last evolution, OR
|
||||
- the live context has grown past _CONTEXT_RATIO of the agent's token budget
|
||||
(mirrors how OpenClacky / Claude Code consolidate under context pressure).
|
||||
|
||||
Turn counting is per user turn (not per message), measured from the last
|
||||
evolution (or session start). After a pass runs, the baseline resets so a long
|
||||
session can evolve multiple times without re-judging old content.
|
||||
|
||||
Per-session evolution state is stored on the agent instance via lightweight
|
||||
attributes set by AgentBridge.agent_reply (see _note_user_turn).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from common.log import logger
|
||||
|
||||
from agent.evolution.config import get_evolution_config
|
||||
from agent.evolution.executor import run_evolution_for_session
|
||||
|
||||
_SCAN_INTERVAL_SECONDS = 60
|
||||
|
||||
# Context-pressure trigger: evolve once the live context exceeds this fraction
|
||||
# of the agent's token budget, even if min_turns hasn't been reached. Kept as a
|
||||
# module constant (not user config) for now. Fallback budget matches
|
||||
# agent_initializer / config.py (agent_max_context_tokens default = 50000).
|
||||
_CONTEXT_RATIO = 0.8
|
||||
_FALLBACK_CONTEXT_BUDGET = 50000
|
||||
|
||||
|
||||
def _context_pressure_reached(agent) -> bool:
|
||||
"""True if the agent's live context exceeds _CONTEXT_RATIO of its budget.
|
||||
|
||||
Uses the agent's own (estimated) token accounting so behavior matches the
|
||||
existing context-trimming path. Best-effort: any error -> False.
|
||||
"""
|
||||
try:
|
||||
with agent.messages_lock:
|
||||
messages = list(agent.messages)
|
||||
if not messages:
|
||||
return False
|
||||
est = sum(agent._estimate_message_tokens(m) for m in messages)
|
||||
budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET
|
||||
return est / budget > _CONTEXT_RATIO
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
|
||||
"""Record activity for a session's agent. Called once per real user turn.
|
||||
|
||||
Maintains, on the agent instance:
|
||||
_evo_last_active : epoch seconds of the last user turn
|
||||
_evo_turns : user turns since the last evolution
|
||||
_evo_channel_type : originating channel (for later notify)
|
||||
_evo_receiver : push target for notify
|
||||
"""
|
||||
try:
|
||||
agent._evo_last_active = time.time()
|
||||
agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1
|
||||
if channel_type:
|
||||
agent._evo_channel_type = channel_type
|
||||
if receiver:
|
||||
agent._evo_receiver = receiver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_evolution_trigger(agent_bridge) -> None:
|
||||
"""Start the idle-scan thread once per process (idempotent)."""
|
||||
if getattr(agent_bridge, "_evolution_trigger_started", False):
|
||||
return
|
||||
agent_bridge._evolution_trigger_started = True
|
||||
|
||||
t = threading.Thread(
|
||||
target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger"
|
||||
)
|
||||
t.start()
|
||||
logger.info("[Evolution] Idle trigger started")
|
||||
|
||||
|
||||
def _scan_loop(agent_bridge) -> None:
|
||||
while True:
|
||||
try:
|
||||
time.sleep(_SCAN_INTERVAL_SECONDS)
|
||||
cfg = get_evolution_config()
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
_scan_once(agent_bridge, cfg)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Scan loop error: {e}")
|
||||
time.sleep(_SCAN_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _scan_once(agent_bridge, cfg) -> None:
|
||||
now = time.time()
|
||||
# Snapshot to avoid holding the dict while running long evolutions.
|
||||
sessions = list(getattr(agent_bridge, "agents", {}).items())
|
||||
for session_id, agent in sessions:
|
||||
try:
|
||||
last_active = getattr(agent, "_evo_last_active", 0)
|
||||
turns = int(getattr(agent, "_evo_turns", 0))
|
||||
# Enough signal = enough turns OR enough context pressure.
|
||||
enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent)
|
||||
if not enough_signal:
|
||||
continue
|
||||
idle = now - last_active if last_active > 0 else -1
|
||||
if last_active <= 0 or idle < cfg.idle_seconds:
|
||||
continue
|
||||
|
||||
channel_type = getattr(agent, "_evo_channel_type", "") or ""
|
||||
receiver = getattr(agent, "_evo_receiver", "") or ""
|
||||
|
||||
# Reset baseline BEFORE running so a long pass / new messages during
|
||||
# it don't double-trigger; turns accrue fresh from here.
|
||||
agent._evo_turns = 0
|
||||
|
||||
run_evolution_for_session(
|
||||
agent_bridge,
|
||||
session_id=session_id,
|
||||
channel_type=channel_type,
|
||||
receiver=receiver,
|
||||
idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}")
|
||||
@@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@@ -109,6 +110,43 @@ def _extract_display_text(content: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Internal markers written into the session for the agent's own bookkeeping
|
||||
# (scheduler injection / self-evolution undo). They must stay in the stored
|
||||
# content (the LLM reads them, e.g. to find a backup_id for undo) but should
|
||||
# never be shown verbatim to the user in the chat history UI.
|
||||
_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task")
|
||||
_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]"
|
||||
|
||||
|
||||
def _is_internal_user_marker(text: str) -> bool:
|
||||
"""True if a user-turn text is an internal injection marker (hide from UI)."""
|
||||
t = (text or "").lstrip()
|
||||
return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS)
|
||||
|
||||
|
||||
def _clean_display_text(text: str) -> str:
|
||||
"""Strip internal markers from assistant text for user-facing display.
|
||||
|
||||
Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)``
|
||||
undo hint. The raw stored message is untouched, so undo + LLM context still
|
||||
work; only the rendered chat bubble is cleaned.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
cleaned = text
|
||||
stripped = cleaned.lstrip()
|
||||
if stripped.startswith(_EVOLUTION_DISPLAY_MARKER):
|
||||
cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip()
|
||||
# Drop a trailing backup_id undo hint line, e.g.
|
||||
# "(backup_id: 20260607-...; to undo, restore this backup)"
|
||||
cleaned = re.sub(
|
||||
r"\n*\(backup_id:[^\)]*\)\s*$",
|
||||
"",
|
||||
cleaned,
|
||||
).rstrip()
|
||||
return cleaned
|
||||
|
||||
|
||||
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract tool_use blocks from an assistant message content.
|
||||
@@ -210,7 +248,10 @@ def _group_into_display_turns(
|
||||
if user_row:
|
||||
content, created_at, _u_extras = user_row
|
||||
text = _extract_display_text(content)
|
||||
if text:
|
||||
# Hide internal injection markers (scheduler / self-evolution) so the
|
||||
# user never sees a synthetic "[SCHEDULED] self-evolution" bubble;
|
||||
# the assistant reply that follows is still rendered.
|
||||
if text and not _is_internal_user_marker(text):
|
||||
turns.append({"role": "user", "content": text, "created_at": created_at})
|
||||
|
||||
# Build an ordered list of steps preserving the original sequence:
|
||||
@@ -265,6 +306,14 @@ def _group_into_display_turns(
|
||||
step["result"] = tr.get("result", "")
|
||||
step["is_error"] = tr.get("is_error", False)
|
||||
|
||||
# Clean internal markers from the user-facing assistant text. Applies to
|
||||
# both the final content and the mirrored content step so the rendered
|
||||
# bubble shows clean text while the stored message keeps the markers.
|
||||
final_text = _clean_display_text(final_text)
|
||||
for step in steps:
|
||||
if step.get("type") == "content":
|
||||
step["content"] = _clean_display_text(step.get("content", ""))
|
||||
|
||||
if steps or final_text:
|
||||
turn = {
|
||||
"role": "assistant",
|
||||
@@ -291,7 +340,7 @@ class ConversationStore:
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock() # Use RLock to allow reentrant locking
|
||||
self._init_db()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -509,6 +558,65 @@ class ConversationStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
|
||||
"""Return the seq numbers of the latest visible user message and the
|
||||
latest assistant message in a session.
|
||||
|
||||
A "visible" user message is one whose content is real user text
|
||||
(not just a tool_result block), so tool-execution turns do not
|
||||
shadow the actual user query.
|
||||
|
||||
Returns:
|
||||
Dict with keys ``user_seq`` and ``bot_seq``; either may be None
|
||||
when no matching message exists.
|
||||
"""
|
||||
result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Latest assistant message (cheap: single row by seq DESC).
|
||||
row = conn.execute(
|
||||
"SELECT seq FROM messages "
|
||||
"WHERE session_id = ? AND role = 'assistant' "
|
||||
"ORDER BY seq DESC LIMIT 1",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
result["bot_seq"] = int(row[0])
|
||||
|
||||
# Latest visible user message: scan recent user rows and
|
||||
# skip pure tool_result entries.
|
||||
rows = conn.execute(
|
||||
"SELECT seq, content FROM messages "
|
||||
"WHERE session_id = ? AND role = 'user' "
|
||||
"ORDER BY seq DESC LIMIT 20",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
for seq, content_raw in rows:
|
||||
try:
|
||||
content = json.loads(content_raw)
|
||||
except Exception:
|
||||
result["user_seq"] = int(seq)
|
||||
break
|
||||
if isinstance(content, list):
|
||||
has_text = any(
|
||||
isinstance(b, dict) and b.get("type") == "text"
|
||||
for b in content
|
||||
)
|
||||
has_tool_result = any(
|
||||
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||
for b in content
|
||||
)
|
||||
if has_text and not has_tool_result:
|
||||
result["user_seq"] = int(seq)
|
||||
break
|
||||
else:
|
||||
result["user_seq"] = int(seq)
|
||||
break
|
||||
finally:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
def clear_session(self, session_id: str) -> None:
|
||||
"""Delete all messages and the session record for a given session_id."""
|
||||
with self._lock:
|
||||
@@ -524,6 +632,109 @@ class ConversationStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int:
|
||||
"""Delete a user message and/or its corresponding assistant reply.
|
||||
|
||||
The assistant reply is identified as all messages between user_seq
|
||||
and the next visible user message (or end of session).
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
user_seq: The seq number of the user message.
|
||||
delete_user: If True (default), delete the user message too.
|
||||
If False, only delete assistant reply (for regenerate scenarios).
|
||||
cascade: If True, also delete all subsequent turns after this one.
|
||||
Used by edit-message which removes this turn and everything after.
|
||||
|
||||
Returns:
|
||||
Number of message rows deleted.
|
||||
"""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
# Verify this is a user message
|
||||
row = conn.execute(
|
||||
"SELECT role FROM messages WHERE session_id = ? AND seq = ?",
|
||||
(session_id, user_seq),
|
||||
).fetchone()
|
||||
if not row or row[0] != "user":
|
||||
return 0
|
||||
|
||||
if cascade:
|
||||
# Delete from this message to end of session
|
||||
start_seq = user_seq if delete_user else user_seq + 1
|
||||
end_seq_row = conn.execute(
|
||||
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||
else:
|
||||
# Find the next visible user message seq (exclude tool_result)
|
||||
# Use batched query to avoid loading too many rows at once
|
||||
next_user_seq = None
|
||||
batch_size = 100
|
||||
offset = 0
|
||||
while True:
|
||||
batch = conn.execute(
|
||||
"""
|
||||
SELECT seq, content FROM messages
|
||||
WHERE session_id = ? AND seq > ? AND role = 'user'
|
||||
ORDER BY seq ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(session_id, user_seq, batch_size, offset),
|
||||
).fetchall()
|
||||
if not batch:
|
||||
break
|
||||
for seq, content in batch:
|
||||
try:
|
||||
content_obj = json.loads(content)
|
||||
except Exception:
|
||||
content_obj = content
|
||||
if _is_visible_user_message(content_obj):
|
||||
next_user_seq = seq
|
||||
break
|
||||
if next_user_seq is not None:
|
||||
break
|
||||
offset += batch_size
|
||||
|
||||
# Determine the end boundary for deletion
|
||||
if next_user_seq is not None:
|
||||
end_seq = next_user_seq
|
||||
else:
|
||||
end_seq_row = conn.execute(
|
||||
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||
|
||||
# Determine the start boundary for deletion
|
||||
start_seq = user_seq if delete_user else user_seq + 1
|
||||
|
||||
# Delete messages from start_seq to end_seq (exclusive)
|
||||
cur = conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?",
|
||||
(session_id, start_seq, end_seq),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
|
||||
# Update session msg_count
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET msg_count = (
|
||||
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||
)
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(session_id, session_id),
|
||||
)
|
||||
|
||||
return deleted
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def prune_scheduled_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -1053,3 +1264,4 @@ def get_conversation_store() -> ConversationStore:
|
||||
_store_instance = ConversationStore(db_path)
|
||||
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
||||
return _store_instance
|
||||
|
||||
|
||||
@@ -34,13 +34,18 @@ class MemoryService:
|
||||
# ------------------------------------------------------------------
|
||||
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
|
||||
"""
|
||||
List memory or dream files with metadata (without content).
|
||||
List memory, dream, or evolution files with metadata (without content).
|
||||
|
||||
Args:
|
||||
category: ``"memory"`` (default) — MEMORY.md + daily files;
|
||||
``"dream"`` — dream diary files from memory/dreams/
|
||||
``"dream"`` — dream diary files from memory/dreams/;
|
||||
``"evolution"`` — self-evolution logs from memory/evolution/
|
||||
merged with the nightly dream diaries, so
|
||||
one tab shows everything the agent learned.
|
||||
"""
|
||||
if category == "dream":
|
||||
if category == "evolution":
|
||||
files = self._list_evolution_files()
|
||||
elif category == "dream":
|
||||
files = self._list_dream_files()
|
||||
else:
|
||||
files = self._list_memory_files()
|
||||
@@ -93,6 +98,26 @@ class MemoryService:
|
||||
|
||||
return files
|
||||
|
||||
def _list_evolution_files(self) -> List[dict]:
|
||||
"""Self-evolution logs (memory/evolution/*.md) merged with the nightly
|
||||
dream diaries (memory/dreams/*.md), newest first.
|
||||
|
||||
Both are surfaced under the unified "Self-Evolution" tab. A file's
|
||||
``type`` records its origin so the reader can resolve the right dir.
|
||||
"""
|
||||
files: List[dict] = []
|
||||
for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")):
|
||||
sub_dir = os.path.join(self.memory_dir, sub)
|
||||
if not os.path.isdir(sub_dir):
|
||||
continue
|
||||
for name in os.listdir(sub_dir):
|
||||
full = os.path.join(sub_dir, name)
|
||||
if os.path.isfile(full) and name.endswith(".md"):
|
||||
files.append(self._file_info(full, name, ftype))
|
||||
# Sort newest first by filename (date-named); ties favor evolution.
|
||||
files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True)
|
||||
return files
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# content — read a single file
|
||||
# ------------------------------------------------------------------
|
||||
@@ -101,7 +126,7 @@ class MemoryService:
|
||||
Read the full content of a memory or dream file.
|
||||
|
||||
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
|
||||
:param category: ``"memory"`` or ``"dream"``
|
||||
:param category: ``"memory"``, ``"dream"`` or ``"evolution"``
|
||||
:return: dict with ``filename`` and ``content``
|
||||
:raises FileNotFoundError: if the file does not exist
|
||||
"""
|
||||
@@ -125,7 +150,7 @@ class MemoryService:
|
||||
Dispatch a memory management action.
|
||||
|
||||
:param action: ``list`` or ``content``
|
||||
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``)
|
||||
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``)
|
||||
:return: protocol-compatible response dict
|
||||
"""
|
||||
payload = payload or {}
|
||||
@@ -166,6 +191,7 @@ class MemoryService:
|
||||
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
||||
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md``
|
||||
- ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md``
|
||||
- ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md``
|
||||
|
||||
Raises ValueError if the resolved path escapes the allowed directory.
|
||||
"""
|
||||
@@ -173,6 +199,8 @@ class MemoryService:
|
||||
base_dir = self.workspace_root
|
||||
elif category == "dream":
|
||||
base_dir = os.path.join(self.memory_dir, "dreams")
|
||||
elif category == "evolution":
|
||||
base_dir = os.path.join(self.memory_dir, "evolution")
|
||||
else:
|
||||
base_dir = self.memory_dir
|
||||
|
||||
|
||||
@@ -347,11 +347,14 @@ class AgentStreamExecutor:
|
||||
Returns:
|
||||
Final response text
|
||||
"""
|
||||
# Log user message with model info
|
||||
|
||||
# Log user message with model info. Truncate very long messages (e.g.
|
||||
# injected transcripts / large prompts) so logs stay readable.
|
||||
thinking_enabled = self._is_thinking_enabled()
|
||||
thinking_label = " | 💭 thinking" if thinking_enabled else ""
|
||||
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {user_message}")
|
||||
_log_msg = user_message if len(user_message) <= 500 else (
|
||||
user_message[:500] + f" …(+{len(user_message) - 500} chars)"
|
||||
)
|
||||
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}")
|
||||
|
||||
# Add user message (Claude format - use content blocks for consistency)
|
||||
self.messages.append({
|
||||
|
||||
@@ -14,6 +14,9 @@ from agent.tools.send.send import Send
|
||||
from agent.tools.memory.memory_search import MemorySearchTool
|
||||
from agent.tools.memory.memory_get import MemoryGetTool
|
||||
|
||||
# Import self-evolution tools
|
||||
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||
|
||||
# Import tools with optional dependencies
|
||||
def _import_optional_tools():
|
||||
"""Import tools that have optional dependencies"""
|
||||
@@ -135,6 +138,7 @@ __all__ = [
|
||||
'Send',
|
||||
'MemorySearchTool',
|
||||
'MemoryGetTool',
|
||||
'EvolutionUndoTool',
|
||||
'EnvConfig',
|
||||
'SchedulerTool',
|
||||
'WebSearch',
|
||||
|
||||
@@ -69,8 +69,8 @@ SAFETY:
|
||||
if not command:
|
||||
return ToolResult.fail("Error: command parameter is required")
|
||||
|
||||
# Security check: Prevent accessing sensitive config files
|
||||
if "~/.cow/.env" in command or "~/.cow" in command:
|
||||
# Security check: Prevent direct access to the credential file
|
||||
if re.search(r'\.cow[/\\]\.env', command):
|
||||
return ToolResult.fail(
|
||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||
)
|
||||
|
||||
3
agent/tools/evolution_undo/__init__.py
Normal file
3
agent/tools/evolution_undo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||
|
||||
__all__ = ["EvolutionUndoTool"]
|
||||
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Evolution undo tool.
|
||||
|
||||
Lets the main chat agent roll back a previous self-evolution when the user asks
|
||||
("undo the last learning"). The rollback itself is a deterministic FILE RESTORE
|
||||
from the snapshot taken before the evolution — the model only supplies the
|
||||
backup_id it reads from the [EVOLUTION] record in the conversation. No LLM-driven
|
||||
re-editing is involved, so a restore can never make things worse.
|
||||
"""
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class EvolutionUndoTool(BaseTool):
|
||||
"""Restore memory/skill files to the state before a self-evolution."""
|
||||
|
||||
name: str = "evolution_undo"
|
||||
description: str = (
|
||||
"Undo a previous self-evolution (self-learning) by restoring the "
|
||||
"memory/skill files to their state before that learning. Use this when "
|
||||
"the user asks to undo / revert / roll back the last self-learning. "
|
||||
"Find the backup_id in the most recent [EVOLUTION] record in the "
|
||||
"conversation and pass it here."
|
||||
)
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backup_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The backup_id from the [EVOLUTION] record to restore "
|
||||
"(e.g. '20260607-155551-850')."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["backup_id"],
|
||||
}
|
||||
|
||||
def execute(self, args: dict):
|
||||
backup_id = (args.get("backup_id") or "").strip()
|
||||
if not backup_id:
|
||||
return ToolResult.fail("Error: backup_id is required")
|
||||
try:
|
||||
from agent.memory.config import get_default_memory_config
|
||||
from agent.evolution.backup import restore_backup
|
||||
|
||||
workspace_dir = get_default_memory_config().get_workspace()
|
||||
ok = restore_backup(workspace_dir, backup_id)
|
||||
if ok:
|
||||
return ToolResult.success(
|
||||
f"Restored memory/skills to the state before evolution "
|
||||
f"{backup_id}. The previous self-learning has been undone."
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"Could not find or restore backup {backup_id}. It may have "
|
||||
f"expired or already been rolled back."
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error during undo: {e}")
|
||||
@@ -7,7 +7,7 @@ without any external MCP SDK dependency.
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
@@ -34,6 +34,8 @@ class McpClient:
|
||||
self.config = config
|
||||
self.name: str = config.get("name", "unknown")
|
||||
raw_transport: str = config.get("type", "stdio")
|
||||
# Per-server timeout for tool calls (default 120s, suitable for data queries)
|
||||
self._timeout: int = int(config.get("timeout", 120))
|
||||
# Normalize streamable-http aliases to a single internal key
|
||||
self.transport: str = (
|
||||
"streamable-http"
|
||||
@@ -43,6 +45,7 @@ class McpClient:
|
||||
|
||||
# stdio state
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._read_queue: queue.Queue = queue.Queue()
|
||||
|
||||
# SSE state
|
||||
self._sse_url: Optional[str] = None
|
||||
@@ -56,7 +59,13 @@ class McpClient:
|
||||
# Shared state
|
||||
self._next_id = 1
|
||||
self._id_lock = threading.Lock()
|
||||
# _call_lock serializes all requests on the single stdio pipe.
|
||||
# SSE and streamable-http use independent HTTP requests, so they
|
||||
# do not acquire this lock (see _send_request).
|
||||
self._call_lock = threading.Lock()
|
||||
# _http_lock protects _http_session_id initialization across
|
||||
# concurrent streamable-http requests.
|
||||
self._http_lock = threading.Lock()
|
||||
self._initialized = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -172,6 +181,9 @@ class McpClient:
|
||||
threading.Thread(
|
||||
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._drain_stdout, daemon=True, name=f"mcp-stdout-{self.name}"
|
||||
).start()
|
||||
|
||||
return self._handshake()
|
||||
|
||||
@@ -179,14 +191,35 @@ class McpClient:
|
||||
for line in self._proc.stderr:
|
||||
line = line.strip()
|
||||
if line:
|
||||
logger.debug(f"[MCP:{self.name}] stderr: {line}")
|
||||
logger.warning(f"[MCP:{self.name}] stderr: {line}")
|
||||
|
||||
def _readline_with_timeout(self, timeout: int = 30) -> str:
|
||||
"""Read one line from stdio stdout with a hard timeout."""
|
||||
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
|
||||
if not ready:
|
||||
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {timeout}s")
|
||||
return self._proc.stdout.readline()
|
||||
def _drain_stdout(self):
|
||||
"""Background thread: read lines from stdout and put them into the queue."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
self._read_queue.put(line)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self._read_queue.put("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _readline_with_timeout(self, timeout: Optional[int] = None) -> str:
|
||||
"""Read one line from stdio stdout with a hard timeout (cross-platform).
|
||||
|
||||
Uses the per-server timeout from mcp.json config when no explicit
|
||||
timeout is provided.
|
||||
"""
|
||||
effective = timeout if timeout is not None else self._timeout
|
||||
try:
|
||||
line = self._read_queue.get(timeout=effective)
|
||||
except queue.Empty:
|
||||
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {effective}s")
|
||||
if not line:
|
||||
raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly")
|
||||
return line
|
||||
|
||||
def _stdio_send(self, message: dict) -> dict:
|
||||
"""Send a JSON-RPC message over stdio and read the response."""
|
||||
@@ -194,6 +227,7 @@ class McpClient:
|
||||
self._proc.stdin.write(raw)
|
||||
self._proc.stdin.flush()
|
||||
|
||||
expected_id = message.get("id")
|
||||
while True:
|
||||
line = self._readline_with_timeout()
|
||||
if not line:
|
||||
@@ -208,6 +242,14 @@ class McpClient:
|
||||
if "id" not in data:
|
||||
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
|
||||
continue
|
||||
# Verify response id matches request id to avoid consuming a stale
|
||||
# response left over from a previously failed/timed-out request.
|
||||
if data.get("id") != expected_id:
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] Stale response id={data.get('id')} "
|
||||
f"(expected {expected_id}), skipping"
|
||||
)
|
||||
continue
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -302,8 +344,12 @@ class McpClient:
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
if self._http_session_id:
|
||||
headers["Mcp-Session-Id"] = self._http_session_id
|
||||
# Read session id under lock to avoid racing with the
|
||||
# initialization write below during concurrent requests.
|
||||
with self._http_lock:
|
||||
sid = self._http_session_id
|
||||
if sid:
|
||||
headers["Mcp-Session-Id"] = sid
|
||||
headers.update(self._http_headers)
|
||||
|
||||
req = urllib.request.Request(
|
||||
@@ -329,8 +375,13 @@ class McpClient:
|
||||
with resp:
|
||||
# Capture session id assigned by the server (if any)
|
||||
session_id = resp.headers.get("Mcp-Session-Id")
|
||||
# Double-checked lock: only the first response sets the
|
||||
# session id, preventing concurrent initializers from
|
||||
# overwriting each other.
|
||||
if session_id and not self._http_session_id:
|
||||
self._http_session_id = session_id
|
||||
with self._http_lock:
|
||||
if not self._http_session_id:
|
||||
self._http_session_id = session_id
|
||||
|
||||
status = resp.status if hasattr(resp, "status") else resp.getcode()
|
||||
|
||||
@@ -409,15 +460,18 @@ class McpClient:
|
||||
|
||||
message = self._build_request(method, params)
|
||||
|
||||
with self._call_lock:
|
||||
if self.transport == "stdio":
|
||||
# stdio transport uses a single pipe and must be serialized.
|
||||
# SSE and streamable-http use independent HTTP requests and
|
||||
# can safely run concurrently across sessions.
|
||||
if self.transport == "stdio":
|
||||
with self._call_lock:
|
||||
return self._stdio_send(message)
|
||||
elif self.transport == "sse":
|
||||
return self._sse_send(message)
|
||||
elif self.transport == "streamable-http":
|
||||
return self._streamable_http_send(message)
|
||||
else:
|
||||
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
||||
elif self.transport == "sse":
|
||||
return self._sse_send(message)
|
||||
elif self.transport == "streamable-http":
|
||||
return self._streamable_http_send(message)
|
||||
else:
|
||||
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
||||
|
||||
def _send_notification(self, method: str, params: dict):
|
||||
"""Fire-and-forget notification (no response expected)."""
|
||||
|
||||
@@ -51,7 +51,7 @@ _MAIN_MODEL_PROVIDER_NAME = "MainModel"
|
||||
_DISCOVERABLE_MODELS = [
|
||||
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN36_PLUS, "DashScope"),
|
||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
|
||||
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
|
||||
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||
@@ -161,7 +161,7 @@ class Vision(BaseTool):
|
||||
"Error: No model available for Vision.\n"
|
||||
"The main model does not support vision and no other API keys are configured.\n"
|
||||
"Options:\n"
|
||||
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.6-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
|
||||
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.7-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
|
||||
" 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
|
||||
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||
)
|
||||
|
||||
3
app.py
3
app.py
@@ -236,6 +236,9 @@ def _clear_singleton_cache(channel_name: str):
|
||||
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
||||
const.QQ: "channel.qq.qq_channel.QQChannel",
|
||||
const.TELEGRAM: "channel.telegram.telegram_channel.TelegramChannel",
|
||||
const.SLACK: "channel.slack.slack_channel.SlackChannel",
|
||||
const.DISCORD: "channel.discord.discord_channel.DiscordChannel",
|
||||
const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
|
||||
"wx": "channel.weixin.weixin_channel.WeixinChannel",
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ class AgentLLMModel(LLMModel):
|
||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||
("ernie", const.QIANFAN),
|
||||
("mimo-", const.MIMO),
|
||||
]
|
||||
|
||||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||||
@@ -294,6 +295,14 @@ class AgentBridge:
|
||||
self.scheduler_initialized = True
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
|
||||
|
||||
# Start the self-evolution idle trigger (idempotent, daemon thread).
|
||||
try:
|
||||
from agent.evolution.trigger import start_evolution_trigger
|
||||
start_evolution_trigger(self)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}")
|
||||
|
||||
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
||||
"""
|
||||
Create the super agent with COW integration
|
||||
@@ -382,7 +391,49 @@ class AgentBridge:
|
||||
"""Initialize agent for a specific session"""
|
||||
agent = self.initializer.initialize_agent(session_id=session_id)
|
||||
self.agents[session_id] = agent
|
||||
|
||||
|
||||
def sync_session_messages_from_store(self, session_id: str) -> int:
|
||||
"""Reload an agent's in-memory ``messages`` list from the persistent
|
||||
conversation store.
|
||||
|
||||
Used after an external mutation (e.g. user edits / deletes a message
|
||||
via the web console) so the agent's next turn sees the same history
|
||||
as the database. The operation is a no-op when the agent has not been
|
||||
instantiated yet for the session.
|
||||
|
||||
Returns:
|
||||
Number of messages now held in the agent's memory. Returns -1 if
|
||||
the agent does not exist or has no compatible ``messages`` attr.
|
||||
"""
|
||||
if not session_id or session_id not in self.agents:
|
||||
return -1
|
||||
agent = self.agents[session_id]
|
||||
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
|
||||
return -1
|
||||
try:
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
# No turn cap here: we want a faithful mirror of what the store
|
||||
# has for this session after deletion.
|
||||
remaining = store.load_messages(session_id, max_turns=10**6)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
|
||||
)
|
||||
return -1
|
||||
with agent.messages_lock:
|
||||
agent.messages.clear()
|
||||
for msg in remaining:
|
||||
agent.messages.append({
|
||||
"role": msg["role"],
|
||||
"content": msg["content"],
|
||||
})
|
||||
count = len(agent.messages)
|
||||
logger.info(
|
||||
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
|
||||
)
|
||||
return count
|
||||
|
||||
def agent_reply(self, query: str, context: Context = None,
|
||||
on_event=None, clear_history: bool = False) -> Reply:
|
||||
"""
|
||||
@@ -504,6 +555,23 @@ class AgentBridge:
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}")
|
||||
|
||||
# Record this user turn for the self-evolution idle trigger. Skip
|
||||
# scheduler-injected / scheduled-task sessions so internal runs do
|
||||
# not count as user activity.
|
||||
if session_id and not session_id.startswith("scheduler_") and not (
|
||||
context and context.get("is_scheduled_task")
|
||||
):
|
||||
try:
|
||||
from agent.evolution.trigger import note_user_turn
|
||||
ch = (context.get("channel_type") or "") if context else ""
|
||||
rcv = (context.get("receiver") or "") if context else ""
|
||||
is_group = bool(context.get("isgroup")) if context else False
|
||||
# Only enable proactive push for single chats (group push is
|
||||
# noisy); group sessions still evolve, just without notify.
|
||||
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
|
||||
# sync any new/removed MCP tools into the live agent in the
|
||||
# background. Off the critical path so user latency is unaffected;
|
||||
|
||||
@@ -620,6 +620,18 @@
|
||||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
<span data-i18n="config_self_evolution">Self-Evolution</span>
|
||||
<span class="cfg-tip" data-tip-key="config_self_evolution_hint"><i class="fas fa-circle-question"></i></span>
|
||||
</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input id="cfg-self-evolution" type="checkbox" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-checked:bg-primary-400 rounded-full
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white
|
||||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
<button id="cfg-agent-save"
|
||||
@@ -760,7 +772,7 @@
|
||||
</button>
|
||||
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
|
||||
class="memory-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-moon mr-1.5"></i><span data-i18n="memory_tab_dreams">梦境日记</span>
|
||||
<i class="fas fa-seedling mr-1.5"></i><span data-i18n="memory_tab_dreams">自主进化</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1399,3 +1399,175 @@
|
||||
.agent-cancelled-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Code Block Enhancements
|
||||
===================================================================== */
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
margin: 1em 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .code-block-wrapper {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 1em;
|
||||
background: #e2e8f0;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.dark .code-block-header {
|
||||
background: #0f172a;
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
.code-block-lang {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.dark .code-block-lang {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.code-copy-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.code-copy-btn:hover {
|
||||
background: rgba(100, 116, 139, 0.1);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dark .code-copy-btn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .code-copy-btn:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.code-block-wrapper pre {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Drag and Drop Overlay
|
||||
===================================================================== */
|
||||
/* Anchor the absolutely-positioned overlay to the chat view. */
|
||||
#view-chat {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drag-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.drag-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-overlay-content {
|
||||
background: white;
|
||||
border: 3px dashed #3b82f6;
|
||||
border-radius: 16px;
|
||||
padding: 3em 4em;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
animation: bounce 1s ease infinite;
|
||||
}
|
||||
|
||||
.dark .drag-overlay-content {
|
||||
background: #1e293b;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.drag-overlay-content i {
|
||||
font-size: 4em;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.dark .drag-overlay-content i {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.drag-overlay-content p {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .drag-overlay-content p {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Message Action Buttons
|
||||
===================================================================== */
|
||||
.edit-msg-btn,
|
||||
.delete-msg-btn,
|
||||
.regenerate-msg-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.user-message-group:hover .edit-msg-btn,
|
||||
.user-message-group:hover .delete-msg-btn,
|
||||
.bot-message-group:hover .regenerate-msg-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edit-msg-btn:hover,
|
||||
.regenerate-msg-btn:hover {
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
.delete-msg-btn:hover {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ const I18N = {
|
||||
config_max_turns: '最大记忆轮次', config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理',
|
||||
config_max_steps: '最大执行步数', config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数',
|
||||
config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否启用深度思考模式',
|
||||
config_self_evolution: '自主进化', config_self_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项',
|
||||
config_channel_type: '通道类型',
|
||||
config_provider: '模型厂商', config_model_name: '模型',
|
||||
config_custom_model_hint: '输入自定义模型名称',
|
||||
@@ -140,7 +141,7 @@ const I18N = {
|
||||
skills_section_title: '技能', skill_enable: '启用', skill_disable: '禁用',
|
||||
skill_toggle_error: '操作失败,请稍后再试',
|
||||
memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容',
|
||||
memory_tab_files: '记忆文件', memory_tab_dreams: '梦境日记',
|
||||
memory_tab_files: '记忆文件', memory_tab_dreams: '自主进化',
|
||||
memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处',
|
||||
memory_back: '返回列表',
|
||||
memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间',
|
||||
@@ -184,6 +185,8 @@ const I18N = {
|
||||
today: '今天', yesterday: '昨天', earlier: '更早',
|
||||
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
|
||||
delete_session_title: '删除会话',
|
||||
delete_message_confirm: '确认删除这条消息?',
|
||||
delete_message_title: '删除消息',
|
||||
untitled_session: '新对话',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
tip_new_chat: '新建对话',
|
||||
@@ -206,6 +209,10 @@ const I18N = {
|
||||
confirm_cancel: '取消',
|
||||
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时',
|
||||
edit_message: '编辑消息',
|
||||
regenerate_response: '重新生成',
|
||||
edit_save: '保存并发送',
|
||||
edit_cancel: '取消',
|
||||
},
|
||||
en: {
|
||||
console: 'Console',
|
||||
@@ -319,6 +326,7 @@ const I18N = {
|
||||
config_max_turns: 'Max Memory Turns', config_max_turns_hint: 'One Q&A pair = one turn, auto-compressed when exceeded',
|
||||
config_max_steps: 'Max Steps', config_max_steps_hint: 'Max tool calls the Agent can make in a single conversation',
|
||||
config_enable_thinking: 'Deep Thinking', config_enable_thinking_hint: 'Enable deep thinking mode',
|
||||
config_self_evolution: 'Self-Evolution', config_self_evolution_hint: 'Auto-review idle conversations to consolidate memory, improve skills, and follow up on unfinished tasks',
|
||||
config_channel_type: 'Channel Type',
|
||||
config_provider: 'Provider', config_model_name: 'Model',
|
||||
config_custom_model_hint: 'Enter custom model name',
|
||||
@@ -336,7 +344,7 @@ const I18N = {
|
||||
skills_section_title: 'Skills', skill_enable: 'Enable', skill_disable: 'Disable',
|
||||
skill_toggle_error: 'Operation failed, please try again',
|
||||
memory_title: 'Memory', memory_desc: 'View agent memory files and contents',
|
||||
memory_tab_files: 'Memory Files', memory_tab_dreams: 'Dream Diary',
|
||||
memory_tab_files: 'Memory Files', memory_tab_dreams: 'Self-Evolution',
|
||||
memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here',
|
||||
memory_back: 'Back to list',
|
||||
memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated',
|
||||
@@ -380,6 +388,8 @@ const I18N = {
|
||||
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
|
||||
delete_session_confirm: 'Delete this session? All messages will be removed.',
|
||||
delete_session_title: 'Delete Session',
|
||||
delete_message_confirm: 'Delete this message?',
|
||||
delete_message_title: 'Delete Message',
|
||||
untitled_session: 'New Chat',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
tip_new_chat: 'New Chat',
|
||||
@@ -402,6 +412,10 @@ const I18N = {
|
||||
confirm_cancel: 'Cancel',
|
||||
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
||||
thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration',
|
||||
edit_message: 'Edit message',
|
||||
regenerate_response: 'Regenerate',
|
||||
edit_save: 'Save and send',
|
||||
edit_cancel: 'Cancel',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -821,11 +835,45 @@ function renderMarkdown(text) {
|
||||
let html = md.render(text);
|
||||
html = _rewriteLocalImgSrc(html);
|
||||
// Order matters: video first (more specific), then image.
|
||||
return injectImagePreviews(injectVideoPlayers(html));
|
||||
html = injectImagePreviews(injectVideoPlayers(html));
|
||||
// Note: Code block headers are added via DOM manipulation after insertion
|
||||
// See addCodeBlockHeadersToElement()
|
||||
return html;
|
||||
}
|
||||
catch (e) { return text.replace(/\n/g, '<br>'); }
|
||||
}
|
||||
|
||||
function _addCodeBlockHeaders(container) {
|
||||
// Add header with language label and copy button to each <pre> block using DOM manipulation
|
||||
const preBlocks = container.querySelectorAll('pre');
|
||||
preBlocks.forEach(pre => {
|
||||
if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrapper')) return;
|
||||
|
||||
const codeEl = pre.querySelector('code');
|
||||
if (!codeEl) return;
|
||||
|
||||
const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-'));
|
||||
const language = langClass ? langClass.replace('language-', '') : 'code';
|
||||
const langLabel = language.charAt(0).toUpperCase() + language.slice(1);
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'code-block-wrapper';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'code-block-header';
|
||||
header.innerHTML = `
|
||||
<span class="code-block-lang">${langLabel}</span>
|
||||
<button class="code-copy-btn" title="Copy code">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
pre.parentNode.insertBefore(wrapper, pre);
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(pre);
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Chat Module
|
||||
// =====================================================================
|
||||
@@ -1085,6 +1133,22 @@ messagesDiv.addEventListener('scroll', () => {
|
||||
|
||||
// Intercept internal navigation links in chat messages
|
||||
messagesDiv.addEventListener('click', (e) => {
|
||||
// Code block copy button
|
||||
const codeCopyBtn = e.target.closest('.code-copy-btn');
|
||||
if (codeCopyBtn) {
|
||||
e.preventDefault();
|
||||
const wrapper = codeCopyBtn.closest('.code-block-wrapper');
|
||||
const codeEl = wrapper && wrapper.querySelector('pre code');
|
||||
if (codeEl) {
|
||||
const codeText = codeEl.textContent;
|
||||
copyToClipboard(codeText).then(() => {
|
||||
const icon = codeCopyBtn.querySelector('i');
|
||||
if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const copyBtn = e.target.closest('.copy-msg-btn');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
@@ -1092,13 +1156,69 @@ messagesDiv.addEventListener('click', (e) => {
|
||||
const answerEl = msgRoot && msgRoot.querySelector('.answer-content');
|
||||
const rawMd = answerEl && answerEl.dataset.rawMd;
|
||||
if (rawMd) {
|
||||
navigator.clipboard.writeText(rawMd).then(() => {
|
||||
copyToClipboard(rawMd).then(() => {
|
||||
const icon = copyBtn.querySelector('i');
|
||||
if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Edit user message
|
||||
const editBtn = e.target.closest('.edit-msg-btn');
|
||||
if (editBtn) {
|
||||
e.preventDefault();
|
||||
const msgRoot = editBtn.closest('.user-message-group');
|
||||
if (msgRoot) editUserMessage(msgRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regenerate bot response
|
||||
const regenerateBtn = e.target.closest('.regenerate-msg-btn');
|
||||
if (regenerateBtn) {
|
||||
e.preventDefault();
|
||||
const botMsgRoot = regenerateBtn.closest('.flex.gap-3');
|
||||
if (botMsgRoot) regenerateResponse(botMsgRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete message (user bubble only; bot bubbles intentionally lack a
|
||||
// delete button — removing only the bot reply would leave an orphan
|
||||
// user message that breaks LLM context alternation).
|
||||
const deleteBtn = e.target.closest('.delete-msg-btn');
|
||||
if (deleteBtn) {
|
||||
e.preventDefault();
|
||||
const userMsgEl = deleteBtn.closest('.user-message-group');
|
||||
if (!userMsgEl) return;
|
||||
|
||||
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
|
||||
// Find the next bot reply for this turn (skip non-message nodes).
|
||||
let botReplyEl = null;
|
||||
let sibling = userMsgEl.nextElementSibling;
|
||||
while (sibling) {
|
||||
if (sibling.classList && sibling.classList.contains('bot-message-group')) {
|
||||
botReplyEl = sibling;
|
||||
break;
|
||||
}
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
userMsgEl.remove();
|
||||
if (botReplyEl) botReplyEl.remove();
|
||||
|
||||
const userSeq = userMsgEl.dataset.seq;
|
||||
if (userSeq) {
|
||||
fetch('/api/messages/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
|
||||
}).catch(err => console.error('Failed to delete:', err));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const a = e.target.closest('a');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href') || '';
|
||||
@@ -1376,14 +1496,83 @@ document.addEventListener('click', (e) => {
|
||||
hideAttachMenu();
|
||||
});
|
||||
|
||||
// Drag-and-drop support on chat input area
|
||||
// Drag-and-drop support on entire chat view
|
||||
const chatView = document.getElementById('view-chat');
|
||||
const chatInputArea = chatInput.closest('.flex-shrink-0');
|
||||
chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
|
||||
chatInputArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); });
|
||||
chatInputArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
|
||||
// Create drag overlay for visual feedback
|
||||
let dragOverlay = document.getElementById('drag-overlay');
|
||||
if (!dragOverlay) {
|
||||
dragOverlay = document.createElement('div');
|
||||
dragOverlay.id = 'drag-overlay';
|
||||
dragOverlay.className = 'drag-overlay hidden';
|
||||
dragOverlay.innerHTML = `
|
||||
<div class="drag-overlay-content">
|
||||
<i class="fas fa-cloud-arrow-up"></i>
|
||||
<p>Drop files here to upload</p>
|
||||
</div>
|
||||
`;
|
||||
chatView.appendChild(dragOverlay);
|
||||
}
|
||||
|
||||
let dragCounter = 0;
|
||||
|
||||
function showDragOverlay() {
|
||||
dragOverlay.classList.remove('hidden');
|
||||
dragOverlay.classList.add('active');
|
||||
}
|
||||
|
||||
function hideDragOverlay() {
|
||||
dragOverlay.classList.remove('active');
|
||||
dragOverlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
chatView.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter++;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
showDragOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
chatView.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chatInputArea.classList.add('drag-over');
|
||||
});
|
||||
|
||||
chatView.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
hideDragOverlay();
|
||||
chatInputArea.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
chatView.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter = 0;
|
||||
hideDragOverlay();
|
||||
chatInputArea.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files);
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('dragover', (e) => {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('drop', (e) => {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Paste image support
|
||||
@@ -1761,6 +1950,191 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) {
|
||||
scrollChatToBottom(true);
|
||||
}
|
||||
|
||||
// Clipboard helper with fallback for non-HTTPS environments
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// Fallback for HTTP environments
|
||||
return new Promise((resolve, reject) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy') ? resolve() : reject(new Error('Copy failed'));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
textArea.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit user message: extract content, remove this and subsequent messages, fill input
|
||||
async function editUserMessage(msgEl) {
|
||||
const rawContent = msgEl.dataset.rawContent;
|
||||
if (!rawContent) return;
|
||||
|
||||
// Delete this message and ALL subsequent messages from database (cascade)
|
||||
// Must await to ensure delete completes before user sends a new message
|
||||
const userSeq = msgEl.dataset.seq;
|
||||
if (userSeq) {
|
||||
try {
|
||||
const resp = await fetch('/api/messages/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
user_seq: parseInt(userSeq),
|
||||
delete_user: true,
|
||||
cascade: true
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete old messages:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this message bubble and every later bubble that belongs to
|
||||
// this or a subsequent turn. We mirror the backend cascade contract:
|
||||
// anything with a data-seq >= current seq, plus any live SSE bubble
|
||||
// that is still being streamed (no seq yet) after this point.
|
||||
const currentSeqNum = userSeq ? parseInt(userSeq) : null;
|
||||
const messagesToRemove = [];
|
||||
let current = msgEl;
|
||||
while (current) {
|
||||
if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('bot-message-group'))) {
|
||||
const seqAttr = current.dataset.seq;
|
||||
if (seqAttr === undefined || seqAttr === '') {
|
||||
// Live message without a persisted seq yet — treat as later.
|
||||
messagesToRemove.push(current);
|
||||
} else if (currentSeqNum === null || parseInt(seqAttr) >= currentSeqNum) {
|
||||
messagesToRemove.push(current);
|
||||
}
|
||||
}
|
||||
current = current.nextElementSibling;
|
||||
}
|
||||
messagesToRemove.forEach(el => {
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
});
|
||||
|
||||
// Fill input with the original content
|
||||
chatInput.value = rawContent;
|
||||
chatInput.style.height = 'auto';
|
||||
chatInput.style.height = chatInput.scrollHeight + 'px';
|
||||
chatInput.focus();
|
||||
scrollChatToBottom();
|
||||
}
|
||||
|
||||
// Regenerate bot response: find the preceding user message and resend it
|
||||
async function regenerateResponse(botMsgEl) {
|
||||
let prevEl = botMsgEl.previousElementSibling;
|
||||
while (prevEl && !prevEl.classList.contains('user-message-group')) {
|
||||
prevEl = prevEl.previousElementSibling;
|
||||
}
|
||||
|
||||
if (!prevEl) {
|
||||
console.warn('No preceding user message found');
|
||||
return;
|
||||
}
|
||||
|
||||
const userContent = prevEl.dataset.rawContent;
|
||||
if (!userContent) {
|
||||
console.warn('No content in preceding user message');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete both the old user message AND bot reply from database
|
||||
// (because /message will create a fresh user message + new bot reply)
|
||||
// Must await to ensure delete completes before /message is sent
|
||||
const userSeq = prevEl.dataset.seq;
|
||||
if (userSeq) {
|
||||
try {
|
||||
const resp = await fetch('/api/messages/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
user_seq: parseInt(userSeq),
|
||||
delete_user: true
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete old messages:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove both the old user message and bot message from DOM
|
||||
if (prevEl.parentNode) prevEl.parentNode.removeChild(prevEl);
|
||||
if (botMsgEl.parentNode) botMsgEl.parentNode.removeChild(botMsgEl);
|
||||
|
||||
// Re-add the user message to DOM (so it appears before the loading indicator)
|
||||
addUserMessage(userContent, new Date());
|
||||
|
||||
// Show loading indicator
|
||||
const loadingEl = addLoadingIndicator();
|
||||
|
||||
// Resend the message
|
||||
const timestamp = new Date();
|
||||
const body = { session_id: sessionId, message: userContent, stream: true, timestamp: timestamp.toISOString(), lang: currentLang };
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
function postWithRetry(attempt) {
|
||||
fetch('/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (data.inline_reply) {
|
||||
loadingEl.remove();
|
||||
addBotMessage(data.inline_reply, new Date());
|
||||
} else if (data.stream) {
|
||||
setSendBtnCancelMode(data.request_id);
|
||||
startSSE(data.request_id, loadingEl, timestamp, null);
|
||||
} else {
|
||||
loadingContainers[data.request_id] = loadingEl;
|
||||
}
|
||||
} else {
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.name === 'AbortError') {
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_timeout'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
return;
|
||||
}
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.warn(`[regenerateResponse] attempt ${attempt + 1} failed, retrying...`, err);
|
||||
setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1));
|
||||
return;
|
||||
}
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
});
|
||||
}
|
||||
|
||||
postWithRetry(0);
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
// Do NOT branch on sendBtnMode here: Enter should always send (so
|
||||
// typing "/cancel" submits normally). Cancel is wired only to the
|
||||
@@ -1874,8 +2248,10 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
if (botEl) return;
|
||||
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||
botEl = document.createElement('div');
|
||||
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3';
|
||||
botEl.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
|
||||
botEl.dataset.requestId = requestId;
|
||||
// Regenerate button starts hidden; it's revealed in the "done"
|
||||
// event handler once seq metadata arrives from the backend.
|
||||
botEl.innerHTML = `
|
||||
<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%]">
|
||||
@@ -1893,6 +2269,9 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
|
||||
<i class="fas fa-volume-up"></i>
|
||||
</button>
|
||||
<button class="regenerate-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('regenerate_response')}" style="display:none;">
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2142,6 +2521,29 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
if (copyBtn && finalText) copyBtn.style.display = '';
|
||||
applyHighlighting(botEl);
|
||||
}
|
||||
|
||||
// Backfill seq metadata so edit/regenerate buttons can call
|
||||
// the delete API without a page refresh. Backend includes
|
||||
// user_seq / bot_seq on the done event after persistence.
|
||||
const targetBotEl = botEl || (requestId ? messagesDiv.querySelector(`[data-request-id="${requestId}"]`) : null);
|
||||
if (targetBotEl) {
|
||||
if (item.bot_seq !== undefined && item.bot_seq !== null) {
|
||||
targetBotEl.dataset.seq = item.bot_seq;
|
||||
}
|
||||
// Reveal regenerate button now that the seq is wired up.
|
||||
const regenBtn = targetBotEl.querySelector('.regenerate-msg-btn');
|
||||
if (regenBtn) regenBtn.style.display = '';
|
||||
if (item.user_seq !== undefined && item.user_seq !== null) {
|
||||
// Locate the preceding user bubble for this turn.
|
||||
let prev = targetBotEl.previousElementSibling;
|
||||
while (prev && !prev.classList.contains('user-message-group')) {
|
||||
prev = prev.previousElementSibling;
|
||||
}
|
||||
if (prev && !prev.dataset.seq) {
|
||||
prev.dataset.seq = item.user_seq;
|
||||
}
|
||||
}
|
||||
}
|
||||
renderBotSpeakerButton(botEl, finalText);
|
||||
scrollChatToBottom();
|
||||
|
||||
@@ -2252,7 +2654,7 @@ function startPolling() {
|
||||
|
||||
function createUserMessageEl(content, timestamp, attachments) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'flex justify-end px-4 sm:px-6 py-3';
|
||||
el.className = 'flex justify-end px-4 sm:px-6 py-3 user-message-group';
|
||||
|
||||
let attachHtml = '';
|
||||
if (attachments && attachments.length > 0) {
|
||||
@@ -2277,9 +2679,19 @@ function createUserMessageEl(content, timestamp, attachments) {
|
||||
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
|
||||
${attachHtml}${textHtml}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
||||
<div class="flex items-center justify-end gap-2 mt-1.5">
|
||||
<button class="edit-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('edit_message')}">
|
||||
<i class="fas fa-pen-to-square"></i>
|
||||
</button>
|
||||
<button class="delete-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors cursor-pointer" title="${t('delete_message_title')}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 dark:text-slate-500">${formatTime(timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Store raw content for editing
|
||||
el.dataset.rawContent = content || '';
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -2455,7 +2867,7 @@ function localizeCancelMarker(text) {
|
||||
|
||||
function createBotMessageEl(content, timestamp, requestId, msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
|
||||
el.className = 'flex gap-3 px-4 sm:px-6 py-3 bot-message-group';
|
||||
if (requestId) el.dataset.requestId = requestId;
|
||||
|
||||
let stepsHtml = '';
|
||||
@@ -2490,6 +2902,9 @@ function createBotMessageEl(content, timestamp, requestId, msg) {
|
||||
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
|
||||
<i class="fas fa-volume-up"></i>
|
||||
</button>
|
||||
<button class="regenerate-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('regenerate_response')}">
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2709,6 +3124,10 @@ function loadHistory(page) {
|
||||
const el = msg.role === 'user'
|
||||
? createUserMessageEl(msg.content, ts)
|
||||
: createBotMessageEl(msg.content || '', ts, null, msg);
|
||||
// Store seq for delete functionality
|
||||
if (msg._seq !== undefined) {
|
||||
el.dataset.seq = msg._seq;
|
||||
}
|
||||
fragment.appendChild(el);
|
||||
});
|
||||
|
||||
@@ -3285,6 +3704,8 @@ function applyHighlighting(container) {
|
||||
hljsLib.highlightElement(block);
|
||||
}
|
||||
});
|
||||
// Add language labels and copy buttons to code blocks
|
||||
_addCodeBlockHeaders(root);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -3405,6 +3826,7 @@ function initConfigView(data) {
|
||||
document.getElementById('cfg-max-turns').value = data.agent_max_context_turns || 20;
|
||||
document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20;
|
||||
document.getElementById('cfg-enable-thinking').checked = data.enable_thinking === true;
|
||||
document.getElementById('cfg-self-evolution').checked = data.self_evolution_enabled === true;
|
||||
|
||||
// Reflect the current UI language (already resolved, may include the user's
|
||||
// local choice) on the selector so it stays in sync with the top-right toggle.
|
||||
@@ -3660,6 +4082,7 @@ function saveAgentConfig() {
|
||||
agent_max_context_turns: parseInt(document.getElementById('cfg-max-turns').value) || 20,
|
||||
agent_max_steps: parseInt(document.getElementById('cfg-max-steps').value) || 20,
|
||||
enable_thinking: document.getElementById('cfg-enable-thinking').checked,
|
||||
self_evolution_enabled: document.getElementById('cfg-self-evolution').checked,
|
||||
};
|
||||
|
||||
const btn = document.getElementById('cfg-agent-save');
|
||||
@@ -3885,13 +4308,14 @@ function toggleSkill(name, currentlyEnabled) {
|
||||
// Memory View
|
||||
// =====================================================================
|
||||
let memoryPage = 1;
|
||||
let memoryCategory = 'memory'; // 'memory' | 'dream'
|
||||
let memoryCategory = 'memory'; // 'memory' | 'evolution'
|
||||
const memoryPageSize = 10;
|
||||
|
||||
function switchMemoryTab(tab) {
|
||||
document.querySelectorAll('.memory-tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('memory-tab-' + tab).classList.add('active');
|
||||
memoryCategory = tab === 'dreams' ? 'dream' : 'memory';
|
||||
// The "dreams" tab now surfaces self-evolution logs (merged with dream diaries).
|
||||
memoryCategory = tab === 'dreams' ? 'evolution' : 'memory';
|
||||
loadMemoryView(1);
|
||||
}
|
||||
|
||||
@@ -3908,9 +4332,9 @@ function loadMemoryView(page) {
|
||||
if (total === 0) {
|
||||
const emptyIcon = emptyEl.querySelector('i');
|
||||
const emptyTitle = emptyEl.querySelector('p');
|
||||
if (memoryCategory === 'dream') {
|
||||
emptyIcon.className = 'fas fa-moon text-purple-400 text-xl';
|
||||
emptyTitle.textContent = currentLang === 'zh' ? '暂无梦境日记' : 'No dream diaries yet';
|
||||
if (memoryCategory === 'evolution') {
|
||||
emptyIcon.className = 'fas fa-seedling text-emerald-400 text-xl';
|
||||
emptyTitle.textContent = currentLang === 'zh' ? '暂无进化记录' : 'No evolution records yet';
|
||||
} else {
|
||||
emptyIcon.className = 'fas fa-brain text-purple-400 text-xl';
|
||||
emptyTitle.textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files';
|
||||
@@ -3927,10 +4351,15 @@ function loadMemoryView(page) {
|
||||
files.forEach(f => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors';
|
||||
tr.onclick = () => openMemoryFile(f.filename, memoryCategory);
|
||||
// In the merged evolution tab, resolve each file by its own origin
|
||||
// (evolution logs vs dream diaries live in different dirs).
|
||||
const fileCategory = (f.type === 'dream' || f.type === 'evolution') ? f.type : memoryCategory;
|
||||
tr.onclick = () => openMemoryFile(f.filename, fileCategory);
|
||||
let typeLabel;
|
||||
if (f.type === 'global') {
|
||||
typeLabel = '<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>';
|
||||
} else if (f.type === 'evolution') {
|
||||
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">Evolution</span>';
|
||||
} else if (f.type === 'dream') {
|
||||
typeLabel = '<span class="px-2 py-0.5 rounded-full text-xs bg-violet-50 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">Dream</span>';
|
||||
} else {
|
||||
@@ -4025,7 +4454,7 @@ const MODELS_CAPABILITY_DEFS = [
|
||||
iconChip: 'bg-blue-50 dark:bg-blue-900/30', iconGlyph: 'text-blue-500' },
|
||||
{ id: 'image', icon: 'fa-image', editable: true, needsModel: true, titleKey: 'models_capability_image', descKey: 'models_capability_image_desc',
|
||||
iconChip: 'bg-blue-50 dark:bg-blue-900/30', iconGlyph: 'text-blue-500' },
|
||||
{ id: 'asr', icon: 'fa-microphone', editable: true, needsModel: false, titleKey: 'models_capability_asr', descKey: 'models_capability_asr_desc',
|
||||
{ id: 'asr', icon: 'fa-microphone', editable: true, needsModel: true, titleKey: 'models_capability_asr', descKey: 'models_capability_asr_desc',
|
||||
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
|
||||
{ id: 'tts', icon: 'fa-volume-high', editable: true, needsModel: true, titleKey: 'models_capability_tts', descKey: 'models_capability_tts_desc',
|
||||
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
|
||||
|
||||
@@ -251,6 +251,21 @@ class WebChannel(ChatChannel):
|
||||
"""生成唯一的请求ID"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _fetch_latest_pair_seqs(self, session_id: str):
|
||||
"""Query the conversation store for the latest user/bot message seqs.
|
||||
|
||||
Returned as ``{"user_seq": int|None, "bot_seq": int|None}``; used to
|
||||
attach seq metadata onto the SSE ``done`` event so the frontend can
|
||||
wire edit / regenerate buttons for live-streamed bubbles without a
|
||||
page refresh.
|
||||
"""
|
||||
try:
|
||||
from agent.memory import get_conversation_store
|
||||
return get_conversation_store().get_latest_pair_seqs(session_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"[WebChannel] _fetch_latest_pair_seqs failed: {e}")
|
||||
return {"user_seq": None, "bot_seq": None}
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
try:
|
||||
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
|
||||
@@ -291,11 +306,14 @@ class WebChannel(ChatChannel):
|
||||
if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"):
|
||||
text_content = getattr(reply, 'text_content', '')
|
||||
if text_content:
|
||||
seqs = self._fetch_latest_pair_seqs(session_id)
|
||||
self.sse_queues[request_id].put({
|
||||
"type": "done",
|
||||
"content": text_content,
|
||||
"request_id": request_id,
|
||||
"timestamp": time.time()
|
||||
"timestamp": time.time(),
|
||||
"user_seq": seqs.get("user_seq"),
|
||||
"bot_seq": seqs.get("bot_seq"),
|
||||
})
|
||||
logger.debug(f"SSE skipped duplicate file for request {request_id}")
|
||||
return
|
||||
@@ -307,11 +325,14 @@ class WebChannel(ChatChannel):
|
||||
logger.debug(f"SSE skipped http media reply for request {request_id}")
|
||||
return
|
||||
|
||||
seqs = self._fetch_latest_pair_seqs(session_id)
|
||||
self.sse_queues[request_id].put({
|
||||
"type": "done",
|
||||
"content": content,
|
||||
"request_id": request_id,
|
||||
"timestamp": time.time()
|
||||
"timestamp": time.time(),
|
||||
"user_seq": seqs.get("user_seq"),
|
||||
"bot_seq": seqs.get("bot_seq"),
|
||||
})
|
||||
logger.debug(f"SSE done sent for request {request_id}")
|
||||
# Auto-trigger TTS once the bot finishes its text reply. The
|
||||
@@ -1025,22 +1046,44 @@ class WebChannel(ChatChannel):
|
||||
|
||||
self._cleanup_stale_voice_recordings()
|
||||
|
||||
# Print available channel types
|
||||
# Print available channel types (ordered by language: prioritize
|
||||
# locally-popular channels for the current UI language)
|
||||
logger.info(
|
||||
"[WebChannel] Available channels (edit `channel_type` in config.json to switch, separate multiple with commas):")
|
||||
logger.info("[WebChannel] 1. web - Web")
|
||||
logger.info("[WebChannel] 2. terminal - Terminal")
|
||||
logger.info("[WebChannel] 3. weixin - WeChat")
|
||||
logger.info("[WebChannel] 4. feishu - Feishu")
|
||||
logger.info("[WebChannel] 5. dingtalk - DingTalk")
|
||||
logger.info("[WebChannel] 6. wecom_bot - WeCom Bot")
|
||||
logger.info("[WebChannel] 7. wechatcom_app - WeCom App")
|
||||
logger.info("[WebChannel] 8. wechat_kf - WeChat Customer Service")
|
||||
logger.info("[WebChannel] 9. wechatmp - WeChat Official Account")
|
||||
logger.info("[WebChannel] 10. wechatmp_service - WeChat Official Account (Service)")
|
||||
logger.info("[WebChannel] 11. telegram - Telegram")
|
||||
logger.info("[WebChannel] 12. slack - Slack")
|
||||
logger.info("[WebChannel] 13. discord - Discord")
|
||||
zh_channels = [
|
||||
("web", "Web"),
|
||||
("terminal", "Terminal"),
|
||||
("weixin", "WeChat"),
|
||||
("feishu", "Feishu"),
|
||||
("dingtalk", "DingTalk"),
|
||||
("wecom_bot", "WeCom Bot"),
|
||||
("wechatcom_app", "WeCom App"),
|
||||
("wechat_kf", "WeChat Customer Service"),
|
||||
("wechatmp", "WeChat Official Account"),
|
||||
("wechatmp_service", "WeChat Official Account (Service)"),
|
||||
("telegram", "Telegram"),
|
||||
("slack", "Slack"),
|
||||
("discord", "Discord"),
|
||||
]
|
||||
en_channels = [
|
||||
("web", "Web"),
|
||||
("terminal", "Terminal"),
|
||||
("telegram", "Telegram"),
|
||||
("slack", "Slack"),
|
||||
("discord", "Discord"),
|
||||
("weixin", "WeChat"),
|
||||
("feishu", "Feishu"),
|
||||
("dingtalk", "DingTalk"),
|
||||
("wecom_bot", "WeCom Bot"),
|
||||
("wechatcom_app", "WeCom App"),
|
||||
("wechat_kf", "WeChat Customer Service"),
|
||||
("wechatmp", "WeChat Official Account"),
|
||||
("wechatmp_service", "WeChat Official Account (Service)"),
|
||||
]
|
||||
channels = en_channels if i18n.get_language() == "en" else zh_channels
|
||||
name_width = max(len(name) for name, _ in channels)
|
||||
for idx, (name, label) in enumerate(channels, 1):
|
||||
logger.info(f"[WebChannel] {idx:>2}. {name:<{name_width}} - {label}")
|
||||
logger.info("[WebChannel] ✅ Web console is running")
|
||||
logger.info(f"[WebChannel] 🌐 Local access: http://localhost:{port}")
|
||||
if is_public_bind:
|
||||
@@ -1096,6 +1139,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||
'/api/sessions/(.*)', 'SessionDetailHandler',
|
||||
'/api/history', 'HistoryHandler',
|
||||
'/api/messages/delete', 'MessageDeleteHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
'/api/version', 'VersionHandler',
|
||||
'/assets/(.*)', 'AssetsHandler',
|
||||
@@ -1404,12 +1448,12 @@ class ConfigHandler:
|
||||
|
||||
_RECOMMENDED_MODELS = [
|
||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
|
||||
const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
|
||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
|
||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
const.QWEN36_PLUS, const.QWEN37_MAX, const.QWEN35_PLUS, const.QWEN3_MAX,
|
||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||
@@ -1442,7 +1486,7 @@ class ConfigHandler:
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"api_base_placeholder": "",
|
||||
"models": [const.MINIMAX_M2_7, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING],
|
||||
"models": [const.MINIMAX_M3, const.MINIMAX_M2_7, const.MINIMAX_M2_7_HIGHSPEED],
|
||||
}),
|
||||
("claudeAPI", {
|
||||
"label": "Claude",
|
||||
@@ -1482,7 +1526,7 @@ class ConfigHandler:
|
||||
"api_base_key": None,
|
||||
"api_base_default": None,
|
||||
"api_base_placeholder": "",
|
||||
"models": [const.QWEN36_PLUS, const.QWEN37_MAX, const.QWEN35_PLUS, const.QWEN3_MAX],
|
||||
"models": [const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS],
|
||||
}),
|
||||
("doubao", {
|
||||
"label": {"zh": "豆包", "en": "Doubao"},
|
||||
@@ -1543,7 +1587,7 @@ class ConfigHandler:
|
||||
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||
"enable_thinking", "web_password",
|
||||
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -1598,6 +1642,7 @@ class ConfigHandler:
|
||||
"agent_max_context_turns": local_config.get("agent_max_context_turns", 20),
|
||||
"agent_max_steps": local_config.get("agent_max_steps", 20),
|
||||
"enable_thinking": bool(local_config.get("enable_thinking", False)),
|
||||
"self_evolution_enabled": bool(local_config.get("self_evolution_enabled", False)),
|
||||
"api_bases": api_bases,
|
||||
"api_keys": api_keys_masked,
|
||||
"providers": providers,
|
||||
@@ -1623,7 +1668,7 @@ class ConfigHandler:
|
||||
continue
|
||||
if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"):
|
||||
value = int(value)
|
||||
if key in ("use_linkai", "enable_thinking"):
|
||||
if key in ("use_linkai", "enable_thinking", "self_evolution_enabled"):
|
||||
value = bool(value)
|
||||
local_config[key] = value
|
||||
applied[key] = value
|
||||
@@ -1720,6 +1765,28 @@ class ModelsHandler:
|
||||
],
|
||||
}
|
||||
|
||||
# ASR engine catalog per provider. The first entry of each list is the
|
||||
# runtime default (mirrors DEFAULT_ASR_MODEL in voice/*). Users can still
|
||||
# pick "custom" in the UI to send any other model id.
|
||||
_ASR_PROVIDER_MODELS = {
|
||||
"openai": [
|
||||
{"value": "gpt-4o-mini-transcribe", "hint": "默认 · 速度快"},
|
||||
{"value": "gpt-4o-transcribe", "hint": "更高准确率"},
|
||||
{"value": "whisper-1", "hint": "经典 Whisper"},
|
||||
],
|
||||
"dashscope": [
|
||||
{"value": "qwen3-asr-flash", "hint": "覆盖普通话、方言与主流外语"},
|
||||
],
|
||||
"zhipu": [
|
||||
{"value": "glm-asr-2512", "hint": "智谱语音识别"},
|
||||
],
|
||||
# LinkAI gateway pins whisper-1 for ASR and ignores any other id,
|
||||
# so expose only that to avoid misleading the user.
|
||||
"linkai": [
|
||||
{"value": "whisper-1", "hint": "网关固定使用"},
|
||||
],
|
||||
}
|
||||
|
||||
# Per-provider voice timbres. Entries can be a bare code string
|
||||
# (label = code) or {value, hint?} when a friendly secondary label
|
||||
# helps recognition. We keep `value` as the raw API code so power
|
||||
@@ -1964,7 +2031,7 @@ class ModelsHandler:
|
||||
],
|
||||
"doubao": [const.DOUBAO_SEED_2_PRO],
|
||||
"moonshot": [const.KIMI_K2_6],
|
||||
"dashscope": [const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX],
|
||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||
"claudeAPI": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||
@@ -1985,7 +2052,7 @@ class ModelsHandler:
|
||||
"linkai": [
|
||||
const.GPT_41_MINI,
|
||||
const.GPT_54_MINI,
|
||||
const.QWEN36_PLUS,
|
||||
const.QWEN37_PLUS,
|
||||
const.DOUBAO_SEED_2_PRO,
|
||||
const.KIMI_K2_6,
|
||||
const.CLAUDE_4_6_SONNET,
|
||||
@@ -2102,7 +2169,7 @@ class ModelsHandler:
|
||||
_VISION_AUTO_ORDER = [
|
||||
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
||||
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
||||
("dashscope", "dashscope_api_key", const.QWEN36_PLUS),
|
||||
("dashscope", "dashscope_api_key", const.QWEN37_PLUS),
|
||||
("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET),
|
||||
("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
|
||||
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
||||
@@ -2240,8 +2307,9 @@ class ModelsHandler:
|
||||
"editable": True,
|
||||
"current_provider": explicit,
|
||||
"suggested_provider": suggested,
|
||||
"current_model": "",
|
||||
"current_model": (local_config.get("voice_to_text_model") or "") if explicit else "",
|
||||
"providers": cls._ASR_PROVIDERS,
|
||||
"provider_models": cls._ASR_PROVIDER_MODELS,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -2613,7 +2681,7 @@ class ModelsHandler:
|
||||
if capability == "vision":
|
||||
return self._set_vision(provider_id, model)
|
||||
if capability == "asr":
|
||||
return self._set_simple("voice_to_text", provider_id)
|
||||
return self._set_asr(provider_id, model)
|
||||
if capability == "tts":
|
||||
return self._set_tts(provider_id, model, (data.get("voice") or "").strip())
|
||||
if capability == "embedding":
|
||||
@@ -2773,6 +2841,30 @@ class ModelsHandler:
|
||||
self._refresh_voice_routing()
|
||||
return json.dumps({"status": "success", key: value})
|
||||
|
||||
def _set_asr(self, provider_id: str, model: str) -> str:
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
local_config["voice_to_text"] = provider_id
|
||||
file_cfg["voice_to_text"] = provider_id
|
||||
# Only overwrite the model when one is supplied. An empty model means
|
||||
# "keep whatever is configured" so switching provider from the console
|
||||
# never wipes a user's hand-set voice_to_text_model (runtime falls back
|
||||
# to the engine default via `or DEFAULT_ASR_MODEL` regardless).
|
||||
if model:
|
||||
local_config["voice_to_text_model"] = model
|
||||
file_cfg["voice_to_text_model"] = model
|
||||
self._write_file_config(file_cfg)
|
||||
logger.info(
|
||||
f"[ModelsHandler] asr updated: provider={provider_id!r} "
|
||||
f"model={model!r}"
|
||||
)
|
||||
self._refresh_voice_routing()
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"provider": provider_id,
|
||||
"model": local_config.get("voice_to_text_model", ""),
|
||||
})
|
||||
|
||||
def _set_tts(self, provider_id: str, model: str, voice: str = "") -> str:
|
||||
local_config = conf()
|
||||
file_cfg = self._read_file_config()
|
||||
@@ -2934,7 +3026,7 @@ class ChannelsHandler:
|
||||
],
|
||||
}),
|
||||
("wechat_kf", {
|
||||
"label": {"zh": "微信客服", "en": "WeCom Customer Service"},
|
||||
"label": {"zh": "微信客服", "en": "WeChat Customer Service"},
|
||||
"icon": "fa-headset",
|
||||
"color": "emerald",
|
||||
"fields": [
|
||||
@@ -3873,6 +3965,40 @@ class HistoryHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class MessageDeleteHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
web.header('Access-Control-Allow-Origin', '*')
|
||||
try:
|
||||
data = json.loads(web.data())
|
||||
session_id = data.get('session_id', '').strip()
|
||||
user_seq = data.get('user_seq')
|
||||
delete_user = data.get('delete_user', True)
|
||||
cascade = data.get('cascade', False)
|
||||
|
||||
if not session_id or user_seq is None:
|
||||
return json.dumps({"status": "error", "message": "session_id and user_seq required"})
|
||||
|
||||
# 1. Delete from database
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
deleted = store.delete_message_pair(session_id, int(user_seq), delete_user=delete_user, cascade=cascade)
|
||||
|
||||
# 2. Sync agent's in-memory context so its next turn sees the
|
||||
# same history as the DB. Handled by the agent_bridge helper.
|
||||
try:
|
||||
from bridge import Bridge
|
||||
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
||||
|
||||
return json.dumps({"status": "success", "deleted": deleted}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Message delete error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class LogsHandler:
|
||||
def GET(self):
|
||||
_require_auth()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 微信客服(WeCom Customer Service)通道
|
||||
# 微信客服(WeChat Customer Service)通道
|
||||
|
||||
> 与 `channel/wechatcom/`(企微自建应用)是两个**独立的 CoW 通道**:
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding=utf-8 -*-
|
||||
"""
|
||||
WeCom Customer Service (微信客服) channel for CoW.
|
||||
WeChat Customer Service (微信客服) channel for CoW.
|
||||
|
||||
Differences from `channel/wechatcom/` (企微自建应用):
|
||||
1. Audience: external WeChat users (not internal members).
|
||||
|
||||
@@ -19,9 +19,15 @@ def verify_server(data):
|
||||
nonce = data.nonce
|
||||
echostr = data.get("echostr", None)
|
||||
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写
|
||||
# Reject when token is empty: an empty token reduces signature verification
|
||||
# to a predictable hash over attacker-controlled values.
|
||||
if not token:
|
||||
raise web.Forbidden("wechatmp_token is not configured")
|
||||
check_signature(token, signature, timestamp, nonce)
|
||||
return echostr
|
||||
except InvalidSignatureException:
|
||||
raise web.Forbidden("Invalid signature")
|
||||
except web.Forbidden:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise web.Forbidden(str(e))
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.0.9
|
||||
2.1.0
|
||||
|
||||
@@ -275,7 +275,7 @@ def update(ctx):
|
||||
def status():
|
||||
"""Show CowAgent running status."""
|
||||
from cli import __version__
|
||||
from cli.utils import load_config_json, get_cli_language
|
||||
from cli.utils import load_config_json, get_cli_language, get_project_root
|
||||
|
||||
# get_cli_language() calls ensure_sys_path(), which adds the project root
|
||||
# to sys.path. Import `common` only AFTER that, otherwise it fails with
|
||||
@@ -292,6 +292,11 @@ def status():
|
||||
|
||||
click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}"))
|
||||
|
||||
# Project path bound to this `cow` CLI — disambiguates which checkout the
|
||||
# command actually controls when the user has multiple clones.
|
||||
project_root = get_project_root()
|
||||
click.echo(_t(f" 路径: {project_root}", f" Path: {project_root}"))
|
||||
|
||||
cfg = load_config_json()
|
||||
if cfg:
|
||||
channel = cfg.get("channel_type", "unknown")
|
||||
|
||||
@@ -34,7 +34,9 @@ chat_client: LinkAIClient
|
||||
|
||||
CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"}
|
||||
|
||||
# channelType -> config key mapping for app credentials
|
||||
# channelType -> config key mapping for app credentials.
|
||||
# secret_key may be "" for single-token channels (e.g. telegram/discord).
|
||||
# For slack, appId carries bot_token and appSecret carries app_token.
|
||||
CREDENTIAL_MAP = {
|
||||
"feishu": ("feishu_app_id", "feishu_app_secret"),
|
||||
"dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"),
|
||||
@@ -43,6 +45,9 @@ CREDENTIAL_MAP = {
|
||||
"wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||
"wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||
"wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"),
|
||||
"telegram": ("telegram_token", ""),
|
||||
"slack": ("slack_bot_token", "slack_app_token"),
|
||||
"discord": ("discord_token", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -357,7 +362,8 @@ class CloudClient(LinkAIClient):
|
||||
local_config[id_key] = app_id
|
||||
os.environ[id_key.upper()] = str(app_id)
|
||||
changed = True
|
||||
if app_secret is not None and local_config.get(secret_key) != app_secret:
|
||||
# secret_key may be empty for single-token channels (e.g. telegram/discord)
|
||||
if secret_key and app_secret is not None and local_config.get(secret_key) != app_secret:
|
||||
local_config[secret_key] = app_secret
|
||||
os.environ[secret_key.upper()] = str(app_secret)
|
||||
changed = True
|
||||
@@ -372,9 +378,10 @@ class CloudClient(LinkAIClient):
|
||||
return
|
||||
id_key, secret_key = cred
|
||||
local_config.pop(id_key, None)
|
||||
local_config.pop(secret_key, None)
|
||||
os.environ.pop(id_key.upper(), None)
|
||||
os.environ.pop(secret_key.upper(), None)
|
||||
if secret_key:
|
||||
local_config.pop(secret_key, None)
|
||||
os.environ.pop(secret_key.upper(), None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# channel_type list helpers
|
||||
@@ -862,25 +869,16 @@ def _build_config():
|
||||
if plugin_config.get("Godcmd"):
|
||||
config["admin_password"] = plugin_config.get("Godcmd").get("password")
|
||||
|
||||
# Add channel-specific app credentials
|
||||
# Add channel-specific app credentials based on CREDENTIAL_MAP.
|
||||
# For multi-channel channel_type (comma-separated), the first matched type wins.
|
||||
current_channel_type = local_conf.get("channel_type", "")
|
||||
if current_channel_type == "feishu":
|
||||
config["app_id"] = local_conf.get("feishu_app_id")
|
||||
config["app_secret"] = local_conf.get("feishu_app_secret")
|
||||
elif current_channel_type == "dingtalk":
|
||||
config["app_id"] = local_conf.get("dingtalk_client_id")
|
||||
config["app_secret"] = local_conf.get("dingtalk_client_secret")
|
||||
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
||||
config["app_id"] = local_conf.get("wechatmp_app_id")
|
||||
config["app_secret"] = local_conf.get("wechatmp_app_secret")
|
||||
elif current_channel_type == "wecom_bot":
|
||||
config["app_id"] = local_conf.get("wecom_bot_id")
|
||||
config["app_secret"] = local_conf.get("wecom_bot_secret")
|
||||
elif current_channel_type == "qq":
|
||||
config["app_id"] = local_conf.get("qq_app_id")
|
||||
config["app_secret"] = local_conf.get("qq_app_secret")
|
||||
elif current_channel_type == "wechatcom_app":
|
||||
config["app_id"] = local_conf.get("wechatcomapp_agent_id")
|
||||
config["app_secret"] = local_conf.get("wechatcomapp_secret")
|
||||
for ch_type in CloudClient._parse_channel_types({"channel_type": current_channel_type}):
|
||||
cred = CREDENTIAL_MAP.get(ch_type)
|
||||
if not cred:
|
||||
continue
|
||||
id_key, secret_key = cred
|
||||
config["app_id"] = local_conf.get(id_key)
|
||||
config["app_secret"] = local_conf.get(secret_key) if secret_key else ""
|
||||
break
|
||||
|
||||
return config
|
||||
|
||||
@@ -108,17 +108,15 @@ QWEN_LONG = "qwen-long"
|
||||
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
|
||||
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
||||
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
||||
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
||||
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
|
||||
QWQ_PLUS = "qwq-plus"
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
|
||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
||||
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
||||
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
||||
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
|
||||
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
|
||||
MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版
|
||||
MINIMAX_M2 = "MiniMax-M2" # MiniMax M2
|
||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
||||
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
||||
|
||||
# GLM (智谱AI)
|
||||
@@ -189,7 +187,7 @@ MODEL_LIST = [
|
||||
ERNIE_45_TURBO_VL, ERNIE_45_TURBO_VL_32K,
|
||||
|
||||
# MiniMax
|
||||
MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
|
||||
MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_ABAB6_5,
|
||||
|
||||
# 小米 MiMo
|
||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||
@@ -218,7 +216,7 @@ MODEL_LIST = [
|
||||
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
||||
|
||||
# Qwen (通义千问)
|
||||
QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||
|
||||
# Doubao (豆包)
|
||||
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||
|
||||
@@ -124,6 +124,8 @@ def detect_language():
|
||||
3. Python locale module
|
||||
4. default English
|
||||
"""
|
||||
if os.environ.get("CLOUD_DEPLOYMENT_ID"):
|
||||
return ZH
|
||||
return (
|
||||
_detect_from_macos()
|
||||
or _detect_from_env()
|
||||
|
||||
18
config.py
18
config.py
@@ -158,13 +158,13 @@ available_setting = {
|
||||
"wechatcomapp_secret": "", # WeCom app secret
|
||||
"wechatcomapp_agent_id": "", # WeCom app agent_id
|
||||
"wechatcomapp_aes_key": "", # WeCom app aes_key
|
||||
# WeCom Customer Service (wechat_kf) config
|
||||
"wechat_kf_corp_id": "", # corp_id of the company the WeCom Customer Service belongs to
|
||||
"wechat_kf_token": "", # WeCom Customer Service callback token
|
||||
"wechat_kf_port": 9888, # WeCom Customer Service callback service port
|
||||
"wechat_kf_secret": "", # WeCom Customer Service app secret
|
||||
"wechat_kf_aes_key": "", # WeCom Customer Service callback aes_key
|
||||
"wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # path for persisting the WeCom Customer Service sync_msg cursor
|
||||
# WeChat Customer Service (wechat_kf) config
|
||||
"wechat_kf_corp_id": "", # corp_id of the company the WeChat Customer Service belongs to
|
||||
"wechat_kf_token": "", # WeChat Customer Service callback token
|
||||
"wechat_kf_port": 9888, # WeChat Customer Service callback service port
|
||||
"wechat_kf_secret": "", # WeChat Customer Service app secret
|
||||
"wechat_kf_aes_key": "", # WeChat Customer Service callback aes_key
|
||||
"wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # path for persisting the WeChat Customer Service sync_msg cursor
|
||||
# Feishu config
|
||||
"feishu_port": 80, # Feishu bot listening port; only needed in webhook mode
|
||||
"feishu_app_id": "", # Feishu bot app id
|
||||
@@ -251,6 +251,10 @@ available_setting = {
|
||||
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
|
||||
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
||||
"knowledge": True, # whether to enable the knowledge base feature
|
||||
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
||||
"self_evolution_enabled": False, # master switch (off until release)
|
||||
"self_evolution_idle_minutes": 15, # idle time before a session is reviewed
|
||||
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
||||
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
|
||||
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ The table below summarizes the inbound message types, bot reply types, and group
|
||||
| [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
|
||||
| [WeCom App](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
|
||||
| [Official Account](/channels/wechatmp) | ✅ | ✅ | | ✅ | |
|
||||
| [WeChat Customer Service](/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
|
||||
| [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ |
|
||||
| [Discord](/channels/discord) | ✅ | ✅ | ✅ | | ✅ |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: WeCom Customer Service
|
||||
description: Integrate CowAgent into WeCom Customer Service (微信客服)
|
||||
title: WeChat Customer Service
|
||||
description: Integrate CowAgent into WeChat Customer Service
|
||||
---
|
||||
|
||||
By binding a WeCom custom enterprise app to a WeCom Customer Service (微信客服) account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores.
|
||||
By binding a WeCom custom enterprise app to a WeChat Customer Service account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores.
|
||||
|
||||
<Note>
|
||||
WeCom Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported.
|
||||
WeChat Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported.
|
||||
</Note>
|
||||
|
||||
## 1. Prerequisites
|
||||
@@ -15,7 +15,7 @@ Required resources:
|
||||
|
||||
1. A server with a public IP
|
||||
2. A registered and verified WeCom account
|
||||
3. WeCom Customer Service capability enabled
|
||||
3. WeChat Customer Service capability enabled
|
||||
|
||||
<Note>
|
||||
It is recommended to create a **dedicated** WeCom custom app for Customer Service rather than reusing the existing `wechatcom_app` one — otherwise the two channels will compete for the same callback URL.
|
||||
@@ -49,7 +49,7 @@ Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Web Console">
|
||||
Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeCom Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect.
|
||||
Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeChat Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect.
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/cow-weixinkefu-web-control.png" width="800"/>
|
||||
</Tab>
|
||||
@@ -92,9 +92,9 @@ Then go back to **Receive Messages → Set API Reception** in the WeCom console
|
||||
3. Verified WeCom accounts must use a filed domain matching the entity
|
||||
</Warning>
|
||||
|
||||
## 4. Bind a WeCom Customer Service Account
|
||||
## 4. Bind a WeChat Customer Service Account
|
||||
|
||||
In the WeCom Admin Console, go to **WeCom Customer Service**, create a customer service account, and bind it to the custom app you created above:
|
||||
In the WeCom Admin Console, go to **WeChat Customer Service**, create a customer service account, and bind it to the custom app you created above:
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step1.jpg" width="600"/>
|
||||
|
||||
@@ -102,7 +102,7 @@ In the WeCom Admin Console, go to **WeCom Customer Service**, create a customer
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step3.jpg" width="600"/>
|
||||
|
||||
After binding, go to **WeCom Customer Service → Account Details**, and under **"Access Link"**:
|
||||
After binding, go to **WeChat Customer Service → Account Details**, and under **"Access Link"**:
|
||||
|
||||
- Click **"Copy Link"** to get an access link like `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be`
|
||||
- Click **"Generate QR Code"** to get the corresponding QR code
|
||||
@@ -117,7 +117,7 @@ After WeChat users enter the customer service conversation via the link or QR co
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-chat-demo.jpg" width="900"/>
|
||||
|
||||
Beyond that, leveraging the official WeChat ecosystem, WeCom Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeCom Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details:
|
||||
Beyond that, leveraging the official WeChat ecosystem, WeChat Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeChat Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details:
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-interface-demo.png" width="800"/>
|
||||
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
"pages": [
|
||||
"memory/index",
|
||||
"memory/context",
|
||||
"memory/deep-dream"
|
||||
"memory/deep-dream",
|
||||
"memory/self-evolution"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -240,6 +241,7 @@
|
||||
"group": "Release Notes",
|
||||
"pages": [
|
||||
"releases/overview",
|
||||
"releases/v2.1.0",
|
||||
"releases/v2.0.9",
|
||||
"releases/v2.0.8",
|
||||
"releases/v2.0.7",
|
||||
@@ -392,7 +394,8 @@
|
||||
"pages": [
|
||||
"zh/memory/index",
|
||||
"zh/memory/context",
|
||||
"zh/memory/deep-dream"
|
||||
"zh/memory/deep-dream",
|
||||
"zh/memory/self-evolution"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -453,6 +456,7 @@
|
||||
"group": "发布记录",
|
||||
"pages": [
|
||||
"zh/releases/overview",
|
||||
"zh/releases/v2.1.0",
|
||||
"zh/releases/v2.0.9",
|
||||
"zh/releases/v2.0.8",
|
||||
"zh/releases/v2.0.7",
|
||||
@@ -605,7 +609,8 @@
|
||||
"pages": [
|
||||
"ja/memory/index",
|
||||
"ja/memory/context",
|
||||
"ja/memory/deep-dream"
|
||||
"ja/memory/deep-dream",
|
||||
"ja/memory/self-evolution"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -666,6 +671,7 @@
|
||||
"group": "リリースノート",
|
||||
"pages": [
|
||||
"ja/releases/overview",
|
||||
"ja/releases/v2.1.0",
|
||||
"ja/releases/v2.0.9",
|
||||
"ja/releases/v2.0.8",
|
||||
"ja/releases/v2.0.7",
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/CowAgent" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square" alt="Stars"></a> <br/>
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
[<a href="../../README.md">English</a>] | [<a href="../zh/README.md">中文</a>] | [日本語]
|
||||
</p>
|
||||
|
||||
@@ -98,11 +106,11 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/ja/models/linkai) | 1 つの Key で 100+ モデルに接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -190,6 +198,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
||||
|
||||
## 🏷 更新履歴
|
||||
|
||||
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国際化対応、新チャネル(Telegram、Discord、Slack、WeChat カスタマーサービス)、CLI インタラクション強化、ワンライナーインストールの最適化、MCP Streamable HTTP 対応、新モデル(claude-opus-4-8、MiMo)。
|
||||
|
||||
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — モデル管理、MCP プロトコル対応、ブラウザセッション永続化、新モデル(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、デプロイのセキュリティ強化。
|
||||
|
||||
> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu チャネル全面アップグレード(音声、ストリーミング、QR 接続)、DeepSeek V4 と Baidu Qianfan 対応、スケジューラツール強化。
|
||||
@@ -236,9 +246,9 @@ GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) する
|
||||
|
||||
## 🛠️ 開発とコントリビューション
|
||||
|
||||
新しいチャネルの追加を歓迎します — [Feishu チャネル](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py) を参考にカスタムチャネルを実装できます。新しい Skill のコントリビューションも [Skill Hub](https://skills.cowagent.ai/submit) で受け付けています。
|
||||
あらゆる形のコントリビューションを歓迎します —— 新機能、バグ修正、パフォーマンス改善、ドキュメント、あるいは [Skill Hub](https://skills.cowagent.ai/submit) への Skill の共有など。まずは [CONTRIBUTING.md](/CONTRIBUTING.md) をご覧いただき、Issue で相談するか、直接 PR を送ってください。
|
||||
|
||||
⭐ Star でプロジェクトの更新をフォローしてください。PR や Issue の提出も歓迎します。
|
||||
⭐ Star でプロジェクトを応援し、Watch → Custom → Releases で新バージョンの通知を受け取れます。PR や Issue の提出も歓迎します。
|
||||
|
||||
## 🌟 コントリビューター
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ CowAgent は複数のチャットチャネルへの接続に対応しており
|
||||
| [QQ](/ja/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
|
||||
| [WeCom アプリ](/ja/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
|
||||
| [WeChat 公式アカウント](/ja/channels/wechatmp) | ✅ | ✅ | | ✅ | |
|
||||
| [WeChat カスタマーサービス](/ja/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
|
||||
|
||||
- **画像 / ファイル / 音声**列は対応するメッセージタイプの送受信に対応していることを示します。詳細は各チャネルのドキュメントを参照してください
|
||||
- **グループチャット**列はグループメッセージを認識して応答できることを示します
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: WeChat カスタマーサービス
|
||||
description: CowAgent を 微信客服(WeCom Customer Service)に統合する
|
||||
title: WeChat Customer Service
|
||||
description: CowAgent を WeChat Customer Service に統合する
|
||||
---
|
||||
|
||||
WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカウントにバインドすることで、CowAgent は外部 WeChat ユーザーからの問い合わせを引き受けることができます。WeChat ミニプログラム、公式アカウント、ビデオチャンネル、ビデオチャンネルストアなどから、リンクや QR コードで WeChat ユーザーに到達できます。
|
||||
WeCom の自建アプリを WeChat Customer Service アカウントにバインドすることで、CowAgent は外部 WeChat ユーザーからの問い合わせを引き受けることができます。WeChat ミニプログラム、公式アカウント、ビデオチャンネル、ビデオチャンネルストアなどから、リンクや QR コードで WeChat ユーザーに到達できます。
|
||||
|
||||
<Note>
|
||||
WeChat カスタマーサービスは Docker デプロイまたはサーバー Python デプロイのみサポートしており、外部からアクセス可能なコールバック URL が必要です。ローカル実行モードには対応していません。
|
||||
WeChat Customer Service は Docker デプロイまたはサーバー Python デプロイのみサポートしており、外部からアクセス可能なコールバック URL が必要です。ローカル実行モードには対応していません。
|
||||
</Note>
|
||||
|
||||
## 1. 前提条件
|
||||
@@ -15,7 +15,7 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ
|
||||
|
||||
1. パブリック IP を持つサーバー
|
||||
2. 登録済みかつ認証済みの WeCom アカウント
|
||||
3. 「微信客服」機能が有効になっていること
|
||||
3. WeChat Customer Service 機能が有効になっていること
|
||||
|
||||
<Note>
|
||||
カスタマーサービス専用に **新たな** 企業微信自建アプリを作成することを推奨します。既存の `wechatcom_app` アプリを流用すると、2 つのチャネルが同じコールバック URL を奪い合うことになります。
|
||||
@@ -49,7 +49,7 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Web コンソール">
|
||||
Cow プロジェクトを起動した後、Web コンソールを開きます。**チャネル** メニューを選択し、**接入チャネル** をクリックし、**微信客服** を選択して、Corp ID / Secret / Token / AES Key を入力し(ポートはデフォルト 9888、変更可能)、接入をクリックします。
|
||||
Cow プロジェクトを起動した後、Web コンソールを開きます。**チャネル** メニューを選択し、**接入チャネル** をクリックし、**WeChat Customer Service** を選択して、Corp ID / Secret / Token / AES Key を入力し(ポートはデフォルト 9888、変更可能)、接入をクリックします。
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/cow-weixinkefu-web-control.png" width="800"/>
|
||||
</Tab>
|
||||
@@ -92,9 +92,9 @@ WeCom の自建アプリを「微信客服(WeCom Customer Service)」アカ
|
||||
3. 認証済みの WeCom アカウントは、法人に対応する届け出済みドメインを設定する必要があります
|
||||
</Warning>
|
||||
|
||||
## 4. 微信客服アカウントとのバインド
|
||||
## 4. WeChat Customer Service アカウントとのバインド
|
||||
|
||||
WeCom 管理コンソールの **微信客服** ページに入り、カスタマーサービスアカウントを作成し、上で作成した企業微信自建アプリとバインドします:
|
||||
WeCom 管理コンソールの **WeChat Customer Service** ページに入り、カスタマーサービスアカウントを作成し、上で作成した WeCom 自建アプリとバインドします:
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step1.jpg" width="600"/>
|
||||
|
||||
@@ -102,7 +102,7 @@ WeCom 管理コンソールの **微信客服** ページに入り、カスタ
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step3.jpg" width="600"/>
|
||||
|
||||
バインド完了後、**微信客服 → 微信客服アカウント詳細** に入り、「**接入リンク**」の項目で:
|
||||
バインド完了後、**WeChat Customer Service → WeChat Customer Service アカウント詳細** に入り、「**接入リンク**」の項目で:
|
||||
|
||||
- 「**リンクをコピー**」をクリックすると、`https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` のような接入リンクが取得できます
|
||||
- 「**QR コード生成**」をクリックすると、対応する QR コードが取得できます
|
||||
@@ -117,7 +117,7 @@ WeChat ユーザーがリンクや QR コードからカスタマーサービス
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-chat-demo.jpg" width="900"/>
|
||||
|
||||
これに加え、WeChat 公式エコシステムの機能に基づき、微信客服を公式アカウント、ミニプログラム、ビデオチャンネルなどの場面でも使用できます。詳細は [WeCom 管理コンソール](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) の **微信客服 → 接入シナリオ** を参照してください:
|
||||
これに加え、WeChat 公式エコシステムの機能に基づき、WeChat Customer Service を公式アカウント、ミニプログラム、ビデオチャンネルなどの場面でも使用できます。詳細は [WeCom 管理コンソール](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) の **WeChat Customer Service → 接入シナリオ** を参照してください:
|
||||
|
||||
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-interface-demo.png" width="800"/>
|
||||
|
||||
|
||||
78
docs/ja/memory/self-evolution.mdx
Normal file
78
docs/ja/memory/self-evolution.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: 自律進化
|
||||
description: Self-Evolution — 会話がアイドル状態になった後に振り返り、記憶を蓄積し、スキルを改善し、未完了のタスクに対応する
|
||||
---
|
||||
|
||||
## 機能概要
|
||||
|
||||
### はじめに
|
||||
|
||||
自律進化(Self-Evolution)は、Agent が単発のタスクをこなすだけでなく、あなたとのやり取りを通じて成長し続けられるようにする仕組みです。会話が一段落すると、Agent は静かに振り返りを行います。覚えておくべきことを長期記憶に保存し、スキルで見つかった問題を修正し、やり残したタスクを引き継いで進めます。使い込むほど、Agent はあなたの好みを理解し、同じ失敗を繰り返さなくなり、自分から物事を仕上げるようになります。これらはすべてバックグラウンドで静かに行われ、実際に何かを行ったときだけ簡潔に知らせます。
|
||||
|
||||
> 自律進化は[夢境蒸留](/ja/memory/deep-dream)と補完し合います。夢境蒸留が記憶そのものを整理するのに対し、自律進化はさらに一歩進んでスキルを改善し、未完了のタスクを前に進め、日々の利用を通じて Agent の能力を磨きます。
|
||||
|
||||
### 3 つの目標
|
||||
|
||||
自律進化は次の 3 つを軸に動きます:
|
||||
|
||||
| 目標 | 説明 |
|
||||
| --- | --- |
|
||||
| **記憶の蓄積** | 会話中の重要な好み、決定、事実を記憶に補い、メインの会話の取りこぼしを補完します |
|
||||
| **スキルの改善** | スキルの利用中に問題(設定の誤りや手順の欠落など)が見つかったら、メモを残すだけでなくスキルファイルを直接修正します。必要に応じて新しいスキルも作成します |
|
||||
| **未完了タスクへの対応** | 会話に残ったやるべきことを見つけ、可能なときにその場で完了させます |
|
||||
|
||||
振り返りが終わり、実際に変更を加えた場合は、Agent が「何を学び、どこを調整したか」を会話の中で一言で伝えるので、元に戻すかどうかを判断できます。
|
||||
|
||||
## 使い方
|
||||
|
||||
### トリガーのタイミング
|
||||
|
||||
自律進化は定時実行ではなく、**会話が自然に終わってアイドル状態になった後**にのみ起動するため、進行中のやり取りを妨げることはありません。次の 2 つの条件を同時に満たす必要があります:
|
||||
|
||||
- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 15 分)以上が経過している
|
||||
- **振り返るだけの内容がある**:前回の進化から十分なターン数が蓄積されている、またはコンテキストが容量の上限に近づいている
|
||||
|
||||
両方の条件を満たしたときにのみ振り返りが始まります。これにより、振り返る価値のある内容を確保しつつ、会話の途中で邪魔をしないようにしています。
|
||||
|
||||
### 関連設定
|
||||
|
||||
自律進化はデフォルトでは無効です。Web コンソールの「設定 → Agent 設定」(「ディープシンキング」の下)にあるスイッチで有効にできるほか、設定ファイルで調整することもできます:
|
||||
|
||||
| パラメータ | 説明 | デフォルト値 |
|
||||
| --- | --- | --- |
|
||||
| `self_evolution_enabled` | 自律進化を有効にするかどうか | `false` |
|
||||
| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `15` |
|
||||
| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `6` |
|
||||
|
||||
<Tip>
|
||||
Web コンソールでは有効・無効のスイッチのみを提供しています。アイドル時間やターン数のしきい値を変更したい場合は、設定ファイルを編集してください。変更は即時に反映され、再起動は不要です。
|
||||
</Tip>
|
||||
|
||||
### 進化の記録
|
||||
|
||||
各振り返りは日付ごとに `memory/evolution/YYYY-MM-DD.md` に記録され、Web コンソールの「メモリ管理 → 自律進化」タブで確認できます。このタブには自律進化の記録と夢日記の両方がまとめられており、Agent の成長の軌跡を一箇所で振り返ることができます。
|
||||
|
||||
### 元に戻す方法
|
||||
|
||||
ある振り返りの変更に納得できない場合は、会話の中で Agent に「直前の変更を取り消して」と伝えるだけで、振り返り前のバックアップから該当ファイルを復元します。各振り返りはそれぞれ独立したバックアップを持つため、互いに干渉することはありません。
|
||||
|
||||
## 設計
|
||||
|
||||
自律進化はシステムの既存の機能を再利用しており、軽量に保たれています:
|
||||
|
||||
- **隔離実行**:各振り返りは独立した短命のタスクとして実行されます。メインの会話と同じモデルを使いますが、ツールは制限されています(コンテキストの読み取りと、記憶およびスキルファイルの編集のみ可能)。メインの会話のコンテキストを汚さず、その動作にも影響しません。
|
||||
- **バックアップによる取り消し**:振り返り前に該当ファイルのスナップショットを取り、取り消し時にそのスナップショットから復元するため、すべての変更が追跡可能で元に戻せます。
|
||||
- **変更検知**:振り返り後にファイルのスナップショットを比較して実際に変更があったかを確認し、それをもとに通知するかどうかを判断します。これにより「何もしなければ通知しない」ことを仕組みとして保証します。
|
||||
|
||||
### 抑制と安全性
|
||||
|
||||
自律進化は、必要なときに動き、それ以外のときは邪魔をしないように設計されています:
|
||||
|
||||
| 仕組み | 説明 |
|
||||
| --- | --- |
|
||||
| **何もしなければ通知しない** | 振り返りで実際の変更がなければ、静かなままで何も送りません |
|
||||
| **アイドル時のみトリガー** | 会話がアイドル状態になったときだけ実行し、進行中の会話を妨げません |
|
||||
| **変更を元に戻せる** | 振り返りごとに事前にバックアップを取るため、納得できない結果は取り消せます |
|
||||
| **組み込みスキルの保護** | 製品に付属する組み込みスキルは保護され、変更されません |
|
||||
| **ワークスペースに限定** | すべての読み書きはワークスペース内に限定され、他のシステムファイルには触れません |
|
||||
| **バックグラウンド実行** | 振り返りはバックグラウンドで実行され、通常の返信を妨げません |
|
||||
@@ -61,7 +61,7 @@ description: Coding Planモデルの設定
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"model": "MiniMax-M3",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
@@ -69,7 +69,7 @@ description: Coding Planモデルの設定
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` |
|
||||
| `model` | `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` |
|
||||
| `open_ai_api_base` | 中国: `https://api.minimaxi.com/v1`、グローバル: `https://api.minimax.io/v1` |
|
||||
| `open_ai_api_key` | Coding Plan専用キー(従量課金とは共有不可) |
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||
|
||||
<Note>
|
||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-5.1。
|
||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.7-plus、kimi-k2.6、ernie-5.1。
|
||||
|
||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||
</Note>
|
||||
@@ -19,12 +19,12 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
||||
| ベンダー | 代表モデル | テキスト | 画像理解 | 画像生成 | 音声認識 | 音声合成 | ベクトル |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Zhipu GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
|
||||
@@ -40,7 +40,7 @@ description: LinkAI プラットフォーム経由でテキスト、ビジョン
|
||||
}
|
||||
```
|
||||
|
||||
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.6-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` など。
|
||||
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` など。
|
||||
|
||||
## 画像生成
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ MiniMax はテキスト対話、画像理解、画像生成、音声合成をサ
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "MiniMax-M2.7",
|
||||
"model": "MiniMax-M3",
|
||||
"minimax_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | `MiniMax-M2.7`、`MiniMax-M2.7-highspeed`、`MiniMax-M2.5`、`MiniMax-M2.1`、`MiniMax-M2.1-lightning`、`MiniMax-M2` などを指定可能 |
|
||||
| `model` | `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` などを指定可能 |
|
||||
| `minimax_api_key` | [MiniMax コンソール](https://platform.minimaxi.com/user-center/basic-information/interface-key) で作成 |
|
||||
|
||||
## 画像理解
|
||||
|
||||
@@ -13,19 +13,19 @@ Tongyi Qianwen(DashScope / Bailian)は国内で最も広範な機能をカ
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "qwen3.6-plus",
|
||||
"model": "qwen3.7-plus",
|
||||
"dashscope_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | `qwen3.6-plus`、`qwen3.7-max`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` などを指定可能 |
|
||||
| `model` | `qwen3.7-plus`、`qwen3.7-max`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` などを指定可能 |
|
||||
| `dashscope_api_key` | [Bailian コンソール](https://bailian.console.aliyun.com/?tab=model#/api-key) で作成。詳細は [公式ドキュメント](https://bailian.console.aliyun.com/?tab=api#/api) を参照 |
|
||||
|
||||
## 画像理解
|
||||
|
||||
`dashscope_api_key` を設定すると、Agent の Vision ツールは自動的に Qwen のビジョンモデルを呼び出して画像を認識します。`qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` などのモデルはそのままマルチモーダルです。メインモデルがテキスト専用(`qwen-turbo` など)の場合は、自動的に `qwen-vl-max` にフォールバックします。
|
||||
`dashscope_api_key` を設定すると、Agent の Vision ツールは自動的に Qwen のビジョンモデルを呼び出して画像を認識します。`qwen3.7-plus` / `qwen3.6-plus` / `qwen3.5-plus` / `qwen3-max` などのモデルはそのままマルチモーダルです。メインモデルがテキスト専用(`qwen-turbo` など)の場合は、自動的に `qwen-vl-max` にフォールバックします。
|
||||
|
||||
Vision モデルを手動で指定したい場合:
|
||||
|
||||
@@ -33,13 +33,13 @@ Vision モデルを手動で指定したい場合:
|
||||
{
|
||||
"tools": {
|
||||
"vision": {
|
||||
"model": "qwen3.6-plus"
|
||||
"model": "qwen3.7-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
サポートするモデル:`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。
|
||||
サポートするモデル:`qwen3.7-plus`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。
|
||||
|
||||
## 画像生成
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
|
||||
|
||||
| バージョン | 日付 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化(ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル)、MCP Streamable HTTP、新モデル追加 |
|
||||
| [2.0.9](/ja/releases/v2.0.9) | 2026.05.22 | モデル管理機能の追加、MCP プロトコル対応、ブラウザログイン状態の永続化、新モデル追加(gpt-5.5、gemini-3.5-flash、qwen3.7-max など)、デプロイ・セキュリティ強化 |
|
||||
| [2.0.8](/ja/releases/v2.0.8) | 2026.05.06 | Feishu チャネル全面アップグレード(音声、ストリーミング出力と Markdown、QR コードによるワンクリック接続)、DeepSeek V4 と百度モデルの追加、スケジュールタスクツールの強化 |
|
||||
| [2.0.7](/ja/releases/v2.0.7) | 2026.04.22 | 画像生成スキル(6 プロバイダー自動ルーティング)、新モデル対応(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、ナレッジベース強化、Web コンソール最適化 |
|
||||
|
||||
69
docs/ja/releases/v2.1.0.mdx
Normal file
69
docs/ja/releases/v2.1.0.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: v2.1.0
|
||||
description: CowAgent 2.1.0 - 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化、MCP プロトコル強化、新モデル追加
|
||||
---
|
||||
|
||||
🌐 [English](https://docs.cowagent.ai/releases/v2.1.0) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.0)
|
||||
|
||||
## 📱 接続チャネルの追加
|
||||
|
||||
今回、主要プラットフォームのチャネルを複数追加。設定すればすぐ使える、すぐ接続可能です:
|
||||
|
||||
- **Telegram Bot**:Telegram ボットに接続、テキストとマルチメディアメッセージに対応
|
||||
- **Discord Bot**:Discord ボットに接続、チャンネルおよび DM で対話可能
|
||||
- **Slack Bot**:Slack ボットに接続、チームのワークフローに統合
|
||||
- **WeChat カスタマーサービス**:WeChat カスタマーサービスチャネルを新設。画像・ファイルの受信に対応し、自動で次のターンに統合。マルチメディアのコンテキスト体験が他チャネルと同等に。Thanks [@6vision](https://github.com/6vision) (#2840)
|
||||
|
||||
ドキュメント:[チャネル概要](https://docs.cowagent.ai/ja/channels)
|
||||
|
||||
## 🌍 国際化対応
|
||||
|
||||
CowAgent は全世界の開発者に向けたエンドツーエンドの国際化フレームワークを導入。システム言語に応じて自動で適応します:
|
||||
|
||||
- **エンドツーエンドのローカライズ**:インストールガイド、CLI、ログとエラー、Agent システムプロンプトなどがローカライズに対応
|
||||
- **言語の自動判定**:デフォルトの `auto` モードはシステムロケールから判定。`config.json` の `cow_lang` で明示的に指定も可能。まずは英語と中国語に対応し、今後さらに多くの言語を拡充予定
|
||||
- **コンソールでワンクリック切り替え**:Web コンソールでシステム言語をオンライン切り替え可能、リアルタイムで反映
|
||||
|
||||
## ⌨️ CLI インタラクション強化
|
||||
|
||||
- **ワンライナーインストールの最適化**:インストールスクリプトを簡素化し、対話的なセットアップに対応——言語を選択でき、ガイドに沿ってモデルとチャンネルも任意で選択可能。数分でデプロイして利用開始できます
|
||||
- **ストリーミング出力**:Terminal チャネルが Agent モードの推論過程、ツール呼び出し、ストリーミング返信をリアルタイムに表示
|
||||
- **コマンドのあいまいマッチング**:コマンドの省略形や近似タイポの自動サジェストに対応。よく使うショートカットを内蔵し、設定ファイルでカスタムエイリアスも定義可能。Thanks [@lyteen](https://github.com/lyteen) (#2850)
|
||||
- **タスクのキャンセル対応**:実行中の Agent タスクを能動的に中断可能。Web 端に中止ボタンを追加、その他のチャネルでは `/cancel` を送信して中止
|
||||
|
||||
ドキュメント:[CLI ガイド](https://docs.cowagent.ai/ja/cli/general)
|
||||
|
||||
## 🧩 MCP プロトコル強化
|
||||
|
||||
MCP ツールが **Streamable HTTP** 伝送に新対応。既存の `stdio`・`sse` に加え、より多くの MCP サービスに互換となり、ストリーミング HTTP プロトコルを用いるリモートツールへ直接接続できます。
|
||||
|
||||
ドキュメント:[MCP ツール](https://docs.cowagent.ai/ja/tools/mcp)
|
||||
|
||||
## 🤖 モデル追加と最適化
|
||||
|
||||
- **モデル新規追加**:`claude-opus-4-8`、Xiaomi `MiMo`
|
||||
- **モデル最適化**:一部モデルが返すツール呼び出し引数の JSON 解析に失敗する問題を修正 (#2823)
|
||||
|
||||
ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models)
|
||||
|
||||
## 🧠 記憶と検索の最適化
|
||||
|
||||
- **キーワード検索の最適化**:中国語キーワード検索のヒット率が低い問題、純英語キーワードでクエリ結果が空になる問題を修正
|
||||
- **ベクトル検索の強化**:ベクトル検索フローを最適化し、Python バージョン互換性を向上
|
||||
|
||||
Thanks [@yangluxin613](https://github.com/yangluxin613) (#2832)
|
||||
|
||||
## 🛠 体験改善と修正
|
||||
|
||||
- **ファイルアクセスの限定**:Web 端のファイル読み取り・送信をデフォルトでユーザーホームディレクトリと Agent ワークスペース内に限定し、任意ファイル読み取りを防止。`web_file_serve_root` で範囲を拡張可能
|
||||
- **スケジュールタスクの安定化**:個人 WeChat チャネルで再起動後にスケジュールタスクの配信が失効する問題を修正
|
||||
- **ブラウザツール**:ナビゲーション URL の非 HTTP スキームが破棄される問題を修正、ブラウザのメモリ使用量を最適化
|
||||
- **WeChat 公式アカウント**:パッシブ返信でキャッシュ済みテキストセグメントの結合、タスク実行中の準備完了セグメントの先行配信、ローカル `file://` 画像の送信に対応。Thanks [@6vision](https://github.com/6vision) (#2848)
|
||||
- **WeCom ボットの応答高速化**:コールバックを非同期ディスパッチに変更し、WeCom の 5 秒タイムアウトによるメッセージ欠落を回避
|
||||
- **ログインの堅牢化**:`web_password` が文字列でない場合にログインエラーになる問題を修正
|
||||
|
||||
## 📦 アップグレード方法
|
||||
|
||||
ソースコードデプロイは `cow update` でワンクリックアップグレード、または最新コードを手動で pull して再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照。
|
||||
|
||||
**リリース日**:2026.06.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.9...2.1.0)
|
||||
@@ -19,7 +19,7 @@ Vision ツールは多段階の自動選択 + 自動フォールバック戦略
|
||||
| プロバイダー | ビジョンモデル | 説明 |
|
||||
| --- | --- | --- |
|
||||
| OpenAI / 互換プロトコル | メインモデルを使用 | すべての OpenAI 互換マルチモーダルモデルに対応 |
|
||||
| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.6-plus など |
|
||||
| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.7-plus など |
|
||||
| Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 |
|
||||
| Gemini | メインモデルを使用 | inlineData 形式 |
|
||||
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-0 シリーズがネイティブ対応 |
|
||||
|
||||
78
docs/memory/self-evolution.mdx
Normal file
78
docs/memory/self-evolution.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Self-Evolution
|
||||
description: Self-Evolution — review a conversation after it goes idle to consolidate memory, improve skills, and follow up on unfinished tasks
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### Introduction
|
||||
|
||||
Self-Evolution lets the Agent do more than finish one task at a time; it keeps improving as it works with you. After a conversation winds down, it quietly reviews what just happened: it saves anything worth remembering into long-term memory, fixes problems that surfaced in a skill, and picks up tasks that were left unfinished. Over time the Agent learns your preferences, repeats fewer mistakes, and gets better at wrapping things up on its own. All of this runs in the background, and it only tells you when it actually did something.
|
||||
|
||||
> Self-Evolution complements [Deep Dream](/memory/deep-dream). Deep Dream organizes memory itself, while Self-Evolution goes a step further to improve skills and push unfinished tasks forward, sharpening the Agent's abilities through everyday use.
|
||||
|
||||
### Three Goals
|
||||
|
||||
Self-Evolution focuses on three things:
|
||||
|
||||
| Goal | Description |
|
||||
| --- | --- |
|
||||
| **Consolidate memory** | Record important preferences, decisions, and facts from the conversation, filling in what the main chat may have missed |
|
||||
| **Improve skills** | When a skill shows a problem in use (such as a wrong setting or a missing step), fix the skill file directly instead of just noting it; create a new skill when one is genuinely needed |
|
||||
| **Follow up on unfinished tasks** | Spot the to-dos left in a conversation and finish them when possible |
|
||||
|
||||
Once a review is done, if it actually changed something, the Agent tells you in a single line what it just learned and what it adjusted, so you can decide whether to roll it back.
|
||||
|
||||
## Usage
|
||||
|
||||
### When It Triggers
|
||||
|
||||
Self-Evolution does not run on a fixed schedule. It only kicks in **after a conversation naturally ends and goes idle**, so it never interrupts an ongoing exchange. Two conditions must both hold:
|
||||
|
||||
- **The conversation is idle**: more time has passed since the last interaction than the configured idle window (15 minutes by default)
|
||||
- **There is enough to review**: enough turns have accumulated since the last evolution, or the context is close to its capacity
|
||||
|
||||
Only when both are met does a review begin. This makes sure there is something worth reviewing while keeping it from bothering you mid-conversation.
|
||||
|
||||
### Configuration
|
||||
|
||||
Self-Evolution is off by default. You can turn it on with the toggle in the Web console under **Settings → Agent Config** (below "Deep Thinking"), or adjust it in the config file:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `self_evolution_enabled` | Whether Self-Evolution is enabled | `false` |
|
||||
| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `15` |
|
||||
| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `6` |
|
||||
|
||||
<Tip>
|
||||
The Web console only exposes the on/off toggle. To change the idle window or the turn threshold, edit the config file. Changes take effect immediately, with no restart needed.
|
||||
</Tip>
|
||||
|
||||
### Evolution Records
|
||||
|
||||
Each review is recorded by date in `memory/evolution/YYYY-MM-DD.md`, viewable in the Web console under the **Memory → Self-Evolution** tab. That tab gathers both self-evolution records and dream diaries in one place, so you can look back on how the Agent has grown.
|
||||
|
||||
### Rolling Back
|
||||
|
||||
If you disagree with a change from a review, just tell the Agent in chat to undo the last change. It restores the affected files from the backup taken before the review. Every review keeps its own backup, so they never interfere with each other.
|
||||
|
||||
## Design
|
||||
|
||||
Self-Evolution reuses what the system already has, which keeps it lightweight:
|
||||
|
||||
- **Isolated execution**: each review runs as a separate, short-lived task. It uses the same model as the main chat but with a restricted toolset (it can only read context and edit memory and skill files). It does not pollute the main chat's context or affect its performance.
|
||||
- **Backup-based undo**: the relevant files are snapshotted before a review and restored from that snapshot on undo, so every change is traceable and reversible.
|
||||
- **Change detection**: after a review, the system compares file snapshots to see whether anything actually changed, and uses that to decide whether to notify you. This is how it guarantees, at the engineering level, that no work means no message.
|
||||
|
||||
### Restraint and Safety
|
||||
|
||||
Self-Evolution is built to act when needed and stay out of the way otherwise:
|
||||
|
||||
| Mechanism | Description |
|
||||
| --- | --- |
|
||||
| **No work, no notification** | If a review produces no real change, it stays silent and sends nothing |
|
||||
| **Triggers only when idle** | It runs only after the conversation is idle, never interrupting an active one |
|
||||
| **Reversible changes** | A backup is taken before every review, so you can undo a result you do not like |
|
||||
| **Built-in skills protected** | The skills shipped with the product are protected and never modified |
|
||||
| **Workspace-scoped** | All reads and writes stay inside the workspace and never touch other system files |
|
||||
| **Runs in the background** | Reviews run in the background and do not block normal replies |
|
||||
@@ -61,7 +61,7 @@ Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-qui
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"model": "MiniMax-M3",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
@@ -69,7 +69,7 @@ Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-qui
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2` |
|
||||
| `model` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` |
|
||||
| `open_ai_api_base` | China: `https://api.minimaxi.com/v1`; Global: `https://api.minimax.io/v1` |
|
||||
| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) |
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model;
|
||||
| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Qwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
|
||||
@@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v
|
||||
}
|
||||
```
|
||||
|
||||
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.6-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc.
|
||||
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc.
|
||||
|
||||
## Image Generation
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ MiniMax supports text chat, image understanding, image generation, and text-to-s
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "MiniMax-M2.7",
|
||||
"model": "MiniMax-M3",
|
||||
"minimax_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | Can be `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.1`, `MiniMax-M2.1-lightning`, `MiniMax-M2`, etc. |
|
||||
| `model` | Can be `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, etc. |
|
||||
| `minimax_api_key` | Create one in the [MiniMax Console](https://platform.minimaxi.com/user-center/basic-information/interface-key) |
|
||||
|
||||
## Image Understanding
|
||||
|
||||
@@ -13,19 +13,19 @@ Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Te
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "qwen3.6-plus",
|
||||
"model": "qwen3.7-plus",
|
||||
"dashscope_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | Can be `qwen3.6-plus`, `qwen3.7-max`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. |
|
||||
| `model` | Can be `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`, `qwen-max`, `qwen-plus`, `qwen-turbo`, `qwq-plus`, etc. |
|
||||
| `dashscope_api_key` | Create one in the [Bailian Console](https://bailian.console.aliyun.com/?tab=model#/api-key); see the [official docs](https://bailian.console.aliyun.com/?tab=api#/api) |
|
||||
|
||||
## Image Understanding
|
||||
|
||||
Once `dashscope_api_key` is configured, the Agent's Vision tool automatically calls Qwen's vision models to recognize images. Models like `qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` are already multimodal; if the main model is text-only (e.g. `qwen-turbo`), it automatically falls back to `qwen-vl-max`.
|
||||
Once `dashscope_api_key` is configured, the Agent's Vision tool automatically calls Qwen's vision models to recognize images. Models like `qwen3.7-plus` / `qwen3.6-plus` / `qwen3.5-plus` / `qwen3-max` are already multimodal; if the main model is text-only (e.g. `qwen-turbo`), it automatically falls back to `qwen-vl-max`.
|
||||
|
||||
To manually specify a Vision model:
|
||||
|
||||
@@ -33,13 +33,13 @@ To manually specify a Vision model:
|
||||
{
|
||||
"tools": {
|
||||
"vision": {
|
||||
"model": "qwen3.6-plus"
|
||||
"model": "qwen3.7-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported models: `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`.
|
||||
Supported models: `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`, `qwen3-max`.
|
||||
|
||||
## Image Generation
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ description: CowAgent version history
|
||||
|
||||
| Version | Date | Description |
|
||||
| --- | --- | --- |
|
||||
| [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models |
|
||||
| [2.0.9](/releases/v2.0.9) | 2026.05.22 | Model management console, MCP protocol support, browser persistent login, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max, etc.), deployment hardening |
|
||||
| [2.0.8](/releases/v2.0.8) | 2026.05.06 | Major Feishu channel upgrade (voice, streaming and Markdown, one-click QR-scan setup), DeepSeek V4 and Baidu models, scheduler tool enhancements |
|
||||
| [2.0.7](/releases/v2.0.7) | 2026.04.22 | Image Generation Skill (6-provider auto-routing), new models (Kimi K2.6, Claude Opus 4.7, GLM 5.1), knowledge base and Web Console improvements |
|
||||
|
||||
69
docs/releases/v2.1.0.mdx
Normal file
69
docs/releases/v2.1.0.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: v2.1.0
|
||||
description: CowAgent 2.1.0 - Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades, MCP protocol enhancements and new models
|
||||
---
|
||||
|
||||
🌐 [English](https://docs.cowagent.ai/releases/v2.1.0) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.0)
|
||||
|
||||
## 📱 New Channels
|
||||
|
||||
This release adds several mainstream platform channels — configure and go, ready out of the box:
|
||||
|
||||
- **Telegram Bot**: Connect a Telegram bot with support for text and multimedia messages
|
||||
- **Discord Bot**: Connect a Discord bot to chat in channels and direct messages
|
||||
- **Slack Bot**: Connect a Slack bot and bring CowAgent into your team's workflow
|
||||
- **WeChat Customer Service**: New WeChat Customer Service channel that receives images and files and automatically merges them into the next turn, bringing its multimedia context experience in line with other channels. Thanks [@6vision](https://github.com/6vision) (#2840)
|
||||
|
||||
Documentation: [Channels Overview](https://docs.cowagent.ai/en/channels)
|
||||
|
||||
## 🌍 Internationalization
|
||||
|
||||
CowAgent introduces an end-to-end internationalization framework built for developers worldwide, adapting automatically based on the system language:
|
||||
|
||||
- **End-to-end localization**: The install flow, CLI, logs and error messages, agent system prompts and more are all localized
|
||||
- **Automatic language detection**: The default `auto` mode infers the language from the system locale, or you can set `cow_lang` explicitly in `config.json`. English and Chinese ship first, with more languages to follow
|
||||
- **One-click switch in the console**: The Web Console supports switching the system language online, taking effect in real time
|
||||
|
||||
## ⌨️ CLI Interaction Upgrades
|
||||
|
||||
- **Streamlined one-line install**: The install script is simplified with an interactive setup — pick your language, and optionally choose a model and channel right from the prompts, getting you up and running in minutes
|
||||
- **Streaming output**: The Terminal channel now renders the agent's reasoning, tool calls and streaming replies in real time
|
||||
- **Fuzzy command matching**: Supports command abbreviations and near-miss typo suggestions, ships with built-in shortcuts, and lets you define custom aliases in the config file. Thanks [@lyteen](https://github.com/lyteen) (#2850)
|
||||
- **Task cancellation**: In-flight agent runs can be interrupted on demand — the Web Console adds a stop button, and other channels can send `/cancel` to abort
|
||||
|
||||
Documentation: [CLI Guide](https://docs.cowagent.ai/en/cli/general)
|
||||
|
||||
## 🧩 MCP Protocol Enhancements
|
||||
|
||||
MCP tools now support the **Streamable HTTP** transport. On top of the existing `stdio` and `sse` options, this makes more MCP services compatible, letting you connect directly to remote tools that use the streamable HTTP protocol.
|
||||
|
||||
Documentation: [MCP Tools](https://docs.cowagent.ai/en/tools/mcp)
|
||||
|
||||
## 🤖 New Models & Improvements
|
||||
|
||||
- **New models**: `claude-opus-4-8`, Xiaomi `MiMo`
|
||||
- **Improvements**: Fixed JSON parsing failures for tool-call arguments returned by some models (#2823)
|
||||
|
||||
Documentation: [Models Overview](https://docs.cowagent.ai/en/models)
|
||||
|
||||
## 🧠 Memory & Retrieval Improvements
|
||||
|
||||
- **Keyword search**: Fixed low hit rates for Chinese keyword search and empty results for pure-English keyword queries
|
||||
- **Vector retrieval**: Optimized the vector retrieval flow and improved Python version compatibility
|
||||
|
||||
Thanks [@yangluxin613](https://github.com/yangluxin613) (#2832)
|
||||
|
||||
## 🛠 UX Improvements & Fixes
|
||||
|
||||
- **Confined file access**: Web file reads and sends are now limited to the user home directory and agent workspace by default to prevent arbitrary file reads; the scope can be widened via `web_file_serve_root`
|
||||
- **More stable scheduled tasks**: Fixed scheduled-task pushes failing after a restart on the Personal WeChat channel
|
||||
- **Browser tool**: Fixed non-HTTP schemes being dropped from navigation URLs; reduced browser memory usage
|
||||
- **WeChat Official Account**: Passive replies now merge cached text segments, flush ready segments while a task is still running, and support sending local `file://` images. Thanks [@6vision](https://github.com/6vision) (#2848)
|
||||
- **Faster WeCom bot responses**: Callbacks are now dispatched asynchronously to avoid message loss from WeCom's 5-second timeout
|
||||
- **More robust login**: Fixed a login error when `web_password` was not a string
|
||||
|
||||
## 📦 Upgrade
|
||||
|
||||
Source-code deployments can run `cow update` for a one-click upgrade, or pull the latest code and restart manually. See the [Upgrade Guide](https://docs.cowagent.ai/en/guide/upgrade) for details.
|
||||
|
||||
**Release Date**: 2026.06.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.9...2.1.0)
|
||||
@@ -19,7 +19,7 @@ If the current provider fails, the tool automatically tries the next one until i
|
||||
| Provider | Vision Model | Notes |
|
||||
| --- | --- | --- |
|
||||
| OpenAI / Compatible | Main model | All OpenAI-protocol-compatible multimodal models |
|
||||
| Qwen (DashScope) | Main model | e.g. qwen3.6-plus, etc. |
|
||||
| Qwen (DashScope) | Main model | e.g. qwen3.7-plus, etc. |
|
||||
| Claude | Main model | Anthropic native image format |
|
||||
| Gemini | Main model | inlineData format |
|
||||
| Doubao | Main model | doubao-seed-2-0 series natively supported |
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/CowAgent" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square" alt="Stars"></a> <br/>
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
[<a href="../../README.md">English</a>] | [中文] | [<a href="../ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
@@ -95,12 +103,12 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
||||
| 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
@@ -191,6 +199,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
||||
|
||||
## 🏷 更新日志
|
||||
|
||||
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 国际化支持、新增通道(Telegram、Discord、Slack、微信客服)、命令行交互升级、一键安装脚本优化、MCP Streamable HTTP 支持、新模型接入(claude-opus-4-8、MiMo)
|
||||
|
||||
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — 模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、部署安全加固
|
||||
|
||||
> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — 飞书渠道全面升级(语音、流式输出、扫码接入)、新模型支持(DeepSeek V4、百度千帆)、定时任务工具增强
|
||||
@@ -248,9 +258,9 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
||||
|
||||
## 🛠️ 开发与贡献
|
||||
|
||||
欢迎接入更多应用通道,参考 [飞书通道实现](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py) 新增自定义通道;同时欢迎贡献新技能,向 [Skill Hub](https://skills.cowagent.ai/submit) 提交。
|
||||
欢迎各种形式的贡献:新功能、Bug 修复、性能优化、文档完善,或向 [Skill Hub](https://skills.cowagent.ai/submit) 分享你的技能。请先阅读 [CONTRIBUTING.md](/CONTRIBUTING.md) 了解如何开始,然后提交 Issue 讨论或直接发起 PR。
|
||||
|
||||
通过 ⭐ Star 关注项目更新,欢迎提交 PR、Issue 进行反馈。
|
||||
欢迎 ⭐ Star 支持项目,并通过 Watch → Custom → Releases 订阅新版本通知。也欢迎提交 PR、Issue 进行反馈。
|
||||
|
||||
## 🌟 贡献者
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换
|
||||
| [QQ](/zh/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
|
||||
| [企业微信应用](/zh/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
|
||||
| [公众号](/zh/channels/wechatmp) | ✅ | ✅ | | ✅ | |
|
||||
| [微信客服](/zh/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
|
||||
| [Telegram](/zh/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Slack](/zh/channels/slack) | ✅ | ✅ | ✅ | | ✅ |
|
||||
| [Discord](/zh/channels/discord) | ✅ | ✅ | ✅ | | ✅ |
|
||||
@@ -27,7 +28,7 @@ CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换
|
||||
- **群聊**列指可识别并响应群消息
|
||||
|
||||
<Tip>
|
||||
每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/models)。
|
||||
每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/zh/models)。
|
||||
</Tip>
|
||||
|
||||
## 通道一览
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 微信客服
|
||||
description: 将 CowAgent 接入微信客服(WeCom Customer Service)
|
||||
description: 将 CowAgent 接入微信客服(WeChat Customer Service)
|
||||
---
|
||||
|
||||
通过把企业微信自建应用绑定到「微信客服」账号,CowAgent 可以接管来自外部微信用户的客服咨询,并可在小程序、公众号、视频号及视频号小店等场景中通过链接或二维码触达微信用户。
|
||||
|
||||
@@ -33,7 +33,7 @@ description: 查看状态、管理配置和上下文等常用命令
|
||||
Process: PID 12345 | Running 2h 15m
|
||||
Version: 2.0.4
|
||||
Channel: web
|
||||
Model: MiniMax-M2.5
|
||||
Model: MiniMax-M3
|
||||
Mode: agent
|
||||
|
||||
Session: 12 messages | 8 skills loaded
|
||||
|
||||
@@ -75,7 +75,7 @@ cow status
|
||||
Status: ● Running (PID: 12345)
|
||||
Version: 2.0.4
|
||||
Channel: web
|
||||
Model: MiniMax-M2.5
|
||||
Model: MiniMax-M3
|
||||
Mode: agent
|
||||
```
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ description: CowAgent 长期记忆、个人知识库、任务规划、技能系
|
||||
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
|
||||
</Frame>
|
||||
|
||||
详细说明请参考 [长期记忆](/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)。
|
||||
详细说明请参考 [长期记忆](/zh/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)。
|
||||
|
||||
## 2. 个人知识库
|
||||
|
||||
@@ -32,7 +32,7 @@ Agent 会在对话中自动将有价值的信息整理为知识页面,维护
|
||||
<img src="https://cdn.link-ai.tech/doc/20260413105435.png" width="800" />
|
||||
</Frame>
|
||||
|
||||
详细说明请参考 [个人知识库](/knowledge)。
|
||||
详细说明请参考 [个人知识库](/zh/knowledge)。
|
||||
|
||||
## 3. 任务规划和工具调用
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
|
||||
<Card title="复杂任务规划" icon="brain" href="/zh/intro/architecture">
|
||||
能够理解复杂任务并自主规划执行,持续思考和调用各类工具和技能直到完成目标。
|
||||
</Card>
|
||||
<Card title="长期记忆" icon="database" href="/memory">
|
||||
<Card title="长期记忆" icon="database" href="/zh/memory">
|
||||
三层记忆流转(上下文→天级记忆→全局记忆),每日梦境蒸馏整理,支持关键词及向量检索。
|
||||
</Card>
|
||||
<Card title="个人知识库" icon="book" href="/knowledge">
|
||||
<Card title="个人知识库" icon="book" href="/zh/knowledge">
|
||||
自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。
|
||||
</Card>
|
||||
<Card title="技能系统" icon="puzzle-piece" href="/zh/skills/index">
|
||||
|
||||
78
docs/zh/memory/self-evolution.mdx
Normal file
78
docs/zh/memory/self-evolution.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: 自主进化
|
||||
description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项
|
||||
---
|
||||
|
||||
## 功能介绍
|
||||
|
||||
### 简介
|
||||
|
||||
自主进化(Self-Evolution)让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之,Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。
|
||||
|
||||
> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。
|
||||
|
||||
### 三个目标
|
||||
|
||||
自主进化围绕三件事工作:
|
||||
|
||||
| 目标 | 说明 |
|
||||
| --- | --- |
|
||||
| **沉淀记忆** | 把对话中重要的偏好、决策、事实补记到记忆中,作为主对话的查缺补漏 |
|
||||
| **优化技能** | 当某个技能在使用中暴露出问题(如配置错误、步骤缺失),直接修正技能文件,而不只是记一笔;也可在需要时创建新技能 |
|
||||
| **处理未完成事项** | 识别对话中遗留的待办,在能完成时直接完成 |
|
||||
|
||||
复盘完成后,如果确实做了改动,Agent 会在对话中用一句话告诉你"刚刚自主学习了什么、调整了哪里",方便你判断是否需要回滚。
|
||||
|
||||
## 如何使用
|
||||
|
||||
### 触发时机
|
||||
|
||||
自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足:
|
||||
|
||||
- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟)
|
||||
- **对话有足够内容** — 自上次进化以来累积了足够轮次,或上下文已接近容量上限
|
||||
|
||||
只有两个条件都满足,才会启动一次复盘。这样既保证有足够的内容值得复盘,又不会在你还在对话时打扰你。
|
||||
|
||||
### 相关配置
|
||||
|
||||
自主进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启用(位于"深度思考"下方),也可在配置文件中调整:
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `self_evolution_enabled` | 是否启用自主进化 | `false` |
|
||||
| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` |
|
||||
| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` |
|
||||
|
||||
<Tip>
|
||||
Web 控制台只提供启用开关,若需调整空闲时长或轮次阈值,请编辑配置文件。修改后即时生效,无需重启。
|
||||
</Tip>
|
||||
|
||||
### 进化记录
|
||||
|
||||
每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自主进化」tab 中查看。该 tab 同时汇总了自主进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。
|
||||
|
||||
### 如何回滚
|
||||
|
||||
如果你不认同某次进化的改动,直接在对话中告诉 Agent "把刚才的改动撤销"即可,它会根据进化前的备份还原相关文件。每次进化的改动都有独立备份,互不影响。
|
||||
|
||||
## 实现设计
|
||||
|
||||
自主进化复用了系统已有的能力,保持轻量:
|
||||
|
||||
- **隔离执行**:每次复盘都启动一个独立的、临时的复盘任务,使用与主对话相同的模型,但拥有受限的工具集(只能读上下文、改记忆与技能文件)。它不会污染主对话的上下文,也不会影响主对话的性能。
|
||||
- **基于备份的撤销**:进化前对相关文件做快照备份,撤销时按备份还原,因此每一次改动都可追溯、可逆。
|
||||
- **改动检测**:复盘结束后通过对比文件快照判断是否真的有改动,以此决定要不要通知你,从工程上保证"没做事就不打扰"。
|
||||
|
||||
### 克制与安全
|
||||
|
||||
自主进化的设计原则是"必要时执行,减少打扰":
|
||||
|
||||
| 机制 | 说明 |
|
||||
| --- | --- |
|
||||
| **没做事不通知** | 如果复盘后没有任何实际改动,全程静默,不产生任何通知 |
|
||||
| **空闲才触发** | 仅在对话空闲后运行,绝不打断正在进行的对话 |
|
||||
| **改动可回滚** | 每次进化前自动备份,若对结果不满意,可一键撤销本次改动 |
|
||||
| **保护内置技能** | 产品自带的内置技能受保护,进化过程不会改动 |
|
||||
| **限定工作空间** | 所有读写都限定在工作空间内,不会触碰系统其他文件 |
|
||||
| **后台异步** | 复盘在后台进行,不阻塞正常对话回复 |
|
||||
@@ -61,7 +61,7 @@ description: Coding Plan 模式模型配置
|
||||
```json
|
||||
{
|
||||
"bot_type": "openai",
|
||||
"model": "MiniMax-M2.5",
|
||||
"model": "MiniMax-M3",
|
||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||
"open_ai_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
@@ -69,7 +69,7 @@ description: Coding Plan 模式模型配置
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | `MiniMax-M2.5`、`MiniMax-M2.5-highspeed`、`MiniMax-M2.1`、`MiniMax-M2` |
|
||||
| `model` | `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` |
|
||||
| `open_ai_api_base` | 国内:`https://api.minimaxi.com/v1`;海外:`https://api.minimax.io/v1` |
|
||||
| `open_ai_api_key` | Coding Plan 专用 Key(与按量计费接口不通用) |
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
||||
| 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](/zh/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
||||
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [智谱 GLM](/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通义千问](/zh/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
|
||||
@@ -40,7 +40,7 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音
|
||||
}
|
||||
```
|
||||
|
||||
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.6-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。
|
||||
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。
|
||||
|
||||
## 图像生成
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ MiniMax 支持文本对话、图像理解、图像生成与语音合成,一份
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "MiniMax-M2.7",
|
||||
"model": "MiniMax-M3",
|
||||
"minimax_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | 可填 `MiniMax-M2.7`、`MiniMax-M2.7-highspeed`、`MiniMax-M2.5`、`MiniMax-M2.1`、`MiniMax-M2.1-lightning`、`MiniMax-M2` 等 |
|
||||
| `model` | 可填 `MiniMax-M3`、`MiniMax-M2.7`、`MiniMax-M2.7-highspeed` 等 |
|
||||
| `minimax_api_key` | 在 [MiniMax 控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建 |
|
||||
|
||||
## 图像理解
|
||||
|
||||
@@ -13,19 +13,19 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 /
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "qwen3.6-plus",
|
||||
"model": "qwen3.7-plus",
|
||||
"dashscope_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | 可填 `qwen3.6-plus`、`qwen3.7-max`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` 等 |
|
||||
| `model` | 可填 `qwen3.7-plus`、`qwen3.7-max`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`、`qwen-max`、`qwen-plus`、`qwen-turbo`、`qwq-plus` 等 |
|
||||
| `dashscope_api_key` | 在 [百炼控制台](https://bailian.console.aliyun.com/?tab=model#/api-key) 创建,参考 [官方文档](https://bailian.console.aliyun.com/?tab=api#/api) |
|
||||
|
||||
## 图像理解
|
||||
|
||||
配置 `dashscope_api_key` 后 Agent 的 Vision 工具会自动调用千问的视觉模型识别图像。`qwen3-max` / `qwen3.5-plus` / `qwen3.6-plus` 等模型本身就是多模态;若主模型是纯文本(如 `qwen-turbo`),会自动回落到 `qwen-vl-max`。
|
||||
配置 `dashscope_api_key` 后 Agent 的 Vision 工具会自动调用千问的视觉模型识别图像。`qwen3.7-plus` / `qwen3.6-plus` / `qwen3.5-plus` / `qwen3-max` 等模型本身就是多模态;若主模型是纯文本(如 `qwen-turbo`),会自动回落到 `qwen-vl-max`。
|
||||
|
||||
如需手动指定 Vision 模型:
|
||||
|
||||
@@ -33,13 +33,13 @@ description: 通义千问模型配置(文本 / 图像理解 / 图像生成 /
|
||||
{
|
||||
"tools": {
|
||||
"vision": {
|
||||
"model": "qwen3.6-plus"
|
||||
"model": "qwen3.7-plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持模型:`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。
|
||||
支持模型:`qwen3.7-plus`、`qwen3.6-plus`、`qwen3.5-plus`、`qwen3-max`。
|
||||
|
||||
## 图像生成
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 |
|
||||
| [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 |
|
||||
| [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级(语音、流式输出和Markdown、扫码一键接入)、DeepSeek V4和百度模型新增、定时任务工具增强 |
|
||||
| [2.0.7](/zh/releases/v2.0.7) | 2026.04.22 | 图像生成技能(六厂商自动路由)、新模型支持(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、知识库增强、Web 控制台优化 |
|
||||
|
||||
68
docs/zh/releases/v2.1.0.mdx
Normal file
68
docs/zh/releases/v2.1.0.mdx
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: v2.1.0
|
||||
description: CowAgent 2.1.0 - 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级、MCP协议增强、新模型接入
|
||||
---
|
||||
|
||||
🌐 [English](https://docs.cowagent.ai/releases/v2.1.0) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.0)
|
||||
|
||||
## 📱 新增接入通道
|
||||
|
||||
本次新增多个主流平台通道,配置即用,开箱接入:
|
||||
|
||||
- **Telegram Bot**:接入 Telegram 机器人,支持文本与多媒体消息
|
||||
- **Discord Bot**:接入 Discord 机器人,可在频道与私信中对话
|
||||
- **Slack Bot**:接入 Slack 机器人,融入团队协作场景
|
||||
- **微信客服**:新增微信客服通道,支持接收图片与文件并自动并入下一轮对话,多媒体上下文体验对齐其它通道。Thanks @6vision (#2840)
|
||||
|
||||
相关文档:[通道概览](https://docs.cowagent.ai/zh/channels)
|
||||
|
||||
## 🌍 国际化支持
|
||||
|
||||
CowAgent 引入全链路国际化框架,面向全球开发者,支持根据系统语言自动适配:
|
||||
|
||||
- **全链路本地化**:安装引导、命令行、日志和报错、Agent 系统提示词等均已支持本地化语言适配
|
||||
- **自动识别语言**:默认 `auto` 模式按系统语言自动判断,也可在 `config.json` 中通过 `cow_lang` 显式配置。首批提供英文与中文,后续将持续扩充更多语言。
|
||||
- **控制台一键切换**:Web 控制台支持在线切换系统语言,实时生效
|
||||
## ⌨️ 命令行交互升级
|
||||
|
||||
- **一键安装脚本优化**:安装脚本简化流程并支持交互式配置——可选择语言,并在引导中按需选择模型与通道,几分钟即可完成部署
|
||||
- **流式输出体验**:Terminal 通道支持 Agent 模式的推理过程、工具调用与流式回复
|
||||
- **命令模糊匹配**:支持命令缩写及近似拼写自动提示,内置常用快捷指令,并可在配置文件中自定义别名。Thanks @lyteen (#2850)
|
||||
- **支持任务取消**:Agent 任务执行中可主动中断,Web 端新增中止按钮,其他通道可发送 `/cancel` 进行中止
|
||||
|
||||
相关文档:[命令行指南](https://docs.cowagent.ai/zh/cli/general)
|
||||
|
||||
## 🧩 MCP 协议增强
|
||||
|
||||
MCP 工具新增对 **Streamable HTTP** 传输的支持,在原有 `stdio` 和 `sse` 基础上进一步兼容更多 MCP 服务,可直接接入采用流式 HTTP 协议的远程工具。
|
||||
|
||||
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
|
||||
|
||||
## 🤖 模型新增与优化
|
||||
|
||||
- **模型新增**:`claude-opus-4-8`、小米 `MiMo`
|
||||
- **模型优化**:修复部分模型的工具调用参数 JSON 解析失败问题 (#2823)
|
||||
|
||||
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
|
||||
|
||||
## 🧠 记忆与检索优化
|
||||
|
||||
- **关键词检索优化**:修复中文关键词搜索命中率低、纯英文关键词出现查询为空的问题
|
||||
- **向量检索增强**:优化向量检索流程,并提升 Python 版本兼容性
|
||||
|
||||
Thanks @yangluxin613 (#2832)
|
||||
|
||||
## 🛠 体验优化与修复
|
||||
|
||||
- **文件访问收敛**:Web 端文件读取与发送默认限制在用户主目录及 Agent 工作空间内,防止任意文件读取,可通过 `web_file_serve_root` 配置放开范围
|
||||
- **定时任务更稳定**:修复定时任务推送在个人微信通道重启后失效的问题
|
||||
- **浏览器工具**:修复导航 URL 中非 http 协议被丢弃的问题;优化浏览器内存占用
|
||||
- **微信公众号**:被动回复支持合并缓存文本片段、任务执行中先行下发已就绪片段、支持发送本地 `file://` 图片。Thanks @6vision (#2848)
|
||||
- **企微机器人响应提速**:回调改为异步分发,避免企微 5 秒超时导致的消息丢失
|
||||
- **登录鲁棒性**:修复 `web_password` 为非字符串时登录报错的问题
|
||||
|
||||
## 📦 升级方式
|
||||
|
||||
源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
|
||||
|
||||
**发布日期**:2026.06.01 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.0.9...2.1.0)
|
||||
@@ -5,7 +5,7 @@ description: 搜索和读取长期记忆及知识库文件
|
||||
|
||||
记忆工具包含两个子工具:`memory_search`(搜索记忆)和 `memory_get`(读取记忆或知识文件)。
|
||||
|
||||
当 [知识库](/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。
|
||||
当 [知识库](/zh/knowledge) 功能开启时,这两个工具同时支持访问 `memory/` 和 `knowledge/` 目录下的文件。
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Vision 工具采用多级自动选择 + 自动兜底策略,无需手动配置
|
||||
| 厂商 | 视觉模型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| OpenAI / 兼容协议 | 使用主模型 | 支持所有 OpenAI 协议兼容的多模态模型 |
|
||||
| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.6-plus 等 |
|
||||
| 通义千问 (DashScope) | 使用主模型 | 例如 qwen3.7-plus 等 |
|
||||
| Claude | 使用主模型 | Anthropic 原生图像格式 |
|
||||
| Gemini | 使用主模型 | inlineData 格式 |
|
||||
| 豆包 (Doubao) | 使用主模型 | doubao-seed-2-0 系列原生支持 |
|
||||
|
||||
@@ -28,15 +28,15 @@ dashscope_models = {
|
||||
|
||||
# Model name prefixes that require MultiModalConversation API instead of Generation API.
|
||||
# Qwen3.5+ series are omni models that only support MultiModalConversation.
|
||||
MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-", "qwen3.6-")
|
||||
MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-", "qwen3.6-", "qwen3.7-plus")
|
||||
|
||||
|
||||
# Qwen对话模型API
|
||||
class DashscopeBot(Bot):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen3.6-plus")
|
||||
self.model_name = conf().get("model") or "qwen3.6-plus"
|
||||
self.sessions = SessionManager(DashscopeSession, model=conf().get("model") or "qwen3.7-plus")
|
||||
self.model_name = conf().get("model") or "qwen3.7-plus"
|
||||
self.client = dashscope.Generation
|
||||
api_key = conf().get("dashscope_api_key")
|
||||
if api_key:
|
||||
|
||||
@@ -133,7 +133,7 @@ class LinkAIBot(Bot, OpenAICompatibleBot):
|
||||
if file_id:
|
||||
body["file_id"] = file_id
|
||||
logger.info(f"[LINKAI] query={query}, app_code={app_code}, model={body.get('model')}, file_id={file_id}")
|
||||
headers = {"Authorization": "Bearer " + linkai_api_key}
|
||||
headers = {"Authorization": "Bearer " + linkai_api_key, "X-Title": "CowAgent"}
|
||||
|
||||
# do http request
|
||||
base_url = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
@@ -272,7 +272,7 @@ class LinkAIBot(Bot, OpenAICompatibleBot):
|
||||
}
|
||||
if self.args.get("max_tokens"):
|
||||
body["max_tokens"] = self.args.get("max_tokens")
|
||||
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")}
|
||||
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key"), "X-Title": "CowAgent"}
|
||||
|
||||
# do http request
|
||||
base_url = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
@@ -565,7 +565,7 @@ def _linkai_call_with_tools(self, messages, tools=None, stream=False, **kwargs):
|
||||
body["thinking"] = thinking
|
||||
|
||||
# Prepare headers
|
||||
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")}
|
||||
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key"), "X-Title": "CowAgent"}
|
||||
base_url = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
|
||||
if stream:
|
||||
|
||||
@@ -22,7 +22,7 @@ class MinimaxBot(Bot):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.args = {
|
||||
"model": conf().get("model") or "MiniMax-M2.7",
|
||||
"model": conf().get("model") or "MiniMax-M3",
|
||||
"temperature": conf().get("temperature", 0.3),
|
||||
"top_p": conf().get("top_p", 0.95),
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ class Keyword(Plugin):
|
||||
config_path = os.path.join(curdir, "config.json")
|
||||
conf = None
|
||||
if not os.path.exists(config_path):
|
||||
logger.debug(f"[keyword]不存在配置文件{config_path}")
|
||||
logger.debug(f"[keyword] config file not found: {config_path}")
|
||||
conf = {"keyword": {}}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(conf, f, indent=4)
|
||||
else:
|
||||
logger.debug(f"[keyword]加载配置文件{config_path}")
|
||||
logger.debug(f"[keyword] loading config file: {config_path}")
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
conf = json.load(f)
|
||||
# 加载关键词
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
"url": "https://github.com/dividduang/blackroom.git",
|
||||
"desc": "小黑屋插件,被拉进小黑屋的人将不能使用@bot的功能的插件"
|
||||
},
|
||||
"group_task_board": {
|
||||
"url": "https://github.com/Wyh-max-star/cowagent-plugin-group-task-board.git",
|
||||
"desc": "群聊任务看板插件,支持从群聊消息中创建、查看和管理任务"
|
||||
},
|
||||
"midjourney": {
|
||||
"url": "https://github.com/baojingyu/midjourney.git",
|
||||
"desc": "利用midjourney实现ai绘图的的插件"
|
||||
|
||||
38
run.sh
38
run.sh
@@ -596,17 +596,18 @@ select_model() {
|
||||
echo ""
|
||||
local title sel
|
||||
title="$(t "选择 AI 模型" "Select AI Model")"
|
||||
# The 11th option is "skip" -> configure later in the web console.
|
||||
# The 12th option is "skip" -> configure later in the web console.
|
||||
select_menu sel "$title" \
|
||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
||||
"Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)" \
|
||||
"Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)" \
|
||||
"OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)" \
|
||||
"MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)" \
|
||||
"Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)" \
|
||||
"Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)" \
|
||||
"Doubao (doubao-seed-2-0-code-preview-260215, etc.)" \
|
||||
"Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)" \
|
||||
"Claude (claude-opus-4-8, claude-opus-4-7, etc.)" \
|
||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
||||
"OpenAI (gpt-5.5, etc.)" \
|
||||
"MiniMax (MiniMax-M3, etc.)" \
|
||||
"GLM (glm-5.1, etc.)" \
|
||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
||||
"Doubao (doubao-seed-2.0, etc.)" \
|
||||
"Kimi (kimi-k2.6, etc.)" \
|
||||
"MiMo (mimo-v2.5-pro, etc.)" \
|
||||
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
||||
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
||||
model_choice="$sel"
|
||||
@@ -632,19 +633,20 @@ configure_model() {
|
||||
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
|
||||
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
|
||||
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
||||
4) read_model_config "OpenAI GPT" "gpt-5.4" "OPENAI_KEY" ;;
|
||||
5) read_model_config "MiniMax" "MiniMax-M2.7" "MINIMAX_KEY" ;;
|
||||
6) read_model_config "Zhipu AI" "glm-5.1" "ZHIPU_KEY" ;;
|
||||
7) read_model_config "Qwen (DashScope)" "qwen3.6-plus" "DASHSCOPE_KEY" ;;
|
||||
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
|
||||
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
|
||||
6) read_model_config "GLM" "glm-5.1" "ZHIPU_KEY" ;;
|
||||
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
||||
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-0-code-preview-260215" "ARK_KEY" ;;
|
||||
9) read_model_config "Kimi (Moonshot)" "kimi-k2.6" "MOONSHOT_KEY" ;;
|
||||
10)
|
||||
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
|
||||
11)
|
||||
# Show where to obtain a LinkAI key (zh users -> console page).
|
||||
echo -e "${CYAN}$(t "获取 LinkAI Key" "Get your LinkAI Key"): https://link-ai.tech/console/interface${NC}"
|
||||
read_model_config "LinkAI" "deepseek-v4-flash" "LINKAI_KEY"
|
||||
USE_LINKAI="true"
|
||||
;;
|
||||
11)
|
||||
12)
|
||||
# Skip: leave model unset, will be configured in web console
|
||||
MODEL_SKIPPED="true"
|
||||
MODEL_NAME=""
|
||||
@@ -657,8 +659,8 @@ configure_model() {
|
||||
channel_label() {
|
||||
case "$1" in
|
||||
web) t "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)" ;;
|
||||
weixin) t "微信" "WeChat (Weixin)" ;;
|
||||
feishu) t "飞书" "Feishu / Lark" ;;
|
||||
weixin) t "微信" "Wechat" ;;
|
||||
feishu) t "飞书" "Feishu" ;;
|
||||
dingtalk) t "钉钉" "DingTalk" ;;
|
||||
wecom_bot) t "企微智能机器人" "WeCom Bot" ;;
|
||||
qq) printf '%s' "QQ" ;;
|
||||
@@ -823,6 +825,7 @@ create_config_file() {
|
||||
ARK_KEY="${ARK_KEY:-}" \
|
||||
DASHSCOPE_KEY="${DASHSCOPE_KEY:-}" \
|
||||
MINIMAX_KEY="${MINIMAX_KEY:-}" \
|
||||
MIMO_KEY="${MIMO_KEY:-}" \
|
||||
DEEPSEEK_KEY="${DEEPSEEK_KEY:-}" \
|
||||
DEEPSEEK_BASE="${DEEPSEEK_BASE:-https://api.deepseek.com/v1}" \
|
||||
USE_LINKAI="${USE_LINKAI:-false}" \
|
||||
@@ -865,6 +868,7 @@ base = {
|
||||
'ark_api_key': e('ARK_KEY', ''),
|
||||
'dashscope_api_key': e('DASHSCOPE_KEY', ''),
|
||||
'minimax_api_key': e('MINIMAX_KEY', ''),
|
||||
'mimo_api_key': e('MIMO_KEY', ''),
|
||||
'deepseek_api_key': e('DEEPSEEK_KEY', ''),
|
||||
'deepseek_api_base': e('DEEPSEEK_BASE'),
|
||||
'voice_to_text': 'openai',
|
||||
|
||||
@@ -367,13 +367,14 @@ $ModelChoices = @{
|
||||
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
||||
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
||||
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
||||
4 = @{ Provider = "OpenAI GPT"; Default = "gpt-5.4"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
||||
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M2.7"; Field = "minimax_api_key" }
|
||||
6 = @{ Provider = "Zhipu AI"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" }
|
||||
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.6-plus"; Field = "dashscope_api_key" }
|
||||
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
||||
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
||||
6 = @{ Provider = "GLM"; Default = "glm-5.1"; Field = "zhipu_ai_api_key" }
|
||||
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
||||
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-0-code-preview-260215"; Field = "ark_api_key" }
|
||||
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.6"; Field = "moonshot_api_key" }
|
||||
10 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
||||
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
|
||||
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
||||
}
|
||||
|
||||
function Select-Model {
|
||||
@@ -381,14 +382,15 @@ function Select-Model {
|
||||
$title = T "选择 AI 模型" "Select AI Model"
|
||||
$options = @(
|
||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
||||
"Claude (claude-opus-4-8, claude-opus-4-7, claude-sonnet-4-6, etc.)",
|
||||
"Gemini (gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview, etc.)",
|
||||
"OpenAI GPT (gpt-5.4, gpt-5.2, gpt-4.1, etc.)",
|
||||
"MiniMax (MiniMax-M2.7, MiniMax-M2.5, etc.)",
|
||||
"Zhipu AI (glm-5.1, glm-5-turbo, glm-5, etc.)",
|
||||
"Qwen (qwen3.6-plus, qwen3.5-plus, qwen3-max, qwq-plus, etc.)",
|
||||
"Doubao (doubao-seed-2-0-code-preview-260215, etc.)",
|
||||
"Kimi (kimi-k2.6, kimi-k2.5, kimi-k2, etc.)",
|
||||
"Claude (claude-opus-4-8, claude-opus-4-7, etc.)",
|
||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
||||
"OpenAI (gpt-5.5, etc.)",
|
||||
"MiniMax (MiniMax-M3, etc.)",
|
||||
"GLM (glm-5.1, etc.)",
|
||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
||||
"Doubao (doubao-seed-2.0, etc.)",
|
||||
"Kimi (kimi-k2.6, etc.)",
|
||||
"MiMo (mimo-v2.5-pro, etc.)",
|
||||
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
||||
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
||||
)
|
||||
@@ -406,7 +408,7 @@ function Configure-Model {
|
||||
$script:ApiBaseField = ""
|
||||
$script:UseLinkai = $false
|
||||
|
||||
if ($script:ModelChoice -eq 11) {
|
||||
if ($script:ModelChoice -eq 12) {
|
||||
# Skip: leave model unset, will be configured in the web console.
|
||||
Write-Warn (T "已跳过模型配置,稍后可在 Web 控制台填写" "Model configuration skipped, you can set it later in the web console")
|
||||
return
|
||||
@@ -432,8 +434,8 @@ function Get-ChannelLabel {
|
||||
param([string]$Key)
|
||||
switch ($Key) {
|
||||
"web" { return (T "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)") }
|
||||
"weixin" { return (T "微信 Weixin" "WeChat (Weixin)") }
|
||||
"feishu" { return (T "飞书 Feishu" "Feishu / Lark") }
|
||||
"weixin" { return (T "微信 Weixin" "Wechat") }
|
||||
"feishu" { return (T "飞书 Feishu" "Feishu") }
|
||||
"dingtalk" { return (T "钉钉 DingTalk" "DingTalk") }
|
||||
"wecom_bot" { return (T "企微智能机器人 WeCom Bot" "WeCom Bot") }
|
||||
"qq" { return "QQ" }
|
||||
@@ -563,6 +565,7 @@ function New-ConfigFile {
|
||||
ark_api_key = ""
|
||||
dashscope_api_key = ""
|
||||
minimax_api_key = ""
|
||||
mimo_api_key = ""
|
||||
deepseek_api_key = ""
|
||||
deepseek_api_base = "https://api.deepseek.com/v1"
|
||||
voice_to_text = "openai"
|
||||
|
||||
63
tests/test_dashscope_provider.py
Normal file
63
tests/test_dashscope_provider.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# encoding:utf-8
|
||||
"""Unit tests for Qwen DashScope qwen3.7-plus provider updates."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
class TestDashscopeConst(unittest.TestCase):
|
||||
def test_qwen37_plus_constant_defined(self):
|
||||
from common import const
|
||||
self.assertEqual(const.QWEN37_PLUS, "qwen3.7-plus")
|
||||
|
||||
def test_qwen37_plus_in_model_list(self):
|
||||
from common import const
|
||||
self.assertIn("qwen3.7-plus", const.MODEL_LIST)
|
||||
|
||||
def test_qwen37_plus_before_qwen37_max_in_model_list(self):
|
||||
from common import const
|
||||
qwen_models = [m for m in const.MODEL_LIST if str(m).startswith("qwen")]
|
||||
self.assertGreater(
|
||||
len(qwen_models),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(qwen_models[0], "qwen3.7-plus")
|
||||
|
||||
|
||||
class TestDashscopeBotDefaultModel(unittest.TestCase):
|
||||
def test_default_model_is_qwen37_plus(self):
|
||||
mock_conf = MagicMock()
|
||||
mock_conf.get = MagicMock(side_effect=lambda key, default=None: default)
|
||||
|
||||
with patch("models.dashscope.dashscope_bot.conf", return_value=mock_conf):
|
||||
with patch("models.dashscope.dashscope_bot.SessionManager"):
|
||||
from models.dashscope.dashscope_bot import DashscopeBot
|
||||
bot = DashscopeBot.__new__(DashscopeBot)
|
||||
bot.sessions = MagicMock()
|
||||
bot.model_name = mock_conf.get("model") or "qwen3.7-plus"
|
||||
self.assertEqual(bot.model_name, "qwen3.7-plus")
|
||||
|
||||
def test_default_model_string_in_source(self):
|
||||
bot_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "models", "dashscope", "dashscope_bot.py"
|
||||
)
|
||||
with open(bot_path, encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
self.assertIn('"qwen3.7-plus"', source)
|
||||
|
||||
|
||||
class TestDashscopeMultimodalRouting(unittest.TestCase):
|
||||
def test_qwen37_plus_uses_multimodal_api(self):
|
||||
from models.dashscope.dashscope_bot import DashscopeBot
|
||||
self.assertTrue(DashscopeBot._is_multimodal_model("qwen3.7-plus"))
|
||||
|
||||
def test_qwen37_max_uses_generation_api(self):
|
||||
from models.dashscope.dashscope_bot import DashscopeBot
|
||||
self.assertFalse(DashscopeBot._is_multimodal_model("qwen3.7-max"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
798
tests/test_evolution.py
Normal file
798
tests/test_evolution.py
Normal file
@@ -0,0 +1,798 @@
|
||||
"""Self-evolution test harness.
|
||||
|
||||
Simulates multiple realistic conversations and checks the evolution pass behaves
|
||||
correctly: stays silent when it should, evolves (memory/skill) when it should,
|
||||
backs up before editing, notifies the user, and supports undo.
|
||||
|
||||
Two modes:
|
||||
- stub (default): the review agent's reasoning is replaced by a scripted
|
||||
output per scenario. Fast, deterministic, validates the WIRING (backup,
|
||||
record, inject, notify, undo, protection). No model calls.
|
||||
- real: the review agent runs the configured model for real. Validates the
|
||||
QUALITY of the judgement (does it correctly decide to act / stay silent).
|
||||
|
||||
Run:
|
||||
python tests/test_evolution.py # stub mode
|
||||
python tests/test_evolution.py --real # real model mode
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeChannel:
|
||||
"""Captures channel.send calls instead of sending."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send(self, reply, context):
|
||||
self.sent.append({"content": getattr(reply, "content", str(reply)), "receiver": context.get("receiver")})
|
||||
|
||||
|
||||
class FakeModel:
|
||||
pass
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Minimal stand-in for a chat Agent."""
|
||||
|
||||
def __init__(self, messages, tools=None):
|
||||
import threading
|
||||
self.messages = messages
|
||||
self.messages_lock = threading.Lock()
|
||||
self.tools = tools or []
|
||||
self.model = FakeModel()
|
||||
self.skill_manager = None
|
||||
self.memory_manager = None
|
||||
|
||||
|
||||
class FakeReviewAgent:
|
||||
"""Review agent whose run_stream returns a scripted result (stub mode)."""
|
||||
|
||||
def __init__(self, scripted_output, workspace, on_edit=None):
|
||||
self._out = scripted_output
|
||||
self._workspace = workspace
|
||||
self._on_edit = on_edit
|
||||
self.model = None
|
||||
|
||||
def run_stream(self, user_message, clear_history=False, **kwargs):
|
||||
# Simulate the side effects a real review agent would perform.
|
||||
if self._on_edit:
|
||||
self._on_edit(self._workspace)
|
||||
return self._out
|
||||
|
||||
|
||||
class FakeAgentBridge:
|
||||
"""Stand-in for AgentBridge wiring used by the executor."""
|
||||
|
||||
def __init__(self, agent, scripted_output, on_edit=None):
|
||||
self.agents = {"session_test": agent}
|
||||
self.default_agent = agent
|
||||
self._scripted = scripted_output
|
||||
self._on_edit = on_edit
|
||||
self.injected = []
|
||||
|
||||
def create_agent(self, **kwargs):
|
||||
from agent.memory.config import get_default_memory_config
|
||||
ws = get_default_memory_config().get_workspace()
|
||||
return FakeReviewAgent(self._scripted, ws, on_edit=self._on_edit)
|
||||
|
||||
def remember_scheduled_output(self, session_id, content, channel_type="", task_description=""):
|
||||
self.injected.append(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test scaffolding
|
||||
# ---------------------------------------------------------------------------
|
||||
def _setup_workspace():
|
||||
"""Create a realistic temp workspace: seeded memory + real editable skills.
|
||||
|
||||
Mirrors a real CowAgent workspace closely enough that the model has genuine
|
||||
content to read, reason about, and edit during a real evolution pass.
|
||||
"""
|
||||
ws = Path(tempfile.mkdtemp(prefix="evo_test_"))
|
||||
(ws / "MEMORY.md").write_text(
|
||||
"# Long-term Memory\n\n"
|
||||
"## User\n"
|
||||
"- Name: 大锤 (David)\n"
|
||||
"- Lives in Shenzhen, works as a backend engineer\n"
|
||||
"- Company: a fintech startup, team of 8\n\n"
|
||||
"## Preferences\n"
|
||||
"- Likes detailed technical explanations\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(ws / "memory").mkdir()
|
||||
(ws / "output").mkdir()
|
||||
skills = ws / "skills"
|
||||
|
||||
# Editable skill 1: weekly report generator (has a structural gap: no risk).
|
||||
(skills / "weekly-report").mkdir(parents=True)
|
||||
(skills / "weekly-report" / "SKILL.md").write_text(
|
||||
"# Weekly Report\n\n"
|
||||
"Generate a weekly work report from the user's notes.\n\n"
|
||||
"## Steps\n"
|
||||
"1. Collect this week's completed items.\n"
|
||||
"2. Summarize key progress in 3-5 bullets.\n"
|
||||
"3. List next week's plan.\n\n"
|
||||
"## Output format\n"
|
||||
"Markdown with sections: 本周进展 / 下周计划\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Editable skill 2: expense tracker (has a wrong currency-format step).
|
||||
(skills / "expense-tracker").mkdir(parents=True)
|
||||
(skills / "expense-tracker" / "SKILL.md").write_text(
|
||||
"# Expense Tracker\n\n"
|
||||
"Record an expense into output/expenses.md.\n\n"
|
||||
"## Steps\n"
|
||||
"1. Parse amount and category from the user message.\n"
|
||||
"2. Append a row to output/expenses.md.\n"
|
||||
"3. Format the amount with a `$` prefix.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Editable skill 3: an API caller whose SKILL.md hardcodes a WRONG endpoint
|
||||
# host. The conversation discovers the correct host at runtime; the right
|
||||
# fix is to edit this file's source, not just log the corrected fact.
|
||||
(skills / "data-fetch").mkdir(parents=True)
|
||||
(skills / "data-fetch" / "SKILL.md").write_text(
|
||||
"# Data Fetch\n\n"
|
||||
"Fetch records from the data service.\n\n"
|
||||
"## Steps\n"
|
||||
"1. Build the request payload from the user's query.\n"
|
||||
"2. POST it to `https://api.example-wrong.com/v1/fetch`.\n"
|
||||
"3. Parse and return the `data` field.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Protected built-in skill: must never be edited by evolution.
|
||||
(skills / "image-generation").mkdir(parents=True)
|
||||
(skills / "image-generation" / "SKILL.md").write_text(
|
||||
"# Image Generation (built-in)\nDo not modify.\n", encoding="utf-8"
|
||||
)
|
||||
return ws
|
||||
|
||||
|
||||
def _point_config_at(ws):
|
||||
"""Force the global memory config to use the temp workspace."""
|
||||
from agent.memory.config import MemoryConfig, set_global_memory_config
|
||||
set_global_memory_config(MemoryConfig(workspace_root=str(ws)))
|
||||
|
||||
|
||||
def _make_messages(turns):
|
||||
msgs = []
|
||||
for u, a in turns:
|
||||
msgs.append({"role": "user", "content": u})
|
||||
msgs.append({"role": "assistant", "content": a})
|
||||
return msgs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
def scenario_silent():
|
||||
"""Pure small talk -> should stay SILENT (no change, no notify)."""
|
||||
return {
|
||||
"name": "闲聊 (should stay SILENT)",
|
||||
"goal": "none",
|
||||
"turns": [
|
||||
("在吗", "在的,有什么可以帮你?"),
|
||||
("今天周五了,终于要放假了", "是呀,周末好好休息一下。"),
|
||||
("哈哈是的,那没事了", "好的,随时找我。"),
|
||||
],
|
||||
"scripted": "[SILENT]",
|
||||
"on_edit": None,
|
||||
"expect_evolved": False,
|
||||
}
|
||||
|
||||
|
||||
def scenario_memory_preference():
|
||||
"""User states a durable working preference -> update MEMORY.md."""
|
||||
def edit(ws):
|
||||
from datetime import datetime
|
||||
p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
|
||||
p.write_text((p.read_text() if p.exists() else "") + "- Prefers concise replies; avoid long explanations\n", encoding="utf-8")
|
||||
|
||||
return {
|
||||
"name": "记忆-偏好 (should note in daily memory)",
|
||||
"goal": "memory",
|
||||
"turns": [
|
||||
("你能帮我解释下什么是数据库事务吗", "当然可以。数据库事务(Transaction)是一组要么全部成功、要么全部失败的操作单元,它有四个核心特性 ACID:原子性、一致性、隔离性、持久性……(接着是 5 段详细展开)"),
|
||||
("打住打住,太长了", "抱歉,我说得太细了。"),
|
||||
("对,你以后回答我都简短一点,抓重点就行,别长篇大论", "好的,明白了。"),
|
||||
("嗯这样挺好", "👌"),
|
||||
],
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:你希望回答简短、抓重点\n"
|
||||
"- 调整:更新了长期记忆 MEMORY.md\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": edit,
|
||||
"expect_evolved": True,
|
||||
"expect_memory_contains": "concise",
|
||||
}
|
||||
|
||||
|
||||
def scenario_memory_correction():
|
||||
"""User corrects a wrong fact the agent stated -> fix MEMORY.md."""
|
||||
def edit(ws):
|
||||
from datetime import datetime
|
||||
p = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
|
||||
p.write_text((p.read_text() if p.exists() else "") + "- Correction: team size is now 15 (was recorded as 8)\n", encoding="utf-8")
|
||||
|
||||
return {
|
||||
"name": "记忆-纠正事实 (should note correction in daily)",
|
||||
"goal": "memory",
|
||||
"turns": [
|
||||
("我们团队最近又招了几个人", "记得你们是 8 人的小团队,扩张了挺好的。"),
|
||||
("不对,我们现在是 15 个人了,上次你就记错了", "抱歉记错了,你们现在是 15 人团队。"),
|
||||
("嗯对,记住啊", "好的,记住了。"),
|
||||
],
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:团队规模已是 15 人(之前记成 8 人)\n"
|
||||
"- 调整:修正了长期记忆 MEMORY.md\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": edit,
|
||||
"expect_evolved": True,
|
||||
"expect_memory_contains": "15",
|
||||
}
|
||||
|
||||
|
||||
def scenario_skill_gap():
|
||||
"""Using weekly-report reveals a missing section -> patch the skill."""
|
||||
def edit(ws):
|
||||
p = ws / "skills" / "weekly-report" / "SKILL.md"
|
||||
p.write_text(p.read_text() + "4. List risk items / blockers.\n", encoding="utf-8")
|
||||
|
||||
return {
|
||||
"name": "技能-缺陷 (should patch weekly-report skill)",
|
||||
"goal": "skill",
|
||||
"turns": [
|
||||
("用周报技能帮我生成这周的周报,素材:完成了支付网关重构、修复了3个线上bug、对接了风控接口",
|
||||
"好的,我用 weekly-report 技能生成:\n\n## 本周进展\n- 完成支付网关重构\n- 修复 3 个线上 bug\n- 对接风控接口\n\n## 下周计划\n- 联调风控接口"),
|
||||
("不错,但是每次周报都漏了风险项,我们 leader 每次都要问这个", "确实,风险项很重要。"),
|
||||
("对,你能不能让这个周报技能以后都带上风险项这一节", "可以的,我把它固化进技能。"),
|
||||
],
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:周报需要固定包含「风险项」一节\n"
|
||||
"- 调整:给 weekly-report 技能补充了风险项步骤\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": edit,
|
||||
"expect_evolved": True,
|
||||
"expect_skill_contains": ("weekly-report", "risk"),
|
||||
}
|
||||
|
||||
|
||||
def scenario_skill_error():
|
||||
"""A skill step is wrong (currency format) -> correct the skill."""
|
||||
def edit(ws):
|
||||
p = ws / "skills" / "expense-tracker" / "SKILL.md"
|
||||
p.write_text(p.read_text().replace("`$` prefix", "`¥` prefix (RMB)"), encoding="utf-8")
|
||||
|
||||
return {
|
||||
"name": "技能-报错纠正 (should fix expense-tracker skill)",
|
||||
"goal": "skill",
|
||||
"turns": [
|
||||
("用记账技能记一笔:午饭 35", "好的,已记录:午饭 $35(已写入 output/expenses.md)"),
|
||||
("怎么是美元?我们都是人民币啊", "抱歉,格式用错了,应该是 ¥35。"),
|
||||
("对,以后这个记账技能都用人民币符号", "明白,我修正技能里的货币格式。"),
|
||||
],
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:记账应使用人民币符号 ¥ 而非 $\n"
|
||||
"- 调整:修正了 expense-tracker 技能的货币格式步骤\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": edit,
|
||||
"expect_evolved": True,
|
||||
"expect_skill_contains": ("expense-tracker", "¥"),
|
||||
}
|
||||
|
||||
|
||||
def scenario_skill_wrong_config():
|
||||
"""A skill's SKILL.md hardcodes a wrong endpoint; the chat works around it
|
||||
at runtime. Correct evolution = FIX the skill source, not log a memory note.
|
||||
"""
|
||||
def edit(ws):
|
||||
p = ws / "skills" / "data-fetch" / "SKILL.md"
|
||||
p.write_text(
|
||||
p.read_text().replace("api.example-wrong.com", "api.example-correct.com"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return {
|
||||
"name": "技能-配置错误 (should fix skill source, not log memory)",
|
||||
"goal": "skill",
|
||||
"turns": [
|
||||
("用 data-fetch 技能拉一下最新数据",
|
||||
"好的,我按技能里的步骤 POST 到 https://api.example-wrong.com/v1/fetch …… 报错了,连接失败。"),
|
||||
("哦那个地址不对,正确的是 api.example-correct.com,你用这个调",
|
||||
"好的,换成 https://api.example-correct.com/v1/fetch ,成功拿到数据了 ✅"),
|
||||
("嗯对,就是这个地址", "明白了,这个才是正确的服务地址。"),
|
||||
],
|
||||
# The model must decide on its own to edit the skill; we still provide a
|
||||
# scripted output for stub mode wiring.
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:data-fetch 的正确服务地址是 api.example-correct.com\n"
|
||||
"- 调整:修正了 data-fetch 技能里写错的接口地址\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": edit,
|
||||
"expect_evolved": True,
|
||||
"expect_skill_contains": ("data-fetch", "api.example-correct.com"),
|
||||
}
|
||||
|
||||
|
||||
def scenario_skill_create():
|
||||
"""A reusable, repeatable workflow emerges that no skill covers, and the
|
||||
user explicitly asks to make it permanent -> CREATE a new skill.
|
||||
"""
|
||||
return {
|
||||
"name": "技能-新增 (should create a new skill)",
|
||||
"goal": "skill",
|
||||
"turns": [
|
||||
("每周一帮我把客户反馈整理成晨会简报:先按「严重/一般/建议」给反馈分类,再统计每类数量,最后写一段 3 句话的总结,发我",
|
||||
"好的,本周客户反馈晨会简报:\n\n## 严重 (2)\n- 支付失败\n- 数据丢失\n## 一般 (3)\n- 加载慢…\n## 建议 (1)\n- 增加暗色模式\n\n总结:本周以稳定性问题为主,建议优先排查支付与数据链路,其余可排期跟进。"),
|
||||
("很好,就是这个格式。上周我也是这么让你弄的", "嗯,分类+计数+三句总结这套流程挺顺手。"),
|
||||
("对,这个我每周一都要用,你能不能把这套流程固化下来,以后我直接说『生成客户反馈晨会简报』你就照这个来",
|
||||
"可以,我把这套「客户反馈晨会简报」流程固化成一个可复用的技能。"),
|
||||
],
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:你每周一都要按「分类+计数+三句总结」生成客户反馈晨会简报\n"
|
||||
"- 调整:新建了「客户反馈晨会简报」技能,固化这套流程\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": _create_briefing_skill,
|
||||
"expect_evolved": True,
|
||||
"expect_new_skill": True,
|
||||
}
|
||||
|
||||
|
||||
def scenario_skill_create_implicit():
|
||||
"""A complex, clearly-structured multi-step workflow is executed once. The
|
||||
user NEVER says "I do this weekly" or "make this a skill" — the agent must
|
||||
判断 on its own that this is a reusable procedure worth capturing.
|
||||
|
||||
This is the realistic, harder case: no explicit instruction to create a
|
||||
skill, only an obviously repeatable workflow.
|
||||
"""
|
||||
return {
|
||||
"name": "技能-隐式新增 (complex workflow, user never asks to save)",
|
||||
"goal": "skill",
|
||||
"turns": [
|
||||
("帮我做一份竞品调研:对比一下 Notion、飞书文档、语雀这三个产品",
|
||||
"好的,我按一套固定方法来做竞品调研:\n1. 先确定对比维度:定价、协作能力、模板生态、API 开放度、本地化;\n2. 逐个产品按维度收集信息;\n3. 做成对比表;\n4. 给出结论和选型建议。\n\n(随后产出了完整的五维度对比表 + 结论)"),
|
||||
("不错。再帮我用同样的方法调研一下 Slack、企业微信、钉钉",
|
||||
"好的,沿用刚才那套方法(定价/协作/模板/API/本地化 五维度 → 收集 → 对比表 → 结论):\n\n(产出了第二份五维度对比表 + 选型建议)"),
|
||||
("可以,结论挺清楚的", "嗯,这套五维度对比的方法做下来结构很清楚。"),
|
||||
],
|
||||
# In real mode the model decides on its own. The scripted side effect
|
||||
# only wires stub mode; it emulates capturing the procedure as a skill.
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 学到:你做竞品调研有一套固定方法(五维度对比 → 收集 → 对比表 → 结论)\n"
|
||||
"- 调整:把这套竞品调研流程固化成了一个可复用技能\n"
|
||||
"如果不对,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": _create_competitor_skill,
|
||||
"expect_evolved": True,
|
||||
"expect_new_skill": True,
|
||||
}
|
||||
|
||||
|
||||
def _create_competitor_skill(ws):
|
||||
"""Stub side effect: emulate capturing the competitor-research procedure."""
|
||||
d = ws / "skills" / "competitor-research"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
"# Competitor Research\n\n"
|
||||
"Compare a set of products with a fixed methodology.\n\n"
|
||||
"## Steps\n"
|
||||
"1. Fix the comparison dimensions (pricing, collaboration, templates, API, localization).\n"
|
||||
"2. Collect info per product across each dimension.\n"
|
||||
"3. Build a comparison table.\n"
|
||||
"4. Give a conclusion and recommendation.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def scenario_skill_no_create():
|
||||
"""A one-off, novel task with no sign of recurrence -> must NOT create a
|
||||
skill (and ideally stay silent). Guards against over-eager skill creation.
|
||||
"""
|
||||
return {
|
||||
"name": "技能-不应新增 (one-off task, must NOT create skill)",
|
||||
"goal": "none",
|
||||
"turns": [
|
||||
("帮我把这段话翻译成英文:今晚的庆功宴改到 8 点", "翻译:The celebration dinner tonight is moved to 8 PM."),
|
||||
("谢谢", "不客气。"),
|
||||
("嗯没事了", "好的,随时找我。"),
|
||||
],
|
||||
"scripted": "[SILENT]",
|
||||
"on_edit": None,
|
||||
"expect_evolved": False,
|
||||
"expect_no_new_skill": True,
|
||||
}
|
||||
|
||||
|
||||
def _create_briefing_skill(ws):
|
||||
"""Stub side effect: emulate creating a new skill under workspace skills/."""
|
||||
d = ws / "skills" / "customer-feedback-briefing"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
"# Customer Feedback Briefing\n\n"
|
||||
"Turn raw customer feedback into a standup briefing.\n\n"
|
||||
"## Steps\n"
|
||||
"1. Classify each item as 严重/一般/建议.\n"
|
||||
"2. Count items per category.\n"
|
||||
"3. Write a 3-sentence summary.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def scenario_unfinished_task():
|
||||
"""A promised deliverable was not produced -> finish it now via tools."""
|
||||
def edit(ws):
|
||||
p = ws / "output" / "team-roster.md"
|
||||
p.write_text("# Team Roster (backend)\n- 张伟\n- 李娜\n- 王强\n- 大锤\n", encoding="utf-8")
|
||||
|
||||
return {
|
||||
"name": "未完成任务 (should finish & write output file)",
|
||||
"goal": "task",
|
||||
"turns": [
|
||||
("帮我把后端团队花名册整理成一个文件保存下,成员有:张伟、李娜、王强,还有我自己(大锤)",
|
||||
"好的,后端 4 个人:张伟、李娜、王强、大锤。我整理成文件保存到 output/team-roster.md。"),
|
||||
("好的麻烦了,我先去开个会", "没问题,我现在就处理。"),
|
||||
("(用户离开,会话中断,文件尚未写入)", "(助手未及写入文件,对话中断)"),
|
||||
],
|
||||
"scripted": (
|
||||
"我刚做了一次自我学习。\n"
|
||||
"- 发现:之前答应整理团队花名册但没完成\n"
|
||||
"- 已完成:把后端成员名单写入 output/team-roster.md\n"
|
||||
"如果不需要,回复「撤销上次学习」即可。"
|
||||
),
|
||||
"on_edit": edit,
|
||||
"expect_evolved": True,
|
||||
"expect_output_file": "team-roster.md",
|
||||
}
|
||||
|
||||
|
||||
SCENARIOS = [
|
||||
scenario_silent,
|
||||
scenario_memory_preference,
|
||||
scenario_memory_correction,
|
||||
scenario_skill_gap,
|
||||
scenario_skill_error,
|
||||
scenario_skill_wrong_config,
|
||||
scenario_skill_create,
|
||||
scenario_skill_create_implicit,
|
||||
scenario_skill_no_create,
|
||||
scenario_unfinished_task,
|
||||
]
|
||||
|
||||
# Skill directories present in a fresh workspace; anything beyond these that
|
||||
# appears after a pass is a newly-created skill.
|
||||
_SEED_SKILLS = {"weekly-report", "expense-tracker", "data-fetch", "image-generation"}
|
||||
|
||||
|
||||
def _new_skill_dirs(ws: Path) -> set:
|
||||
"""Skill directories created beyond the seeded set."""
|
||||
skills_dir = ws / "skills"
|
||||
if not skills_dir.exists():
|
||||
return set()
|
||||
return {p.name for p in skills_dir.iterdir() if p.is_dir()} - _SEED_SKILLS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner (stub mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_stub():
|
||||
from agent.evolution.executor import run_evolution_for_session
|
||||
from agent.evolution import backup as backup_mod
|
||||
from config import conf
|
||||
# Evolution is disabled by default now; enable for the test.
|
||||
conf()["self_evolution_enabled"] = True
|
||||
|
||||
passed, failed = 0, 0
|
||||
for make in SCENARIOS:
|
||||
sc = make()
|
||||
ws = _setup_workspace()
|
||||
try:
|
||||
_point_config_at(ws)
|
||||
# Patch channel push to capture instead of send.
|
||||
channel = FakeChannel()
|
||||
import agent.evolution.executor as ex
|
||||
orig_notify = ex._notify_user
|
||||
ex._notify_user = lambda ct, rcv, summary: channel.send(
|
||||
type("R", (), {"content": summary})(),
|
||||
{"receiver": rcv},
|
||||
)
|
||||
|
||||
agent = FakeAgent(_make_messages(sc["turns"]))
|
||||
bridge = FakeAgentBridge(agent, sc["scripted"], on_edit=sc["on_edit"])
|
||||
|
||||
evolved = run_evolution_for_session(
|
||||
bridge, "session_test", channel_type="telegram", receiver="user_42"
|
||||
)
|
||||
|
||||
ok = True
|
||||
errs = []
|
||||
|
||||
if evolved != sc["expect_evolved"]:
|
||||
ok = False
|
||||
errs.append(f"evolved={evolved}, expected {sc['expect_evolved']}")
|
||||
|
||||
if sc["expect_evolved"]:
|
||||
# memory / skill content checks
|
||||
if "expect_memory_contains" in sc:
|
||||
# Evolution now writes to the dated daily file, not MEMORY.md.
|
||||
from datetime import datetime
|
||||
daily = ws / "memory" / (datetime.now().strftime("%Y-%m-%d") + ".md")
|
||||
mem = daily.read_text() if daily.exists() else ""
|
||||
if sc["expect_memory_contains"] not in mem:
|
||||
ok = False
|
||||
errs.append("daily memory missing expected content")
|
||||
if "expect_skill_contains" in sc:
|
||||
sk, txt = sc["expect_skill_contains"]
|
||||
content = (ws / "skills" / sk / "SKILL.md").read_text()
|
||||
if txt not in content:
|
||||
ok = False
|
||||
errs.append("skill missing expected content")
|
||||
if sc.get("expect_new_skill") and not _new_skill_dirs(ws):
|
||||
ok = False
|
||||
errs.append("expected a new skill to be created")
|
||||
# notify happened
|
||||
if not channel.sent:
|
||||
ok = False
|
||||
errs.append("no notification sent")
|
||||
# injection happened (undo support)
|
||||
if not bridge.injected or "[EVOLUTION]" not in bridge.injected[0]:
|
||||
ok = False
|
||||
errs.append("no [EVOLUTION] record injected")
|
||||
# protected skill untouched
|
||||
prot = (ws / "skills" / "image-generation" / "SKILL.md").read_text()
|
||||
if prot != "# Image Generation (built-in)\nDo not modify.\n":
|
||||
ok = False
|
||||
errs.append("PROTECTED skill was modified!")
|
||||
# backup exists (undo possible)
|
||||
backups = list((ws / "memory" / ".evolution_backups").glob("*"))
|
||||
if not backups:
|
||||
ok = False
|
||||
errs.append("no backup created")
|
||||
else:
|
||||
# SILENT: nothing should have changed / been sent
|
||||
if channel.sent:
|
||||
ok = False
|
||||
errs.append("notification sent on SILENT")
|
||||
if bridge.injected:
|
||||
ok = False
|
||||
errs.append("injected record on SILENT")
|
||||
if sc.get("expect_no_new_skill") and _new_skill_dirs(ws):
|
||||
ok = False
|
||||
errs.append(f"unexpected new skill created: {_new_skill_dirs(ws)}")
|
||||
|
||||
ex._notify_user = orig_notify
|
||||
|
||||
if ok:
|
||||
passed += 1
|
||||
print(f" PASS {sc['name']}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" FAIL {sc['name']}: {'; '.join(errs)}")
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
# Undo verification (uses the memory scenario's backup path).
|
||||
print("\n-- undo tool --")
|
||||
_verify_undo()
|
||||
|
||||
print(f"\nStub results: {passed} passed, {failed} failed")
|
||||
return failed == 0
|
||||
|
||||
|
||||
def _verify_undo():
|
||||
from agent.evolution.backup import create_backup, restore_backup
|
||||
ws = _setup_workspace()
|
||||
try:
|
||||
_point_config_at(ws)
|
||||
mem = ws / "MEMORY.md"
|
||||
bid = create_backup(ws, [mem])
|
||||
mem.write_text("CORRUPTED", encoding="utf-8")
|
||||
from agent.tools.evolution_undo import EvolutionUndoTool
|
||||
r = EvolutionUndoTool().execute({"backup_id": bid})
|
||||
restored = mem.read_text()
|
||||
if r.status == "success" and "大锤" in restored:
|
||||
print(" PASS undo restores pre-evolution state")
|
||||
else:
|
||||
print(f" FAIL undo: status={r.status}, content={restored[:40]}")
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner (real mode) — minimal: just prints the model's decision per scenario.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _snapshot_ws(ws: Path) -> dict:
|
||||
"""Map every text file under the workspace -> content (skip backups dir)."""
|
||||
snap = {}
|
||||
for p in ws.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
rel = str(p.relative_to(ws))
|
||||
if rel.startswith("memory/.evolution_backups"):
|
||||
continue
|
||||
try:
|
||||
snap[rel] = p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _print_diff(before: dict, after: dict) -> bool:
|
||||
"""Print added/changed files. Returns True if anything changed."""
|
||||
changed = False
|
||||
keys = sorted(set(before) | set(after))
|
||||
for rel in keys:
|
||||
old = before.get(rel)
|
||||
new = after.get(rel)
|
||||
if old == new:
|
||||
continue
|
||||
changed = True
|
||||
tag = "NEW FILE" if old is None else "CHANGED"
|
||||
print(f" ~ {rel} [{tag}]")
|
||||
old_lines = set((old or "").splitlines())
|
||||
for line in (new or "").splitlines():
|
||||
if line not in old_lines:
|
||||
print(f" + {line}")
|
||||
return changed
|
||||
|
||||
|
||||
def run_real():
|
||||
"""Run real model evolution on each scenario and print the actual output.
|
||||
|
||||
Uses config.json's configured model via a real AgentBridge, so you see
|
||||
exactly what the model decides and writes for each conversation.
|
||||
"""
|
||||
from bridge.bridge import Bridge
|
||||
from agent.memory.config import (
|
||||
MemoryConfig,
|
||||
set_global_memory_config,
|
||||
get_default_memory_config,
|
||||
)
|
||||
from config import conf, load_config
|
||||
|
||||
# Load config.json so real API keys are available to the bots.
|
||||
load_config()
|
||||
|
||||
# Default the test to deepseek-v4-flash (fast, low cost) unless overridden.
|
||||
override_model = os.environ.get("EVO_TEST_MODEL", "deepseek-v4-flash")
|
||||
conf()["model"] = override_model
|
||||
conf()["bot_type"] = os.environ.get("EVO_TEST_BOT_TYPE", "deepseek")
|
||||
# Force-enable evolution for the test regardless of config.json default.
|
||||
conf()["self_evolution_enabled"] = True
|
||||
print(f"[test] model: {override_model} (bot_type={conf().get('bot_type')}, "
|
||||
f"key={'set' if conf().get('deepseek_api_key') else 'MISSING'})")
|
||||
|
||||
from agent.memory.manager import MemoryManager
|
||||
import agent.evolution.executor as ex
|
||||
|
||||
bridge = Bridge()
|
||||
agent_bridge = bridge.get_agent_bridge()
|
||||
|
||||
# Capture the user-facing reply instead of pushing it to a channel.
|
||||
captured = {"reply": None}
|
||||
orig_notify = ex._notify_user
|
||||
ex._notify_user = lambda ct, rcv, summary: captured.__setitem__("reply", summary)
|
||||
|
||||
results = [] # (name, goal, evolved, changed, reply_ok)
|
||||
|
||||
only = os.environ.get("EVO_TEST_ONLY") # substring filter on goal/name
|
||||
try:
|
||||
for make in SCENARIOS:
|
||||
sc = make()
|
||||
if only and only not in sc["goal"] and only not in sc["name"]:
|
||||
continue
|
||||
ws = _setup_workspace()
|
||||
captured["reply"] = None
|
||||
try:
|
||||
mem_cfg = MemoryConfig(workspace_root=str(ws))
|
||||
set_global_memory_config(mem_cfg)
|
||||
|
||||
sid = "session_evo_real"
|
||||
# Fully isolated agent: tool cwd + memory_manager -> temp ws.
|
||||
iso_mem = MemoryManager(mem_cfg)
|
||||
agent = agent_bridge.create_agent(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
tools=None,
|
||||
workspace_dir=str(ws),
|
||||
memory_manager=iso_mem,
|
||||
enable_skills=False,
|
||||
)
|
||||
# Notify path needs a channel+receiver to fire; give dummies.
|
||||
agent_bridge.agents[sid] = agent
|
||||
with agent.messages_lock:
|
||||
agent.messages.clear()
|
||||
agent.messages.extend(_make_messages(sc["turns"]))
|
||||
|
||||
before = _snapshot_ws(ws)
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
print(f"场景: {sc['name']} [目标: {sc['goal']}]")
|
||||
print("-" * 72)
|
||||
print("【会话输入】")
|
||||
for u, a in sc["turns"]:
|
||||
print(f" 用户: {u}")
|
||||
print(f" 助手: {a}")
|
||||
|
||||
from agent.evolution.executor import run_evolution_for_session
|
||||
evolved = run_evolution_for_session(
|
||||
agent_bridge, sid, channel_type="telegram", receiver="tester"
|
||||
)
|
||||
|
||||
after = _snapshot_ws(ws)
|
||||
print("\n【进化结果】 evolved =", evolved)
|
||||
changed = False
|
||||
if evolved:
|
||||
changed = _print_diff(before, after)
|
||||
if not changed:
|
||||
print(" (无文件变更)")
|
||||
else:
|
||||
print(" (静默,未做任何改动)")
|
||||
|
||||
new_skills = _new_skill_dirs(ws)
|
||||
if new_skills:
|
||||
print(f" 新建技能: {', '.join(sorted(new_skills))}")
|
||||
# Surface mismatches against the scenario's skill expectation.
|
||||
if sc.get("expect_new_skill") and not new_skills:
|
||||
print(" ⚠ 预期新建技能,但未创建")
|
||||
if sc.get("expect_no_new_skill") and new_skills:
|
||||
print(" ⚠ 不应新建技能,但创建了")
|
||||
|
||||
print("\n【给用户的回复】")
|
||||
if captured["reply"]:
|
||||
for line in captured["reply"].splitlines():
|
||||
print(f" {line}")
|
||||
else:
|
||||
print(" (无推送)")
|
||||
|
||||
reply_ok = bool(captured["reply"]) == bool(evolved)
|
||||
results.append((sc["name"], sc["goal"], evolved, changed, reply_ok))
|
||||
agent_bridge.agents.pop(sid, None)
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
finally:
|
||||
ex._notify_user = orig_notify
|
||||
|
||||
# Summary table.
|
||||
print("\n" + "=" * 72)
|
||||
print("汇总 (deepseek-v4-flash 真实运行)")
|
||||
print("-" * 72)
|
||||
for name, goal, evolved, changed, reply_ok in results:
|
||||
exp = "静默" if goal == "none" else "应进化"
|
||||
got = "进化" if evolved else "静默"
|
||||
mark = "✓" if (goal == "none") != evolved else "✗"
|
||||
print(f" {mark} {name:42s} 预期={exp} 实际={got}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--real" in sys.argv:
|
||||
run_real()
|
||||
else:
|
||||
ok = run_stub()
|
||||
sys.exit(0 if ok else 1)
|
||||
24
tests/test_invariant_bash.py
Normal file
24
tests/test_invariant_bash.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
from agent.tools.bash.bash import Bash
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
"cat ~/.cow/.env",
|
||||
"cat .cow/.env",
|
||||
"less ~/.cow/.env",
|
||||
"cat /home/user/.cow/.env",
|
||||
])
|
||||
def test_credential_file_access_is_blocked(command):
|
||||
result = Bash().execute({"command": command})
|
||||
assert result.status == "error", f"Expected blocked result for: {command}"
|
||||
assert "Access denied" in str(result.result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
"ls ~/.cow/skills",
|
||||
"ls ~/.cow/",
|
||||
"echo hello",
|
||||
])
|
||||
def test_legitimate_cow_directory_access_is_not_blocked(command):
|
||||
result = Bash().execute({"command": command})
|
||||
assert "Access denied" not in str(result.result)
|
||||
@@ -1,7 +1,7 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
Unit tests for MiniMax provider additions:
|
||||
- MiniMax-M2.7-highspeed constant in const.py
|
||||
- MiniMax-M3 / M2.7 / M2.7-highspeed constants in const.py
|
||||
- Default model update in MinimaxBot
|
||||
- MinimaxVoice TTS provider
|
||||
"""
|
||||
@@ -16,7 +16,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
class TestMinimaxConst(unittest.TestCase):
|
||||
"""Test that MiniMax-M2.7-highspeed is properly registered in const.py."""
|
||||
"""Test that MiniMax M3 / M2.7 constants are properly registered in const.py."""
|
||||
|
||||
def test_m3_constant_defined(self):
|
||||
from common import const
|
||||
self.assertTrue(hasattr(const, "MINIMAX_M3"))
|
||||
self.assertEqual(const.MINIMAX_M3, "MiniMax-M3")
|
||||
|
||||
def test_m2_7_highspeed_constant_defined(self):
|
||||
from common import const
|
||||
@@ -27,6 +32,10 @@ class TestMinimaxConst(unittest.TestCase):
|
||||
from common import const
|
||||
self.assertEqual(const.MINIMAX_M2_7, "MiniMax-M2.7")
|
||||
|
||||
def test_m3_in_model_list(self):
|
||||
from common import const
|
||||
self.assertIn("MiniMax-M3", const.MODEL_LIST)
|
||||
|
||||
def test_m2_7_highspeed_in_model_list(self):
|
||||
from common import const
|
||||
self.assertIn("MiniMax-M2.7-highspeed", const.MODEL_LIST)
|
||||
@@ -41,9 +50,9 @@ class TestMinimaxConst(unittest.TestCase):
|
||||
|
||||
|
||||
class TestMinimaxBotDefaultModel(unittest.TestCase):
|
||||
"""Test that MinimaxBot defaults to MiniMax-M2.7."""
|
||||
"""Test that MinimaxBot defaults to MiniMax-M3."""
|
||||
|
||||
def test_default_model_is_m2_7(self):
|
||||
def test_default_model_is_m3(self):
|
||||
# Patch conf() to return empty config
|
||||
mock_conf = MagicMock()
|
||||
mock_conf.get = MagicMock(side_effect=lambda key, default=None: default)
|
||||
@@ -57,18 +66,18 @@ class TestMinimaxBotDefaultModel(unittest.TestCase):
|
||||
with patch("models.minimax.minimax_bot.conf", return_value=mock_conf):
|
||||
bot = minimax_bot.MinimaxBot.__new__(minimax_bot.MinimaxBot)
|
||||
bot.args = {
|
||||
"model": mock_conf.get("model") or "MiniMax-M2.7",
|
||||
"model": mock_conf.get("model") or "MiniMax-M3",
|
||||
}
|
||||
self.assertEqual(bot.args["model"], "MiniMax-M2.7")
|
||||
self.assertEqual(bot.args["model"], "MiniMax-M3")
|
||||
|
||||
def test_default_model_string(self):
|
||||
"""Verify the fallback string literal in minimax_bot.py is MiniMax-M2.7."""
|
||||
"""Verify the fallback string literal in minimax_bot.py is MiniMax-M3."""
|
||||
import ast
|
||||
bot_path = os.path.join(os.path.dirname(__file__), "..", "models", "minimax", "minimax_bot.py")
|
||||
with open(bot_path) as f:
|
||||
source = f.read()
|
||||
# Verify MiniMax-M2.7 is in the source (not M2.1)
|
||||
self.assertIn("MiniMax-M2.7", source)
|
||||
# Verify MiniMax-M3 is in the source (not the older default)
|
||||
self.assertIn("MiniMax-M3", source)
|
||||
self.assertNotIn('"MiniMax-M2.1"', source)
|
||||
|
||||
|
||||
|
||||
99
tests/test_models_handler.py
Normal file
99
tests/test_models_handler.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# encoding:utf-8
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
if "web" not in sys.modules:
|
||||
web_stub = types.ModuleType("web")
|
||||
web_stub.HTTPError = type("HTTPError", (Exception,), {})
|
||||
web_stub.cookies = lambda: {}
|
||||
web_stub.header = lambda *args, **kwargs: None
|
||||
web_stub.data = lambda: b"{}"
|
||||
web_stub.input = lambda **kwargs: types.SimpleNamespace(**kwargs)
|
||||
web_stub.setcookie = lambda *args, **kwargs: None
|
||||
web_stub.seeother = lambda *args, **kwargs: Exception("seeother")
|
||||
web_stub.notfound = lambda *args, **kwargs: Exception("notfound")
|
||||
web_stub.badrequest = lambda *args, **kwargs: Exception("badrequest")
|
||||
web_stub.application = lambda *args, **kwargs: types.SimpleNamespace(wsgifunc=lambda: None)
|
||||
web_stub.httpserver = types.SimpleNamespace(
|
||||
LogMiddleware=type("LogMiddleware", (), {"log": lambda *args, **kwargs: None}),
|
||||
StaticMiddleware=lambda app: app,
|
||||
WSGIServer=lambda *args, **kwargs: types.SimpleNamespace(serve_forever=lambda: None),
|
||||
)
|
||||
sys.modules["web"] = web_stub
|
||||
|
||||
|
||||
class TestModelsHandler(unittest.TestCase):
|
||||
def test_set_asr_capability_persists_provider_and_model(self):
|
||||
from channel.web.web_channel import ModelsHandler
|
||||
|
||||
local_config = {}
|
||||
file_config = {}
|
||||
handler = ModelsHandler()
|
||||
|
||||
with patch("channel.web.web_channel.conf", return_value=local_config):
|
||||
with patch.object(ModelsHandler, "_read_file_config", return_value=file_config):
|
||||
with patch.object(ModelsHandler, "_write_file_config") as write_file:
|
||||
with patch.object(ModelsHandler, "_refresh_voice_routing") as refresh_voice:
|
||||
result = json.loads(handler._handle_set_capability({
|
||||
"capability": "asr",
|
||||
"provider_id": "dashscope",
|
||||
"model": "qwen3-asr-flash",
|
||||
}))
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(local_config["voice_to_text"], "dashscope")
|
||||
self.assertEqual(local_config["voice_to_text_model"], "qwen3-asr-flash")
|
||||
self.assertEqual(file_config["voice_to_text"], "dashscope")
|
||||
self.assertEqual(file_config["voice_to_text_model"], "qwen3-asr-flash")
|
||||
write_file.assert_called_once_with(file_config)
|
||||
refresh_voice.assert_called_once()
|
||||
|
||||
def test_set_asr_empty_model_keeps_existing(self):
|
||||
# Switching provider with an empty model must not wipe a user's
|
||||
# hand-configured voice_to_text_model.
|
||||
from channel.web.web_channel import ModelsHandler
|
||||
|
||||
local_config = {"voice_to_text_model": "qwen3-asr-flash"}
|
||||
file_config = {"voice_to_text_model": "qwen3-asr-flash"}
|
||||
handler = ModelsHandler()
|
||||
|
||||
with patch("channel.web.web_channel.conf", return_value=local_config):
|
||||
with patch.object(ModelsHandler, "_read_file_config", return_value=file_config):
|
||||
with patch.object(ModelsHandler, "_write_file_config"):
|
||||
with patch.object(ModelsHandler, "_refresh_voice_routing"):
|
||||
result = json.loads(handler._handle_set_capability({
|
||||
"capability": "asr",
|
||||
"provider_id": "zhipu",
|
||||
"model": "",
|
||||
}))
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(local_config["voice_to_text"], "zhipu")
|
||||
# Existing model preserved, not overwritten with "".
|
||||
self.assertEqual(local_config["voice_to_text_model"], "qwen3-asr-flash")
|
||||
self.assertEqual(file_config["voice_to_text_model"], "qwen3-asr-flash")
|
||||
self.assertEqual(result["model"], "qwen3-asr-flash")
|
||||
|
||||
def test_asr_capability_exposes_provider_models(self):
|
||||
from channel.web.web_channel import ModelsHandler
|
||||
|
||||
cap = ModelsHandler._asr_capability({
|
||||
"voice_to_text": "dashscope",
|
||||
"voice_to_text_model": "qwen3-asr-flash",
|
||||
})
|
||||
|
||||
self.assertTrue(cap["editable"])
|
||||
self.assertEqual(cap["current_provider"], "dashscope")
|
||||
self.assertEqual(cap["current_model"], "qwen3-asr-flash")
|
||||
self.assertIn("provider_models", cap)
|
||||
self.assertIn("dashscope", cap["provider_models"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user