mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01373465b0 | ||
|
|
3b3ef715bb | ||
|
|
47b2bf9d46 | ||
|
|
ea47f3097e | ||
|
|
70c1c44d15 | ||
|
|
e3dce45b2a | ||
|
|
3bb8ec3bea | ||
|
|
35e42a3ad6 | ||
|
|
949575ad14 | ||
|
|
1c34f0f03d | ||
|
|
eed2eab014 | ||
|
|
381cbed4fd | ||
|
|
4cde25325d | ||
|
|
8d70af5e89 | ||
|
|
b3408d8e5f | ||
|
|
e74906fbec | ||
|
|
52209217fc | ||
|
|
018493a60b | ||
|
|
630373b1f0 | ||
|
|
dec31dfd75 | ||
|
|
2397ea019e | ||
|
|
77da90e316 | ||
|
|
18ce17d21a | ||
|
|
ce09b14836 | ||
|
|
e7a069b060 | ||
|
|
6e3933be30 | ||
|
|
e2cb9e11b0 | ||
|
|
d281a34c6f | ||
|
|
c97cf5610f | ||
|
|
ab674a3517 | ||
|
|
7d63e7d8fa | ||
|
|
6538843bdf | ||
|
|
bd5fede122 | ||
|
|
047fb57630 | ||
|
|
583c1de5ba | ||
|
|
c9c293f67c | ||
|
|
561631baba | ||
|
|
80d0a6aeb2 | ||
|
|
6b5ee245ae | ||
|
|
5c43c2f519 | ||
|
|
9387980e74 | ||
|
|
075d9fc608 | ||
|
|
63bfab03f6 | ||
|
|
1d7e6b3703 | ||
|
|
ad64e17a34 | ||
|
|
0092376c07 | ||
|
|
6fb19a68b5 | ||
|
|
d5427d967a | ||
|
|
7fd30b608c | ||
|
|
830b05f243 | ||
|
|
e85290cddc | ||
|
|
1940d628a8 | ||
|
|
cffa590d3e | ||
|
|
402e2bfee0 | ||
|
|
f5caba81d6 | ||
|
|
354350dec9 | ||
|
|
0513298f57 | ||
|
|
08e23e5bd8 | ||
|
|
e812c7d29a | ||
|
|
ef46199346 | ||
|
|
7c9ea62993 | ||
|
|
8cb53e6129 | ||
|
|
12c0383dc8 | ||
|
|
83b53039f3 | ||
|
|
7e6a309935 | ||
|
|
33c03e30d9 | ||
|
|
1f1abdd7b6 | ||
|
|
16134bd150 | ||
|
|
c887fc71ad | ||
|
|
9fc39f648f | ||
|
|
ec9557e3d8 | ||
|
|
7cf0f7d42d | ||
|
|
b7aa64279d | ||
|
|
26300a8d43 | ||
|
|
8dd21ddb83 | ||
|
|
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 | ||
|
|
04d28f9d2d |
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Thanks for your contribution! Please write this PR in English.
|
Thanks for your contribution! Please write this PR in English.
|
||||||
【中文开发者】请使用英文填写,感谢 ❤️
|
推荐使用英文填写,感谢 ❤️
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## What does this PR do?
|
## What does this PR do?
|
||||||
@@ -16,6 +16,7 @@ Thanks for your contribution! Please write this PR in English.
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md)
|
||||||
- [ ] I tested this change locally
|
- [ ] I tested this change locally
|
||||||
- [ ] Code comments and docs are in English
|
- [ ] Code comments and docs are in English
|
||||||
- [ ] Linked related issue (if any): closes #
|
- [ ] Linked related issue (if any): closes #
|
||||||
|
|||||||
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Windows Bash Streaming Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "agent/tools/bash/bash.py"
|
||||||
|
- "tests/test_bash_streaming.py"
|
||||||
|
- ".github/workflows/test-windows-bash.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-bash-tests:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install pytest
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run Windows Bash streaming tests
|
||||||
|
run: python -m pytest tests/test_bash_streaming.py -v
|
||||||
37
README.md
37
README.md
@@ -1,13 +1,21 @@
|
|||||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/github/license/zhayujie/CowAgent" alt="License: MIT"></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" alt="Stars"></a> <br/>
|
<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>]
|
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, and grows alongside you through a personal knowledge base and long-term memory — a reference implementation of Agent Harness engineering.
|
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering.
|
||||||
|
|
||||||
CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms.
|
CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms.
|
||||||
|
|
||||||
@@ -28,6 +36,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
|||||||
| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached |
|
| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached |
|
||||||
| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval |
|
| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval |
|
||||||
| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing |
|
| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing |
|
||||||
|
| [Evolution](https://docs.cowagent.ai/memory/self-evolution) | Self-Evolution reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use |
|
||||||
| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation |
|
| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation |
|
||||||
| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration |
|
| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration |
|
||||||
| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack |
|
| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack |
|
||||||
@@ -39,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
|||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||||
|
|
||||||
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
|
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
|
||||||
|
|
||||||
@@ -94,15 +103,15 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
|
|
||||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [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 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -190,6 +199,10 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
|||||||
|
|
||||||
## 🏷 Changelog
|
## 🏷 Changelog
|
||||||
|
|
||||||
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
||||||
|
|
||||||
|
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.
|
||||||
|
|
||||||
> **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.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.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.
|
||||||
@@ -238,9 +251,9 @@ For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https:/
|
|||||||
|
|
||||||
## 🛠️ Development & Contributing
|
## 🛠️ 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
|
## 🌟 Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class ChatService:
|
|||||||
agent.model.channel_type = channel_type or ""
|
agent.model.channel_type = channel_type or ""
|
||||||
agent.model.session_id = session_id or ""
|
agent.model.session_id = session_id or ""
|
||||||
|
|
||||||
|
# Build a context so context-aware tools (e.g. scheduler) can resolve the
|
||||||
|
# receiver/session. This streaming path bypasses agent_bridge.agent_reply,
|
||||||
|
# so the attach step that normally happens there must be done here too.
|
||||||
|
context = self._build_context(query, session_id, channel_type)
|
||||||
|
self._attach_context_aware_tools(agent, context)
|
||||||
|
|
||||||
|
# Mark this session as mid-run so the self-evolution idle scan does not
|
||||||
|
# fire concurrently when a single turn runs longer than idle_minutes.
|
||||||
|
self._mark_run_active(agent, True)
|
||||||
|
|
||||||
# State shared between the event callback and this method
|
# State shared between the event callback and this method
|
||||||
state = _StreamState()
|
state = _StreamState()
|
||||||
|
|
||||||
@@ -171,6 +181,12 @@ class ChatService:
|
|||||||
|
|
||||||
from agent.protocol.agent_stream import AgentStreamExecutor
|
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(
|
executor = AgentStreamExecutor(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
model=agent.model,
|
model=agent.model,
|
||||||
@@ -180,6 +196,7 @@ class ChatService:
|
|||||||
on_event=on_event,
|
on_event=on_event,
|
||||||
messages=messages_copy,
|
messages=messages_copy,
|
||||||
max_context_turns=max_context_turns,
|
max_context_turns=max_context_turns,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -191,6 +208,15 @@ class ChatService:
|
|||||||
agent.messages.clear()
|
agent.messages.clear()
|
||||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
# Clear the mid-run flag so idle scans can review this session again.
|
||||||
|
self._mark_run_active(agent, False)
|
||||||
|
# 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).
|
# Sync executor messages back to agent (thread-safe).
|
||||||
# The executor may have trimmed context, making its list shorter than
|
# The executor may have trimmed context, making its list shorter than
|
||||||
@@ -254,10 +280,68 @@ class ChatService:
|
|||||||
# Execute post-process tools
|
# Execute post-process tools
|
||||||
agent._execute_post_process_tools()
|
agent._execute_post_process_tools()
|
||||||
|
|
||||||
|
# Record this user turn for the self-evolution idle trigger. This
|
||||||
|
# streaming path bypasses agent_bridge.agent_reply, so the activity must
|
||||||
|
# be noted here, otherwise idle scans never see any signal to evolve.
|
||||||
|
self._note_evolution_turn(agent, context)
|
||||||
|
|
||||||
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_context(query: str, session_id: str, channel_type: str):
|
||||||
|
"""Build a Context for tool resolution on the streaming chat path.
|
||||||
|
|
||||||
|
receiver falls back to session_id; the scheduler's delivery keys on
|
||||||
|
session_id as the receiver.
|
||||||
|
"""
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
# Pass an explicit kwargs dict: Context's default kwargs is a shared
|
||||||
|
# mutable default, so omitting it would leak fields across sessions.
|
||||||
|
ctx = Context(ContextType.TEXT, query, kwargs={})
|
||||||
|
ctx["session_id"] = session_id
|
||||||
|
ctx["receiver"] = session_id
|
||||||
|
ctx["isgroup"] = False
|
||||||
|
ctx["channel_type"] = channel_type or ""
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_context_aware_tools(agent, context):
|
||||||
|
"""Attach the current context to tools that need it (scheduler)."""
|
||||||
|
try:
|
||||||
|
if not (context and getattr(agent, "tools", None)):
|
||||||
|
return
|
||||||
|
for tool in agent.tools:
|
||||||
|
if tool.name == "scheduler":
|
||||||
|
from agent.tools.scheduler.integration import attach_scheduler_to_tool
|
||||||
|
attach_scheduler_to_tool(tool, context)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mark_run_active(agent, active):
|
||||||
|
"""Toggle the self-evolution mid-run flag for this session's agent."""
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import mark_run_active
|
||||||
|
mark_run_active(agent, active)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _note_evolution_turn(agent, context):
|
||||||
|
"""Record a user turn so the self-evolution idle trigger has signal."""
|
||||||
|
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 single chats get a proactive push target; group push is noisy.
|
||||||
|
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
||||||
try:
|
try:
|
||||||
|
|||||||
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 = 10
|
||||||
|
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,
|
||||||
|
)
|
||||||
551
agent/evolution/executor.py
Normal file
551
agent/evolution/executor.py
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
"""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 re
|
||||||
|
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, run workspace scripts, and
|
||||||
|
# edit memory/skill files. bash is needed by skill-creator's init script and is
|
||||||
|
# confined to the workspace by _BashWorkspaceGuard.
|
||||||
|
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "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)
|
||||||
|
|
||||||
|
|
||||||
|
class _BashWorkspaceGuard:
|
||||||
|
"""Wraps the bash tool so evolution can only run commands inside the
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
Evolution needs bash for skill-creator's init script, but it runs
|
||||||
|
unattended in the background, so a raw shell is too broad. This guard:
|
||||||
|
- forces the command to execute with cwd = workspace,
|
||||||
|
- rejects commands that reference an absolute path or ``..`` segment
|
||||||
|
pointing OUTSIDE the workspace (the common ways to escape it).
|
||||||
|
It is a coarse textual check, not a sandbox — paired with the model's
|
||||||
|
instruction to only run skill-creator scripts, it keeps writes local.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, inner, workspace_dir: str):
|
||||||
|
self._inner = inner
|
||||||
|
self._ws = Path(workspace_dir).resolve()
|
||||||
|
# Pin the shell's working directory to the workspace.
|
||||||
|
try:
|
||||||
|
self._inner.cwd = str(self._ws)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
return self.execute(params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Evolution] guarded bash error: {e}")
|
||||||
|
from agent.tools.base_tool import ToolResult
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
|
||||||
|
def _escapes_workspace(self, command: str) -> bool:
|
||||||
|
# Absolute paths that are not under the workspace.
|
||||||
|
for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command):
|
||||||
|
try:
|
||||||
|
resolved = Path(tok).resolve()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if self._ws != resolved and self._ws not in resolved.parents:
|
||||||
|
return True
|
||||||
|
# Parent-dir traversal that climbs above the workspace.
|
||||||
|
for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command):
|
||||||
|
try:
|
||||||
|
resolved = (self._ws / tok).resolve()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if self._ws != resolved and self._ws not in resolved.parents:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, args):
|
||||||
|
from agent.tools.base_tool import ToolResult
|
||||||
|
command = (args.get("command") or "").strip()
|
||||||
|
if command and self._escapes_workspace(command):
|
||||||
|
return ToolResult.fail(
|
||||||
|
"Error: evolution may only run commands inside the workspace; "
|
||||||
|
"this command references a path outside it and was blocked."
|
||||||
|
)
|
||||||
|
return self._inner.execute(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_tools(tools: list, workspace_dir: str) -> list:
|
||||||
|
"""Wrap write/edit/bash tools with workspace guards; leave others as-is."""
|
||||||
|
guarded = []
|
||||||
|
for t in tools:
|
||||||
|
name = getattr(t, "name", None)
|
||||||
|
if name in _WRITE_TOOLS:
|
||||||
|
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
|
||||||
|
elif name == "bash":
|
||||||
|
guarded.append(_BashWorkspaceGuard(t, workspace_dir))
|
||||||
|
else:
|
||||||
|
guarded.append(t)
|
||||||
|
return guarded
|
||||||
|
|
||||||
|
|
||||||
|
# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is
|
||||||
|
# watched too: evolution may rarely refine the assistant's persona/style there.
|
||||||
|
_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.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")
|
||||||
|
# Files the skill subsystem maintains automatically (the enable/disable index).
|
||||||
|
# Not an evolution result, so a rewrite must not count as a change signal.
|
||||||
|
_WATCH_IGNORE_NAMES = ("skills_config.json",)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
if p.name in _WATCH_IGNORE_NAMES:
|
||||||
|
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():
|
||||||
|
# Routine no-op: the per-minute scan hits every idle session. Advance
|
||||||
|
# the cursor so we don't re-scan the same tail; no log (pure noise).
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
# AGENT.md (persona) is backed up too so a rare persona edit is undoable.
|
||||||
|
# Persona is workspace-global (not per-user): it always lives at the
|
||||||
|
# workspace root, regardless of user_id.
|
||||||
|
agent_file = Path(workspace_dir) / "AGENT.md"
|
||||||
|
backup_files = [Path(memory_file), today_daily, agent_file]
|
||||||
|
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="",
|
||||||
|
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=True,
|
||||||
|
runtime_info=getattr(agent, "runtime_info", None),
|
||||||
|
)
|
||||||
|
# Reuse the live model so it follows the user's configured model.
|
||||||
|
review_agent.model = agent.model
|
||||||
|
# Inject the evolution task brief AFTER the full system prompt: the agent
|
||||||
|
# gets the full context (tools, workspace, user preferences, memory, time)
|
||||||
|
# AND its evolution-specific instructions on top, instead of one
|
||||||
|
# overwriting the other.
|
||||||
|
review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Respect an explicit silent verdict: empty, exactly [SILENT], or text
|
||||||
|
# that STARTS with [SILENT] means the model chose to stay quiet.
|
||||||
|
if not result or result.startswith(SILENT_TOKEN):
|
||||||
|
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Anti-nag backstop: if the model wrote a summary but actually changed no
|
||||||
|
# watched file, stay silent — never notify about work that didn't happen.
|
||||||
|
if not _workspace_changed(workspace_dir, pre_snapshot):
|
||||||
|
logger.info(
|
||||||
|
f"[Evolution] ✗ session={session_id}: text produced but no file "
|
||||||
|
f"changed — staying silent"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# The model produced a real summary. Strip any stray [SILENT] tokens it
|
||||||
|
# left mid-text, then notify.
|
||||||
|
result = result.replace(SILENT_TOKEN, "").strip()
|
||||||
|
if not result:
|
||||||
|
logger.info(f"[Evolution] ✗ No change for session={session_id} ([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)
|
||||||
|
# The injection appended its own messages ([SCHEDULED]/[EVOLUTION]).
|
||||||
|
# Advance the cursor past them so the next scan does not treat
|
||||||
|
# evolution's own bookkeeping as new user content and re-trigger.
|
||||||
|
try:
|
||||||
|
with agent.messages_lock:
|
||||||
|
agent._evo_done_msg_count = len(agent.messages)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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}")
|
||||||
170
agent/evolution/prompts.py
Normal file
170
agent/evolution/prompts.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""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.
|
||||||
|
- Signal types: skill, unfinished task, memory, knowledge.
|
||||||
|
- 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. Follow the
|
||||||
|
`skill-creator` skill's conventions (read its SKILL.md for the required
|
||||||
|
structure), then create `skills/<name>/SKILL.md` by WRITING the file
|
||||||
|
directly with the write tool — this is the simplest reliable path. (bash
|
||||||
|
is available and confined to the workspace if a helper script is truly
|
||||||
|
needed, but a direct write is preferred.) 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 — RARE, last resort. Default to writing NOTHING here. The main
|
||||||
|
assistant already writes memory during the chat, and a nightly pass plus
|
||||||
|
context-overflow saves are dedicated safety nets — so memory is almost always
|
||||||
|
already covered without you. Skip unless the main assistant clearly missed a
|
||||||
|
durable fact that belongs in no skill AND would visibly change future replies.
|
||||||
|
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
|
||||||
|
conversation. Treat it as precious: edit it in place to CORRECT a wrong
|
||||||
|
fact, or append a new durable preference/decision/lesson — but do so
|
||||||
|
SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles
|
||||||
|
routine consolidation).
|
||||||
|
- For a NEW fact that is important but not yet clearly lasting, append ONE
|
||||||
|
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
|
||||||
|
daily file is the safe place — but first ask whether this really belongs
|
||||||
|
in a skill.
|
||||||
|
- PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal
|
||||||
|
about the assistant's own identity/personality/style, make a small edit to
|
||||||
|
AGENT.md; never for user/world facts, and when in doubt do nothing.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
4. KNOWLEDGE — only if the conversation produced durable, reusable reference
|
||||||
|
knowledge on a topic (the kind worth looking up again) that the main
|
||||||
|
assistant did NOT already save to `knowledge/`. Add or update the relevant
|
||||||
|
file there. Like memory, this is the exception: skip routine Q&A, and if the
|
||||||
|
topic is already covered in `knowledge/`, do NOTHING rather than duplicate.
|
||||||
|
|
||||||
|
# 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 speaks in the conversation transcript. Write it for an ordinary user, in plain
|
||||||
|
everyday words — NOT a developer report. No need to expose internal details
|
||||||
|
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
|
||||||
|
what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let
|
||||||
|
the user know they can undo it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
protected_note = ""
|
||||||
|
if protected_skills:
|
||||||
|
names = ", ".join(sorted(protected_skills))
|
||||||
|
protected_note = (
|
||||||
|
"\n\nPROTECTED skills (built-in — never edit these): "
|
||||||
|
f"{names}\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from common import i18n
|
||||||
|
lang_name = "中文" if i18n.is_zh() else "English"
|
||||||
|
except Exception:
|
||||||
|
lang_name = "中文"
|
||||||
|
return (
|
||||||
|
"Here is the conversation transcript that just went idle. Review it per "
|
||||||
|
"your instructions. Acting is the exception: the main value is fixing or "
|
||||||
|
"creating a skill and finishing promised work. Memory and knowledge are "
|
||||||
|
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
|
||||||
|
"not already covered."
|
||||||
|
f"{protected_note}\n"
|
||||||
|
f"The summary should preferably be written in: {lang_name}\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}")
|
||||||
151
agent/evolution/trigger.py
Normal file
151
agent/evolution/trigger.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""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 mark_run_active(agent, active: bool) -> None:
|
||||||
|
"""Flag whether the agent is mid-run, so idle scans skip a busy session.
|
||||||
|
|
||||||
|
Without this, a single run that lasts longer than idle_minutes would let
|
||||||
|
the scanner fire an evolution pass concurrently with the live turn.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
agent._evo_run_active = bool(active)
|
||||||
|
if active:
|
||||||
|
agent._evo_last_active = time.time()
|
||||||
|
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:
|
||||||
|
# Skip sessions whose agent is mid-run: a long turn must not be
|
||||||
|
# reviewed while it is still producing the answer.
|
||||||
|
if getattr(agent, "_evo_run_active", False):
|
||||||
|
continue
|
||||||
|
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}")
|
||||||
@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Iterable
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
from agent.memory.config import MemoryConfig
|
||||||
|
from agent.memory.manager import MemoryManager
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeService:
|
class KnowledgeService:
|
||||||
@@ -25,9 +30,189 @@ class KnowledgeService:
|
|||||||
Operates directly on the filesystem.
|
Operates directly on the filesystem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, workspace_root: str):
|
PROTECTED_FILES = {"index.md", "log.md"}
|
||||||
self.workspace_root = workspace_root
|
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||||
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
|
|
||||||
|
def __init__(self, workspace_root: str, memory_manager=None):
|
||||||
|
self.workspace_root = os.path.abspath(workspace_root)
|
||||||
|
self.knowledge_dir = os.path.join(self.workspace_root, "knowledge")
|
||||||
|
self._memory_manager = memory_manager
|
||||||
|
|
||||||
|
def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None,
|
||||||
|
allow_missing: bool = True) -> tuple:
|
||||||
|
if not isinstance(rel_path, str) or not rel_path.strip():
|
||||||
|
raise ValueError("path is required")
|
||||||
|
rel_path = rel_path.replace("\\", "/").strip("/")
|
||||||
|
parts = rel_path.split("/")
|
||||||
|
if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts):
|
||||||
|
raise ValueError("invalid path")
|
||||||
|
if kind == "document" and not rel_path.lower().endswith(".md"):
|
||||||
|
raise ValueError("document path must end with .md")
|
||||||
|
|
||||||
|
root = Path(self.knowledge_dir).resolve()
|
||||||
|
candidate = root.joinpath(*parts)
|
||||||
|
# Resolve the nearest existing ancestor so a symlink cannot be used
|
||||||
|
# to escape when the final destination does not exist yet.
|
||||||
|
ancestor = candidate
|
||||||
|
while not ancestor.exists() and ancestor != root:
|
||||||
|
ancestor = ancestor.parent
|
||||||
|
try:
|
||||||
|
ancestor.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("path outside knowledge dir")
|
||||||
|
if candidate.exists():
|
||||||
|
try:
|
||||||
|
candidate.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("path outside knowledge dir")
|
||||||
|
elif not allow_missing:
|
||||||
|
raise FileNotFoundError(f"path not found: {rel_path}")
|
||||||
|
return rel_path, candidate
|
||||||
|
|
||||||
|
def _ensure_not_protected(self, rel_path: str):
|
||||||
|
if rel_path in self.PROTECTED_FILES:
|
||||||
|
raise ValueError(f"protected knowledge file: {rel_path}")
|
||||||
|
|
||||||
|
def _manager(self):
|
||||||
|
if self._memory_manager is None:
|
||||||
|
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
|
||||||
|
return self._memory_manager
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_sync(coro):
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro)
|
||||||
|
result = []
|
||||||
|
error = []
|
||||||
|
|
||||||
|
def runner():
|
||||||
|
try:
|
||||||
|
result.append(asyncio.run(coro))
|
||||||
|
except Exception as exc:
|
||||||
|
error.append(exc)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=runner)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
if error:
|
||||||
|
raise error[0]
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def _sync_index(self, old_paths: Iterable[str]):
|
||||||
|
old_paths = sorted(set(old_paths))
|
||||||
|
if not old_paths:
|
||||||
|
return
|
||||||
|
manager = self._manager()
|
||||||
|
for rel_path in old_paths:
|
||||||
|
manager.storage.delete_by_path(f"knowledge/{rel_path}")
|
||||||
|
manager.mark_dirty()
|
||||||
|
self._run_sync(manager.sync())
|
||||||
|
|
||||||
|
def create_category(self, path: str) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
|
if full_path.exists():
|
||||||
|
return {"path": rel_path, "created": False, "reason": "already_exists"}
|
||||||
|
full_path.mkdir(parents=True)
|
||||||
|
return {"path": rel_path, "created": True}
|
||||||
|
|
||||||
|
def rename_category(self, path: str, new_path: str) -> dict:
|
||||||
|
old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False)
|
||||||
|
new_rel, new_full = self._resolve_path(new_path, kind="category")
|
||||||
|
if not old_full.is_dir():
|
||||||
|
raise ValueError(f"not a category: {old_rel}")
|
||||||
|
if new_full.exists():
|
||||||
|
raise FileExistsError(f"target already exists: {new_rel}")
|
||||||
|
old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/")
|
||||||
|
for p in old_full.rglob("*.md") if p.is_file()]
|
||||||
|
new_full.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
old_full.rename(new_full)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"}
|
||||||
|
except FileExistsError:
|
||||||
|
raise FileExistsError(f"target already exists: {new_rel}")
|
||||||
|
old_paths = [f"{old_rel}/{p}" for p in old_documents]
|
||||||
|
self._sync_index(old_paths)
|
||||||
|
return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)}
|
||||||
|
|
||||||
|
def delete_category(self, path: str, confirm: bool = False) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
|
if not full_path.exists():
|
||||||
|
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||||
|
if not full_path.is_dir():
|
||||||
|
raise ValueError(f"not a category: {rel_path}")
|
||||||
|
knowledge_root = Path(self.knowledge_dir).resolve()
|
||||||
|
documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/")
|
||||||
|
for p in full_path.rglob("*.md") if p.is_file()]
|
||||||
|
if any(p in self.PROTECTED_FILES for p in documents):
|
||||||
|
raise ValueError("category contains protected knowledge files")
|
||||||
|
if any(full_path.iterdir()) and not confirm:
|
||||||
|
raise ValueError("category is not empty; confirmation is required")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(full_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||||
|
self._sync_index(documents)
|
||||||
|
return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)}
|
||||||
|
|
||||||
|
def delete_documents(self, paths: Iterable[str]) -> dict:
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
raise ValueError("paths must be a list")
|
||||||
|
results = []
|
||||||
|
deleted = []
|
||||||
|
for path in paths:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if not full_path.exists():
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||||
|
continue
|
||||||
|
if not full_path.is_file():
|
||||||
|
raise ValueError(f"not a document: {rel_path}")
|
||||||
|
try:
|
||||||
|
full_path.unlink()
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": True})
|
||||||
|
except FileNotFoundError:
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||||
|
self._sync_index(deleted)
|
||||||
|
return {"results": results, "deleted": sum(1 for item in results if item["deleted"])}
|
||||||
|
|
||||||
|
def move_documents(self, paths: Iterable[str], target_category: str) -> dict:
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
raise ValueError("paths must be a list")
|
||||||
|
target_rel, target_full = self._resolve_path(target_category, kind="category")
|
||||||
|
if not target_full.is_dir():
|
||||||
|
raise FileNotFoundError(f"category not found: {target_rel}")
|
||||||
|
results = []
|
||||||
|
moved_old_paths = []
|
||||||
|
for path in paths:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if not full_path.exists():
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||||
|
continue
|
||||||
|
destination = target_full / full_path.name
|
||||||
|
new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/")
|
||||||
|
if destination.exists():
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||||
|
"target": new_rel})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.link(full_path, destination)
|
||||||
|
full_path.unlink()
|
||||||
|
moved_old_paths.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "moved": True, "target": new_rel})
|
||||||
|
except FileExistsError:
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||||
|
"target": new_rel})
|
||||||
|
except FileNotFoundError:
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||||
|
self._sync_index(moved_old_paths)
|
||||||
|
return {"results": results, "moved": len(moved_old_paths)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# list — directory tree with stats
|
# list — directory tree with stats
|
||||||
@@ -121,15 +306,8 @@ class KnowledgeService:
|
|||||||
:raises ValueError: if path is invalid or escapes knowledge dir
|
:raises ValueError: if path is invalid or escapes knowledge dir
|
||||||
:raises FileNotFoundError: if file does not exist
|
:raises FileNotFoundError: if file does not exist
|
||||||
"""
|
"""
|
||||||
if not rel_path or ".." in rel_path:
|
rel_path, full_path = self._resolve_path(rel_path, kind="document")
|
||||||
raise ValueError("invalid path")
|
if not full_path.is_file():
|
||||||
|
|
||||||
full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path))
|
|
||||||
allowed = os.path.normpath(self.knowledge_dir)
|
|
||||||
if not full_path.startswith(allowed + os.sep) and full_path != allowed:
|
|
||||||
raise ValueError("path outside knowledge dir")
|
|
||||||
|
|
||||||
if not os.path.isfile(full_path):
|
|
||||||
raise FileNotFoundError(f"file not found: {rel_path}")
|
raise FileNotFoundError(f"file not found: {rel_path}")
|
||||||
|
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
@@ -228,13 +406,26 @@ class KnowledgeService:
|
|||||||
result = self.build_graph()
|
result = self.build_graph()
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "create_category":
|
||||||
|
result = self.create_category(payload.get("path"))
|
||||||
|
elif action == "rename_category":
|
||||||
|
result = self.rename_category(payload.get("path"), payload.get("new_path"))
|
||||||
|
elif action == "delete_category":
|
||||||
|
result = self.delete_category(payload.get("path"), payload.get("confirm", False))
|
||||||
|
elif action == "delete_documents":
|
||||||
|
result = self.delete_documents(payload.get("paths") or [])
|
||||||
|
elif action == "move_documents":
|
||||||
|
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
||||||
else:
|
else:
|
||||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
||||||
|
except FileExistsError as e:
|
||||||
|
return {"action": action, "code": 409, "message": str(e), "payload": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
||||||
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -109,6 +110,48 @@ def _extract_display_text(content: Any) -> str:
|
|||||||
return ""
|
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 _is_evolution_text(text: str) -> bool:
|
||||||
|
"""True if assistant text is a self-evolution summary (before cleaning)."""
|
||||||
|
return (text or "").lstrip().startswith(_EVOLUTION_DISPLAY_MARKER)
|
||||||
|
|
||||||
|
|
||||||
|
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]]:
|
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Extract tool_use blocks from an assistant message content.
|
Extract tool_use blocks from an assistant message content.
|
||||||
@@ -210,7 +253,10 @@ def _group_into_display_turns(
|
|||||||
if user_row:
|
if user_row:
|
||||||
content, created_at, _u_extras = user_row
|
content, created_at, _u_extras = user_row
|
||||||
text = _extract_display_text(content)
|
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})
|
turns.append({"role": "user", "content": text, "created_at": created_at})
|
||||||
|
|
||||||
# Build an ordered list of steps preserving the original sequence:
|
# Build an ordered list of steps preserving the original sequence:
|
||||||
@@ -265,6 +311,18 @@ def _group_into_display_turns(
|
|||||||
step["result"] = tr.get("result", "")
|
step["result"] = tr.get("result", "")
|
||||||
step["is_error"] = tr.get("is_error", False)
|
step["is_error"] = tr.get("is_error", False)
|
||||||
|
|
||||||
|
# Detect a self-evolution bubble BEFORE cleaning the marker away, so the
|
||||||
|
# UI can flag it even though the visible text stays clean.
|
||||||
|
is_evolution = _is_evolution_text(final_text)
|
||||||
|
|
||||||
|
# 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:
|
if steps or final_text:
|
||||||
turn = {
|
turn = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@@ -272,6 +330,8 @@ def _group_into_display_turns(
|
|||||||
"steps": steps,
|
"steps": steps,
|
||||||
"created_at": final_ts or (user_row[1] if user_row else 0),
|
"created_at": final_ts or (user_row[1] if user_row else 0),
|
||||||
}
|
}
|
||||||
|
if is_evolution:
|
||||||
|
turn["kind"] = "evolution"
|
||||||
if merged_extras:
|
if merged_extras:
|
||||||
turn["extras"] = merged_extras
|
turn["extras"] = merged_extras
|
||||||
turns.append(turn)
|
turns.append(turn)
|
||||||
@@ -291,7 +351,7 @@ class ConversationStore:
|
|||||||
|
|
||||||
def __init__(self, db_path: Path):
|
def __init__(self, db_path: Path):
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.RLock() # Use RLock to allow reentrant locking
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -509,6 +569,65 @@ class ConversationStore:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def clear_session(self, session_id: str) -> None:
|
||||||
"""Delete all messages and the session record for a given session_id."""
|
"""Delete all messages and the session record for a given session_id."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -524,6 +643,109 @@ class ConversationStore:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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(
|
def prune_scheduled_messages(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -1053,3 +1275,4 @@ def get_conversation_store() -> ConversationStore:
|
|||||||
_store_instance = ConversationStore(db_path)
|
_store_instance = ConversationStore(db_path)
|
||||||
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
||||||
return _store_instance
|
return _store_instance
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,18 @@ class MemoryService:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
|
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:
|
Args:
|
||||||
category: ``"memory"`` (default) — MEMORY.md + daily files;
|
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()
|
files = self._list_dream_files()
|
||||||
else:
|
else:
|
||||||
files = self._list_memory_files()
|
files = self._list_memory_files()
|
||||||
@@ -93,6 +98,26 @@ class MemoryService:
|
|||||||
|
|
||||||
return files
|
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
|
# content — read a single file
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -101,7 +126,7 @@ class MemoryService:
|
|||||||
Read the full content of a memory or dream file.
|
Read the full content of a memory or dream file.
|
||||||
|
|
||||||
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
|
: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``
|
:return: dict with ``filename`` and ``content``
|
||||||
:raises FileNotFoundError: if the file does not exist
|
:raises FileNotFoundError: if the file does not exist
|
||||||
"""
|
"""
|
||||||
@@ -125,7 +150,7 @@ class MemoryService:
|
|||||||
Dispatch a memory management action.
|
Dispatch a memory management action.
|
||||||
|
|
||||||
:param action: ``list`` or ``content``
|
: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
|
:return: protocol-compatible response dict
|
||||||
"""
|
"""
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
@@ -166,6 +191,7 @@ class MemoryService:
|
|||||||
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
||||||
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.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`` (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.
|
Raises ValueError if the resolved path escapes the allowed directory.
|
||||||
"""
|
"""
|
||||||
@@ -173,6 +199,8 @@ class MemoryService:
|
|||||||
base_dir = self.workspace_root
|
base_dir = self.workspace_root
|
||||||
elif category == "dream":
|
elif category == "dream":
|
||||||
base_dir = os.path.join(self.memory_dir, "dreams")
|
base_dir = os.path.join(self.memory_dir, "dreams")
|
||||||
|
elif category == "evolution":
|
||||||
|
base_dir = os.path.join(self.memory_dir, "evolution")
|
||||||
else:
|
else:
|
||||||
base_dir = self.memory_dir
|
base_dir = self.memory_dir
|
||||||
|
|
||||||
|
|||||||
@@ -825,9 +825,10 @@ class MemoryStorage:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def delete_by_path(self, path: str):
|
def delete_by_path(self, path: str):
|
||||||
"""Delete all chunks from a file"""
|
"""Delete all chunks and file metadata for a path."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
||||||
|
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_file_hash(self, path: str) -> Optional[str]:
|
def get_file_hash(self, path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -462,13 +462,12 @@ class MemoryFlushManager:
|
|||||||
daily_content=daily_content or "(no recent daily records)",
|
daily_content=daily_content or "(no recent daily records)",
|
||||||
)
|
)
|
||||||
from agent.protocol.models import LLMRequest
|
from agent.protocol.models import LLMRequest
|
||||||
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
|
# No output cap: the prompt already keeps MEMORY.md concise (~50
|
||||||
input_chars = len(memory_content) + len(daily_content)
|
# items), so a hard max_tokens would only risk truncating a large
|
||||||
dream_max_tokens = max(2000, min(input_chars, 8000))
|
# rewrite. Let the model use its default output budget.
|
||||||
request = LLMRequest(
|
request = LLMRequest(
|
||||||
messages=[{"role": "user", "content": user_msg}],
|
messages=[{"role": "user", "content": user_msg}],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=dream_max_tokens,
|
|
||||||
stream=False,
|
stream=False,
|
||||||
system=_dream_system_prompt(),
|
system=_dream_system_prompt(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class Agent:
|
|||||||
self.workspace_dir = workspace_dir # Workspace directory
|
self.workspace_dir = workspace_dir # Workspace directory
|
||||||
self.enable_skills = enable_skills # Skills enabled flag
|
self.enable_skills = enable_skills # Skills enabled flag
|
||||||
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
||||||
|
# Optional extra instructions appended AFTER the rebuilt full system
|
||||||
|
# prompt. Used by the self-evolution review agent to add its task brief
|
||||||
|
# on top of the full context (tools, workspace, user preferences, time)
|
||||||
|
# so it both follows the user's preferences and knows its evolution job.
|
||||||
|
self.extra_system_suffix = None
|
||||||
|
|
||||||
# Initialize skill manager
|
# Initialize skill manager
|
||||||
self.skill_manager = None
|
self.skill_manager = None
|
||||||
@@ -120,15 +125,20 @@ class Agent:
|
|||||||
except Exception:
|
except Exception:
|
||||||
lang = "zh"
|
lang = "zh"
|
||||||
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
|
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
|
||||||
return builder.build(
|
full = builder.build(
|
||||||
tools=self.tools,
|
tools=self.tools,
|
||||||
context_files=context_files,
|
context_files=context_files,
|
||||||
skill_manager=self.skill_manager,
|
skill_manager=self.skill_manager,
|
||||||
memory_manager=self.memory_manager,
|
memory_manager=self.memory_manager,
|
||||||
runtime_info=self.runtime_info,
|
runtime_info=self.runtime_info,
|
||||||
)
|
)
|
||||||
|
if self.extra_system_suffix:
|
||||||
|
full = f"{full}\n\n{self.extra_system_suffix}"
|
||||||
|
return full
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
|
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
|
||||||
|
if self.extra_system_suffix:
|
||||||
|
return f"{self.system_prompt}\n\n{self.extra_system_suffix}"
|
||||||
return self.system_prompt
|
return self.system_prompt
|
||||||
|
|
||||||
def refresh_skills(self):
|
def refresh_skills(self):
|
||||||
|
|||||||
@@ -347,11 +347,14 @@ class AgentStreamExecutor:
|
|||||||
Returns:
|
Returns:
|
||||||
Final response text
|
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_enabled = self._is_thinking_enabled()
|
||||||
thinking_label = " | 💭 thinking" if thinking_enabled else ""
|
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)
|
# Add user message (Claude format - use content blocks for consistency)
|
||||||
self.messages.append({
|
self.messages.append({
|
||||||
@@ -1171,10 +1174,21 @@ class AgentStreamExecutor:
|
|||||||
# Set tool context
|
# Set tool context
|
||||||
tool.model = self.model
|
tool.model = self.model
|
||||||
tool.context = self.agent
|
tool.context = self.agent
|
||||||
|
tool.progress_callback = lambda message: self._emit_event(
|
||||||
|
"tool_execution_progress",
|
||||||
|
{
|
||||||
|
"tool_call_id": tool_id,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Execute tool
|
# Execute tool
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
result: ToolResult = tool.execute_tool(arguments)
|
try:
|
||||||
|
result: ToolResult = tool.execute_tool(arguments)
|
||||||
|
finally:
|
||||||
|
tool.progress_callback = None
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
|
||||||
result_dict = {
|
result_dict = {
|
||||||
@@ -1716,4 +1730,4 @@ class AgentStreamExecutor:
|
|||||||
not as a message. The AgentLLMModel will handle this.
|
not as a message. The AgentLLMModel will handle this.
|
||||||
"""
|
"""
|
||||||
# Don't add system message here - it will be handled separately by the LLM adapter
|
# Don't add system message here - it will be handled separately by the LLM adapter
|
||||||
return self.messages
|
return self.messages
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ class SkillService:
|
|||||||
"""
|
"""
|
||||||
self.manager = skill_manager
|
self.manager = skill_manager
|
||||||
|
|
||||||
|
def _safe_skill_dir(self, name: str) -> str:
|
||||||
|
"""Derive and validate the skill directory path.
|
||||||
|
|
||||||
|
Ensures the resolved path stays within the custom_dir root,
|
||||||
|
preventing path traversal via names like ``../escaped``.
|
||||||
|
|
||||||
|
:raises ValueError: if the name would escape the skills root.
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
# Reject obvious traversal components.
|
||||||
|
if ".." in name or name.startswith("/") or name.startswith("\\"):
|
||||||
|
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
|
||||||
|
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
|
||||||
|
root = os.path.realpath(self.manager.custom_dir)
|
||||||
|
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
|
||||||
|
raise ValueError(
|
||||||
|
f"skill name {name!r} resolves outside the skills directory"
|
||||||
|
)
|
||||||
|
return skill_dir
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# query
|
# query
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -107,7 +128,7 @@ class SkillService:
|
|||||||
if not files:
|
if not files:
|
||||||
raise ValueError("skill files list is empty")
|
raise ValueError("skill files list is empty")
|
||||||
|
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
|
|
||||||
tmp_dir = skill_dir + ".tmp"
|
tmp_dir = skill_dir + ".tmp"
|
||||||
if os.path.exists(tmp_dir):
|
if os.path.exists(tmp_dir):
|
||||||
@@ -146,7 +167,7 @@ class SkillService:
|
|||||||
raise ValueError("package url is required")
|
raise ValueError("package url is required")
|
||||||
|
|
||||||
url = files[0]["url"]
|
url = files[0]["url"]
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
zip_path = os.path.join(tmp_dir, "package.zip")
|
zip_path = os.path.join(tmp_dir, "package.zip")
|
||||||
@@ -217,7 +238,7 @@ class SkillService:
|
|||||||
if not name:
|
if not name:
|
||||||
raise ValueError("skill name is required")
|
raise ValueError("skill name is required")
|
||||||
|
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
if os.path.exists(skill_dir):
|
if os.path.exists(skill_dir):
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(skill_dir)
|
||||||
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from agent.tools.send.send import Send
|
|||||||
from agent.tools.memory.memory_search import MemorySearchTool
|
from agent.tools.memory.memory_search import MemorySearchTool
|
||||||
from agent.tools.memory.memory_get import MemoryGetTool
|
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
|
# Import tools with optional dependencies
|
||||||
def _import_optional_tools():
|
def _import_optional_tools():
|
||||||
"""Import tools that have optional dependencies"""
|
"""Import tools that have optional dependencies"""
|
||||||
@@ -135,6 +138,7 @@ __all__ = [
|
|||||||
'Send',
|
'Send',
|
||||||
'MemorySearchTool',
|
'MemorySearchTool',
|
||||||
'MemoryGetTool',
|
'MemoryGetTool',
|
||||||
|
'EvolutionUndoTool',
|
||||||
'EnvConfig',
|
'EnvConfig',
|
||||||
'SchedulerTool',
|
'SchedulerTool',
|
||||||
'WebSearch',
|
'WebSearch',
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ class BaseTool:
|
|||||||
description: str = "Base tool"
|
description: str = "Base tool"
|
||||||
params: dict = {} # Store JSON Schema
|
params: dict = {} # Store JSON Schema
|
||||||
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
||||||
|
progress_callback = None
|
||||||
|
|
||||||
|
def report_progress(self, message: str):
|
||||||
|
callback = getattr(self, "progress_callback", None)
|
||||||
|
if not callback:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(str(message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[{self.name}] progress callback failed: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_json_schema(cls) -> dict:
|
def get_json_schema(cls) -> dict:
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
@@ -19,6 +22,10 @@ class Bash(BaseTool):
|
|||||||
"""Tool for executing bash commands"""
|
"""Tool for executing bash commands"""
|
||||||
|
|
||||||
_IS_WIN = sys.platform == "win32"
|
_IS_WIN = sys.platform == "win32"
|
||||||
|
_PROGRESS_MAX_BYTES = 4 * 1024
|
||||||
|
_PROGRESS_INTERVAL = 0.5
|
||||||
|
# cmd.exe command line limit is ~8191 chars; rewrite python -c above this.
|
||||||
|
_WIN_CMD_SAFE_LEN = 7000
|
||||||
|
|
||||||
name: str = "bash"
|
name: str = "bash"
|
||||||
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
||||||
@@ -69,8 +76,8 @@ SAFETY:
|
|||||||
if not command:
|
if not command:
|
||||||
return ToolResult.fail("Error: command parameter is required")
|
return ToolResult.fail("Error: command parameter is required")
|
||||||
|
|
||||||
# Security check: Prevent accessing sensitive config files
|
# Security check: Prevent direct access to the credential file
|
||||||
if "~/.cow/.env" in command or "~/.cow" in command:
|
if re.search(r'\.cow[/\\]\.env', command):
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||||
)
|
)
|
||||||
@@ -106,25 +113,35 @@ SAFETY:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
||||||
|
|
||||||
|
# Temp script written for long `python -c` commands (Windows only),
|
||||||
|
# cleaned up after execution.
|
||||||
|
temp_script_path = None
|
||||||
|
|
||||||
# On Windows, convert $VAR references to %VAR% for cmd.exe
|
# On Windows, convert $VAR references to %VAR% for cmd.exe
|
||||||
if self._IS_WIN:
|
if self._IS_WIN:
|
||||||
env["PYTHONIOENCODING"] = "utf-8"
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
command = self._convert_env_vars_for_windows(command, dotenv_vars)
|
command = self._convert_env_vars_for_windows(command, dotenv_vars)
|
||||||
|
# cmd.exe has an ~8191 char command line limit. Long
|
||||||
|
# `python -c "..."` commands silently fail, so spill the inline
|
||||||
|
# code into a temp .py file and run that instead.
|
||||||
|
if len(command) > self._WIN_CMD_SAFE_LEN:
|
||||||
|
command, temp_script_path = self._rewrite_long_python_c(command)
|
||||||
if command and not command.strip().lower().startswith("chcp"):
|
if command and not command.strip().lower().startswith("chcp"):
|
||||||
command = f"chcp 65001 >nul 2>&1 && {command}"
|
command = f"chcp 65001 >nul 2>&1 && {command}"
|
||||||
|
|
||||||
result = subprocess.run(
|
try:
|
||||||
command,
|
result = self._run_streaming(
|
||||||
shell=True,
|
command,
|
||||||
cwd=self.cwd,
|
timeout,
|
||||||
stdout=subprocess.PIPE,
|
env,
|
||||||
stderr=subprocess.PIPE,
|
dotenv_vars,
|
||||||
text=True,
|
)
|
||||||
encoding="utf-8",
|
finally:
|
||||||
errors="replace",
|
if temp_script_path:
|
||||||
timeout=timeout,
|
try:
|
||||||
env=env,
|
os.remove(temp_script_path)
|
||||||
)
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
||||||
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
||||||
@@ -236,6 +253,105 @@ SAFETY:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error executing command: {str(e)}")
|
return ToolResult.fail(f"Error executing command: {str(e)}")
|
||||||
|
|
||||||
|
def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict):
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
cwd=self.cwd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
start_new_session=not self._IS_WIN,
|
||||||
|
)
|
||||||
|
stdout_chunks, stderr_chunks = [], []
|
||||||
|
recent = bytearray()
|
||||||
|
recent_lock = threading.Lock()
|
||||||
|
|
||||||
|
def drain(stream, chunks):
|
||||||
|
while True:
|
||||||
|
chunk = os.read(stream.fileno(), 4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
with recent_lock:
|
||||||
|
recent.extend(chunk)
|
||||||
|
if len(recent) > self._PROGRESS_MAX_BYTES:
|
||||||
|
del recent[:-self._PROGRESS_MAX_BYTES]
|
||||||
|
|
||||||
|
readers = [
|
||||||
|
threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True),
|
||||||
|
threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True),
|
||||||
|
]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
last_reported_at = started
|
||||||
|
last_snapshot = None
|
||||||
|
try:
|
||||||
|
while process.poll() is None:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - started
|
||||||
|
if elapsed >= timeout:
|
||||||
|
self._kill_process(process)
|
||||||
|
raise subprocess.TimeoutExpired(command, timeout)
|
||||||
|
if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL:
|
||||||
|
with recent_lock:
|
||||||
|
snapshot = bytes(recent).decode("utf-8", errors="replace")
|
||||||
|
snapshot = self._redact_progress(snapshot, dotenv_vars)
|
||||||
|
if snapshot and snapshot != last_snapshot:
|
||||||
|
self.report_progress(snapshot)
|
||||||
|
last_snapshot = snapshot
|
||||||
|
last_reported_at = now
|
||||||
|
time.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
self._kill_process(process)
|
||||||
|
process.wait()
|
||||||
|
join_deadline = time.monotonic() + 5
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=max(0, join_deadline - time.monotonic()))
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=process.returncode,
|
||||||
|
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
||||||
|
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _kill_process(self, process):
|
||||||
|
if self._IS_WIN:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 and process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except (PermissionError, ProcessLookupError):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redact_progress(text: str, dotenv_vars: dict) -> str:
|
||||||
|
text = re.sub(
|
||||||
|
r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+',
|
||||||
|
lambda match: f"{match.group(1)}=[REDACTED]",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
for value in dotenv_vars.values():
|
||||||
|
value = str(value or "")
|
||||||
|
if len(value) >= 6:
|
||||||
|
text = text.replace(value, "[REDACTED]")
|
||||||
|
return text
|
||||||
|
|
||||||
def _get_safety_warning(self, command: str) -> str:
|
def _get_safety_warning(self, command: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get safety warning for absolutely catastrophic commands only.
|
Get safety warning for absolutely catastrophic commands only.
|
||||||
@@ -293,3 +409,43 @@ SAFETY:
|
|||||||
return m.group(0)
|
return m.group(0)
|
||||||
|
|
||||||
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rewrite_long_python_c(command: str):
|
||||||
|
"""
|
||||||
|
Rewrite `python -c "<code>"` into `python <tempfile>` to bypass the
|
||||||
|
cmd.exe command line length limit on Windows.
|
||||||
|
|
||||||
|
Returns (new_command, temp_file_path). On any parse failure the original
|
||||||
|
command and None are returned, so behavior is unchanged when unmatched.
|
||||||
|
"""
|
||||||
|
# Match: <python|python3|py> [flags] -c "<code>" (single or double quoted)
|
||||||
|
m = re.search(
|
||||||
|
r'^(?P<prefix>.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)'
|
||||||
|
r'(?P<quote>["\'])(?P<code>.*)(?P=quote)\s*(?P<suffix>.*)$',
|
||||||
|
command,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
quote = m.group("quote")
|
||||||
|
code = m.group("code")
|
||||||
|
# Reverse common shell-level escaping of the quote char inside the code.
|
||||||
|
code = code.replace("\\" + quote, quote)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(code)
|
||||||
|
except OSError:
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
prefix = m.group("prefix")
|
||||||
|
# Drop the trailing "-c " from the prefix, keep the interpreter + flags.
|
||||||
|
interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip()
|
||||||
|
suffix = m.group("suffix").strip()
|
||||||
|
new_command = f'{interp} "{path}"'
|
||||||
|
if suffix:
|
||||||
|
new_command += f' {suffix}'
|
||||||
|
return new_command, path
|
||||||
|
|||||||
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 json
|
||||||
import os
|
import os
|
||||||
import select
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -34,6 +34,8 @@ class McpClient:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.name: str = config.get("name", "unknown")
|
self.name: str = config.get("name", "unknown")
|
||||||
raw_transport: str = config.get("type", "stdio")
|
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
|
# Normalize streamable-http aliases to a single internal key
|
||||||
self.transport: str = (
|
self.transport: str = (
|
||||||
"streamable-http"
|
"streamable-http"
|
||||||
@@ -43,6 +45,7 @@ class McpClient:
|
|||||||
|
|
||||||
# stdio state
|
# stdio state
|
||||||
self._proc: Optional[subprocess.Popen] = None
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._read_queue: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
# SSE state
|
# SSE state
|
||||||
self._sse_url: Optional[str] = None
|
self._sse_url: Optional[str] = None
|
||||||
@@ -56,7 +59,13 @@ class McpClient:
|
|||||||
# Shared state
|
# Shared state
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
self._id_lock = threading.Lock()
|
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()
|
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
|
self._initialized = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -172,6 +181,9 @@ class McpClient:
|
|||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
|
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
|
||||||
).start()
|
).start()
|
||||||
|
threading.Thread(
|
||||||
|
target=self._drain_stdout, daemon=True, name=f"mcp-stdout-{self.name}"
|
||||||
|
).start()
|
||||||
|
|
||||||
return self._handshake()
|
return self._handshake()
|
||||||
|
|
||||||
@@ -179,14 +191,35 @@ class McpClient:
|
|||||||
for line in self._proc.stderr:
|
for line in self._proc.stderr:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
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:
|
def _drain_stdout(self):
|
||||||
"""Read one line from stdio stdout with a hard timeout."""
|
"""Background thread: read lines from stdout and put them into the queue."""
|
||||||
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
|
try:
|
||||||
if not ready:
|
for line in self._proc.stdout:
|
||||||
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {timeout}s")
|
self._read_queue.put(line)
|
||||||
return self._proc.stdout.readline()
|
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:
|
def _stdio_send(self, message: dict) -> dict:
|
||||||
"""Send a JSON-RPC message over stdio and read the response."""
|
"""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.write(raw)
|
||||||
self._proc.stdin.flush()
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
expected_id = message.get("id")
|
||||||
while True:
|
while True:
|
||||||
line = self._readline_with_timeout()
|
line = self._readline_with_timeout()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -208,6 +242,14 @@ class McpClient:
|
|||||||
if "id" not in data:
|
if "id" not in data:
|
||||||
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
|
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
|
||||||
continue
|
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
|
return data
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -302,8 +344,12 @@ class McpClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json, text/event-stream",
|
"Accept": "application/json, text/event-stream",
|
||||||
}
|
}
|
||||||
if self._http_session_id:
|
# Read session id under lock to avoid racing with the
|
||||||
headers["Mcp-Session-Id"] = self._http_session_id
|
# 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)
|
headers.update(self._http_headers)
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -329,8 +375,13 @@ class McpClient:
|
|||||||
with resp:
|
with resp:
|
||||||
# Capture session id assigned by the server (if any)
|
# Capture session id assigned by the server (if any)
|
||||||
session_id = resp.headers.get("Mcp-Session-Id")
|
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:
|
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()
|
status = resp.status if hasattr(resp, "status") else resp.getcode()
|
||||||
|
|
||||||
@@ -409,15 +460,18 @@ class McpClient:
|
|||||||
|
|
||||||
message = self._build_request(method, params)
|
message = self._build_request(method, params)
|
||||||
|
|
||||||
with self._call_lock:
|
# stdio transport uses a single pipe and must be serialized.
|
||||||
if self.transport == "stdio":
|
# 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)
|
return self._stdio_send(message)
|
||||||
elif self.transport == "sse":
|
elif self.transport == "sse":
|
||||||
return self._sse_send(message)
|
return self._sse_send(message)
|
||||||
elif self.transport == "streamable-http":
|
elif self.transport == "streamable-http":
|
||||||
return self._streamable_http_send(message)
|
return self._streamable_http_send(message)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
||||||
|
|
||||||
def _send_notification(self, method: str, params: dict):
|
def _send_notification(self, method: str, params: dict):
|
||||||
"""Fire-and-forget notification (no response expected)."""
|
"""Fire-and-forget notification (no response expected)."""
|
||||||
|
|||||||
@@ -182,8 +182,15 @@ class TaskStore:
|
|||||||
if enabled_only:
|
if enabled_only:
|
||||||
task_list = [t for t in task_list if t.get("enabled", True)]
|
task_list = [t for t in task_list if t.get("enabled", True)]
|
||||||
|
|
||||||
# Sort by next_run_at
|
# Sort by enabled status (enabled first), then by next_run_at
|
||||||
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
|
def sort_key(t):
|
||||||
|
enabled = t.get("enabled", True)
|
||||||
|
next_run = t.get("next_run_at", "")
|
||||||
|
# Enabled tasks first (0), disabled tasks second (1)
|
||||||
|
# Then sort by next_run_at (empty string sorts last)
|
||||||
|
return (0 if enabled else 1, next_run if next_run else "9999-12-31")
|
||||||
|
|
||||||
|
task_list.sort(key=sort_key)
|
||||||
|
|
||||||
return task_list
|
return task_list
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from .diff import (
|
|||||||
FuzzyMatchResult
|
FuzzyMatchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .url_safety import (
|
||||||
|
validate_url_safe,
|
||||||
|
assert_public_ip
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'truncate_head',
|
'truncate_head',
|
||||||
'truncate_tail',
|
'truncate_tail',
|
||||||
@@ -36,5 +41,7 @@ __all__ = [
|
|||||||
'normalize_for_fuzzy_match',
|
'normalize_for_fuzzy_match',
|
||||||
'fuzzy_find_text',
|
'fuzzy_find_text',
|
||||||
'generate_diff_string',
|
'generate_diff_string',
|
||||||
'FuzzyMatchResult'
|
'FuzzyMatchResult',
|
||||||
|
'validate_url_safe',
|
||||||
|
'assert_public_ip'
|
||||||
]
|
]
|
||||||
|
|||||||
66
agent/tools/utils/url_safety.py
Normal file
66
agent/tools/utils/url_safety.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
|
||||||
|
|
||||||
|
A URL is only considered safe when it uses an http/https scheme, has a
|
||||||
|
hostname, that hostname resolves, and every resolved address is a public
|
||||||
|
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
|
||||||
|
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
|
||||||
|
addresses are rejected, for both IPv4 and IPv6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||||
|
"""Return True if the address is not safe to connect to (non-public)."""
|
||||||
|
return (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_public_ip(ip_str: str) -> None:
|
||||||
|
"""Raise ValueError if the given literal IP is a non-public address.
|
||||||
|
|
||||||
|
Used to re-validate the concrete address a redirect resolved to.
|
||||||
|
"""
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise ValueError(
|
||||||
|
f"URL resolves to a non-public address ({ip_str}), "
|
||||||
|
f"request blocked for security"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url_safe(url: str) -> None:
|
||||||
|
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||||
|
|
||||||
|
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||||
|
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||||
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the URL targets a disallowed address.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||||
|
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL has no hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Resolve all addresses for the hostname.
|
||||||
|
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||||
|
|
||||||
|
for family, _, _, _, sockaddr in addr_infos:
|
||||||
|
assert_public_ip(sockaddr[0])
|
||||||
@@ -26,13 +26,14 @@ from typing import Any, Dict, List, Optional
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.utils.url_safety import validate_url_safe
|
||||||
from common import const
|
from common import const
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
DEFAULT_MODEL = const.GPT_41_MINI
|
DEFAULT_MODEL = const.GPT_41_MINI
|
||||||
DEFAULT_TIMEOUT = 60
|
DEFAULT_TIMEOUT = 180
|
||||||
MAX_TOKENS = 1000
|
MAX_TOKENS = 4000
|
||||||
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
||||||
|
|
||||||
SUPPORTED_EXTENSIONS = {
|
SUPPORTED_EXTENSIONS = {
|
||||||
@@ -51,7 +52,7 @@ _MAIN_MODEL_PROVIDER_NAME = "MainModel"
|
|||||||
_DISCOVERABLE_MODELS = [
|
_DISCOVERABLE_MODELS = [
|
||||||
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||||
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
("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"),
|
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
|
||||||
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||||
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||||
@@ -161,7 +162,7 @@ class Vision(BaseTool):
|
|||||||
"Error: No model available for Vision.\n"
|
"Error: No model available for Vision.\n"
|
||||||
"The main model does not support vision and no other API keys are configured.\n"
|
"The main model does not support vision and no other API keys are configured.\n"
|
||||||
"Options:\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"
|
" 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. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||||
)
|
)
|
||||||
@@ -654,6 +655,22 @@ class Vision(BaseTool):
|
|||||||
return api_base
|
return api_base
|
||||||
return api_base.rstrip("/") + "/v1"
|
return api_base.rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_url_safe(url: str) -> None:
|
||||||
|
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||||
|
|
||||||
|
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||||
|
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||||
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
|
Delegates to the shared ``agent.tools.utils.url_safety`` helper so the
|
||||||
|
same guard protects every tool that fetches model-supplied URLs.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the URL targets a disallowed address.
|
||||||
|
"""
|
||||||
|
validate_url_safe(url)
|
||||||
|
|
||||||
def _build_image_content(self, image: str) -> dict:
|
def _build_image_content(self, image: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Build the image_url content block.
|
Build the image_url content block.
|
||||||
@@ -661,6 +678,7 @@ class Vision(BaseTool):
|
|||||||
so every bot backend can consume them without extra downloads.
|
so every bot backend can consume them without extra downloads.
|
||||||
"""
|
"""
|
||||||
if image.startswith(("http://", "https://")):
|
if image.startswith(("http://", "https://")):
|
||||||
|
self._validate_url_safe(image)
|
||||||
return self._download_to_data_url(image)
|
return self._download_to_data_url(image)
|
||||||
|
|
||||||
if not os.path.isfile(image):
|
if not os.path.isfile(image):
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import requests
|
|||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.utils.truncate import truncate_head, format_size
|
from agent.tools.utils.truncate import truncate_head, format_size
|
||||||
|
from agent.tools.utils.url_safety import validate_url_safe
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
# Cap on how many redirects we follow; each hop's target is re-validated
|
||||||
|
# against the SSRF guard so a public URL cannot bounce us into an internal one.
|
||||||
|
MAX_REDIRECTS = 10
|
||||||
|
|
||||||
DEFAULT_HEADERS = {
|
DEFAULT_HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
@@ -107,23 +111,65 @@ class WebFetch(BaseTool):
|
|||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)")
|
return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)")
|
||||||
|
|
||||||
|
# SSRF guard: reject URLs that resolve to private/loopback/link-local/
|
||||||
|
# cloud-metadata addresses before any request is issued.
|
||||||
|
try:
|
||||||
|
validate_url_safe(url)
|
||||||
|
except ValueError as e:
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
|
||||||
if _is_document_url(url):
|
if _is_document_url(url):
|
||||||
return self._fetch_document(url)
|
return self._fetch_document(url)
|
||||||
|
|
||||||
return self._fetch_webpage(url)
|
return self._fetch_webpage(url)
|
||||||
|
|
||||||
|
# ---- Safe request helper ----
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_get(url: str, **kwargs) -> requests.Response:
|
||||||
|
"""Issue a GET request while re-validating every redirect hop (SSRF guard).
|
||||||
|
|
||||||
|
Auto-redirect is disabled and each hop is followed manually so the
|
||||||
|
target of every redirect is re-resolved and checked against the SSRF
|
||||||
|
guard. This prevents a public URL from 3xx-bouncing into a private,
|
||||||
|
loopback, link-local or cloud-metadata address. ``kwargs`` are passed
|
||||||
|
through to ``requests.get`` (e.g. ``stream``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if any hop resolves to a non-public address.
|
||||||
|
"""
|
||||||
|
kwargs.pop("allow_redirects", None)
|
||||||
|
current = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
response = requests.get(
|
||||||
|
current,
|
||||||
|
headers=DEFAULT_HEADERS,
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
allow_redirects=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if not response.is_redirect and not response.is_permanent_redirect:
|
||||||
|
return response
|
||||||
|
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
if not location:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Resolve the redirect target relative to the current URL, then
|
||||||
|
# re-validate it before following.
|
||||||
|
current = requests.compat.urljoin(current, location)
|
||||||
|
validate_url_safe(current)
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
raise ValueError(f"Too many redirects (>{MAX_REDIRECTS})")
|
||||||
|
|
||||||
# ---- Web page fetching ----
|
# ---- Web page fetching ----
|
||||||
|
|
||||||
def _fetch_webpage(self, url: str) -> ToolResult:
|
def _fetch_webpage(self, url: str) -> ToolResult:
|
||||||
"""Fetch and extract readable text from an HTML web page."""
|
"""Fetch and extract readable text from an HTML web page."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = self._safe_get(url)
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
timeout=DEFAULT_TIMEOUT,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
|
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
|
||||||
@@ -131,6 +177,8 @@ class WebFetch(BaseTool):
|
|||||||
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
||||||
|
except ValueError as e:
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error: Failed to fetch URL: {e}")
|
return ToolResult.fail(f"Error: Failed to fetch URL: {e}")
|
||||||
|
|
||||||
@@ -158,13 +206,7 @@ class WebFetch(BaseTool):
|
|||||||
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
|
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = self._safe_get(url, stream=True)
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
timeout=DEFAULT_TIMEOUT,
|
|
||||||
stream=True,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
content_length = int(response.headers.get("Content-Length", 0))
|
content_length = int(response.headers.get("Content-Length", 0))
|
||||||
@@ -191,6 +233,9 @@ class WebFetch(BaseTool):
|
|||||||
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
||||||
|
except ValueError as e:
|
||||||
|
self._cleanup_file(local_path)
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._cleanup_file(local_path)
|
self._cleanup_file(local_path)
|
||||||
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
||||||
|
|||||||
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.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||||
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
||||||
const.QQ: "channel.qq.qq_channel.QQChannel",
|
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",
|
const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
|
||||||
"wx": "channel.weixin.weixin_channel.WeixinChannel",
|
"wx": "channel.weixin.weixin_channel.WeixinChannel",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class AgentLLMModel(LLMModel):
|
|||||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||||
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||||
("ernie", const.QIANFAN),
|
("ernie", const.QIANFAN),
|
||||||
|
("mimo-", const.MIMO),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||||||
@@ -294,6 +295,14 @@ class AgentBridge:
|
|||||||
self.scheduler_initialized = True
|
self.scheduler_initialized = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Eager scheduler init failed: {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:
|
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
||||||
"""
|
"""
|
||||||
Create the super agent with COW integration
|
Create the super agent with COW integration
|
||||||
@@ -382,7 +391,49 @@ class AgentBridge:
|
|||||||
"""Initialize agent for a specific session"""
|
"""Initialize agent for a specific session"""
|
||||||
agent = self.initializer.initialize_agent(session_id=session_id)
|
agent = self.initializer.initialize_agent(session_id=session_id)
|
||||||
self.agents[session_id] = agent
|
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,
|
def agent_reply(self, query: str, context: Context = None,
|
||||||
on_event=None, clear_history: bool = False) -> Reply:
|
on_event=None, clear_history: bool = False) -> Reply:
|
||||||
"""
|
"""
|
||||||
@@ -464,6 +515,24 @@ class AgentBridge:
|
|||||||
)
|
)
|
||||||
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
|
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
|
||||||
|
|
||||||
|
# Eagerly persist the user message BEFORE running the agent so the
|
||||||
|
# session and the user's bubble are immediately visible — even if
|
||||||
|
# the user switches away or refreshes before the reply finishes.
|
||||||
|
# The reply (assistant/tool messages) is appended once the run
|
||||||
|
# completes; the final persist skips this already-stored user turn.
|
||||||
|
pre_persisted = self._pre_persist_user_message(
|
||||||
|
session_id, query, context, clear_history
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark this session as mid-run so the self-evolution idle scan does
|
||||||
|
# not fire concurrently when a single turn runs longer than
|
||||||
|
# idle_minutes.
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import mark_run_active
|
||||||
|
mark_run_active(agent, True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use agent's run_stream method with event handler
|
# Use agent's run_stream method with event handler
|
||||||
response = agent.run_stream(
|
response = agent.run_stream(
|
||||||
@@ -473,6 +542,13 @@ class AgentBridge:
|
|||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
# Clear the mid-run flag so idle scans can review this session.
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import mark_run_active
|
||||||
|
mark_run_active(agent, False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Restore original tools
|
# Restore original tools
|
||||||
if context and context.get("is_scheduled_task"):
|
if context and context.get("is_scheduled_task"):
|
||||||
agent.tools = original_tools
|
agent.tools = original_tools
|
||||||
@@ -490,7 +566,11 @@ class AgentBridge:
|
|||||||
# Persist new messages generated during this run
|
# Persist new messages generated during this run
|
||||||
if session_id:
|
if session_id:
|
||||||
channel_type = (context.get("channel_type") or "") if context else ""
|
channel_type = (context.get("channel_type") or "") if context else ""
|
||||||
new_messages = getattr(agent, '_last_run_new_messages', [])
|
new_messages = list(getattr(agent, '_last_run_new_messages', []))
|
||||||
|
# The leading user turn was already persisted eagerly above;
|
||||||
|
# drop it here so it isn't stored twice.
|
||||||
|
if pre_persisted and new_messages and new_messages[0].get("role") == "user":
|
||||||
|
new_messages = new_messages[1:]
|
||||||
if new_messages:
|
if new_messages:
|
||||||
self._persist_messages(session_id, list(new_messages), channel_type)
|
self._persist_messages(session_id, list(new_messages), channel_type)
|
||||||
else:
|
else:
|
||||||
@@ -504,6 +584,23 @@ class AgentBridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {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
|
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
|
||||||
# sync any new/removed MCP tools into the live agent in the
|
# sync any new/removed MCP tools into the live agent in the
|
||||||
# background. Off the critical path so user latency is unaffected;
|
# background. Off the critical path so user latency is unaffected;
|
||||||
@@ -689,6 +786,48 @@ class AgentBridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to sync API keys: {e}")
|
logger.warning(f"[AgentBridge] Failed to sync API keys: {e}")
|
||||||
|
|
||||||
|
def _pre_persist_user_message(
|
||||||
|
self, session_id: str, query: str, context: Context, clear_history: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Persist the user's message before the agent runs.
|
||||||
|
|
||||||
|
This makes a brand-new session (and the user's bubble) visible even if
|
||||||
|
the reply hasn't finished — switching away or refreshing no longer
|
||||||
|
loses the in-flight session. Returns True when the user turn was
|
||||||
|
stored, so the caller can skip it in the post-run persist.
|
||||||
|
|
||||||
|
Best-effort: any failure is swallowed and reported as not-persisted.
|
||||||
|
"""
|
||||||
|
if not session_id or not query:
|
||||||
|
return False
|
||||||
|
# Only real user turns: skip scheduler-injected / scheduled-task runs.
|
||||||
|
if session_id.startswith("scheduler_") or (
|
||||||
|
context and context.get("is_scheduled_task")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
if not conf().get("conversation_persistence", True):
|
||||||
|
return False
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
store = get_conversation_store()
|
||||||
|
# clear_history starts a fresh transcript: wipe the store first so
|
||||||
|
# the eager user turn becomes seq 0, matching in-memory state.
|
||||||
|
if clear_history:
|
||||||
|
store.clear_session(session_id)
|
||||||
|
channel_type = (context.get("channel_type") or "") if context else ""
|
||||||
|
user_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": query}],
|
||||||
|
}
|
||||||
|
store.append_messages(session_id, [user_msg], channel_type=channel_type)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[AgentBridge] Failed to pre-persist user message for session={session_id}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def _persist_messages(
|
def _persist_messages(
|
||||||
self, session_id: str, new_messages: list, channel_type: str = ""
|
self, session_id: str, new_messages: list, channel_type: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -524,6 +524,14 @@ class AgentInitializer:
|
|||||||
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip evolution_undo when self-evolution is disabled: with no
|
||||||
|
# evolution there is nothing to roll back, so the tool is dead weight.
|
||||||
|
if tool_name == "evolution_undo":
|
||||||
|
from agent.evolution.config import get_evolution_config
|
||||||
|
if not get_evolution_config().enabled:
|
||||||
|
logger.debug("[AgentInitializer] evolution_undo skipped - self-evolution disabled")
|
||||||
|
continue
|
||||||
|
|
||||||
# Special handling for EnvConfig tool
|
# Special handling for EnvConfig tool
|
||||||
if tool_name == "env_config":
|
if tool_name == "env_config":
|
||||||
from agent.tools import EnvConfig
|
from agent.tools import EnvConfig
|
||||||
|
|||||||
@@ -519,7 +519,10 @@ class ChatChannel(Channel):
|
|||||||
def cancel_session(self, session_id):
|
def cancel_session(self, session_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if session_id in self.sessions:
|
if session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
# futures[session_id] is only created in consume() when a task is
|
||||||
|
# dispatched, so it may be absent if cancel happens right after
|
||||||
|
# produce() but before the first dispatch. Default to [].
|
||||||
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
|
|||||||
def cancel_all_session(self):
|
def cancel_all_session(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for session_id in self.sessions:
|
for session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Docs Link -->
|
<!-- Docs Link -->
|
||||||
<a href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
|
<a id="docs-link" href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
|
||||||
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||||
cursor-pointer transition-colors duration-150" title="Documentation">
|
cursor-pointer transition-colors duration-150" title="Documentation">
|
||||||
<i class="fas fa-book text-base"></i>
|
<i class="fas fa-book text-base"></i>
|
||||||
@@ -620,6 +620,18 @@
|
|||||||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<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>
|
<span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||||
<button id="cfg-agent-save"
|
<button id="cfg-agent-save"
|
||||||
@@ -760,7 +772,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -828,6 +840,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||||
|
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
||||||
|
<button onclick="createKnowledgeCategory()"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
||||||
|
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||||
|
</button>
|
||||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||||
@@ -983,6 +1000,14 @@
|
|||||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="task-refresh-btn" onclick="refreshTasksView()"
|
||||||
|
class="px-3 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||||
|
<i class="fas fa-refresh text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
||||||
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||||
@@ -1056,6 +1081,34 @@
|
|||||||
</div><!-- /main-content -->
|
</div><!-- /main-content -->
|
||||||
</div><!-- /app -->
|
</div><!-- /app -->
|
||||||
|
|
||||||
|
<!-- Knowledge Action Dialog -->
|
||||||
|
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||||
|
<i id="knowledge-dialog-icon" class="fas fa-folder text-emerald-500"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 id="knowledge-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100"></h3>
|
||||||
|
<p id="knowledge-dialog-subtitle" class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||||
|
<input id="knowledge-dialog-input" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
|
||||||
|
<select id="knowledge-dialog-select"
|
||||||
|
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
|
||||||
|
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
|
||||||
|
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
||||||
|
<button id="knowledge-dialog-cancel" class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5">取消</button>
|
||||||
|
<button id="knowledge-dialog-submit" class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium disabled:opacity-50">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Confirm Dialog -->
|
<!-- Confirm Dialog -->
|
||||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
@@ -1153,6 +1206,240 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
|
||||||
|
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
|
w-full max-w-md mx-4">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-sliders text-primary-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
|
||||||
|
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
|
||||||
|
<input id="custom-provider-base" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||||
|
placeholder="https://...../v1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||||
|
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||||
|
<button id="custom-provider-modal-delete"
|
||||||
|
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
cursor-pointer transition-colors duration-150 hidden"
|
||||||
|
data-i18n="models_custom_delete">删除</button>
|
||||||
|
<span id="custom-provider-modal-status"
|
||||||
|
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||||
|
<button id="custom-provider-modal-cancel"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||||
|
hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
cursor-pointer transition-colors duration-150"
|
||||||
|
data-i18n="cancel">取消</button>
|
||||||
|
<button id="custom-provider-modal-save"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-i18n="save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task Edit Modal -->
|
||||||
|
<div id="task-edit-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
|
w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-clock text-primary-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base" data-i18n="task_edit_title">编辑定时任务</h3>
|
||||||
|
<p id="task-edit-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 任务名称和启用状态 -->
|
||||||
|
<div class="flex gap-4 items-end">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_name">任务名称</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-name" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors"
|
||||||
|
placeholder="任务名称">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pb-[2px]">
|
||||||
|
<label class="text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
<span data-i18n="task_enabled">启用</span>
|
||||||
|
</label>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="task-edit-enabled" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 调度类型 + 调度值 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_schedule_type">调度类型</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-edit-schedule-type"
|
||||||
|
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
appearance-none bg-no-repeat bg-right"
|
||||||
|
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||||
|
<option value="cron" data-i18n="task_schedule_cron">Cron 表达式</option>
|
||||||
|
<option value="interval" data-i18n="task_schedule_interval">固定间隔</option>
|
||||||
|
<option value="once" data-i18n="task_schedule_once">一次性任务</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron 表达式 -->
|
||||||
|
<div id="task-edit-cron-wrap">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_cron_expression">Cron 表达式</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-cron-expression" type="text"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||||
|
placeholder="0 9 * * *">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定间隔 -->
|
||||||
|
<div id="task-edit-interval-wrap" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_interval_seconds">间隔秒数</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-interval-seconds" type="number" min="60"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors"
|
||||||
|
placeholder="3600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 一次性任务时间 -->
|
||||||
|
<div id="task-edit-once-wrap" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_once_time">执行时间</span>
|
||||||
|
</label>
|
||||||
|
<input id="task-edit-once-time" type="datetime-local" step="1"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
cursor-pointer"
|
||||||
|
onclick="this.showPicker && this.showPicker()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron/Interval 提示 -->
|
||||||
|
<p id="task-edit-cron-hint" class="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
<span data-i18n="task_cron_hint">格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00</span>
|
||||||
|
</p>
|
||||||
|
<p id="task-edit-interval-hint" class="text-xs text-slate-400 dark:text-slate-500 hidden">
|
||||||
|
<span data-i18n="task_interval_hint">最小 60 秒,例如 3600 表示每小时执行一次</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 动作类型 + 通道类型 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_action_type">动作类型</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-edit-action-type"
|
||||||
|
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
appearance-none bg-no-repeat bg-right"
|
||||||
|
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||||
|
<option value="send_message" data-i18n="task_action_send_message">发送消息</option>
|
||||||
|
<option value="agent_task" data-i18n="task_action_agent_task">AI 任务</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span data-i18n="task_channel_type">通道类型</span>
|
||||||
|
</label>
|
||||||
|
<select id="task-edit-channel-type"
|
||||||
|
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors
|
||||||
|
appearance-none bg-no-repeat bg-right
|
||||||
|
disabled:opacity-100 disabled:text-slate-800 dark:disabled:text-slate-300 disabled:bg-slate-100 dark:disabled:bg-[#252525] disabled:cursor-not-allowed"
|
||||||
|
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||||
|
<span data-i18n="task_channel_hint">选择定时消息发送的通道</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的接收者ID字段(自动填充) -->
|
||||||
|
<input id="task-edit-receiver" type="hidden" value="">
|
||||||
|
|
||||||
|
<!-- 消息内容/任务描述 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||||
|
<span id="task-edit-content-label" data-i18n="task_message_content">消息内容</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="task-edit-content" rows="3"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||||
|
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||||
|
focus:outline-none focus:border-primary-500 transition-colors resize-none"
|
||||||
|
placeholder="输入消息内容或任务描述"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||||
|
<button id="task-edit-modal-delete"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||||
|
cursor-pointer transition-colors duration-150 hidden"
|
||||||
|
data-i18n="task_delete_btn">删除任务</button>
|
||||||
|
<span id="task-edit-modal-status"
|
||||||
|
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||||
|
<button id="task-edit-modal-cancel"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||||
|
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||||
|
hover:bg-slate-50 dark:hover:bg-white/5
|
||||||
|
cursor-pointer transition-colors duration-150"
|
||||||
|
data-i18n="cancel">取消</button>
|
||||||
|
<button id="task-edit-modal-save"
|
||||||
|
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||||
|
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-i18n="save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script defer src="assets/js/console.js"></script>
|
<script defer src="assets/js/console.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -244,6 +244,52 @@
|
|||||||
}
|
}
|
||||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
||||||
|
|
||||||
|
/* Rename button: shares the look of the delete button, sits to its left.
|
||||||
|
Negative right margin tightens the gap to the delete button only. */
|
||||||
|
.session-rename {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: -6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.session-item:hover .session-rename { opacity: 1; }
|
||||||
|
.session-rename:hover {
|
||||||
|
color: #4ABE6E;
|
||||||
|
background: rgba(74, 190, 110, 0.12);
|
||||||
|
}
|
||||||
|
.dark .session-rename:hover { background: rgba(74, 190, 110, 0.18); }
|
||||||
|
|
||||||
|
/* Inline title editor */
|
||||||
|
.session-title-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #111827;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #4ABE6E;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dark .session-title-input {
|
||||||
|
color: #e5e5e5;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: #4ABE6E;
|
||||||
|
}
|
||||||
|
|
||||||
/* Context Divider */
|
/* Context Divider */
|
||||||
.context-divider {
|
.context-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -605,6 +651,18 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.tool-error-text { color: #f87171; }
|
.tool-error-text { color: #f87171; }
|
||||||
|
.tool-live-output:empty { display: none; }
|
||||||
|
.tool-streaming .tool-live-output:not(:empty)::after {
|
||||||
|
content: ' ';
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.45em;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
background: currentColor;
|
||||||
|
animation: tool-cursor-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes tool-cursor-blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
/* Log level highlighting */
|
/* Log level highlighting */
|
||||||
.log-line { display: block; }
|
.log-line { display: block; }
|
||||||
@@ -854,6 +912,14 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* "Custom" row is an add-new action: trailing + instead of ✓. */
|
||||||
|
.vendor-picker-add-mark {
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat Input */
|
/* Chat Input */
|
||||||
#chat-input {
|
#chat-input {
|
||||||
@@ -1191,6 +1257,34 @@
|
|||||||
background: #EDFDF3;
|
background: #EDFDF3;
|
||||||
color: #228547;
|
color: #228547;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.knowledge-tree-file:hover .knowledge-actions,
|
||||||
|
.knowledge-tree-group-btn:hover .knowledge-actions,
|
||||||
|
.knowledge-tree-file:focus-within .knowledge-actions,
|
||||||
|
.knowledge-tree-group-btn:focus-within .knowledge-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.knowledge-action {
|
||||||
|
padding: 3px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
.knowledge-action:hover {
|
||||||
|
color: #228547;
|
||||||
|
background: rgba(34, 133, 71, 0.08);
|
||||||
|
}
|
||||||
|
.knowledge-action.danger:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
.dark .knowledge-tree-file:hover {
|
.dark .knowledge-tree-file:hover {
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -1399,3 +1493,181 @@
|
|||||||
.agent-cancelled-tag {
|
.agent-cancelled-tag {
|
||||||
font-style: italic;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-msg-btn:disabled,
|
||||||
|
.delete-msg-btn:disabled {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
opacity: 0.35 !important;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -251,6 +251,21 @@ class WebChannel(ChatChannel):
|
|||||||
"""生成唯一的请求ID"""
|
"""生成唯一的请求ID"""
|
||||||
return str(uuid.uuid4())
|
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):
|
def send(self, reply: Reply, context: Context):
|
||||||
try:
|
try:
|
||||||
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
|
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://"):
|
if reply.type in (ReplyType.IMAGE_URL, ReplyType.FILE) and content.startswith("file://"):
|
||||||
text_content = getattr(reply, 'text_content', '')
|
text_content = getattr(reply, 'text_content', '')
|
||||||
if text_content:
|
if text_content:
|
||||||
|
seqs = self._fetch_latest_pair_seqs(session_id)
|
||||||
self.sse_queues[request_id].put({
|
self.sse_queues[request_id].put({
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"content": text_content,
|
"content": text_content,
|
||||||
"request_id": request_id,
|
"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}")
|
logger.debug(f"SSE skipped duplicate file for request {request_id}")
|
||||||
return
|
return
|
||||||
@@ -307,11 +325,14 @@ class WebChannel(ChatChannel):
|
|||||||
logger.debug(f"SSE skipped http media reply for request {request_id}")
|
logger.debug(f"SSE skipped http media reply for request {request_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
seqs = self._fetch_latest_pair_seqs(session_id)
|
||||||
self.sse_queues[request_id].put({
|
self.sse_queues[request_id].put({
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"content": content,
|
"content": content,
|
||||||
"request_id": request_id,
|
"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}")
|
logger.debug(f"SSE done sent for request {request_id}")
|
||||||
# Auto-trigger TTS once the bot finishes its text reply. The
|
# Auto-trigger TTS once the bot finishes its text reply. The
|
||||||
@@ -339,6 +360,13 @@ class WebChannel(ChatChannel):
|
|||||||
):
|
):
|
||||||
logger.debug(f"Polling skipped duplicate file reply for session {session_id}")
|
logger.debug(f"Polling skipped duplicate file reply for session {session_id}")
|
||||||
return
|
return
|
||||||
|
# SSE-enabled requests already stream the text reply to the
|
||||||
|
# client. Do NOT also enqueue it for polling: if the user
|
||||||
|
# switched away mid-run, the queued copy would resurface as a
|
||||||
|
# duplicate bubble when they return and poll the session.
|
||||||
|
if reply.type == ReplyType.TEXT and context.get("on_event") is not None:
|
||||||
|
logger.debug(f"Polling skipped SSE text reply for session {session_id}")
|
||||||
|
return
|
||||||
response_data = {
|
response_data = {
|
||||||
"type": str(reply.type),
|
"type": str(reply.type),
|
||||||
"content": content,
|
"content": content,
|
||||||
@@ -404,7 +432,15 @@ class WebChannel(ChatChannel):
|
|||||||
elif event_type == "tool_execution_start":
|
elif event_type == "tool_execution_start":
|
||||||
tool_name = data.get("tool_name", "tool")
|
tool_name = data.get("tool_name", "tool")
|
||||||
arguments = data.get("arguments", {})
|
arguments = data.get("arguments", {})
|
||||||
q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments})
|
q.put({"type": "tool_start", "tool_call_id": data.get("tool_call_id"), "tool": tool_name, "arguments": arguments})
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_progress":
|
||||||
|
q.put({
|
||||||
|
"type": "tool_progress",
|
||||||
|
"tool_call_id": data.get("tool_call_id"),
|
||||||
|
"tool": data.get("tool_name", "tool"),
|
||||||
|
"content": str(data.get("message", ""))[-4 * 1024:],
|
||||||
|
})
|
||||||
|
|
||||||
elif event_type == "tool_execution_end":
|
elif event_type == "tool_execution_end":
|
||||||
tool_name = data.get("tool_name", "tool")
|
tool_name = data.get("tool_name", "tool")
|
||||||
@@ -417,6 +453,7 @@ class WebChannel(ChatChannel):
|
|||||||
result_str = result_str[:2000] + "…"
|
result_str = result_str[:2000] + "…"
|
||||||
q.put({
|
q.put({
|
||||||
"type": "tool_end",
|
"type": "tool_end",
|
||||||
|
"tool_call_id": data.get("tool_call_id"),
|
||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
"status": status,
|
"status": status,
|
||||||
"result": result_str,
|
"result": result_str,
|
||||||
@@ -919,7 +956,12 @@ class WebChannel(ChatChannel):
|
|||||||
post_done = True
|
post_done = True
|
||||||
post_deadline = time.time() + 2 # 2s post-attach tail
|
post_deadline = time.time() + 2 # 2s post-attach tail
|
||||||
finally:
|
finally:
|
||||||
self.sse_queues.pop(request_id, None)
|
# Only drop the queue once the reply is actually complete. If the
|
||||||
|
# client disconnected early (e.g. switched sessions and will
|
||||||
|
# re-attach with the same request_id), keep the queue so the new
|
||||||
|
# connection can resume reading the remaining events.
|
||||||
|
if post_done or time.time() >= deadline:
|
||||||
|
self.sse_queues.pop(request_id, None)
|
||||||
|
|
||||||
def cancel_request(self):
|
def cancel_request(self):
|
||||||
"""
|
"""
|
||||||
@@ -1025,22 +1067,44 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
self._cleanup_stale_voice_recordings()
|
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(
|
logger.info(
|
||||||
"[WebChannel] Available channels (edit `channel_type` in config.json to switch, separate multiple with commas):")
|
"[WebChannel] Available channels (edit `channel_type` in config.json to switch, separate multiple with commas):")
|
||||||
logger.info("[WebChannel] 1. web - Web")
|
zh_channels = [
|
||||||
logger.info("[WebChannel] 2. terminal - Terminal")
|
("web", "Web"),
|
||||||
logger.info("[WebChannel] 3. weixin - WeChat")
|
("terminal", "Terminal"),
|
||||||
logger.info("[WebChannel] 4. feishu - Feishu")
|
("weixin", "WeChat"),
|
||||||
logger.info("[WebChannel] 5. dingtalk - DingTalk")
|
("feishu", "Feishu"),
|
||||||
logger.info("[WebChannel] 6. wecom_bot - WeCom Bot")
|
("dingtalk", "DingTalk"),
|
||||||
logger.info("[WebChannel] 7. wechatcom_app - WeCom App")
|
("wecom_bot", "WeCom Bot"),
|
||||||
logger.info("[WebChannel] 8. wechat_kf - WeChat Customer Service")
|
("wechatcom_app", "WeCom App"),
|
||||||
logger.info("[WebChannel] 9. wechatmp - WeChat Official Account")
|
("wechat_kf", "WeChat Customer Service"),
|
||||||
logger.info("[WebChannel] 10. wechatmp_service - WeChat Official Account (Service)")
|
("wechatmp", "WeChat Official Account"),
|
||||||
logger.info("[WebChannel] 11. telegram - Telegram")
|
("wechatmp_service", "WeChat Official Account (Service)"),
|
||||||
logger.info("[WebChannel] 12. slack - Slack")
|
("telegram", "Telegram"),
|
||||||
logger.info("[WebChannel] 13. discord - Discord")
|
("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("[WebChannel] ✅ Web console is running")
|
||||||
logger.info(f"[WebChannel] 🌐 Local access: http://localhost:{port}")
|
logger.info(f"[WebChannel] 🌐 Local access: http://localhost:{port}")
|
||||||
if is_public_bind:
|
if is_public_bind:
|
||||||
@@ -1090,12 +1154,17 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/list', 'KnowledgeListHandler',
|
'/api/knowledge/list', 'KnowledgeListHandler',
|
||||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||||
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
|
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||||
'/api/sessions', 'SessionsHandler',
|
'/api/sessions', 'SessionsHandler',
|
||||||
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
||||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||||
'/api/sessions/(.*)', 'SessionDetailHandler',
|
'/api/sessions/(.*)', 'SessionDetailHandler',
|
||||||
'/api/history', 'HistoryHandler',
|
'/api/history', 'HistoryHandler',
|
||||||
|
'/api/messages/delete', 'MessageDeleteHandler',
|
||||||
'/api/logs', 'LogsHandler',
|
'/api/logs', 'LogsHandler',
|
||||||
'/api/version', 'VersionHandler',
|
'/api/version', 'VersionHandler',
|
||||||
'/assets/(.*)', 'AssetsHandler',
|
'/assets/(.*)', 'AssetsHandler',
|
||||||
@@ -1403,15 +1472,17 @@ class ChatHandler:
|
|||||||
class ConfigHandler:
|
class ConfigHandler:
|
||||||
|
|
||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
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,
|
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
|
||||||
|
# default) since it is often unavailable due to policy restrictions.
|
||||||
|
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
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.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.GLM_5_2, 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.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||||
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, 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,
|
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
]
|
]
|
||||||
@@ -1442,7 +1513,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": None,
|
"api_base_key": None,
|
||||||
"api_base_default": None,
|
"api_base_default": None,
|
||||||
"api_base_placeholder": "",
|
"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", {
|
("claudeAPI", {
|
||||||
"label": "Claude",
|
"label": "Claude",
|
||||||
@@ -1450,7 +1521,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [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],
|
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
}),
|
}),
|
||||||
("gemini", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1474,7 +1545,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "zhipu_ai_api_base",
|
"api_base_key": "zhipu_ai_api_base",
|
||||||
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
|
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
|
||||||
"models": [const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
|
"models": [const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
|
||||||
}),
|
}),
|
||||||
("dashscope", {
|
("dashscope", {
|
||||||
"label": {"zh": "通义千问", "en": "Qwen"},
|
"label": {"zh": "通义千问", "en": "Qwen"},
|
||||||
@@ -1482,7 +1553,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": None,
|
"api_base_key": None,
|
||||||
"api_base_default": None,
|
"api_base_default": None,
|
||||||
"api_base_placeholder": "",
|
"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", {
|
("doubao", {
|
||||||
"label": {"zh": "豆包", "en": "Doubao"},
|
"label": {"zh": "豆包", "en": "Doubao"},
|
||||||
@@ -1498,7 +1569,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "moonshot_base_url",
|
"api_base_key": "moonshot_base_url",
|
||||||
"api_base_default": "https://api.moonshot.cn/v1",
|
"api_base_default": "https://api.moonshot.cn/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||||
}),
|
}),
|
||||||
("qianfan", {
|
("qianfan", {
|
||||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||||
@@ -1542,8 +1613,9 @@ class ConfigHandler:
|
|||||||
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
||||||
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||||
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
||||||
|
"custom_providers",
|
||||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||||
"enable_thinking", "web_password",
|
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1583,6 +1655,32 @@ class ConfigHandler:
|
|||||||
"api_key_field": p.get("api_key_field"),
|
"api_key_field": p.get("api_key_field"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Expose user-defined custom providers as "custom:<id>" entries so
|
||||||
|
# the legacy config page can display and select them. Credentials
|
||||||
|
# are managed on the Models page, hence the null key/base fields.
|
||||||
|
# Mirrors the Models page: when expanded entries exist, the bare
|
||||||
|
# legacy "custom" entry is hidden — unless the flat single-provider
|
||||||
|
# custom config is still active or filled in.
|
||||||
|
try:
|
||||||
|
from models.custom_provider import get_custom_providers
|
||||||
|
custom_list = get_custom_providers()
|
||||||
|
legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config)
|
||||||
|
if custom_list and not legacy_custom_in_use:
|
||||||
|
providers.pop("custom", None)
|
||||||
|
for cp in custom_list:
|
||||||
|
cid = f"custom:{cp.get('id')}"
|
||||||
|
cname = cp.get("name") or cp.get("id")
|
||||||
|
providers[cid] = {
|
||||||
|
"label": {"zh": cname, "en": cname},
|
||||||
|
"models": [cp["model"]] if cp.get("model") else [],
|
||||||
|
"api_base_key": None,
|
||||||
|
"api_base_default": None,
|
||||||
|
"api_base_placeholder": "",
|
||||||
|
"api_key_field": None,
|
||||||
|
}
|
||||||
|
except Exception as cp_err:
|
||||||
|
logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}")
|
||||||
|
|
||||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||||
|
|
||||||
@@ -1598,6 +1696,7 @@ class ConfigHandler:
|
|||||||
"agent_max_context_turns": local_config.get("agent_max_context_turns", 20),
|
"agent_max_context_turns": local_config.get("agent_max_context_turns", 20),
|
||||||
"agent_max_steps": local_config.get("agent_max_steps", 20),
|
"agent_max_steps": local_config.get("agent_max_steps", 20),
|
||||||
"enable_thinking": bool(local_config.get("enable_thinking", False)),
|
"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_bases": api_bases,
|
||||||
"api_keys": api_keys_masked,
|
"api_keys": api_keys_masked,
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
@@ -1623,7 +1722,7 @@ class ConfigHandler:
|
|||||||
continue
|
continue
|
||||||
if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"):
|
if key in ("agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps"):
|
||||||
value = int(value)
|
value = int(value)
|
||||||
if key in ("use_linkai", "enable_thinking"):
|
if key in ("use_linkai", "enable_thinking", "self_evolution_enabled"):
|
||||||
value = bool(value)
|
value = bool(value)
|
||||||
local_config[key] = value
|
local_config[key] = value
|
||||||
applied[key] = value
|
applied[key] = value
|
||||||
@@ -1720,6 +1819,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
|
# Per-provider voice timbres. Entries can be a bare code string
|
||||||
# (label = code) or {value, hint?} when a friendly secondary label
|
# (label = code) or {value, hint?} when a friendly secondary label
|
||||||
# helps recognition. We keep `value` as the raw API code so power
|
# helps recognition. We keep `value` as the raw API code so power
|
||||||
@@ -1964,7 +2085,7 @@ class ModelsHandler:
|
|||||||
],
|
],
|
||||||
"doubao": [const.DOUBAO_SEED_2_PRO],
|
"doubao": [const.DOUBAO_SEED_2_PRO],
|
||||||
"moonshot": [const.KIMI_K2_6],
|
"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],
|
"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],
|
"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],
|
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||||
@@ -1985,7 +2106,7 @@ class ModelsHandler:
|
|||||||
"linkai": [
|
"linkai": [
|
||||||
const.GPT_41_MINI,
|
const.GPT_41_MINI,
|
||||||
const.GPT_54_MINI,
|
const.GPT_54_MINI,
|
||||||
const.QWEN36_PLUS,
|
const.QWEN37_PLUS,
|
||||||
const.DOUBAO_SEED_2_PRO,
|
const.DOUBAO_SEED_2_PRO,
|
||||||
const.KIMI_K2_6,
|
const.KIMI_K2_6,
|
||||||
const.CLAUDE_4_6_SONNET,
|
const.CLAUDE_4_6_SONNET,
|
||||||
@@ -2049,13 +2170,99 @@ class ModelsHandler:
|
|||||||
def _is_real_key(value: str) -> bool:
|
def _is_real_key(value: str) -> bool:
|
||||||
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _custom_provider_cards(cls, local_config: dict) -> List[dict]:
|
||||||
|
"""Expand ``custom_providers`` into one card per provider.
|
||||||
|
|
||||||
|
Each user-defined OpenAI-compatible provider becomes its own card with
|
||||||
|
id ``custom:<id>`` so the frontend can render, edit, delete and
|
||||||
|
activate them independently. The card carries ``is_custom=True`` and
|
||||||
|
``active`` flags that the UI uses to render the extra controls.
|
||||||
|
|
||||||
|
Returns an empty list when no multi-providers are configured, in which
|
||||||
|
case the caller keeps the single legacy ``custom`` card untouched —
|
||||||
|
guaranteeing backward compatibility with the flat
|
||||||
|
``custom_api_key`` / ``custom_api_base`` config.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from models.custom_provider import get_custom_providers, parse_custom_bot_type
|
||||||
|
providers = get_custom_providers()
|
||||||
|
except Exception as e: # pragma: no cover - defensive
|
||||||
|
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
|
||||||
|
providers = []
|
||||||
|
if not providers:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Determine the currently active provider id from bot_type.
|
||||||
|
bot_type = local_config.get("bot_type") or ""
|
||||||
|
_, active_id = parse_custom_bot_type(bot_type)
|
||||||
|
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
|
||||||
|
cards = []
|
||||||
|
for p in providers:
|
||||||
|
pid = p.get("id") or ""
|
||||||
|
name = p.get("name") or pid
|
||||||
|
raw_key = p.get("api_key") or ""
|
||||||
|
raw_base = p.get("api_base") or ""
|
||||||
|
configured = cls._is_real_key(raw_key)
|
||||||
|
cards.append({
|
||||||
|
"id": f"custom:{pid}",
|
||||||
|
"label": {"zh": name, "en": name},
|
||||||
|
"configured": configured,
|
||||||
|
"is_custom": True,
|
||||||
|
"custom_id": pid,
|
||||||
|
"custom_name": name,
|
||||||
|
"active": (pid == active_id),
|
||||||
|
"model": p.get("model") or "",
|
||||||
|
# Custom cards are edited via the dedicated set_custom_provider
|
||||||
|
# action, not the field-based set_provider flow, so the field
|
||||||
|
# names are intentionally null.
|
||||||
|
"api_key_field": None,
|
||||||
|
"api_base_field": None,
|
||||||
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
|
"api_base": raw_base,
|
||||||
|
"api_base_default": "",
|
||||||
|
"api_base_placeholder": meta.get("api_base_placeholder") or "",
|
||||||
|
"models": [p.get("model")] if p.get("model") else [],
|
||||||
|
})
|
||||||
|
return cards
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _legacy_custom_in_use(cls, local_config: dict) -> bool:
|
||||||
|
"""True when the flat single-provider custom config is still relevant:
|
||||||
|
either it is the active bot_type, or its key/base fields are filled.
|
||||||
|
In that case the legacy "custom" card must stay visible even when
|
||||||
|
multi ``custom_providers`` entries exist."""
|
||||||
|
if (local_config.get("bot_type") or "") == "custom":
|
||||||
|
return True
|
||||||
|
return (cls._is_real_key(local_config.get("custom_api_key") or "")
|
||||||
|
or bool(local_config.get("custom_api_base")))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _provider_overview(cls) -> List[dict]:
|
def _provider_overview(cls) -> List[dict]:
|
||||||
"""All known providers (configured first, unconfigured after).
|
"""All known providers (configured first, unconfigured after).
|
||||||
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
|
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.
|
||||||
|
|
||||||
|
When the user has defined multiple custom (OpenAI-compatible)
|
||||||
|
providers via ``custom_providers``, the single built-in ``custom``
|
||||||
|
card is replaced by one card per provider (see
|
||||||
|
``_custom_provider_cards``). Otherwise the legacy single ``custom``
|
||||||
|
card is shown unchanged.
|
||||||
|
"""
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
# Keep the legacy single "custom" card visible alongside the expanded
|
||||||
|
# ones when the flat custom_api_key/base config is active or filled,
|
||||||
|
# so existing single-provider setups never disappear from the UI.
|
||||||
|
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||||
items = []
|
items = []
|
||||||
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
||||||
|
if pid == "custom" and custom_cards:
|
||||||
|
# Multi-provider mode: emit the expanded cards, plus the
|
||||||
|
# legacy card when it is still in use.
|
||||||
|
items.extend(custom_cards)
|
||||||
|
if not keep_legacy_custom:
|
||||||
|
continue
|
||||||
key_field = p.get("api_key_field")
|
key_field = p.get("api_key_field")
|
||||||
base_field = p.get("api_base_key")
|
base_field = p.get("api_base_key")
|
||||||
raw_key = local_config.get(key_field, "") if key_field else ""
|
raw_key = local_config.get(key_field, "") if key_field else ""
|
||||||
@@ -2065,6 +2272,7 @@ class ModelsHandler:
|
|||||||
"id": pid,
|
"id": pid,
|
||||||
"label": p["label"],
|
"label": p["label"],
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
|
"is_custom": (pid == "custom"),
|
||||||
"api_key_field": key_field,
|
"api_key_field": key_field,
|
||||||
"api_base_field": base_field,
|
"api_base_field": base_field,
|
||||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
@@ -2073,7 +2281,19 @@ class ModelsHandler:
|
|||||||
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
||||||
"models": list(p.get("models") or []),
|
"models": list(p.get("models") or []),
|
||||||
})
|
})
|
||||||
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
|
|
||||||
|
def _sort_key(it):
|
||||||
|
pid = it["id"]
|
||||||
|
# Custom expanded cards share the sort weight of the base "custom"
|
||||||
|
# entry so they cluster where the single custom card used to be.
|
||||||
|
base_id = "custom" if it.get("is_custom") else pid
|
||||||
|
try:
|
||||||
|
order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id)
|
||||||
|
except ValueError:
|
||||||
|
order = len(ConfigHandler.PROVIDER_MODELS)
|
||||||
|
return (0 if it["configured"] else 1, order)
|
||||||
|
|
||||||
|
items.sort(key=_sort_key)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2081,13 +2301,28 @@ class ModelsHandler:
|
|||||||
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
||||||
bot_type = local_config.get("bot_type") or ""
|
bot_type = local_config.get("bot_type") or ""
|
||||||
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
||||||
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
|
is_custom_id = provider_id.startswith("custom:")
|
||||||
|
if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id
|
||||||
|
and local_config.get("use_linkai")):
|
||||||
provider_id = "linkai"
|
provider_id = "linkai"
|
||||||
|
# In multi-provider mode, replace the single "custom" entry with the
|
||||||
|
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
|
||||||
|
# The legacy "custom" entry stays when its flat config is still used.
|
||||||
|
provider_ids = []
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||||
|
for pid in ConfigHandler.PROVIDER_MODELS.keys():
|
||||||
|
if pid == "custom" and custom_cards:
|
||||||
|
provider_ids.extend(c["id"] for c in custom_cards)
|
||||||
|
if keep_legacy_custom:
|
||||||
|
provider_ids.append(pid)
|
||||||
|
else:
|
||||||
|
provider_ids.append(pid)
|
||||||
return {
|
return {
|
||||||
"editable": True,
|
"editable": True,
|
||||||
"current_provider": provider_id,
|
"current_provider": provider_id,
|
||||||
"current_model": local_config.get("model", ""),
|
"current_model": local_config.get("model", ""),
|
||||||
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
|
"providers": provider_ids,
|
||||||
"use_linkai": bool(local_config.get("use_linkai", False)),
|
"use_linkai": bool(local_config.get("use_linkai", False)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2102,7 +2337,7 @@ class ModelsHandler:
|
|||||||
_VISION_AUTO_ORDER = [
|
_VISION_AUTO_ORDER = [
|
||||||
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
||||||
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
("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),
|
("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET),
|
||||||
("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
|
("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
|
||||||
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
||||||
@@ -2240,8 +2475,9 @@ class ModelsHandler:
|
|||||||
"editable": True,
|
"editable": True,
|
||||||
"current_provider": explicit,
|
"current_provider": explicit,
|
||||||
"suggested_provider": suggested,
|
"suggested_provider": suggested,
|
||||||
"current_model": "",
|
"current_model": (local_config.get("voice_to_text_model") or "") if explicit else "",
|
||||||
"providers": cls._ASR_PROVIDERS,
|
"providers": cls._ASR_PROVIDERS,
|
||||||
|
"provider_models": cls._ASR_PROVIDER_MODELS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2520,6 +2756,12 @@ class ModelsHandler:
|
|||||||
return self._handle_set_provider(data)
|
return self._handle_set_provider(data)
|
||||||
if action == "delete_provider":
|
if action == "delete_provider":
|
||||||
return self._handle_delete_provider(data)
|
return self._handle_delete_provider(data)
|
||||||
|
if action == "set_custom_provider":
|
||||||
|
return self._handle_set_custom_provider(data)
|
||||||
|
if action == "delete_custom_provider":
|
||||||
|
return self._handle_delete_custom_provider(data)
|
||||||
|
if action == "set_active_custom_provider":
|
||||||
|
return self._handle_set_active_custom_provider(data)
|
||||||
if action == "set_capability":
|
if action == "set_capability":
|
||||||
return self._handle_set_capability(data)
|
return self._handle_set_capability(data)
|
||||||
if action == "set_voice_reply_mode":
|
if action == "set_voice_reply_mode":
|
||||||
@@ -2603,6 +2845,170 @@ class ModelsHandler:
|
|||||||
self._reset_bridge()
|
self._reset_bridge()
|
||||||
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Multiple custom (OpenAI-compatible) providers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# These actions manage the ``custom_providers`` list. Activation is done
|
||||||
|
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
|
||||||
|
# ``custom_active_provider`` field — a single source of truth.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_custom_providers(raw) -> List[dict]:
|
||||||
|
"""Return a clean list of provider dicts (drops malformed entries)."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for p in raw:
|
||||||
|
if isinstance(p, dict) and (p.get("id") or "").strip():
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
|
||||||
|
"""Write the providers list to both in-memory conf and the on-disk
|
||||||
|
config, then reset the bridge so bots rebuild.
|
||||||
|
|
||||||
|
If ``bot_type`` is given, also update ``bot_type``. When activating a
|
||||||
|
provider (bot_type is ``custom:<id>``), also write the provider's
|
||||||
|
``model`` into the global ``model`` field so that all paths (chat,
|
||||||
|
agent, vision) automatically use the correct model."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
local_config["custom_providers"] = providers
|
||||||
|
file_cfg["custom_providers"] = providers
|
||||||
|
if bot_type is not None:
|
||||||
|
local_config["bot_type"] = bot_type
|
||||||
|
file_cfg["bot_type"] = bot_type
|
||||||
|
# Sync the provider's model into the global model field.
|
||||||
|
_, pid = parse_custom_bot_type(bot_type)
|
||||||
|
if pid:
|
||||||
|
provider = next((p for p in providers if p.get("id") == pid), None)
|
||||||
|
if provider and provider.get("model"):
|
||||||
|
local_config["model"] = provider["model"]
|
||||||
|
file_cfg["model"] = provider["model"]
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
self._reset_bridge()
|
||||||
|
|
||||||
|
def _handle_set_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Add a new custom provider or update an existing one.
|
||||||
|
|
||||||
|
Payload::
|
||||||
|
|
||||||
|
{
|
||||||
|
"action": "set_custom_provider",
|
||||||
|
"id": "3f2a9c1b", # required for edit; omit for create
|
||||||
|
"name": "siliconflow", # required, display label
|
||||||
|
"api_base": "https://...", # required when creating
|
||||||
|
"api_key": "sk-...", # optional on edit (keep existing)
|
||||||
|
"model": "deepseek-ai/...", # optional default model
|
||||||
|
"make_active": true # optional, also activate it
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from models.custom_provider import generate_provider_id, parse_custom_bot_type
|
||||||
|
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return json.dumps({"status": "error", "message": "name is required"})
|
||||||
|
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
api_base = (data.get("api_base") or "").strip()
|
||||||
|
# api_key omitted/empty on edit => keep the existing one.
|
||||||
|
api_key_raw = data.get("api_key")
|
||||||
|
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
||||||
|
model = (data.get("model") or "").strip()
|
||||||
|
make_active = bool(data.get("make_active"))
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
|
||||||
|
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None
|
||||||
|
if existing is None:
|
||||||
|
# Creating a new provider — api_base is mandatory.
|
||||||
|
if not api_base:
|
||||||
|
return json.dumps({"status": "error", "message": "api_base is required"})
|
||||||
|
provider_id = generate_provider_id()
|
||||||
|
entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base}
|
||||||
|
if model:
|
||||||
|
entry["model"] = model
|
||||||
|
providers.append(entry)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
existing["name"] = name
|
||||||
|
if api_base:
|
||||||
|
existing["api_base"] = api_base
|
||||||
|
if api_key:
|
||||||
|
existing["api_key"] = api_key
|
||||||
|
# Only touch model when explicitly provided in the payload; an
|
||||||
|
# explicit empty string clears it, a missing key keeps it (the
|
||||||
|
# UI modal no longer sends model, so manual config survives edits).
|
||||||
|
if "model" in data:
|
||||||
|
if model:
|
||||||
|
existing["model"] = model
|
||||||
|
else:
|
||||||
|
existing.pop("model", None)
|
||||||
|
created = False
|
||||||
|
|
||||||
|
# Decide bot_type — only switch when explicitly requested.
|
||||||
|
new_bot_type = None
|
||||||
|
if make_active:
|
||||||
|
new_bot_type = f"custom:{provider_id}"
|
||||||
|
|
||||||
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
|
logger.info(
|
||||||
|
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
|
||||||
|
f"{'created' if created else 'updated'}"
|
||||||
|
)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"id": provider_id,
|
||||||
|
"name": name,
|
||||||
|
"created": created,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _handle_delete_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Remove a custom provider by id."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
remaining = [p for p in providers if p.get("id") != provider_id]
|
||||||
|
if len(remaining) == len(providers):
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
|
# If the deleted provider was active, fall back to the first remaining.
|
||||||
|
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
|
||||||
|
new_bot_type = None
|
||||||
|
if current_active_id == provider_id:
|
||||||
|
if remaining:
|
||||||
|
new_bot_type = f"custom:{remaining[0]['id']}"
|
||||||
|
else:
|
||||||
|
new_bot_type = "custom" # revert to legacy
|
||||||
|
|
||||||
|
self._persist_custom_providers(remaining, new_bot_type)
|
||||||
|
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
|
||||||
|
return json.dumps({"status": "success", "id": provider_id})
|
||||||
|
|
||||||
|
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
if not any(p.get("id") == provider_id for p in providers):
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
|
new_bot_type = f"custom:{provider_id}"
|
||||||
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
|
logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}")
|
||||||
|
return json.dumps({"status": "success", "active_id": provider_id})
|
||||||
|
|
||||||
def _handle_set_capability(self, data: dict) -> str:
|
def _handle_set_capability(self, data: dict) -> str:
|
||||||
capability = (data.get("capability") or "").strip()
|
capability = (data.get("capability") or "").strip()
|
||||||
provider_id = (data.get("provider_id") or "").strip()
|
provider_id = (data.get("provider_id") or "").strip()
|
||||||
@@ -2613,7 +3019,7 @@ class ModelsHandler:
|
|||||||
if capability == "vision":
|
if capability == "vision":
|
||||||
return self._set_vision(provider_id, model)
|
return self._set_vision(provider_id, model)
|
||||||
if capability == "asr":
|
if capability == "asr":
|
||||||
return self._set_simple("voice_to_text", provider_id)
|
return self._set_asr(provider_id, model)
|
||||||
if capability == "tts":
|
if capability == "tts":
|
||||||
return self._set_tts(provider_id, model, (data.get("voice") or "").strip())
|
return self._set_tts(provider_id, model, (data.get("voice") or "").strip())
|
||||||
if capability == "embedding":
|
if capability == "embedding":
|
||||||
@@ -2667,13 +3073,28 @@ class ModelsHandler:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _set_chat(self, provider_id: str, model: str) -> str:
|
def _set_chat(self, provider_id: str, model: str) -> str:
|
||||||
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
# Accept expanded custom provider ids ("custom:<id>") as well as the
|
||||||
|
# built-in vendors, so the chat capability card and the custom
|
||||||
|
# providers section behave consistently.
|
||||||
|
custom_provider = None
|
||||||
|
if provider_id.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_id)
|
||||||
|
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||||
|
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||||
|
if custom_provider is None:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||||
|
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
||||||
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
applied = {}
|
applied = {}
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
file_cfg = self._read_file_config()
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
# Fall back to the custom provider's default model when none is given.
|
||||||
|
if not model and custom_provider:
|
||||||
|
model = custom_provider.get("model") or ""
|
||||||
|
|
||||||
if provider_id:
|
if provider_id:
|
||||||
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
||||||
local_config["bot_type"] = bot_type_value
|
local_config["bot_type"] = bot_type_value
|
||||||
@@ -2773,6 +3194,30 @@ class ModelsHandler:
|
|||||||
self._refresh_voice_routing()
|
self._refresh_voice_routing()
|
||||||
return json.dumps({"status": "success", key: value})
|
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:
|
def _set_tts(self, provider_id: str, model: str, voice: str = "") -> str:
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
file_cfg = self._read_file_config()
|
file_cfg = self._read_file_config()
|
||||||
@@ -3720,6 +4165,141 @@ class SchedulerHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerToggleHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
enabled = body.get("enabled", True)
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
store.enable_task(task_id, enabled)
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler toggle error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerUpdateHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
from agent.tools.scheduler.scheduler_service import SchedulerService
|
||||||
|
from datetime import datetime
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
|
||||||
|
# Get original task (single query to avoid repeated I/O)
|
||||||
|
original_task = store.get_task(task_id)
|
||||||
|
if not original_task:
|
||||||
|
return json.dumps({"status": "error", "message": f"Task '{task_id}' not found"})
|
||||||
|
|
||||||
|
# Build updates dict
|
||||||
|
updates = {}
|
||||||
|
if "name" in body:
|
||||||
|
updates["name"] = body["name"]
|
||||||
|
if "enabled" in body:
|
||||||
|
updates["enabled"] = body["enabled"]
|
||||||
|
|
||||||
|
# Update schedule
|
||||||
|
if "schedule" in body:
|
||||||
|
updates["schedule"] = body["schedule"]
|
||||||
|
# If schedule config changed, recalculate next_run_at
|
||||||
|
# Build merged temp task data for calculation (without modifying the original object)
|
||||||
|
merged = dict(original_task)
|
||||||
|
merged.update(updates)
|
||||||
|
if "action" in body:
|
||||||
|
merged["action"] = body["action"]
|
||||||
|
temp_service = SchedulerService(store, lambda t: None)
|
||||||
|
next_run = temp_service._calculate_next_run(merged, datetime.now())
|
||||||
|
if next_run:
|
||||||
|
updates["next_run_at"] = next_run.isoformat()
|
||||||
|
else:
|
||||||
|
# Cannot calculate next run time, schedule config may be invalid
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Cannot calculate next run time. Please check the schedule config (e.g., cron expression format, or whether the one-time task time has already passed)."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Update action
|
||||||
|
if "action" in body:
|
||||||
|
action = body["action"]
|
||||||
|
channel_type = action.get("channel_type", "web")
|
||||||
|
|
||||||
|
# Get the task's original channel_type
|
||||||
|
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
||||||
|
|
||||||
|
# If channel type changed or no receiver, reject the update.
|
||||||
|
# Note: the web UI disables the channel selector, so this branch
|
||||||
|
# is only reachable via direct API calls. Changing a task's channel
|
||||||
|
# after creation is not supported because the receiver identity is
|
||||||
|
# channel-bound and cannot be trivially re-populated (e.g. weixin
|
||||||
|
# requires a valid context_token tied to the original user-session).
|
||||||
|
if old_channel and old_channel != channel_type:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Cannot change channel type from '{old_channel}' to '{channel_type}'. Please create a new task on the target channel instead."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
if not action.get("receiver"):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Receiver is required. Please create a new task through the chat interface."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
updates["action"] = action
|
||||||
|
|
||||||
|
# If schedule was not updated but action was, ensure next_run_at exists
|
||||||
|
if "schedule" not in body and "next_run_at" not in original_task:
|
||||||
|
merged = dict(original_task)
|
||||||
|
merged.update(updates)
|
||||||
|
temp_service = SchedulerService(store, lambda t: None)
|
||||||
|
next_run = temp_service._calculate_next_run(merged, datetime.now())
|
||||||
|
if next_run:
|
||||||
|
updates["next_run_at"] = next_run.isoformat()
|
||||||
|
|
||||||
|
store.update_task(task_id, updates)
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler update error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerDeleteHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
store.delete_task(task_id)
|
||||||
|
return json.dumps({"status": "success"}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler delete error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
class SessionsHandler:
|
class SessionsHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
@@ -3873,6 +4453,40 @@ class HistoryHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
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.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:
|
class LogsHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
@@ -4014,6 +4628,25 @@ class KnowledgeGraphHandler:
|
|||||||
return json.dumps({"nodes": [], "links": []})
|
return json.dumps({"nodes": [], "links": []})
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeActionHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data() or b"{}")
|
||||||
|
action = body.get("action", "")
|
||||||
|
payload = body.get("payload") or {}
|
||||||
|
from agent.knowledge.service import KnowledgeService
|
||||||
|
result = KnowledgeService(_get_workspace_root()).dispatch(action, payload)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success" if result["code"] < 300 else "error",
|
||||||
|
**result,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Knowledge action error: {e}")
|
||||||
|
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||||
|
|
||||||
|
|
||||||
class VersionHandler:
|
class VersionHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ def verify_server(data):
|
|||||||
nonce = data.nonce
|
nonce = data.nonce
|
||||||
echostr = data.get("echostr", None)
|
echostr = data.get("echostr", None)
|
||||||
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写
|
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)
|
check_signature(token, signature, timestamp, nonce)
|
||||||
return echostr
|
return echostr
|
||||||
except InvalidSignatureException:
|
except InvalidSignatureException:
|
||||||
raise web.Forbidden("Invalid signature")
|
raise web.Forbidden("Invalid signature")
|
||||||
|
except web.Forbidden:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise web.Forbidden(str(e))
|
raise web.Forbidden(str(e))
|
||||||
|
|||||||
@@ -12,16 +12,19 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import web
|
||||||
import websocket
|
import websocket
|
||||||
|
|
||||||
from bridge.context import Context, ContextType
|
from bridge.context import Context, ContextType
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
|
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
|
||||||
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -32,6 +35,9 @@ from config import conf
|
|||||||
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
||||||
HEARTBEAT_INTERVAL = 30
|
HEARTBEAT_INTERVAL = 30
|
||||||
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
|
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
|
||||||
|
# Fixed URL path for the callback (webhook) HTTP server. The bot's
|
||||||
|
# receive-message URL must point at this path, e.g. http://host:9892/wecombot
|
||||||
|
CALLBACK_PATH = "/wecombot"
|
||||||
|
|
||||||
|
|
||||||
def _escape_control_chars_inside_json_strings(s: str) -> str:
|
def _escape_control_chars_inside_json_strings(s: str) -> str:
|
||||||
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
|
|||||||
self._pending_lock = threading.Lock()
|
self._pending_lock = threading.Lock()
|
||||||
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
||||||
|
|
||||||
|
# Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
|
||||||
|
self.mode = "websocket"
|
||||||
|
self._crypt = None
|
||||||
|
self._http_server = None
|
||||||
|
# stream_id -> {"committed", "current", "finished", "images", "last_access"}
|
||||||
|
self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min)
|
||||||
|
self._callback_lock = threading.Lock()
|
||||||
|
|
||||||
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||||
conf()["single_chat_prefix"] = [""]
|
conf()["single_chat_prefix"] = [""]
|
||||||
|
|
||||||
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def startup(self):
|
def startup(self):
|
||||||
|
self.mode = conf().get("wecom_bot_mode", "websocket")
|
||||||
|
if self.mode == "webhook":
|
||||||
|
self._startup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
self.bot_id = conf().get("wecom_bot_id", "")
|
self.bot_id = conf().get("wecom_bot_id", "")
|
||||||
self.bot_secret = conf().get("wecom_bot_secret", "")
|
self.bot_secret = conf().get("wecom_bot_secret", "")
|
||||||
|
|
||||||
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
|
|||||||
pass
|
pass
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
if self._http_server:
|
||||||
|
try:
|
||||||
|
self._http_server.stop()
|
||||||
|
logger.info("[WecomBot] Callback HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# WebSocket connection
|
# WebSocket connection
|
||||||
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
|
|||||||
def _gen_req_id(self) -> str:
|
def _gen_req_id(self) -> str:
|
||||||
return uuid.uuid4().hex[:16]
|
return uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Callback (webhook) mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _startup_callback(self):
|
||||||
|
"""Start an HTTP server that receives encrypted callbacks (webhook mode).
|
||||||
|
|
||||||
|
The bot's "接收消息" URL in the WeCom admin console should point at this
|
||||||
|
server (any path is accepted). Verification (GET) and message delivery
|
||||||
|
(POST) are both handled by ``WecomBotCallbackController``.
|
||||||
|
"""
|
||||||
|
token = conf().get("wecom_bot_token", "")
|
||||||
|
aes_key = conf().get("wecom_bot_encoding_aes_key", "")
|
||||||
|
if not token or not aes_key:
|
||||||
|
err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Enterprise-internal smart bot: receive_id is an empty string.
|
||||||
|
self._crypt = WecomBotCrypt(token, aes_key, "")
|
||||||
|
except Exception as e:
|
||||||
|
err = f"[WecomBot] invalid callback credentials: {e}"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
port = int(conf().get("wecom_bot_port", 9892))
|
||||||
|
logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}, path {CALLBACK_PATH} ...")
|
||||||
|
# Only serve the fixed callback path; everything else 404s instead of being
|
||||||
|
# treated as a (signature-failing) WeCom callback.
|
||||||
|
urls = (re.escape(CALLBACK_PATH), "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
|
||||||
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
|
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||||
|
func = web.httpserver.LogMiddleware(func)
|
||||||
|
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||||
|
self._http_server = server
|
||||||
|
self.report_startup_success()
|
||||||
|
try:
|
||||||
|
server.start()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
def _new_callback_stream(self, response_url: str = "") -> str:
|
||||||
|
"""Create a new stream state and return its id."""
|
||||||
|
stream_id = uuid.uuid4().hex[:16]
|
||||||
|
now = time.time()
|
||||||
|
with self._callback_lock:
|
||||||
|
self._callback_streams[stream_id] = {
|
||||||
|
"committed": "",
|
||||||
|
"current": "",
|
||||||
|
"finished": False,
|
||||||
|
"images": [], # list of (base64_str, md5_str), flushed only at finish
|
||||||
|
"image_urls": [], # public http(s) image urls (usable in response_url markdown)
|
||||||
|
"image_pending": False, # an image reply is being prepared; don't finish on text yet
|
||||||
|
"last_access": now,
|
||||||
|
"created_at": now,
|
||||||
|
"response_url": response_url or "",
|
||||||
|
"delivered": False, # final answer handed to WeCom via a poll
|
||||||
|
"url_sent": False, # final answer pushed via response_url (active reply)
|
||||||
|
}
|
||||||
|
return stream_id
|
||||||
|
|
||||||
|
def _callback_handle_message(self, data: dict) -> dict:
|
||||||
|
"""Handle a freshly-received user message in callback mode.
|
||||||
|
|
||||||
|
Produces the context for async processing and returns the initial passive
|
||||||
|
reply (a stream packet with finish=false) so WeCom starts polling for the
|
||||||
|
agent's streamed answer. Returns ``None`` when there's nothing to reply
|
||||||
|
(e.g. an image/file silently cached for the next query).
|
||||||
|
"""
|
||||||
|
msg_id = data.get("msgid", "")
|
||||||
|
if msg_id and self.received_msgs.get(msg_id):
|
||||||
|
logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
|
||||||
|
return None
|
||||||
|
if msg_id:
|
||||||
|
self.received_msgs[msg_id] = True
|
||||||
|
|
||||||
|
chattype = data.get("chattype", "single")
|
||||||
|
is_group = chattype == "group"
|
||||||
|
|
||||||
|
default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
|
||||||
|
result = self._build_context(data, is_group, default_aeskey=default_aeskey)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
context, wecom_msg = result
|
||||||
|
|
||||||
|
# response_url lets us actively reply once within 1h, used as a fallback
|
||||||
|
# when the agent finishes after WeCom stops polling (max ~6min window).
|
||||||
|
response_url = data.get("response_url", "") or ""
|
||||||
|
stream_id = self._new_callback_stream(response_url=response_url)
|
||||||
|
wecom_msg.stream_id = stream_id
|
||||||
|
context["wecom_stream_id"] = stream_id
|
||||||
|
context["on_event"] = self._make_callback_stream_callback(stream_id)
|
||||||
|
self.produce(context)
|
||||||
|
|
||||||
|
# First passive reply: register the stream id, WeCom will poll for updates.
|
||||||
|
return {
|
||||||
|
"msgtype": "stream",
|
||||||
|
"stream": {"id": stream_id, "finish": False, "content": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _callback_handle_stream_poll(self, data: dict) -> dict:
|
||||||
|
"""Handle a "流式消息刷新" poll: return the latest accumulated content."""
|
||||||
|
stream_id = data.get("stream", {}).get("id", "")
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if state is None:
|
||||||
|
# Unknown / expired stream: tell WeCom we're done to stop polling.
|
||||||
|
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
if state.get("url_sent"):
|
||||||
|
# Final answer already pushed via response_url; finish silently.
|
||||||
|
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||||
|
# We never force-finish on a timer: while a task is still running the
|
||||||
|
# bubble should keep spinning until either the task finishes or the
|
||||||
|
# user cancels. If WeCom's 6min window closes before completion, the
|
||||||
|
# answer is delivered later via response_url instead.
|
||||||
|
finished = state["finished"]
|
||||||
|
content = state["committed"] + state["current"]
|
||||||
|
images = state["images"] if finished else []
|
||||||
|
if finished:
|
||||||
|
state["delivered"] = True
|
||||||
|
logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")
|
||||||
|
|
||||||
|
stream = {"id": stream_id, "finish": finished, "content": content}
|
||||||
|
if images:
|
||||||
|
stream["msg_item"] = [
|
||||||
|
{"msgtype": "image", "image": {"base64": b64, "md5": md5}}
|
||||||
|
for (b64, md5) in images
|
||||||
|
]
|
||||||
|
return {"msgtype": "stream", "stream": stream}
|
||||||
|
|
||||||
|
def _make_callback_stream_callback(self, stream_id: str):
|
||||||
|
"""Build an on_event callback that accumulates agent output into stream state.
|
||||||
|
|
||||||
|
Mirrors the websocket streaming behaviour: intermediate turns (text before
|
||||||
|
a tool call) are committed with a '---' separator; WeCom reads the full
|
||||||
|
accumulated content on each poll.
|
||||||
|
"""
|
||||||
|
def on_event(event: dict):
|
||||||
|
event_type = event.get("type")
|
||||||
|
edata = event.get("data", {})
|
||||||
|
cancelled = False
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "turn_start":
|
||||||
|
state["current"] = ""
|
||||||
|
elif event_type == "message_update":
|
||||||
|
delta = edata.get("delta", "")
|
||||||
|
if delta:
|
||||||
|
state["current"] += delta
|
||||||
|
elif event_type == "message_end":
|
||||||
|
tool_calls = edata.get("tool_calls", [])
|
||||||
|
if tool_calls:
|
||||||
|
if state["current"].strip():
|
||||||
|
state["committed"] += state["current"].strip() + "\n\n---\n\n"
|
||||||
|
state["current"] = ""
|
||||||
|
else:
|
||||||
|
state["committed"] += state["current"]
|
||||||
|
state["current"] = ""
|
||||||
|
elif event_type == "agent_cancelled":
|
||||||
|
# Mechanism 1: a cancelled run never reaches send(), so finalize
|
||||||
|
# its stream here to stop the "···" bubble immediately.
|
||||||
|
if state["current"]:
|
||||||
|
state["committed"] += state["current"]
|
||||||
|
state["current"] = ""
|
||||||
|
state["committed"] = state["committed"].rstrip()
|
||||||
|
if state["committed"].endswith("---"):
|
||||||
|
state["committed"] = state["committed"][:-3].rstrip()
|
||||||
|
if not state["committed"].strip():
|
||||||
|
state["committed"] = "🛑 已中止"
|
||||||
|
state["finished"] = True
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
cancelled = True
|
||||||
|
|
||||||
|
if cancelled:
|
||||||
|
# Outside the lock: response_url fallback re-acquires it.
|
||||||
|
self._schedule_response_url_fallback(stream_id)
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Subscribe & heartbeat
|
# Subscribe & heartbeat
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
|
|||||||
chattype = body.get("chattype", "single")
|
chattype = body.get("chattype", "single")
|
||||||
is_group = chattype == "group"
|
is_group = chattype == "group"
|
||||||
|
|
||||||
|
result = self._build_context(body, is_group)
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
context, wecom_msg = result
|
||||||
|
wecom_msg.req_id = req_id
|
||||||
|
if req_id:
|
||||||
|
context["on_event"] = self._make_stream_callback(req_id)
|
||||||
|
self.produce(context)
|
||||||
|
|
||||||
|
def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
|
||||||
|
"""Parse a wecom message body into a Context, applying file-cache logic.
|
||||||
|
|
||||||
|
Shared by both the websocket (long-connection) and callback (webhook)
|
||||||
|
receive paths. Returns ``(context, wecom_msg)`` when the message should be
|
||||||
|
handed to the agent, or ``None`` when it was consumed (cached image/file,
|
||||||
|
parse failure, etc.).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
wecom_msg = WecomBotMessage(body, is_group=is_group)
|
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
logger.warning(f"[WecomBot] {e}")
|
logger.warning(f"[WecomBot] {e}")
|
||||||
return
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
||||||
return
|
return None
|
||||||
|
|
||||||
wecom_msg.req_id = req_id
|
|
||||||
|
|
||||||
# File cache logic (same pattern as feishu)
|
# File cache logic (same pattern as feishu)
|
||||||
from channel.file_cache import get_file_cache
|
from channel.file_cache import get_file_cache
|
||||||
@@ -314,13 +541,13 @@ class WecomBotChannel(ChatChannel):
|
|||||||
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
||||||
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
||||||
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.FILE:
|
if wecom_msg.ctype == ContextType.FILE:
|
||||||
wecom_msg.prepare()
|
wecom_msg.prepare()
|
||||||
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
||||||
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.TEXT:
|
if wecom_msg.ctype == ContextType.TEXT:
|
||||||
cached_files = file_cache.get(session_id)
|
cached_files = file_cache.get(session_id)
|
||||||
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
|
|||||||
msg=wecom_msg,
|
msg=wecom_msg,
|
||||||
no_need_at=True,
|
no_need_at=True,
|
||||||
)
|
)
|
||||||
if context:
|
if not context:
|
||||||
if req_id:
|
return None
|
||||||
context["on_event"] = self._make_stream_callback(req_id)
|
return context, wecom_msg
|
||||||
self.produce(context)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Event callback
|
# Event callback
|
||||||
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Callback (webhook) send: write the final reply into the stream state
|
||||||
|
# so the next "流式消息刷新" poll returns it with finish=true.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _callback_send(self, reply: Reply, context: Context):
|
||||||
|
msg = context.get("msg")
|
||||||
|
stream_id = getattr(msg, "stream_id", None) if msg else None
|
||||||
|
if not stream_id:
|
||||||
|
stream_id = context.get("wecom_stream_id")
|
||||||
|
if not stream_id:
|
||||||
|
logger.warning("[WecomBot] callback send without stream_id, dropping reply")
|
||||||
|
return
|
||||||
|
|
||||||
|
if reply.type == ReplyType.TEXT:
|
||||||
|
self._callback_finalize_text(stream_id, reply.content)
|
||||||
|
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||||
|
self._callback_finalize_image(stream_id, reply.content)
|
||||||
|
elif reply.type == ReplyType.FILE:
|
||||||
|
# Passive callback replies only support text + image (base64); files
|
||||||
|
# are not supported by the protocol, so append a notice to whatever
|
||||||
|
# text the agent already streamed (do not drop it).
|
||||||
|
text = getattr(reply, "text_content", "") or ""
|
||||||
|
note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)"
|
||||||
|
self._callback_finalize_text(stream_id, note, append=True)
|
||||||
|
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
|
||||||
|
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
|
||||||
|
text = getattr(reply, "text_content", "") or ""
|
||||||
|
note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)"
|
||||||
|
self._callback_finalize_text(stream_id, note, append=True)
|
||||||
|
else:
|
||||||
|
self._callback_finalize_text(stream_id, str(reply.content))
|
||||||
|
|
||||||
|
def _callback_get_or_create_state(self, stream_id: str) -> dict:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if state is None:
|
||||||
|
now = time.time()
|
||||||
|
state = {
|
||||||
|
"committed": "",
|
||||||
|
"current": "",
|
||||||
|
"finished": False,
|
||||||
|
"images": [],
|
||||||
|
"image_urls": [],
|
||||||
|
"image_pending": False,
|
||||||
|
"last_access": now,
|
||||||
|
"created_at": now,
|
||||||
|
"response_url": "",
|
||||||
|
"delivered": False,
|
||||||
|
"url_sent": False,
|
||||||
|
}
|
||||||
|
self._callback_streams[stream_id] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_get_or_create_state(stream_id)
|
||||||
|
accumulated = (state["committed"] + state["current"]).strip()
|
||||||
|
if append and accumulated:
|
||||||
|
state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
|
||||||
|
else:
|
||||||
|
state["committed"] = accumulated if accumulated else (content or "")
|
||||||
|
state["current"] = ""
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
# Don't finish synchronously: chat_channel splits an image-with-caption
|
||||||
|
# reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
|
||||||
|
# text finished the stream immediately, WeCom would close it before the
|
||||||
|
# image arrives. Defer the finish so a trailing image can merge in.
|
||||||
|
self._schedule_text_finish(stream_id)
|
||||||
|
|
||||||
|
def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
|
||||||
|
def _run():
|
||||||
|
time.sleep(delay)
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if not state or state["finished"] or state.get("image_pending"):
|
||||||
|
return # already finished, or an image reply is on its way
|
||||||
|
state["finished"] = True
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
self._schedule_response_url_fallback(stream_id)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()
|
||||||
|
|
||||||
|
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
|
||||||
|
# Mark the image as pending up front (before the slow load/compress) so a
|
||||||
|
# preceding text finalize won't close the stream while we work.
|
||||||
|
with self._callback_lock:
|
||||||
|
self._callback_get_or_create_state(stream_id)["image_pending"] = True
|
||||||
|
b64md5 = self._load_image_base64(img_path_or_url)
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_get_or_create_state(stream_id)
|
||||||
|
accumulated = (state["committed"] + state["current"]).strip()
|
||||||
|
state["current"] = ""
|
||||||
|
if b64md5:
|
||||||
|
state["images"].append(b64md5)
|
||||||
|
state["committed"] = accumulated
|
||||||
|
# Remember the public url (if any) so the response_url fallback
|
||||||
|
# can embed it as markdown when the poll window has closed.
|
||||||
|
if img_path_or_url.startswith(("http://", "https://")):
|
||||||
|
state["image_urls"].append(img_path_or_url)
|
||||||
|
else:
|
||||||
|
state["committed"] = accumulated or "[图片发送失败]"
|
||||||
|
state["finished"] = True
|
||||||
|
state["image_pending"] = False
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
self._schedule_response_url_fallback(stream_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Active reply fallback (response_url): rescue replies that finish after
|
||||||
|
# WeCom stops polling (the passive stream window is ~6 min from the user's
|
||||||
|
# message). A short delay lets an in-flight poll deliver first; only if no
|
||||||
|
# poll picks up the finished answer do we push it actively via response_url.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
|
||||||
|
def _run():
|
||||||
|
time.sleep(delay)
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
if state.get("delivered") or state.get("url_sent"):
|
||||||
|
return # a poll already delivered (or fallback already ran)
|
||||||
|
response_url = state.get("response_url") or ""
|
||||||
|
if not response_url:
|
||||||
|
logger.warning(
|
||||||
|
f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
content = (state["committed"] + state["current"]).strip()
|
||||||
|
image_urls = list(state.get("image_urls") or [])
|
||||||
|
has_images = bool(state.get("images"))
|
||||||
|
state["url_sent"] = True
|
||||||
|
|
||||||
|
self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()
|
||||||
|
|
||||||
|
def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
|
||||||
|
"""Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
|
||||||
|
md = content or ""
|
||||||
|
if image_urls:
|
||||||
|
md += ("\n\n" if md else "") + "\n".join(f"" for u in image_urls)
|
||||||
|
elif has_images:
|
||||||
|
md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)"
|
||||||
|
if not md:
|
||||||
|
md = "(处理完成)"
|
||||||
|
payload = {"msgtype": "markdown", "markdown": {"content": md}}
|
||||||
|
try:
|
||||||
|
resp = requests.post(response_url, json=payload, timeout=15)
|
||||||
|
logger.info(
|
||||||
|
f"[WecomBot] response_url active reply sent for {stream_id}: "
|
||||||
|
f"status={resp.status_code}, body={resp.text[:200]}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")
|
||||||
|
|
||||||
|
def _load_image_base64(self, img_path_or_url: str):
|
||||||
|
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
|
||||||
|
local_path = img_path_or_url
|
||||||
|
if local_path.startswith("file://"):
|
||||||
|
local_path = local_path[7:]
|
||||||
|
|
||||||
|
# Temp files we create here (downloads/conversions/compressions) must be
|
||||||
|
# cleaned up afterwards; the caller's original local file must not be.
|
||||||
|
temp_files = []
|
||||||
|
try:
|
||||||
|
if local_path.startswith(("http://", "https://")):
|
||||||
|
try:
|
||||||
|
resp = requests.get(local_path, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
temp_files.append(tmp_path)
|
||||||
|
local_path = tmp_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
logger.error(f"[WecomBot] Image file not found: {local_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
formatted = self._ensure_image_format(local_path)
|
||||||
|
if not formatted:
|
||||||
|
return None
|
||||||
|
if formatted != local_path:
|
||||||
|
temp_files.append(formatted)
|
||||||
|
local_path = formatted
|
||||||
|
|
||||||
|
# Unlike the long-connection path (which uploads and sends only a tiny
|
||||||
|
# media_id), the callback reply embeds the whole image as base64 inside
|
||||||
|
# an AES-encrypted body that is returned on EVERY poll. Empirically a
|
||||||
|
# ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the
|
||||||
|
# finish packet and poll forever, so cap well below that.
|
||||||
|
callback_max_size = 512 * 1024
|
||||||
|
if os.path.getsize(local_path) > callback_max_size:
|
||||||
|
compressed = self._compress_image(local_path, callback_max_size)
|
||||||
|
if compressed:
|
||||||
|
temp_files.append(compressed)
|
||||||
|
local_path = compressed
|
||||||
|
else:
|
||||||
|
logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(local_path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
for path in temp_files:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Send reply
|
# Send reply
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
|
if self.mode == "webhook":
|
||||||
|
self._callback_send(reply, context)
|
||||||
|
return
|
||||||
|
|
||||||
msg = context.get("msg")
|
msg = context.get("msg")
|
||||||
is_group = context.get("isgroup", False)
|
is_group = context.get("isgroup", False)
|
||||||
receiver = context.get("receiver", "")
|
receiver = context.get("receiver", "")
|
||||||
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error("[WecomBot] Failed to get media_id from finish response")
|
logger.error("[WecomBot] Failed to get media_id from finish response")
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotCallbackController:
|
||||||
|
"""HTTP controller for wecom bot callback (webhook) mode.
|
||||||
|
|
||||||
|
- GET : URL verification (echo the decrypted echostr).
|
||||||
|
- POST : encrypted message / stream-refresh / event callbacks; returns an
|
||||||
|
encrypted passive reply (or "success" for an empty reply).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel() -> "WecomBotChannel":
|
||||||
|
return WecomBotChannel()
|
||||||
|
|
||||||
|
def GET(self):
|
||||||
|
channel = self._channel()
|
||||||
|
params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
|
||||||
|
if not channel._crypt:
|
||||||
|
return "wecom bot callback not ready"
|
||||||
|
ret, echo = channel._crypt.verify_url(
|
||||||
|
params.msg_signature, params.timestamp, params.nonce, params.echostr
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error(f"[WecomBot] URL verify failed: ret={ret}")
|
||||||
|
return "verify fail"
|
||||||
|
if isinstance(echo, bytes):
|
||||||
|
echo = echo.decode("utf-8")
|
||||||
|
return echo
|
||||||
|
|
||||||
|
def POST(self):
|
||||||
|
channel = self._channel()
|
||||||
|
if not channel._crypt:
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
params = web.input(msg_signature="", timestamp="", nonce="")
|
||||||
|
body = web.data()
|
||||||
|
ret, plain = channel._crypt.decrypt_msg(
|
||||||
|
body, params.msg_signature, params.timestamp, params.nonce
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(plain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] callback json parse failed: {e}")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
msgtype = data.get("msgtype", "")
|
||||||
|
# Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
|
||||||
|
# callbacks here (poll completion is logged in the stream-poll handler).
|
||||||
|
if msgtype != "stream":
|
||||||
|
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if msgtype == "stream":
|
||||||
|
reply = channel._callback_handle_stream_poll(data)
|
||||||
|
elif msgtype == "event":
|
||||||
|
event_type = data.get("event", {}).get("eventtype", "")
|
||||||
|
logger.info(f"[WecomBot] callback event: {event_type}")
|
||||||
|
reply = None
|
||||||
|
elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
|
||||||
|
reply = channel._callback_handle_message(data)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
|
||||||
|
reply = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
|
||||||
|
reply = None
|
||||||
|
|
||||||
|
if not reply:
|
||||||
|
# Empty reply package is acceptable.
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
plain_reply = json.dumps(reply, ensure_ascii=False)
|
||||||
|
ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
|
||||||
|
return "success"
|
||||||
|
web.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
return json.dumps(enc, ensure_ascii=False)
|
||||||
|
|||||||
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
WeCom (企业微信) smart-bot callback message encryption/decryption.
|
||||||
|
|
||||||
|
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
|
||||||
|
AI bot callback (webhook) mode. The bot's receive-message callback delivers
|
||||||
|
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
|
||||||
|
same way before being returned in the HTTP response.
|
||||||
|
|
||||||
|
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
# Error codes (mirrors the official ierror.py)
|
||||||
|
WXBizMsgCrypt_OK = 0
|
||||||
|
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||||
|
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||||
|
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||||
|
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||||
|
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||||
|
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||||
|
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||||
|
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||||
|
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||||
|
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||||
|
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||||
|
|
||||||
|
|
||||||
|
class FormatException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_sha1(token, timestamp, nonce, encrypt):
|
||||||
|
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
|
||||||
|
try:
|
||||||
|
if isinstance(encrypt, bytes):
|
||||||
|
encrypt = encrypt.decode("utf-8")
|
||||||
|
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||||
|
sortlist.sort()
|
||||||
|
sha = hashlib.sha1()
|
||||||
|
sha.update("".join(sortlist).encode("utf-8"))
|
||||||
|
return WXBizMsgCrypt_OK, sha.hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] compute signature error: {e}")
|
||||||
|
return WXBizMsgCrypt_ComputeSignature_Error, None
|
||||||
|
|
||||||
|
|
||||||
|
class _PKCS7Encoder:
|
||||||
|
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
|
||||||
|
|
||||||
|
block_size = 32
|
||||||
|
|
||||||
|
def encode(self, text: bytes) -> bytes:
|
||||||
|
text_length = len(text)
|
||||||
|
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||||
|
if amount_to_pad == 0:
|
||||||
|
amount_to_pad = self.block_size
|
||||||
|
pad = bytes([amount_to_pad])
|
||||||
|
return text + pad * amount_to_pad
|
||||||
|
|
||||||
|
def decode(self, decrypted: bytes) -> bytes:
|
||||||
|
pad = decrypted[-1]
|
||||||
|
if pad < 1 or pad > 32:
|
||||||
|
pad = 0
|
||||||
|
return decrypted[:-pad] if pad else decrypted
|
||||||
|
|
||||||
|
|
||||||
|
class _Prpcrypt:
|
||||||
|
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
self.key = key
|
||||||
|
self.mode = AES.MODE_CBC
|
||||||
|
|
||||||
|
def encrypt(self, text: str, receive_id: str):
|
||||||
|
text_bytes = text.encode()
|
||||||
|
# 16-byte random prefix + network-order length + body + receive_id
|
||||||
|
text_bytes = (
|
||||||
|
self._get_random_str()
|
||||||
|
+ struct.pack("I", socket.htonl(len(text_bytes)))
|
||||||
|
+ text_bytes
|
||||||
|
+ receive_id.encode()
|
||||||
|
)
|
||||||
|
text_bytes = _PKCS7Encoder().encode(text_bytes)
|
||||||
|
try:
|
||||||
|
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||||
|
ciphertext = cryptor.encrypt(text_bytes)
|
||||||
|
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] AES encrypt error: {e}")
|
||||||
|
return WXBizMsgCrypt_EncryptAES_Error, None
|
||||||
|
|
||||||
|
def decrypt(self, text, receive_id: str):
|
||||||
|
try:
|
||||||
|
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||||
|
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] AES decrypt error: {e}")
|
||||||
|
return WXBizMsgCrypt_DecryptAES_Error, None
|
||||||
|
try:
|
||||||
|
pad = plain_text[-1]
|
||||||
|
content = plain_text[16:-pad]
|
||||||
|
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
json_content = content[4 : json_len + 4].decode("utf-8")
|
||||||
|
from_receive_id = content[json_len + 4 :].decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
|
||||||
|
return WXBizMsgCrypt_IllegalBuffer, None
|
||||||
|
if from_receive_id != receive_id:
|
||||||
|
logger.error(
|
||||||
|
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
|
||||||
|
)
|
||||||
|
return WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||||
|
return WXBizMsgCrypt_OK, json_content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_random_str() -> bytes:
|
||||||
|
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotCrypt:
|
||||||
|
"""High-level helper for verifying URLs and (de)crypting callback messages."""
|
||||||
|
|
||||||
|
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
|
||||||
|
try:
|
||||||
|
self.key = base64.b64decode(encoding_aes_key + "=")
|
||||||
|
assert len(self.key) == 32
|
||||||
|
except Exception:
|
||||||
|
raise FormatException("[WecomBot] invalid EncodingAESKey")
|
||||||
|
self.token = token
|
||||||
|
self.receive_id = receive_id
|
||||||
|
|
||||||
|
def verify_url(self, msg_signature, timestamp, nonce, echostr):
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
if signature != msg_signature:
|
||||||
|
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
return pc.decrypt(echostr, self.receive_id)
|
||||||
|
|
||||||
|
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
|
||||||
|
"""Encrypt a passive-reply JSON string and return the full response JSON.
|
||||||
|
|
||||||
|
Returns (ret, response_dict). On success ret==0 and response_dict is a
|
||||||
|
dict with encrypt/msgsignature/timestamp/nonce fields.
|
||||||
|
"""
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
encrypt = encrypt.decode("utf-8")
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
return WXBizMsgCrypt_OK, {
|
||||||
|
"encrypt": encrypt,
|
||||||
|
"msgsignature": signature,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"nonce": nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
|
||||||
|
"""Verify signature and decrypt the encrypted callback payload.
|
||||||
|
|
||||||
|
``post_data`` may be the raw request body (bytes/str) containing
|
||||||
|
``{"encrypt": "..."}`` or the already-extracted encrypt string.
|
||||||
|
Returns (ret, plaintext_json_str).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
encrypt = None
|
||||||
|
if isinstance(post_data, (bytes, bytearray)):
|
||||||
|
post_data = post_data.decode("utf-8")
|
||||||
|
if isinstance(post_data, str):
|
||||||
|
try:
|
||||||
|
encrypt = json.loads(post_data).get("encrypt")
|
||||||
|
except Exception:
|
||||||
|
encrypt = post_data
|
||||||
|
elif isinstance(post_data, dict):
|
||||||
|
encrypt = post_data.get("encrypt")
|
||||||
|
if not encrypt:
|
||||||
|
return WXBizMsgCrypt_ParseJson_Error, None
|
||||||
|
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
if signature != msg_signature:
|
||||||
|
logger.error("[WecomBot] callback signature not match")
|
||||||
|
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
return pc.decrypt(encrypt, self.receive_id)
|
||||||
@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
|
|||||||
class WecomBotMessage(ChatMessage):
|
class WecomBotMessage(ChatMessage):
|
||||||
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
||||||
|
|
||||||
def __init__(self, msg_body: dict, is_group: bool = False):
|
def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""):
|
||||||
super().__init__(msg_body)
|
super().__init__(msg_body)
|
||||||
self.msg_id = msg_body.get("msgid")
|
self.msg_id = msg_body.get("msgid")
|
||||||
self.create_time = msg_body.get("create_time")
|
self.create_time = msg_body.get("create_time")
|
||||||
self.is_group = is_group
|
self.is_group = is_group
|
||||||
|
# In callback (webhook) mode the media bodies carry no per-message aeskey;
|
||||||
|
# the download url is encrypted with the bot's EncodingAESKey instead.
|
||||||
|
self._default_aeskey = default_aeskey
|
||||||
|
|
||||||
msg_type = msg_body.get("msgtype")
|
msg_type = msg_body.get("msgtype")
|
||||||
from_userid = msg_body.get("from", {}).get("userid", "")
|
from_userid = msg_body.get("from", {}).get("userid", "")
|
||||||
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.IMAGE
|
self.ctype = ContextType.IMAGE
|
||||||
image_info = msg_body.get("image", {})
|
image_info = msg_body.get("image", {})
|
||||||
image_url = image_info.get("url", "")
|
image_url = image_info.get("url", "")
|
||||||
aeskey = image_info.get("aeskey", "")
|
aeskey = image_info.get("aeskey", "") or self._default_aeskey
|
||||||
tmp_dir = _get_tmp_dir()
|
tmp_dir = _get_tmp_dir()
|
||||||
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
elif item_type == "image":
|
elif item_type == "image":
|
||||||
img_info = item.get("image", {})
|
img_info = item.get("image", {})
|
||||||
img_url = img_info.get("url", "")
|
img_url = img_info.get("url", "")
|
||||||
img_aeskey = img_info.get("aeskey", "")
|
img_aeskey = img_info.get("aeskey", "") or self._default_aeskey
|
||||||
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
||||||
try:
|
try:
|
||||||
img_data = _decrypt_media(img_url, img_aeskey)
|
img_data = _decrypt_media(img_url, img_aeskey)
|
||||||
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
file_info = msg_body.get("file", {})
|
file_info = msg_body.get("file", {})
|
||||||
file_url = file_info.get("url", "")
|
file_url = file_info.get("url", "")
|
||||||
aeskey = file_info.get("aeskey", "")
|
aeskey = file_info.get("aeskey", "") or self._default_aeskey
|
||||||
tmp_dir = _get_tmp_dir()
|
tmp_dir = _get_tmp_dir()
|
||||||
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
||||||
self.content = base_path
|
self.content = base_path
|
||||||
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
video_info = msg_body.get("video", {})
|
video_info = msg_body.get("video", {})
|
||||||
video_url = video_info.get("url", "")
|
video_url = video_info.get("url", "")
|
||||||
aeskey = video_info.get("aeskey", "")
|
aeskey = video_info.get("aeskey", "") or self._default_aeskey
|
||||||
tmp_dir = _get_tmp_dir()
|
tmp_dir = _get_tmp_dir()
|
||||||
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.0.9
|
2.1.1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import click
|
import click
|
||||||
from cli import __version__
|
from cli import __version__
|
||||||
from cli.commands.skill import skill
|
from cli.commands.skill import skill
|
||||||
from cli.commands.process import start, stop, restart, update, status, logs
|
from cli.commands.process import start, stop, restart, self_restart, update, status, logs
|
||||||
from cli.commands.context import context
|
from cli.commands.context import context
|
||||||
from cli.commands.install import install_browser
|
from cli.commands.install import install_browser
|
||||||
from cli.commands.knowledge import knowledge
|
from cli.commands.knowledge import knowledge
|
||||||
@@ -68,6 +68,7 @@ main.add_command(skill)
|
|||||||
main.add_command(start)
|
main.add_command(start)
|
||||||
main.add_command(stop)
|
main.add_command(stop)
|
||||||
main.add_command(restart)
|
main.add_command(restart)
|
||||||
|
main.add_command(self_restart)
|
||||||
main.add_command(update)
|
main.add_command(update)
|
||||||
main.add_command(status)
|
main.add_command(status)
|
||||||
main.add_command(logs)
|
main.add_command(logs)
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||||
"""Install a package, retrying with --user on permission failure."""
|
"""Install a package, preferring prebuilt wheels; retry with --user on perm error."""
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
|
base = [python, "-m", "pip", "install", "--prefer-binary"]
|
||||||
|
ret = subprocess.call(base + [package_spec])
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(" Retrying with --user flag...", "yellow")
|
stream(" Retrying with --user flag...", "yellow")
|
||||||
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
|
ret = subprocess.call(base + ["--user", package_spec])
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +156,22 @@ def run_install_browser(
|
|||||||
|
|
||||||
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
||||||
|
|
||||||
|
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||||
|
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||||
|
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||||
|
ret = subprocess.call(
|
||||||
|
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
stream(
|
||||||
|
" Could not pre-install a prebuilt greenlet wheel.\n"
|
||||||
|
" playwright may try to build greenlet from source, which needs\n"
|
||||||
|
" Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/",
|
||||||
|
"yellow",
|
||||||
|
)
|
||||||
|
|
||||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||||
|
|||||||
@@ -195,6 +195,120 @@ def restart(ctx, no_logs):
|
|||||||
ctx.invoke(start, no_logs=no_logs)
|
ctx.invoke(start, no_logs=no_logs)
|
||||||
|
|
||||||
|
|
||||||
|
# Detached relay that survives the caller's process tree. Run via `python -c`
|
||||||
|
# with start_new_session=True so it keeps going after the agent's bash child
|
||||||
|
# (and the main app it kills) both die. Flow: self-check the new code FIRST
|
||||||
|
# (import app); abort without touching the old process if it fails. Only when
|
||||||
|
# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL
|
||||||
|
# fallback), then start a fresh app.py and write the pid.
|
||||||
|
_RELAY_SCRIPT = r"""
|
||||||
|
import os, sys, time, signal, subprocess
|
||||||
|
|
||||||
|
root, python, app_py, pid_file, log_file = sys.argv[1:6]
|
||||||
|
old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
|
||||||
|
|
||||||
|
|
||||||
|
def alive(pid):
|
||||||
|
if not pid:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
try:
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
f.write("[self-restart] " + msg + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 0) Self-check: make sure the new code actually loads BEFORE killing anything.
|
||||||
|
# `import app` exercises top-level imports / syntax of the entry module. If it
|
||||||
|
# fails, abort and leave the running service untouched — never end up with the
|
||||||
|
# old process killed and the new one unable to start.
|
||||||
|
check = subprocess.run(
|
||||||
|
[python, "-c", "import app"], cwd=root,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
if check.returncode != 0:
|
||||||
|
detail = (check.stdout or b"").decode("utf-8", "replace").strip()
|
||||||
|
log("self-check FAILED, aborting restart; service left running:\n" + detail)
|
||||||
|
sys.exit(1)
|
||||||
|
log("self-check passed")
|
||||||
|
|
||||||
|
# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state).
|
||||||
|
if alive(old_pid):
|
||||||
|
try:
|
||||||
|
os.kill(old_pid, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# 2) Wait up to ~15s for it to go, then force-kill as a backstop.
|
||||||
|
for _ in range(150):
|
||||||
|
if not alive(old_pid):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.kill(old_pid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# 3) Start a fresh instance, detached, logging to the same file.
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[python, app_py], cwd=root,
|
||||||
|
stdout=f, stderr=f, start_new_session=True,
|
||||||
|
)
|
||||||
|
with open(pid_file, "w") as f:
|
||||||
|
f.write(str(proc.pid))
|
||||||
|
log("restarted, new pid=" + str(proc.pid))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@click.command(name="self-restart", hidden=True)
|
||||||
|
def self_restart():
|
||||||
|
"""Restart from inside the running agent (detached; survives parent death).
|
||||||
|
|
||||||
|
Intended to be invoked by the agent itself (e.g. via bash after editing its
|
||||||
|
own code), not by users — so it is hidden from `cow help`. Unlike `restart`,
|
||||||
|
the actual stop+start runs in a detached relay process that outlives the
|
||||||
|
agent's bash child, which would otherwise die together with the main app it
|
||||||
|
kills.
|
||||||
|
"""
|
||||||
|
if _IS_WIN:
|
||||||
|
click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
root = get_project_root()
|
||||||
|
app_py = os.path.join(root, "app.py")
|
||||||
|
if not os.path.exists(app_py):
|
||||||
|
click.echo("Error: app.py not found in project root.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
python = sys.executable
|
||||||
|
pid = _read_pid() or 0
|
||||||
|
|
||||||
|
subprocess.Popen(
|
||||||
|
[
|
||||||
|
python, "-c", _RELAY_SCRIPT,
|
||||||
|
root, python, app_py, _get_pid_file(), _get_log_file(), str(pid),
|
||||||
|
],
|
||||||
|
cwd=root,
|
||||||
|
start_new_session=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
click.echo(click.style(
|
||||||
|
"✓ Restart scheduled. The service will stop and come back in a few seconds.",
|
||||||
|
fg="green",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def update(ctx):
|
def update(ctx):
|
||||||
@@ -275,7 +389,7 @@ def update(ctx):
|
|||||||
def status():
|
def status():
|
||||||
"""Show CowAgent running status."""
|
"""Show CowAgent running status."""
|
||||||
from cli import __version__
|
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
|
# get_cli_language() calls ensure_sys_path(), which adds the project root
|
||||||
# to sys.path. Import `common` only AFTER that, otherwise it fails with
|
# to sys.path. Import `common` only AFTER that, otherwise it fails with
|
||||||
@@ -292,6 +406,11 @@ def status():
|
|||||||
|
|
||||||
click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}"))
|
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()
|
cfg = load_config_json()
|
||||||
if cfg:
|
if cfg:
|
||||||
channel = cfg.get("channel_type", "unknown")
|
channel = cfg.get("channel_type", "unknown")
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ chat_client: LinkAIClient
|
|||||||
|
|
||||||
CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"}
|
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 = {
|
CREDENTIAL_MAP = {
|
||||||
"feishu": ("feishu_app_id", "feishu_app_secret"),
|
"feishu": ("feishu_app_id", "feishu_app_secret"),
|
||||||
"dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"),
|
"dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"),
|
||||||
@@ -43,6 +45,9 @@ CREDENTIAL_MAP = {
|
|||||||
"wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"),
|
"wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||||
"wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"),
|
"wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||||
"wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"),
|
"wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"),
|
||||||
|
"telegram": ("telegram_token", ""),
|
||||||
|
"slack": ("slack_bot_token", "slack_app_token"),
|
||||||
|
"discord": ("discord_token", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -167,6 +172,11 @@ class CloudClient(LinkAIClient):
|
|||||||
if key in available_setting and config.get(key) is not None:
|
if key in available_setting and config.get(key) is not None:
|
||||||
local_config[key] = config.get(key)
|
local_config[key] = config.get(key)
|
||||||
|
|
||||||
|
# Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true")
|
||||||
|
# to a real bool so the evolution config parser reads it correctly.
|
||||||
|
if config.get("self_evolution_enabled") is not None:
|
||||||
|
local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled"))
|
||||||
|
|
||||||
# Voice settings
|
# Voice settings
|
||||||
reply_voice_mode = config.get("reply_voice_mode")
|
reply_voice_mode = config.get("reply_voice_mode")
|
||||||
if reply_voice_mode:
|
if reply_voice_mode:
|
||||||
@@ -336,6 +346,20 @@ class CloudClient(LinkAIClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
|
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# value helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _to_bool(value) -> bool:
|
||||||
|
"""Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1")."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in ("y", "yes", "true", "1", "on")
|
||||||
|
return False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# channel credentials helpers
|
# channel credentials helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -357,7 +381,8 @@ class CloudClient(LinkAIClient):
|
|||||||
local_config[id_key] = app_id
|
local_config[id_key] = app_id
|
||||||
os.environ[id_key.upper()] = str(app_id)
|
os.environ[id_key.upper()] = str(app_id)
|
||||||
changed = True
|
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
|
local_config[secret_key] = app_secret
|
||||||
os.environ[secret_key.upper()] = str(app_secret)
|
os.environ[secret_key.upper()] = str(app_secret)
|
||||||
changed = True
|
changed = True
|
||||||
@@ -372,9 +397,10 @@ class CloudClient(LinkAIClient):
|
|||||||
return
|
return
|
||||||
id_key, secret_key = cred
|
id_key, secret_key = cred
|
||||||
local_config.pop(id_key, None)
|
local_config.pop(id_key, None)
|
||||||
local_config.pop(secret_key, None)
|
|
||||||
os.environ.pop(id_key.upper(), 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
|
# channel_type list helpers
|
||||||
@@ -848,6 +874,10 @@ def _build_config():
|
|||||||
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
||||||
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
||||||
"agent_max_steps": local_conf.get("agent_max_steps"),
|
"agent_max_steps": local_conf.get("agent_max_steps"),
|
||||||
|
# Self-evolution switch reported so the console can reflect state
|
||||||
|
"self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N",
|
||||||
|
"self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"),
|
||||||
|
"self_evolution_min_turns": local_conf.get("self_evolution_min_turns"),
|
||||||
"channelType": local_conf.get("channel_type"),
|
"channelType": local_conf.get("channel_type"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,25 +892,16 @@ def _build_config():
|
|||||||
if plugin_config.get("Godcmd"):
|
if plugin_config.get("Godcmd"):
|
||||||
config["admin_password"] = plugin_config.get("Godcmd").get("password")
|
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", "")
|
current_channel_type = local_conf.get("channel_type", "")
|
||||||
if current_channel_type == "feishu":
|
for ch_type in CloudClient._parse_channel_types({"channel_type": current_channel_type}):
|
||||||
config["app_id"] = local_conf.get("feishu_app_id")
|
cred = CREDENTIAL_MAP.get(ch_type)
|
||||||
config["app_secret"] = local_conf.get("feishu_app_secret")
|
if not cred:
|
||||||
elif current_channel_type == "dingtalk":
|
continue
|
||||||
config["app_id"] = local_conf.get("dingtalk_client_id")
|
id_key, secret_key = cred
|
||||||
config["app_secret"] = local_conf.get("dingtalk_client_secret")
|
config["app_id"] = local_conf.get(id_key)
|
||||||
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
config["app_secret"] = local_conf.get(secret_key) if secret_key else ""
|
||||||
config["app_id"] = local_conf.get("wechatmp_app_id")
|
break
|
||||||
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")
|
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
109
common/const.py
109
common/const.py
@@ -1,4 +1,4 @@
|
|||||||
# 厂商类型
|
# Provider types
|
||||||
OPEN_AI = "openAI"
|
OPEN_AI = "openAI"
|
||||||
OPENAI = "openai"
|
OPENAI = "openai"
|
||||||
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
||||||
@@ -8,48 +8,49 @@ XUNFEI = "xunfei"
|
|||||||
CHATGPTONAZURE = "chatGPTOnAzure"
|
CHATGPTONAZURE = "chatGPTOnAzure"
|
||||||
LINKAI = "linkai"
|
LINKAI = "linkai"
|
||||||
CLAUDEAPI= "claudeAPI"
|
CLAUDEAPI= "claudeAPI"
|
||||||
QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot)
|
QWEN = "qwen" # legacy alias, actually routed to DashscopeBot
|
||||||
QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入
|
QWEN_DASHSCOPE = "dashscope" # Qwen via DashScope
|
||||||
GEMINI = "gemini"
|
GEMINI = "gemini"
|
||||||
ZHIPU_AI = "zhipu"
|
ZHIPU_AI = "zhipu"
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
MiniMax = "minimax"
|
MiniMax = "minimax"
|
||||||
DEEPSEEK = "deepseek"
|
DEEPSEEK = "deepseek"
|
||||||
MIMO = "mimo" # 小米 MiMo 大模型
|
MIMO = "mimo" # Xiaomi MiMo
|
||||||
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
||||||
MODELSCOPE = "modelscope"
|
MODELSCOPE = "modelscope"
|
||||||
|
|
||||||
# 模型列表
|
# Model list
|
||||||
# Claude (Anthropic)
|
# Claude (Anthropic)
|
||||||
CLAUDE3 = "claude-3-opus-20240229"
|
CLAUDE3 = "claude-3-opus-20240229"
|
||||||
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
||||||
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
||||||
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
||||||
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
||||||
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名称,会不断更新指向最新发布的模型
|
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to the newest release
|
||||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
|
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent推荐模型
|
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
||||||
|
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型
|
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model
|
||||||
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent推荐模型
|
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
|
||||||
|
|
||||||
# Gemini (Google)
|
# Gemini (Google)
|
||||||
GEMINI_PRO = "gemini-1.0-pro"
|
GEMINI_PRO = "gemini-1.0-pro"
|
||||||
GEMINI_15_flash = "gemini-1.5-flash"
|
GEMINI_15_flash = "gemini-1.5-flash"
|
||||||
GEMINI_15_PRO = "gemini-1.5-pro"
|
GEMINI_15_PRO = "gemini-1.5-pro"
|
||||||
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
|
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # "-exp" models are experimental and will be phased out
|
||||||
GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型
|
GEMINI_20_FLASH = "gemini-2.0-flash" # stable release
|
||||||
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
||||||
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
||||||
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent推荐模型
|
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent recommended model
|
||||||
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
|
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
|
||||||
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型
|
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent recommended model
|
||||||
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent推荐模型
|
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent recommended model
|
||||||
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent推荐模型
|
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent recommended model
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
GPT35 = "gpt-3.5-turbo"
|
GPT35 = "gpt-3.5-turbo"
|
||||||
@@ -85,10 +86,10 @@ TTS_1 = "tts-1"
|
|||||||
TTS_1_HD = "tts-1-hd"
|
TTS_1_HD = "tts-1-hd"
|
||||||
|
|
||||||
# DeepSeek
|
# DeepSeek
|
||||||
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型
|
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3 chat model
|
||||||
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型
|
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1 model
|
||||||
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用)
|
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - default recommendation (thinking + tool calls)
|
||||||
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用)
|
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - stronger on complex tasks (thinking + tool calls)
|
||||||
|
|
||||||
# Baidu Qianfan / ERNIE
|
# Baidu Qianfan / ERNIE
|
||||||
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
||||||
@@ -100,32 +101,31 @@ ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K"
|
|||||||
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
||||||
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
||||||
|
|
||||||
# Qwen (通义千问 - 阿里云 DashScope)
|
# Qwen (Alibaba Cloud DashScope)
|
||||||
QWEN_TURBO = "qwen-turbo"
|
QWEN_TURBO = "qwen-turbo"
|
||||||
QWEN_PLUS = "qwen-plus"
|
QWEN_PLUS = "qwen-plus"
|
||||||
QWEN_MAX = "qwen-max"
|
QWEN_MAX = "qwen-max"
|
||||||
QWEN_LONG = "qwen-long"
|
QWEN_LONG = "qwen-long"
|
||||||
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
|
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent recommended model
|
||||||
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
|
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
||||||
|
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent recommended model
|
||||||
QWQ_PLUS = "qwq-plus"
|
QWQ_PLUS = "qwq-plus"
|
||||||
|
|
||||||
# MiniMax
|
# MiniMax
|
||||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
|
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
||||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
||||||
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
||||||
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
|
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision)
|
||||||
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
|
|
||||||
MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版
|
|
||||||
MINIMAX_M2 = "MiniMax-M2" # MiniMax M2
|
|
||||||
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# GLM (Zhipu AI)
|
||||||
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
|
GLM_5_2 = "glm-5.2" # GLM-5.2 - Agent recommended model (default)
|
||||||
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
|
GLM_5_1 = "glm-5.1" # GLM-5.1
|
||||||
GLM_5 = "glm-5" # 智谱 GLM-5
|
GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo
|
||||||
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
|
GLM_5 = "glm-5" # GLM-5
|
||||||
|
GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision)
|
||||||
GLM_4 = "glm-4"
|
GLM_4 = "glm-4"
|
||||||
GLM_4_PLUS = "glm-4-plus"
|
GLM_4_PLUS = "glm-4-plus"
|
||||||
GLM_4_flash = "glm-4-flash"
|
GLM_4_flash = "glm-4-flash"
|
||||||
@@ -134,20 +134,22 @@ GLM_4_ALLTOOLS = "glm-4-alltools"
|
|||||||
GLM_4_0520 = "glm-4-0520"
|
GLM_4_0520 = "glm-4-0520"
|
||||||
GLM_4_AIR = "glm-4-air"
|
GLM_4_AIR = "glm-4-air"
|
||||||
GLM_4_AIRX = "glm-4-airx"
|
GLM_4_AIRX = "glm-4-airx"
|
||||||
GLM_4_7 = "glm-4.7" # 智谱 GLM-4.7 - Agent推荐模型
|
GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
|
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
||||||
|
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||||
KIMI_K2 = "kimi-k2"
|
KIMI_K2 = "kimi-k2"
|
||||||
KIMI_K2_5 = "kimi-k2.5"
|
KIMI_K2_5 = "kimi-k2.5"
|
||||||
KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default)
|
KIMI_K2_6 = "kimi-k2.6"
|
||||||
|
|
||||||
# 小米 MiMo
|
# Xiaomi MiMo
|
||||||
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐)
|
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - flagship, long context (default recommendation)
|
||||||
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频)
|
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - multimodal (text/image/audio/video)
|
||||||
MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro
|
MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro
|
||||||
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态
|
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - multimodal
|
||||||
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版
|
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
|
||||||
|
|
||||||
# Doubao (Volcengine Ark)
|
# Doubao (Volcengine Ark)
|
||||||
DOUBAO = "doubao"
|
DOUBAO = "doubao"
|
||||||
@@ -156,11 +158,11 @@ DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
|||||||
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||||
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
|
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
|
||||||
|
|
||||||
# ModelScope(魔搭社区)
|
# ModelScope
|
||||||
QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
||||||
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
||||||
|
|
||||||
# 其他模型
|
# Other models
|
||||||
WEN_XIN = "wenxin"
|
WEN_XIN = "wenxin"
|
||||||
WEN_XIN_4 = "wenxin-4"
|
WEN_XIN_4 = "wenxin-4"
|
||||||
XUNFEI = "xunfei"
|
XUNFEI = "xunfei"
|
||||||
@@ -189,13 +191,13 @@ MODEL_LIST = [
|
|||||||
ERNIE_45_TURBO_VL, ERNIE_45_TURBO_VL_32K,
|
ERNIE_45_TURBO_VL, ERNIE_45_TURBO_VL_32K,
|
||||||
|
|
||||||
# MiniMax
|
# 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
|
# Xiaomi MiMo
|
||||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -213,19 +215,19 @@ MODEL_LIST = [
|
|||||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||||
O1, O1_MINI,
|
O1, O1_MINI,
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# GLM (Zhipu AI)
|
||||||
ZHIPU_AI, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
|
ZHIPU_AI, GLM_5_2, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
|
||||||
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
||||||
|
|
||||||
# Qwen (通义千问)
|
# 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, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||||
KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||||
|
|
||||||
# ModelScope
|
# ModelScope
|
||||||
MODELSCOPE,
|
MODELSCOPE,
|
||||||
@@ -233,11 +235,12 @@ MODEL_LIST = [
|
|||||||
# LinkAI
|
# LinkAI
|
||||||
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
||||||
|
|
||||||
# 其他模型
|
# Other models
|
||||||
WEN_XIN, WEN_XIN_4, XUNFEI,
|
WEN_XIN, WEN_XIN_4, XUNFEI,
|
||||||
]
|
]
|
||||||
|
|
||||||
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
||||||
|
|
||||||
# channel
|
# channel
|
||||||
FEISHU = "feishu"
|
FEISHU = "feishu"
|
||||||
DINGTALK = "dingtalk"
|
DINGTALK = "dingtalk"
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ def detect_language():
|
|||||||
3. Python locale module
|
3. Python locale module
|
||||||
4. default English
|
4. default English
|
||||||
"""
|
"""
|
||||||
|
if os.environ.get("CLOUD_DEPLOYMENT_ID"):
|
||||||
|
return ZH
|
||||||
return (
|
return (
|
||||||
_detect_from_macos()
|
_detect_from_macos()
|
||||||
or _detect_from_env()
|
or _detect_from_env()
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
|
|||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
rgb_image = img.convert("RGB")
|
rgb_image = img.convert("RGB")
|
||||||
quality = 95
|
quality = 95
|
||||||
|
min_quality = 10
|
||||||
while True:
|
while True:
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
rgb_image.save(out_buf, "JPEG", quality=quality)
|
rgb_image.save(out_buf, "JPEG", quality=quality)
|
||||||
if fsize(out_buf) <= max_size:
|
if fsize(out_buf) <= max_size or quality <= min_quality:
|
||||||
|
# Stop at min_quality: further decrements would pass an invalid
|
||||||
|
# quality (<1) to PIL and the loop would otherwise never terminate
|
||||||
|
# for images that cannot be compressed below max_size.
|
||||||
return out_buf
|
return out_buf
|
||||||
quality -= 5
|
quality -= 5
|
||||||
|
|
||||||
|
|||||||
@@ -40,5 +40,6 @@
|
|||||||
"agent_max_steps": 20,
|
"agent_max_steps": 20,
|
||||||
"enable_thinking": false,
|
"enable_thinking": false,
|
||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
"knowledge": true
|
"knowledge": true,
|
||||||
|
"self_evolution_enabled": true
|
||||||
}
|
}
|
||||||
|
|||||||
51
config.py
51
config.py
@@ -24,8 +24,11 @@ available_setting = {
|
|||||||
"open_ai_api_base": "https://api.openai.com/v1",
|
"open_ai_api_base": "https://api.openai.com/v1",
|
||||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
||||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
|
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
|
||||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom")
|
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
|
||||||
|
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
|
||||||
|
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
||||||
|
"custom_providers": [],
|
||||||
"proxy": "", # proxy used by openai
|
"proxy": "", # proxy used by openai
|
||||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
||||||
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
||||||
@@ -180,6 +183,11 @@ available_setting = {
|
|||||||
# WeCom smart bot config (long connection mode)
|
# WeCom smart bot config (long connection mode)
|
||||||
"wecom_bot_id": "", # WeCom smart bot BotID
|
"wecom_bot_id": "", # WeCom smart bot BotID
|
||||||
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
||||||
|
# WeCom smart bot transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
|
||||||
|
"wecom_bot_mode": "websocket",
|
||||||
|
"wecom_bot_token": "", # webhook mode: Token configured on the bot's receive-message URL
|
||||||
|
"wecom_bot_encoding_aes_key": "", # webhook mode: EncodingAESKey configured on the bot's receive-message URL
|
||||||
|
"wecom_bot_port": 9892, # webhook mode: local HTTP server port for the receive-message URL
|
||||||
# Telegram config
|
# Telegram config
|
||||||
"telegram_token": "", # Bot token from @BotFather
|
"telegram_token": "", # Bot token from @BotFather
|
||||||
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)
|
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)
|
||||||
@@ -251,6 +259,10 @@ available_setting = {
|
|||||||
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
|
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
|
||||||
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
||||||
"knowledge": True, # whether to enable the knowledge base feature
|
"knowledge": True, # whether to enable the knowledge base feature
|
||||||
|
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
||||||
|
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||||
|
"self_evolution_idle_minutes": 10, # 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
|
"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)
|
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||||
}
|
}
|
||||||
@@ -317,24 +329,37 @@ class Config(dict):
|
|||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_value(val):
|
||||||
|
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
|
||||||
|
if not isinstance(val, str) or len(val) <= 8:
|
||||||
|
return val
|
||||||
|
return val[0:3] + "*" * 5 + val[-3:]
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_sensitive_recursive(obj):
|
||||||
|
"""Recursively mask values whose keys contain 'key' or 'secret'."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
masked = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
if ("key" in k or "secret" in k) and isinstance(v, str):
|
||||||
|
masked[k] = _mask_value(v)
|
||||||
|
else:
|
||||||
|
masked[k] = _mask_sensitive_recursive(v)
|
||||||
|
return masked
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [_mask_sensitive_recursive(item) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def drag_sensitive(config):
|
def drag_sensitive(config):
|
||||||
try:
|
try:
|
||||||
if isinstance(config, str):
|
if isinstance(config, str):
|
||||||
conf_dict: dict = json.loads(config)
|
conf_dict: dict = json.loads(config)
|
||||||
conf_dict_copy = copy.deepcopy(conf_dict)
|
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
|
||||||
for key in conf_dict_copy:
|
|
||||||
if "key" in key or "secret" in key:
|
|
||||||
if isinstance(conf_dict_copy[key], str):
|
|
||||||
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
|
|
||||||
return json.dumps(conf_dict_copy, indent=4)
|
return json.dumps(conf_dict_copy, indent=4)
|
||||||
|
|
||||||
elif isinstance(config, dict):
|
elif isinstance(config, dict):
|
||||||
config_copy = copy.deepcopy(config)
|
return _mask_sensitive_recursive(config)
|
||||||
for key in config:
|
|
||||||
if "key" in key or "secret" in key:
|
|
||||||
if isinstance(config_copy[key], str):
|
|
||||||
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
|
|
||||||
return config_copy
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ services:
|
|||||||
DINGTALK_CLIENT_SECRET: ''
|
DINGTALK_CLIENT_SECRET: ''
|
||||||
WECOM_BOT_ID: ''
|
WECOM_BOT_ID: ''
|
||||||
WECOM_BOT_SECRET: ''
|
WECOM_BOT_SECRET: ''
|
||||||
# 如需通过宿主机访问 Web 控制台,改为 '0.0.0.0' 并设置 WEB_PASSWORD
|
# To access the web console from the host, set this to '0.0.0.0' and set WEB_PASSWORD
|
||||||
WEB_HOST: '127.0.0.1'
|
WEB_HOST: '127.0.0.1'
|
||||||
WEB_PASSWORD: ''
|
WEB_PASSWORD: ''
|
||||||
AGENT: 'True'
|
AGENT: 'True'
|
||||||
AGENT_MAX_CONTEXT_TOKENS: 50000
|
AGENT_MAX_CONTEXT_TOKENS: 50000
|
||||||
AGENT_MAX_CONTEXT_TURNS: 20
|
AGENT_MAX_CONTEXT_TURNS: 20
|
||||||
AGENT_MAX_STEPS: 20
|
AGENT_MAX_STEPS: 20
|
||||||
|
SELF_EVOLUTION_ENABLED: 'True'
|
||||||
volumes:
|
volumes:
|
||||||
- ./cow:/home/agent/cow
|
- ./cow:/home/agent/cow
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
"label": "官网",
|
"label": "官网",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "博客",
|
||||||
|
"href": "https://cowagent.ai/zh/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -51,6 +55,10 @@
|
|||||||
"label": "Website",
|
"label": "Website",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Blog",
|
||||||
|
"href": "https://cowagent.ai/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -179,7 +187,8 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"memory/index",
|
"memory/index",
|
||||||
"memory/context",
|
"memory/context",
|
||||||
"memory/deep-dream"
|
"memory/deep-dream",
|
||||||
|
"memory/self-evolution"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -240,6 +249,8 @@
|
|||||||
"group": "Release Notes",
|
"group": "Release Notes",
|
||||||
"pages": [
|
"pages": [
|
||||||
"releases/overview",
|
"releases/overview",
|
||||||
|
"releases/v2.1.2",
|
||||||
|
"releases/v2.1.1",
|
||||||
"releases/v2.1.0",
|
"releases/v2.1.0",
|
||||||
"releases/v2.0.9",
|
"releases/v2.0.9",
|
||||||
"releases/v2.0.8",
|
"releases/v2.0.8",
|
||||||
@@ -265,6 +276,10 @@
|
|||||||
"label": "官网",
|
"label": "官网",
|
||||||
"href": "https://cowagent.ai/?lang=zh"
|
"href": "https://cowagent.ai/?lang=zh"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "博客",
|
||||||
|
"href": "https://cowagent.ai/zh/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -393,7 +408,8 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"zh/memory/index",
|
"zh/memory/index",
|
||||||
"zh/memory/context",
|
"zh/memory/context",
|
||||||
"zh/memory/deep-dream"
|
"zh/memory/deep-dream",
|
||||||
|
"zh/memory/self-evolution"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -454,6 +470,8 @@
|
|||||||
"group": "发布记录",
|
"group": "发布记录",
|
||||||
"pages": [
|
"pages": [
|
||||||
"zh/releases/overview",
|
"zh/releases/overview",
|
||||||
|
"zh/releases/v2.1.2",
|
||||||
|
"zh/releases/v2.1.1",
|
||||||
"zh/releases/v2.1.0",
|
"zh/releases/v2.1.0",
|
||||||
"zh/releases/v2.0.9",
|
"zh/releases/v2.0.9",
|
||||||
"zh/releases/v2.0.8",
|
"zh/releases/v2.0.8",
|
||||||
@@ -479,6 +497,10 @@
|
|||||||
"label": "ウェブサイト",
|
"label": "ウェブサイト",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "ブログ",
|
||||||
|
"href": "https://cowagent.ai/blog/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/zhayujie/CowAgent"
|
"href": "https://github.com/zhayujie/CowAgent"
|
||||||
@@ -607,7 +629,8 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"ja/memory/index",
|
"ja/memory/index",
|
||||||
"ja/memory/context",
|
"ja/memory/context",
|
||||||
"ja/memory/deep-dream"
|
"ja/memory/deep-dream",
|
||||||
|
"ja/memory/self-evolution"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -668,6 +691,8 @@
|
|||||||
"group": "リリースノート",
|
"group": "リリースノート",
|
||||||
"pages": [
|
"pages": [
|
||||||
"ja/releases/overview",
|
"ja/releases/overview",
|
||||||
|
"ja/releases/v2.1.2",
|
||||||
|
"ja/releases/v2.1.1",
|
||||||
"ja/releases/v2.1.0",
|
"ja/releases/v2.1.0",
|
||||||
"ja/releases/v2.0.9",
|
"ja/releases/v2.0.9",
|
||||||
"ja/releases/v2.0.8",
|
"ja/releases/v2.0.8",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: One-click install and manage CowAgent with scripts
|
|||||||
|
|
||||||
The project provides scripts for one-click install, configuration, startup, and management. Script-based deployment is recommended for quick setup.
|
The project provides scripts for one-click install, configuration, startup, and management. Script-based deployment is recommended for quick setup.
|
||||||
|
|
||||||
Supports Linux, macOS, and Windows. Requires Python 3.7-3.12 (3.9 recommended).
|
Supports Linux, macOS, and Windows. Requires Python 3.7-3.13 (3.9 recommended).
|
||||||
|
|
||||||
## Install Command
|
## Install Command
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistan
|
|||||||
|
|
||||||
CowAgent's architecture consists of the following core modules:
|
CowAgent's architecture consists of the following core modules:
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" />
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" />
|
||||||
|
|
||||||
| Module | Description |
|
| Module | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Plan** | Understands user intent, decomposes complex tasks into multi-step plans, and iteratively invokes tools until the goal is achieved |
|
| **Plan** | Understands user intent, decomposes complex tasks into multi-step plans, and iteratively invokes tools until the goal is achieved |
|
||||||
| **Memory** | Automatically persists important information as core memory and daily memory, with hybrid keyword and vector retrieval for cross-session context continuity |
|
| **Memory** | Automatically persists important information as core memory and daily memory, with hybrid keyword and vector retrieval for cross-session context continuity |
|
||||||
| **Knowledge** | Organizes structured knowledge by topic. The Agent autonomously distills valuable information into Markdown pages, maintaining indexes and cross-references to build a growing knowledge network |
|
| **Knowledge** | Organizes structured knowledge by topic. The Agent autonomously distills valuable information into Markdown pages, maintaining indexes and cross-references to build a growing knowledge network |
|
||||||
|
| **Evolution** | Reviews a conversation in an isolated environment after it goes idle, improving skills, following up on unfinished tasks, and backfilling memory and knowledge so the Agent keeps growing through everyday use |
|
||||||
| **Tools** | Core capability for Agent to access OS resources. 10+ built-in tools including file read/write, terminal, browser, scheduler, memory search, web search, and more |
|
| **Tools** | Core capability for Agent to access OS resources. 10+ built-in tools including file read/write, terminal, browser, scheduler, memory search, web search, and more |
|
||||||
| **Skills** | Loads and manages Skills. Supports one-click installation from Skill Hub, GitHub, and more, or custom skill creation through conversation |
|
| **Skills** | Loads and manages Skills. Supports one-click installation from Skill Hub, GitHub, and more, or custom skill creation through conversation |
|
||||||
| **Models** | Model layer with unified access to OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, and other mainstream LLMs |
|
| **Models** | Model layer with unified access to OpenAI, Claude, Gemini, DeepSeek, MiniMax, GLM, Qwen, and other mainstream LLMs |
|
||||||
@@ -84,4 +85,5 @@ Configure Agent mode parameters in `config.json`:
|
|||||||
| `agent_max_steps` | Max decision steps per task | `20` |
|
| `agent_max_steps` | Max decision steps per task | `20` |
|
||||||
| `enable_thinking` | Enable deep-thinking mode | `false` |
|
| `enable_thinking` | Enable deep-thinking mode | `false` |
|
||||||
| `knowledge` | Enable personal knowledge base | `true` |
|
| `knowledge` | Enable personal knowledge base | `true` |
|
||||||
|
| `self_evolution_enabled` | Enable Self-Evolution (on by default for new installs) | `false` |
|
||||||
| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` |
|
| `cow_lang` | Language for the UI, command text and system prompts; `auto` to detect, or set `zh` / `en` | `auto` |
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ In subsequent long-term conversations, the Agent intelligently stores or retriev
|
|||||||
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
|
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
See [Long-term Memory](/memory) and [Deep Dream](/memory/deep-dream) for details.
|
Building on this, **Self-Evolution** lets the Agent keep growing through everyday use: after a conversation goes idle, it reviews it automatically to improve skills, follow up on unfinished tasks, and backfill memory and knowledge. It speaks up only when it actually made a change, and every change can be undone. Enabled by default for new installs.
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-demo.png" width="800" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
See [Long-term Memory](/memory), [Deep Dream](/memory/deep-dream), and [Self-Evolution](/memory/self-evolution) for details.
|
||||||
|
|
||||||
## 2. Personal Knowledge Base
|
## 2. Personal Knowledge Base
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
|||||||
<Card title="Personal Knowledge Base" icon="book" href="/knowledge/index">
|
<Card title="Personal Knowledge Base" icon="book" href="/knowledge/index">
|
||||||
Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing.
|
Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing.
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card title="Self-Evolution" icon="seedling" href="/memory/self-evolution">
|
||||||
|
Reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use.
|
||||||
|
</Card>
|
||||||
<Card title="Skills System" icon="puzzle-piece" href="/skills/index">
|
<Card title="Skills System" icon="puzzle-piece" href="/skills/index">
|
||||||
A complete skill creation and execution engine. Install from Skill Hub or generate custom skills via natural-language conversation.
|
A complete skill creation and execution engine. Install from Skill Hub or generate custom skills via natural-language conversation.
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/github/license/zhayujie/CowAgent" alt="License: MIT"></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" alt="Stars"></a> <br/>
|
<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>] | [日本語]
|
[<a href="../../README.md">English</a>] | [<a href="../zh/README.md">中文</a>] | [日本語]
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**CowAgent** は、自律的にタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、パーソナルナレッジベースと長期記憶でユーザーとともに成長するオープンソースのスーパー AI アシスタントです。エンドツーエンドの Agent Harness のリファレンス実装の一つでもあります。
|
**CowAgent** は、自律的にタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、パーソナルナレッジベースと長期記憶を構築し、自己進化によってユーザーとともに成長するオープンソースのスーパー AI アシスタントです。エンドツーエンドの Agent Harness のリファレンス実装の一つでもあります。
|
||||||
|
|
||||||
CowAgent は軽量でデプロイしやすく、拡張性に優れています。主要な LLM プロバイダーをそのまま組み込み、Web や主要な IM プラットフォーム上で動作。個人 PC やサーバー上で 24 時間 365 日稼働できます。
|
CowAgent は軽量でデプロイしやすく、拡張性に優れています。主要な LLM プロバイダーをそのまま組み込み、Web や主要な IM プラットフォーム上で動作。個人 PC やサーバー上で 24 時間 365 日稼働できます。
|
||||||
|
|
||||||
@@ -28,6 +36,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
|
|||||||
| [タスク計画](https://docs.cowagent.ai/ja/intro/architecture) | 複雑なタスクを分解し、目標達成までツールを繰り返し呼び出して段階的に実行 |
|
| [タスク計画](https://docs.cowagent.ai/ja/intro/architecture) | 複雑なタスクを分解し、目標達成までツールを繰り返し呼び出して段階的に実行 |
|
||||||
| [長期記憶](https://docs.cowagent.ai/ja/memory/index) | 三層構造(コンテキスト → デイリー → コア)、Deep Dream による自動蒸留、キーワードとベクトルのハイブリッド検索 |
|
| [長期記憶](https://docs.cowagent.ai/ja/memory/index) | 三層構造(コンテキスト → デイリー → コア)、Deep Dream による自動蒸留、キーワードとベクトルのハイブリッド検索 |
|
||||||
| [ナレッジベース](https://docs.cowagent.ai/ja/knowledge/index) | 構造化された知識を Markdown Wiki として自動整理し、進化し続けるナレッジグラフを可視化ブラウジング |
|
| [ナレッジベース](https://docs.cowagent.ai/ja/knowledge/index) | 構造化された知識を Markdown Wiki として自動整理し、進化し続けるナレッジグラフを可視化ブラウジング |
|
||||||
|
| [自己進化](https://docs.cowagent.ai/ja/memory/self-evolution) | 会話を自動でレビューして Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完。日々の利用を通じて成長 |
|
||||||
| [Skill](https://docs.cowagent.ai/ja/skills/index) | [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub からワンクリックでインストール;対話によるカスタム Skill 作成にも対応 |
|
| [Skill](https://docs.cowagent.ai/ja/skills/index) | [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub からワンクリックでインストール;対話によるカスタム Skill 作成にも対応 |
|
||||||
| [ツール](https://docs.cowagent.ai/ja/tools/index) | ファイル I/O、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10+ の組み込みツール — MCP プロトコルに完全対応 |
|
| [ツール](https://docs.cowagent.ai/ja/tools/index) | ファイル I/O、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10+ の組み込みツール — MCP プロトコルに完全対応 |
|
||||||
| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram、Slack を同時にサポート |
|
| [チャネル](https://docs.cowagent.ai/ja/channels/index) | 一つの Agent で Web、WeChat、Feishu、DingTalk、WeCom、QQ、公式アカウント、Telegram、Slack を同時にサポート |
|
||||||
@@ -39,7 +48,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
|
|||||||
|
|
||||||
## 🏗️ アーキテクチャ
|
## 🏗️ アーキテクチャ
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||||
|
|
||||||
CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。
|
CowAgent は完全な **Agent Harness** です:メッセージは各種**チャネル**から流入し、**Agent Core** が記憶・知識・利用可能なツール/Skill を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。
|
||||||
|
|
||||||
@@ -94,15 +103,15 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [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 | ✅ | ✅ | | | | |
|
| [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 | ✅ | ✅ | | | ✅ | |
|
| [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+ モデルに接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/ja/models/linkai) | 1 つの Key で 100+ モデルに接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -190,6 +199,10 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
## 🏷 更新履歴
|
## 🏷 更新履歴
|
||||||
|
|
||||||
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
||||||
|
|
||||||
|
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル(MiniMax-M3、qwen3.7-plus)、Python 3.13 対応。
|
||||||
|
|
||||||
> **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.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.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)、デプロイのセキュリティ強化。
|
||||||
@@ -238,9 +251,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 の提出も歓迎します。
|
||||||
|
|
||||||
## 🌟 コントリビューター
|
## 🌟 コントリビューター
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: スクリプトによるCowAgentのワンクリックインスト
|
|||||||
|
|
||||||
本プロジェクトでは、ワンクリックでのインストール、設定、起動、管理を行うスクリプトを提供しています。素早くセットアップするには、スクリプトによるデプロイを推奨します。
|
本プロジェクトでは、ワンクリックでのインストール、設定、起動、管理を行うスクリプトを提供しています。素早くセットアップするには、スクリプトによるデプロイを推奨します。
|
||||||
|
|
||||||
Linux、macOS、Windowsに対応しています。Python 3.7〜3.12が必要です(3.9を推奨)。
|
Linux、macOS、Windowsに対応しています。Python 3.7〜3.13が必要です(3.9を推奨)。
|
||||||
|
|
||||||
## インストールコマンド
|
## インストールコマンド
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ CowAgent 2.0 は、シンプルなチャットボットから、自律的な思
|
|||||||
|
|
||||||
CowAgent のアーキテクチャは以下のコアモジュールで構成されています:
|
CowAgent のアーキテクチャは以下のコアモジュールで構成されています:
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" />
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" />
|
||||||
|
|
||||||
| モジュール | 説明 |
|
| モジュール | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Plan** | ユーザーの意図を理解し、複雑なタスクをマルチステップの計画に分解、目標達成までツールを反復的に呼び出す |
|
| **Plan** | ユーザーの意図を理解し、複雑なタスクをマルチステップの計画に分解、目標達成までツールを反復的に呼び出す |
|
||||||
| **Memory** | 重要な情報をコアメモリとデイリーメモリとして自動永続化し、キーワードとベクトルのハイブリッド検索でセッション間の連続性を実現 |
|
| **Memory** | 重要な情報をコアメモリとデイリーメモリとして自動永続化し、キーワードとベクトルのハイブリッド検索でセッション間の連続性を実現 |
|
||||||
| **Knowledge** | トピック別に構造化された知識を整理。Agent が価値ある情報を Markdown ページとして自律的に整理し、インデックスと相互参照で成長するナレッジネットワークを構築 |
|
| **Knowledge** | トピック別に構造化された知識を整理。Agent が価値ある情報を Markdown ページとして自律的に整理し、インデックスと相互参照で成長するナレッジネットワークを構築 |
|
||||||
|
| **Evolution** | 会話がアイドルになった後、隔離環境で自動レビューを実行。Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完して、日々の利用を通じて Agent を成長させる |
|
||||||
| **Tools** | Agent が OS リソースにアクセスするための中核能力。ファイル読み書き、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10 以上の組み込みツール |
|
| **Tools** | Agent が OS リソースにアクセスするための中核能力。ファイル読み書き、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10 以上の組み込みツール |
|
||||||
| **Skills** | Skill の読み込み・管理。Skill Hub や GitHub からのワンクリックインストール、または会話を通じたカスタム Skill の作成をサポート |
|
| **Skills** | Skill の読み込み・管理。Skill Hub や GitHub からのワンクリックインストール、または会話を通じたカスタム Skill の作成をサポート |
|
||||||
| **Models** | モデル層。OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen など主要 LLM への統一アクセスを提供 |
|
| **Models** | モデル層。OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen など主要 LLM への統一アクセスを提供 |
|
||||||
@@ -82,4 +83,5 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス
|
|||||||
| `agent_max_context_turns` | 最大コンテキストターン数 | `30` |
|
| `agent_max_context_turns` | 最大コンテキストターン数 | `30` |
|
||||||
| `agent_max_steps` | タスクあたりの最大判断ステップ数 | `15` |
|
| `agent_max_steps` | タスクあたりの最大判断ステップ数 | `15` |
|
||||||
| `knowledge` | パーソナルナレッジベースの有効化 | `true` |
|
| `knowledge` | パーソナルナレッジベースの有効化 | `true` |
|
||||||
|
| `self_evolution_enabled` | 自己進化の有効化(新規インストールではデフォルト有効) | `false` |
|
||||||
| `cow_lang` | UI・コマンド文言・システムプロンプトなどの言語。`auto` で自動検出、`zh` / `en` も指定可 | `auto` |
|
| `cow_lang` | UI・コマンド文言・システムプロンプトなどの言語。`auto` で自動検出、`zh` / `en` も指定可 | `auto` |
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ description: CowAgent の長期記憶、タスク計画、Skill システム、C
|
|||||||
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
|
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
詳細は [長期記憶](/ja/memory) と [Deep Dream](/ja/memory/deep-dream) を参照してください。
|
これに加えて、**自己進化(Self-Evolution)** により Agent は日々の利用を通じて成長し続けます。会話がアイドルになった後に自動でレビューを行い、Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完します。実際に変更があったときのみ簡潔に通知し、変更はいつでも取り消せます。新規インストールではデフォルトで有効です。
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-demo.png" width="800" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
詳細は [長期記憶](/ja/memory)、[Deep Dream](/ja/memory/deep-dream)、[自己進化](/ja/memory/self-evolution) を参照してください。
|
||||||
|
|
||||||
## 2. パーソナルナレッジベース
|
## 2. パーソナルナレッジベース
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
|
|||||||
<Card title="ナレッジベース" icon="book" href="/ja/knowledge">
|
<Card title="ナレッジベース" icon="book" href="/ja/knowledge">
|
||||||
構造化された知識を自動整理し、ナレッジグラフの可視化をサポート。相互参照により継続的に成長するナレッジネットワークを構築します。
|
構造化された知識を自動整理し、ナレッジグラフの可視化をサポート。相互参照により継続的に成長するナレッジネットワークを構築します。
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card title="自己進化" icon="seedling" href="/ja/memory/self-evolution">
|
||||||
|
会話の終了後に自動でレビューし、Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完。日々の利用を通じて Agent が成長し続けます。
|
||||||
|
</Card>
|
||||||
<Card title="Skill システム" icon="puzzle-piece" href="/ja/skills/index">
|
<Card title="Skill システム" icon="puzzle-piece" href="/ja/skills/index">
|
||||||
Skill の作成・実行エンジンを実装し、組み込み Skill を搭載。自然言語の会話を通じてカスタム Skill の開発もサポートしています。
|
Skill の作成・実行エンジンを実装し、組み込み Skill を搭載。自然言語の会話を通じてカスタム Skill の開発もサポートしています。
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
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 つの条件を同時に満たす必要があります:
|
||||||
|
|
||||||
|
- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 10 分)以上が経過している
|
||||||
|
- **振り返るだけの内容がある**:前回の進化から十分なターン数が蓄積されている、またはコンテキストが容量の上限に近づいている
|
||||||
|
|
||||||
|
両方の条件を満たしたときにのみ振り返りが始まります。これにより、振り返る価値のある内容を確保しつつ、会話の途中で邪魔をしないようにしています。
|
||||||
|
|
||||||
|
### 関連設定
|
||||||
|
|
||||||
|
自律進化は Web コンソールの「設定 → Agent 設定」(「ディープシンキング」の下)にあるスイッチで切り替えられるほか、設定ファイルで調整することもできます:
|
||||||
|
|
||||||
|
| パラメータ | 説明 | デフォルト値 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `self_evolution_enabled` | 自律進化を有効にするかどうか(新規インストールはデフォルトで有効) | `false` |
|
||||||
|
| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `10` |
|
||||||
|
| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `6` |
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
Web コンソールでは有効・無効のスイッチのみを提供しています。アイドル時間やターン数のしきい値を変更したい場合は、設定ファイルを編集してください。変更は即時に反映され、再起動は不要です。
|
||||||
|
</Tip>
|
||||||
|
|
||||||
|
### 進化の記録
|
||||||
|
|
||||||
|
各振り返りは日付ごとに `memory/evolution/YYYY-MM-DD.md` に記録され、Web コンソールの「メモリ管理 → 自律進化」タブで確認できます。このタブには自律進化の記録と夢日記の両方がまとめられており、Agent の成長の軌跡を一箇所で振り返ることができます。
|
||||||
|
|
||||||
|
### 元に戻す方法
|
||||||
|
|
||||||
|
ある振り返りの変更に納得できない場合は、会話の中で Agent に「直前の変更を取り消して」と伝えるだけで、振り返り前のバックアップから該当ファイルを復元します。各振り返りはそれぞれ独立したバックアップを持つため、互いに干渉することはありません。
|
||||||
|
|
||||||
|
## 設計
|
||||||
|
|
||||||
|
自律進化はシステムの既存の機能を再利用しており、軽量に保たれています:
|
||||||
|
|
||||||
|
- **隔離実行**:各振り返りは独立した短命のタスクとして実行されます。メインの会話と同じモデルを使いますが、ツールは制限されています(コンテキストの読み取りと、記憶およびスキルファイルの編集のみ可能)。メインの会話のコンテキストを汚さず、その動作にも影響しません。
|
||||||
|
- **バックアップによる取り消し**:振り返り前に該当ファイルのスナップショットを取り、取り消し時にそのスナップショットから復元するため、すべての変更が追跡可能で元に戻せます。
|
||||||
|
- **変更検知**:振り返り後にファイルのスナップショットを比較して実際に変更があったかを確認し、それをもとに通知するかどうかを判断します。これにより「何もしなければ通知しない」ことを仕組みとして保証します。
|
||||||
|
|
||||||
|
### 抑制と安全性
|
||||||
|
|
||||||
|
自律進化は、必要なときに動き、それ以外のときは邪魔をしないように設計されています:
|
||||||
|
|
||||||
|
| 仕組み | 説明 |
|
||||||
|
| --- | --- |
|
||||||
|
| **何もしなければ通知しない** | 振り返りで実際の変更がなければ、静かなままで何も送りません |
|
||||||
|
| **アイドル時のみトリガー** | 会話がアイドル状態になったときだけ実行し、進行中の会話を妨げません |
|
||||||
|
| **変更を元に戻せる** | 振り返りごとに事前にバックアップを取るため、納得できない結果は取り消せます |
|
||||||
|
| **組み込みスキルの保護** | 製品に付属する組み込みスキルは保護され、変更されません |
|
||||||
|
| **ワークスペースに限定** | すべての読み書きはワークスペース内に限定され、他のシステムファイルには触れません |
|
||||||
|
| **バックグラウンド実行** | 振り返りはバックグラウンドで実行され、通常の返信を妨げません |
|
||||||
@@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-8",
|
"model": "claude-fable-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
| `model` | `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
||||||
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
| モデル | 用途 |
|
| モデル | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-opus-4-8` | デフォルト推奨。最新フラッグシップ。複雑な推論や長いタスクチェーンに最適 |
|
| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め |
|
||||||
| `claude-opus-4-7` | 前世代の Opus フラッグシップ |
|
| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い |
|
||||||
|
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ description: Coding Planモデルの設定
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "MiniMax-M3",
|
||||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||||
"open_ai_api_key": "YOUR_API_KEY"
|
"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_base` | 中国: `https://api.minimaxi.com/v1`、グローバル: `https://api.minimax.io/v1` |
|
||||||
| `open_ai_api_key` | Coding Plan専用キー(従量課金とは共有不可) |
|
| `open_ai_api_key` | Coding Plan専用キー(従量課金とは共有不可) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: カスタム
|
title: カスタム
|
||||||
description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け
|
description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば:
|
OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば:
|
||||||
|
|
||||||
- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す
|
- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す
|
||||||
- **ローカルモデル**:Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル
|
- **ローカルモデル**:Ollama、vLLM などのツールでローカルにデプロイしたモデル
|
||||||
- **プライベートデプロイ**:企業内部にデプロイしたモデルサービス
|
- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス
|
||||||
|
|
||||||
<Note>
|
## Web コンソールでの設定
|
||||||
`openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## テキスト対話
|
推奨方法です。Web コンソールの「モデル」ページで「プロバイダーを追加」をクリックし、「カスタム」を選択して、名称・API Base・API Key を入力します。複数のカスタムプロバイダーを追加でき、追加後は「メインモデル」でプロバイダーとモデルを選択すると有効になります。
|
||||||
|
|
||||||
### サードパーティ API プロキシ
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
|
||||||
|
|
||||||
```json
|
主なローカルデプロイツールのデフォルトアドレス:
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "",
|
|
||||||
"custom_api_key": "YOUR_API_KEY",
|
|
||||||
"custom_api_base": "https://{your-proxy.com}/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `bot_type` | `custom` に設定する必要があります |
|
|
||||||
| `model` | モデル名。プロキシサービスがサポートする任意のモデル名を指定 |
|
|
||||||
| `custom_api_key` | API キー。プロキシサービスから提供されます |
|
|
||||||
| `custom_api_base` | API アドレス。プロキシサービスから提供され、OpenAI プロトコル互換である必要があります |
|
|
||||||
|
|
||||||
### ローカルモデル
|
|
||||||
|
|
||||||
ローカルモデルは通常 API Key が不要で、API Base のみ設定します:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "qwen3.5:27b",
|
|
||||||
"custom_api_base": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
一般的なローカルデプロイツールとデフォルトアドレス:
|
|
||||||
|
|
||||||
| ツール | デフォルト API Base |
|
| ツール | デフォルト API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -53,10 +23,40 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
|
|||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### モデル切り替え
|
## 設定ファイルでの設定
|
||||||
|
|
||||||
カスタムベンダーでモデルを切り替える際は `model` のみが変更され、`bot_type` と API アドレスは変わりません:
|
`config.json` を直接編集することもできます。`custom_providers` リストに複数のプロバイダーを定義し、`bot_type` を `"custom:<id>"` に設定していずれかを有効化します:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "custom:3f2a9c1b",
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
|
"name": "プロバイダーA",
|
||||||
|
"api_key": "YOUR_API_KEY_A",
|
||||||
|
"api_base": "https://api.a.com/v1",
|
||||||
|
"model": "deepseek-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
|
"name": "プロバイダーB",
|
||||||
|
"api_key": "YOUR_API_KEY_B",
|
||||||
|
"api_base": "https://api.b.com/v1",
|
||||||
|
"model": "qwen3-max"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
|
||||||
```
|
| パラメータ | 説明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `custom_providers` | カスタムプロバイダーのリスト。各項目は `id`、`name`、`api_base`、`api_key`(任意)、`model`(任意)を含む |
|
||||||
|
| `bot_type` | `"custom:<id>"` に設定して対応するプロバイダーを有効化 |
|
||||||
|
| `id` | 一意の識別子(8 桁の 16 進数)。Web コンソールから追加すると自動生成され、手動設定の場合は重複しない任意の文字列でよい |
|
||||||
|
| `name` | 表示名。自由に変更可能 |
|
||||||
|
| `model` | このプロバイダーで使用するモデル。有効化時に適用される |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
従来の単一プロバイダー設定(`bot_type` を `"custom"` にし、`custom_api_key` / `custom_api_base` を使用)は引き続き互換性があり、変更なしでそのまま利用できます。
|
||||||
|
</Note>
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ Zhipu AI はテキスト対話、画像理解、音声認識(ASR)、ベク
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "glm-5.1",
|
"model": "glm-5.2",
|
||||||
"zhipu_ai_api_key": "YOUR_API_KEY"
|
"zhipu_ai_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` などを指定可能。詳細は [モデルコード](https://bigmodel.cn/dev/api/normal-model/glm-4) を参照 |
|
| `model` | `glm-5.2`、`glm-5.1`、`glm-5-turbo`、`glm-5`、`glm-4.7`、`glm-4-plus`、`glm-4-flash`、`glm-4-air` などを指定可能。詳細は [モデルコード](https://bigmodel.cn/dev/api/normal-model/glm-4) を参照 |
|
||||||
| `zhipu_ai_api_key` | [Zhipu AI コンソール](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) で作成 |
|
| `zhipu_ai_api_key` | [Zhipu AI コンソール](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) で作成 |
|
||||||
| `zhipu_ai_api_base` | 任意。デフォルトは `https://open.bigmodel.cn/api/paas/v4` |
|
| `zhipu_ai_api_base` | 任意。デフォルトは `https://open.bigmodel.cn/api/paas/v4` |
|
||||||
|
|
||||||
## 画像理解
|
## 画像理解
|
||||||
|
|
||||||
Zhipu の chat 系モデル(`glm-5.1`、`glm-5-turbo` など)はビジョンに対応していないため、ビジョン呼び出しは `glm-5v-turbo` に統一的にルーティングされます。`zhipu_ai_api_key` を設定すると、Agent の Vision ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。
|
Zhipu の chat 系モデル(`glm-5.2`、`glm-5.1`、`glm-5-turbo` など)はビジョンに対応していないため、ビジョン呼び出しは `glm-5v-turbo` に統一的にルーティングされます。`zhipu_ai_api_key` を設定すると、Agent の Vision ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。
|
||||||
|
|
||||||
## 音声認識
|
## 音声認識
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<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.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -19,14 +19,14 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| ベンダー | 代表モデル | テキスト | 画像理解 | 画像生成 | 音声認識 | 音声合成 | ベクトル |
|
| ベンダー | 代表モデル | テキスト | 画像理解 | 画像生成 | 音声認識 | 音声合成 | ベクトル |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [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 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Zhipu GLM](/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.6",
|
"model": "kimi-k2.7-code",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||||
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
```json
|
||||||
{
|
{
|
||||||
"model": "MiniMax-M2.7",
|
"model": "MiniMax-M3",
|
||||||
"minimax_api_key": "YOUR_API_KEY"
|
"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) で作成 |
|
| `minimax_api_key` | [MiniMax コンソール](https://platform.minimaxi.com/user-center/basic-information/interface-key) で作成 |
|
||||||
|
|
||||||
## 画像理解
|
## 画像理解
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ Tongyi Qianwen(DashScope / Bailian)は国内で最も広範な機能をカ
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "qwen3.6-plus",
|
"model": "qwen3.7-plus",
|
||||||
"dashscope_api_key": "YOUR_API_KEY"
|
"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` | [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 モデルを手動で指定したい場合:
|
Vision モデルを手動で指定したい場合:
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@ Vision モデルを手動で指定したい場合:
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"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,8 @@ description: CowAgent バージョン更新履歴
|
|||||||
|
|
||||||
| バージョン | 日付 | 説明 |
|
| バージョン | 日付 | 説明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル追加(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
|
||||||
|
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加(MiniMax-M3、qwen3.7-plus など)、各種改善 |
|
||||||
| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化(ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル)、MCP Streamable HTTP、新モデル追加 |
|
| [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.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.8](/ja/releases/v2.0.8) | 2026.05.06 | Feishu チャネル全面アップグレード(音声、ストリーミング出力と Markdown、QR コードによるワンクリック接続)、DeepSeek V4 と百度モデルの追加、スケジュールタスクツールの強化 |
|
||||||
|
|||||||
61
docs/ja/releases/v2.1.1.mdx
Normal file
61
docs/ja/releases/v2.1.1.mdx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.1
|
||||||
|
description: CowAgent 2.1.1 - 自己進化、Web コンソールのメッセージ管理とマルチセッション並行、クロスプラットフォーム対応の MCP 強化、新モデルと改善
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1)
|
||||||
|
|
||||||
|
## 🧬 自己進化
|
||||||
|
|
||||||
|
CowAgent に **自己進化(Self-Evolution)** が加わりました。Agent は単発のタスクをこなすだけでなく、日々の協働を通じて成長し続けます:
|
||||||
|
|
||||||
|
- **アイドル後の自動レビュー**:会話がアイドルになると、Agent はバックグラウンドでその会話をレビューし、使用中に表面化した Skill の問題を修正し、再利用可能な新しい Skill を作成し、未完了のタスクを引き継ぎ、重要な情報を記憶とナレッジベースに記録します
|
||||||
|
- **静かに実行、必要なときだけ通知**:実際に変更があったときのみ調整内容を一言で伝え、変更がなければ何も通知しません
|
||||||
|
- **安全で取り消し可能**:レビューのたびに事前にバックアップを取り、いつでも取り消せます。組み込み Skill は保護され、すべての読み書きはワークスペース内に限定されます
|
||||||
|
|
||||||
|
新規インストールではデフォルトで有効です。既存ユーザーは Web コンソールの **設定 → Agent 設定** からワンクリックで有効化できます。
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-demo.png" alt="自己進化の会話例" />
|
||||||
|
|
||||||
|
ドキュメント:[自己進化](https://docs.cowagent.ai/ja/memory/self-evolution)
|
||||||
|
|
||||||
|
## 💬 Web コンソールの強化
|
||||||
|
|
||||||
|
Web コンソールのチャット体験がさらに強化され、主要なチャット製品に近い操作感になりました:
|
||||||
|
|
||||||
|
- **メッセージ管理**:ユーザーと Bot のメッセージを編集・削除・再生成できます。コードブロックに言語ラベルとワンクリックコピーボタンを追加。Thanks [@core-power](https://github.com/core-power) (#2865)
|
||||||
|
- **マルチセッション並行**:複数のセッションを同時に実行しても互いに干渉せず、セッションに戻るとライブストリーミングが自動的に再開されます
|
||||||
|
- **細部の改善**:チャット画面のどこにでもファイルをドラッグ&ドロップ可能。アクティブなセッションを削除すると隣接セッションへ自動で切り替わります
|
||||||
|
|
||||||
|
## 🧩 クロスプラットフォーム対応の MCP 強化
|
||||||
|
|
||||||
|
- **Windows 互換性の修正**:Windows で `stdio` 通信が動作しない問題を修正し、サーバーのタイムアウトを `mcp.json` で設定できるようにしました
|
||||||
|
- **並行呼び出し**:`sse` と `streamable-http` トランスポートがセッション間の並行呼び出しに対応し、複数ツールの応答が高速化されました
|
||||||
|
|
||||||
|
Thanks [@xliu123321](https://github.com/xliu123321) (#2859)
|
||||||
|
|
||||||
|
ドキュメント:[MCP ツール](https://docs.cowagent.ai/ja/tools/mcp)
|
||||||
|
|
||||||
|
## 🤖 新モデルと改善
|
||||||
|
|
||||||
|
- **MiniMax-M3**:追加してデフォルトモデルに設定(M2.7 シリーズはオプションとして継続)。Thanks [@octo-patch](https://github.com/octo-patch) (#2855)
|
||||||
|
- **Qwen3.7-plus**:マルチモーダル対話に対応
|
||||||
|
- **ASR モデルの選択**:Web コンソールで ASR(音声認識)モデルを選択して永続化できるようになりました。Thanks [@nightwhite](https://github.com/nightwhite) (#2857)
|
||||||
|
- **インストールメニューの簡素化**:ワンライナーインストールスクリプトのモデルメニューを簡素化し、Xiaomi MiMo オプションを追加
|
||||||
|
|
||||||
|
ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models)
|
||||||
|
|
||||||
|
## 🛠 改善と修正
|
||||||
|
|
||||||
|
- **Python 3.13 対応**:Python 3.13 環境でのインストールと依存関係の互換性を修正
|
||||||
|
- **国際化**:チャネル一覧を UI の言語順に表示。`auto` モードでの言語自動フォールバックを改善
|
||||||
|
- **より確実なキャンセル**:ストリーミング応答を中断できない場合がある問題を修正
|
||||||
|
- **CLI**:`cow status` に現在のプロジェクトパスを表示
|
||||||
|
- **デプロイのセキュリティ強化**:認証ファイルのブロック範囲を `~/.cow/.env` に限定し、他のディレクトリに影響しないように修正(Thanks [@orbisai0security](https://github.com/orbisai0security) #2863)。WeChat 公式アカウントは `wechatmp_token` が空の場合に Webhook リクエストを拒否します
|
||||||
|
- **グループタスクボードプラグイン**:グループタスクボードプラグインのソースを追加。Thanks [@Wyh-max-star](https://github.com/Wyh-max-star) (#2853)
|
||||||
|
|
||||||
|
## 📦 アップグレード
|
||||||
|
|
||||||
|
ソースコードでデプロイしている場合は `cow update` でワンクリックアップグレードするか、最新コードを取得して手動で再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。
|
||||||
|
|
||||||
|
**リリース日**:2026.06.09 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1)
|
||||||
67
docs/ja/releases/v2.1.2.mdx
Normal file
67
docs/ja/releases/v2.1.2.mdx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.2
|
||||||
|
description: CowAgent 2.1.2 - Web コンソールの管理機能強化、自己進化の改善、新モデル、企業微信スマートボットのコールバックモード、セキュリティ強化
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
|
||||||
|
|
||||||
|
## 💬 Web コンソールの改善
|
||||||
|
|
||||||
|
本リリースでは Web コンソールに複数の可視化管理機能を追加し、ファイルを編集せずに UI 上でより多くの設定を行えるようになりました:
|
||||||
|
|
||||||
|
- **定期タスク管理**:コンソール上で任意の定期タスクを直接表示・編集・有効化/無効化・削除できます。タスク一覧は有効状態を優先し、次回実行時刻の順にソートされます。Thanks @HnBigVolibear (#2892)
|
||||||
|
- **ナレッジベースのカテゴリと文書管理**:ナレッジベースをカテゴリで整理し、各カテゴリ配下の文書を UI で管理できるようになりました。Thanks @yangziyu-hhh (#2893)
|
||||||
|
- **複数のカスタムモデルプロバイダー**:複数の OpenAI 互換プロバイダーを設定し、有効なものをワンクリックで切り替えられます。既存の設定とも完全に互換です。Thanks @kirs-hi (#2877)
|
||||||
|
- **セッションのリネーム**:セッションを手動でリネームでき、並行する複数のタスクを区別しやすくなります (#2897)
|
||||||
|
- **Bash のストリーミング出力**:長時間実行される Bash コマンドの進捗をリアルタイムにストリーミング出力します。Thanks @yangziyu-hhh (#2879)
|
||||||
|
|
||||||
|
## 🧬 自己進化の改善
|
||||||
|
|
||||||
|
前バージョンで導入した自己進化を、本バージョンでさらに改善しました:
|
||||||
|
|
||||||
|
- **トリガー閾値の引き下げ**:デフォルトのレビュー閾値を引き下げ、日々の協働がより早く改善へとつながります
|
||||||
|
- **同時レビューの回避**:単一ターンの処理が長引いた場合に、アイドルレビューが誤って起動しなくなり、進行中の会話との干渉を回避します
|
||||||
|
- **レビュー要約の改善**:要約生成のプロンプトを改善し、要約を簡潔に保ちつつ情報密度を高め、会話の言語で出力します
|
||||||
|
|
||||||
|
ドキュメント:[自己進化](https://docs.cowagent.ai/ja/memory/self-evolution)
|
||||||
|
|
||||||
|
## 🤖 新モデル
|
||||||
|
|
||||||
|
- **kimi-k2.7-code**:追加して Kimi のデフォルトモデルに設定。高速版の `kimi-k2.7-code-highspeed` も利用できます
|
||||||
|
- **glm-5.2**:追加して GLM のデフォルトモデルに設定
|
||||||
|
|
||||||
|
ドキュメント:[モデル概要](https://docs.cowagent.ai/ja/models)
|
||||||
|
|
||||||
|
## 🏢 企業微信スマートボットのコールバックモード
|
||||||
|
|
||||||
|
企業微信スマートボットのチャネルに、既存のロングコネクションに加えて **HTTP コールバックモード** を追加しました。ロングコネクションを維持できない環境でも安定して接続できます:
|
||||||
|
|
||||||
|
- **モード切替**:`wecom_bot_mode` で `websocket`(ロングコネクション)と `webhook`(コールバック)を切り替えます
|
||||||
|
- **暗号化通信**:コールバックモードは URL 検証、メッセージ復号、受動応答の暗号化に完全対応します
|
||||||
|
- **安定性の修正**:応答の中断、ストリームの早期終了、一時画像ファイルのリークなどの問題を修正しました
|
||||||
|
|
||||||
|
Thanks @6vision (#2896 #2869)
|
||||||
|
|
||||||
|
ドキュメント:[企業微信スマートボット](https://docs.cowagent.ai/ja/channels/wecom-bot)
|
||||||
|
|
||||||
|
## 🔒 セキュリティ強化
|
||||||
|
|
||||||
|
- **Vision ツールの SSRF 対策**:画像 URL を解決する前に対象アドレスを検証し、内部・ループバック・クラウドサーバーのメタデータエンドポイントへのリクエストをブロックします。Thanks @kirs-hi (#2886)
|
||||||
|
- **Web フェッチの SSRF 対策**:`web_fetch` は取得前に対象アドレスを検証し、リダイレクトのたびに再検証することで、リダイレクト経由で検証を回避して内部アドレスへ到達することを防ぎます。Thanks @christop (#2900)
|
||||||
|
- **Skill インストールのパストラバーサル対策**:Skill のインストール時にパスを検証し、悪意ある Skill 名がパストラバーサルで `skills/` ディレクトリを抜け出して許可されない場所に書き込むことを防ぎます。Thanks @kirs-hi (#2886)
|
||||||
|
|
||||||
|
## 🛠 改善と修正
|
||||||
|
|
||||||
|
- **CLI セルフ再起動**:self-restart コマンドを追加し、Agent が自身のプロセスを再起動できるようになりました
|
||||||
|
- **Windows 互換性**:cow CLI のディレクトリをユーザー PATH に永続化。`python -c` の長いコマンドが `cmd.exe` の長さ制限を超える問題を修正。インストール時に greenlet をソースからビルドしないように修正
|
||||||
|
- **カスタムロール**:ロールプラグインが `roles/*.json` 配下の独立したプロンプトファイルによるカスタマイズに対応しました。Thanks @sufan721 (#2891)
|
||||||
|
- **安定性の修正**:`/cancel` 時の KeyError と画像圧縮の無限ループを修正(Thanks @kirs-hi #2888)
|
||||||
|
- **インストールの改善**:起動スクリプトとデフォルト設定を更新。ASR/TTS のデフォルト値、自己進化フラグ、インストール時のハングを修正
|
||||||
|
- **Vision ツールの安定性**:Vision ツールのタイムアウトと max_tokens を引き上げ
|
||||||
|
- **記憶の蒸留**:ディープドリーム蒸留の出力長制限を撤廃し、大きな `MEMORY.md` が切り詰められないようにしました
|
||||||
|
|
||||||
|
## 📦 アップグレード
|
||||||
|
|
||||||
|
ソースコードでデプロイしている場合は `cow update` でワンクリックアップグレードするか、最新コードを取得して手動で再起動してください。詳細は [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。
|
||||||
|
|
||||||
|
**リリース日**:2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)
|
||||||
@@ -19,7 +19,7 @@ Vision ツールは多段階の自動選択 + 自動フォールバック戦略
|
|||||||
| プロバイダー | ビジョンモデル | 説明 |
|
| プロバイダー | ビジョンモデル | 説明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| OpenAI / 互換プロトコル | メインモデルを使用 | すべての OpenAI 互換マルチモーダルモデルに対応 |
|
| OpenAI / 互換プロトコル | メインモデルを使用 | すべての OpenAI 互換マルチモーダルモデルに対応 |
|
||||||
| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.6-plus など |
|
| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.7-plus など |
|
||||||
| Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 |
|
| Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 |
|
||||||
| Gemini | メインモデルを使用 | inlineData 形式 |
|
| Gemini | メインモデルを使用 | inlineData 形式 |
|
||||||
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-0 シリーズがネイティブ対応 |
|
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-0 シリーズがネイティブ対応 |
|
||||||
|
|||||||
94
docs/memory/self-evolution.mdx
Normal file
94
docs/memory/self-evolution.mdx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
For the full architecture and engineering behind the self-evolution mechanism, see the blog post: [A Five-Layer Self-Evolution Mechanism for AI Agents](https://cowagent.ai/blog/self-evolution/).
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-demo.png" alt="Self-Evolution in a conversation" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
### 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; ② when a reusable workflow emerges, turn it into a new skill so it can be reused next time |
|
||||||
|
| **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 (10 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
|
||||||
|
|
||||||
|
You can toggle Self-Evolution 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 (on by default for new installs) | `false` |
|
||||||
|
| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `10` |
|
||||||
|
| `self_evolution_min_turns` | Minimum conversation turns required to trigger | `6` |
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-config.png" alt="Enable Self-Evolution in the Web console" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-logs.png" alt="Self-Evolution records list" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
### 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 |
|
||||||
@@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-8",
|
"model": "claude-fable-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Supports `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | Supports `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
||||||
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
| Model | Use Case |
|
| Model | Use Case |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks |
|
| `claude-fable-5` | Latest flagship; best for complex reasoning and long-running tasks, at a higher price |
|
||||||
| `claude-opus-4-7` | Previous-generation Opus flagship |
|
| `claude-opus-4-8` | Previous flagship with balanced quality and cost |
|
||||||
|
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Reference: [Quick Start](https://help.aliyun.com/zh/model-studio/coding-plan-qui
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot_type": "openai",
|
"bot_type": "openai",
|
||||||
"model": "MiniMax-M2.5",
|
"model": "MiniMax-M3",
|
||||||
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
"open_ai_api_base": "https://api.minimaxi.com/v1",
|
||||||
"open_ai_api_key": "YOUR_API_KEY"
|
"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 |
|
| 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_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) |
|
| `open_ai_api_key` | Coding Plan specific key (not shared with pay-as-you-go) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: Custom
|
title: Custom
|
||||||
description: Custom vendor configuration for third-party API proxies and local models
|
description: Custom provider configuration for third-party API proxies and local models
|
||||||
---
|
---
|
||||||
|
|
||||||
For model services accessed via the OpenAI-compatible protocol or locally deployed models, such as:
|
For model services accessed via the OpenAI-compatible protocol, such as:
|
||||||
|
|
||||||
- **Third-party API proxies**: call multiple models through a unified API base
|
- **Third-party API proxies**: call multiple models through a unified API base
|
||||||
- **Local models**: models deployed locally with tools like Ollama, vLLM, LocalAI
|
- **Local models**: models deployed locally with tools like Ollama, vLLM
|
||||||
- **Private deployments**: model services deployed inside an enterprise
|
- **Private deployments**: model services deployed inside an enterprise
|
||||||
|
|
||||||
<Note>
|
## Web Console
|
||||||
Difference from the `openai` vendor: when a custom vendor is selected, switching models via `/config model` does not automatically switch the vendor type — the custom API address is always used.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Text Chat
|
Recommended. On the "Models" page of the Web console, click "Add Provider" and pick "Custom", then fill in the name, API Base and API Key. Multiple custom providers can be added; after adding one, select it together with a model in the "Main Model" card to enable it.
|
||||||
|
|
||||||
### Third-party API proxy
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-custom-model-config.png" />
|
||||||
|
|
||||||
```json
|
Default endpoints of common local deployment tools:
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "",
|
|
||||||
"custom_api_key": "YOUR_API_KEY",
|
|
||||||
"custom_api_base": "https://{your-proxy.com}/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `bot_type` | Must be set to `custom` |
|
|
||||||
| `model` | Model name; any model name supported by the proxy service |
|
|
||||||
| `custom_api_key` | API key provided by the proxy service |
|
|
||||||
| `custom_api_base` | API endpoint provided by the proxy service; must be OpenAI-compatible |
|
|
||||||
|
|
||||||
### Local models
|
|
||||||
|
|
||||||
Local models usually do not require an API key — only the API base needs to be filled in:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bot_type": "custom",
|
|
||||||
"model": "qwen3.5:27b",
|
|
||||||
"custom_api_base": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Common local deployment tools and their default endpoints:
|
|
||||||
|
|
||||||
| Tool | Default API Base |
|
| Tool | Default API Base |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -53,10 +23,40 @@ Common local deployment tools and their default endpoints:
|
|||||||
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/v1` |
|
||||||
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` |
|
||||||
|
|
||||||
### Switching Models
|
## Configuration File
|
||||||
|
|
||||||
Switching models under a custom vendor only changes `model` — `bot_type` and the API endpoint remain unchanged:
|
You can also edit `config.json` directly: define multiple providers in the `custom_providers` list and set `bot_type` to `"custom:<id>"` to activate one of them:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_type": "custom:3f2a9c1b",
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"id": "3f2a9c1b",
|
||||||
|
"name": "ProviderA",
|
||||||
|
"api_key": "YOUR_API_KEY_A",
|
||||||
|
"api_base": "https://api.a.com/v1",
|
||||||
|
"model": "deepseek-v3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a1b2c3d4",
|
||||||
|
"name": "ProviderB",
|
||||||
|
"api_key": "YOUR_API_KEY_B",
|
||||||
|
"api_base": "https://api.b.com/v1",
|
||||||
|
"model": "qwen3-max"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
/config model qwen3.5:27b
|
|
||||||
```
|
| Parameter | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `custom_providers` | List of custom providers; each item has `id`, `name`, `api_base`, `api_key` (optional) and `model` (optional) |
|
||||||
|
| `bot_type` | Set to `"custom:<id>"` to activate the corresponding provider |
|
||||||
|
| `id` | Unique identifier (8-char hex); auto-generated when adding via the Web console, or any unique string when editing manually |
|
||||||
|
| `name` | Display label, can be renamed freely |
|
||||||
|
| `model` | Model used by this provider, takes effect when activated |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The legacy single-provider configuration (`bot_type` set to `"custom"` with `custom_api_key` / `custom_api_base`) remains fully compatible and keeps working without any changes.
|
||||||
|
</Note>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: DeepSeek
|
|||||||
description: DeepSeek model configuration (Text Chat + Thinking Mode)
|
description: DeepSeek model configuration (Text Chat + Thinking Mode)
|
||||||
---
|
---
|
||||||
|
|
||||||
DeepSeek is one of the default recommended vendors in Agent mode, focused on cost-effective text chat and task planning.
|
DeepSeek is one of the default recommended providers in Agent mode, focused on cost-effective text chat and task planning.
|
||||||
|
|
||||||
## Text Chat
|
## Text Chat
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ Zhipu AI supports text chat, image understanding, speech-to-text (ASR), and embe
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "glm-5.1",
|
"model": "glm-5.2",
|
||||||
"zhipu_ai_api_key": "YOUR_API_KEY"
|
"zhipu_ai_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Can be `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4-plus`, `glm-4-flash`, `glm-4-air`, etc. See [model codes](https://bigmodel.cn/dev/api/normal-model/glm-4) |
|
| `model` | Can be `glm-5.2`, `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4-plus`, `glm-4-flash`, `glm-4-air`, etc. See [model codes](https://bigmodel.cn/dev/api/normal-model/glm-4) |
|
||||||
| `zhipu_ai_api_key` | Create one in the [Zhipu AI Console](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| `zhipu_ai_api_key` | Create one in the [Zhipu AI Console](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
| `zhipu_ai_api_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` |
|
| `zhipu_ai_api_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` |
|
||||||
|
|
||||||
## Image Understanding
|
## Image Understanding
|
||||||
|
|
||||||
Zhipu's chat models (`glm-5.1`, `glm-5-turbo`, etc.) do not support vision; vision calls are uniformly routed to `glm-5v-turbo`. Once `zhipu_ai_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file.
|
Zhipu's chat models (`glm-5.2`, `glm-5.1`, `glm-5-turbo`, etc.) do not support vision; vision calls are uniformly routed to `glm-5v-turbo`. Once `zhipu_ai_api_key` is configured, the Agent's Vision tool automatically uses this model, with no need to specify it explicitly in the configuration file.
|
||||||
|
|
||||||
## Speech-to-Text (ASR)
|
## Speech-to-Text (ASR)
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
---
|
---
|
||||||
title: Models Overview
|
title: Models Overview
|
||||||
description: Model vendors supported by CowAgent and their capability matrix
|
description: Model providers supported by CowAgent and their capability matrix
|
||||||
---
|
---
|
||||||
|
|
||||||
CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several vendors also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow.
|
CowAgent supports a wide range of mainstream large language models. Model interfaces live under the project's `models/` directory. Beyond text chat, several providers also provide vision understanding, image generation, speech-to-text, text-to-speech, and embeddings — all of which can be invoked on demand in the Agent flow.
|
||||||
|
|
||||||
## Capability Matrix
|
## Capability Matrix
|
||||||
|
|
||||||
A snapshot of each vendor's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the vendor can power.
|
A snapshot of each provider's capabilities. "Text" refers to the main chat model; the remaining columns show which Agent capabilities the provider can power.
|
||||||
|
|
||||||
| Vendor | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
|
| Provider | Representative Models | Text | Vision | Image Gen | STT | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [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 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Qwen](/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/models/linkai) | 100+ models from multiple vendors | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | |
|
| [Custom](/models/custom) | Local models / third-party proxies | ✅ | | | | | |
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own vendor and model — there is no forced binding between them.
|
Every capability in the Web console (Vision / Image / STT / TTS / Embedding / Web Search) can be configured independently with its own provider and model — there is no forced binding between them.
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
## How to Configure
|
## How to Configure
|
||||||
@@ -35,4 +35,4 @@ A snapshot of each vendor's capabilities. "Text" refers to the main chat model;
|
|||||||
|
|
||||||
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-models-config.png" />
|
<img width="900" src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-models-config.png" />
|
||||||
|
|
||||||
**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected vendor. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`.
|
**Option 2:** Edit `config.json` manually and fill in the model name and API key for the selected provider. Every model also supports OpenAI-compatible access — just set `bot_type` to `openai` and configure `open_ai_api_base` and `open_ai_api_key`.
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.6",
|
"model": "kimi-k2.7-code",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Can be `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||||
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
||||||
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: LinkAI
|
|||||||
description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform
|
description: Access text, vision, image, speech, and embedding capabilities through the LinkAI platform
|
||||||
---
|
---
|
||||||
|
|
||||||
A single `linkai_api_key` gives you access to all capabilities of mainstream vendors such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao.
|
A single `linkai_api_key` gives you access to all capabilities of mainstream providers such as OpenAI, Claude, Gemini, DeepSeek, MiniMax, Qwen, Kimi, and Doubao.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||||
@@ -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
|
## Image Generation
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Use the global `enable_thinking` flag to toggle visibility (also switchable from
|
|||||||
Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models:
|
Once `mimo_api_key` is configured, the Agent's Vision tool can automatically use MiMo's vision models:
|
||||||
|
|
||||||
- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup.
|
- When the main model itself is multimodal (`mimo-v2.5-pro` / `mimo-v2.5`), images are handled directly by the main model with no extra setup.
|
||||||
- When the main model belongs to another vendor, the Vision tool falls back to `mimo-v2.5-pro` in order.
|
- When the main model belongs to another provider, the Vision tool falls back to `mimo-v2.5-pro` in order.
|
||||||
|
|
||||||
To force a specific Vision model, set it explicitly in the configuration:
|
To force a specific Vision model, set it explicitly in the configuration:
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ MiniMax supports text chat, image understanding, image generation, and text-to-s
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "MiniMax-M2.7",
|
"model": "MiniMax-M3",
|
||||||
"minimax_api_key": "YOUR_API_KEY"
|
"minimax_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| 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) |
|
| `minimax_api_key` | Create one in the [MiniMax Console](https://platform.minimaxi.com/user-center/basic-information/interface-key) |
|
||||||
|
|
||||||
## Image Understanding
|
## Image Understanding
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
||||||
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||||
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
||||||
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other vendors via the compatible protocol |
|
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
||||||
|
|
||||||
## Image Understanding
|
## Image Understanding
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Qwen
|
|||||||
description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding)
|
description: Qwen model configuration (Text / Image Understanding / Image Generation / Speech-to-Text / Text-to-Speech / Embedding)
|
||||||
---
|
---
|
||||||
|
|
||||||
Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`.
|
Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured providers. Text, image understanding, image generation, speech-to-text, text-to-speech, and embedding can all be enabled with a single `dashscope_api_key`.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||||
@@ -13,19 +13,19 @@ Qwen (Alibaba DashScope / Bailian) is one of the most fully-featured vendors. Te
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "qwen3.6-plus",
|
"model": "qwen3.7-plus",
|
||||||
"dashscope_api_key": "YOUR_API_KEY"
|
"dashscope_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| 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) |
|
| `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
|
## 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:
|
To manually specify a Vision model:
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@ To manually specify a Vision model:
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"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
|
## Image Generation
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: CowAgent version history
|
|||||||
|
|
||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | Web Console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements |
|
||||||
|
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |
|
||||||
| [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.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.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.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 |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b)
|
|||||||
|
|
||||||
## 💰 Coding Plan Support
|
## 💰 Coding Plan Support
|
||||||
|
|
||||||
Added integration with vendor Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported vendors include Aliyun, MiniMax, GLM, Kimi, and Volcengine.
|
Added integration with provider Coding Plan (monthly programming subscription) tiers via the unified OpenAI-compatible path. Supported providers include Aliyun, MiniMax, GLM, Kimi, and Volcengine.
|
||||||
|
|
||||||
See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration.
|
See [Coding Plan docs](https://docs.cowagent.ai/en/models/coding-plan) for detailed configuration.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Docs: [Image Generation Skill](https://docs.cowagent.ai/en/skills/image-generati
|
|||||||
- **Claude Opus 4.7**: Added `claude-opus-4-7` model support
|
- **Claude Opus 4.7**: Added `claude-opus-4-7` model support
|
||||||
- **GLM 5.1**: Added `glm-5.1` model support
|
- **GLM 5.1**: Added `glm-5.1` model support
|
||||||
- **Kimi Coding Plan**: Support for Kimi Coding Plan mode
|
- **Kimi Coding Plan**: Support for Kimi Coding Plan mode
|
||||||
- **Custom model providers**: New custom model provider configuration for easier integration with additional vendors
|
- **Custom model providers**: New custom model provider configuration for easier integration with additional providers
|
||||||
|
|
||||||
## 💬 Web Console Improvements
|
## 💬 Web Console Improvements
|
||||||
|
|
||||||
|
|||||||
63
docs/releases/v2.1.1.mdx
Normal file
63
docs/releases/v2.1.1.mdx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.1
|
||||||
|
description: CowAgent 2.1.1 - Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements, new models and improvements
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.1) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.1)
|
||||||
|
|
||||||
|
## 🧬 Self-Evolution
|
||||||
|
|
||||||
|
CowAgent introduces **Self-Evolution**, letting the agent go beyond completing a single task and keep improving through everyday collaboration with you:
|
||||||
|
|
||||||
|
- **Automatic review after idle**: Once a conversation goes idle, the agent reviews it in the background to fix problems a skill exposed in use, create reusable new skills, follow up on unfinished tasks, and record important information into memory and the knowledge base
|
||||||
|
- **Silent by default, notify on demand**: It reports what it changed only when it actually made a change, and stays silent otherwise
|
||||||
|
- **Safe and reversible**: Every review is backed up beforehand and can be undone at any time. Built-in skills are protected, and all reads and writes stay within the workspace
|
||||||
|
|
||||||
|
Enabled by default for new installs. Existing users can turn it on with a single click in the Web Console under **Settings → Agent Config**.
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-evolution-demo.png" alt="Self-Evolution conversation example" />
|
||||||
|
|
||||||
|
Documentation: [Self-Evolution](https://docs.cowagent.ai/memory/self-evolution)
|
||||||
|
|
||||||
|
## 💬 Web Console Upgrades
|
||||||
|
|
||||||
|
The Web Console chat experience gets several enhancements:
|
||||||
|
|
||||||
|
- **Message management**: Edit, delete, and regenerate both user and bot messages; code blocks now include language labels and a one-click copy button
|
||||||
|
- **Parallel sessions**: Run multiple sessions at the same time without interference, with live streaming automatically resumed when you switch back to a session
|
||||||
|
- **Refinements**: Drag and drop files anywhere in the chat view; automatically switch to a sibling session after deleting the active one
|
||||||
|
|
||||||
|
Thanks [@core-power](https://github.com/core-power) (#2865)
|
||||||
|
|
||||||
|
## 🧩 Cross-platform MCP Enhancements
|
||||||
|
|
||||||
|
- **Windows compatibility fix**: Fixed `stdio` communication failing on Windows, and made the server timeout configurable via `mcp.json`
|
||||||
|
- **Concurrent calls**: The `sse` and `streamable-http` transports now support concurrent calls across sessions for faster multi-tool responses
|
||||||
|
|
||||||
|
Thanks [@xliu123321](https://github.com/xliu123321) (#2859)
|
||||||
|
|
||||||
|
Documentation: [MCP Tools](https://docs.cowagent.ai/tools/mcp)
|
||||||
|
|
||||||
|
## 🤖 New Models & Improvements
|
||||||
|
|
||||||
|
- **MiniMax-M3**: Added and set as the default model, with the M2.7 series kept as an option. Thanks [@octo-patch](https://github.com/octo-patch) (#2855)
|
||||||
|
- **Qwen3.7-plus**: Added support for multi-modal conversations
|
||||||
|
- **Selectable ASR model**: The Web Console can now select and persist the ASR (speech recognition) model. Thanks [@nightwhite](https://github.com/nightwhite) (#2857)
|
||||||
|
- **Simplified install menu**: The one-line install script streamlines the model menu and adds the Xiaomi MiMo option
|
||||||
|
|
||||||
|
Documentation: [Models Overview](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
|
- **Python 3.13 support**: Fixed installation and dependency compatibility on Python 3.13
|
||||||
|
- **Internationalization**: The channel list is now ordered by the interface language; refined the automatic language fallback under `auto` mode
|
||||||
|
- **More reliable cancellation**: Fixed cases where a streaming reply could not be interrupted
|
||||||
|
- **CLI**: `cow status` now shows the current project path
|
||||||
|
- **Hardened deployment security**: The credential-file block is narrowed to `~/.cow/.env` so other directories are no longer affected (Thanks [@orbisai0security](https://github.com/orbisai0security) #2863); the WeChat Official Account now rejects webhook requests when `wechatmp_token` is empty
|
||||||
|
- **Group task board plugin**: Added the group task board plugin source. Thanks [@Wyh-max-star](https://github.com/Wyh-max-star) (#2853)
|
||||||
|
|
||||||
|
## 📦 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/guide/upgrade) for details.
|
||||||
|
|
||||||
|
**Release Date**: 2026.06.09 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1)
|
||||||
67
docs/releases/v2.1.2.mdx
Normal file
67
docs/releases/v2.1.2.mdx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.2
|
||||||
|
description: CowAgent 2.1.2 - Web Console management upgrades, Self-Evolution improvements, new models, WeCom smart-bot callback mode, and security hardening
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
|
||||||
|
|
||||||
|
## 💬 Web Console Improvements
|
||||||
|
|
||||||
|
This release adds several visual management capabilities to the Web Console, so more configuration can be done in the UI without editing files:
|
||||||
|
|
||||||
|
- **Scheduled task management**: View, edit, enable/disable, and delete any scheduled task directly in the console. The task list is sorted by enabled status first, then by next run time. Thanks @HnBigVolibear (#2892)
|
||||||
|
- **Knowledge base categories and document management**: The knowledge base can now be organized by category, with documents under each category managed in the UI. Thanks @yangziyu-hhh (#2893)
|
||||||
|
- **Multiple custom model providers**: Configure multiple OpenAI-compatible providers and switch the active one with a single click, fully compatible with existing configuration. Thanks @kirs-hi (#2877)
|
||||||
|
- **Session renaming**: Rename sessions manually to tell parallel tasks apart (#2897)
|
||||||
|
- **Bash streaming output**: Long-running Bash commands now stream their progress in real time. Thanks @yangziyu-hhh (#2879)
|
||||||
|
|
||||||
|
## 🧬 Self-Evolution Improvements
|
||||||
|
|
||||||
|
Building on the Self-Evolution introduced in the previous release, this version refines it further:
|
||||||
|
|
||||||
|
- **Lower trigger thresholds**: The default review thresholds are lowered, so everyday collaboration turns into improvements sooner
|
||||||
|
- **No concurrent reviews**: When a single turn runs long, the idle review no longer fires by mistake, avoiding interference with the active conversation
|
||||||
|
- **Better review summary**: Refined the summary prompt to keep summaries concise, raise their information density, and output them in the conversation language
|
||||||
|
|
||||||
|
Documentation: [Self-Evolution](https://docs.cowagent.ai/memory/self-evolution)
|
||||||
|
|
||||||
|
## 🤖 New Models
|
||||||
|
|
||||||
|
- **kimi-k2.7-code**: Added and set as the default Kimi model, with `kimi-k2.7-code-highspeed` also available
|
||||||
|
- **glm-5.2**: Added and set as the default GLM model
|
||||||
|
|
||||||
|
Documentation: [Models Overview](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🏢 WeCom Smart-Bot Callback Mode
|
||||||
|
|
||||||
|
The WeCom smart-bot channel adds an **HTTP callback mode** alongside the existing long connection, so deployments that cannot keep a long connection open can still connect reliably:
|
||||||
|
|
||||||
|
- **Mode switching**: Switch between `websocket` (long connection) and `webhook` (callback) via `wecom_bot_mode`
|
||||||
|
- **Encrypted transport**: Callback mode fully supports URL verification, message decryption, and passive-reply encryption
|
||||||
|
- **Stability fixes**: Fixed reply interruption, premature stream termination, and temporary image file leaks
|
||||||
|
|
||||||
|
Thanks @6vision (#2896 #2869)
|
||||||
|
|
||||||
|
Documentation: [WeCom Smart Bot](https://docs.cowagent.ai/channels/wecom-bot)
|
||||||
|
|
||||||
|
## 🔒 Security Hardening
|
||||||
|
|
||||||
|
- **Vision tool SSRF protection**: Validates the target address before resolving an image URL, blocking requests to internal, loopback, and cloud server metadata endpoints. Thanks @kirs-hi (#2886)
|
||||||
|
- **Web fetch SSRF protection**: `web_fetch` validates the target address before fetching and re-validates every redirect hop, preventing redirects from bypassing the check to reach internal addresses. Thanks @christop (#2900)
|
||||||
|
- **Skill install path traversal protection**: Validates the path when installing a skill, preventing a malicious skill name from escaping the `skills/` directory through path traversal and writing to an unauthorized location. Thanks @kirs-hi (#2886)
|
||||||
|
|
||||||
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
|
- **CLI self-restart**: Added the self-restart command so the agent can restart its own process
|
||||||
|
- **Windows compatibility**: Persist the cow CLI directory to the user PATH; fixed `python -c` long commands exceeding the `cmd.exe` length limit; avoid building greenlet from source during install
|
||||||
|
- **Custom roles**: The role plugin supports customization via standalone prompt files under `roles/*.json`. Thanks @sufan721 (#2891)
|
||||||
|
- **Stability fixes**: Fixed a KeyError on `/cancel` and an infinite loop in image compression (Thanks @kirs-hi #2888)
|
||||||
|
- **Install improvements**: Updated the startup script and default config; fixed ASR/TTS defaults, the self-evolution flag, and install hangs
|
||||||
|
- **Vision tool stability**: Increased the vision tool timeout and max_tokens
|
||||||
|
- **Memory distillation**: Removed the output length cap in deep-dream distillation to avoid truncating a large `MEMORY.md`
|
||||||
|
|
||||||
|
## 📦 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/guide/upgrade) for details.
|
||||||
|
|
||||||
|
**Release Date**: 2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)
|
||||||
@@ -19,7 +19,7 @@ If the current provider fails, the tool automatically tries the next one until i
|
|||||||
| Provider | Vision Model | Notes |
|
| Provider | Vision Model | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| OpenAI / Compatible | Main model | All OpenAI-protocol-compatible multimodal models |
|
| 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 |
|
| Claude | Main model | Anthropic native image format |
|
||||||
| Gemini | Main model | inlineData format |
|
| Gemini | Main model | inlineData format |
|
||||||
| Doubao | Main model | doubao-seed-2-0 series natively supported |
|
| Doubao | Main model | doubao-seed-2-0 series natively supported |
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/github/license/zhayujie/CowAgent" alt="License: MIT"></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" alt="Stars"></a> <br/>
|
<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>]
|
[<a href="../../README.md">English</a>] | [中文] | [<a href="../ja/README.md">日本語</a>]
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆,与你一同成长,是 Agent Harness 工程的最佳实践之一。
|
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆、通过自主进化与你一同成长,是 Agent Harness 工程的最佳实践之一。
|
||||||
|
|
||||||
CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、Slack、网页等多渠道,7×24 运行于个人电脑或服务器中。
|
CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖微信、飞书、钉钉、企微、QQ、Telegram、Slack、网页等多渠道,7×24 运行于个人电脑或服务器中。
|
||||||
|
|
||||||
@@ -28,6 +36,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖
|
|||||||
| [任务规划](https://docs.cowagent.ai/zh/intro/architecture) | 理解复杂任务并自主分解执行,循环调用工具直到完成目标 |
|
| [任务规划](https://docs.cowagent.ai/zh/intro/architecture) | 理解复杂任务并自主分解执行,循环调用工具直到完成目标 |
|
||||||
| [长期记忆](https://docs.cowagent.ai/zh/memory) | 三层记忆架构(上下文 → 天级 → 核心),梦境蒸馏自动整理,支持关键词与向量混合检索 |
|
| [长期记忆](https://docs.cowagent.ai/zh/memory) | 三层记忆架构(上下文 → 天级 → 核心),梦境蒸馏自动整理,支持关键词与向量混合检索 |
|
||||||
| [知识库](https://docs.cowagent.ai/zh/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 |
|
| [知识库](https://docs.cowagent.ai/zh/knowledge) | 自动整理结构化知识为 Markdown Wiki,构建持续增长的知识图谱,可视化浏览 |
|
||||||
|
| [自主进化](https://docs.cowagent.ai/zh/memory/self-evolution) | 自动复盘对话,优化技能、处理未完成事项、沉淀记忆与知识,在使用中持续成长 |
|
||||||
| [技能](https://docs.cowagent.ai/zh/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 |
|
| [技能](https://docs.cowagent.ai/zh/skills) | 从 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一键安装;也可通过对话创造自定义技能 |
|
||||||
| [工具](https://docs.cowagent.ai/zh/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 |
|
| [工具](https://docs.cowagent.ai/zh/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 |
|
||||||
| [通道](https://docs.cowagent.ai/zh/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 |
|
| [通道](https://docs.cowagent.ai/zh/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 |
|
||||||
@@ -95,14 +104,14 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 |
|
| 厂商 | 代表模型 | 文本 | 图像理解 | 图像生成 | 语音识别 | 语音合成 | 向量 |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [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 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、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 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -191,6 +200,10 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏷 更新日志
|
## 🏷 更新日志
|
||||||
|
|
||||||
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
||||||
|
|
||||||
|
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自进化能力、Web 控制台升级(消息管理、多会话并行)、新模型接入(MiniMax-M3、qwen3.7-plus)、Python 3.13 支持
|
||||||
|
|
||||||
> **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.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.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)、部署安全加固
|
||||||
@@ -250,9 +263,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 进行反馈。
|
||||||
|
|
||||||
## 🌟 贡献者
|
## 🌟 贡献者
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ description: 查看状态、管理配置和上下文等常用命令
|
|||||||
Process: PID 12345 | Running 2h 15m
|
Process: PID 12345 | Running 2h 15m
|
||||||
Version: 2.0.4
|
Version: 2.0.4
|
||||||
Channel: web
|
Channel: web
|
||||||
Model: MiniMax-M2.5
|
Model: MiniMax-M3
|
||||||
Mode: agent
|
Mode: agent
|
||||||
|
|
||||||
Session: 12 messages | 8 skills loaded
|
Session: 12 messages | 8 skills loaded
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ cow status
|
|||||||
Status: ● Running (PID: 12345)
|
Status: ● Running (PID: 12345)
|
||||||
Version: 2.0.4
|
Version: 2.0.4
|
||||||
Channel: web
|
Channel: web
|
||||||
Model: MiniMax-M2.5
|
Model: MiniMax-M3
|
||||||
Mode: agent
|
Mode: agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: 使用脚本一键安装和管理 CowAgent
|
|||||||
|
|
||||||
项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行。
|
项目提供了一键安装、配置、启动、管理程序的脚本,推荐使用脚本快速运行。
|
||||||
|
|
||||||
支持 Linux、macOS、Windows 操作系统,需安装 Python 3.7 ~ 3.12(推荐 3.9)。
|
支持 Linux、macOS、Windows 操作系统,需安装 Python 3.7 ~ 3.13(推荐 3.9)。
|
||||||
|
|
||||||
## 安装命令
|
## 安装命令
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user