Compare commits

..

75 Commits

Author SHA1 Message Date
zhayujie
01373465b0 fix(web): correct Bridge import path in MessageDeleteHandler #2902 2026-06-17 22:04:24 +08:00
zhayujie
3b3ef715bb feat: update 2.1.2 release docs 2026-06-17 19:09:47 +08:00
zhayujie
47b2bf9d46 Merge pull request #2900 from Jr61-star/harness-fix/net-egress-cowagent-web-fetch-no-private-ip-guard
fix(web_fetch): add SSRF guard for model-supplied URLs
2026-06-17 17:47:48 +08:00
christop
ea47f3097e fix(web_fetch): add SSRF guard for model-supplied URLs
web_fetch fetched any http/https URL a model emitted, checking only the
scheme. It performed requests.get(..., allow_redirects=True) with no
hostname resolution check and no private/loopback/link-local/cloud-metadata
filtering, and never re-validated redirect targets. A model (including one
under prompt injection) could make CowAgent fetch 127.0.0.1, RFC1918,
169.254.169.254 or other internal endpoints and return their bodies into
the conversation; a public URL could also 302-bounce into a private target.

The repo already shipped an SSRF validator for the vision tool
(Vision._validate_url_safe). Extract that logic into a shared helper
(agent/tools/utils/url_safety.py) and reuse it:

- execute() now validates the URL before dispatching to either fetch path.
- A new _safe_get() helper disables auto-redirect and follows redirects
  manually, re-validating every hop so a public URL cannot bounce into an
  internal address. Both the webpage and document fetch paths use it.
- Vision._validate_url_safe now delegates to the shared helper (public API
  unchanged), so both URL-consuming tools share one guard.

Stdlib only (ipaddress, socket, urllib.parse); no new dependency. Adds
tests/test_security_ssrf_web_fetch.py covering loopback, cloud-metadata,
RFC1918 and a public->loopback redirect.

Sink: agent/tools/web_fetch/web_fetch.py (_fetch_webpage / _fetch_document).
Signed-off-by: christop <825583681@qq.com>
2026-06-17 17:38:35 +08:00
zhayujie
70c1c44d15 feat: add kimi-k2.7-code and glm-5.2 models
- Add Kimi kimi-k2.7-code (default), kimi-k2.7-code-highspeed, and GLM glm-5.2 (default)
- Fix 400 error when disabling thinking on kimi-k2.7-code; omit the thinking param for this series since it only accepts type=enabled
- Update README, docs (zh/en/ja), install scripts, and Web console model dropdown
2026-06-17 11:54:35 +08:00
zhayujie
e3dce45b2a fix(bash): bypass cmd.exe length limit for long python -c on Windows 2026-06-16 21:11:33 +08:00
zhayujie
3bb8ec3bea feat(web): support manually renaming sessions in console #2897 2026-06-16 20:03:38 +08:00
zhayujie
35e42a3ad6 Merge pull request #2893 from yangziyu-hhh/master
feat(knowledge): add category and document management
2026-06-16 18:52:43 +08:00
zhayujie
949575ad14 Merge pull request #2896 from 6vision/feat/wecom-bot-callback #2869
Feat: wecom bot callback
2026-06-16 17:29:30 +08:00
zhayujie
1c34f0f03d Merge pull request #2892 from HnBigVolibear/master
feat(web): enhance scheduled task management in web console
2026-06-16 17:24:05 +08:00
6vision
eed2eab014 refactor(wecom_bot): use wecom_bot_mode field and clean up temp images
Replace the boolean wecom_bot_callback with a wecom_bot_mode field
("websocket" | "webhook"), consistent with the Feishu channel's
feishu_event_mode. Update startup() and send() to branch on the mode.

Also fix a temp-file leak in _load_image_base64: downloads, format
conversions and compressions wrote to /tmp but were never removed. Track
only the temp files created here and delete them in a finally block,
leaving the caller's original local file untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:23:31 +08:00
zhayujie
381cbed4fd fix(evolution): improve review summary language, tone and length 2026-06-15 22:28:07 +08:00
zhayujie
4cde25325d fix(evolution): pin review summary language via i18n hint 2026-06-15 22:04:52 +08:00
zhayujie
8d70af5e89 refactor(evolution): simplify review summary prompt 2026-06-15 21:43:53 +08:00
zhayujie
b3408d8e5f fix(windows): persist cow CLI dir to user PATH 2026-06-15 20:53:01 +08:00
zhayujie
e74906fbec fix(evolution): skip idle review while a turn is running 2026-06-15 20:33:52 +08:00
6vision
52209217fc refactor(wecom_bot): config-file-only callback mode + fixed callback path
Remove the callback-mode fields (Callback Mode / Token / EncodingAESKey /
Port) from the web console channel form; these are rarely changed and are
now configured via config.json only. The console keeps Bot ID / Secret for
the long-connection setup.

Serve the callback HTTP server on a fixed path (/wecombot) instead of any
path (/.*), so unrelated requests 404 rather than being processed as
signature-failing WeCom callbacks. The bot's receive-message URL must point
at http(s)://host:<port>/wecombot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 19:47:19 +08:00
6vision
018493a60b fix(wecom_bot): revert callback image cap to 512KB (2MB inline body rejected by WeCom)
The 2MB cap (matching the long-connection upload path) does not work for
the callback path: there the whole image is base64-embedded in an
AES-encrypted body returned on every poll. A ~1.5MB image (base64 ~2.1MB,
encrypted ~2.8MB) makes WeCom reject the finish packet and poll forever,
which also surfaces as a truncated text bubble and WeCom's own timeout
error. Cap well below that at 512KB so the finish packet is accepted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 19:22:16 +08:00
6vision
630373b1f0 fix(wecom_bot): defer text finish for image race; align callback image cap to 2MB
Defer the callback stream finish after a text reply so a trailing
image-with-caption send (text first, image 0.3s later) can merge in
instead of closing the stream prematurely. Raise the callback inline
image cap from 512KB to 2MB to match the long-connection upload path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:56:20 +08:00
6vision
dec31dfd75 fix(wecom_bot): shrink callback inline images so finish packets aren't rejected
In callback mode the image is base64-embedded in the stream finish reply and
the whole response is AES-encrypted and returned on every poll. A multi-MB
body is rejected/times out on WeCom's side, leaving the "···" bubble spinning
and the image never shown.

- Compress callback images to <=512KB (JPEG, resize if needed) instead of the
  10MB the protocol nominally allows
- Fall back to the original image if compression fails, and log the final
  base64 payload size for diagnosis

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:26:38 +08:00
zhayujie
2397ea019e feat: config cli support evolution 2026-06-15 17:57:10 +08:00
zhayujie
77da90e316 feat: record self-evolution turn on streaming chat 2026-06-15 17:51:22 +08:00
6vision
18ce17d21a fix(wecom_bot): finalize stream on cancel; never force-finish on a timer
The streaming "···" bubble should only stop when the task actually completes
or the user cancels — not on an arbitrary timeout that would also steal the
response_url fallback's chance to deliver a late answer.

- On agent_cancelled, finalize the stream (finish=true, "🛑 已中止" if empty)
  and schedule the response_url fallback so the bubble clears immediately when
  a run is cancelled, even past the poll window
- Do not force-finish a still-running stream on a timer; let it keep spinning
  until completion or cancel. Answers that finish after WeCom's ~6min poll
  window are delivered via response_url instead

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 17:26:06 +08:00
zhayujie
ce09b14836 feat: sync self-evolution switch and fix scheduler context 2026-06-15 16:30:34 +08:00
yangziyu-hhh
e7a069b060 Merge remote-tracking branch 'upstream/master' 2026-06-15 13:17:12 +08:00
yangziyu-hhh
6e3933be30 feat(knowledge): add category and document management 2026-06-15 13:15:07 +08:00
zhayujie
e2cb9e11b0 fix(install): avoid greenlet source build on Windows & guide browser tool install 2026-06-15 11:50:41 +08:00
zhayujie
d281a34c6f fix(models): demote claude-fable-5 from Claude default 2026-06-14 21:06:41 +08:00
湖南大白熊工作室
c97cf5610f Merge branch 'zhayujie:master' into master 2026-06-14 18:38:45 +08:00
zhayujie
ab674a3517 fix(docs): architecture graph link 2026-06-14 17:22:41 +08:00
zhayujie
7d63e7d8fa feat(cli): add agent self-restart command 2026-06-14 17:20:41 +08:00
zhayujie
6538843bdf fix(memory): remove max_tokens cap in deep dream distillation 2026-06-14 11:06:25 +08:00
HnBigVolibear
bd5fede122 feat(web): enhance scheduled task management in web console. Now we can edit any scheduled tasks in web!
- Add toggle, update and delete APIs for scheduled tasks
- Add task edit modal with schedule/action updates in web console  (PS: In edit box, now I Prevent channel type changes during editing (weixin token bound to session) )
- Add enable/disable switch with visual feedback in task cards
- Sort task list by enabled status first, then by next_run_at

Closes #2882
2026-06-14 02:11:15 +08:00
zhayujie
047fb57630 Merge pull request #2891 from sufan721/feat-add-role-module
- Auto-scan roles/*.json on startup and merge into built-in roles
2026-06-13 18:23:57 +08:00
sufan721
583c1de5ba - Auto-scan roles/*.json on startup and merge into built-in roles
- Same title overrides built-in role; different title appends as new
  - roles/ directory is optional — no impact when absent

  Users can now add custom roles by simply dropping a .json file
  into the roles/ directory and restarting. No config changes needed.
2026-06-13 16:50:54 +08:00
zhayujie
c9c293f67c Merge pull request #2888 from kirs-hi/fix/robustness-cancel-keyerror-compress-loop
fix: avoid KeyError on /cancel and infinite loop in image compression
2026-06-12 18:28:10 +08:00
6vision
561631baba fix(wecom_bot): rescue late replies via response_url + fix degrade notice
Handle agent replies that finish after WeCom stops polling the passive
stream (the poll window is ~6min from the user's message).

- Capture response_url from the message callback and, when a reply is
  finalized but no poll picks it up within a short grace period, push it as
  a one-shot active markdown reply (valid 1h, single use)
- Guard against double delivery via delivered/url_sent flags
- Embed public image URLs in the active markdown; note when a local image
  can't be delivered post-timeout
- Append (instead of discarding) the unsupported-type notice for
  file/voice/video replies so streamed text is preserved
- Quiet the per-poll debug log and log stream completion with content size

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:24:33 +08:00
zhayujie
80d0a6aeb2 Merge pull request #2879 from yangziyu-hhh/master
feat: stream Bash progress and guard message actions during replies
2026-06-12 18:19:13 +08:00
zhayujie
6b5ee245ae feat(web): integrate custom providers into the provider credentials section
- Merge the separate custom-providers section into the unified provider
  grid; "Custom" in the add-provider picker now acts as an add-new action
  (trailing + mark) and opens the dedicated modal, supporting multiple
  OpenAI-compatible endpoints
- Simplify the custom provider modal: drop the default-model field, add
  an inline delete button, align colors with the theme
- Keep the legacy single "custom" card visible (models page, chat
  dropdown and legacy config page) while custom_api_key/custom_api_base
  is still in use, so existing single-provider setups don't disappear
- Unify user-facing wording from "vendor" to "provider" in UI and docs
- Restructure custom provider docs (zh/en/ja) around Web console and
  config file usage
2026-06-12 18:03:33 +08:00
6vision
5c43c2f519 feat(wecom_bot): add callback (webhook) mode alongside long connection
Support receiving WeCom smart-bot messages via encrypted HTTP callback in
addition to the existing WebSocket long connection. Disabled by default
(wecom_bot_callback=false).

- Add wecom_bot_callback / wecom_bot_token / wecom_bot_encoding_aes_key /
  wecom_bot_port config keys
- Add WXBizJsonMsgCrypt-based crypto module for URL verification, callback
  decryption and passive-reply encryption (receive_id empty for internal bots)
- Reply asynchronously via the official stream-refresh polling: register a
  stream id on first reply, accumulate agent output into per-stream state, and
  serve the latest content (text + image) on each poll until finish
- Fall back to EncodingAESKey for media decryption when callback bodies carry
  no per-message aeskey
- Degrade unsupported passive replies (file/voice/video) to a text notice
- Expose the new fields in the Web console channel config

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:26:54 +08:00
yangziyu-hhh
9387980e74 ci: add Windows Bash streaming tests 2026-06-12 14:07:40 +08:00
yangziyu-hhh
075d9fc608 fix: address Bash streaming review feedback 2026-06-12 13:45:39 +08:00
zhayujie
63bfab03f6 Merge pull request #2877 from kirs-hi/feat/custom-providers-ui
feat(web): manage multiple custom (OpenAI-compatible) providers in console UI
2026-06-12 11:54:48 +08:00
zhayujie
1d7e6b3703 fix(web): accept custom:<id> providers in the chat capability card
_set_chat rejected the expanded "custom:<id>" ids with "unknown
provider", so switching to a custom provider was only possible from
the custom providers section. Now the chat card and the custom section
behave consistently: _set_chat validates the id against
custom_providers, falls back to the provider's default model when none
is picked, and _chat_capability expands the dropdown with the
"custom:<id>" entries (legacy single-custom mode unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 11:54:19 +08:00
kirs-hi
ad64e17a34 fix: avoid KeyError on cancel and infinite loop in image compression
Two independent robustness fixes:

1. ChatChannel.cancel_session / cancel_all_session raised KeyError when a
   session existed in self.sessions but no future had been dispatched yet.
   self.sessions[sid] is created in produce(), but self.futures[sid] is only
   created later in consume() on first dispatch. Cancelling in that window
   (e.g. user sends a message then immediately cancels) crashed the cancel
   path. Use self.futures.get(sid, []) so an absent entry is a no-op.

2. compress_imgfile decremented JPEG quality by 5 with no lower bound. For an
   image that cannot be compressed below max_size, quality went 0, negative,
   ... — the loop never terminated and passed invalid quality values to PIL.
   Add a min_quality floor (10) and return the best effort once reached.

Adds tests/test_robustness_fixes.py covering both paths (5 tests).
2026-06-12 11:03:04 +08:00
kirs-hi
0092376c07 fix: address review — no auto-hijack, sync model on activation, cleanup
1. Creating a provider no longer auto-switches bot_type. Only an
   explicit make_active=true changes the active model — prevents
   silently hijacking users on Claude/OpenAI/etc.

2. When a provider IS activated, its 'model' is now written into the
   global 'model' field. This ensures all three paths (regular chat,
   agent_bridge, vision) use the correct model without per-path patches.

3. Removed unused i18n key 'models_custom_name_exists' (no longer
   referenced after the id-based rework removed name-collision checks).

4. Updated tests: 39 passing (added model-sync tests, fixed tests that
   relied on the removed auto-activation behavior).
2026-06-12 10:05:05 +08:00
zhayujie
6fb19a68b5 feat: update default config in run script 2026-06-11 19:30:08 +08:00
zhayujie
d5427d967a fix(installer): fix ASR/TTS default, self-evolution flag, and QuickEdit hang 2026-06-11 19:24:08 +08:00
zhayujie
7fd30b608c fix(cow_cli): fix line breaks in CLI replies 2026-06-11 19:08:30 +08:00
zhayujie
830b05f243 Merge pull request #2886 from kirs-hi/fix/ssrf-and-path-traversal
fix(security): SSRF protection for vision tool + path traversal guard for skill install
2026-06-11 18:24:09 +08:00
kirs-hi
e85290cddc fix(security): SSRF protection for vision tool + path traversal guard for skill install
1. Vision SSRF (#2878, #2872):
   Add _validate_url_safe() that resolves the target hostname via DNS and
   rejects any IP in private (RFC1918), loopback, link-local, or reserved
   ranges before requests.get() is called. This blocks attacks that use
   attacker-controlled image URLs to probe internal services or cloud
   metadata endpoints (169.254.169.254).

2. Skill install path traversal (#2873):
   Add _safe_skill_dir() that validates the skill name cannot escape the
   skills/ root directory. Rejects names containing '..', absolute paths,
   and any resolved path that falls outside the custom_dir boundary.
   Applied to _add_url(), _add_package(), and delete().

Both fixes include comprehensive unit tests (19 test cases) covering
blocked patterns, edge cases, and allowed legitimate usage.

Closes #2878
Closes #2873
Ref: #2872
2026-06-11 17:40:41 +08:00
kirs-hi
1940d628a8 refactor(custom): id-based routing, single source of truth, security fixes
Rework the multi custom-provider design per maintainer review:

1. Data model: use server-generated uuid4 short id as primary key;
   'name' is now a pure display label that can be freely renamed.

2. Routing: drop 'custom_active_provider'; activate a provider by
   setting bot_type to 'custom:<id>'. Single source of truth — no
   pointer drift between bot_type and a separate active selector.

3. Security: drag_sensitive() now recursively masks api_key/secret in
   nested structures (custom_providers list); previously only top-level
   string fields were masked.

4. Per-provider model: the provider's 'model' field now takes effect on
   the main chat path and agent path (was silently ignored before).

5. XSS fix: replace all inline onclick handlers in custom-provider UI
   with data-* attributes + event delegation. Provider names never
   appear in executable HTML contexts.

Legacy compatibility: bot_type='custom' (no colon) still reads the flat
custom_api_key/custom_api_base fields byte-for-byte identically.

Closes: consolidates #2876 into this PR as requested.
Ref: #2838
2026-06-11 17:25:24 +08:00
kirs-hi
cffa590d3e feat(web): manage multiple custom (OpenAI-compatible) providers in console UI
Adds first-class support for configuring more than one custom
OpenAI-compatible provider (e.g. SiliconFlow, DeepSeek, local vLLM)
and switching the active one from the web console, addressing #2838.

Backend:
- config: new `custom_providers` (list) and `custom_active_provider`
  fields, fully backward compatible with the legacy single
  `open_ai_api_base`/`model` fields (used as fallback).
- models/custom_provider.py: centralized resolver
  `resolve_custom_credentials()` returning (api_key, api_base, model),
  with active-provider selection and graceful fallback.
- chat_gpt_bot.py wired to use the resolver.
- web_channel.py: `_provider_overview` expands `custom_providers` into
  one card per provider (id `custom:<name>`, active flag, masked key);
  new POST actions `set_custom_provider`, `delete_custom_provider`,
  `set_active_custom_provider` with hermetic persistence + bridge reset.

Frontend:
- console.js: dedicated "Custom providers" section with add / edit /
  delete / set-active actions, masked-key keep-existing handling, and
  ~20 new zh/en i18n strings.
- chat.html: custom provider modal.

Tests:
- tests/test_custom_provider.py (11) - resolver/config behavior.
- tests/test_custom_provider_handlers.py (18) - write-side handlers and
  overview expansion, including duplicate-name rejection.

All 29 unit tests pass.
2026-06-10 18:12:30 +08:00
yangziyu-hhh
402e2bfee0 feat: stream Bash progress and guard message actions during replies 2026-06-10 14:19:03 +08:00
zhayujie
f5caba81d6 feat(models): support claude-fable-5 2026-06-10 09:39:37 +08:00
zhayujie
354350dec9 fix: hide code block language label when language is undefined 2026-06-09 19:20:15 +08:00
zhayujie
0513298f57 feat(evolution): allow rare persona (AGENT.md) self-evolution 2026-06-09 19:03:49 +08:00
zhayujie
08e23e5bd8 fix(vision): bump vision timeout from 60s to 180s to avoid premature failures 2026-06-09 16:36:01 +08:00
zhayujie
e812c7d29a feat(vision): increase vision tool max_tokens 2026-06-09 16:08:17 +08:00
zhayujie
ef46199346 feat: update run.sh for python3.13 2026-06-09 15:24:32 +08:00
zhayujie
7c9ea62993 chore(evolution): lower trigger thresholds to 6 turns / 10 min idle 2026-06-09 15:22:38 +08:00
zhayujie
8cb53e6129 feat: release 2.1.1 2026-06-09 14:38:05 +08:00
zhayujie
12c0383dc8 docs: update self-evolution docs 2026-06-09 12:07:41 +08:00
zhayujie
83b53039f3 feat: add 2.1.1 release docs 2026-06-09 11:41:32 +08:00
zhayujie
7e6a309935 feat(evolution): default on for new installs, unify naming, add docs 2026-06-09 10:49:43 +08:00
zhayujie
33c03e30d9 fix(web): switch to a sibling session when deleting the active one 2026-06-09 10:49:34 +08:00
zhayujie
1f1abdd7b6 fix(evolution): correct [SILENT] verdict and enable guarded bash for skill creation 2026-06-09 09:29:30 +08:00
zhayujie
16134bd150 fix: update python version in powershell script 2026-06-08 20:19:57 +08:00
zhayujie
c887fc71ad fix: support Python 3.13 by installing web.py from GitHub 2026-06-08 20:15:32 +08:00
zhayujie
9fc39f648f feat(evolution): give review agent full context, add knowledge signal, polish UX 2026-06-08 20:06:01 +08:00
zhayujie
ec9557e3d8 feat(web): resume live streaming when switching back to a session 2026-06-08 17:32:27 +08:00
zhayujie
7cf0f7d42d fix(web): self-heal stuck Cancel send button 2026-06-08 15:48:21 +08:00
zhayujie
b7aa64279d fix(web): support parallel sessions; fix lost/duplicate in-flight replies 2026-06-08 15:36:48 +08:00
zhayujie
26300a8d43 feat(evolution): flag self-evolution bubbles in UI and relax MEMORY.md writes 2026-06-07 21:00:03 +08:00
zhayujie
8dd21ddb83 Merge pull request #2868 from zhayujie/feat-self-evolution
feat(evolution): add self-evolution subsystem
2026-06-07 20:10:50 +08:00
116 changed files with 6958 additions and 603 deletions

32
.github/workflows/test-windows-bash.yml vendored Normal file
View 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

View File

@@ -15,7 +15,7 @@
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
</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.
@@ -36,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 |
| [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 |
| [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 |
| [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 |
@@ -47,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
## 🏗️ 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.
@@ -102,14 +103,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| 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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-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 | ✅ | ✅ | ✅ | | | ✅ |
| [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-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
@@ -198,6 +199,10 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
## 🏷 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.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.

View File

@@ -49,6 +49,16 @@ class ChatService:
agent.model.channel_type = channel_type 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 = _StreamState()
@@ -199,6 +209,8 @@ class ChatService:
logger.info("[ChatService] Cleared agent message history after executor recovery")
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:
@@ -268,10 +280,68 @@ class ChatService:
# 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}")
@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
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
try:

View File

@@ -13,7 +13,7 @@ from typing import Any
# Defaults — conservative (see executor module docstring). Disabled by default
# until release; enable via ``self_evolution_enabled``.
DEFAULT_ENABLED = False
DEFAULT_IDLE_MINUTES = 15
DEFAULT_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.

View File

@@ -19,6 +19,7 @@ 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
@@ -37,8 +38,10 @@ from agent.evolution.prompts import (
from agent.evolution.record import append_session_evolution
# Tools the isolated evolution agent is allowed to use. Everything else is
# withheld so a review pass can only read context and edit memory/skill files.
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"}
# 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.
@@ -159,22 +162,95 @@ class _WorkspaceWriteGuard:
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 tools with the workspace guard; leave others as-is."""
"""Wrap write/edit/bash tools with workspace guards; leave others as-is."""
guarded = []
for t in tools:
if getattr(t, "name", None) in _WRITE_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.
_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output")
# 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:
@@ -195,6 +271,8 @@ def _workspace_snapshot(workspace_dir) -> dict:
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)
@@ -269,8 +347,8 @@ def run_evolution_for_session(
new_messages = all_messages[done:]
transcript = _build_transcript(new_messages)
if not transcript.strip():
logger.info(f"[Evolution] session={session_id}: no new messages, skip")
# Advance the cursor anyway so we don't re-scan the same tail.
# 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
@@ -304,7 +382,11 @@ def run_evolution_for_session(
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
datetime.now().strftime("%Y-%m-%d") + ".md"
)
backup_files = [Path(memory_file), today_daily]
# 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
@@ -332,17 +414,23 @@ def run_evolution_for_session(
str(workspace_dir),
)
review_agent = agent_bridge.create_agent(
system_prompt=EVOLUTION_SYSTEM_PROMPT,
system_prompt="",
tools=review_tools,
description="Self-evolution review agent",
max_steps=cfg.max_steps,
workspace_dir=str(workspace_dir),
skill_manager=getattr(agent, "skill_manager", None),
memory_manager=getattr(agent, "memory_manager", None),
enable_skills=False,
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"
@@ -355,26 +443,40 @@ def run_evolution_for_session(
# only looks at messages added after this point (silent or not).
agent._evo_done_msg_count = total_msgs
if not result or SILENT_TOKEN in result:
# 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
# Hard gate: an evolution only counts (and only notifies) if a workspace
# file ACTUALLY changed. If the model did real work (wrote memory /
# patched a skill / finished a task) the user is told; if it merely
# produced text without changing anything, we stay silent. This is the
# key anti-nag rule — no notification unless something was actually done.
# 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}: model produced text but "
f"changed no file — treating as silent"
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

View File

@@ -7,7 +7,7 @@ language (instructed at the end of the prompt).
Design goals (see ref/hermes-agent background_review for inspiration):
- Default to doing NOTHING. Evolution is the exception, not the rule.
- Three signal types: memory, skill, unfinished task.
- 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.
"""
@@ -52,11 +52,13 @@ them. When their signal is clear, act; do not be shy here.
the relevant skill file under the skills directory and make a small
incremental edit so it never recurs.
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
no existing skill covers and the user is likely to want again. To create
one, follow the `skill-creator` skill's conventions (read its SKILL.md for
the required structure) and write the new skill under the workspace
`skills/` directory. Only create when the workflow is genuinely reusable
not for a one-off task.
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,
@@ -72,25 +74,34 @@ them. When their signal is clear, act; do not be shy here.
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
You only ever notify the user as a side effect of having actually done work.
3. MEMORY — LAST resort, and you are only a SAFETY NET here, not the primary
writer. The main assistant already writes memory DURING the conversation, and
a nightly pass consolidates daily notes into long-term memory. Prefer fixing
a skill (above) over writing memory whenever the fact belongs in a skill.
Act ONLY on something the main assistant clearly MISSED that does not belong
in any skill.
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: writing here is RARE and reserved for
CORRECTING a wrong fact already in MEMORY.md (edit that line in place).
Do NOT append new entries to MEMORY.md — that is the nightly pass's job.
- For a genuinely important NEW durable fact the chat missed, append ONE
short bullet to today's `memory/YYYY-MM-DD.md` (not MEMORY.md). When unsure,
the daily file is the safe place — but first ask whether this really
belongs in a skill instead.
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
@@ -119,16 +130,11 @@ them. When their signal is clear, act; do not be shy here.
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
- Otherwise, after performing the edits, output a short user-facing summary in
the SAME LANGUAGE the user used in the conversation. Tell the user, briefly:
1) that you just did a self-learning pass,
2) what you learned and what you changed (in plain terms — no need to cite
exact file paths; "remembered X" / "improved the weekly-report skill" is
enough).
Keep it to 1-3 lines. Generic shape (do not copy domain words):
"I just did a self-learning pass.
- Learned: <what you learned>
- Changed: <remembered it / improved the <name> skill / finished <task>>
Reply 'undo the last learning' if this is wrong."
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.
"""
@@ -138,9 +144,6 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
``protected_skills`` lists skill names that must never be edited (built-in
skills shipped with the product). Surfaced so the agent avoids them.
"""
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
protected_note = ""
if protected_skills:
names = ", ".join(sorted(protected_skills))
@@ -148,15 +151,19 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
"\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 and act on any clear signal. Prefer fixing a skill at "
"its source over writing memory whenever the fact belongs in a skill.\n"
f"Today is {today}. Only if a fact genuinely belongs in memory (and not "
f"in a skill): append one short bullet to the daily file "
f"`memory/{today}.md` for a new fact, or edit MEMORY.md in place to "
f"correct an existing wrong fact."
"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>"

View File

@@ -73,6 +73,20 @@ def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
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):
@@ -105,6 +119,10 @@ def _scan_once(agent_bridge, cfg) -> None:
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.

View File

@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
import os
import re
import asyncio
import shutil
import threading
from pathlib import Path
from typing import Optional
from typing import Optional, Iterable
from common.log import logger
from config import conf
from agent.memory.config import MemoryConfig
from agent.memory.manager import MemoryManager
class KnowledgeService:
@@ -25,9 +30,189 @@ class KnowledgeService:
Operates directly on the filesystem.
"""
def __init__(self, workspace_root: str):
self.workspace_root = workspace_root
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
PROTECTED_FILES = {"index.md", "log.md"}
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
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
@@ -121,15 +306,8 @@ class KnowledgeService:
:raises ValueError: if path is invalid or escapes knowledge dir
:raises FileNotFoundError: if file does not exist
"""
if not rel_path or ".." in rel_path:
raise ValueError("invalid path")
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):
rel_path, full_path = self._resolve_path(rel_path, kind="document")
if not full_path.is_file():
raise FileNotFoundError(f"file not found: {rel_path}")
with open(full_path, "r", encoding="utf-8") as f:
@@ -228,13 +406,26 @@ class KnowledgeService:
result = self.build_graph()
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:
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:
return {"action": action, "code": 403, "message": str(e), "payload": None}
except FileNotFoundError as e:
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:
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
return {"action": action, "code": 500, "message": str(e), "payload": None}

View File

@@ -124,6 +124,11 @@ def _is_internal_user_marker(text: str) -> bool:
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.
@@ -306,6 +311,10 @@ def _group_into_display_turns(
step["result"] = tr.get("result", "")
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.
@@ -321,6 +330,8 @@ def _group_into_display_turns(
"steps": steps,
"created_at": final_ts or (user_row[1] if user_row else 0),
}
if is_evolution:
turn["kind"] = "evolution"
if merged_extras:
turn["extras"] = merged_extras
turns.append(turn)

View File

@@ -825,9 +825,10 @@ class MemoryStorage:
return []
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:
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
self.conn.commit()
def get_file_hash(self, path: str) -> Optional[str]:

View File

@@ -462,13 +462,12 @@ class MemoryFlushManager:
daily_content=daily_content or "(no recent daily records)",
)
from agent.protocol.models import LLMRequest
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
input_chars = len(memory_content) + len(daily_content)
dream_max_tokens = max(2000, min(input_chars, 8000))
# No output cap: the prompt already keeps MEMORY.md concise (~50
# items), so a hard max_tokens would only risk truncating a large
# rewrite. Let the model use its default output budget.
request = LLMRequest(
messages=[{"role": "user", "content": user_msg}],
temperature=0.3,
max_tokens=dream_max_tokens,
stream=False,
system=_dream_system_prompt(),
)

View File

@@ -52,6 +52,11 @@ class Agent:
self.workspace_dir = workspace_dir # Workspace directory
self.enable_skills = enable_skills # Skills enabled flag
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
self.skill_manager = None
@@ -120,15 +125,20 @@ class Agent:
except Exception:
lang = "zh"
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
return builder.build(
full = builder.build(
tools=self.tools,
context_files=context_files,
skill_manager=self.skill_manager,
memory_manager=self.memory_manager,
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:
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
def refresh_skills(self):

View File

@@ -1174,10 +1174,21 @@ class AgentStreamExecutor:
# Set tool context
tool.model = self.model
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
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
result_dict = {
@@ -1719,4 +1730,4 @@ class AgentStreamExecutor:
not as a message. The AgentLLMModel will handle this.
"""
# Don't add system message here - it will be handled separately by the LLM adapter
return self.messages
return self.messages

View File

@@ -34,6 +34,27 @@ class SkillService:
"""
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
# ------------------------------------------------------------------
@@ -107,7 +128,7 @@ class SkillService:
if not files:
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"
if os.path.exists(tmp_dir):
@@ -146,7 +167,7 @@ class SkillService:
raise ValueError("package url is required")
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:
zip_path = os.path.join(tmp_dir, "package.zip")
@@ -217,7 +238,7 @@ class SkillService:
if not name:
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):
shutil.rmtree(skill_dir)
logger.info(f"[SkillService] delete: removed directory {skill_dir}")

View File

@@ -38,6 +38,16 @@ class BaseTool:
description: str = "Base tool"
params: dict = {} # Store JSON Schema
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
def get_json_schema(cls) -> dict:

View File

@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
import os
import re
import signal
import sys
import subprocess
import tempfile
import threading
import time
from typing import Dict, Any
from agent.tools.base_tool import BaseTool, ToolResult
@@ -19,6 +22,10 @@ class Bash(BaseTool):
"""Tool for executing bash commands"""
_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"
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.
@@ -106,25 +113,35 @@ SAFETY:
else:
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
if self._IS_WIN:
env["PYTHONIOENCODING"] = "utf-8"
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"):
command = f"chcp 65001 >nul 2>&1 && {command}"
result = subprocess.run(
command,
shell=True,
cwd=self.cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
env=env,
)
try:
result = self._run_streaming(
command,
timeout,
env,
dotenv_vars,
)
finally:
if temp_script_path:
try:
os.remove(temp_script_path)
except OSError:
pass
logger.debug(f"[Bash] Exit code: {result.returncode}")
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
@@ -236,6 +253,105 @@ SAFETY:
except Exception as 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:
"""
Get safety warning for absolutely catastrophic commands only.
@@ -293,3 +409,43 @@ SAFETY:
return m.group(0)
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

View File

@@ -182,8 +182,15 @@ class TaskStore:
if enabled_only:
task_list = [t for t in task_list if t.get("enabled", True)]
# Sort by next_run_at
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
# Sort by enabled status (enabled first), then by next_run_at
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

View File

@@ -20,6 +20,11 @@ from .diff import (
FuzzyMatchResult
)
from .url_safety import (
validate_url_safe,
assert_public_ip
)
__all__ = [
'truncate_head',
'truncate_tail',
@@ -36,5 +41,7 @@ __all__ = [
'normalize_for_fuzzy_match',
'fuzzy_find_text',
'generate_diff_string',
'FuzzyMatchResult'
'FuzzyMatchResult',
'validate_url_safe',
'assert_public_ip'
]

View 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])

View File

@@ -26,13 +26,14 @@ from typing import Any, Dict, List, Optional
import requests
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.utils.url_safety import validate_url_safe
from common import const
from common.log import logger
from config import conf
DEFAULT_MODEL = const.GPT_41_MINI
DEFAULT_TIMEOUT = 60
MAX_TOKENS = 1000
DEFAULT_TIMEOUT = 180
MAX_TOKENS = 4000
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
SUPPORTED_EXTENSIONS = {
@@ -654,6 +655,22 @@ class Vision(BaseTool):
return api_base
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:
"""
Build the image_url content block.
@@ -661,6 +678,7 @@ class Vision(BaseTool):
so every bot backend can consume them without extra downloads.
"""
if image.startswith(("http://", "https://")):
self._validate_url_safe(image)
return self._download_to_data_url(image)
if not os.path.isfile(image):

View File

@@ -16,11 +16,15 @@ import requests
from agent.tools.base_tool import BaseTool, ToolResult
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
DEFAULT_TIMEOUT = 30
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 = {
"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"):
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):
return self._fetch_document(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 ----
def _fetch_webpage(self, url: str) -> ToolResult:
"""Fetch and extract readable text from an HTML web page."""
parsed = urlparse(url)
try:
response = requests.get(
url,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
allow_redirects=True,
)
response = self._safe_get(url)
response.raise_for_status()
except requests.Timeout:
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}")
except requests.HTTPError as e:
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:
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}")
try:
response = requests.get(
url,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
stream=True,
allow_redirects=True,
)
response = self._safe_get(url, stream=True)
response.raise_for_status()
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}")
except requests.HTTPError as e:
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:
self._cleanup_file(local_path)
return ToolResult.fail(f"Error: Failed to download file: {e}")

View File

@@ -515,6 +515,24 @@ class AgentBridge:
)
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:
# Use agent's run_stream method with event handler
response = agent.run_stream(
@@ -524,6 +542,13 @@ class AgentBridge:
cancel_event=cancel_event,
)
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
if context and context.get("is_scheduled_task"):
agent.tools = original_tools
@@ -541,7 +566,11 @@ class AgentBridge:
# Persist new messages generated during this run
if session_id:
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:
self._persist_messages(session_id, list(new_messages), channel_type)
else:
@@ -757,6 +786,48 @@ class AgentBridge:
except Exception as 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(
self, session_id: str, new_messages: list, channel_type: str = ""
) -> None:

View File

@@ -524,6 +524,14 @@ class AgentInitializer:
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
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
if tool_name == "env_config":
from agent.tools import EnvConfig

View File

@@ -519,7 +519,10 @@ class ChatChannel(Channel):
def cancel_session(self, session_id):
with self.lock:
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()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
def cancel_all_session(self):
with self.lock:
for session_id in self.sessions:
for future in self.futures[session_id]:
for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:

View File

@@ -285,7 +285,7 @@
</button>
<!-- 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
cursor-pointer transition-colors duration-150" title="Documentation">
<i class="fas fa-book text-base"></i>
@@ -840,6 +840,11 @@
</div>
<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-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">
<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">
@@ -995,6 +1000,14 @@
<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>
</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 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">
@@ -1068,6 +1081,34 @@
</div><!-- /main-content -->
</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 -->
<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
@@ -1165,6 +1206,240 @@
</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>
</body>
</html>

View File

@@ -244,6 +244,52 @@
}
.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 {
display: flex;
@@ -605,6 +651,18 @@
color: inherit;
}
.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-line { display: block; }
@@ -854,6 +912,14 @@
font-size: 11px;
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 {
@@ -1191,6 +1257,34 @@
background: #EDFDF3;
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 {
background: rgba(255,255,255,0.06);
color: #e2e8f0;
@@ -1571,3 +1665,9 @@
.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

View File

@@ -360,6 +360,13 @@ class WebChannel(ChatChannel):
):
logger.debug(f"Polling skipped duplicate file reply for session {session_id}")
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 = {
"type": str(reply.type),
"content": content,
@@ -425,7 +432,15 @@ class WebChannel(ChatChannel):
elif event_type == "tool_execution_start":
tool_name = data.get("tool_name", "tool")
arguments = data.get("arguments", {})
q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments})
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":
tool_name = data.get("tool_name", "tool")
@@ -438,6 +453,7 @@ class WebChannel(ChatChannel):
result_str = result_str[:2000] + ""
q.put({
"type": "tool_end",
"tool_call_id": data.get("tool_call_id"),
"tool": tool_name,
"status": status,
"result": result_str,
@@ -940,7 +956,12 @@ class WebChannel(ChatChannel):
post_done = True
post_deadline = time.time() + 2 # 2s post-attach tail
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):
"""
@@ -1133,7 +1154,11 @@ class WebChannel(ChatChannel):
'/api/knowledge/list', 'KnowledgeListHandler',
'/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
'/api/scheduler/delete', 'SchedulerDeleteHandler',
'/api/sessions', 'SessionsHandler',
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
@@ -1447,15 +1472,17 @@ class ChatHandler:
class ConfigHandler:
_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_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.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.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.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.MIMO_V2_5_PRO, const.MIMO_V2_5,
]
@@ -1494,7 +1521,7 @@ class ConfigHandler:
"api_base_key": "claude_api_base",
"api_base_default": "https://api.anthropic.com/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", {
"label": "Gemini",
@@ -1518,7 +1545,7 @@ class ConfigHandler:
"api_base_key": "zhipu_ai_api_base",
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
"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", {
"label": {"zh": "通义千问", "en": "Qwen"},
@@ -1542,7 +1569,7 @@ class ConfigHandler:
"api_base_key": "moonshot_base_url",
"api_base_default": "https://api.moonshot.cn/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", {
"label": {"zh": "百度千帆", "en": "ERNIE"},
@@ -1586,6 +1613,7 @@ class ConfigHandler:
"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",
"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",
"enable_thinking", "self_evolution_enabled", "web_password",
}
@@ -1627,6 +1655,32 @@ class ConfigHandler:
"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 "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
@@ -2116,13 +2170,99 @@ class ModelsHandler:
def _is_real_key(value: str) -> bool:
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
def _provider_overview(cls) -> List[dict]:
"""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()
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 = []
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")
base_field = p.get("api_base_key")
raw_key = local_config.get(key_field, "") if key_field else ""
@@ -2132,6 +2272,7 @@ class ModelsHandler:
"id": pid,
"label": p["label"],
"configured": configured,
"is_custom": (pid == "custom"),
"api_key_field": key_field,
"api_base_field": base_field,
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
@@ -2140,7 +2281,19 @@ class ModelsHandler:
"api_base_placeholder": p.get("api_base_placeholder") 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
@classmethod
@@ -2148,13 +2301,28 @@ class ModelsHandler:
"""Main chat model — drives the agent. bot_type maps to a provider id."""
bot_type = local_config.get("bot_type") or ""
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"
# 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 {
"editable": True,
"current_provider": provider_id,
"current_model": local_config.get("model", ""),
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
"providers": provider_ids,
"use_linkai": bool(local_config.get("use_linkai", False)),
}
@@ -2588,6 +2756,12 @@ class ModelsHandler:
return self._handle_set_provider(data)
if action == "delete_provider":
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":
return self._handle_set_capability(data)
if action == "set_voice_reply_mode":
@@ -2671,6 +2845,170 @@ class ModelsHandler:
self._reset_bridge()
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:
capability = (data.get("capability") or "").strip()
provider_id = (data.get("provider_id") or "").strip()
@@ -2735,13 +3073,28 @@ class ModelsHandler:
})
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}"})
applied = {}
local_config = conf()
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:
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
local_config["bot_type"] = bot_type_value
@@ -3812,6 +4165,141 @@ class SchedulerHandler:
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:
def GET(self):
_require_auth()
@@ -3988,7 +4476,7 @@ class MessageDeleteHandler:
# 2. Sync agent's in-memory context so its next turn sees the
# same history as the DB. Handled by the agent_bridge helper.
try:
from bridge import Bridge
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}")
@@ -4140,6 +4628,25 @@ class KnowledgeGraphHandler:
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:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')

View File

@@ -12,16 +12,19 @@ import hashlib
import json
import math
import os
import re
import threading
import time
import uuid
import requests
import web
import websocket
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
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 common.expired_dict import ExpiredDict
from common.log import logger
@@ -32,6 +35,9 @@ from config import conf
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
HEARTBEAT_INTERVAL = 30
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:
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
self._pending_lock = threading.Lock()
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()["single_chat_prefix"] = [""]
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
# ------------------------------------------------------------------
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_secret = conf().get("wecom_bot_secret", "")
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
pass
self._ws = None
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
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
def _gen_req_id(self) -> str:
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
# ------------------------------------------------------------------
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
chattype = body.get("chattype", "single")
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:
wecom_msg = WecomBotMessage(body, is_group=is_group)
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
except NotImplementedError as e:
logger.warning(f"[WecomBot] {e}")
return
return None
except Exception as e:
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
return
wecom_msg.req_id = req_id
return None
# File cache logic (same pattern as feishu)
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:
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
logger.info(f"[WecomBot] Image cached for session {session_id}")
return
return None
if wecom_msg.ctype == ContextType.FILE:
wecom_msg.prepare()
file_cache.add(session_id, wecom_msg.content, file_type="file")
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
return
return None
if wecom_msg.ctype == ContextType.TEXT:
cached_files = file_cache.get(session_id)
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
msg=wecom_msg,
no_need_at=True,
)
if context:
if req_id:
context["on_event"] = self._make_stream_callback(req_id)
self.produce(context)
if not context:
return None
return context, wecom_msg
# ------------------------------------------------------------------
# Event callback
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
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"![]({u})" 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
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
if self.mode == "webhook":
self._callback_send(reply, context)
return
msg = context.get("msg")
is_group = context.get("isgroup", False)
receiver = context.get("receiver", "")
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
else:
logger.error("[WecomBot] Failed to get media_id from finish response")
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)

View 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)

View File

@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
class WecomBotMessage(ChatMessage):
"""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)
self.msg_id = msg_body.get("msgid")
self.create_time = msg_body.get("create_time")
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")
from_userid = msg_body.get("from", {}).get("userid", "")
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.IMAGE
image_info = msg_body.get("image", {})
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()
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
elif item_type == "image":
img_info = item.get("image", {})
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")
try:
img_data = _decrypt_media(img_url, img_aeskey)
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.FILE
file_info = msg_body.get("file", {})
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()
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
self.content = base_path
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.FILE
video_info = msg_body.get("video", {})
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()
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")

View File

@@ -1 +1 @@
2.1.0
2.1.1

View File

@@ -3,7 +3,7 @@
import click
from cli import __version__
from cli.commands.skill import skill
from cli.commands.process import start, stop, restart, update, status, logs
from cli.commands.process import start, stop, restart, self_restart, update, status, logs
from cli.commands.context import context
from cli.commands.install import install_browser
from cli.commands.knowledge import knowledge
@@ -68,6 +68,7 @@ main.add_command(skill)
main.add_command(start)
main.add_command(stop)
main.add_command(restart)
main.add_command(self_restart)
main.add_command(update)
main.add_command(status)
main.add_command(logs)

View File

@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
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
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:
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
@@ -155,6 +156,22 @@ def run_install_browser(
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…"))
stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream)

View File

@@ -195,6 +195,120 @@ def restart(ctx, 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.pass_context
def update(ctx):

View File

@@ -172,6 +172,11 @@ class CloudClient(LinkAIClient):
if key in available_setting and config.get(key) is not None:
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
reply_voice_mode = config.get("reply_voice_mode")
if reply_voice_mode:
@@ -341,6 +346,20 @@ class CloudClient(LinkAIClient):
except Exception as 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
# ------------------------------------------------------------------
@@ -855,6 +874,10 @@ def _build_config():
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
"agent_max_steps": local_conf.get("agent_max_steps"),
# 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"),
}

View File

@@ -1,4 +1,4 @@
# 厂商类型
# Provider types
OPEN_AI = "openAI"
OPENAI = "openai"
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
@@ -8,48 +8,49 @@ XUNFEI = "xunfei"
CHATGPTONAZURE = "chatGPTOnAzure"
LINKAI = "linkai"
CLAUDEAPI= "claudeAPI"
QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot)
QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入
QWEN = "qwen" # legacy alias, actually routed to DashscopeBot
QWEN_DASHSCOPE = "dashscope" # Qwen via DashScope
GEMINI = "gemini"
ZHIPU_AI = "zhipu"
MOONSHOT = "moonshot"
MiniMax = "minimax"
DEEPSEEK = "deepseek"
MIMO = "mimo" # 小米 MiMo 大模型
MIMO = "mimo" # Xiaomi MiMo
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
MODELSCOPE = "modelscope"
# 模型列表
# Model list
# Claude (Anthropic)
CLAUDE3 = "claude-3-opus-20240229"
CLAUDE_3_OPUS = "claude-3-opus-latest"
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名称,会不断更新指向最新发布的模型
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
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" # dated name pinned to a specific release
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
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_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
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_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - 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 recommended model
# Gemini (Google)
GEMINI_PRO = "gemini-1.0-pro"
GEMINI_15_flash = "gemini-1.5-flash"
GEMINI_15_PRO = "gemini-1.5-pro"
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型
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" # stable release
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
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_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent推荐模型
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - 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 recommended model
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent recommended model
# OpenAI
GPT35 = "gpt-3.5-turbo"
@@ -85,10 +86,10 @@ TTS_1 = "tts-1"
TTS_1_HD = "tts-1-hd"
# DeepSeek
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用)
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用)
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3 chat model
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1 model
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - default recommendation (thinking + tool calls)
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - stronger on complex tasks (thinking + tool calls)
# Baidu Qianfan / ERNIE
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
@@ -100,30 +101,31 @@ ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K"
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
# Qwen (通义千问 - 阿里云 DashScope)
# Qwen (Alibaba Cloud DashScope)
QWEN_TURBO = "qwen-turbo"
QWEN_PLUS = "qwen-plus"
QWEN_MAX = "qwen-max"
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)
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent recommended model
QWQ_PLUS = "qwq-plus"
# MiniMax
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision)
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
# GLM (智谱AI)
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
GLM_5 = "glm-5" # 智谱 GLM-5
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
# GLM (Zhipu AI)
GLM_5_2 = "glm-5.2" # GLM-5.2 - Agent recommended model (default)
GLM_5_1 = "glm-5.1" # GLM-5.1
GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo
GLM_5 = "glm-5" # GLM-5
GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision)
GLM_4 = "glm-4"
GLM_4_PLUS = "glm-4-plus"
GLM_4_flash = "glm-4-flash"
@@ -132,20 +134,22 @@ GLM_4_ALLTOOLS = "glm-4-alltools"
GLM_4_0520 = "glm-4-0520"
GLM_4_AIR = "glm-4-air"
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)
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_5 = "kimi-k2.5"
KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default)
KIMI_K2_6 = "kimi-k2.6"
# 小米 MiMo
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐)
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频)
# Xiaomi MiMo
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 - multimodal (text/image/audio/video)
MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - multimodal
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
# Doubao (Volcengine Ark)
DOUBAO = "doubao"
@@ -154,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_MINI = "doubao-seed-2-0-mini-260215"
# ModelScope(魔搭社区)
# ModelScope
QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
# 其他模型
# Other models
WEN_XIN = "wenxin"
WEN_XIN_4 = "wenxin-4"
XUNFEI = "xunfei"
@@ -189,11 +193,11 @@ MODEL_LIST = [
# MiniMax
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,
# 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", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
@@ -211,19 +215,19 @@ MODEL_LIST = [
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
O1, O1_MINI,
# GLM (智谱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,
# GLM (Zhipu AI)
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,
# Qwen (通义千问)
# Qwen
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,
# Kimi (Moonshot)
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,
@@ -231,11 +235,12 @@ MODEL_LIST = [
# LinkAI
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
# 其他模型
# Other models
WEN_XIN, WEN_XIN_4, XUNFEI,
]
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
# channel
FEISHU = "feishu"
DINGTALK = "dingtalk"

View File

@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
img = Image.open(file)
rgb_image = img.convert("RGB")
quality = 95
min_quality = 10
while True:
out_buf = io.BytesIO()
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
quality -= 5

View File

@@ -40,5 +40,6 @@
"agent_max_steps": 20,
"enable_thinking": false,
"reasoning_effort": "high",
"knowledge": true
"knowledge": true,
"self_evolution_enabled": true
}

View File

@@ -24,8 +24,11 @@ available_setting = {
"open_ai_api_base": "https://api.openai.com/v1",
"claude_api_base": "https://api.anthropic.com/v1", # claude 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_base": "", # custom OpenAI-compatible provider api base (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"); 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
# 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
@@ -180,6 +183,11 @@ available_setting = {
# WeCom smart bot config (long connection mode)
"wecom_bot_id": "", # WeCom smart bot BotID
"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_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)
@@ -252,8 +260,8 @@ available_setting = {
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
"knowledge": True, # whether to enable the knowledge base feature
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
"self_evolution_enabled": False, # master switch (off until release)
"self_evolution_idle_minutes": 15, # idle time before a session is reviewed
"self_evolution_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
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
@@ -321,24 +329,37 @@ class Config(dict):
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):
try:
if isinstance(config, str):
conf_dict: dict = json.loads(config)
conf_dict_copy = copy.deepcopy(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:]
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
return json.dumps(conf_dict_copy, indent=4)
elif isinstance(config, dict):
config_copy = copy.deepcopy(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
return _mask_sensitive_recursive(config)
except Exception as e:
logger.exception(e)
return config

View File

@@ -38,12 +38,13 @@ services:
DINGTALK_CLIENT_SECRET: ''
WECOM_BOT_ID: ''
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_PASSWORD: ''
AGENT: 'True'
AGENT_MAX_CONTEXT_TOKENS: 50000
AGENT_MAX_CONTEXT_TURNS: 20
AGENT_MAX_STEPS: 20
SELF_EVOLUTION_ENABLED: 'True'
volumes:
- ./cow:/home/agent/cow

View File

@@ -22,6 +22,10 @@
"label": "官网",
"href": "https://cowagent.ai/"
},
{
"label": "博客",
"href": "https://cowagent.ai/zh/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -51,6 +55,10 @@
"label": "Website",
"href": "https://cowagent.ai/"
},
{
"label": "Blog",
"href": "https://cowagent.ai/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -241,6 +249,8 @@
"group": "Release Notes",
"pages": [
"releases/overview",
"releases/v2.1.2",
"releases/v2.1.1",
"releases/v2.1.0",
"releases/v2.0.9",
"releases/v2.0.8",
@@ -266,6 +276,10 @@
"label": "官网",
"href": "https://cowagent.ai/?lang=zh"
},
{
"label": "博客",
"href": "https://cowagent.ai/zh/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -456,6 +470,8 @@
"group": "发布记录",
"pages": [
"zh/releases/overview",
"zh/releases/v2.1.2",
"zh/releases/v2.1.1",
"zh/releases/v2.1.0",
"zh/releases/v2.0.9",
"zh/releases/v2.0.8",
@@ -481,6 +497,10 @@
"label": "ウェブサイト",
"href": "https://cowagent.ai/"
},
{
"label": "ブログ",
"href": "https://cowagent.ai/blog/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
@@ -671,6 +691,8 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
"ja/releases/v2.1.2",
"ja/releases/v2.1.1",
"ja/releases/v2.1.0",
"ja/releases/v2.0.9",
"ja/releases/v2.0.8",

View File

@@ -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.
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

View File

@@ -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:
<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 |
| --- | --- |
| **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 |
| **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 |
| **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 |
@@ -84,4 +85,5 @@ Configure Agent mode parameters in `config.json`:
| `agent_max_steps` | Max decision steps per task | `20` |
| `enable_thinking` | Enable deep-thinking mode | `false` |
| `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` |

View File

@@ -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" />
</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

View File

@@ -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">
Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing.
</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">
A complete skill creation and execution engine. Install from Skill Hub or generate custom skills via natural-language conversation.
</Card>

View File

@@ -15,7 +15,7 @@
[<a href="../../README.md">English</a>] | [<a href="../zh/README.md">中文</a>] | [日本語]
</p>
**CowAgent** は、自律的にタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、パーソナルナレッジベースと長期記憶ユーザーとともに成長するオープンソースのスーパー AI アシスタントです。エンドツーエンドの Agent Harness のリファレンス実装の一つでもあります。
**CowAgent** は、自律的にタスクを計画し、コンピュータや外部リソースを操作し、Skill を作成・実行し、パーソナルナレッジベースと長期記憶を構築し、自己進化によってユーザーとともに成長するオープンソースのスーパー AI アシスタントです。エンドツーエンドの Agent Harness のリファレンス実装の一つでもあります。
CowAgent は軽量でデプロイしやすく、拡張性に優れています。主要な LLM プロバイダーをそのまま組み込み、Web や主要な IM プラットフォーム上で動作。個人 PC やサーバー上で 24 時間 365 日稼働できます。
@@ -36,6 +36,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
| [タスク計画](https://docs.cowagent.ai/ja/intro/architecture) | 複雑なタスクを分解し、目標達成までツールを繰り返し呼び出して段階的に実行 |
| [長期記憶](https://docs.cowagent.ai/ja/memory/index) | 三層構造(コンテキスト → デイリー → コア、Deep Dream による自動蒸留、キーワードとベクトルのハイブリッド検索 |
| [ナレッジベース](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 作成にも対応 |
| [ツール](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 を同時にサポート |
@@ -47,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 を組み合わせてタスクを計画・判断、**モデル**が応答を生成し、結果は元のチャネルに返されます。各レイヤーは疎結合で、独立して拡張可能です。
@@ -102,14 +103,14 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | 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 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-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 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [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-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
@@ -198,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.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、デプロイのセキュリティ強化。

View File

@@ -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を推奨)。
## インストールコマンド

View File

@@ -9,13 +9,14 @@ CowAgent 2.0 は、シンプルなチャットボットから、自律的な思
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** | ユーザーの意図を理解し、複雑なタスクをマルチステップの計画に分解、目標達成までツールを反復的に呼び出す |
| **Memory** | 重要な情報をコアメモリとデイリーメモリとして自動永続化し、キーワードとベクトルのハイブリッド検索でセッション間の連続性を実現 |
| **Knowledge** | トピック別に構造化された知識を整理。Agent が価値ある情報を Markdown ページとして自律的に整理し、インデックスと相互参照で成長するナレッジネットワークを構築 |
| **Evolution** | 会話がアイドルになった後、隔離環境で自動レビューを実行。Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完して、日々の利用を通じて Agent を成長させる |
| **Tools** | Agent が OS リソースにアクセスするための中核能力。ファイル読み書き、ターミナル、ブラウザ、スケジューラ、記憶検索、Web 検索など 10 以上の組み込みツール |
| **Skills** | Skill の読み込み・管理。Skill Hub や GitHub からのワンクリックインストール、または会話を通じたカスタム Skill の作成をサポート |
| **Models** | モデル層。OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen など主要 LLM への統一アクセスを提供 |
@@ -82,4 +83,5 @@ Agent のワークスペースはデフォルトで `~/cow` にあり、シス
| `agent_max_context_turns` | 最大コンテキストターン数 | `30` |
| `agent_max_steps` | タスクあたりの最大判断ステップ数 | `15` |
| `knowledge` | パーソナルナレッジベースの有効化 | `true` |
| `self_evolution_enabled` | 自己進化の有効化(新規インストールではデフォルト有効) | `false` |
| `cow_lang` | UI・コマンド文言・システムプロンプトなどの言語。`auto` で自動検出、`zh` / `en` も指定可 | `auto` |

View File

@@ -15,7 +15,13 @@ description: CowAgent の長期記憶、タスク計画、Skill システム、C
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
</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. パーソナルナレッジベース

View File

@@ -25,6 +25,9 @@ CowAgent は自ら思考しタスクを計画し、コンピュータや外部
<Card title="ナレッジベース" icon="book" href="/ja/knowledge">
構造化された知識を自動整理し、ナレッジグラフの可視化をサポート。相互参照により継続的に成長するナレッジネットワークを構築します。
</Card>
<Card title="自己進化" icon="seedling" href="/ja/memory/self-evolution">
会話の終了後に自動でレビューし、Skill を改善し、未完了のタスクを引き継ぎ、記憶と知識を補完。日々の利用を通じて Agent が成長し続けます。
</Card>
<Card title="Skill システム" icon="puzzle-piece" href="/ja/skills/index">
Skill の作成・実行エンジンを実装し、組み込み Skill を搭載。自然言語の会話を通じてカスタム Skill の開発もサポートしています。
</Card>

View File

@@ -29,19 +29,19 @@ description: Self-Evolution — 会話がアイドル状態になった後に振
自律進化は定時実行ではなく、**会話が自然に終わってアイドル状態になった後**にのみ起動するため、進行中のやり取りを妨げることはありません。次の 2 つの条件を同時に満たす必要があります:
- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 15 分)以上が経過している
- **会話がアイドル状態**:最後のやり取りから、設定したアイドル時間(デフォルトは 10 分)以上が経過している
- **振り返るだけの内容がある**:前回の進化から十分なターン数が蓄積されている、またはコンテキストが容量の上限に近づいている
両方の条件を満たしたときにのみ振り返りが始まります。これにより、振り返る価値のある内容を確保しつつ、会話の途中で邪魔をしないようにしています。
### 関連設定
自律進化はデフォルトでは無効です。Web コンソールの「設定 → Agent 設定」(「ディープシンキング」の下)にあるスイッチで有効にできるほか、設定ファイルで調整することもできます:
自律進化は Web コンソールの「設定 → Agent 設定」(「ディープシンキング」の下)にあるスイッチで切り替えられるほか、設定ファイルで調整することもできます:
| パラメータ | 説明 | デフォルト値 |
| --- | --- | --- |
| `self_evolution_enabled` | 自律進化を有効にするかどうか | `false` |
| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `15` |
| `self_evolution_enabled` | 自律進化を有効にするかどうか(新規インストールはデフォルトで有効) | `false` |
| `self_evolution_idle_minutes` | 会話がアイドル状態になってからトリガーするまでの時間(分) | `10` |
| `self_evolution_min_turns` | トリガーに必要な最小会話ターン数 | `6` |
<Tip>

View File

@@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
```json
{
"model": "claude-opus-4-8",
"model": "claude-fable-5",
"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_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
@@ -28,8 +28,9 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
| モデル | 用途 |
| --- | --- |
| `claude-opus-4-8` | デフォルト推奨。最新フラッグシップ。複雑な推論や長いタスクチェーンに最適 |
| `claude-opus-4-7` | 前世代の Opus フラッグシップ |
| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め |
| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い |
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |

View File

@@ -1,51 +1,21 @@
---
title: カスタム
description: カスタムベンダー設定。サードパーティ API プロキシやローカルモデル向け
description: サードパーティ API プロキシやローカルモデル向けのカスタムプロバイダー設定
---
OpenAI 互換プロトコルで接続するサードパーティのモデルサービスや、ローカルにデプロイしたモデルに適しています。例えば:
OpenAI 互換プロトコルで接続するモデルサービス向けの設定です。例えば:
- **サードパーティ API プロキシ**:統一された API Base から複数のモデルを呼び出す
- **ローカルモデル**Ollama、vLLM、LocalAI などのツールでローカルにデプロイしたモデル
- **プライベートデプロイ**:企業内部にデプロイたモデルサービス
- **サードパーティ API プロキシ**:統一された API アドレスで複数のモデルを呼び出す
- **ローカルモデル**Ollama、vLLM などのツールでローカルにデプロイしたモデル
- **プライベートデプロイ**:企業内部にデプロイされたモデルサービス
<Note>
`openai` ベンダーとの違い:カスタムベンダーを選択した場合、`/config model` でモデルを切り替えてもベンダータイプは自動で切り替わらず、常にカスタムの API アドレスを使用します。
</Note>
## Web コンソールでの設定
## テキスト対話
推奨方法です。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 |
| --- | --- |
@@ -53,10 +23,40 @@ OpenAI 互換プロトコルで接続するサードパーティのモデルサ
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/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>

View File

@@ -13,20 +13,20 @@ Zhipu AI はテキスト対話、画像理解、音声認識ASR、ベク
```json
{
"model": "glm-5.1",
"model": "glm-5.2",
"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_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 ツールは自動的にこのモデルを使用するため、設定ファイルで明示的に指定する必要はありません。
## 音声認識

View File

@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
<Note>
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨しますdeepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.1、qwen3.7-plus、kimi-k2.6、ernie-5.1。
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 で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
</Note>
@@ -20,13 +20,13 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [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 | ✅ | ✅ | ✅ | | | |
| [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-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [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 | ✅ | ✅ | | | | |
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |

View File

@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
```json
{
"model": "kimi-k2.6",
"model": "kimi-k2.7-code",
"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_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |

View File

@@ -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.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 と百度モデルの追加、スケジュールタスクツールの強化 |

View 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)

View 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)

View File

@@ -9,8 +9,16 @@ description: Self-Evolution — review a conversation after it goes idle to cons
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:
@@ -18,7 +26,7 @@ Self-Evolution focuses on three things:
| Goal | Description |
| --- | --- |
| **Consolidate memory** | Record important preferences, decisions, and facts from the conversation, filling in what the main chat may have missed |
| **Improve skills** | When a skill shows a problem in use (such as a wrong setting or a missing step), fix the skill file directly instead of just noting it; create a new skill when one is genuinely needed |
| **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.
@@ -29,21 +37,25 @@ Once a review is done, if it actually changed something, the Agent tells you in
Self-Evolution does not run on a fixed schedule. It only kicks in **after a conversation naturally ends and goes idle**, so it never interrupts an ongoing exchange. Two conditions must both hold:
- **The conversation is idle**: more time has passed since the last interaction than the configured idle window (15 minutes by default)
- **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
Self-Evolution is off by default. You can turn it on with the toggle in the Web console under **Settings → Agent Config** (below "Deep Thinking"), or adjust it in the config file:
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 | `false` |
| `self_evolution_idle_minutes` | How long the conversation must be idle before it triggers (minutes) | `15` |
| `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>
@@ -52,6 +64,10 @@ Self-Evolution is off by default. You can turn it on with the toggle in the Web
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.

View File

@@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand
```json
{
"model": "claude-opus-4-8",
"model": "claude-fable-5",
"claude_api_key": "YOUR_API_KEY"
}
```
| 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_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 |
| --- | --- |
| `claude-opus-4-8` | Default recommended, latest flagship; best for complex reasoning and long-running tasks |
| `claude-opus-4-7` | Previous-generation Opus flagship |
| `claude-fable-5` | Latest flagship; best for complex reasoning and long-running tasks, at a higher price |
| `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-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |

View File

@@ -1,51 +1,21 @@
---
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
- **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
<Note>
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>
## Web Console
## 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
{
"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:
Default endpoints of common local deployment tools:
| 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` |
| [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>

View File

@@ -3,7 +3,7 @@ title: DeepSeek
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

View File

@@ -13,20 +13,20 @@ Zhipu AI supports text chat, image understanding, speech-to-text (ASR), and embe
```json
{
"model": "glm-5.1",
"model": "glm-5.2",
"zhipu_ai_api_key": "YOUR_API_KEY"
}
```
| 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_base` | Optional, defaults to `https://open.bigmodel.cn/api/paas/v4` |
## 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)

View File

@@ -1,32 +1,32 @@
---
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
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 | ✅ | | | | | |
| [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 | ✅ | ✅ | ✅ | | | |
| [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-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [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 | ✅ | ✅ | | | | |
| [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 | ✅ | | | | | |
<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>
## 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" />
**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`.

View File

@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
```json
{
"model": "kimi-k2.6",
"model": "kimi-k2.7-code",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| 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_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |

View File

@@ -3,7 +3,7 @@ title: LinkAI
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>
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.

View File

@@ -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:
- 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:

View File

@@ -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 |
| `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 |
| `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

View File

@@ -3,7 +3,7 @@ title: Qwen
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>
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.

View File

@@ -5,6 +5,8 @@ description: CowAgent version history
| 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.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 |

View File

@@ -34,7 +34,7 @@ Related commits: [30c6d9b](https://github.com/zhayujie/CowAgent/commit/30c6d9b)
## 💰 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.

View File

@@ -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
- **GLM 5.1**: Added `glm-5.1` model support
- **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

63
docs/releases/v2.1.1.mdx Normal file
View 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
View 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)

View File

@@ -15,7 +15,7 @@
[<a href="../../README.md">English</a>] | [中文] | [<a href="../ja/README.md">日本語</a>]
</p>
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆与你一同成长,是 Agent Harness 工程的最佳实践之一。
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆、通过自主进化与你一同成长,是 Agent Harness 工程的最佳实践之一。
CowAgent 轻量、易部署、可扩展自由接入主流大模型覆盖微信、飞书、钉钉、企微、QQ、Telegram、Slack、网页等多渠道7×24 运行于个人电脑或服务器中。
@@ -36,6 +36,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖
| [任务规划](https://docs.cowagent.ai/zh/intro/architecture) | 理解复杂任务并自主分解执行,循环调用工具直到完成目标 |
| [长期记忆](https://docs.cowagent.ai/zh/memory) | 三层记忆架构(上下文 → 天级 → 核心),梦境蒸馏自动整理,支持关键词与向量混合检索 |
| [知识库](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/tools) | 内置文件读写、终端、浏览器、定时任务、记忆检索、联网搜索等 10+ 工具,支持 MCP 协议 |
| [通道](https://docs.cowagent.ai/zh/channels) | 一个 Agent 同时接入 Web、微信、飞书、钉钉、企微、QQ、公众号、Telegram、Slack 等多个渠道 |
@@ -104,13 +105,13 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [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 | ✅ | ✅ | ✅ | | | |
| [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-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 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 | ✅ | ✅ | | | | |
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -199,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.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、部署安全加固

View File

@@ -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)。
## 安装命令

View File

@@ -16,6 +16,7 @@ CowAgent 的整体架构由以下核心模块组成:
| **Plan** | 理解用户意图,将复杂任务分解为多步骤计划,循环调用工具直到完成目标 |
| **Memory** | 自动将重要信息持久化为核心记忆和日级记忆,支持关键词和向量混合检索,跨会话保持上下文连续性 |
| **Knowledge** | 以主题维度组织结构化知识Agent 自主整理有价值信息为 Markdown 页面,维护索引和交叉引用,构建持续增长的知识网络 |
| **Evolution** | 对话空闲后在隔离环境中自动复盘,优化技能、处理未完成事项、补全记忆与知识,让 Agent 在使用中持续成长 |
| **Tools** | Agent 访问操作系统资源的核心能力,内置文件读写、终端执行、浏览器操作、定时调度、记忆检索、联网搜索等 10+ 种工具 |
| **Skills** | 加载和管理 Skills支持从 Skill Hub、GitHub 等一键安装,或通过对话创建自定义技能 |
| **Models** | 模型层,统一接入 OpenAI、Claude、Gemini、DeepSeek、MiniMax、GLM、Qwen 等国内外主流大语言模型 |
@@ -84,4 +85,5 @@ Agent 的工作空间默认位于 `~/cow` 目录,用于存储系统提示词
| `agent_max_steps` | 单次任务最大决策步数 | `20` |
| `enable_thinking` | 是否启用深度思考模式 | `false` |
| `knowledge` | 是否启用个人知识库 | `true` |
| `self_evolution_enabled` | 是否启用自主进化 | `false` |
| `cow_lang` | 界面、命令文案、系统提示词等的语言,`auto` 自动检测,可设为 `zh` / `en` | `auto` |

View File

@@ -15,7 +15,13 @@ description: CowAgent 长期记忆、个人知识库、任务规划、技能系
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
</Frame>
详细说明请参考 [长期记忆](/zh/memory) 和 [梦境蒸馏](/zh/memory/deep-dream)
在此基础上,**自主进化Self-Evolution** 让 Agent 在使用中持续成长:对话空闲后自动复盘,优化技能、处理遗留任务、补全记忆与知识,仅在确有改动时简短告知,且每次改动可随时撤销
<Frame>
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-demo-zh.png" width="800" />
</Frame>
详细说明请参考 [长期记忆](/zh/memory)、[梦境蒸馏](/zh/memory/deep-dream) 和 [自主进化](/zh/memory/self-evolution)。
## 2. 个人知识库

View File

@@ -32,6 +32,9 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
<Card title="个人知识库" icon="book" href="/zh/knowledge">
自动整理结构化知识,支持知识图谱可视化,通过交叉引用构建持续增长的知识网络。
</Card>
<Card title="自主进化" icon="seedling" href="/zh/memory/self-evolution">
对话结束后自动复盘,优化技能、处理遗留任务、沉淀记忆与知识,让 Agent 在使用中持续成长。
</Card>
<Card title="技能系统" icon="puzzle-piece" href="/zh/skills/index">
实现了Skills创建和运行的引擎内置多种技能并支持通过自然语言对话完成自定义Skills开发。
</Card>

View File

@@ -1,25 +1,34 @@
---
title: 自主进化
description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项
description: Self-Evolution自动复盘,沉淀记忆、优化技能、处理未完成事项
---
## 功能介绍
### 简介
自主进化Self-Evolution让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把值得记住的沉淀为长期记忆、把使用中暴露的问题修进技能、把没做完的事情接着推进。久而久之Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,只有真正做了事情时才会简短地告诉你。
自主进化Self-Evolution让 Agent 不止于"完成单次任务",而是能在与你的相处中持续成长。每段对话告一段落后,它会自动"回头复盘"一次:把使用中暴露的问题修进技能、把没做完的事情接着推进,并把值得记住的沉淀进记忆与知识库。久而久之Agent 会越来越懂你的偏好、越来越少重复犯错、越来越主动地把事情收尾,而这一切都在后台静默完成,真正做了事情时才会主动地告诉你。
<Note>
想了解自进化机制完整的架构设计与工程实现,可阅读博客文章:[让 Agent 在对话中成长:自进化机制的五层实现](https://cowagent.ai/zh/blog/self-evolution/)。
</Note>
> 它与[梦境蒸馏](/zh/memory/deep-dream)互补:梦境蒸馏负责整理记忆本身,自主进化则在记忆之外,进一步优化技能、推进未完成的任务,让 Agent 的能力随使用不断打磨。
### 三个目标
<Frame>
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-demo-zh.png" alt="自主进化对话示例" />
</Frame>
自主进化围绕三件事工作:
### 几个目标
自主进化围绕以下几件事工作,并以「优化技能、处理未完成事项」为主,「沉淀记忆、知识」作为主对话的查缺补漏:
| 目标 | 说明 |
| --- | --- |
| **沉淀记忆** | 把对话中重要的偏好、决策、事实补记到记忆中,作为主对话的查缺补漏 |
| **优化技能** | 当某个技能在使用中暴露出问题(如配置错误、步骤缺失),直接修正技能文件,而不只是记一笔;也可在需要时创建新技能 |
| **优化技能** | ① 技能在使用中暴露问题(如配置错误、步骤缺失)时,直接修正技能文件;② 出现一套可复用的流程时,主动固化为新技能,下次直接调用 |
| **处理未完成事项** | 识别对话中遗留的待办,在能完成时直接完成 |
| **沉淀记忆** | 把对话中重要的偏好、决策、事实补记到记忆中,作为主对话的查缺补漏 |
| **沉淀知识** | 把对话中产生的、值得日后查阅的可复用知识补充进知识库(主对话遗漏时) |
复盘完成后如果确实做了改动Agent 会在对话中用一句话告诉你"刚刚自主学习了什么、调整了哪里",方便你判断是否需要回滚。
@@ -29,21 +38,25 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优
自主进化不是定时执行,而是在**一段对话自然结束、进入空闲后**才触发,避免打断正在进行的交流。需要同时满足:
- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 15 分钟)
- **对话有足够内容** — 自上次进化以来累积了足够轮次,或上下文已接近容量上限
- **对话已空闲** — 距离最后一次互动超过设定的空闲时长(默认 10 分钟)
- **对话有足够内容** — 自上次进化以来累积了足够轮次(默认 6 轮),或上下文已接近容量上限
只有两个条件都满足,才会启动一次复盘。这样既保证有足够的内容值得复盘,又不会在你还在对话时打扰你。
### 相关配置
自主进化默认关闭,可在 Web 控制台「配置 → Agent 配置」中通过开关启(位于"深度思考"下方),也可在配置文件中调整:
自主进化可在 Web 控制台「配置 → Agent 配置」中通过开关启(位于"深度思考"下方),也可在配置文件中调整:
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `self_evolution_enabled` | 是否启用自主进化 | `false` |
| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `15` |
| `self_evolution_idle_minutes` | 对话空闲多久后触发(分钟) | `10` |
| `self_evolution_min_turns` | 触发所需的最少对话轮次 | `6` |
<Frame>
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-config-zh.png" alt="在 Web 控制台开启自主进化" />
</Frame>
<Tip>
Web 控制台只提供启用开关,若需调整空闲时长或轮次阈值,请编辑配置文件。修改后即时生效,无需重启。
</Tip>
@@ -52,6 +65,10 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优
每次进化的过程和结果会按日期记录在 `memory/evolution/YYYY-MM-DD.md` 中,可在 Web 控制台的「记忆管理 → 自主进化」tab 中查看。该 tab 同时汇总了自主进化记录与梦境日记,方便统一回顾 Agent 的成长轨迹。
<Frame>
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-logs-zh.png" alt="自主进化记录列表" />
</Frame>
### 如何回滚
如果你不认同某次进化的改动,直接在对话中告诉 Agent "把刚才的改动撤销"即可,它会根据进化前的备份还原相关文件。每次进化的改动都有独立备份,互不影响。
@@ -73,6 +90,6 @@ description: Self-Evolution — 会话空闲后自动复盘,沉淀记忆、优
| **没做事不通知** | 如果复盘后没有任何实际改动,全程静默,不产生任何通知 |
| **空闲才触发** | 仅在对话空闲后运行,绝不打断正在进行的对话 |
| **改动可回滚** | 每次进化前自动备份,若对结果不满意,可一键撤销本次改动 |
| **保护内置技能** | 产品自带的内置技能受保护,进化过程不会改动 |
| **保护内置技能** | 项目自带的内置技能受保护,进化过程不会改动 |
| **限定工作空间** | 所有读写都限定在工作空间内,不会触碰系统其他文件 |
| **后台异步** | 复盘在后台进行,不阻塞正常对话回复 |

View File

@@ -13,14 +13,14 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
```json
{
"model": "claude-opus-4-8",
"model": "claude-fable-5",
"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_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
@@ -28,8 +28,9 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
| 模型 | 适用场景 |
| --- | --- |
| `claude-opus-4-8` | 默认推荐,最新旗舰,复杂推理与长链路任务效果最佳 |
| `claude-opus-4-7` | 上一代 Opus 旗舰 |
| `claude-fable-5` | 最新旗舰,复杂推理与长链路任务效果最佳,价格较高 |
| `claude-opus-4-8` | 上一代 Opus 旗舰,综合表现与成本均衡 |
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 |

View File

@@ -1,51 +1,21 @@
---
title: 自定义
description: 自定义厂商配置,用于第三方 API 代理本地模型
description: 自定义厂商配置,用于第三方 API 代理本地模型
---
适用于通过 OpenAI 兼容协议接入的第三方模型服务或本地部署的模型,例如:
适用于通过 OpenAI 兼容协议接入的模型服务,例如:
- **第三方 API 代理**使用统一的 API Base 调用多种模型
- **本地模型**通过 Ollama、vLLM、LocalAI 等工具在本地部署的模型
- **第三方 API 代理**通过统一的 API 地址调用多种模型
- **本地模型**使用 Ollama、vLLM 等工具在本地部署的模型
- **私有化部署**:企业内部部署的模型服务
<Note>
与 `openai` 厂商的区别:选择自定义厂商后,通过 `/config model` 切换模型时,不会自动切换厂商类型,始终使用自定义的 API 地址。
</Note>
## Web 端配置
## 文本对话
推荐方式。在 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 |
| --- | --- |
@@ -53,10 +23,40 @@ description: 自定义厂商配置,适用于第三方 API 代理和本地模
| [vLLM](https://docs.vllm.ai) | `http://localhost:8000/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 位十六进制),在 Web 端添加时自动生成,手动配置时填写任意不重复的标识即可 |
| `name` | 展示名称,可随意修改 |
| `model` | 该厂商使用的模型,激活时生效 |
<Note>
历史的单厂商配置(`bot_type` 为 `"custom"`,配合 `custom_api_key` / `custom_api_base`)仍然兼容,无需改动即可继续使用。
</Note>

View File

@@ -13,20 +13,20 @@ description: 智谱 AI GLM 模型配置(文本 / 图像理解 / 语音识别 /
```json
{
"model": "glm-5.1",
"model": "glm-5.2",
"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` | 在 [智谱 AI 控制台](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 创建 |
| `zhipu_ai_api_base` | 可选,默认为 `https://open.bigmodel.cn/api/paas/v4` |
## 图像理解
智谱 chat 系列模型(`glm-5.1`、`glm-5-turbo` 等)不支持视觉,视觉调用统一路由到 `glm-5v-turbo`。配置 `zhipu_ai_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。
智谱 chat 系列模型(`glm-5.2`、`glm-5.1`、`glm-5-turbo` 等)不支持视觉,视觉调用统一路由到 `glm-5v-turbo`。配置 `zhipu_ai_api_key` 后 Agent 的 Vision 工具会自动使用该模型,无需在配置文件中显式指定。
## 语音识别

View File

@@ -14,13 +14,13 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](/zh/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
| [Claude](/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智谱 GLM](/zh/models/glm) | glm-5.1、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](/zh/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
```json
{
"model": "kimi-k2.6",
"model": "kimi-k2.7-code",
"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_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |

View File

@@ -5,6 +5,8 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |
| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级流式输出、命令模糊匹配、任务取消、MCP Streamable HTTP、新模型接入 |
| [2.0.9](/zh/releases/v2.0.9) | 2026.05.22 | 新增模型管理、MCP 协议支持、浏览器登录态持久化、新模型接入gpt-5.5、gemini-3.5-flash、qwen3.7-max 等)、部署安全加固 |
| [2.0.8](/zh/releases/v2.0.8) | 2026.05.06 | 飞书渠道全面升级语音、流式输出和Markdown、扫码一键接入、DeepSeek V4和百度模型新增、定时任务工具增强 |

View File

@@ -0,0 +1,63 @@
---
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 不止于完成单次任务,而是在与你的日常协作中持续成长:
- **空闲后自动复盘**:对话空闲后自动复盘,修正技能在使用中暴露的问题、创建可复用的新技能,处理遗留的未完成事项,并将重要信息补充进记忆与知识库
- **静默执行、按需提醒**:仅在实际有改动时主动告知本次调整内容,无变更时全程静默
- **安全可回退**:每次复盘前自动备份,可随时撤销本次改动;内置技能受保护,所有读写均限定在工作空间内
新安装用户默认开启,已有用户可在 Web 控制台 **设置 → Agent 配置** 中一键开启。
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-evolution-demo-zh.png" alt="自主进化对话示例" />
相关文档:[自主进化](https://docs.cowagent.ai/zh/memory/self-evolution)
## 💬 Web 控制台升级
Web 控制台的聊天体验进一步增强:
- **消息管理**:用户与机器人的消息均支持编辑、删除、重新生成;代码块新增语言标签和一键复制按钮
- **多会话并行**:支持多个会话同时进行、互不干扰,切回会话时自动恢复实时流式输出
- **细节优化**:支持将文件拖拽到整个聊天区域;删除当前会话后自动切换到相邻会话
Thanks @core-power (#2865)
## 🧩 MCP 跨平台增强
- **Windows 兼容修复**:修复 MCP 在 Windows 下 `stdio` 通信不可用的问题,并支持通过 `mcp.json` 配置服务超时时间
- **并发调用支持**`sse` 与 `streamable-http` 传输支持跨会话并发调用,多工具响应更快
Thanks @xliu123321 (#2859)
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
## 🤖 模型新增与优化
- **MiniMax-M3**:新增并设为默认模型,保留 M2.7 系列作为可选项。Thanks @octo-patch (#2855)
- **通义千问 qwen3.7-plus**:支持多模态对话
- **语音识别模型可选**Web 控制台支持选择 ASR语音识别模型并持久化保存。Thanks @nightwhite (#2857)
- **安装菜单简化**:一键安装脚本精简模型选择菜单,新增小米 MiMo 选项
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
## 🛠 体验优化与修复
- **Python 3.13 支持**:修复在 Python 3.13 环境下的安装与依赖兼容问题
- **国际化体验**:通道列表按界面语言排序展示;优化 `auto` 模式下的语言自动回退逻辑
- **任务取消更可靠**:修复部分场景下流式回复无法中断的问题
- **CLI 增强**`cow status` 新增显示当前项目路径
- **部署安全加固**:凭证文件拦截范围收敛至 `~/.cow/.env`不再误拦其他目录Thanks @orbisai0security #2863微信公众号在 `wechatmp_token` 为空时拒绝 Webhook 请求
- **群任务看板插件**新增群聊任务看板插件源。Thanks @Wyh-max-star (#2853)
## 📦 升级方式
源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
**发布日期**2026.06.09 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.0...2.1.1)

View File

@@ -0,0 +1,67 @@
---
title: v2.1.2
description: CowAgent 2.1.2Web 控制台多项管理能力升级、自主进化体验优化、模型新增支持、企业微信智能机器人回调模式、安全加固和体验优化
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.2) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.2)
## 💬 Web 控制台优化
本次 Web 控制台围绕「可视化管理」做了多项增强,让更多配置无需改文件即可在界面完成:
- **定时任务管理**:支持在控制台直接查看、编辑、启用/停用、删除任意定时任务任务列表按启用状态与下次执行时间排序。Thanks @HnBigVolibear (#2892)
- **知识库分类与文档管理**知识库支持按分类组织可在界面管理分类下的文档。Thanks @yangziyu-hhh (#2893)
- **多自定义模型厂商**:支持配置多个 OpenAI 兼容供应商并一键切换当前生效项且兼容已有配置。Thanks @kirs-hi (#2877)
- **会话重命名**:支持手动重命名会话,便于区分多个并行任务 (#2897)
- **Bash 流式输出**:长时间运行的 Bash 命令支持流式实时输出进度。Thanks @yangziyu-hhh (#2879)
## 🧬 自主进化体验优化
延续上个版本的自主进化能力,本次迭代进一步优化:
- **触发阈值下调**:默认复盘触发阈值下调,更快触发复盘、沉淀经验
- **避免并发冲突**:单轮任务耗时较长时不再误触发空闲复盘,避免与主对话相互干扰
- **自进化摘要优化**:优化摘要生成提示词,精简篇幅、提升信息密度,并跟随对话语言输出
相关文档:[自主进化](https://docs.cowagent.ai/zh/memory/self-evolution)
## 🤖 模型新增
- **kimi-k2.7-code**:新增并设为 Kimi 默认模型,同时提供 `kimi-k2.7-code-highspeed` 高速版
- **glm-5.2**:新增并设为 GLM 默认模型
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
## 🏢 企业微信智能机器人回调模式
企业微信智能机器人通道在原有长连接的基础上,新增 **HTTP 回调模式**,让无法保持长连接的部署环境也能稳定接入:
- **模式切换**:通过 `wecom_bot_mode` 在 `websocket`(长连接)与 `webhook`(回调)之间切换
- **加密传输**:回调模式完整支持 URL 验签、消息解密与被动回复加密
- **稳定性优化**:修复回复中断、流式提前结束、临时图片文件泄漏等问题
Thanks @6vision (#2896 #2869)
相关文档:[企业微信智能机器人](https://docs.cowagent.ai/zh/channels/wecom-bot)
## 🔒 安全加固
- **视觉工具 SSRF 防护**:解析图片 URL 前校验目标地址拦截指向内网、回环及云服务器元数据接口的请求。Thanks @kirs-hi (#2886)
- **网页抓取 SSRF 防护**`web_fetch` 抓取前校验目标地址并逐跳校验重定向目标防止通过跳转绕过校验。Thanks @christop (#2900)
- **技能安装路径穿越防护**:安装技能时校验路径,防止恶意技能名通过路径穿越逃逸 `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 默认值、自主进化开关及安装过程卡顿等问题
- **视觉工具稳定性**:提升视觉工具的超时时间与 max_tokens
- **记忆蒸馏优化**:深度梦境蒸馏移除输出长度限制,避免大体量 `MEMORY.md` 被截断
## 📦 升级方式
源码部署可执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
**发布日期**2026.06.18 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.1...2.1.2)

View File

@@ -29,7 +29,7 @@ def create_bot(bot_type):
from models.mimo.mimo_bot import MimoBot
return MimoBot()
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API
elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM) or bot_type.startswith("custom:"): # OpenAI-compatible API
from models.chatgpt.chat_gpt_bot import ChatGPTBot
return ChatGPTBot()

View File

@@ -17,6 +17,7 @@ from common import const
from common.i18n import t as _t
from models.bot import Bot
from models.openai_compatible_bot import OpenAICompatibleBot
from models.custom_provider import resolve_custom_credentials, parse_custom_bot_type
from models.chatgpt.chat_gpt_session import ChatGPTSession
from models.openai.open_ai_image import OpenAIImage
from models.session_manager import SessionManager
@@ -32,9 +33,12 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
def __init__(self):
super().__init__()
# Resolve api key / base from config (no global SDK state anymore).
if conf().get("bot_type") == "custom":
self._api_key = conf().get("custom_api_key", "")
self._api_base = conf().get("custom_api_base") or None
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
custom_model = None
if is_custom:
# Supports multiple custom providers via bot_type "custom:<id>"
# with automatic fallback to the legacy custom_api_key/base fields.
self._api_key, self._api_base, custom_model = resolve_custom_credentials()
else:
self._api_key = conf().get("open_ai_api_key")
self._api_base = conf().get("open_ai_api_base") or None
@@ -46,8 +50,9 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
)
if conf().get("rate_limit_chatgpt"):
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
conf_model = conf().get("model") or "gpt-3.5-turbo"
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
# Per-provider model takes precedence over global model.
conf_model = custom_model or conf().get("model") or "gpt-3.5-turbo"
self.sessions = SessionManager(ChatGPTSession, model=conf_model)
# o1相关模型不支持system prompt暂时用文心模型的session
self.args = {
@@ -70,11 +75,20 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
def get_api_config(self):
"""Get API configuration for OpenAI-compatible base class"""
is_custom = conf().get("bot_type") == "custom"
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
if is_custom:
custom_key, custom_base, custom_model = resolve_custom_credentials()
api_key = custom_key
api_base = custom_base
model = custom_model or conf().get("model", "gpt-3.5-turbo")
else:
api_key = conf().get("open_ai_api_key")
api_base = conf().get("open_ai_api_base")
model = conf().get("model", "gpt-3.5-turbo")
return {
'api_key': conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"),
'api_base': conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base"),
'model': conf().get("model", "gpt-3.5-turbo"),
'api_key': api_key,
'api_base': api_base,
'model': model,
'default_temperature': conf().get("temperature", 0.9),
'default_top_p': conf().get("top_p", 1.0),
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
@@ -185,10 +199,16 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
mime_type = mime_type_map.get(extension, "image/jpeg")
# Get model and API config
is_custom = conf().get("bot_type") == "custom"
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
api_key = context.get("openai_api_key") or (conf().get("custom_api_key") if is_custom else conf().get("open_ai_api_key"))
api_base = conf().get("custom_api_base") if is_custom else conf().get("open_ai_api_base")
is_custom, _ = parse_custom_bot_type(conf().get("bot_type", ""))
if is_custom:
custom_key, custom_base, custom_model = resolve_custom_credentials()
model = context.get("gpt_model") or custom_model or conf().get("model", "gpt-4o")
api_key = context.get("openai_api_key") or custom_key
api_base = custom_base
else:
model = context.get("gpt_model") or conf().get("model", "gpt-4o")
api_key = context.get("openai_api_key") or conf().get("open_ai_api_key")
api_base = conf().get("open_ai_api_base")
# Build vision request
messages = [

View File

@@ -223,7 +223,7 @@ class ClaudeAPIBot(Bot, OpenAIImage):
return 8192
elif model and model.startswith("claude-3") and "opus" in model:
return 4096
elif model and (model.startswith("claude-sonnet-4") or model.startswith("claude-opus-4")):
elif model and (model.startswith("claude-sonnet-4") or model.startswith("claude-opus-4") or model.startswith("claude-fable")):
return 64000
return 8192

125
models/custom_provider.py Normal file
View File

@@ -0,0 +1,125 @@
# encoding:utf-8
"""
Centralized resolver for custom (OpenAI-compatible) provider credentials.
CowAgent historically supported only a *single* custom provider via the flat
config keys ``custom_api_key`` / ``custom_api_base``. This module adds support
for *multiple* custom providers (see issue #2838) while remaining 100%
backward compatible.
Config model
------------
- ``custom_providers``: list of dicts, each describing one custom provider::
{
"id": "3f2a9c1b", # server-generated short uuid (primary key)
"name": "siliconflow", # user-facing display label (not a key)
"api_key": "sk-...", # required
"api_base": "https://...", # required, must be OpenAI-compatible
"model": "deepseek-ai/DeepSeek-V3" # optional default model
}
Routing
-------
- ``bot_type: "custom"`` (legacy): reads the flat ``custom_api_key`` / ``custom_api_base``.
- ``bot_type: "custom:<id>"`` (multi-provider): looks up the provider by id in
``custom_providers``. There is a single source of truth — no separate
``custom_active_provider`` field.
Backward-compatibility contract
-------------------------------
When ``bot_type`` is exactly ``"custom"`` (no colon suffix), behaviour is
unchanged: we return ``custom_api_key`` / ``custom_api_base`` values.
"""
import uuid
from config import conf
from common.log import logger
def generate_provider_id() -> str:
"""Generate a short random id for a new custom provider."""
return uuid.uuid4().hex[:8]
def get_custom_providers():
"""Return the list of configured custom providers (always a list)."""
providers = conf().get("custom_providers")
if not isinstance(providers, list):
return []
# Keep only well-formed entries with an id.
return [p for p in providers if isinstance(p, dict) and p.get("id")]
def _find_provider_by_id(providers, provider_id):
"""Look up a provider by its id, or None if not found."""
if not providers or not provider_id:
return None
for p in providers:
if p.get("id") == provider_id:
return p
return None
def parse_custom_bot_type(bot_type):
"""Parse bot_type to extract custom provider id.
Returns:
(is_custom, provider_id) where:
- is_custom: True if bot_type starts with "custom"
- provider_id: the id suffix (e.g. "3f2a9c1b") or empty string for legacy mode
"""
if not bot_type or not isinstance(bot_type, str):
return False, ""
if bot_type == "custom":
return True, ""
if bot_type.startswith("custom:"):
return True, bot_type[7:] # len("custom:") == 7
return False, ""
def resolve_custom_credentials():
"""Resolve the effective (api_key, api_base, model) for custom mode.
Resolution order:
1. If ``bot_type`` is ``"custom:<id>"``, look up that id in
``custom_providers``.
2. If ``bot_type`` is exactly ``"custom"`` (legacy), return the flat
``custom_api_key`` / ``custom_api_base``.
:return: tuple ``(api_key, api_base, model)``. ``api_base`` and ``model``
may be ``None`` / empty when not configured.
"""
bot_type = conf().get("bot_type", "")
is_custom, provider_id = parse_custom_bot_type(bot_type)
if not is_custom:
# Not custom at all — should not happen but be defensive.
return (
conf().get("open_ai_api_key", ""),
conf().get("open_ai_api_base") or None,
None,
)
if provider_id:
# Multi-provider mode: look up by id.
providers = get_custom_providers()
provider = _find_provider_by_id(providers, provider_id)
if provider is not None:
return (
provider.get("api_key", ""),
provider.get("api_base") or None,
provider.get("model") or None,
)
logger.warning(
"[CUSTOM] provider id '%s' not found in custom_providers, "
"falling back to legacy fields", provider_id
)
# Legacy single-provider fallback — unchanged behavior.
return (
conf().get("custom_api_key", ""),
conf().get("custom_api_base") or None,
None,
)

View File

@@ -657,7 +657,7 @@ class DeepSeekBot(Bot, OpenAICompatibleBot):
headers = self._build_headers()
resp = requests.post(
f"{self.api_base}/chat/completions",
headers=headers, json=payload, timeout=60,
headers=headers, json=payload, timeout=180,
)
if resp.status_code != 200:
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}

View File

@@ -170,7 +170,7 @@ class DoubaoBot(Bot):
"Content-Type": "application/json",
}
resp = requests.post(f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=60)
headers=headers, json=payload, timeout=180)
if resp.status_code != 200:
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
data = resp.json()

View File

@@ -257,7 +257,7 @@ class GoogleGeminiBot(Bot):
}
endpoint = f"{self.api_base}/v1beta/models/{model_name}:generateContent"
headers = {"x-goog-api-key": self.api_key, "Content-Type": "application/json"}
resp = requests.post(endpoint, headers=headers, json=payload, timeout=60)
resp = requests.post(endpoint, headers=headers, json=payload, timeout=180)
if resp.status_code != 200:
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}

View File

@@ -643,7 +643,7 @@ class MimoBot(Bot, OpenAICompatibleBot):
headers = self._build_headers()
resp = requests.post(
f"{self.api_base}/chat/completions",
headers=headers, json=payload, timeout=60,
headers=headers, json=payload, timeout=180,
)
if resp.status_code != 200:
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}

View File

@@ -201,7 +201,7 @@ class MinimaxBot(Bot):
"Content-Type": "application/json",
}
resp = requests.post(f"{self.api_base}/chat/completions",
headers=headers, json=payload, timeout=60)
headers=headers, json=payload, timeout=180)
if resp.status_code != 200:
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
data = resp.json()

View File

@@ -47,9 +47,20 @@ class MoonshotBot(Bot):
return model == "kimi-for-coding" or "api.kimi.com/coding" in base
@staticmethod
def _model_supports_thinking(model_name: str) -> bool:
"""Return True if the model supports the ``thinking`` request parameter."""
def _is_builtin_reasoning_model(model_name: str) -> bool:
"""Return True for Kimi code models with built-in reasoning.
These models only accept thinking type=enabled and reject disabled,
so the thinking param must be omitted entirely.
"""
return model_name.lower().startswith("kimi-k2.7-code")
@classmethod
def _model_supports_thinking(cls, model_name: str) -> bool:
"""Return True if the model accepts the ``thinking`` request parameter."""
m = model_name.lower()
if cls._is_builtin_reasoning_model(m):
return False
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
def _build_headers(self) -> dict:
@@ -195,7 +206,7 @@ class MoonshotBot(Bot):
}
headers = self._build_headers()
resp = requests.post(f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=60)
headers=headers, json=payload, timeout=180)
if resp.status_code != 200:
return {"error": True, "message": f"HTTP {resp.status_code}: {resp.text[:300]}"}
data = resp.json()

Some files were not shown because too many files have changed in this diff Show More