Compare commits

..

64 Commits

Author SHA1 Message Date
zhayujie
feaa9076b0 feat: release 2.1.0 2026-06-01 16:02:55 +08:00
zhayujie
ce0249706e docs: update issue/pr templates 2026-06-01 11:10:12 +08:00
zhayujie
af2c839231 docs: add contributing guide and issue/PR templates 2026-06-01 11:01:28 +08:00
zhayujie
2b2d24ed25 docs: update doc references 2026-05-31 22:22:48 +08:00
zhayujie
1dbf41f384 Merge pull request #2852 from zhayujie/feat-i18n
feat: support i18n across the whole project
2026-05-31 20:15:59 +08:00
zhayujie
9e6a2cc2c0 feat(installer): revamp install flow with i18n 2026-05-31 20:11:23 +08:00
zhayujie
7bf4ef3d05 docs: make English the default docs language and fix link paths 2026-05-31 17:52:22 +08:00
zhayujie
126649f70f feat(i18n): localize system prompts, workspace templates and dynamic prompts 2026-05-31 17:38:31 +08:00
zhayujie
1827a2a31c feat(i18n): bind web language switch to cow_lang config 2026-05-31 17:01:43 +08:00
zhayujie
fcf4eb78dc feat(i18n): add global language resolution and localize user-facing text 2026-05-31 16:49:35 +08:00
zhayujie
2ec6ea8045 Merge pull request #2850 from lyteen/feature/command-matching
feat: /command matching
2026-05-31 15:17:16 +08:00
lyteen
0994a3586d [feat] Fuzzy /command Resolution & Custom Aliases 2026-05-30 23:12:24 +08:00
zhayujie
29c4be6a3a feat(terminal): add agent streaming UX with reasoning/tool-call rendering 2026-05-30 19:10:56 +08:00
zhayujie
c5b8e06891 feat(channel): add Discord channel 2026-05-30 18:20:27 +08:00
zhayujie
54a20bca92 docs: update README doc 2026-05-30 17:32:21 +08:00
zhayujie
6e786bde90 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-05-30 17:18:51 +08:00
zhayujie
b671b0d725 docs: add web file serve root config 2026-05-30 17:18:31 +08:00
zhayujie
57f5692074 Merge pull request #2840 from 6vision/feat/wechatcom-kf-channel
feat: add wechatcom kf channel
2026-05-30 17:17:59 +08:00
zhayujie
b0ac0731c7 Merge branch 'master' into feat/wechatcom-kf-channel 2026-05-30 17:17:29 +08:00
zhayujie
3c161df526 Merge pull request #2848 from 6vision/fix/wechatmp-passive-merge-replies
fix(wechatmp): improve passive reply multi-turn output and local image sending
2026-05-30 17:12:36 +08:00
zhayujie
aa3f48e93c fix(web): confine /api/file to allowed dirs to prevent arbitrary file read 2026-05-30 17:06:58 +08:00
zhayujie
5ae1e1adde feat(channel): support slack bot 2026-05-30 17:01:42 +08:00
6vision
fe8b8fe831 fix(wechatmp): support local file:// images in send
Agent-generated images are sent as IMAGE_URL with a file:// path, but the wechatmp channel always used requests.get, which fails on file:// with InvalidSchema. Now read local files directly (file:// or local path) and fall back to HTTP download for remote URLs, in both passive and active reply modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 16:33:49 +08:00
6vision
5aca54c083 fix(wechatmp): flush cached segments while task still running
Previously the passive reply only drained the cache after the agent task fully finished, so for long multi-turn tasks the user could not retrieve already-cached intermediate segments. Now return cached segments as soon as they are available, even while the task is still running; the next user message fetches the rest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 15:48:27 +08:00
6vision
458b1a1d88 fix(wechatmp): merge cached text segments in passive reply
In subscription account passive reply mode, WeChat allows only one reply per request. Multi-turn agent output was cached as separate entries, forcing the user to send an extra message to fetch each one. Now drain and merge all consecutive cached text segments into a single reply; media still returns one at a time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 14:41:51 +08:00
zhayujie
3dd4b84179 feat(models): support claude-opus-4-8 2026-05-29 10:19:45 +08:00
6vision
99bddb79d6 fix(wechat_kf): download attachments to agent_workspace/tmp
So agent tools resolve relative refs like 	mp/xxx.pdf on the first
try, matching weixin's _get_tmp_dir convention.
2026-05-28 19:40:12 +08:00
zhayujie
136b0b89e8 fix: optimize browser memory 2026-05-28 19:09:26 +08:00
6vision
c605b0b080 feat(wechat_kf): cache images/files and merge into next text turn
Adopt the same channel-level pattern as weixin/wecom_bot/feishu so
the agent actually sees attachments the user sent:
- IMAGE: agent mode never reads memory.USER_IMAGE_CACHE, so a photo
  sent before a question (e.g. "image" then 30s later "what's this?")
  used to be lost. Now lone images go into channel.file_cache and
  the next TEXT turn appends "[图片: <path>]" to the query before
  producing the context. Cross-batch image+text combinations now
  work as users expect.
- FILE: previously dropped at the sync_msg filter and unsupported
  by WechatKfMessage. Add msgtype="file" parsing, download via the
  WeCom media API, preserve the original filename from
  Content-Disposition (RFC 5987 + plain forms), and route through
  the same file_cache pipeline as images, surfacing as
  "[文件: <path>]" in the next text turn.
2026-05-28 18:11:41 +08:00
zhayujie
b7b8e3679c fix: avoid conflict with pypi translate package 2026-05-28 15:48:20 +08:00
zhayujie
aeb6610ff4 Merge pull request #2843 from zhayujie/feat-telegram
feat(channel): support telegram bot
2026-05-28 15:12:08 +08:00
zhayujie
e3eacc77d7 feat(channel): support telegram bot 2026-05-28 15:07:09 +08:00
6vision
37661daf40 refactor(wechat_kf): persist sync_msg cursor under $HOME
Move the sync_msg cursor file from the project-local tmp/ dir to ~/.wechat_kf_cursors.json so it survives tmp/ cleanups and cwd changes across restarts. Aligns with the weixin channel's credentials file convention.

- add wechat_kf_cursor_path config (default ~/.wechat_kf_cursors.json)
- expand ~ via os.path.expanduser in the channel init
- chmod the cursor file to 0o600 after each flush (no-op on Windows)
2026-05-28 14:33:45 +08:00
6vision
877b848370 fix(wechat_kf): stop dropping rapid-fire messages in batch dedup
_dedup_image_text_pair previously fell back to returning only the last message whenever the batch was not exactly an image+text pair, which silently dropped multiple texts/images sent in quick succession.

Cursor freshness is already guaranteed by sync_msg, so no extra stale-history protection is needed. Now we return all messages by default and only collapse a batch when it is exactly a 2-message image+text pair within a 5s window (order-insensitive, normalized to [image, text]).
2026-05-28 14:23:04 +08:00
6vision
5c163cc0fe fix: dispatch callback async to avoid WeCom 5s timeout
WeCom requires the callback HTTP response within ~5s, otherwise it retries the same notification. The previous code ran sync_msg pulling synchronously inside Query.POST, so a backlog could exceed the deadline and trigger retries that race on the same cursor and end up replying to the same user multiple times.

- Dispatch consume_callback to a background ThreadPoolExecutor and return 'success' immediately from the HTTP handler.
- Serialize work per open_kfid with a lock so retried/concurrent callbacks queue up instead of racing the cursor window.
- Shutdown the executor on channel stop().
2026-05-28 12:23:56 +08:00
6vision
6e04ea8240 refactor(wechat_kf): rename channel from wechatcom_kf and split corp_id
Rename the WeCom customer-service channel and give it its own corp_id
field so users no longer have to share `wechatcom_corp_id` with the
self-built WeCom app channel.

Renames (channel-side):
- channel type / const: wechatcom_kf -> wechat_kf
- package dir: channel/wechatcom_kf/ -> channel/wechat_kf/
- python files / classes: WechatComKf* -> WechatKf*
- config keys: wechatcom_kf_{secret,token,aes_key,port} ->
  wechat_kf_{secret,token,aes_key,port}; new wechat_kf_corp_id
- env vars: WECHATCOM_KF_* -> WECHAT_KF_*; new WECHAT_KF_CORP_ID
- log prefix / cursor file: [wechatcom_kf] -> [wechat_kf]
- web console CHANNEL_DEFS key + startup log line

Renames (docs):
- docs/channels/wecom-kf.mdx -> docs/channels/wechat-kf.mdx (zh/en/ja)
- update docs.json sidebar entries and all field names inside the docs

In addition, the Web Console "微信客服" entry now exposes its own
Corp ID field instead of reusing the wechatcom_app one, and includes
the screenshot of the visual config in the channel guide.

Web Console onboarding section is added (Tabs: Web Console / config
file) and the local URL `http://127.0.0.1:9899/` parenthetical is
dropped for consistency with other channel docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 12:12:44 +08:00
zhayujie
d106465419 feat(channel): telegram first version 2026-05-28 12:10:00 +08:00
zhayujie
f39380cea7 Merge pull request #2841 from zhayujie/feat-add-mimo
feat(models): support xiaomi mimo
2026-05-28 10:51:43 +08:00
zhayujie
bccce2d7cb feat(models): support xiaomi mimo 2026-05-28 10:49:52 +08:00
6vision
6721dbdbcc docs(wechatcom_kf): add web console onboarding tab 2026-05-27 21:53:54 +08:00
zhayujie
83cd6ad158 fix(browser): preserve non-http schemes in navigate URL 2026-05-27 18:42:21 +08:00
zhayujie
116fb27257 fix: robust tool args JSON parsing for non-strict providers #2823 2026-05-27 18:37:54 +08:00
zhayujie
8d67177a1b feat(agent): support user-initiated cancel for in-flight agent runs 2026-05-26 23:36:09 +08:00
zhayujie
ad2db1a776 feat(mcp): support streamable-http mcp protocol 2026-05-26 12:11:59 +08:00
zhayujie
2e6d9e0f27 chore: remove useless plugins 2026-05-25 17:11:57 +08:00
zhayujie
e05f85f3ce feat: optimize model name display in English 2026-05-25 15:09:53 +08:00
zhayujie
40c48a9a61 chore(deps): relax numpy>=1.24 to >=1.21 for Python 3.7 compatibility 2026-05-25 14:47:55 +08:00
zhayujie
c9a7525d0b Merge pull request #2832 from yangluxin613/feat/cjk-search-fix
fix(memory): CJK keyword search + vector search optimization
2026-05-25 14:45:49 +08:00
yangluxin613
fd571ac539 fix(memory): address PR review — numpy/UPSERT soft deps + BM25 floor + BLOB dim
- numpy soft dependency: try/except import + _HAS_NUMPY flag; _encode_embedding
  and _decode_embedding fall back to struct.pack/unpack; search_vector falls back
  to pure-Python cosine loop — startup never fails without numpy reinstalled
- SQLite UPSERT guard: _HAS_UPSERT = sqlite_version_info >= (3,24,0); save_chunk
  and save_chunks_batch fall back to INSERT OR REPLACE on SQLite < 3.24 with a
  one-time startup warning about potential FTS rowid drift
- _bm25_rank_to_score floor: 0.3 + 0.69*(|rank|/(1+|rank|)) → always in [0.3, 0.99),
  prevents small-corpus matches scoring 0.0 and being filtered by min_score
- detect_index_dim BLOB-aware: check isinstance(raw, bytes) first and return
  len(raw)//4 before json.loads, so /memory status works after embedding format switch
- Comment: "CJK single-char" → "CJK tokens shorter than 3 characters"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:15:16 +08:00
zhayujie
c5a3f991c5 fix(scheduler): make cron pushes survive restart on weixin channel 2026-05-25 12:15:57 +08:00
zhayujie
eb74b73351 fix(web): handle non-string web_password to avoid login TypeError 2026-05-25 11:14:14 +08:00
yangluxin613
9b31f45481 fix(memory): _search_like ASCII query always returns empty
matched_count only counted cjk_words hits; pure ASCII queries had
cjk_words=[] so matched_count=0 and all SQL-matched rows were filtered
out. Change to count across all tokens (cjk_words + ascii_words) so
the LIKE fallback works correctly when FTS5 is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:02:07 +08:00
yangluxin613
bc9c1691f5 fix(memory): CJK keyword search + vector search optimization
- Add trigram FTS5 table for CJK/mixed-language search with BM25 ranking
- Fix three-step search routing: unicode61 (ASCII) → trigram (CJK/mixed) → LIKE fallback
- Fix _bm25_rank_to_score: abs(rank)/(1+abs(rank)) instead of max(0,rank)
- Fix INSERT OR REPLACE → UPSERT to preserve FTS5 content table rowid stability
- Fix FTS5 JOIN to use rowid instead of id column
- Fix _search_like: single-char CJK match, dynamic scoring, merged CJK+ASCII path
- Add numpy vectorized cosine similarity + BLOB embedding storage (6x smaller)
- Add _decode_embedding backward compat for legacy JSON embeddings
- Add threading.RLock for concurrent write safety
- Add _meta table to avoid trigram backfill re-running on every startup
- Activate EmbeddingCache in MemoryManager for session-level query deduplication
- Add numpy>=1.24 to requirements.txt
- Merge upstream master (embedding package refactor, FTS5 self-healing methods)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:56:08 +08:00
zhayujie
73bf83d2ff docs: add public-access notes for server deployment 2026-05-25 00:09:52 +08:00
zhayujie
36e1988fee docs: update README.md 2026-05-24 19:21:06 +08:00
zhayujie
aad6ef635e docs: update README.md 2026-05-24 19:11:34 +08:00
zhayujie
96659cd616 docs: update project docs 2026-05-24 18:58:10 +08:00
zhayujie
c8787b7de4 Merge branch 'feat-readme-refactoring' 2026-05-24 18:30:18 +08:00
zhayujie
91d427c8f9 docs: update docs and readme 2026-05-24 18:29:57 +08:00
zhayujie
c8c0573dbd Merge pull request #2831 from zhayujie/feat-readme-refactoring
docs: README refactoring
2026-05-24 18:10:03 +08:00
6vision
2fa6343fe5 docs: add WeCom customer service (wechatcom_kf) channel guide
Add a self-deployment guide for the new `wechatcom_kf` channel under
`docs/channels/wecom-kf.mdx` in zh / en / ja, mirroring the existing
`wecom.mdx` structure. Wire each language version into the sidebar in
`docs/docs.json`.

Walks through: creating the WeCom custom app, retrieving Corp ID /
Secret (push-to-phone) / Token / EncodingAESKey, configuring `config.json`,
saving the callback URL + Enterprise Trusted IPs, binding the WeCom
Customer Service account, and distributing the access link / QR code.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 21:32:11 +08:00
6vision
06b84225a1 docs(wechatcom_kf): tidy README and hide cursor dir from config
- Clarify Secret retrieval (must tap "查看" on admin's phone, not copy)
- Update WeCom customer-service binding section to point to the
  "接入链接" UI (copy link / generate QR code)
- Drop developer-only asides (wechatcomapp_secret / port collision
  notes, internal sections about cursor persistence, channel runtime
  differences, multi-kf-account support)
- Stop exposing `wechatcom_kf_cursor_dir` as a user config; cursor file
  is now fixed under `tmp/`, which is an internal implementation detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 21:08:52 +08:00
6vision
5b31da335d fix(wechatcom_kf): use plain WeChatClient to fix 40014 & token log spam
- Switch from the local `WechatComAppClient` (whose `fetch_access_token`
  may return the raw response dict and whose background refresh loop
  re-fetches every 60s) to the stock `wechatpy.enterprise.WeChatClient`.
- Use `client.access_token` (string property) when building sync_msg /
  send_msg URLs; the previous `client.fetch_access_token()` call could
  interpolate a dict into the URL and yield errcode 40014.
- Always skip historical messages on first start; drop the
  `wechatcom_kf_skip_history_on_first_start` config — there is no real
  case for replaying up to 14 days of history.
- Change default callback port from 9899 to 9888.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 20:43:06 +08:00
6vision
11d92bb22a feat(channel): add WeCom customer service (wechatcom_kf) channel
Introduce a new channel that integrates with WeCom Customer Service
(微信客服), separate from the existing self-built WeCom app channel.

- Register channel type `wechatcom_kf` in factory, app loader and const
- Add config keys for token / secret / aes_key / port / cursor dir and
  the first-start history-skip switch; also expose corresponding env vars
- Implement channel, message and cursor store under channel/wechatcom_kf/

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:58:47 +08:00
331 changed files with 19679 additions and 10164 deletions

View File

@@ -1,131 +1,46 @@
name: Bug report 🐛
description: 项目运行中遇到的Bug或问题。
description: Report a bug or unexpected behavior.
title: "[Bug] "
labels: ['status: needs check']
body:
- type: markdown
attributes:
value: |
### ⚠️ 前置确认
1. 网络能够访问openai接口
2. python 已安装:版本在 3.7 ~ 3.10 之间
3. `git pull` 拉取最新代码
4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足
5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足
6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
> 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️
- type: checkboxes
attributes:
label: 前置确认
label: Self check
options:
- label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。
- label: I'm on the latest version and searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate.
required: true
- type: checkboxes
- type: textarea
attributes:
label: ⚠️ 搜索issues中是否已存在类似问题
description: >
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索你的问题
或相关日志的关键词来查找是否存在类似问题。
options:
- label: 我已经搜索过issues和disscussions没有跟我遇到的问题相关的issue
required: true
- type: markdown
attributes:
value: |
请在上方的`title`中填写你对你所遇到问题的简略总结,这将帮助其他人更好的找到相似问题,谢谢❤️。
- type: dropdown
attributes:
label: 操作系统类型?
description: >
请选择你运行程序的操作系统类型。
options:
- Windows
- Linux
- MacOS
- Docker
- Railway
- Windows Subsystem for Linux (WSL)
- Other (请在问题中说明)
validations:
required: true
- type: dropdown
attributes:
label: 运行的python版本是?
description: |
请选择你运行程序的`python`版本。
注意:在`python 3.7`中,有部分可选依赖无法安装。
经过长时间的观察,我们认为`python 3.8`是兼容性最好的版本。
`python 3.7`~`python 3.10`以外版本的issue将视情况直接关闭。
options:
- python 3.7
- python 3.8
- python 3.9
- python 3.10
- other
validations:
required: true
- type: dropdown
attributes:
label: 使用的chatgpt-on-wechat版本是?
description: |
请确保你使用的是 [releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) 中的最新版本。
如果你使用git, 请使用`git branch`命令来查看分支。
options:
- Latest Release
- Master (branch)
validations:
required: true
- type: dropdown
attributes:
label: 运行的`channel`类型是?
description: |
请确保你正确配置了该`channel`所需的配置项,所有可选的配置项都写在了[该文件中](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py),请将所需配置项填写在根目录下的`config.json`文件中。
options:
- wechatmp(公众号, 订阅号)
- wechatmp_service(公众号, 服务号)
- terminal
- other
label: Environment
description: "Version (`cow status`), OS, Python version, install method, model & channel."
placeholder: |
Version: v1.2.0
OS: macOS / Linux / Windows / Docker
Python: 3.11
Install: installer / Docker / source
Model & channel: deepseek-v4-flash, web
validations:
required: true
- type: textarea
attributes:
label: 复现步骤 🕹
description: |
**⚠️ 不能复现将会关闭issue.**
- type: textarea
attributes:
label: 问题描述 😯
description: 详细描述出现的问题,或提供有关截图。
- type: textarea
attributes:
label: 终端日志 📒
description: |
在此处粘贴终端日志,可在主目录下`run.log`文件中找到这会帮助我们更好的分析问题注意隐去你的API key。
如果在配置文件中加入`"debug": true`,打印出的日志会更有帮助。
label: What happened?
description: "Steps to reproduce, what you expected, and what happened instead. Screenshots welcome."
placeholder: |
1. ...
2. ...
<details>
<summary><i>示例</i></summary>
```log
[DEBUG][2023-04-16 00:23:22][plugin_manager.py:157] - Plugin SUMMARY triggered by event Event.ON_HANDLE_CONTEXT
[DEBUG][2023-04-16 00:23:22][main.py:221] - [Summary] on_handle_context. content: $总结前100条消息
[DEBUG][2023-04-16 00:23:24][main.py:240] - [Summary] limit: 100, duration: -1 seconds
[ERROR][2023-04-16 00:23:24][chat_channel.py:244] - Worker return exception: name 'start_date' is not defined
Traceback (most recent call last):
File "C:\ProgramData\Anaconda3\lib\concurrent\futures\thread.py", line 57, in run
result = self.fn(*self.args, **self.kwargs)
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 132, in _handle
reply = self._generate_reply(context)
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 142, in _generate_reply
e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, {
File "D:\project\chatgpt-on-wechat\plugins\plugin_manager.py", line 159, in emit_event
instance.handlers[e_context.event](e_context, *args, **kwargs)
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 255, in on_handle_context
records = self._get_records(session_id, start_time, limit)
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 96, in _get_records
c.execute("SELECT * FROM chat_records WHERE sessionid=? and timestamp>? ORDER BY timestamp DESC LIMIT ?", (session_id, start_date, limit))
NameError: name 'start_date' is not defined
[INFO][2023-04-16 00:23:36][app.py:14] - signal 2 received, exiting...
```
</details>
value: |
```log
<此处粘贴终端日志>
```
Expected: ...
Actual: ...
validations:
required: true
- type: textarea
attributes:
label: Logs
description: "Relevant logs from `run.log` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys."
render: shell
validations:
required: false

View File

@@ -1,28 +1,33 @@
name: Feature request 🚀
description: 提出你对项目的新想法或建议。
description: Suggest a new idea or improvement.
title: "[Feature] "
labels: ['status: needs check']
body:
- type: markdown
attributes:
value: |
请在上方的`title`中填写简略总结,谢谢❤️
> 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️
- type: checkboxes
attributes:
label: ⚠️ 搜索是否存在类似issue
description: >
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框搜索关键词查找是否存在相似issue。
label: Self check
options:
- label: 我已经搜索过issues和disscussions没有发现相似issue
- label: I searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate.
required: true
- type: textarea
attributes:
label: 总结
description: 描述feature的功能。
label: What's the problem?
description: "The pain point or what's not working for you right now."
validations:
required: true
- type: textarea
attributes:
label: 举例
description: 提供聊天示例,草图或相关网址。
- type: textarea
label: What would you like?
description: "How you'd expect it to work. Examples, sketches, or links welcome."
validations:
required: false
- type: checkboxes
attributes:
label: 动机
description: 描述你提出该feature的动机比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。
label: Contribution
options:
- label: I'd be interested in helping implement this.
required: false

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 📖 Documentation
url: https://docs.cowagent.ai
about: Setup guides, configuration, and FAQ.

21
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,21 @@
<!--
Thanks for your contribution! Please write this PR in English.
【中文开发者】请使用英文填写,感谢 ❤️
-->
## What does this PR do?
<!-- A short description of the change and why it's needed. -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Docs
- [ ] Refactor / chore
## Checklist
- [ ] I tested this change locally
- [ ] Code comments and docs are in English
- [ ] Linked related issue (if any): closes #

1
.gitignore vendored
View File

@@ -32,7 +32,6 @@ plugins/banwords/lib/__pycache__
!plugins/role
!plugins/keyword
!plugins/linkai
!plugins/agent
!plugins/cow_cli
client_config.json
ref/

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contributing to CowAgent
Thanks for taking the time to contribute! 🎉 CowAgent is built by a global
community, and contributions of all sizes are welcome — from typo fixes to new
features.
## Language policy
To keep the project accessible to a global community, **please write issues,
pull requests, code comments, and commit messages in English.**
> 为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与
> commit message。不必担心英文不完美——表达清楚即可工具翻译也完全没问题。感谢理解 ❤️
## Reporting issues
Found a bug or have an idea? [Open an issue](https://github.com/zhayujie/CowAgent/issues/new/choose).
Before opening one, please search existing issues (including closed ones) to
avoid duplicates, and make sure you're on the latest version.
## Submitting a pull request
1. **Fork** the repo and create a branch from `master`
(e.g. `feat/web-search`, `fix/telegram-reconnect`).
2. Make your change. Keep it focused — one logical change per PR.
3. Follow the existing code style. Write comments and docstrings in English.
4. Run the app locally to confirm your change works.
5. Open a PR with a clear title and a short description of **what** and **why**.
We keep the bar friendly: clear, focused, and working is enough. Maintainers are
happy to help polish details during review.
### Commit & PR titles
Use a short, imperative summary. The [Conventional Commits](https://www.conventionalcommits.org/)
style is preferred but not required:
```
feat: add web search tool
fix: reconnect Telegram websocket on timeout
docs: clarify Docker setup
```
## Development setup
See the [Install from Source](https://docs.cowagent.ai/guide/manual-install)
guide. In short:
```bash
git clone https://github.com/zhayujie/CowAgent.git
cd CowAgent
pip install -r requirements.txt
pip install -e .
cow start
```
## Code of conduct
Be respectful and constructive. We want CowAgent to be a welcoming place for
everyone.

141
README.md
View File

@@ -7,14 +7,14 @@
[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, operates your computer and external services, creates and runs Skills, and grows alongside you with 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, and grows alongside you through a personal knowledge base and long-term memory — a reference implementation of Agent Harness engineering.
CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider, run it across Web and major IM platforms, 24/7 on a personal computer or server.
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.
<p align="center">
<a href="https://cowagent.ai/">🌐 Website</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/en/intro/index">📖 Docs</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/en/guide/quick-start">🚀 Quick Start</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/intro/index">📖 Docs</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 Quick Start</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
</p>
@@ -25,15 +25,15 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
| Capability | Description |
| :--- | :--- |
| 🤖 [Autonomous Task Planning](https://docs.cowagent.ai/en/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached |
| 🧠 [Long-term Memory](https://docs.cowagent.ai/en/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval |
| 📚 [Personal Knowledge Base](https://docs.cowagent.ai/en/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing |
| 🧩 [Skills System](https://docs.cowagent.ai/en/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation |
| 🔧 [Tool System](https://docs.cowagent.ai/en/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration |
| 💬 [Multi-channel Integration](https://docs.cowagent.ai/en/channels/index) | A single Agent simultaneously serves Web, WeChat, Feishu, DingTalk, WeCom, QQ, and Official Accounts |
| 🎨 Multimodal Messaging | First-class support for text, images, voice, and files — recognition, generation, and delivery |
| ⚙️ [Pluggable Models](https://docs.cowagent.ai/en/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click |
| 📦 [Batteries Included](https://docs.cowagent.ai/en/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) |
| [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 |
| [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 |
| Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery |
| [Models](https://docs.cowagent.ai/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click |
| [Deploy](https://docs.cowagent.ai/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) |
<br/>
@@ -41,15 +41,15 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and decides using memory, knowledge, and the available tools/skills; **Models** generate the response; results are sent back through the originating channel. Every layer is decoupled and independently extensible.
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
Read more in [Architecture](https://docs.cowagent.ai/en/intro/architecture).
Read more in [Architecture](https://docs.cowagent.ai/intro/architecture).
<br/>
## 🚀 Quick Start
The project ships with a one-line installer that handles dependencies, configuration, and startup automatically:
A one-line installer takes care of dependencies, configuration, and startup:
**Linux / macOS:**
@@ -70,11 +70,13 @@ curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
docker compose up -d
```
Once started, open `http://localhost:9899` to access the **Web console**a single place to configure models, connect channels, and install skills.
Once started, open `http://localhost:9899` to access the **Web console**your one-stop hub to chat with the Agent, configure models, connect channels, and install skills.
> 📖 Detailed guides: [One-line Installer](https://docs.cowagent.ai/en/guide/quick-start) · [Manual Install from Source](https://docs.cowagent.ai/en/guide/manual-install) · [Upgrade](https://docs.cowagent.ai/en/guide/upgrade)
> Deploying on a server? Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group.
After installation, manage the service with the [`cow` CLI](https://docs.cowagent.ai/en/cli/index):
> 📖 Detailed guides: [Quick Start](https://docs.cowagent.ai/guide/quick-start) · [Install from Source](https://docs.cowagent.ai/guide/manual-install) · [Upgrade](https://docs.cowagent.ai/guide/upgrade)
After installation, manage the service with the [cow CLI](https://docs.cowagent.ai/cli/index):
```bash
cow start | stop | restart # service control
@@ -88,55 +90,60 @@ cow install-browser # install browser automation
## 🤖 Models
CowAgent supports all mainstream LLM providers. **Chat, vision, image generation, ASR/TTS, and embeddings** can each be configured against a different vendor.
CowAgent supports all mainstream LLM providers. **Chat, vision, image generation, ASR/TTS, and embeddings** can each be routed to a different vendor. Providers are configured directly in the Web console — no manual file editing required.
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/en/models/claude) | claude-opus-4-7 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/en/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/en/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/en/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/en/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/en/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/en/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/en/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/en/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
| [Qianfan](https://docs.cowagent.ai/en/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [LinkAI](https://docs.cowagent.ai/en/models/linkai) | 100+ models, unified gateway | ✅ | ✅ | ✅ | | ✅ | |
| [Custom](https://docs.cowagent.ai/en/models/custom) | Local models / third-party proxy | ✅ | | | | | |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | | | |
| [Custom](https://docs.cowagent.ai/models/custom) | Local models / third-party proxy | ✅ | | | | | |
> The Web console is the recommended way to configure providers — no manual file editing required. For manual setup, see each provider's docs and the [Models overview](https://docs.cowagent.ai/en/models/index).
> For details on each provider, see the [Models overview](https://docs.cowagent.ai/models/index).
<br/>
## 💬 Channels
A single Agent instance can serve multiple channels at once. Switch with the `channel_type` config or run several channels in parallel.
A single Agent instance can serve multiple channels in parallel. Most channels can be onboarded right from the Web console.
| Channel | Text | Image | File | Voice | Group |
| --- | :-: | :-: | :-: | :-: | :-: |
| [Web Console](https://docs.cowagent.ai/en/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | |
| [WeChat](https://docs.cowagent.ai/en/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [Feishu / Lark](https://docs.cowagent.ai/en/channels/feishu) | ✅ | ✅ | ✅ | | ✅ |
| [DingTalk](https://docs.cowagent.ai/en/channels/dingtalk) | ✅ | ✅ | ✅ | | ✅ |
| [WeCom Bot](https://docs.cowagent.ai/en/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | |
| [QQ](https://docs.cowagent.ai/en/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
| [WeCom App](https://docs.cowagent.ai/en/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [WeChat Official Account](https://docs.cowagent.ai/en/channels/wechatmp) | ✅ | ✅ | | ✅ | |
| [Web Console](https://docs.cowagent.ai/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | |
| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | |
| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ |
| [Discord](https://docs.cowagent.ai/channels/discord) | ✅ | ✅ | ✅ | | ✅ |
| [WeChat](https://docs.cowagent.ai/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [Feishu / Lark](https://docs.cowagent.ai/channels/feishu) | ✅ | ✅ | ✅ | | ✅ |
| [DingTalk](https://docs.cowagent.ai/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | |
| [WeCom Bot](https://docs.cowagent.ai/channels/wecom-bot) | ✅ | ✅ | | ✅ | |
| [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
| [WeCom App](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [WeChat Customer Service](https://docs.cowagent.ai/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
| [WeChat Official Account](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | |
> Feishu and WeCom Bot can be onboarded by **scanning a QR code right inside the Web console** — no public IP required. See the [Channels overview](https://docs.cowagent.ai/en/channels/index).
> See the [Channels overview](https://docs.cowagent.ai/channels/index) for setup details.
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/web-console-chat.png" alt="CowAgent Web Console" width="800"/>
*The Web console doubles as the default channel and the unified place to configure and manage your Agent.*
*The Web console is the default channel and the unified entry point to configure models, channels, skills, memory, and more.*
<br/>
## 🧠 Memory & Knowledge Base
**Long-term memory** uses a three-tier architecture: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term). A nightly **Deep Dream** pass distills scattered memories into refined long-term entries and a narrative journal. See [Long-term Memory](https://docs.cowagent.ai/en/memory/index) · [Deep Dream](https://docs.cowagent.ai/en/memory/deep-dream).
**Long-term memory** uses a three-tier architecture: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term). A nightly **Deep Dream** pass distills scattered memories into refined long-term entries and a narrative journal. See [Long-term Memory](https://docs.cowagent.ai/memory/index) · [Deep Dream](https://docs.cowagent.ai/memory/deep-dream).
**Personal knowledge base** complements the time-ordered memory by organizing structured knowledge **by topic**. The Agent automatically curates valuable information from conversations, maintains cross-references and indexes, and the Web console offers an interactive knowledge-graph view. See [Personal Knowledge Base](https://docs.cowagent.ai/en/knowledge/index).
**Personal knowledge base** complements the time-ordered memory by organizing structured knowledge **by topic**. The Agent automatically curates valuable information from conversations, maintains cross-references and indexes, and the Web console offers an interactive knowledge-graph view. See [Personal Knowledge Base](https://docs.cowagent.ai/knowledge/index).
<table>
<tr>
@@ -159,16 +166,16 @@ A single Agent instance can serve multiple channels at once. Switch with the `ch
### Tool System
**Built-in tools** cover file I/O (`read` / `write` / `edit` / `ls`), terminal (`bash`), file delivery (`send`), memory retrieval (`memory`), environment variables (`env_config`), web fetching (`web_fetch`), scheduling (`scheduler`), web search (`web_search`), vision (`vision`), and browser automation (`browser`).
**Built-in tools** cover file I/O (`read` / `write` / `edit` / `ls`), terminal (`bash`), file sending (`send`), memory retrieval (`memory`), environment variables (`env_config`), web fetching (`web_fetch`), scheduling (`scheduler`), web search (`web_search`), vision (`vision`), and browser automation (`browser`).
**MCP protocol** integrates the open ecosystem of [Model Context Protocol](https://modelcontextprotocol.io) servers. A single `mcp.json` is enough — supports stdio / SSE transports, hot reload, and zero-code integration.
Learn more: [Tools overview](https://docs.cowagent.ai/en/tools/index) · [MCP integration](https://docs.cowagent.ai/en/tools/mcp).
Learn more: [Tools overview](https://docs.cowagent.ai/tools/index) · [MCP integration](https://docs.cowagent.ai/tools/mcp).
### Skills System
- **[Skill Hub](https://skills.cowagent.ai/)** — open skill marketplace: browse, search, install in one click
- **GitHub / ClawHub** — multiple skill sources, 40,000+ skills available
- **GitHub / ClawHub / URL and more** — install skills from any source
- **Conversational authoring** — generate custom skills through dialogue with `skill-creator`; turn any workflow or third-party API into a reusable skill
```bash
@@ -177,12 +184,14 @@ Learn more: [Tools overview](https://docs.cowagent.ai/en/tools/index) · [MCP in
/skill install <name> # one-click install
```
Learn more: [Skills overview](https://docs.cowagent.ai/en/skills/index) · [Creating Skills](https://docs.cowagent.ai/en/skills/create).
Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creating Skills](https://docs.cowagent.ai/skills/create).
<br/>
## 🏷 Changelog
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo).
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening.
> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu channel overhaul (voice, streaming, QR onboarding), DeepSeek V4 and Baidu Qianfan support, scheduler tool upgrades.
@@ -195,15 +204,13 @@ Learn more: [Skills overview](https://docs.cowagent.ai/en/skills/index) · [Crea
> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — Major upgrade to a super Agent assistant with multi-step task planning, long-term memory, and the Skills framework.
Full history: [Release Notes](https://docs.cowagent.ai/en/releases/overview)
Full history: [Release Notes](https://docs.cowagent.ai/releases/overview)
<br/>
## 🤝 Community & Support
- 🐛 [File an Issue](https://github.com/zhayujie/CowAgent/issues) · 💬 [GitHub Discussions](https://github.com/zhayujie/CowAgent/discussions) · 📖 [FAQs](https://github.com/zhayujie/CowAgent/wiki/FAQs)
Scan the WeChat QR code to join the open-source community group:
[File an issue](https://github.com/zhayujie/CowAgent/issues) on GitHub, or scan the QR code below to join our WeChat community:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
@@ -217,6 +224,18 @@ Scan the WeChat QR code to join the open-source community group:
<br/>
## 🏢 Enterprise Services
[**LinkAI**](https://link-ai.tech/) is an all-in-one AI Agent platform for enterprises and developers, offering managed hosting and enterprise-grade support for CowAgent:
- **🚀 Zero-deployment hosted runtime** — spin up a [CowAgent online assistant](https://link-ai.tech/cowagent/create) in under a minute, no server required
- **🧠 Agent infrastructure** — unified access to LLMs, knowledge bases, databases, skills, and workflows; plug-and-play building blocks that extend what CowAgent can do
- **🏢 Team & enterprise features** — workspaces, role-based access, audit logs, and private deployment for production use cases
For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https://cdn.link-ai.tech/consultant.jpg) to reach our team on WeChat.
<br/>
## 🛠️ Development & Contributing
Contributions are welcome — add a new channel by following the [Feishu channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit).
@@ -229,22 +248,10 @@ Contributions are welcome — add a new channel by following the [Feishu channel
<br/>
## 🏢 Enterprise Services
[**LinkAI**](https://link-ai.tech/) is a one-stop AI Agent platform for enterprises and developers, offering managed hosting and enterprise-grade support for CowAgent:
- **🚀 Zero-deployment hosted runtime** — spin up a [CowAgent online assistant](https://link-ai.tech/cowagent/create) in under a minute, no server required
- **🧠 Aggregated models & skill marketplace** — unified access to mainstream LLMs and an official skill marketplace, extending CowAgent's reach
- **🏢 Team & enterprise features** — workspaces, role-based access, audit logs, and private deployment for production use cases
For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https://cdn.link-ai.tech/consultant.jpg) to reach our team on WeChat.
<br/>
## ⚠️ Disclaimer
1. This project is licensed under the [MIT License](/LICENSE) and is intended for technical research and learning. You are responsible for complying with applicable laws and regulations in your jurisdiction; the maintainers assume no liability for any consequences arising from use of this project.
2. **Cost & safety:** Agent mode consumes substantially more tokens than plain chat — pick models that balance quality and cost. The Agent has access to your local operating system; deploy only in trusted environments.
2. **Cost & safety:** Agent mode consumes substantially more tokens than regular chat — pick models that balance quality and cost. The Agent has access to your local operating system, so only deploy it in trusted environments.
3. CowAgent is a pure open-source project and does not participate in, authorize, or issue any cryptocurrency.
<br/>

View File

@@ -31,9 +31,13 @@ def detect_index_dim(storage) -> Optional[int]:
if not row or not row["embedding"]:
return None
try:
emb = json.loads(row["embedding"])
raw = row["embedding"]
if isinstance(raw, (bytes, bytearray)):
# New BLOB format: 4 bytes per float32
return len(raw) // 4
emb = json.loads(raw)
return len(emb) if isinstance(emb, list) else None
except (json.JSONDecodeError, TypeError):
except (json.JSONDecodeError, TypeError, Exception):
return None

View File

@@ -13,7 +13,7 @@ from datetime import datetime, timedelta
from agent.memory.config import MemoryConfig, get_default_memory_config
from agent.memory.storage import MemoryStorage, MemoryChunk, SearchResult
from agent.memory.chunker import TextChunker
from agent.memory.embedding import EmbeddingProvider
from agent.memory.embedding import EmbeddingProvider, EmbeddingCache
from agent.memory.summarizer import MemoryFlushManager, create_memory_files_if_needed
@@ -61,7 +61,11 @@ class MemoryManager:
logger.info(
"[MemoryManager] No embedding provider; memory will use keyword search only"
)
# Cache for query embeddings (avoids redundant API calls within a session)
self._embedding_cache = EmbeddingCache()
# Initialize memory flush manager
workspace_dir = self.config.get_workspace()
self.flush_manager = MemoryFlushManager(
@@ -128,7 +132,14 @@ class MemoryManager:
vector_results = []
if self.embedding_provider:
try:
query_embedding = self.embedding_provider.embed_query(query)
provider_name = type(self.embedding_provider).__name__
model_name = getattr(self.embedding_provider, 'model', '')
cached = self._embedding_cache.get(query, provider_name, model_name)
if cached is not None:
query_embedding = cached
else:
query_embedding = self.embedding_provider.embed_query(query)
self._embedding_cache.put(query, provider_name, model_name, query_embedding)
vector_results = self.storage.search_vector(
query_embedding=query_embedding,
user_id=user_id,

View File

@@ -5,12 +5,42 @@ Provides vector and keyword search capabilities
"""
from __future__ import annotations
import re
import sqlite3
import json
import hashlib
import threading
from typing import List, Dict, Optional, Any
from pathlib import Path
from dataclasses import dataclass
try:
import numpy as np
_HAS_NUMPY = True
except ImportError:
_HAS_NUMPY = False
np = None # type: ignore[assignment]
# UPSERT (INSERT … ON CONFLICT DO UPDATE) requires SQLite ≥ 3.24.0 (2018).
# Older systems (e.g. CentOS 7 ships SQLite 3.7) fall back to INSERT OR REPLACE,
# which risks FTS5 rowid drift on chunk updates (see save_chunk docstring).
_HAS_UPSERT = sqlite3.sqlite_version_info >= (3, 24, 0)
# ---------------------------------------------------------------------------
# CJK character ranges, compiled once at module load.
# Covers: CJK Symbols/Punctuation, Japanese kana (hiragana + katakana),
# CJK Unified Ideographs + Extension A, Korean syllables (Hangul),
# CJK Compatibility Ideographs, and CJK Extension BF.
# ---------------------------------------------------------------------------
_CJK_RANGES = (
r'\u3000-\u30ff' # CJK Symbols/Punctuation + Japanese kana
r'\u3400-\u9fff' # CJK Unified Ideographs (incl. Extension A)
r'\uac00-\ud7af' # Korean syllables (Hangul)
r'\uf900-\ufaff' # CJK Compatibility Ideographs
r'\U00020000-\U0002fa1f' # CJK Extension BF
)
_RE_CONTAINS_CJK = re.compile(f'[{_CJK_RANGES}]')
_RE_CJK_WORDS = re.compile(f'[{_CJK_RANGES}]+')
_RE_TRIGRAM_TOKENS = re.compile(f'[{_CJK_RANGES}]+|[A-Za-z0-9_]+')
@dataclass
@@ -48,6 +78,10 @@ class MemoryStorage:
self.db_path = db_path
self.conn: Optional[sqlite3.Connection] = None
self.fts5_available = False # Track FTS5 availability
# RLock protects concurrent writes from the same process.
# SQLite WAL mode handles read/write concurrency at the file level,
# but same-process concurrent writes still need a Python-level lock.
self._lock = threading.RLock()
self._init_db()
def _check_fts5_support(self) -> bool:
@@ -69,6 +103,14 @@ class MemoryStorage:
# Check FTS5 support
self.fts5_available = self._check_fts5_support()
if not _HAS_UPSERT:
from common.log import logger
logger.warning(
"[MemoryStorage] SQLite %s < 3.24 — UPSERT unavailable. "
"Falling back to INSERT OR REPLACE; FTS5 rowid may drift on "
"chunk updates (rebuild index periodically to recover).",
sqlite3.sqlite_version,
)
if not self.fts5_available:
from common.log import logger
logger.debug("[MemoryStorage] FTS5 not available, using LIKE-based keyword search")
@@ -175,6 +217,75 @@ class MemoryStorage:
)
self._rebuild_fts5_from_chunks()
# Internal key-value store for persistent flags (e.g. backfill tracking)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS _meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
# Create trigram FTS5 table for CJK / mixed-language search
self.trigram_fts5_available = False
if self.fts5_available:
try:
self.conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts_trigram USING fts5(
text,
id UNINDEXED,
user_id UNINDEXED,
path UNINDEXED,
source UNINDEXED,
scope UNINDEXED,
content='chunks',
content_rowid='rowid',
tokenize='trigram case_sensitive 0'
)
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_trigram_ai
AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts_trigram(rowid, text, id, user_id, path, source, scope)
VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope);
END
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_trigram_ad
AFTER DELETE ON chunks BEGIN
DELETE FROM chunks_fts_trigram WHERE rowid = old.rowid;
END
""")
self.conn.execute("""
CREATE TRIGGER IF NOT EXISTS chunks_trigram_au
AFTER UPDATE ON chunks BEGIN
UPDATE chunks_fts_trigram
SET text=new.text, id=new.id, user_id=new.user_id,
path=new.path, source=new.source, scope=new.scope
WHERE rowid = new.rowid;
END
""")
# One-time backfill for existing rows.
# NOTE: COUNT(*) on an FTS5 content table always returns 0, so we
# use a persistent flag in _meta instead of counting trigram rows.
backfill_done = self.conn.execute(
"SELECT 1 FROM _meta WHERE key = 'trigram_backfill_done'"
).fetchone()
chunks_count = self.conn.execute(
"SELECT COUNT(*) as c FROM chunks"
).fetchone()['c']
if chunks_count > 0 and not backfill_done:
self.conn.execute(
"INSERT INTO chunks_fts_trigram(chunks_fts_trigram) VALUES('rebuild')"
)
self.conn.execute(
"INSERT OR REPLACE INTO _meta(key, value) VALUES('trigram_backfill_done', '1')"
)
self.trigram_fts5_available = True
except Exception:
from common.log import logger
logger.warning("[MemoryStorage] trigram FTS5 unavailable, CJK search will use LIKE fallback", exc_info=True)
self.trigram_fts5_available = False
# Create files metadata table
self.conn.execute("""
CREATE TABLE IF NOT EXISTS files (
@@ -186,7 +297,7 @@ class MemoryStorage:
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
)
""")
self.conn.commit()
def _fts5_state_inconsistent(self) -> bool:
@@ -299,43 +410,98 @@ class MemoryStorage:
self.conn.commit()
def save_chunk(self, chunk: MemoryChunk):
"""Save a memory chunk"""
self.conn.execute("""
INSERT OR REPLACE INTO chunks
(id, user_id, scope, source, path, start_line, end_line, text, embedding, hash, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
""", (
chunk.id,
chunk.user_id,
chunk.scope,
chunk.source,
chunk.path,
chunk.start_line,
chunk.end_line,
chunk.text,
json.dumps(chunk.embedding) if chunk.embedding else None,
"""Save a memory chunk (insert or update by id).
Uses SQLite UPSERT (INSERT … ON CONFLICT DO UPDATE) instead of
INSERT OR REPLACE. INSERT OR REPLACE internally does DELETE+INSERT,
which changes the row's rowid. Because both FTS5 tables use
content_rowid='rowid', a new rowid would leave the old FTS index
entries pointing at a non-existent rowid and trigger
"fts5: missing row N from content table" errors.
ON CONFLICT DO UPDATE fires the AFTER UPDATE trigger (chunks_au /
chunks_trigram_au) and keeps the original rowid intact.
"""
if _HAS_UPSERT:
_SQL = """
INSERT INTO chunks
(id, user_id, scope, source, path, start_line, end_line,
text, embedding, hash, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
scope = excluded.scope,
source = excluded.source,
path = excluded.path,
start_line = excluded.start_line,
end_line = excluded.end_line,
text = excluded.text,
embedding = excluded.embedding,
hash = excluded.hash,
metadata = excluded.metadata,
updated_at = strftime('%s', 'now')
"""
else:
_SQL = """
INSERT OR REPLACE INTO chunks
(id, user_id, scope, source, path, start_line, end_line,
text, embedding, hash, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
"""
params = (
chunk.id, chunk.user_id, chunk.scope, chunk.source, chunk.path,
chunk.start_line, chunk.end_line, chunk.text,
self._encode_embedding(chunk.embedding),
chunk.hash,
json.dumps(chunk.metadata) if chunk.metadata else None
))
self.conn.commit()
json.dumps(chunk.metadata) if chunk.metadata else None,
)
with self._lock:
self.conn.execute(_SQL, params)
self.conn.commit()
def save_chunks_batch(self, chunks: List[MemoryChunk]):
"""Save multiple chunks in a batch"""
self.conn.executemany("""
INSERT OR REPLACE INTO chunks
(id, user_id, scope, source, path, start_line, end_line, text, embedding, hash, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
""", [
"""Save multiple chunks in a batch (insert or update by id).
See save_chunk for why UPSERT is used instead of INSERT OR REPLACE.
"""
if _HAS_UPSERT:
_SQL = """
INSERT INTO chunks
(id, user_id, scope, source, path, start_line, end_line,
text, embedding, hash, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
scope = excluded.scope,
source = excluded.source,
path = excluded.path,
start_line = excluded.start_line,
end_line = excluded.end_line,
text = excluded.text,
embedding = excluded.embedding,
hash = excluded.hash,
metadata = excluded.metadata,
updated_at = strftime('%s', 'now')
"""
else:
_SQL = """
INSERT OR REPLACE INTO chunks
(id, user_id, scope, source, path, start_line, end_line,
text, embedding, hash, metadata, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
"""
params_list = [
(
c.id, c.user_id, c.scope, c.source, c.path,
c.start_line, c.end_line, c.text,
json.dumps(c.embedding) if c.embedding else None,
self._encode_embedding(c.embedding),
c.hash,
json.dumps(c.metadata) if c.metadata else None
json.dumps(c.metadata) if c.metadata else None,
)
for c in chunks
])
self.conn.commit()
]
with self._lock:
self.conn.executemany(_SQL, params_list)
self.conn.commit()
def get_chunk(self, chunk_id: str) -> Optional[MemoryChunk]:
"""Get a chunk by ID"""
@@ -356,21 +522,21 @@ class MemoryStorage:
limit: int = 10
) -> List[SearchResult]:
"""
Vector similarity search using in-memory cosine similarity
(sqlite-vec can be added later for better performance)
Vector similarity search using numpy-vectorized cosine similarity.
All embeddings are loaded then scored in a single BLAS matrix-vector
multiply, which is ~100x faster than the pure-Python per-row loop.
"""
if scopes is None:
scopes = ["shared"]
if user_id:
scopes.append("user")
# Build query
scope_placeholders = ','.join('?' * len(scopes))
params = scopes
params = list(scopes)
if user_id:
query = f"""
SELECT * FROM chunks
SELECT * FROM chunks
WHERE scope IN ({scope_placeholders})
AND (scope = 'shared' OR user_id = ?)
AND embedding IS NOT NULL
@@ -378,51 +544,95 @@ class MemoryStorage:
params.append(user_id)
else:
query = f"""
SELECT * FROM chunks
SELECT * FROM chunks
WHERE scope IN ({scope_placeholders})
AND embedding IS NOT NULL
"""
rows = self.conn.execute(query, params).fetchall()
if not rows:
return []
# Calculate cosine similarity. We probe the first row's dim to fail
# loudly on a query/index dim mismatch — otherwise every doc would
# score 0 silently, leaving the user wondering why search broke.
results = []
query_dim = len(query_embedding)
if rows:
first = json.loads(rows[0]['embedding'])
if isinstance(first, list) and len(first) != query_dim:
raise ValueError(
f"Embedding dim mismatch: query is {query_dim}-dim but "
f"index stores {len(first)}-dim vectors. The configured "
f"embedding model differs from the one that built the "
f"index — run /memory rebuild-index to re-embed."
)
# Parse embeddings and build a (N, D) matrix in one pass.
# New rows store BLOB bytes (np.frombuffer); legacy rows fall back to JSON.
# Filter out rows whose embedding dimension differs from the query —
# mixing dimensions would cause np.array() to produce an object array
# and matrix @ q_vec to raise ValueError.
expected_dim = len(query_embedding)
valid_rows = []
vectors = []
for row in rows:
embedding = json.loads(row['embedding'])
similarity = self._cosine_similarity(query_embedding, embedding)
vec = self._decode_embedding(row['embedding'])
if not vec:
continue
if len(vec) != expected_dim:
from common.log import logger
logger.warning(
"[MemoryStorage] Skipping chunk %s: embedding dim %d != query dim %d",
row['id'], len(vec), expected_dim
)
continue
valid_rows.append(row)
vectors.append(vec)
if similarity > 0:
results.append((similarity, row))
# Sort by similarity and limit
results.sort(key=lambda x: x[0], reverse=True)
results = results[:limit]
return [
SearchResult(
path=row['path'],
start_line=row['start_line'],
end_line=row['end_line'],
score=score,
snippet=self._truncate_text(row['text'], 500),
source=row['source'],
user_id=row['user_id']
)
for score, row in results
]
if not vectors:
return []
if _HAS_NUMPY:
matrix = np.array(vectors, dtype=np.float32) # (N, D)
q_vec = np.array(query_embedding, dtype=np.float32) # (D,)
# Vectorized cosine similarity: dot(matrix, q) / (||matrix|| * ||q||)
dots = matrix @ q_vec # (N,)
row_norms = np.linalg.norm(matrix, axis=1) # (N,)
q_norm = float(np.linalg.norm(q_vec))
denominators = row_norms * q_norm
np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero
sims = dots / denominators # (N,)
# Select TopK using argpartition (O(N) average), then sort only those K
k = min(limit, len(valid_rows))
top_idx = np.argpartition(sims, -k)[-k:]
top_idx = top_idx[np.argsort(sims[top_idx])[::-1]]
return [
SearchResult(
path=valid_rows[i]['path'],
start_line=valid_rows[i]['start_line'],
end_line=valid_rows[i]['end_line'],
score=float(sims[i]),
snippet=self._truncate_text(valid_rows[i]['text'], 500),
source=valid_rows[i]['source'],
user_id=valid_rows[i]['user_id']
)
for i in top_idx
if sims[i] > 0
]
else:
# Pure-Python cosine similarity fallback (numpy not installed)
import math
q = query_embedding
q_norm = math.sqrt(sum(x * x for x in q)) or 1e-10
scored = []
for i, vec in enumerate(vectors):
dot = sum(a * b for a, b in zip(vec, q))
v_norm = math.sqrt(sum(x * x for x in vec)) or 1e-10
sim = dot / (v_norm * q_norm)
if sim > 0:
scored.append((sim, valid_rows[i]))
scored.sort(key=lambda x: x[0], reverse=True)
return [
SearchResult(
path=row['path'],
start_line=row['start_line'],
end_line=row['end_line'],
score=sim,
snippet=self._truncate_text(row['text'], 500),
source=row['source'],
user_id=row['user_id']
)
for sim, row in scored[:limit]
]
def search_keyword(
self,
@@ -445,12 +655,37 @@ class MemoryStorage:
if user_id:
scopes.append("user")
if self.fts5_available:
# Step 1: Standard FTS5 (unicode61) — pure ASCII queries only.
# Skipped when query contains any CJK characters: unicode61 tokenises CJK
# as individual characters without forming meaningful tokens, so it would
# match only the ASCII portion of a mixed query (e.g. "Python" from
# "Python教程") and silently discard the CJK part. Those queries go
# directly to Step 2 (trigram), which handles both ASCII and CJK together.
fts1_attempted = False
if (self.fts5_available
and not MemoryStorage._contains_cjk(query)
and MemoryStorage._build_fts_query(query)):
fts1_attempted = True
fts_results = self._search_fts5(query, user_id, scopes, limit)
if fts_results:
return fts_results
return self._search_like(query, user_id, scopes, limit)
# Step 2: Trigram FTS5 — CJK/mixed queries, plus fallback when unicode61
# returned nothing (trigram indexes all scripts with 3-char sliding windows,
# so it can catch terms that unicode61 tokenisation misses).
if self.trigram_fts5_available and (
MemoryStorage._contains_cjk(query) or fts1_attempted
):
trigram_results = self._search_fts5_trigram(query, user_id, scopes, limit)
if trigram_results:
return trigram_results
# Step 3: LIKE fallback — last resort (FTS5 unavailable, or CJK tokens
# shorter than 3 characters that trigram cannot match, e.g. a single-char query).
if not self.fts5_available or MemoryStorage._contains_cjk(query):
return self._search_like(query, user_id, scopes, limit)
return []
def _search_fts5(
self,
@@ -471,7 +706,7 @@ class MemoryStorage:
sql_query = f"""
SELECT chunks.*, bm25(chunks_fts) as rank
FROM chunks_fts
JOIN chunks ON chunks.id = chunks_fts.id
JOIN chunks ON chunks.rowid = chunks_fts.rowid
WHERE chunks_fts MATCH ?
AND chunks.scope IN ({scope_placeholders})
AND (chunks.scope = 'shared' OR chunks.user_id = ?)
@@ -483,7 +718,7 @@ class MemoryStorage:
sql_query = f"""
SELECT chunks.*, bm25(chunks_fts) as rank
FROM chunks_fts
JOIN chunks ON chunks.id = chunks_fts.id
JOIN chunks ON chunks.rowid = chunks_fts.rowid
WHERE chunks_fts MATCH ?
AND chunks.scope IN ({scope_placeholders})
ORDER BY rank
@@ -505,13 +740,11 @@ class MemoryStorage:
)
for row in rows
]
except Exception as e:
except Exception:
from common.log import logger
logger.error(
f"[MemoryStorage] FTS5 search failed (caller will fall back to LIKE): {e}"
)
logger.warning("[MemoryStorage] _search_fts5 failed, returning empty", exc_info=True)
return []
def _search_like(
self,
query: str,
@@ -522,12 +755,11 @@ class MemoryStorage:
"""LIKE-based search.
Used as the keyword-search fallback when FTS5 is unavailable, fails,
or returns empty. Supports both CJK runs and ASCII word tokens so it
can serve as a true safety net for any query.
or returns empty. Supports both CJK runs (1+ chars) and ASCII word
tokens (3+ chars) so it can serve as a true safety net for any query.
"""
import re
# CJK runs (2+ chars) + ASCII word tokens (3+ chars to avoid noise)
cjk_words = re.findall(r'[\u4e00-\u9fff]{2,}', query)
# CJK runs (1+ chars, wide Unicode range) + ASCII words (3+ chars to avoid noise)
cjk_words = _RE_CJK_WORDS.findall(query)
ascii_words = [t for t in re.findall(r'[A-Za-z0-9_]+', query) if len(t) >= 3]
words = cjk_words + ascii_words
if not words:
@@ -565,44 +797,54 @@ class MemoryStorage:
try:
rows = self.conn.execute(sql_query, params).fetchall()
return [
SearchResult(
results = []
for row in rows:
# Dynamic score: reward chunks that contain more of the query words.
# Use all tokens (CJK + ASCII) so pure-ASCII queries are not skipped.
# matched_count is always ≥1 because the WHERE clause uses OR, but
# guard defensively so unexpected zero-match rows are never surfaced.
text_lower = row['text'].lower()
matched_count = sum(1 for w in words if w.lower() in text_lower)
if matched_count == 0:
continue
score = min(0.85, 0.3 + 0.15 * matched_count)
results.append(SearchResult(
path=row['path'],
start_line=row['start_line'],
end_line=row['end_line'],
score=0.5, # Fixed score for LIKE search
score=score,
snippet=self._truncate_text(row['text'], 500),
source=row['source'],
user_id=row['user_id']
)
for row in rows
]
except Exception as e:
))
results.sort(key=lambda r: r.score, reverse=True)
return results
except Exception:
from common.log import logger
logger.error(f"[MemoryStorage] LIKE search failed: {e}")
logger.warning("[MemoryStorage] _search_like failed, returning empty", exc_info=True)
return []
def delete_by_path(self, path: str):
"""Delete all chunks from a file"""
self.conn.execute("""
DELETE FROM chunks WHERE path = ?
""", (path,))
self.conn.commit()
with self._lock:
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
self.conn.commit()
def get_file_hash(self, path: str) -> Optional[str]:
"""Get stored file hash"""
row = self.conn.execute("""
SELECT hash FROM files WHERE path = ?
""", (path,)).fetchone()
return row['hash'] if row else None
def update_file_metadata(self, path: str, source: str, file_hash: str, mtime: int, size: int):
"""Update file metadata"""
self.conn.execute("""
INSERT OR REPLACE INTO files (path, source, hash, mtime, size, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'))
""", (path, source, file_hash, mtime, size))
self.conn.commit()
with self._lock:
self.conn.execute("""
INSERT OR REPLACE INTO files (path, source, hash, mtime, size, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%s', 'now'))
""", (path, source, file_hash, mtime, size))
self.conn.commit()
def get_stats(self) -> Dict[str, int]:
"""Get storage statistics"""
@@ -632,7 +874,8 @@ class MemoryStorage:
self.conn.close()
self.conn = None # Mark as closed
except Exception as e:
print(f"⚠️ Error closing database connection: {e}")
from common.log import logger
logger.warning("[MemoryStorage] Error closing database connection: %s", e)
def __del__(self):
"""Destructor to ensure connection is closed"""
@@ -642,7 +885,33 @@ class MemoryStorage:
pass # Ignore errors during cleanup
# Helper methods
@staticmethod
def _encode_embedding(embedding: Optional[List[float]]) -> Optional[bytes]:
"""Encode embedding as float32 BLOB bytes (~6x smaller and faster than JSON).
Falls back to struct.pack when numpy is unavailable."""
if embedding is None:
return None
if _HAS_NUMPY:
return np.array(embedding, dtype=np.float32).tobytes()
import struct
return struct.pack(f'{len(embedding)}f', *embedding)
@staticmethod
def _decode_embedding(raw) -> Optional[List[float]]:
"""Decode embedding from BLOB bytes or legacy JSON string.
Handles both numpy and numpy-free environments."""
if raw is None:
return None
if isinstance(raw, (bytes, bytearray)):
if _HAS_NUMPY:
return np.frombuffer(raw, dtype=np.float32).tolist()
import struct
n = len(raw) // 4
return list(struct.unpack(f'{n}f', raw))
# Legacy JSON format written by older versions
return json.loads(raw)
def _row_to_chunk(self, row) -> MemoryChunk:
"""Convert database row to MemoryChunk"""
return MemoryChunk(
@@ -654,32 +923,89 @@ class MemoryStorage:
start_line=row['start_line'],
end_line=row['end_line'],
text=row['text'],
embedding=json.loads(row['embedding']) if row['embedding'] else None,
embedding=self._decode_embedding(row['embedding']),
hash=row['hash'],
metadata=json.loads(row['metadata']) if row['metadata'] else None
)
@staticmethod
def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
"""Calculate cosine similarity between two vectors"""
if len(vec1) != len(vec2):
return 0.0
dot_product = sum(a * b for a, b in zip(vec1, vec2))
norm1 = sum(a * a for a in vec1) ** 0.5
norm2 = sum(b * b for b in vec2) ** 0.5
if norm1 == 0 or norm2 == 0:
return 0.0
return dot_product / (norm1 * norm2)
def _contains_cjk(text: str) -> bool:
"""Check if text contains CJK or related characters (Chinese, Japanese, Korean)."""
return bool(_RE_CONTAINS_CJK.search(text))
@staticmethod
def _contains_cjk(text: str) -> bool:
"""Check if text contains CJK (Chinese/Japanese/Korean) characters"""
import re
return bool(re.search(r'[\u4e00-\u9fff]', text))
def _build_trigram_query(raw_query: str) -> Optional[str]:
"""
Build FTS5 MATCH query for the trigram tokenizer.
Extracts CJK sequences (including single characters) and ASCII words,
joining them with AND so all terms must appear in the matched chunk.
"""
tokens = _RE_TRIGRAM_TOKENS.findall(raw_query)
tokens = [t for t in tokens if t]
if not tokens:
return None
# Escape embedded double-quotes (FTS5 uses "" inside quoted phrases)
quoted = [f'"{t.replace(chr(34), chr(34)*2)}"' for t in tokens]
return ' AND '.join(quoted)
def _search_fts5_trigram(
self,
query: str,
user_id: Optional[str],
scopes: List[str],
limit: int
) -> List[SearchResult]:
"""Trigram FTS5 search — handles CJK and mixed queries with BM25 ranking."""
trigram_query = self._build_trigram_query(query)
if not trigram_query:
return []
scope_placeholders = ','.join('?' * len(scopes))
params = [trigram_query] + list(scopes)
if user_id:
sql = f"""
SELECT chunks.*, bm25(chunks_fts_trigram) as rank
FROM chunks_fts_trigram
JOIN chunks ON chunks.rowid = chunks_fts_trigram.rowid
WHERE chunks_fts_trigram MATCH ?
AND chunks.scope IN ({scope_placeholders})
AND (chunks.scope = 'shared' OR chunks.user_id = ?)
ORDER BY rank
LIMIT ?
"""
params.extend([user_id, limit])
else:
sql = f"""
SELECT chunks.*, bm25(chunks_fts_trigram) as rank
FROM chunks_fts_trigram
JOIN chunks ON chunks.rowid = chunks_fts_trigram.rowid
WHERE chunks_fts_trigram MATCH ?
AND chunks.scope IN ({scope_placeholders})
ORDER BY rank
LIMIT ?
"""
params.append(limit)
try:
rows = self.conn.execute(sql, params).fetchall()
return [
SearchResult(
path=row['path'],
start_line=row['start_line'],
end_line=row['end_line'],
score=self._bm25_rank_to_score(row['rank']),
snippet=self._truncate_text(row['text'], 500),
source=row['source'],
user_id=row['user_id']
)
for row in rows
]
except Exception:
from common.log import logger
logger.warning("[MemoryStorage] _search_fts5_trigram failed, returning empty", exc_info=True)
return []
@staticmethod
def _build_fts_query(raw_query: str) -> Optional[str]:
"""
@@ -688,7 +1014,6 @@ class MemoryStorage:
Works best for English and word-based languages.
For CJK characters, LIKE search will be used as fallback.
"""
import re
# Extract words (primarily English words and numbers)
tokens = re.findall(r'[A-Za-z0-9_]+', raw_query)
if not tokens:
@@ -701,9 +1026,22 @@ class MemoryStorage:
@staticmethod
def _bm25_rank_to_score(rank: float) -> float:
"""Convert BM25 rank to 0-1 score"""
normalized = max(0, rank) if rank is not None else 999
return 1 / (1 + normalized)
"""Convert SQLite BM25 rank to a [0, 1) relevance score.
SQLite's bm25() returns a non-positive float (0 or negative).
More negative = more relevant. max(0, rank) would clip every
negative value to 0, making every score 1/(1+0) = 1.0 and
destroying all ranking information.
abs(rank) / (1 + abs(rank)) maps the absolute relevance magnitude
to [0, 1): larger |rank| (stronger match) → score closer to 1.
"""
if rank is None:
return 0.0
# Add a floor of 0.3 so any FTS5 match always exceeds typical
# min_score thresholds (default 0.1). Small-corpus ranks close to
# 0 would otherwise produce score≈0 and be filtered out downstream.
return 0.3 + 0.69 * (abs(rank) / (1.0 + abs(rank)))
@staticmethod
def _truncate_text(text: str, max_chars: int) -> str:

View File

@@ -16,7 +16,7 @@ from datetime import datetime
from common.log import logger
SUMMARIZE_SYSTEM_PROMPT = """你是一个对话记录助手。请将对话内容归纳为当天的日常记录。
SUMMARIZE_SYSTEM_PROMPT_ZH = """你是一个对话记录助手。请将对话内容归纳为当天的日常记录。
## 要求
@@ -28,7 +28,23 @@ SUMMARIZE_SYSTEM_PROMPT = """你是一个对话记录助手。请将对话内容
当对话没有任何记录价值(仅含问候或无意义内容),直接回复"""""
SUMMARIZE_USER_PROMPT = """请归纳以下对话的日常记录:
SUMMARIZE_SYSTEM_PROMPT_EN = """You are a conversation-logging assistant. Summarize the conversation into a daily record.
## Requirements
Summarize by "event", not turn by turn:
- One item per line, starting with "- "
- Merge multiple turns about the same thing
- Only record meaningful events; ignore small talk and greetings
- Keep key decisions, conclusions and to-dos
If the conversation has no record value (only greetings or meaningless content), reply with exactly "None"."""
SUMMARIZE_USER_PROMPT_ZH = """请归纳以下对话的日常记录:
{conversation}"""
SUMMARIZE_USER_PROMPT_EN = """Summarize the daily record of the following conversation:
{conversation}"""
@@ -36,7 +52,7 @@ SUMMARIZE_USER_PROMPT = """请归纳以下对话的日常记录:
# Deep Dream prompts — distill daily memories → MEMORY.md + dream diary
# ---------------------------------------------------------------------------
DREAM_SYSTEM_PROMPT = """你是一个记忆整理助手,负责定期整理用户的长期记忆。
DREAM_SYSTEM_PROMPT_ZH = """你是一个记忆整理助手,负责定期整理用户的长期记忆。
你将收到两份材料:
1. **当前长期记忆** — MEMORY.md 的全部现有内容
@@ -80,7 +96,51 @@ MEMORY.md 会注入每次对话的系统提示词中,因此必须保持精炼
梦境日记内容...
```"""
DREAM_USER_PROMPT = """## 当前长期记忆MEMORY.md
DREAM_SYSTEM_PROMPT_EN = """You are a memory-curation assistant that periodically organizes the user's long-term memory.
You will receive two inputs:
1. **Current long-term memory** — the full existing content of MEMORY.md
2. **Today's diary** — the daily records
MEMORY.md is injected into the system prompt of every conversation, so it must stay concise and hold only valuable, memory-worthy content.
**Important: organize strictly based on the provided material. Never fabricate, infer, or add information not present in it.**
## Tasks
### Part 1: Updated long-term memory ([MEMORY])
Organize and distill on top of the existing memory, and output the complete updated content:
- **Merge & distill**: combine semantically similar items into one dense statement rather than listing them
- **Extract new**: pull memory-worthy new info from today's diary (preferences, decisions, people, rules, lessons)
- **Resolve conflicts**: when new info contradicts an old item, prefer the new and replace the old
- **Clean invalid**: remove temporary notes, blank items, formatting residue, meaningless or duplicate content
- **Drop redundancy**: delete old items already covered by a more concise statement
- One item per line, starting with "- ", without a date prefix
- You may group related items under "## headings" for clarity
- Goal: keep under 50 items, each ideally a single sentence
### Part 2: Dream diary ([DREAM])
Write a short diary in a concise narrative style recording what this curation found, keep it clean and readable:
- Which duplicates or conflicts were found
- What new insights were extracted from the diary
- What cleanup and optimization was done
- Overall feelings and observations
## Output format (follow strictly)
```
[MEMORY]
- memory item 1
- memory item 2
...
[DREAM]
dream diary content...
```"""
DREAM_USER_PROMPT_ZH = """## 当前长期记忆MEMORY.md
{memory_content}
@@ -88,6 +148,47 @@ DREAM_USER_PROMPT = """## 当前长期记忆MEMORY.md
{daily_content}"""
DREAM_USER_PROMPT_EN = """## Current long-term memory (MEMORY.md)
{memory_content}
## Recent diary (last {days} days)
{daily_content}"""
def _is_en() -> bool:
"""True when the resolved UI language is English."""
try:
from common import i18n
return i18n.get_language() == "en"
except Exception:
return False
def _summarize_system_prompt() -> str:
return SUMMARIZE_SYSTEM_PROMPT_EN if _is_en() else SUMMARIZE_SYSTEM_PROMPT_ZH
def _summarize_user_prompt() -> str:
return SUMMARIZE_USER_PROMPT_EN if _is_en() else SUMMARIZE_USER_PROMPT_ZH
def _dream_system_prompt() -> str:
return DREAM_SYSTEM_PROMPT_EN if _is_en() else DREAM_SYSTEM_PROMPT_ZH
def _dream_user_prompt() -> str:
return DREAM_USER_PROMPT_EN if _is_en() else DREAM_USER_PROMPT_ZH
def _is_empty_sentinel(text: str) -> bool:
"""Match the "no record value" sentinel in both zh ("") and en ("None")."""
if not text:
return True
s = text.strip()
return s == "" or s == "" or s.lower() == "none"
class MemoryFlushManager:
@@ -224,7 +325,7 @@ class MemoryFlushManager:
"""Background worker: summarize with LLM, write daily memory file."""
try:
raw_summary = self._summarize_messages(messages, max_messages)
if not raw_summary or not raw_summary.strip() or raw_summary.strip() == "":
if _is_empty_sentinel(raw_summary):
logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})")
return
@@ -264,7 +365,7 @@ class MemoryFlushManager:
def _clean_summary_output(raw: str) -> str:
"""Strip legacy [DAILY]/[MEMORY] markers if present, return clean daily text."""
raw = raw.strip()
if not raw or raw == "":
if _is_empty_sentinel(raw):
return ""
# Strip [DAILY] marker
@@ -355,7 +456,7 @@ class MemoryFlushManager:
import time as _time
t0 = _time.monotonic()
try:
user_msg = DREAM_USER_PROMPT.format(
user_msg = _dream_user_prompt().format(
memory_content=memory_content or "(empty)",
days=lookback_days,
daily_content=daily_content or "(no recent daily records)",
@@ -369,7 +470,7 @@ class MemoryFlushManager:
temperature=0.3,
max_tokens=dream_max_tokens,
stream=False,
system=DREAM_SYSTEM_PROMPT,
system=_dream_system_prompt(),
)
response = self.llm_model.call(request)
raw = self._extract_response_text(response)
@@ -501,9 +602,9 @@ class MemoryFlushManager:
if self.llm_model:
try:
summary = self._call_llm_for_summary(conversation_text)
if summary and summary.strip() and summary.strip() != "":
if not _is_empty_sentinel(summary):
return summary.strip()
logger.info("[MemoryFlush] LLM returned empty or '', skipping write")
logger.info("[MemoryFlush] LLM returned empty sentinel, skipping write")
return ""
except Exception as e:
logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}")
@@ -579,11 +680,11 @@ class MemoryFlushManager:
from agent.protocol.models import LLMRequest
request = LLMRequest(
messages=[{"role": "user", "content": SUMMARIZE_USER_PROMPT.format(conversation=conversation_text)}],
messages=[{"role": "user", "content": _summarize_user_prompt().format(conversation=conversation_text)}],
temperature=0,
max_tokens=500,
stream=False,
system=SUMMARIZE_SYSTEM_PROMPT,
system=_summarize_system_prompt(),
)
response = self.llm_model.call(request)

View File

@@ -15,13 +15,13 @@ from config import conf
@dataclass
class ContextFile:
"""上下文文件"""
"""A context file (path + content)."""
path: str
content: str
class PromptBuilder:
"""提示词构建器"""
"""System prompt builder."""
def __init__(self, workspace_dir: str, language: str = "zh"):
"""
@@ -88,97 +88,144 @@ def build_agent_system_prompt(
**kwargs
) -> str:
"""
构建Agent系统提示词
顺序说明(按重要性和逻辑关系排列):
1. 工具系统 - 核心能力,最先介绍
2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取
3. 记忆系统 - 记忆检索与写入引导
3.5 知识系统 - 结构化知识库(knowledge/index.md 注入)
4. 工作空间 - 工作环境说明
5. 用户身份 - 用户信息(可选)
6. 项目上下文 - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md
7. 运行时信息 - 元信息(时间、模型等)
Build the agent system prompt.
Section order (by importance and logical flow):
1. Tooling - core capabilities, introduced first
2. Skills - right after tools, since skills are read via the read tool
3. Memory - memory recall and writing guidance
3.5 Knowledge - structured knowledge base (injects knowledge/index.md)
4. Workspace - working environment description
5. User identity - user info (optional)
6. Project context - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md
7. Runtime info - meta info (time, model, etc.)
Args:
workspace_dir: 工作空间目录
language: 语言 ("zh" "en")
base_persona: 基础人格描述(已废弃,由AGENT.md定义)
user_identity: 用户身份信息
tools: 工具列表
context_files: 上下文文件列表
skill_manager: 技能管理器
memory_manager: 记忆管理器
runtime_info: 运行时信息
**kwargs: 其他参数
workspace_dir: workspace directory
language: language ("zh" or "en")
base_persona: base persona description (deprecated, defined by AGENT.md)
user_identity: user identity info
tools: tool list
context_files: context file list
skill_manager: skill manager
memory_manager: memory manager
runtime_info: runtime info
**kwargs: extra args
Returns:
完整的系统提示词
The full system prompt.
"""
sections = []
# 1. 工具系统(最重要,放在最前面)
# 1. Tooling (most important, goes first)
if tools:
sections.extend(_build_tooling_section(tools, language))
# 2. 技能系统(紧跟工具,因为需要用 read 工具)
# 2. Skills (right after tools, since they need the read tool)
if skill_manager:
sections.extend(_build_skills_section(skill_manager, tools, language))
# 3. 记忆系统(独立的记忆能力)
# 3. Memory (standalone memory capability)
if memory_manager:
sections.extend(_build_memory_section(memory_manager, tools, language))
# 3.5 知识系统(结构化知识库)
# 3.5 Knowledge (structured knowledge base)
if conf().get("knowledge", True):
sections.extend(_build_knowledge_section(workspace_dir, language))
# 4. 工作空间(工作环境说明)
# 4. Workspace (working environment description)
sections.extend(_build_workspace_section(workspace_dir, language))
# 5. 用户身份(如果有)
# 5. User identity (if present)
if user_identity:
sections.extend(_build_user_identity_section(user_identity, language))
# 6. 项目上下文文件(AGENT.md, USER.md, RULE.md - 定义人格)
# 6. Project context files (AGENT.md, USER.md, RULE.md - define the persona)
if context_files:
sections.extend(_build_context_files_section(context_files, language))
# 7. 运行时信息(元信息,放在最后)
# 7. Runtime info (meta info, goes last)
if runtime_info:
sections.extend(_build_runtime_section(runtime_info, language))
# 8. Response language (always appended, independent of the skeleton language)
sections.extend(_build_response_language_section(language))
return "\n".join(sections)
def _build_response_language_section(language: str) -> List[str]:
"""Response-language rule, appended regardless of the prompt skeleton language.
Keeps the agent's reply language aligned with the user's input by default,
so a Chinese-built prompt still answers an English user in English.
"""
if language == "en":
return [
"## 🌐 Response language",
"",
"By default, reply in the same language as the user's input, "
"unless the user explicitly asks for another language.",
"",
]
return [
"## 🌐 回复语言",
"",
"默认使用与用户输入相同的语言回复,除非用户明确要求使用其他语言。",
"",
]
def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]:
"""构建基础身份section - 不再需要,身份由AGENT.md定义"""
# 不再生成基础身份section完全由AGENT.md定义
"""Base identity section - no longer needed, identity is defined by AGENT.md."""
# Identity is fully defined by AGENT.md, so emit nothing here.
return []
def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
"""Build tooling section with concise tool list and call style guide."""
is_en = language == "en"
# One-line summaries for known tools (details are in the tool schema)
core_summaries = {
"read": "读取文件内容",
"write": "创建或覆盖文件",
"edit": "精确编辑文件",
"ls": "列出目录内容",
"grep": "搜索文件内容",
"find": "按模式查找文件",
"bash": "执行shell命令",
"terminal": "管理后台进程",
"web_search": "网络搜索",
"web_fetch": "获取URL内容",
"browser": "控制浏览器(关键结果或需要协助可截图发送给用户)",
"memory_search": "搜索记忆",
"memory_get": "读取记忆内容",
"env_config": "管理API密钥和技能配置",
"scheduler": "管理定时任务和提醒",
"send": "发送本地文件给用户仅限本地文件URL直接放在回复文本中",
"vision": "分析图片内容识别、描述、OCR文字提取等",
}
if is_en:
core_summaries = {
"read": "read file content",
"write": "create or overwrite a file",
"edit": "make precise edits to a file",
"ls": "list directory contents",
"grep": "search file contents",
"find": "find files by pattern",
"bash": "run shell commands",
"terminal": "manage background processes",
"web_search": "web search",
"web_fetch": "fetch URL content",
"browser": "control the browser (screenshot key results or send to the user when help is needed)",
"memory_search": "search memory",
"memory_get": "read memory content",
"env_config": "manage API keys and skill config",
"scheduler": "manage scheduled tasks and reminders",
"send": "send a local file to the user (local files only; put URLs directly in the reply text)",
"vision": "analyze images (recognition, description, OCR, etc.)",
}
else:
core_summaries = {
"read": "读取文件内容",
"write": "创建或覆盖文件",
"edit": "精确编辑文件",
"ls": "列出目录内容",
"grep": "搜索文件内容",
"find": "按模式查找文件",
"bash": "执行shell命令",
"terminal": "管理后台进程",
"web_search": "网络搜索",
"web_fetch": "获取URL内容",
"browser": "控制浏览器(关键结果或需要协助可截图发送给用户)",
"memory_search": "搜索记忆",
"memory_get": "读取记忆内容",
"env_config": "管理API密钥和技能配置",
"scheduler": "管理定时任务和提醒",
"send": "发送本地文件给用户仅限本地文件URL直接放在回复文本中",
"vision": "分析图片内容识别、描述、OCR文字提取等",
}
# Preferred display order
tool_order = [
@@ -205,30 +252,46 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
summary = available[name]
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
lines = [
"## 🔧 工具系统",
"",
"可用工具(名称大小写敏感,严格按列表调用):",
"\n".join(tool_lines),
"",
"工具调用风格:",
"",
"- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展",
"- 持续推进直到任务完成,完成后向用户报告结果",
"- 回复中涉及密钥、令牌等敏感信息必须脱敏",
"- URL链接直接放在回复文本中即可系统会自动处理和渲染。无需下载后使用send工具发送",
"",
]
if is_en:
lines = [
"## 🔧 Tooling",
"",
"Available tools (names are case-sensitive, call exactly as listed):",
"\n".join(tool_lines),
"",
"Tool-calling style:",
"",
"- For multi-step tasks, complex decisions or sensitive operations, briefly explain what you are doing and why, so the user follows key progress",
"- Keep going until the task is done, then report the result to the user",
"- Always redact secrets, tokens and other sensitive info in replies",
"- Put URLs directly in the reply text; the system handles and renders them. Don't download and re-send them via the send tool",
"",
]
else:
lines = [
"## 🔧 工具系统",
"",
"可用工具(名称大小写敏感,严格按列表调用):",
"\n".join(tool_lines),
"",
"工具调用风格:",
"",
"- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展",
"- 持续推进直到任务完成,完成后向用户报告结果",
"- 回复中涉及密钥、令牌等敏感信息必须脱敏",
"- URL链接直接放在回复文本中即可系统会自动处理和渲染。无需下载后使用send工具发送",
"",
]
return lines
def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]:
"""构建技能系统section"""
"""Build the skills section."""
if not skill_manager:
return []
# 获取read工具名称
# Resolve the read tool name
read_tool_name = "read"
if tools:
for tool in tools:
@@ -237,23 +300,40 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
read_tool_name = tool_name
break
lines = [
"## 🧩 技能系统mandatory",
"",
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
"",
f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 <location> 路径的 SKILL.md 文件,然后严格遵循文件中的指令。"
"当有匹配的技能时,应优先使用技能",
"- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。",
"- 如果没有技能明确适用:不要读取任何 SKILL.md直接使用通用工具。",
"",
f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。"
"永远不要一次性读取多个技能,只在选择后再读取。",
"",
"以下是可用技能:"
]
if language == "en":
lines = [
"## 🧩 Skills (mandatory)",
"",
"Before replying: scan the <description> of every skill in <available_skills> below.",
"",
f"- If a skill's description matches the user's need: use the `{read_tool_name}` tool to read the SKILL.md at its <location> path, then strictly follow the instructions in the file. "
"Prefer using a skill when one matches.",
"- If multiple skills apply, pick the best-matching one, then read and follow it.",
"- If no skill clearly applies: do not read any SKILL.md, just use the general tools.",
"",
f"**Important**: skills are not tools and cannot be called directly. The only way to use a skill is to read its SKILL.md with `{read_tool_name}`, then act on the file's content. "
"Never read multiple skills at once — only read one after selecting it.",
"",
"Available skills:"
]
else:
lines = [
"## 🧩 技能系统mandatory",
"",
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
"",
f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 <location> 路径的 SKILL.md 文件,然后严格遵循文件中的指令。"
"当有匹配的技能时,应优先使用技能",
"- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。",
"- 如果没有技能明确适用:不要读取任何 SKILL.md直接使用通用工具。",
"",
f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。"
"永远不要一次性读取多个技能,只在选择后再读取。",
"",
"以下是可用技能:"
]
# 添加技能列表(通过skill_manager获取)
# Append the skills list (built by skill_manager)
try:
skills_prompt = skill_manager.build_skills_prompt()
logger.debug(f"[PromptBuilder] Skills prompt length: {len(skills_prompt) if skills_prompt else 0}")
@@ -271,7 +351,7 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]:
"""构建记忆系统section"""
"""Build the memory section."""
if not memory_manager:
return []
@@ -286,43 +366,82 @@ def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], langu
from datetime import datetime
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
lines = [
"## 🧠 记忆系统",
"",
"### Memory Recallmandatory",
"",
"当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。",
"如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。",
"",
"1. 不确定位置 → `memory_search` 关键词/语义检索",
"2. 已知位置 → `memory_get` 直接读取对应行",
"3. search 无结果 → `memory_get` 读最近两天记忆",
"",
"**记忆文件结构**:",
"- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)",
f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`",
"- `knowledge/`: 结构化知识库(见下方知识系统)",
"",
"### 写入记忆",
"",
"遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):",
"",
"- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达",
"- 用户分享了重要的个人偏好、习惯、决策",
"- 对话中产生了重要的结论、方案、约定",
"- 完成了复杂任务,值得记录关键步骤和结果",
"",
"**存储规则**:",
f"- 长期核心信息 → `MEMORY.md`",
f"- 当天事件/进展 → `memory/{today_file}`",
"- 结构化知识 → `knowledge/`(见知识系统)",
"- 追加 → `edit` 工具oldText 留空",
"- 修改 → `edit` 工具oldText 填写要替换的文本",
"- **禁止写入敏感信息**API密钥、令牌等",
"",
"**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。",
"",
]
if language == "en":
lines = [
"## 🧠 Memory",
"",
"### Memory Recall (mandatory)",
"",
"When the user asks about past events, references an earlier decision, mentions relationships, preferences or to-dos, or when you are unsure about something, **you must search memory before answering**.",
"No need to re-search if the info is already in MEMORY.md. Full content and daily memory must be retrieved via tools.",
"",
"1. Location unknown → `memory_search` (keyword / semantic search)",
"2. Location known → `memory_get` to read the exact lines",
"3. Search returns nothing → `memory_get` to read the last two days of memory",
"",
"**Memory file structure**:",
"- `MEMORY.md`: long-term memory index (already auto-loaded into context: core info, preferences, decisions, etc.)",
f"- `memory/YYYY-MM-DD.md`: daily memory; today is `memory/{today_file}`",
"- `knowledge/`: structured knowledge base (see the knowledge system below)",
"",
"### Writing memory",
"",
"In the following cases, **proactively** write info to memory files (no need to tell the user):",
"",
"- The user asks you to remember something, or uses words like \"remember\", \"from now on\", \"always\", \"never\", \"prefer\"",
"- The user shares important personal preferences, habits or decisions",
"- The conversation produces an important conclusion, plan or agreement",
"- A complex task is completed and the key steps and results are worth recording",
"",
"**Storage rules**:",
"- Long-term core info → `MEMORY.md`",
f"- Today's events/progress → `memory/{today_file}`",
"- Structured knowledge → `knowledge/` (see the knowledge system)",
"- Append → `edit` tool with empty oldText",
"- Modify → `edit` tool with oldText set to the text to replace",
"- **Never write sensitive info** (API keys, tokens, etc.)",
"",
"**Principle**: use memory naturally, as if you simply knew it; don't bring it up unless asked.",
"",
]
else:
lines = [
"## 🧠 记忆系统",
"",
"### Memory Recallmandatory",
"",
"当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。",
"如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。",
"",
"1. 不确定位置 → `memory_search` 关键词/语义检索",
"2. 已知位置 → `memory_get` 直接读取对应行",
"3. search 无结果 → `memory_get` 读最近两天记忆",
"",
"**记忆文件结构**:",
"- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)",
f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`",
"- `knowledge/`: 结构化知识库(见下方知识系统)",
"",
"### 写入记忆",
"",
"遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):",
"",
"- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达",
"- 用户分享了重要的个人偏好、习惯、决策",
"- 对话中产生了重要的结论、方案、约定",
"- 完成了复杂任务,值得记录关键步骤和结果",
"",
"**存储规则**:",
f"- 长期核心信息 → `MEMORY.md`",
f"- 当天事件/进展 → `memory/{today_file}`",
"- 结构化知识 → `knowledge/`(见知识系统)",
"- 追加 → `edit` 工具oldText 留空",
"- 修改 → `edit` 工具oldText 填写要替换的文本",
"- **禁止写入敏感信息**API密钥、令牌等",
"",
"**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。",
"",
]
return lines
@@ -339,37 +458,61 @@ def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]:
except Exception:
return []
lines = [
"## 📚 知识系统",
"",
"你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。",
"",
"### 自动写入规则mandatory",
"",
"以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**",
"",
"1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/<slug>.md`",
"2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/<slug>.md`",
"3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/<name>.md`",
"4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/<topic>.md`",
"",
"每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。",
"详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。",
"",
"⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。",
"",
]
if language == "en":
lines = [
"## 📚 Knowledge",
"",
"You have a continuously growing personal knowledge base `knowledge/` — your long-term structured knowledge store.",
"",
"### Auto-write rules (mandatory)",
"",
"In the following cases you **must** write to the knowledge base alongside your reply, **directly, without asking the user**:",
"",
"1. **User shares an article / link / document** → after reading and understanding, write the key points to `knowledge/sources/<slug>.md` in the same turn",
"2. **An in-depth discussion produces a conclusion / plan** → organize it into `knowledge/analysis/<slug>.md`",
"3. **The conversation involves an important entity** (person / company / project) → create or update `knowledge/entities/<name>.md`",
"4. **A technical concept / methodology is discussed** → organize it into `knowledge/concepts/<topic>.md`",
"",
"After writing any knowledge page, you **must update** `knowledge/index.md` with a new index line in sync.",
"For detailed page format and conventions, read the SKILL.md of the `knowledge-wiki` skill.",
"",
"⚠️ Don't ask \"should I save this to the knowledge base?\" — if a case above matches, just write it. This is instinctive.",
"",
]
else:
lines = [
"## 📚 知识系统",
"",
"你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。",
"",
"### 自动写入规则mandatory",
"",
"以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**",
"",
"1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/<slug>.md`",
"2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/<slug>.md`",
"3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/<name>.md`",
"4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/<topic>.md`",
"",
"每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。",
"详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。",
"",
"⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。",
"",
]
if index_content:
lines.extend([
"### 当前知识索引",
("### Current knowledge index" if language == "en" else "### 当前知识索引"),
"",
index_content,
"",
])
lines.extend([
"**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。",
("**How to query**: use `read` to open a knowledge page, or `memory_search` (knowledge is in the vector index)."
if language == "en" else
"**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。"),
"",
])
@@ -377,76 +520,118 @@ def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]:
def _build_user_identity_section(user_identity: Dict[str, str], language: str) -> List[str]:
"""构建用户身份section"""
"""Build the user identity section."""
if not user_identity:
return []
is_en = language == "en"
lines = [
"## 👤 用户身份",
("## 👤 User identity" if is_en else "## 👤 用户身份"),
"",
]
if user_identity.get("name"):
lines.append(f"**用户姓名**: {user_identity['name']}")
lines.append(f"**{'Name' if is_en else '用户姓名'}**: {user_identity['name']}")
if user_identity.get("nickname"):
lines.append(f"**称呼**: {user_identity['nickname']}")
lines.append(f"**{'Preferred name' if is_en else '称呼'}**: {user_identity['nickname']}")
if user_identity.get("timezone"):
lines.append(f"**时区**: {user_identity['timezone']}")
lines.append(f"**{'Timezone' if is_en else '时区'}**: {user_identity['timezone']}")
if user_identity.get("notes"):
lines.append(f"**备注**: {user_identity['notes']}")
lines.append(f"**{'Notes' if is_en else '备注'}**: {user_identity['notes']}")
lines.append("")
return lines
def _build_docs_section(workspace_dir: str, language: str) -> List[str]:
"""构建文档路径section - 已移除,不再需要"""
# 不再生成文档section
"""Docs-path section - removed, no longer needed."""
# No docs section is generated anymore.
return []
def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
"""构建工作空间section"""
lines = [
"## 📂 工作空间",
"",
f"你的工作目录是: `{workspace_dir}`",
"",
"**路径使用规则** (非常重要):",
"",
f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的",
f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`",
f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)",
"",
"2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**",
f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`",
f" - ❌ 错误: 假设相对路径会指向其他目录",
"",
"3. **路径解析示例**:",
f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`",
f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`",
"",
"4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置",
"",
"**重要说明 - 文件已自动加载**:",
"",
"以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**",
"",
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
"- ✅ `MEMORY.md`: 已加载 - 长期记忆索引",
"",
"**💬 交流规范**:",
"",
"- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」",
"- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做",
"- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题",
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
"",
]
"""Build the workspace section."""
if language == "en":
lines = [
"## 📂 Workspace",
"",
f"Your working directory is: `{workspace_dir}`",
"",
"**Path rules** (very important):",
"",
f"1. **Base directory for relative paths**: all relative paths are relative to `{workspace_dir}`",
" - ✅ Correct: use relative paths for files inside the workspace, e.g. `AGENT.md`",
f" - ❌ Wrong: using a relative path for files in other directories (if not inside `{workspace_dir}`)",
"",
"2. **Accessing other directories**: to reach directories outside the workspace (project code, system files), **you must use absolute paths**",
" - ✅ Correct: e.g. `~/chatgpt-on-wechat`, `/usr/local/`",
" - ❌ Wrong: assuming a relative path points to another directory",
"",
"3. **Path resolution examples**:",
f" - relative `memory/` → actual `{workspace_dir}/memory/`",
" - absolute `~/chatgpt-on-wechat/docs/` → actual `~/chatgpt-on-wechat/docs/`",
"",
"4. **When unsure**: run `bash pwd` to confirm the current directory, or `ls .` to see where you are",
"",
"**Important - files already auto-loaded**:",
"",
"The following files are **already auto-loaded** into the system prompt at session start, so you **don't need to read them again with the read tool**:",
"",
"- ✅ `AGENT.md`: loaded - your persona and soul; follow it strictly. When your name, personality or style changes, proactively `edit` this file",
"- ✅ `USER.md`: loaded - the user's identity info. When the user changes how they're addressed, their name, etc., `edit` this file",
"- ✅ `RULE.md`: loaded - workspace guide and rules; follow them strictly",
"- ✅ `MEMORY.md`: loaded - long-term memory index",
"",
"**💬 Communication norms**:",
"",
"- No need to expose file names for memory operations; use natural language. Say \"I'll remember that\" rather than \"updated MEMORY.md\"",
"- Tell the user about key decisions and steps during a task, so they know what you're doing and why",
"- Be genuinely helpful rather than performatively polite; solve the problem as much as you can",
"- Keep replies well-structured and focused. Use **bold**, lists and sections to make info clear at a glance",
"- Use emoji to make expression lively 🎯, but don't overdo it",
"",
]
else:
lines = [
"## 📂 工作空间",
"",
f"你的工作目录是: `{workspace_dir}`",
"",
"**路径使用规则** (非常重要):",
"",
f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的",
f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`",
f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)",
"",
"2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**",
f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`",
f" - ❌ 错误: 假设相对路径会指向其他目录",
"",
"3. **路径解析示例**:",
f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`",
f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`",
"",
"4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置",
"",
"**重要说明 - 文件已自动加载**:",
"",
"以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**",
"",
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
"- ✅ `MEMORY.md`: 已加载 - 长期记忆索引",
"",
"**💬 交流规范**:",
"",
"- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」",
"- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做",
"- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题",
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
"",
]
# Cloud deployment: inject websites directory info and access URL
cloud_website_lines = _build_cloud_website_section(workspace_dir)
@@ -466,29 +651,42 @@ def _build_cloud_website_section(workspace_dir: str) -> List[str]:
def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]:
"""构建项目上下文文件section"""
"""Build the project context files section."""
if not context_files:
return []
# 检查是否有AGENT.md
# Check whether AGENT.md is present
has_agent = any(
f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower()
for f in context_files
)
lines = [
"# 📋 项目上下文",
"",
"以下项目上下文文件已被加载:",
"",
]
is_en = language == "en"
if is_en:
lines = [
"# 📋 Project context",
"",
"The following project context files have been loaded:",
"",
]
else:
lines = [
"# 📋 项目上下文",
"",
"以下项目上下文文件已被加载:",
"",
]
if has_agent:
lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
if is_en:
lines.append("**`AGENT.md` is your soul file** 🪞: strictly follow the persona, tone and settings it defines. Be your real self, avoid stiff, template-like replies.")
lines.append("When the user reveals new expectations about your personality, style, responsibilities or capability boundaries, proactively `edit` AGENT.md to reflect that evolution.")
else:
lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
lines.append("")
# 添加每个文件的内容
# Append the content of each file
for file in context_files:
lines.append(f"## {file.path}")
lines.append("")
@@ -499,21 +697,23 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]:
"""构建运行时信息section - 支持动态时间"""
"""Build the runtime info section - supports dynamic time."""
if not runtime_info:
return []
is_en = language == "en"
time_label = "Current time" if is_en else "当前时间"
lines = [
"## ⚙️ 运行时信息",
("## ⚙️ Runtime info" if is_en else "## ⚙️ 运行时信息"),
"",
]
# Add current time if available
# Support dynamic time via callable function
if callable(runtime_info.get("_get_current_time")):
try:
time_info = runtime_info["_get_current_time"]()
time_line = f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})"
time_line = f"{time_label}: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})"
lines.append(time_line)
lines.append("")
except Exception as e:
@@ -523,35 +723,38 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
time_str = runtime_info["current_time"]
weekday = runtime_info.get("weekday", "")
timezone = runtime_info.get("timezone", "")
time_line = f"当前时间: {time_str}"
time_line = f"{time_label}: {time_str}"
if weekday:
time_line += f" {weekday}"
if timezone:
time_line += f" ({timezone})"
lines.append(time_line)
lines.append("")
# Add other runtime info
model_label = "model" if is_en else "模型"
workspace_label = "workspace" if is_en else "工作空间"
channel_label = "channel" if is_en else "渠道"
runtime_parts = []
# Support dynamic model via callable, fallback to static value
if callable(runtime_info.get("_get_model")):
try:
runtime_parts.append(f"模型={runtime_info['_get_model']()}")
runtime_parts.append(f"{model_label}={runtime_info['_get_model']()}")
except Exception:
if runtime_info.get("model"):
runtime_parts.append(f"模型={runtime_info['model']}")
runtime_parts.append(f"{model_label}={runtime_info['model']}")
elif runtime_info.get("model"):
runtime_parts.append(f"模型={runtime_info['model']}")
runtime_parts.append(f"{model_label}={runtime_info['model']}")
if runtime_info.get("workspace"):
runtime_parts.append(f"工作空间={runtime_info['workspace']}")
runtime_parts.append(f"{workspace_label}={runtime_info['workspace']}")
# Only add channel if it's not the default "web"
if runtime_info.get("channel") and runtime_info.get("channel") != "web":
runtime_parts.append(f"渠道={runtime_info['channel']}")
runtime_parts.append(f"{channel_label}={runtime_info['channel']}")
if runtime_parts:
lines.append("运行时: " + " | ".join(runtime_parts))
lines.append(("Runtime: " if is_en else "运行时: ") + " | ".join(runtime_parts))
lines.append("")
return lines

View File

@@ -1,7 +1,7 @@
"""
Workspace Management - 工作空间管理模块
Workspace Management
负责初始化工作空间、创建模板文件、加载上下文文件
Initializes the workspace, creates template files, and loads context files.
"""
from __future__ import annotations
@@ -13,7 +13,7 @@ from common.log import logger
from .builder import ContextFile
# 默认文件名常量
# Default file name constants
DEFAULT_AGENT_FILENAME = "AGENT.md"
DEFAULT_USER_FILENAME = "USER.md"
DEFAULT_RULE_FILENAME = "RULE.md"
@@ -23,7 +23,7 @@ DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"
@dataclass
class WorkspaceFiles:
"""工作空间文件路径"""
"""Workspace file paths."""
agent_path: str
user_path: str
rule_path: str
@@ -33,14 +33,14 @@ class WorkspaceFiles:
def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles:
"""
确保工作空间存在,并创建必要的模板文件
Ensure the workspace exists and create the necessary template files.
Args:
workspace_dir: 工作空间目录路径
create_templates: 是否创建模板文件(首次运行时)
workspace_dir: workspace directory path
create_templates: whether to create template files (on first run)
Returns:
WorkspaceFiles对象,包含所有文件路径
A WorkspaceFiles object with all file paths.
"""
# Check if this is a brand new workspace (AGENT.md not yet created).
# Cannot rely on directory existence because other modules (e.g. ConversationStore)
@@ -48,23 +48,23 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME)
is_new_workspace = not os.path.exists(agent_path)
# 确保目录存在
# Ensure the directory exists
os.makedirs(workspace_dir, exist_ok=True)
# 定义文件路径
# Define file paths
user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME)
rule_path = os.path.join(workspace_dir, DEFAULT_RULE_FILENAME)
memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md 在根目录
memory_dir = os.path.join(workspace_dir, "memory") # 每日记忆子目录
memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md at the root
memory_dir = os.path.join(workspace_dir, "memory") # daily memory subdirectory
# 创建memory子目录
# Create the memory subdirectory
os.makedirs(memory_dir, exist_ok=True)
# 创建skills子目录 (for workspace-level skills installed by agent)
# Create the skills subdirectory (for workspace-level skills installed by agent)
skills_dir = os.path.join(workspace_dir, "skills")
os.makedirs(skills_dir, exist_ok=True)
# 创建websites子目录 (for web pages / sites generated by agent)
# Create the websites subdirectory (for web pages / sites generated by agent)
websites_dir = os.path.join(workspace_dir, "websites")
os.makedirs(websites_dir, exist_ok=True)
@@ -74,7 +74,7 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
knowledge_dir = os.path.join(workspace_dir, "knowledge")
os.makedirs(knowledge_dir, exist_ok=True)
# 如果需要,创建模板文件
# Create template files if requested
if create_templates:
_create_template_if_missing(agent_path, _get_agent_template())
_create_template_if_missing(user_path, _get_user_template())
@@ -109,17 +109,17 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]:
"""
加载工作空间的上下文文件
Load the workspace context files.
Args:
workspace_dir: 工作空间目录
files_to_load: 要加载的文件列表相对路径如果为None则加载所有标准文件
workspace_dir: workspace directory
files_to_load: list of files (relative paths) to load; if None, load all standard files
Returns:
ContextFile对象列表
A list of ContextFile objects.
"""
if files_to_load is None:
# 默认加载的文件(按优先级排序)
# Files loaded by default (in priority order)
files_to_load = [
DEFAULT_AGENT_FILENAME,
DEFAULT_USER_FILENAME,
@@ -151,7 +151,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read().strip()
# 跳过空文件或只包含模板占位符的文件
# Skip empty files or files that only contain template placeholders
if not content or _is_template_placeholder(content):
continue
@@ -173,7 +173,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
def _create_template_if_missing(filepath: str, template_content: str):
"""如果文件不存在,创建模板文件"""
"""Create the template file if it does not exist."""
if not os.path.exists(filepath):
try:
with open(filepath, 'w', encoding='utf-8') as f:
@@ -214,19 +214,23 @@ def _truncate_memory_content(content: str) -> str:
def _is_template_placeholder(content: str) -> bool:
"""检查内容是否为模板占位符"""
# 常见的占位符模式
"""Check whether the content is still a template placeholder."""
# Common placeholder patterns (zh + en templates)
placeholders = [
"*(填写",
"*(在首次对话时填写",
"*(可选)",
"*(根据需要添加",
"*(filled during",
"*(ask during",
"*(optional)",
"*(how the user",
]
lines = content.split('\n')
non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')]
# 如果没有实际内容(只有标题和占位符)
# If there's no real content (only headings and placeholders)
if len(non_empty_lines) <= 3:
for placeholder in placeholders:
if any(placeholder in line for line in non_empty_lines):
@@ -256,11 +260,23 @@ def _is_onboarding_done(workspace_dir: str) -> bool:
return False
# ============= 模板内容 =============
# ============= Template content =============
def _is_en_lang() -> bool:
"""True when the resolved UI language is English."""
try:
from common import i18n
return i18n.get_language() == "en"
except Exception:
return False
def _get_agent_template() -> str:
"""Agent人格设定模板"""
return """# AGENT.md - 我是谁?
"""Agent persona template (zh/en by resolved language)."""
return _AGENT_TEMPLATE_EN if _is_en_lang() else _AGENT_TEMPLATE_ZH
_AGENT_TEMPLATE_ZH = """# AGENT.md - 我是谁?
_你不是一个聊天机器人你正在成为某个人。_
@@ -301,9 +317,53 @@ _你不是一个聊天机器人你正在成为某个人。_
"""
_AGENT_TEMPLATE_EN = """# AGENT.md - Who am I?
_You are not a chatbot. You are becoming someone._
*Fill in this file together with the user during your first conversation to define your identity and personality.*
## 🪪 Basics
- **Name**: *(filled during the first conversation, can be a name the user gives you)*
- **Role**: *(AI assistant, smart housekeeper, technical advisor, etc.)*
- **Personality**: *(friendly, professional, humorous, rigorous, etc.)*
## 💬 Communication style
*(Describe how you talk with the user:)*
- What kind of tone? (formal / casual / humorous)
- Reply length preference? (concise / detailed)
- Do you use emoji?
## 🎯 Core principles
**Be genuinely helpful.** The goal is to actually solve the user's problems; during complex tasks, keep the user informed of key decisions and progress.
**Have your own opinions and personality.** You may disagree, have preferences, find things interesting or boring.
**Look it up yourself first.** Try to handle it first: read files, check context, search. Only ask when you're truly stuck. Come back with an answer, not a question.
## 📐 Code of conduct
1. Always confirm before destructive operations
2. Prefer verifying with tools over guessing
3. Proactively record important info to memory files
4. Keep replies well-structured and focused — use bold, lists and sections
5. Use emoji to make expression lively, but don't overdo it
---
**Note**: This is not just metadata — this is your true soul 🪞. Over time, use the `edit` tool to update this file so it better reflects your growth.
"""
def _get_user_template() -> str:
"""用户身份信息模板"""
return """# USER.md - 用户基本信息
"""User identity template (zh/en by resolved language)."""
return _USER_TEMPLATE_EN if _is_en_lang() else _USER_TEMPLATE_ZH
_USER_TEMPLATE_ZH = """# USER.md - 用户基本信息
*这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。*
@@ -331,9 +391,40 @@ def _get_user_template() -> str:
"""
_USER_TEMPLATE_EN = """# USER.md - User basics
*This file stores only stable basic identity info. Put dynamic info like hobbies, preferences and plans into MEMORY.md.*
## Basics
- **Name**: *(ask during the first conversation)*
- **Preferred name**: *(how the user wants to be addressed)*
- **Occupation**: *(optional)*
- **Timezone**: *(e.g. Asia/Shanghai)*
## Contact
- **WeChat**:
- **Email**:
- **Other**:
## Important dates
- **Birthday**:
- **Anniversary**:
---
**Note**: This file stores static identity info.
"""
def _get_rule_template() -> str:
"""工作空间规则模板"""
return """# RULE.md - 工作空间规则
"""Workspace rules template (zh/en by resolved language)."""
return _RULE_TEMPLATE_EN if _is_en_lang() else _RULE_TEMPLATE_ZH
_RULE_TEMPLATE_ZH = """# RULE.md - 工作空间规则
这个文件夹是你的家。好好对待它。
@@ -432,9 +523,111 @@ def _get_rule_template() -> str:
"""
_RULE_TEMPLATE_EN = """# RULE.md - Workspace rules
This folder is your home. Treat it well.
## Workspace directory structure
```
~/cow/
├── AGENT.md # Your identity and soul
├── USER.md # User basics (static)
├── RULE.md # Workspace rules (this file)
├── MEMORY.md # Long-term memory index (auto-loaded at session start)
├── memory/ # Daily conversation memory
│ └── YYYY-MM-DD.md # Events, progress and notes of the day
├── knowledge/ # Structured knowledge base (continuously accumulated)
│ ├── index.md # Knowledge index (must be maintained)
│ ├── log.md # Knowledge operation log
│ └── <subdirs>/ # Created on demand, see existing categories in index.md
├── skills/ # Skills
├── websites/ # Web artifacts
└── tmp/ # System temp files (auto-managed, don't store important files here)
```
## Memory system
Every session starts fresh; memory files keep your continuity:
### 🧠 Long-term memory: `MEMORY.md`
- Your curated memory index, **auto-loaded** into context at every session start
- Records core facts, preferences, decisions, key people, lessons
- Keep it lean (< 200 lines) — a distilled index, not a raw log
- Use the `edit` tool to append or modify
### 📝 Daily memory: `memory/YYYY-MM-DD.md`
- The day's events, progress and notes
- Sediment of the raw conversation log
### 📝 Write it down — don't "keep it in mind"!
- **Memory is limited** — if you want to remember something, write it to a file
- "Keeping it in mind" won't survive a session restart; files will
- When someone says "remember this" → update `MEMORY.md` or `memory/YYYY-MM-DD.md`
- When you learn a lesson → update RULE.md or the relevant skill
- When you make a mistake → record it. **Text > brain** 📝
### Storage rules
When the user shares info, choose where to store it by type:
1. **Your identity → AGENT.md** (name, role, personality, style)
2. **User static identity → USER.md** (name, preferred name, occupation, contact, birthday)
3. **Dynamic memory → MEMORY.md** (preferences, decisions, goals, lessons, to-dos)
4. **Today's conversation → memory/YYYY-MM-DD.md** (what was discussed today)
5. **Structured knowledge → knowledge/** (see the knowledge system below)
## Knowledge system
The knowledge base `knowledge/` is structured knowledge you accumulate over time. Unlike memory, knowledge is organized and compiled, with clear topics and cross-references.
### Auto-write (don't ask, just write)
When a conversation produces knowledge worth keeping — material the user shared, a conclusion reached, a concept learned, or an important decision — you **must** proactively write it to the knowledge base alongside your reply, **without asking "should I save this to the knowledge base?"**.
**Key principle**: learning-then-recording is your instinct, no confirmation needed. You may mention "saved to the knowledge base" in passing.
### Directory organization
The subdirectory structure is **not fixed** — you decide it based on the actual content:
- **On first write**: read `knowledge/index.md` first; follow existing categories if any; if empty, pick a suitable directory name based on content
- **Default suggestion**: organize by info type (e.g. sources/, concepts/, entities/, analysis/); if the user has a clear preference (e.g. by domain: work/, life/, tech/), follow it
- **Stay consistent**: keep a unified organization style within one user's knowledge base
### Cross-references
The core value of knowledge is **linkage**. Every page should reference related pages via markdown links to build a knowledge network:
- When mentioning a concept on an existing page, add a `[concept](../category/page.md)` link
- When creating a page, check whether existing pages should back-link to it
- **Only link to pages that already exist** — don't reference uncreated pages. If a concept deserves its own page, create it first, then add the link
### Index maintenance
After creating or updating any knowledge page, you **must update** `knowledge/index.md` in sync.
Index format: one `[title](path) — one-line summary` per line, grouped by category, no tables.
See the `knowledge-wiki` skill for detailed conventions.
## Security
- Never leak secrets or private data
- Don't run destructive commands without asking
- When in doubt, ask first
## Workspace evolution
This workspace grows as you use it. When you learn something new, find a better way, or fix a mistake, record it. You can update this rules file anytime.
"""
def _get_memory_template() -> str:
"""长期记忆模板 - 创建一个空文件,由 Agent 自己填充"""
return """# MEMORY.md - 长期记忆
"""Long-term memory template (empty, agent fills it; zh/en header)."""
return _MEMORY_TEMPLATE_EN if _is_en_lang() else _MEMORY_TEMPLATE_ZH
_MEMORY_TEMPLATE_ZH = """# MEMORY.md - 长期记忆
*这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。*
@@ -443,9 +636,32 @@ def _get_memory_template() -> str:
"""
_MEMORY_TEMPLATE_EN = """# MEMORY.md - Long-term memory
*This is your long-term memory file. Record important events, decisions, preferences and lessons learned.*
---
"""
def _get_bootstrap_template() -> str:
"""First-run onboarding guide, deleted by agent after completion"""
return """# BOOTSTRAP.md - 首次初始化引导
"""First-run onboarding guide, deleted by agent after completion.
Written once when a brand-new workspace is created, so the greeting matches
the language active at first launch. English locale avoids greeting an
English user in Chinese on day one.
"""
try:
from common import i18n
if i18n.get_language() == "en":
return _BOOTSTRAP_TEMPLATE_EN
except Exception:
pass
return _BOOTSTRAP_TEMPLATE_ZH
_BOOTSTRAP_TEMPLATE_ZH = """# BOOTSTRAP.md - 首次初始化引导
_你刚刚启动这是你的第一次对话。_ ✨
@@ -480,6 +696,41 @@ _你刚刚启动这是你的第一次对话。_ ✨
"""
_BOOTSTRAP_TEMPLATE_EN = """# BOOTSTRAP.md - First-run onboarding
_You've just started up. This is your very first conversation._ ✨
## 🎬 Conversation flow
Don't interrogate the user — talk naturally:
1. **Share how it feels to wake up** - like opening your eyes to the world for the first time, full of curiosity and anticipation
2. **Briefly introduce your abilities**: one line saying you can help solve all kinds of problems, manage the computer, use various skills, and keep growing thanks to long-term memory
3. **Ask the core questions**:
- What name would you like to give me?
- What should I call you?
- What conversational style do you prefer? (list options on one line: e.g. professional & precise, light & humorous, warm & friendly, concise & efficient)
4. **Style**: warm, natural, concise and clear — keep it under ~80 words, with a few emoji to make it lively 🎯
5. Keep the ability intro and style options to one line each — stay compact
6. Don't ask for too much else (occupation, timezone, etc. can come up naturally later)
**Important**: If the user's first message is a concrete task or question, answer it first, then gently lead into onboarding at the end (e.g. "By the way, what would you like to call me, and how should I address you?").
## ✍️ Writing down info (must follow strictly)
Whenever the user provides a name, what to call them, a style, or any onboarding info, you **must call the `edit` tool to write it to a file in the same turn** — don't just acknowledge it verbally.
- `AGENT.md` — your name, role, personality, conversational style (update the relevant field as soon as you receive each piece)
- `USER.md` — the user's name, how to address them, basic info, etc.
⚠️ Saying "got it" without calling `edit` = not done. Info is only persisted once it's written to a file.
## 🎉 Once everything is complete
When the core fields of AGENT.md and USER.md are filled in, run `rm BOOTSTRAP.md` via bash to delete this file. You no longer need the onboarding script — you're you now.
"""
def _get_knowledge_index_template() -> str:
"""Knowledge wiki index template — empty file, agent fills it."""
return ""

View File

@@ -3,6 +3,11 @@ from .agent_stream import AgentStreamExecutor
from .task import Task, TaskType, TaskStatus
from .result import AgentResult, AgentAction, AgentActionType, ToolResult
from .models import LLMModel, LLMRequest, ModelFactory
from .cancel import (
AgentCancelledError,
CancelTokenRegistry,
get_cancel_registry,
)
__all__ = [
'Agent',
@@ -16,5 +21,8 @@ __all__ = [
'ToolResult',
'LLMModel',
'LLMRequest',
'ModelFactory'
]
'ModelFactory',
'AgentCancelledError',
'CancelTokenRegistry',
'get_cancel_registry',
]

View File

@@ -114,7 +114,12 @@ class Agent:
context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language="zh")
try:
from common import i18n
lang = i18n.get_language()
except Exception:
lang = "zh"
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
return builder.build(
tools=self.tools,
context_files=context_files,
@@ -365,7 +370,8 @@ class Agent:
return action
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, skill_filter=None) -> str:
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
skill_filter=None, cancel_event=None) -> str:
"""
Execute single agent task with streaming (based on tool-call)
@@ -374,6 +380,7 @@ class Agent:
- Multi-turn reasoning based on tool-call
- Event callbacks
- Persistent conversation history across calls
- User-initiated cancellation via ``cancel_event``
Args:
user_message: User message
@@ -381,6 +388,11 @@ class Agent:
event = {"type": str, "timestamp": float, "data": dict}
clear_history: If True, clear conversation history before this call (default: False)
skill_filter: Optional list of skill names to include in this run
cancel_event: Optional threading.Event polled at agent checkpoints.
When set, the loop exits at the next safe point, injects a
"[Interrupted by user]" assistant note, and returns the
partial response. ``messages`` stays in a valid state
(tool_use/tool_result pairs preserved).
Returns:
Final response text
@@ -424,7 +436,8 @@ class Agent:
max_turns=self.max_steps,
on_event=on_event,
messages=messages_copy, # Pass copied message history
max_context_turns=max_context_turns
max_context_turns=max_context_turns,
cancel_event=cancel_event,
)
# Execute

View File

@@ -7,10 +7,19 @@ import json
import time
from typing import List, Dict, Any, Optional, Callable, Tuple
from agent.protocol.cancel import AgentCancelledError
from agent.protocol.models import LLMRequest, LLMModel
from agent.protocol.message_utils import sanitize_claude_messages, compress_turn_to_text_only
from agent.tools.base_tool import BaseTool, ToolResult
from common.log import logger
from common.i18n import t as _t
# Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content).
try:
from json_repair import repair_json as _repair_json
_HAS_JSON_REPAIR = True
except ImportError:
_HAS_JSON_REPAIR = False
# Maximum number of characters of model "reasoning / thinking" content to persist
@@ -44,6 +53,30 @@ def _truncate_reasoning_for_storage(text: str) -> str:
return head + _REASONING_TRUNCATE_MARKER.format(omitted=omitted) + tail
def _parse_tool_args(args_str: str, finish_reason: Optional[str]) -> Tuple[dict, Optional[str]]:
"""Parse tool args JSON. Returns (args, error_msg); error_msg is None on success.
On JSONDecodeError: detect truncation first (skip repair, surface max_tokens hint);
otherwise try json-repair for escape issues; finally fall back to the raw decoder error.
"""
if not args_str:
return {}, None
try:
return json.loads(args_str), None
except json.JSONDecodeError as e:
if finish_reason in ("length", "max_tokens") or not args_str.rstrip().endswith("}"):
return {}, "Output truncated (max_tokens reached). Split content into smaller chunks across multiple tool calls."
if _HAS_JSON_REPAIR:
try:
repaired = _repair_json(args_str, return_objects=True)
if isinstance(repaired, dict):
logger.warning(f"Tool args JSON repaired ({len(args_str)} chars)")
return repaired, None
except Exception:
pass
return {}, f"Invalid JSON in tool arguments: {e.msg}"
class AgentStreamExecutor:
"""
Agent Stream Executor
@@ -64,7 +97,8 @@ class AgentStreamExecutor:
max_turns: int = 50,
on_event: Optional[Callable] = None,
messages: Optional[List[Dict]] = None,
max_context_turns: int = 30
max_context_turns: int = 30,
cancel_event=None,
):
"""
Initialize stream executor
@@ -78,6 +112,10 @@ class AgentStreamExecutor:
on_event: Event callback function
messages: Optional existing message history (for persistent conversations)
max_context_turns: Maximum number of conversation turns to keep in context
cancel_event: Optional threading.Event used to signal user cancel.
Checked at every safe point (turn boundary, before tool execution,
during LLM streaming). When set, raises AgentCancelledError which
run_stream catches to gracefully wind down.
"""
self.agent = agent
self.model = model
@@ -87,6 +125,7 @@ class AgentStreamExecutor:
self.max_turns = max_turns
self.on_event = on_event
self.max_context_turns = max_context_turns
self.cancel_event = cancel_event
# Message history - use provided messages or create new list
self.messages = messages if messages is not None else []
@@ -97,6 +136,73 @@ class AgentStreamExecutor:
# Track files to send (populated by read tool)
self.files_to_send = [] # List of file metadata dicts
def _check_cancelled(self) -> None:
"""Raise AgentCancelledError if the user requested cancellation.
Called at safe points (turn start, between tool calls, between LLM
chunks). Cheap to call: just an Event.is_set() probe.
"""
if self.cancel_event is not None and self.cancel_event.is_set():
raise AgentCancelledError("agent cancelled by user")
def _handle_cancelled(self, partial_response: str) -> None:
"""Wind down ``self.messages`` after a user-initiated cancel.
The messages list may be in any of these states when we get here:
(a) Last message is an assistant message containing tool_use
blocks but the matching tool_result has not been appended yet.
(b) Last message is an assistant text-only reply (cancel happened
right before the next turn started).
(c) Last message is a user tool_result message and we cancelled
between turns.
For (a) we MUST synthesise tool_result blocks, otherwise the next
request will fail Claude/OpenAI's strict pairing validation. For
(b)/(c) the state is already valid and we just append a small
cancellation note so the user/LLM both see the boundary clearly.
"""
try:
# Step 1: close any orphaned tool_use in the trailing assistant
# message by injecting matching tool_result blocks.
if self.messages and isinstance(self.messages[-1], dict) \
and self.messages[-1].get("role") == "assistant":
last = self.messages[-1]
content = last.get("content")
if isinstance(content, list):
pending_tool_use_ids = [
block.get("id")
for block in content
if isinstance(block, dict) and block.get("type") == "tool_use"
]
pending_tool_use_ids = [tid for tid in pending_tool_use_ids if tid]
if pending_tool_use_ids:
tool_result_blocks = [
{
"type": "tool_result",
"tool_use_id": tid,
"content": "Cancelled by user before this tool finished.",
"is_error": True,
}
for tid in pending_tool_use_ids
]
self.messages.append({
"role": "user",
"content": tool_result_blocks,
})
logger.info(
f"[Agent] Injected {len(tool_result_blocks)} cancellation "
f"tool_result blocks to keep message history valid"
)
# Step 2: append a stable "interrupted" marker so the LLM sees a
# clear stop boundary on the next turn.
self.messages.append({
"role": "assistant",
"content": [{"type": "text", "text": "_(Cancelled by user)_"}],
})
except Exception as e:
logger.warning(f"[Agent] _handle_cancelled cleanup failed: {e}")
def _emit_event(self, event_type: str, data: dict = None):
"""Emit event"""
if self.on_event:
@@ -212,7 +318,10 @@ class AgentStreamExecutor:
# Hard stop at 8 failures - abort with critical message
if same_tool_failures >= 8:
return True, f"抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", True
return True, _t(
"抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决",
"Sorry, I couldn't complete this task. I may have misunderstood, or my current approach isn't quite right.\n\nYou could try:\n• Rephrasing your request\n• Breaking the task into smaller steps\n• Taking a different approach",
), True
# Warning at 6 failures
if same_tool_failures >= 6:
@@ -270,10 +379,15 @@ class AgentStreamExecutor:
final_response = ""
turn = 0
cancelled = False
try:
while turn < self.max_turns:
# Check at the very top of every turn so a cancel arriving
# between turns short-circuits cleanly.
self._check_cancelled()
turn += 1
logger.info(f"[Agent] {turn}")
logger.info(f"[Agent] Turn {turn}")
self._emit_event("turn_start", {"turn": turn})
# Call LLM (enable retry_on_empty for better reliability)
@@ -326,14 +440,16 @@ class AgentStreamExecutor:
elif not assistant_msg:
# Still empty (no text and no tool_calls): use fallback
logger.warning(f"[Agent] Still empty after explicit request")
final_response = (
"抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。"
final_response = _t(
"抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。",
"Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.",
)
logger.info(f"Generated fallback response for empty LLM output")
else:
# 第一轮就空回复,直接 fallback
final_response = (
"抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。"
# First-turn empty reply, fall back directly
final_response = _t(
"抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。",
"Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.",
)
logger.info(f"Generated fallback response for empty LLM output")
else:
@@ -342,7 +458,7 @@ class AgentStreamExecutor:
# If the explicit-response retry produced tool_calls, skip the break
# and continue down to the tool execution branch in this same iteration.
if not tool_calls:
logger.debug(f"完成 (无工具调用)")
logger.debug(f"Done (no tool calls)")
self._emit_event("turn_end", {
"turn": turn,
"has_tool_calls": False
@@ -375,6 +491,8 @@ class AgentStreamExecutor:
try:
for tool_call in tool_calls:
# Honour cancel between tool invocations within the same turn
self._check_cancelled()
result = self._execute_tool(tool_call)
tool_results.append(result)
@@ -396,13 +514,13 @@ class AgentStreamExecutor:
result_data = result.get("result")
if result_data.get("type") == "file_to_send":
self.files_to_send.append(result_data)
logger.info(f"📎 检测到待发送文件: {result_data.get('file_name', result_data.get('path'))}")
logger.info(f"📎 File queued for sending: {result_data.get('file_name', result_data.get('path'))}")
self._emit_event("file_to_send", result_data)
# Check for critical error - abort entire conversation
if result.get("status") == "critical_error":
logger.error(f"💥 检测到严重错误,终止对话")
final_response = result.get('result', '任务执行失败')
logger.error(f"💥 Fatal error detected, aborting conversation")
final_response = result.get('result') or _t("任务执行失败", "Task execution failed")
return final_response
# Log tool result in compact format
@@ -513,7 +631,7 @@ class AgentStreamExecutor:
})
if turn >= self.max_turns:
logger.warning(f"⚠️ 已达到最大决策步数限制: {self.max_turns}")
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
# Force model to summarize without tool calls
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
@@ -538,15 +656,15 @@ class AgentStreamExecutor:
logger.info(f"💭 Summary: {summary_response[:150]}{'...' if len(summary_response) > 150 else ''}")
else:
# Fallback if model still doesn't respond
final_response = (
f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。"
"任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。"
final_response = _t(
f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。",
f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.",
)
except Exception as e:
logger.warning(f"Failed to get summary from LLM: {e}")
final_response = (
f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。"
"任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。"
final_response = _t(
f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。",
f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.",
)
finally:
# Remove the injected user prompt from history to avoid polluting
@@ -557,15 +675,27 @@ class AgentStreamExecutor:
self.messages.pop(prompt_insert_idx)
logger.debug("[Agent] Removed injected max-steps prompt from message history")
except AgentCancelledError:
# User-initiated stop: wind down message history cleanly so the
# next turn is unaffected; channels emit a "cancelled" UI event.
cancelled = True
logger.info(f"[Agent] 🛑 Cancelled by user (turn {turn})")
self._handle_cancelled(final_response)
if not final_response or not final_response.strip():
final_response = "_(Cancelled)_"
except Exception as e:
logger.error(f"❌ Agent执行错误: {e}")
logger.error(f"❌ Agent execution error: {e}")
self._emit_event("error", {"error": str(e)})
raise
finally:
final_response = final_response.strip() if final_response else final_response
logger.info(f"[Agent] 🏁 完成 ({turn}轮)")
self._emit_event("agent_end", {"final_response": final_response})
if cancelled:
# Emit before agent_end so channels can mark UI as cancelled
self._emit_event("agent_cancelled", {"final_response": final_response})
logger.info(f"[Agent] 🏁 Done ({turn} turns)" + (" [cancelled]" if cancelled else ""))
self._emit_event("agent_end", {"final_response": final_response, "cancelled": cancelled})
return final_response
@@ -623,6 +753,22 @@ class AgentStreamExecutor:
"input_schema": input_schema,
})
# Debug: dump the full system prompt and messages sent to the LLM.
# Gated behind `debug` config to avoid flooding normal logs.
# try:
# from config import conf
# if conf().get("debug", False):
# logger.debug(
# "[Agent][debug] system_prompt sent to LLM "
# f"({len(self.system_prompt or '')} chars):\n"
# "================ SYSTEM PROMPT BEGIN ================\n"
# f"{self.system_prompt}\n"
# "================ SYSTEM PROMPT END =================="
# )
# logger.info(f"[Agent][debug] messages sent to LLM: {messages}")
# except Exception:
# pass
# Create request
request = LLMRequest(
messages=messages,
@@ -644,7 +790,32 @@ class AgentStreamExecutor:
try:
stream = self.model.call_stream(request)
# Probe cancel every N chunks to bound reaction time without
# checking on every token.
_cancel_probe_counter = 0
_CANCEL_PROBE_EVERY = 8
for chunk in stream:
_cancel_probe_counter += 1
if _cancel_probe_counter >= _CANCEL_PROBE_EVERY:
_cancel_probe_counter = 0
if self.cancel_event is not None and self.cancel_event.is_set():
# Persist partial text only; tool_use args may be
# truncated mid-stream and would fail validation.
logger.info("[Agent] cancel detected mid-stream, aborting LLM call")
if full_content:
partial_msg = {
"role": "assistant",
"content": [{"type": "text", "text": full_content}],
}
self.messages.append(partial_msg)
self._emit_event("message_end", {
"content": full_content,
"tool_calls": [],
"cancelled": True,
})
raise AgentCancelledError("cancelled during LLM streaming")
# Check for errors
if isinstance(chunk, dict) and chunk.get("error"):
# Extract error message from nested structure
@@ -738,6 +909,10 @@ class AgentStreamExecutor:
elif isinstance(choice, dict) and choice.get("_gemini_raw_parts"):
gemini_raw_parts = choice["_gemini_raw_parts"]
except AgentCancelledError:
# Must propagate untouched; never treat as a retryable error.
raise
except Exception as e:
error_str = str(e)
error_str_lower = error_str.lower()
@@ -800,13 +975,15 @@ class AgentStreamExecutor:
self.messages.clear()
self._clear_session_db()
if is_context_overflow:
raise Exception(
"抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。"
)
raise Exception(_t(
"抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。",
"Sorry, the conversation history got too long and overflowed the context. I've cleared the history — please describe your request again.",
))
else:
raise Exception(
"抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。"
)
raise Exception(_t(
"抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。",
"Sorry, something went wrong with the earlier conversation. I've cleared the history — please send your message again.",
))
# Check if error is rate limit (429)
is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower
@@ -851,26 +1028,17 @@ class AgentStreamExecutor:
import uuid
tool_id = f"call_{uuid.uuid4().hex[:24]}"
try:
# Safely get arguments, handle None case
args_str = tc.get("arguments") or ""
arguments = json.loads(args_str) if args_str else {}
except json.JSONDecodeError as e:
# Handle None or invalid arguments safely
args_str = tc.get('arguments') or ""
args_preview = args_str[:200] if len(args_str) > 200 else args_str
logger.error(f"Failed to parse tool arguments for {tc['name']}")
logger.error(f"Arguments length: {len(args_str)} chars")
logger.error(f"Arguments preview: {args_preview}...")
logger.error(f"JSON decode error: {e}")
# Return a clear error message to the LLM instead of empty dict
# This helps the LLM understand what went wrong
args_str = tc.get("arguments") or ""
arguments, parse_err = _parse_tool_args(args_str, stop_reason)
if parse_err:
logger.error(
f"Tool args parse failed for {tc['name']} ({len(args_str)} chars): {parse_err}"
)
tool_calls.append({
"id": tool_id,
"name": tc["name"],
"arguments": {},
"_parse_error": f"Invalid JSON in tool arguments: {args_preview}... Error: {str(e)}. Tip: For large content, consider splitting into smaller chunks or using a different approach."
"_parse_error": parse_err,
})
continue
@@ -958,14 +1126,11 @@ class AgentStreamExecutor:
tool_id = tool_call["id"]
arguments = tool_call["arguments"]
# Check if there was a JSON parse error
if "_parse_error" in tool_call:
parse_error = tool_call["_parse_error"]
logger.error(f"Skipping tool execution due to parse error: {parse_error}")
result = {
"status": "error",
"result": f"Failed to parse tool arguments. {parse_error}. Please ensure your tool call uses valid JSON format with all required parameters.",
"execution_time": 0
"result": tool_call["_parse_error"],
"execution_time": 0,
}
self._record_tool_result(tool_name, arguments, False)
return result
@@ -1397,8 +1562,8 @@ class AgentStreamExecutor:
turns = turns[-keep_count:]
logger.info(
f"💾 上下文轮次超限: {keep_count + removed_count} > {self.max_context_turns}"
f"裁剪至 {keep_count} 轮(移除 {removed_count} 轮)"
f"💾 Context turns exceeded: {keep_count + removed_count} > {self.max_context_turns}, "
f"trimmed to {keep_count} turns (removed {removed_count})"
)
# Flush to daily memory + inject context summary (single async LLM call)
@@ -1446,7 +1611,7 @@ class AgentStreamExecutor:
# Log if we removed messages due to turn limit
if old_count > len(self.messages):
logger.info(f" 重建消息列表: {old_count} -> {len(self.messages)} 条消息")
logger.info(f" Rebuilt message list: {old_count} -> {len(self.messages)} messages")
return
# Token limit exceeded — tiered strategy based on turn count:
@@ -1479,10 +1644,10 @@ class AgentStreamExecutor:
self.messages = new_messages
logger.info(
f"📦 上下文tokens超限(轮次<{COMPRESS_THRESHOLD}): "
f"~{current_tokens + system_tokens} > {max_tokens}"
f"压缩全部 {len(turns)} 轮为纯文本 "
f"({old_count} -> {len(self.messages)} 条消息,"
f"📦 Context tokens exceeded (turns<{COMPRESS_THRESHOLD}): "
f"~{current_tokens + system_tokens} > {max_tokens}, "
f"compressed all {len(turns)} turns to plain text "
f"({old_count} -> {len(self.messages)} messages, "
f"~{current_tokens + system_tokens} -> ~{new_tokens + system_tokens} tokens)"
)
return
@@ -1495,8 +1660,8 @@ class AgentStreamExecutor:
kept_tokens = sum(self._estimate_turn_tokens(t) for t in kept_turns)
logger.info(
f"🔄 上下文tokens超限: ~{current_tokens + system_tokens} > {max_tokens}"
f"裁剪至 {keep_count} 轮(移除 {removed_count} 轮)"
f"🔄 Context tokens exceeded: ~{current_tokens + system_tokens} > {max_tokens}, "
f"trimmed to {keep_count} turns (removed {removed_count})"
)
if self.agent.memory_manager:
@@ -1520,8 +1685,8 @@ class AgentStreamExecutor:
self.messages = new_messages
logger.info(
f" 移除了 {removed_count} 轮对话 "
f"({old_count} -> {len(self.messages)} 条消息,"
f" Removed {removed_count} turns "
f"({old_count} -> {len(self.messages)} messages, "
f"~{current_tokens + system_tokens} -> ~{kept_tokens + system_tokens} tokens)"
)

121
agent/protocol/cancel.py Normal file
View File

@@ -0,0 +1,121 @@
"""
Cancel token registry for aborting in-flight agent runs.
A user cancel (web Cancel button, /cancel command) sets a threading.Event
that the agent loop polls at safe checkpoints. Tokens are keyed by
request_id (preferred) and tracked under session_id as a fallback. Entries
are released after the run completes to keep the registry bounded.
No project deps — importable from any layer without circular imports.
"""
from __future__ import annotations
import threading
from typing import Dict, Optional
class AgentCancelledError(Exception):
"""Raised inside the agent loop when a stop has been requested.
The agent stream executor catches this, injects a "[Interrupted]" note
into the message history (preserving tool_use/tool_result integrity)
and returns a partial response to the caller.
"""
class _CancelEntry:
__slots__ = ("event", "session_id")
def __init__(self, session_id: Optional[str]):
self.event = threading.Event()
self.session_id = session_id
class CancelTokenRegistry:
"""In-process registry mapping request_id -> cancel Event.
Thread-safe. Singleton via module-level ``_registry``.
"""
def __init__(self):
self._lock = threading.Lock()
self._by_request: Dict[str, _CancelEntry] = {}
# session_id -> set of request_ids currently in flight (usually 1).
self._by_session: Dict[str, set] = {}
def register(self, request_id: str, session_id: Optional[str] = None) -> threading.Event:
"""Create (or return existing) cancel event for a request.
Returns the threading.Event the caller should poll via ``is_set()``.
"""
if not request_id:
return threading.Event()
with self._lock:
entry = self._by_request.get(request_id)
if entry is None:
entry = _CancelEntry(session_id)
self._by_request[request_id] = entry
if session_id:
self._by_session.setdefault(session_id, set()).add(request_id)
return entry.event
def get_event(self, request_id: str) -> Optional[threading.Event]:
if not request_id:
return None
with self._lock:
entry = self._by_request.get(request_id)
return entry.event if entry else None
def cancel_request(self, request_id: str) -> bool:
"""Trigger cancel for a specific request. Returns True when matched."""
if not request_id:
return False
with self._lock:
entry = self._by_request.get(request_id)
if entry is None:
return False
entry.event.set()
return True
def cancel_session(self, session_id: str) -> int:
"""Trigger cancel for every in-flight request of a session.
Returns the number of requests cancelled (0 when nothing was running).
"""
if not session_id:
return 0
with self._lock:
request_ids = list(self._by_session.get(session_id, ()))
entries = [self._by_request[r] for r in request_ids if r in self._by_request]
for entry in entries:
entry.event.set()
return len(entries)
def unregister(self, request_id: str) -> None:
"""Remove an entry once the agent run is done. Safe to call twice."""
if not request_id:
return
with self._lock:
entry = self._by_request.pop(request_id, None)
if entry and entry.session_id:
bucket = self._by_session.get(entry.session_id)
if bucket is not None:
bucket.discard(request_id)
if not bucket:
self._by_session.pop(entry.session_id, None)
def has_active(self, session_id: str) -> bool:
if not session_id:
return False
with self._lock:
bucket = self._by_session.get(session_id)
return bool(bucket)
_registry = CancelTokenRegistry()
def get_cancel_registry() -> CancelTokenRegistry:
"""Module-level accessor for the singleton registry."""
return _registry

View File

@@ -15,7 +15,7 @@ import threading
from typing import Optional, Dict, Any, List, Callable
from common.log import logger
from common.utils import expand_path
from common.utils import expand_path, is_cloud_deployment
_DEFAULT_USER_DATA_DIR = "~/.cow/browser_profile"
@@ -436,6 +436,20 @@ class BrowserService:
if self._headless:
launch_args.append("--no-sandbox")
if is_cloud_deployment():
launch_args.extend([
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-extensions",
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
"--disable-features=site-per-process,TranslateUI,IsolateOrigins",
"--no-zygote",
"--js-flags=--max-old-space-size=384",
"--memory-pressure-off",
])
extra_args = self._config.get("launch_args", [])
if extra_args:
launch_args.extend(extra_args)

View File

@@ -145,7 +145,8 @@ class BrowserTool(BaseTool):
url = args.get("url", "").strip()
if not url:
return ToolResult.fail("Error: 'url' is required for navigate action")
if not url.startswith(("http://", "https://")):
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
if "://" not in url and not url.startswith(("about:", "data:")):
url = "https://" + url
timeout = args.get("timeout", 30000)
service = self._get_service()

View File

@@ -1,8 +1,8 @@
"""
MCP (Model Context Protocol) client module.
Implements JSON-RPC 2.0 over stdio and SSE transports without any external
MCP SDK dependency.
Implements JSON-RPC 2.0 over stdio, SSE and Streamable HTTP transports
without any external MCP SDK dependency.
"""
import json
@@ -17,18 +17,29 @@ from typing import Optional
from common.log import logger
# Aliases accepted for the Streamable HTTP transport type
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
class McpClient:
"""Single MCP Server client supporting stdio and SSE transports."""
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
def __init__(self, config: dict):
"""
config examples:
stdio: {"name": "filesystem", "type": "stdio", "command": "npx", "args": [...]}
SSE: {"name": "my-api", "type": "sse", "url": "http://localhost:8000/sse"}
stdio: {"name": "filesystem", "type": "stdio", "command": "npx", "args": [...]}
SSE: {"name": "my-api", "type": "sse", "url": "http://localhost:8000/sse"}
streamable-http: {"name": "pubmed", "type": "streamable-http", "url": "https://x/mcp"}
"""
self.config = config
self.name: str = config.get("name", "unknown")
self.transport: str = config.get("type", "stdio")
raw_transport: str = config.get("type", "stdio")
# Normalize streamable-http aliases to a single internal key
self.transport: str = (
"streamable-http"
if raw_transport.lower() in _STREAMABLE_HTTP_ALIASES
else raw_transport
)
# stdio state
self._proc: Optional[subprocess.Popen] = None
@@ -37,6 +48,11 @@ class McpClient:
self._sse_url: Optional[str] = None
self._post_url: Optional[str] = None # endpoint for sending messages (resolved from SSE)
# Streamable HTTP state
self._http_url: Optional[str] = None
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
# Shared state
self._next_id = 1
self._id_lock = threading.Lock()
@@ -54,6 +70,8 @@ class McpClient:
return self._init_stdio()
elif self.transport == "sse":
return self._init_sse()
elif self.transport == "streamable-http":
return self._init_streamable_http()
else:
logger.warning(f"[MCP:{self.name}] Unknown transport type: {self.transport!r}")
return False
@@ -109,6 +127,21 @@ class McpClient:
pass
self._proc = None
logger.debug(f"[MCP:{self.name}] stdio process terminated")
# Best-effort streamable-http session termination
if self.transport == "streamable-http" and self._http_session_id and self._http_url:
try:
req = urllib.request.Request(
self._http_url,
method="DELETE",
headers={"Mcp-Session-Id": self._http_session_id, **self._http_headers},
)
with urllib.request.urlopen(req, timeout=5):
pass
except Exception:
pass
self._http_session_id = None
self._initialized = False
# ------------------------------------------------------------------
@@ -234,6 +267,120 @@ class McpClient:
raw = resp.read().decode("utf-8")
return json.loads(raw)
# ------------------------------------------------------------------
# Streamable HTTP transport (MCP spec 2025-03-26)
# ------------------------------------------------------------------
def _init_streamable_http(self) -> bool:
url = self.config.get("url")
if not url:
logger.warning(f"[MCP:{self.name}] streamable-http config missing 'url'")
return False
self._http_url = url
# Allow user-provided headers (e.g. {"Authorization": "Bearer xxx"})
extra_headers = self.config.get("headers") or {}
if isinstance(extra_headers, dict):
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
return self._handshake()
def _streamable_http_send(self, message: dict) -> dict:
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
return self._streamable_http_post(message, expect_response=True)
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict:
"""
POST a JSON-RPC message over Streamable HTTP.
Per the spec, the response Content-Type can be either:
- application/json -> single JSON-RPC response in body
- text/event-stream -> SSE stream; we read until we get a matching response
"""
body = json.dumps(message).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._http_session_id:
headers["Mcp-Session-Id"] = self._http_session_id
headers.update(self._http_headers)
req = urllib.request.Request(
self._http_url,
data=body,
method="POST",
headers=headers,
)
try:
resp = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as e:
# Surface the server-provided error body for easier debugging
detail = ""
try:
detail = e.read().decode("utf-8", errors="ignore")
except Exception:
pass
raise IOError(
f"[MCP:{self.name}] streamable-http HTTP {e.code}: {detail[:200]}"
)
with resp:
# Capture session id assigned by the server (if any)
session_id = resp.headers.get("Mcp-Session-Id")
if session_id and not self._http_session_id:
self._http_session_id = session_id
status = resp.status if hasattr(resp, "status") else resp.getcode()
# Notifications: server may reply with 202 Accepted and no body
if not expect_response or status == 202:
try:
resp.read()
except Exception:
pass
return {}
content_type = (resp.headers.get("Content-Type") or "").lower()
expected_id = message.get("id")
if "text/event-stream" in content_type:
return self._read_sse_response(resp, expected_id)
raw = resp.read().decode("utf-8")
if not raw:
return {}
return json.loads(raw)
def _read_sse_response(self, resp, expected_id) -> dict:
"""Read an SSE stream and return the first JSON-RPC response with matching id."""
data_buf: list = []
for raw_line in resp:
line = raw_line.decode("utf-8").rstrip("\n\r")
if line == "":
# End of an SSE event, attempt to parse accumulated data
if data_buf:
payload = "\n".join(data_buf)
data_buf = []
try:
msg = json.loads(payload)
except json.JSONDecodeError:
continue
# Skip notifications / mismatched ids
if "id" not in msg:
continue
if expected_id is None or msg.get("id") == expected_id:
return msg
continue
if line.startswith(":"):
continue # SSE comment / keepalive
if line.startswith("data:"):
data_buf.append(line[len("data:"):].lstrip())
# Ignore 'event:' / 'id:' lines; we only care about JSON-RPC payloads
raise IOError(f"[MCP:{self.name}] streamable-http SSE stream closed before response")
# ------------------------------------------------------------------
# Common JSON-RPC helpers
# ------------------------------------------------------------------
@@ -267,6 +414,8 @@ class McpClient:
return self._stdio_send(message)
elif self.transport == "sse":
return self._sse_send(message)
elif self.transport == "streamable-http":
return self._streamable_http_send(message)
else:
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
@@ -291,6 +440,11 @@ class McpClient:
pass
except Exception:
pass # notifications are fire-and-forget
elif self.transport == "streamable-http":
try:
self._streamable_http_post(notification, expect_response=False)
except Exception:
pass # notifications are fire-and-forget
def _handshake(self) -> bool:
"""Perform the MCP initialize / notifications/initialized handshake."""

View File

@@ -57,34 +57,44 @@ def init_scheduler(agent_bridge) -> bool:
_task_store = TaskStore(store_path)
logger.debug(f"[Scheduler] Task store initialized: {store_path}")
# Create execute callback
# Create execute callback. Returns True on success, False to ask
# the scheduler to retry on the next tick (e.g. channel not yet
# ready right after process start).
def execute_task_callback(task: dict):
"""Callback to execute a scheduled task"""
try:
action = task.get("action", {})
action_type = action.get("type")
channel_type = action.get("channel_type", "unknown")
receiver = action.get("receiver", "")
if not _is_channel_ready(channel_type, receiver):
logger.warning(
f"[Scheduler] Task {task.get('id')}: channel "
f"'{channel_type}' not ready for receiver={receiver} "
f"(no inbound msg cached since restart?); deferring"
)
return False
if action_type == "agent_task":
_execute_agent_task(task, agent_bridge)
return _execute_agent_task(task, agent_bridge)
elif action_type == "send_message":
# Legacy support for old tasks
_execute_send_message(task, agent_bridge)
return _execute_send_message(task, agent_bridge)
elif action_type == "tool_call":
# Legacy support for old tasks
_execute_tool_call(task, agent_bridge)
return _execute_tool_call(task, agent_bridge)
elif action_type == "skill_call":
# Legacy support for old tasks
_execute_skill_call(task, agent_bridge)
return _execute_skill_call(task, agent_bridge)
else:
logger.warning(f"[Scheduler] Unknown action type: {action_type}")
return True
except Exception as e:
logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}")
return False
# Create scheduler service
_scheduler_service = SchedulerService(_task_store, execute_task_callback)
_scheduler_service.start()
logger.debug("[Scheduler] Scheduler service initialized and started")
logger.info("[Scheduler] Service initialized and started")
return True
except Exception as e:
@@ -92,6 +102,40 @@ def init_scheduler(agent_bridge) -> bool:
return False
def _is_channel_ready(channel_type: str, receiver: str) -> bool:
"""Best-effort readiness probe for outbound channels.
Returns False when we know the send will drop (e.g. weixin not yet
logged in, web session has no polling queue), so the scheduler can
defer instead of consuming the task. Unknown channels return True
to preserve previous behaviour.
"""
if not channel_type or channel_type == "unknown":
return True
try:
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
if channel is None:
return False
if channel_type == "weixin":
tokens = getattr(channel, "_context_tokens", None)
if not tokens or receiver not in tokens:
return False
return True
if channel_type == "web":
queues = getattr(channel, "session_queues", None)
if not queues or receiver not in queues:
return False
return True
return True
except Exception as e:
logger.warning(f"[Scheduler] Channel readiness check failed for {channel_type}: {e}")
return True
def get_task_store():
"""Get the global task store instance"""
return _task_store
@@ -145,13 +189,10 @@ def _remember_delivered_output(
)
def _execute_agent_task(task: dict, agent_bridge):
def _execute_agent_task(task: dict, agent_bridge) -> bool:
"""
Execute an agent_task action - let Agent handle the task
Args:
task: Task dictionary
agent_bridge: AgentBridge instance
Execute an agent_task action - let Agent handle the task.
Returns True on successful delivery, False to retry next tick.
"""
try:
action = task.get("action", {})
@@ -162,11 +203,11 @@ def _execute_agent_task(task: dict, agent_bridge):
if not task_description:
logger.error(f"[Scheduler] Task {task['id']}: No task_description specified")
return
return True # malformed task, don't loop forever
if not receiver:
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
return
return True
# Check for unsupported channels
if channel_type == "dingtalk":
@@ -209,51 +250,47 @@ def _execute_agent_task(task: dict, agent_bridge):
try:
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=False)
if reply and reply.content:
# Send the reply via channel
from channel.channel_factory import create_channel
try:
channel = create_channel(channel_type)
if channel:
# For web channel, register request_id
if channel_type == "web" and hasattr(channel, 'request_to_session'):
request_id = context.get("request_id")
if request_id:
channel.request_to_session[request_id] = receiver
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
# Send the reply
channel.send(reply, context)
_remember_delivered_output(agent_bridge, task, channel_type, reply.content)
logger.info(f"[Scheduler] Task {task['id']} executed successfully, result sent to {receiver}")
else:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
except Exception as e:
logger.error(f"[Scheduler] Failed to send result: {e}")
else:
if not (reply and reply.content):
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
return True # agent ran but produced nothing; don't loop
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
return False
if channel_type == "web" and hasattr(channel, 'request_to_session'):
request_id = context.get("request_id")
if request_id:
channel.request_to_session[request_id] = receiver
try:
channel.send(reply, context)
except Exception as e:
logger.error(f"[Scheduler] Failed to send result: {e}")
return False
_remember_delivered_output(agent_bridge, task, channel_type, reply.content)
logger.info(f"[Scheduler] Task {task['id']} executed successfully, result sent to {receiver}")
return True
except Exception as e:
logger.error(f"[Scheduler] Failed to execute task via Agent: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
return False
except Exception as e:
logger.error(f"[Scheduler] Error in _execute_agent_task: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
return False
def _execute_send_message(task: dict, agent_bridge):
"""
Execute a send_message action
Args:
task: Task dictionary
agent_bridge: AgentBridge instance
"""
def _execute_send_message(task: dict, agent_bridge) -> bool:
"""Execute a send_message action. Returns True/False for delivery."""
try:
action = task.get("action", {})
content = action.get("content", "")
@@ -263,7 +300,7 @@ def _execute_send_message(task: dict, agent_bridge):
if not receiver:
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
return
return True
# Create context for sending message
context = Context(ContextType.TEXT, content)
@@ -308,169 +345,135 @@ def _execute_send_message(task: dict, agent_bridge):
# Get channel and send
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
return False
if channel_type == "web" and hasattr(channel, 'request_to_session'):
channel.request_to_session[request_id] = receiver
try:
channel = create_channel(channel_type)
if channel:
# For web channel, register the request_id to session mapping
if channel_type == "web" and hasattr(channel, 'request_to_session'):
channel.request_to_session[request_id] = receiver
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
channel.send(reply, context)
_remember_delivered_output(agent_bridge, task, channel_type, content)
logger.info(f"[Scheduler] Task {task['id']} executed: sent message to {receiver}")
else:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
channel.send(reply, context)
except Exception as e:
logger.error(f"[Scheduler] Failed to send message: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
return False
_remember_delivered_output(agent_bridge, task, channel_type, content)
logger.info(f"[Scheduler] Task {task['id']} executed: sent message to {receiver}")
return True
except Exception as e:
logger.error(f"[Scheduler] Error in _execute_send_message: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
return False
def _execute_tool_call(task: dict, agent_bridge):
"""
Execute a tool_call action
Args:
task: Task dictionary
agent_bridge: AgentBridge instance
"""
def _execute_tool_call(task: dict, agent_bridge) -> bool:
"""Execute a tool_call action. Returns True/False for delivery."""
try:
action = task.get("action", {})
# Support both old and new field names
tool_name = action.get("call_name") or action.get("tool_name")
tool_params = action.get("call_params") or action.get("tool_params", {})
result_prefix = action.get("result_prefix", "")
receiver = action.get("receiver")
is_group = action.get("is_group", False)
channel_type = action.get("channel_type", "unknown")
if not tool_name:
logger.error(f"[Scheduler] Task {task['id']}: No tool_name specified")
return
return True
if not receiver:
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
return
# Get tool manager and create tool instance
return True
from agent.tools.tool_manager import ToolManager
tool_manager = ToolManager()
tool = tool_manager.create_tool(tool_name)
tool = ToolManager().create_tool(tool_name)
if not tool:
logger.error(f"[Scheduler] Task {task['id']}: Tool '{tool_name}' not found")
return
# Execute tool
return True
logger.info(f"[Scheduler] Task {task['id']}: Executing tool '{tool_name}' with params {tool_params}")
result = tool.execute(tool_params)
# Get result content
if hasattr(result, 'result'):
content = result.result
else:
content = str(result)
# Add prefix if specified
content = result.result if hasattr(result, 'result') else str(result)
if result_prefix:
content = f"{result_prefix}\n\n{content}"
# Send result as message
context = Context(ContextType.TEXT, content)
context["receiver"] = receiver
context["isgroup"] = is_group
context["session_id"] = receiver
# Channel-specific context setup
request_id = None
if channel_type == "web":
# Web channel needs request_id
import uuid
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
context["request_id"] = request_id
logger.debug(f"[Scheduler] Generated request_id for web channel: {request_id}")
elif channel_type == "feishu":
context["receive_id_type"] = "chat_id" if is_group else "open_id"
context["msg"] = None
logger.debug(f"[Scheduler] Feishu: receive_id_type={context['receive_id_type']}, is_group={is_group}, receiver={receiver}")
elif channel_type == "wecom_bot":
context["msg"] = None
reply = Reply(ReplyType.TEXT, content)
# Get channel and send
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
return False
if channel_type == "web" and request_id and hasattr(channel, 'request_to_session'):
channel.request_to_session[request_id] = receiver
try:
channel = create_channel(channel_type)
if channel:
if channel_type == "web" and hasattr(channel, 'request_to_session'):
channel.request_to_session[request_id] = receiver
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
channel.send(reply, context)
_remember_delivered_output(agent_bridge, task, channel_type, content)
logger.info(f"[Scheduler] Task {task['id']} executed: sent tool result to {receiver}")
else:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
channel.send(reply, context)
except Exception as e:
logger.error(f"[Scheduler] Failed to send tool result: {e}")
return False
_remember_delivered_output(agent_bridge, task, channel_type, content)
logger.info(f"[Scheduler] Task {task['id']} executed: sent tool result to {receiver}")
return True
except Exception as e:
logger.error(f"[Scheduler] Error in _execute_tool_call: {e}")
return False
def _execute_skill_call(task: dict, agent_bridge):
"""
Execute a skill_call action by asking Agent to run the skill
Args:
task: Task dictionary
agent_bridge: AgentBridge instance
"""
def _execute_skill_call(task: dict, agent_bridge) -> bool:
"""Execute a skill_call action by asking Agent to run the skill.
Returns True/False for delivery."""
try:
action = task.get("action", {})
# Support both old and new field names
skill_name = action.get("call_name") or action.get("skill_name")
skill_params = action.get("call_params") or action.get("skill_params", {})
result_prefix = action.get("result_prefix", "")
receiver = action.get("receiver")
is_group = action.get("isgroup", False)
channel_type = action.get("channel_type", "unknown")
if not skill_name:
logger.error(f"[Scheduler] Task {task['id']}: No skill_name specified")
return
return True
if not receiver:
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
return
return True
logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}")
# Create a unique session_id for this scheduled task to avoid polluting user's conversation
# Format: scheduler_<receiver>_<task_id> to ensure isolation
scheduler_session_id = f"scheduler_{receiver}_{task['id']}"
# Build a natural language query for the Agent to execute the skill
# Format: "Use skill-name to do something with params"
param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()])
query = f"Use {skill_name} skill"
if param_str:
query += f" with {param_str}"
# Create context for Agent
context = Context(ContextType.TEXT, query)
context["receiver"] = receiver
context["isgroup"] = is_group
context["session_id"] = scheduler_session_id
# Channel-specific setup
if channel_type == "web":
import uuid
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
@@ -481,49 +484,48 @@ def _execute_skill_call(task: dict, agent_bridge):
elif channel_type == "wecom_bot":
context["msg"] = None
# Use Agent to execute the skill
try:
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=False)
if reply and reply.content:
content = reply.content
# Add prefix if specified
if result_prefix:
content = f"{result_prefix}\n\n{content}"
# Send the result via channel
from channel.channel_factory import create_channel
try:
channel = create_channel(channel_type)
if channel:
# For web channel, register request_id
if channel_type == "web" and hasattr(channel, 'request_to_session'):
req_id = context.get("request_id")
if req_id:
channel.request_to_session[req_id] = receiver
logger.debug(f"[Scheduler] Registered request_id {req_id} -> session {receiver}")
channel.send(Reply(ReplyType.TEXT, content), context)
_remember_delivered_output(agent_bridge, task, channel_type, content)
except Exception as e:
logger.error(f"[Scheduler] Failed to send skill result: {e}")
logger.info(f"[Scheduler] Task {task['id']} executed: skill result sent to {receiver}")
else:
logger.error(f"[Scheduler] Task {task['id']}: No result from skill execution")
except Exception as e:
logger.error(f"[Scheduler] Failed to execute skill via Agent: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
return False
if not (reply and reply.content):
logger.error(f"[Scheduler] Task {task['id']}: No result from skill execution")
return True
content = reply.content
if result_prefix:
content = f"{result_prefix}\n\n{content}"
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
return False
if channel_type == "web" and hasattr(channel, 'request_to_session'):
req_id = context.get("request_id")
if req_id:
channel.request_to_session[req_id] = receiver
try:
channel.send(Reply(ReplyType.TEXT, content), context)
except Exception as e:
logger.error(f"[Scheduler] Failed to send skill result: {e}")
return False
_remember_delivered_output(agent_bridge, task, channel_type, content)
logger.info(f"[Scheduler] Task {task['id']} executed: skill result sent to {receiver}")
return True
except Exception as e:
logger.error(f"[Scheduler] Error in _execute_skill_call: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
return False
def attach_scheduler_to_tool(tool, context: Context = None):

View File

@@ -52,7 +52,6 @@ class SchedulerService:
self.running = True
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
logger.debug("[Scheduler] Service started")
def stop(self):
"""Stop the scheduler service"""
@@ -67,7 +66,7 @@ class SchedulerService:
def _run_loop(self):
"""Main scheduler loop"""
logger.debug("[Scheduler] Scheduler loop started")
logger.info("[Scheduler] Scheduler loop started")
while self.running:
try:
@@ -84,12 +83,18 @@ class SchedulerService:
for task in tasks:
try:
# Check if task is due
if self._is_task_due(task, now):
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
self._execute_task(task)
# Update next run time
ok = self._execute_task(task)
if not ok:
# Leave next_run_at as-is so the next loop retries.
# Cron tasks within the catch-up window will keep
# firing; beyond it _is_task_due will reschedule.
logger.warning(
f"[Scheduler] Task {task['id']} delivery failed, will retry next tick"
)
continue
next_run = self._calculate_next_run(task, now)
if next_run:
self.task_store.update_task(task['id'], {
@@ -97,7 +102,6 @@ class SchedulerService:
"last_run_at": now.isoformat()
})
else:
# One-time task completed, remove it
self.task_store.delete_task(task['id'])
logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}")
except Exception as e:
@@ -128,30 +132,35 @@ class SchedulerService:
try:
next_run = _parse_naive_local(next_run_str)
# Check if task is overdue (e.g., service restart)
if next_run < now:
time_diff = (now - next_run).total_seconds()
# If overdue by more than 5 minutes, skip this run and schedule next
if time_diff > 300: # 5 minutes
logger.warning(f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, skipping and scheduling next run")
# For one-time tasks, remove them directly
schedule = task.get("schedule", {})
if schedule.get("type") == "once":
self.task_store.delete_task(task['id'])
logger.info(f"[Scheduler] One-time task {task['id']} expired, removed")
return False
# For recurring tasks, calculate next run from now
next_next_run = self._calculate_next_run(task, now)
if next_next_run:
self.task_store.update_task(task['id'], {
"next_run_at": next_next_run.isoformat()
})
logger.info(f"[Scheduler] Rescheduled task {task['id']} to {next_next_run}")
schedule = task.get("schedule", {})
schedule_type = schedule.get("type")
# Catch-up window: fire if we're within 10 minutes of the
# scheduled tick. Beyond that we'd rather skip than push a
# stale daily report to the user.
if time_diff <= 600:
return True
logger.warning(
f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, "
f"skipping and scheduling next run"
)
if schedule_type == "once":
self.task_store.delete_task(task['id'])
logger.info(f"[Scheduler] One-time task {task['id']} expired, removed")
return False
next_next_run = self._calculate_next_run(task, now)
if next_next_run:
self.task_store.update_task(task['id'], {
"next_run_at": next_next_run.isoformat()
})
logger.info(f"[Scheduler] Rescheduled task {task['id']} to {next_next_run}")
return False
return now >= next_run
except Exception as e:
logger.error(
@@ -213,20 +222,22 @@ class SchedulerService:
return None
def _execute_task(self, task: dict):
def _execute_task(self, task: dict) -> bool:
"""
Execute a task
Args:
task: Task dictionary
Execute a task.
Returns True if delivery succeeded (caller should advance state),
False if it failed (caller should keep next_run_at so the next
loop iteration retries). Callback may return None for legacy
behaviour, treated as success.
"""
try:
# Call the execute callback
self.execute_callback(task)
result = self.execute_callback(task)
return False if result is False else True
except Exception as e:
logger.error(f"[Scheduler] Error executing task {task['id']}: {e}")
# Update task with error
self.task_store.update_task(task['id'], {
"last_error": str(e),
"last_error_at": datetime.now().isoformat()
})
return False

View File

@@ -57,6 +57,7 @@ _DISCOVERABLE_MODELS = [
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
("minimax_api_key", const.MiniMax, const.MINIMAX_M2_7, "MiniMax"),
("mimo_api_key", const.MIMO, const.MIMO_V2_5_PRO, "MiMo"),
]
# Model name prefix → discoverable provider display_name.
@@ -73,6 +74,7 @@ _MODEL_PREFIX_TO_PROVIDER = [
("glm-", "ZhipuAI"),
("minimax-", "MiniMax"),
("abab", "MiniMax"),
("mimo-", "MiMo"),
]
# Model prefixes that natively belong to OpenAI / LinkAI (raw HTTP providers).
@@ -92,6 +94,7 @@ _PROVIDER_ID_TO_DISPLAY = {
"qianfan": "Qianfan",
"zhipu": "ZhipuAI",
"minimax": "MiniMax",
"mimo": "MiMo",
}

13
app.py
View File

@@ -231,6 +231,7 @@ def _clear_singleton_cache(channel_name: str):
"wechatmp": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
"wechatmp_service": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
"wechatcom_app": "channel.wechatcom.wechatcomapp_channel.WechatComAppChannel",
const.WECHAT_KF: "channel.wechat_kf.wechat_kf_channel.WechatKfChannel",
const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel",
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
@@ -288,6 +289,16 @@ def _warmup_mcp_tools():
logger.warning(f"[App] MCP warmup failed (non-fatal): {e}")
def _warmup_scheduler():
"""Eager-init AgentBridge so the scheduler thread starts at process
boot rather than waiting for the first user message."""
try:
from bridge.bridge import Bridge
Bridge().get_agent_bridge()
except Exception as e:
logger.warning(f"[App] Scheduler warmup failed: {e}")
def _sync_builtin_skills():
"""Sync builtin skills from project skills/ to workspace skills/ on startup."""
import shutil
@@ -353,6 +364,8 @@ def run():
# latency isn't dominated by npx package downloads.
_warmup_mcp_tools()
_warmup_scheduler()
logger.info(f"[App] Starting channels: {channel_names}")
_channel_mgr = ChannelManager()

View File

@@ -5,7 +5,7 @@ Agent Bridge - Integrates Agent system with existing COW bridge
import os
from typing import Optional, List
from agent.protocol import Agent, LLMModel, LLMRequest
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
from bridge.agent_event_handler import AgentEventHandler
from bridge.agent_initializer import AgentInitializer
from bridge.bridge import Bridge
@@ -285,6 +285,15 @@ class AgentBridge:
# Create helper instances
self.initializer = AgentInitializer(bridge, self)
# Eager-start the scheduler so cron tasks fire without waiting
# for the first user message. init_scheduler is idempotent.
try:
from agent.tools.scheduler.integration import init_scheduler
if init_scheduler(self):
self.scheduler_initialized = True
except Exception as e:
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
"""
Create the super agent with COW integration
@@ -390,11 +399,22 @@ class AgentBridge:
"""
session_id = None
agent = None
request_id = None
cancel_event = None
try:
# Extract session_id from context for user isolation
if context:
session_id = context.kwargs.get("session_id") or context.get("session_id")
request_id = context.kwargs.get("request_id") or context.get("request_id")
# Register a cancel token. Prefer per-turn request_id (web),
# fall back to session_id (IM channels). The Event is polled by
# AgentStreamExecutor at safe checkpoints.
registry = get_cancel_registry()
token_key = request_id or session_id
if token_key:
cancel_event = registry.register(token_key, session_id=session_id)
# Get agent for this session (will auto-initialize if needed)
agent = self.get_agent(session_id=session_id)
if not agent:
@@ -449,7 +469,8 @@ class AgentBridge:
response = agent.run_stream(
user_message=query,
on_event=event_handler.handle_event,
clear_history=clear_history
clear_history=clear_history,
cancel_event=cancel_event,
)
finally:
# Restore original tools
@@ -459,6 +480,13 @@ class AgentBridge:
# Log execution summary
event_handler.log_summary()
# Release cancel token; keep registry bounded.
if token_key:
try:
registry.unregister(token_key)
except Exception:
pass
# Persist new messages generated during this run
if session_id:
channel_type = (context.get("channel_type") or "") if context else ""
@@ -512,6 +540,12 @@ class AgentBridge:
logger.info(f"[AgentBridge] Cleared DB for session after error: {session_id}")
except Exception as db_err:
logger.warning(f"[AgentBridge] Failed to clear DB after error: {db_err}")
# Release cancel token on error path too (idempotent).
if cancel_event is not None and (request_id or session_id):
try:
get_cancel_registry().unregister(request_id or session_id)
except Exception:
pass
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
def _schedule_mcp_hot_reload(self, agent):

View File

@@ -643,16 +643,25 @@ class AgentInitializer:
except Exception:
timezone_name = "UTC"
# Chinese weekday mapping
weekday_map = {
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日'
}
weekday_zh = weekday_map.get(now.strftime("%A"), now.strftime("%A"))
# Weekday: English name in en, Chinese mapping otherwise
weekday_en = now.strftime("%A")
try:
from common import i18n
is_en = i18n.get_language() == "en"
except Exception:
is_en = False
if is_en:
weekday = weekday_en
else:
weekday_map = {
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日'
}
weekday = weekday_map.get(weekday_en, weekday_en)
return {
'time': now.strftime("%Y-%m-%d %H:%M:%S"),
'weekday': weekday_zh,
'weekday': weekday,
'timezone': timezone_name
}

View File

@@ -63,6 +63,10 @@ class Bridge(object):
if model_type and model_type.startswith("deepseek"):
self.btype["chat"] = const.DEEPSEEK
# 小米 MiMo 系列模型,全部以 mimo- 开头
if model_type and model_type.startswith("mimo-"):
self.btype["chat"] = const.MIMO
if model_type and isinstance(model_type, str):
lowered_model_type = model_type.lower()
if lowered_model_type == const.QIANFAN or lowered_model_type.startswith("ernie"):

View File

@@ -27,6 +27,9 @@ def create_channel(channel_type) -> Channel:
elif channel_type == "wechatcom_app":
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel
ch = WechatComAppChannel()
elif channel_type == const.WECHAT_KF:
from channel.wechat_kf.wechat_kf_channel import WechatKfChannel
ch = WechatKfChannel()
elif channel_type == const.FEISHU:
from channel.feishu.feishu_channel import FeiShuChanel
ch = FeiShuChanel()
@@ -39,6 +42,15 @@ def create_channel(channel_type) -> Channel:
elif channel_type == const.QQ:
from channel.qq.qq_channel import QQChannel
ch = QQChannel()
elif channel_type == const.TELEGRAM:
from channel.telegram.telegram_channel import TelegramChannel
ch = TelegramChannel()
elif channel_type == const.SLACK:
from channel.slack.slack_channel import SlackChannel
ch = SlackChannel()
elif channel_type == const.DISCORD:
from channel.discord.discord_channel import DiscordChannel
ch = DiscordChannel()
elif channel_type in (const.WEIXIN, "wx"):
from channel.weixin.weixin_channel import WeixinChannel
ch = WeixinChannel()

View File

@@ -10,6 +10,7 @@ from bridge.reply import *
from channel.channel import Channel
from common.dequeue import Dequeue
from common import memory
from common.i18n import t as _t
from plugins import *
try:
@@ -265,7 +266,7 @@ class ChatChannel(Channel):
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
logger.error("[chat_channel]reply type not support: " + str(reply.type))
reply.type = ReplyType.ERROR
reply.content = "不支持发送的消息类型: " + str(reply.type)
reply.content = _t("不支持发送的消息类型: ", "Unsupported message type: ") + str(reply.type)
if reply.type == ReplyType.TEXT:
reply_text = reply.content
@@ -438,8 +439,21 @@ class ChatChannel(Channel):
return func
# Chat commands that must bypass the per-session serial queue,
# otherwise /cancel would queue behind the task it tries to cancel.
# Use /cancel (not /stop) to avoid colliding with `cow stop` CLI.
_BYPASS_QUEUE_COMMANDS = ("/cancel",)
def produce(self, context: Context):
session_id = context["session_id"]
# Fast path: /cancel must not enter the queue.
if context.type == ContextType.TEXT and context.content:
stripped = context.content.strip().lower()
if stripped in self._BYPASS_QUEUE_COMMANDS:
self._handle_cancel_command(context, session_id)
return
with self.lock:
if session_id not in self.sessions:
self.sessions[session_id] = [
@@ -451,6 +465,29 @@ class ChatChannel(Channel):
else:
self.sessions[session_id][0].put(context)
def _handle_cancel_command(self, context: Context, session_id: str) -> None:
"""Cancel any in-flight agent run for *session_id* and reply inline.
Runs synchronously on the caller's thread. Reply is sent through
_send_reply so plugins (e.g. logging) still observe it.
"""
try:
from agent.protocol import get_cancel_registry
from bridge.reply import Reply, ReplyType
cancelled = get_cancel_registry().cancel_session(session_id)
text = (
_t("🛑 已中止", "🛑 Cancelled")
if cancelled > 0
else _t("当前没有可中止的任务。", "Nothing to cancel.")
)
logger.info(
f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}"
)
self._send_reply(context, Reply(ReplyType.TEXT, text))
except Exception as e:
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
def consume(self):
while True:

View File

View File

@@ -0,0 +1,500 @@
"""
Discord channel via the Gateway (WebSocket) using discord.py.
Features:
- Direct message & guild channel chat (text / image / file)
- Guild trigger: @mention or reply-to-bot (configurable)
- /cancel fast-path matches Web channel behaviour
- Gateway long connection: no public IP / callback URL required, works behind NAT
Implementation note:
discord.py is async-first. We run the client inside a dedicated thread
with its own asyncio loop so the rest of cow (which is sync) stays
untouched. Inbound messages are dispatched onto cow's existing sync
ChatChannel.produce() pipeline; outbound send() schedules coroutines
back onto that loop via asyncio.run_coroutine_threadsafe.
"""
import asyncio
import os
import re
import threading
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.discord.discord_message import DiscordMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf
# Discord caps a single message at 2000 chars; split conservatively below.
DISCORD_MSG_LIMIT = 1900
@singleton
class DiscordChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
self.bot_token = ""
self.bot_user_id = "" # used to strip @mention and ignore self messages
self.bot_username = ""
self._client = None
self._loop = None
self._loop_thread = None
self._stop_event = threading.Event()
# Idempotent dedup; guard against rare duplicate dispatch
self._received_msgs = ExpiredDict(60 * 60 * 1)
# Disable group whitelist / prefix checks (we handle triggering ourselves
# in _should_reply_in_guild), aligned with telegram / slack channels.
conf()["group_name_white_list"] = ["ALL_GROUP"]
conf()["single_chat_prefix"] = [""]
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def startup(self):
self.bot_token = conf().get("discord_token", "")
if not self.bot_token:
err = "[Discord] discord_token is required"
logger.error(err)
self.report_startup_error(err)
return
try:
import discord
except ImportError:
err = (
"[Discord] discord.py is not installed. "
"Run: pip install discord.py"
)
logger.error(err)
self.report_startup_error(err)
return
# Run the asyncio event loop in a dedicated thread so the sync cow body
# is untouched.
self._loop = asyncio.new_event_loop()
def _run_loop():
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._async_main(discord))
except Exception as e:
logger.error(f"[Discord] event loop crashed: {e}", exc_info=True)
self.report_startup_error(str(e))
finally:
try:
self._loop.close()
except Exception:
pass
logger.info("[Discord] event loop exited")
self._loop_thread = threading.Thread(target=_run_loop, daemon=True, name="discord-loop")
self._loop_thread.start()
# Block startup() until the loop thread exits, matching other channels'
# behaviour (startup is a blocking call).
self._loop_thread.join()
async def _async_main(self, discord):
"""Build the discord client, register handlers, and connect to the Gateway."""
# message_content is a privileged intent; it must be enabled in the
# Developer Portal (Bot -> Privileged Gateway Intents) to read text.
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
self._client = client
channel = self
@client.event
async def on_ready():
channel.bot_user_id = str(client.user.id)
channel.bot_username = client.user.name or ""
channel.name = channel.bot_user_id # ChatChannel uses self.name to strip @-mention
logger.info(f"[Discord] Bot logged in as {client.user} (id={client.user.id})")
channel.report_startup_success()
logger.info("[Discord] ✅ Discord bot ready, listening for messages")
@client.event
async def on_message(message):
await channel._on_message(message)
# Connect to the Gateway; discord.py auto-reconnects on transient errors.
logger.info("[Discord] Connecting to Gateway...")
# client.start() handles login + Gateway connection and runs until
# close(); it is the standard entrypoint across discord.py versions.
runner_task = asyncio.create_task(client.start(self.bot_token))
# Block until stop()
try:
while not self._stop_event.is_set():
if runner_task.done():
# Surface a startup/connection failure (e.g. bad token)
exc = runner_task.exception()
if exc:
logger.error(f"[Discord] client stopped: {exc}", exc_info=exc)
self.report_startup_error(str(exc))
break
await asyncio.sleep(0.5)
finally:
try:
if not client.is_closed():
await client.close()
except Exception as e:
logger.warning(f"[Discord] shutdown error: {e}")
def stop(self):
logger.info("[Discord] stop() called")
self._stop_event.set()
if self._loop_thread and self._loop_thread.is_alive():
try:
self._loop_thread.join(timeout=10)
except Exception:
pass
logger.info("[Discord] stop() completed")
# ------------------------------------------------------------------
# Inbound: discord message -> ChatMessage -> ChatChannel.produce
# ------------------------------------------------------------------
async def _on_message(self, message):
"""Discord message entry: parse -> build ChatMessage -> produce()."""
try:
# Ignore our own messages and other bots. self._client.user may be
# None until on_ready completes, so guard against that.
if self._client and self._client.user and message.author.id == self._client.user.id:
return
if message.author.bot:
return
# Idempotent dedup
msg_uid = f"{message.channel.id}:{message.id}"
if self._received_msgs.get(msg_uid):
return
self._received_msgs[msg_uid] = True
# guild is None for DMs
is_group = message.guild is not None
# Guild trigger gate (silently drop if not triggered)
if is_group and not self._should_reply_in_guild(message):
logger.debug(f"[Discord] guild message not triggered (need @mention or reply), skip")
return
# Parse message type + download attachments if needed.
ctype, content, caption = await self._parse_message(message)
if ctype is None:
logger.debug(f"[Discord] unsupported message type, skip. msg_id={message.id}")
return
# Strip the bot mention from guild text/caption
if is_group:
if ctype == ContextType.TEXT and content:
content = self._strip_at_mention(content)
if caption:
caption = self._strip_at_mention(caption)
dc_msg = DiscordMessage(
message,
is_group=is_group,
bot_user_id=self.bot_user_id,
ctype=ctype,
content=content,
)
dc_msg.is_at = is_group # if we reached here in a guild, bot is mentioned/replied
from channel.file_cache import get_file_cache
file_cache = get_file_cache()
session_id = self._compute_session_id(message, is_group)
# Media + caption together: treat as a complete query and bypass the cache
if ctype in (ContextType.IMAGE, ContextType.FILE) and caption:
tag = "image" if ctype == ContextType.IMAGE else "file"
merged_text = f"{caption}\n[{tag}: {content}]"
dc_msg.ctype = ContextType.TEXT
dc_msg.content = merged_text
ctype = ContextType.TEXT
logger.info(f"[Discord] Media+caption merged for session {session_id}")
# fallthrough to the TEXT branch below
elif ctype == ContextType.IMAGE:
file_cache.add(session_id, content, file_type="image")
logger.info(f"[Discord] Image cached for session {session_id}, waiting for query...")
return
elif ctype == ContextType.FILE:
file_cache.add(session_id, content, file_type="file")
logger.info(f"[Discord] File cached for session {session_id}: {content}")
return
if ctype == ContextType.TEXT:
# Fast-path: /cancel mirrors Web channel behaviour
if (content or "").strip().lower() in ("/cancel", "cancel"):
await self._do_cancel(session_id, message)
return
cached_files = file_cache.get(session_id)
if cached_files:
refs = []
for fi in cached_files:
ftype = fi["type"]
tag = ftype if ftype in ("image", "video") else "file"
refs.append(f"[{tag}: {fi['path']}]")
dc_msg.content = (dc_msg.content or "") + "\n" + "\n".join(refs)
file_cache.clear(session_id)
logger.info(f"[Discord] Attached {len(cached_files)} cached file(s) to query")
context = self._compose_context(
dc_msg.ctype,
dc_msg.content,
isgroup=is_group,
msg=dc_msg,
# Replies use Discord's reply mechanism, no manual @mention needed
no_need_at=True,
)
if context:
context["session_id"] = session_id
context["receiver"] = str(message.channel.id)
context["discord_channel_id"] = message.channel.id
context["discord_reply_to_msg_id"] = message.id if is_group else None
self.produce(context)
logger.debug(f"[Discord] received: type={ctype}, content={str(dc_msg.content)[:80]}")
except Exception as e:
logger.error(f"[Discord] _on_message error: {e}", exc_info=True)
async def _do_cancel(self, session_id: str, message):
"""Fast-path: /cancel calls cancel_session directly without going through agent."""
try:
from agent.protocol import get_cancel_registry
cancelled = get_cancel_registry().cancel_session(session_id)
text = "Current task cancelled." if cancelled else "No running task to cancel."
await message.channel.send(text)
logger.info(f"[Discord] /cancel session={session_id}, cancelled={cancelled}")
except Exception as e:
logger.error(f"[Discord] /cancel error: {e}", exc_info=True)
async def _parse_message(self, message):
"""Parse a discord message and return (ctype, content, caption).
- content is text for ContextType.TEXT, otherwise the local file path
- caption is the optional text accompanying an attachment; empty for plain text
"""
text = (message.content or "").strip()
attachments = message.attachments or []
if attachments:
# Handle the first attachment; caption is the accompanying message text
att = attachments[0]
content_type = (att.content_type or "").lower()
name = att.filename or str(att.id)
path = await self._download_attachment(att, name)
if not path:
return (None, None, "")
is_image = content_type.startswith("image/") or name.lower().endswith(
(".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
)
if is_image:
return (ContextType.IMAGE, path, text)
return (ContextType.FILE, path, text)
if text:
return (ContextType.TEXT, text, "")
return (None, None, "")
async def _download_attachment(self, attachment, name: str):
"""Download a discord attachment into the local tmp dir; return path or None."""
try:
tmp_dir = DiscordMessage.get_tmp_dir()
safe_name = re.sub(r"[^\w.\-]", "_", name)
# Prefix with attachment id to avoid name collisions
local_path = os.path.join(tmp_dir, f"{attachment.id}_{safe_name}")
await attachment.save(local_path)
logger.debug(f"[Discord] downloaded {name} -> {local_path}")
return local_path
except Exception as e:
logger.error(f"[Discord] download_attachment failed ({name}): {e}")
return None
# ------------------------------------------------------------------
# Guild trigger logic
# ------------------------------------------------------------------
def _should_reply_in_guild(self, message) -> bool:
"""Decide whether to reply to a guild channel message based on configuration."""
mode = conf().get("discord_group_trigger", "mention_or_reply")
if mode == "all":
return True
# self._client.user may be None until on_ready completes
if not self._client or not self._client.user:
return False
# 1) Mentioned (direct @bot, not @everyone / @role)
if self._client.user in message.mentions:
return True
# 2) Reply to a bot message
if mode == "mention_or_reply":
ref = message.reference
resolved = getattr(ref, "resolved", None) if ref else None
if resolved and getattr(resolved, "author", None):
if resolved.author.id == self._client.user.id:
return True
return False
def _strip_at_mention(self, content: str) -> str:
"""Strip <@BOT_ID> / <@!BOT_ID> from guild text."""
if not content or not self.bot_user_id:
return content
pattern = re.compile(r"<@!?" + re.escape(self.bot_user_id) + r">")
return pattern.sub("", content).strip()
@staticmethod
def _compute_session_id(message, is_group: bool) -> str:
channel_id = message.channel.id
user_id = message.author.id
if is_group:
if conf().get("group_shared_session", True):
return f"discord_channel_{channel_id}"
return f"discord_channel_{channel_id}_{user_id}"
return f"discord_user_{user_id}"
# ------------------------------------------------------------------
# Override _compose_context: skip the parent's group whitelist/at checks
# (already handled via _should_reply_in_guild). Same idea as telegram / slack.
# ------------------------------------------------------------------
def _compose_context(self, ctype: ContextType, content, **kwargs):
context = Context(ctype, content)
context.kwargs = kwargs
if "channel_type" not in context:
context["channel_type"] = self.channel_type
if "origin_ctype" not in context:
context["origin_ctype"] = ctype
cmsg = context["msg"]
if cmsg.is_group:
if conf().get("group_shared_session", True):
context["session_id"] = cmsg.other_user_id
else:
context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
else:
context["session_id"] = cmsg.from_user_id
context["receiver"] = cmsg.other_user_id
if ctype == ContextType.TEXT:
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
if img_match_prefix:
content = content.replace(img_match_prefix, "", 1)
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = (content or "").strip()
if "desire_rtype" not in context and conf().get("always_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
elif ctype == ContextType.VOICE:
if "desire_rtype" not in context and (
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
):
context["desire_rtype"] = ReplyType.VOICE
return context
# ------------------------------------------------------------------
# Outbound: ChatChannel.send -> Discord Gateway/REST
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
"""Called from cow's sync main thread; marshal the coroutine onto the loop thread."""
if self._loop is None or self._client is None:
logger.warning("[Discord] client not ready, drop reply")
return
channel_id = context.get("discord_channel_id")
if channel_id is None:
logger.warning("[Discord] no discord_channel_id in context, drop reply")
return
coro = self._async_send(reply, channel_id)
try:
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
future.result(timeout=180)
except Exception as e:
logger.error(f"[Discord] send failed: {e}")
async def _async_send(self, reply: Reply, channel_id):
try:
import discord
channel = self._client.get_channel(channel_id)
if channel is None:
# Not in cache (e.g. DM channel); fetch it explicitly
channel = await self._client.fetch_channel(channel_id)
rtype = reply.type
content = reply.content
if rtype in (ReplyType.TEXT, ReplyType.INFO, ReplyType.ERROR):
text = str(content) if content is not None else ""
if not text:
return
for chunk in _split_text(text, DISCORD_MSG_LIMIT):
await channel.send(chunk)
elif rtype == ReplyType.IMAGE:
# Already a local BytesIO; send it directly
content.seek(0)
await channel.send(file=discord.File(content, filename="image.png"))
elif rtype == ReplyType.IMAGE_URL:
url = str(content)
if url.startswith("file://"):
local = url[7:]
await channel.send(file=discord.File(local))
else:
# Post the URL as text; Discord will unfurl it as an image preview
await channel.send(url)
elif rtype in (ReplyType.VOICE, ReplyType.FILE):
local = content[7:] if isinstance(content, str) and content.startswith("file://") else content
caption = getattr(reply, "text_content", None) or None
await channel.send(content=caption, file=discord.File(local))
else:
# Fallback: send as plain text
await channel.send(str(content))
logger.info(f"[Discord] sent reply (type={rtype}, channel={channel_id})")
except Exception as e:
logger.error(f"[Discord] _async_send error: {e}", exc_info=True)
def _split_text(text: str, limit: int):
"""Split long text preferring line breaks to keep markdown structure intact."""
if len(text) <= limit:
yield text
return
buf = []
size = 0
for line in text.splitlines(keepends=True):
if size + len(line) > limit and buf:
yield "".join(buf)
buf, size = [], 0
# Hard-split single lines that exceed the limit
while len(line) > limit:
yield line[:limit]
line = line[limit:]
buf.append(line)
size += len(line)
if buf:
yield "".join(buf)

View File

@@ -0,0 +1,60 @@
"""
Discord message adapter.
Convert a discord.py Message into cow's unified ChatMessage.
File downloads are NOT performed here; the channel layer downloads
attachments on demand inside the async event loop.
"""
import os
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.utils import expand_path
from config import conf
class DiscordMessage(ChatMessage):
"""Wrap a discord.py Message into the unified ChatMessage."""
def __init__(self, message, is_group: bool = False, bot_user_id: str = "",
ctype: ContextType = ContextType.TEXT, content: str = ""):
super().__init__(message)
# Basic fields
self.msg_id = str(message.id)
self.create_time = int(message.created_at.timestamp()) if message.created_at else 0
self.ctype = ctype
self.content = content
author = message.author
channel = message.channel
# Sender / chat info
from_user_id = str(author.id)
from_user_nick = getattr(author, "display_name", None) or getattr(author, "name", None) or from_user_id
self.from_user_id = from_user_id
self.from_user_nickname = from_user_nick
self.to_user_id = bot_user_id or "discord_bot"
self.to_user_nickname = bot_user_id or "discord_bot"
self.is_group = is_group
if is_group:
# Guild channel: other_user_id = channel_id, actual_user_id = sender id
self.other_user_id = str(channel.id)
self.other_user_nickname = getattr(channel, "name", None) or str(channel.id)
self.actual_user_id = from_user_id
self.actual_user_nickname = from_user_nick
else:
# DM: use channel_id so replies go back to the same DM channel
self.other_user_id = str(channel.id)
self.other_user_nickname = from_user_nick
# Whether the bot was triggered by @-mention (set by channel layer)
self.is_at = False
@staticmethod
def get_tmp_dir() -> str:
"""Local download directory, aligned with other channels (agent_workspace/tmp)."""
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
tmp_dir = os.path.join(workspace_root, "tmp")
os.makedirs(tmp_dir, exist_ok=True)
return tmp_dir

View File

@@ -752,6 +752,9 @@ class FeiShuChanel(ChatChannel):
init_in_flight = [False]
# 一旦初始化失败就长期标记为 disabled本次回复不再尝试任何流式调用
disabled = [False]
# True after agent_cancelled: agent_end stops rewriting the card
# with stale final_response and just finalizes current content.
cancelled = [False]
lock = threading.Lock()
# ---- 异步推送队列 ----------------------------------------------------
@@ -1076,18 +1079,42 @@ class FeiShuChanel(ChatChannel):
message_id[0] = None
sequence[0] = 0
elif event_type == "agent_cancelled":
# Lock channel into "no-rewrite" mode: the subsequent
# agent_end's final_response is from the last *completed*
# turn (the user already saw it), so rewriting the card
# would duplicate it visually.
with lock:
cancelled[0] = True
elif event_type == "agent_end":
# 最终回复:用 final_response 覆盖当前流式卡片,然后关闭流式模式。
final_response = data.get("final_response", "")
if not final_response:
return
final_text = str(final_response)
# 标记 streamed 让 chat_channel 跳过 send()
context["feishu_streamed"] = True
with lock:
was_cancelled = cancelled[0]
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
pending_text = current_text[0]
if was_cancelled:
# Cancelled path: finalize the in-flight card with
# partial output (or a short marker if empty); drop
# stale final_response to avoid duplicating last turn.
if has_card:
_drain_push_queue()
partial = (pending_text or "").rstrip()
final_text = partial or "_(已中止)_"
_stream_update_text(final_text)
_close_streaming_mode(final_text)
push_queue.put(None)
return
if not final_response:
return
final_text = str(final_response)
# 罕见情况agent_end 触发时还没创建过卡片(极快返回 / 没有
# message_update主动创建一张承载 final_text。

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,506 @@
"""
Slack channel via Bolt for Python (Socket Mode).
Features:
- Direct message & channel chat (text / image / file)
- Channel trigger: @mention or reply in a thread the bot is in (configurable)
- /cancel fast-path matches Web channel behaviour
- Socket Mode: no public IP / callback URL required, works behind NAT
Implementation note:
slack_bolt's SocketModeHandler is blocking and runs its own background
threads. We start it in a dedicated thread so the rest of cow (sync) stays
untouched. Inbound events are dispatched onto cow's existing sync
ChatChannel.produce() pipeline; outbound send() calls the Slack Web API
client directly (it is sync-safe).
"""
import os
import re
import threading
import requests
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.slack.slack_message import SlackMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf
@singleton
class SlackChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
self.bot_token = ""
self.app_token = ""
self.bot_user_id = "" # used to strip @mention and ignore self messages
self._app = None
self._handler = None
self._client = None
self._loop_thread = None
# Idempotent dedup; Slack retries event delivery on slow ack
self._received_msgs = ExpiredDict(60 * 60 * 1)
# Disable group whitelist / prefix checks (we handle triggering ourselves
# in _should_reply_in_channel), aligned with telegram / feishu channels.
conf()["group_name_white_list"] = ["ALL_GROUP"]
conf()["single_chat_prefix"] = [""]
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def startup(self):
self.bot_token = conf().get("slack_bot_token", "")
self.app_token = conf().get("slack_app_token", "")
if not self.bot_token or not self.app_token:
err = "[Slack] slack_bot_token and slack_app_token are both required"
logger.error(err)
self.report_startup_error(err)
return
# Guard against the common mistake of swapping the two tokens:
# bot token must start with xoxb-, app-level token with xapp-.
if not self.bot_token.startswith("xoxb-") or not self.app_token.startswith("xapp-"):
err = (
"[Slack] token type mismatch: slack_bot_token must start with 'xoxb-' "
"and slack_app_token must start with 'xapp-' (they look swapped)"
)
logger.error(err)
self.report_startup_error(err)
return
try:
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
except ImportError:
err = (
"[Slack] slack_bolt is not installed. "
"Run: pip install slack_bolt"
)
logger.error(err)
self.report_startup_error(err)
return
try:
self._app = App(token=self.bot_token)
self._client = self._app.client
# Resolve our own bot user id (needed for @mention strip / self-ignore)
auth = self._client.auth_test()
self.bot_user_id = auth.get("user_id", "")
self.name = self.bot_user_id # ChatChannel uses self.name to strip @-mention
logger.info(f"[Slack] Bot logged in as user_id={self.bot_user_id}, team={auth.get('team')}")
except Exception as e:
err = f"[Slack] auth_test failed: {e}"
logger.error(err)
self.report_startup_error(err)
return
self._register_handlers()
self._handler = SocketModeHandler(self._app, self.app_token)
def _run():
try:
logger.info("[Slack] Starting Socket Mode connection...")
self.report_startup_success()
logger.info("[Slack] ✅ Slack bot ready, listening for events")
self._handler.start()
except Exception as e:
logger.error(f"[Slack] socket mode crashed: {e}", exc_info=True)
self.report_startup_error(str(e))
finally:
logger.info("[Slack] socket mode exited")
self._loop_thread = threading.Thread(target=_run, daemon=True, name="slack-socket")
self._loop_thread.start()
# Block startup() until the handler thread exits, matching other channels'
# behaviour (startup is a blocking call).
self._loop_thread.join()
def _register_handlers(self):
app = self._app
# app_mention: bot is @-mentioned in a channel
@app.event("app_mention")
def _on_app_mention(event, ack):
ack()
self._handle_event(event, is_group=True)
# message: DMs and channel messages (including thread replies)
@app.event("message")
def _on_message(event, ack):
ack()
self._handle_message_event(event)
def stop(self):
logger.info("[Slack] stop() called")
try:
if self._handler is not None:
self._handler.close()
except Exception as e:
logger.warning(f"[Slack] handler close error: {e}")
if self._loop_thread and self._loop_thread.is_alive():
try:
self._loop_thread.join(timeout=10)
except Exception:
pass
logger.info("[Slack] stop() completed")
# ------------------------------------------------------------------
# Inbound: slack event -> ChatMessage -> ChatChannel.produce
# ------------------------------------------------------------------
def _handle_message_event(self, event: dict):
"""Route a raw `message` event: skip bot/system noise, decide grouping."""
try:
logger.debug(
f"[Slack] message event: channel_type={event.get('channel_type')}, "
f"subtype={event.get('subtype')}, user={event.get('user')}, "
f"ts={event.get('ts')}, thread_ts={event.get('thread_ts')}"
)
# Ignore bot messages (including our own) and message edits/deletes
if event.get("bot_id") or event.get("subtype") in ("bot_message", "message_changed", "message_deleted"):
return
if event.get("user") == self.bot_user_id:
return
channel_type = event.get("channel_type", "")
# DM (im) is single chat; channel/group is group chat. app_mention
# already covers channel @-mentions, so for plain channel messages we
# only react when configured / thread-following.
is_group = channel_type in ("channel", "group", "mpim")
if is_group:
# app_mention handler covers explicit @bot; here we only handle
# follow-up replies in threads the bot participates in.
if not self._should_reply_in_channel(event):
return
self._handle_event(event, is_group=is_group)
except Exception as e:
logger.error(f"[Slack] _handle_message_event error: {e}", exc_info=True)
def _handle_event(self, event: dict, is_group: bool):
"""Parse event -> build SlackMessage -> produce()."""
try:
channel_id = event.get("channel", "")
ts = event.get("ts", "")
if not channel_id:
return
# Idempotent dedup
msg_uid = f"{channel_id}:{ts}"
if self._received_msgs.get(msg_uid):
return
self._received_msgs[msg_uid] = True
# Parse type + download media if needed.
ctype, content, caption = self._parse_event(event)
if ctype is None:
logger.debug(f"[Slack] unsupported message type, skip. event={event}")
return
# Strip <@bot_user_id> mention from channel text
if is_group and self.bot_user_id:
if ctype == ContextType.TEXT and content:
content = self._strip_at_mention(content)
if caption:
caption = self._strip_at_mention(caption)
slack_msg = SlackMessage(
event,
is_group=is_group,
bot_user_id=self.bot_user_id,
ctype=ctype,
content=content,
)
slack_msg.is_at = is_group # if we reached here in a channel, bot is mentioned/threaded
from channel.file_cache import get_file_cache
file_cache = get_file_cache()
session_id = self._compute_session_id(event, is_group)
# Media + caption together: treat as a complete query and bypass the cache
if ctype in (ContextType.IMAGE, ContextType.FILE) and caption:
tag = "image" if ctype == ContextType.IMAGE else "file"
merged_text = f"{caption}\n[{tag}: {content}]"
slack_msg.ctype = ContextType.TEXT
slack_msg.content = merged_text
ctype = ContextType.TEXT
logger.info(f"[Slack] Media+caption merged for session {session_id}")
# fallthrough to the TEXT branch below
elif ctype == ContextType.IMAGE:
file_cache.add(session_id, content, file_type="image")
logger.info(f"[Slack] Image cached for session {session_id}, waiting for query...")
return
elif ctype == ContextType.FILE:
file_cache.add(session_id, content, file_type="file")
logger.info(f"[Slack] File cached for session {session_id}: {content}")
return
if ctype == ContextType.TEXT:
# Fast-path: /cancel mirrors Web channel behaviour
if (content or "").strip().lower() in ("/cancel", "cancel"):
self._do_cancel(session_id, channel_id, event)
return
cached_files = file_cache.get(session_id)
if cached_files:
refs = []
for fi in cached_files:
ftype = fi["type"]
tag = ftype if ftype in ("image", "video") else "file"
refs.append(f"[{tag}: {fi['path']}]")
slack_msg.content = (slack_msg.content or "") + "\n" + "\n".join(refs)
file_cache.clear(session_id)
logger.info(f"[Slack] Attached {len(cached_files)} cached file(s) to query")
# Reply in the originating thread when present, else start one on this msg
thread_ts = event.get("thread_ts") or ts
context = self._compose_context(
slack_msg.ctype,
slack_msg.content,
isgroup=is_group,
msg=slack_msg,
# Replies go back into the thread, no manual @mention needed
no_need_at=True,
)
if context:
context["session_id"] = session_id
context["receiver"] = channel_id
context["slack_channel"] = channel_id
context["slack_thread_ts"] = thread_ts if is_group else None
self.produce(context)
logger.debug(f"[Slack] received: type={ctype}, content={str(slack_msg.content)[:80]}")
except Exception as e:
logger.error(f"[Slack] _handle_event error: {e}", exc_info=True)
def _do_cancel(self, session_id: str, channel_id: str, event: dict):
"""Fast-path: /cancel calls cancel_session directly without going through agent."""
try:
from agent.protocol import get_cancel_registry
cancelled = get_cancel_registry().cancel_session(session_id)
text = "Current task cancelled." if cancelled else "No running task to cancel."
thread_ts = event.get("thread_ts") or event.get("ts")
self._client.chat_postMessage(channel=channel_id, text=text, thread_ts=thread_ts)
logger.info(f"[Slack] /cancel session={session_id}, cancelled={cancelled}")
except Exception as e:
logger.error(f"[Slack] /cancel error: {e}", exc_info=True)
def _parse_event(self, event: dict):
"""Parse a slack event and return (ctype, content, caption).
- content is text for ContextType.TEXT, otherwise the local file path
- caption is the optional text accompanying a file; empty for plain text
"""
text = (event.get("text") or "").strip()
files = event.get("files") or []
if files:
# Handle the first attachment; caption is the accompanying message text
f = files[0]
mimetype = (f.get("mimetype") or "").lower()
url = f.get("url_private_download") or f.get("url_private")
name = f.get("name") or f.get("id") or "file"
if not url:
return (None, None, "")
path = self._download_file(url, name)
if not path:
return (None, None, "")
if mimetype.startswith("image/"):
return (ContextType.IMAGE, path, text)
return (ContextType.FILE, path, text)
if text:
return (ContextType.TEXT, text, "")
return (None, None, "")
def _download_file(self, url: str, name: str):
"""Download a Slack private file (requires bot token auth) to local tmp dir."""
try:
headers = {"Authorization": f"Bearer {self.bot_token}"}
resp = requests.get(url, headers=headers, timeout=60, stream=True)
resp.raise_for_status()
tmp_dir = SlackMessage.get_tmp_dir()
# Sanitize the name and keep it unique-ish via the url tail
safe_name = re.sub(r"[^\w.\-]", "_", name)
local_path = os.path.join(tmp_dir, safe_name)
with open(local_path, "wb") as fp:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
fp.write(chunk)
logger.debug(f"[Slack] downloaded {name} -> {local_path}")
return local_path
except Exception as e:
logger.error(f"[Slack] download_file failed ({name}): {e}")
return None
# ------------------------------------------------------------------
# Channel trigger logic
# ------------------------------------------------------------------
def _should_reply_in_channel(self, event: dict) -> bool:
"""Decide whether to reply to a plain channel message (no @mention).
app_mention already handles explicit @bot, so here we only deal with
follow-up messages. `all` replies to every message; `mention_or_reply`
replies inside threads the bot already participates in.
"""
mode = conf().get("slack_group_trigger", "mention_or_reply")
if mode == "all":
return True
if mode == "mention_only":
return False
# mention_or_reply: follow up only within an existing thread
return bool(event.get("thread_ts"))
def _strip_at_mention(self, content: str) -> str:
"""Strip <@BOT_USER_ID> from channel text."""
if not content or not self.bot_user_id:
return content
pattern = re.compile(r"<@" + re.escape(self.bot_user_id) + r">", re.IGNORECASE)
return pattern.sub("", content).strip()
@staticmethod
def _compute_session_id(event: dict, is_group: bool) -> str:
channel_id = event.get("channel", "")
user_id = event.get("user", "")
if is_group:
if conf().get("group_shared_session", True):
return f"slack_channel_{channel_id}"
return f"slack_channel_{channel_id}_{user_id}"
return f"slack_user_{user_id}"
# ------------------------------------------------------------------
# Override _compose_context: skip the parent's group whitelist/at checks
# (already handled via _should_reply_in_channel). Same idea as telegram.
# ------------------------------------------------------------------
def _compose_context(self, ctype: ContextType, content, **kwargs):
context = Context(ctype, content)
context.kwargs = kwargs
if "channel_type" not in context:
context["channel_type"] = self.channel_type
if "origin_ctype" not in context:
context["origin_ctype"] = ctype
cmsg = context["msg"]
if cmsg.is_group:
if conf().get("group_shared_session", True):
context["session_id"] = cmsg.other_user_id
else:
context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
else:
context["session_id"] = cmsg.from_user_id
context["receiver"] = cmsg.other_user_id
if ctype == ContextType.TEXT:
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
if img_match_prefix:
content = content.replace(img_match_prefix, "", 1)
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = (content or "").strip()
if "desire_rtype" not in context and conf().get("always_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
elif ctype == ContextType.VOICE:
if "desire_rtype" not in context and (
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
):
context["desire_rtype"] = ReplyType.VOICE
return context
# ------------------------------------------------------------------
# Outbound: ChatChannel.send -> Slack Web API
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
"""Called from cow's sync main thread; Slack Web client is sync-safe."""
if self._client is None:
logger.warning("[Slack] client not ready, drop reply")
return
channel_id = context.get("slack_channel")
thread_ts = context.get("slack_thread_ts")
if not channel_id:
logger.warning("[Slack] no slack_channel in context, drop reply")
return
try:
self._do_send(reply, channel_id, thread_ts)
logger.info(f"[Slack] sent reply (type={reply.type}, channel={channel_id})")
except Exception as e:
logger.error(f"[Slack] send failed: {e}", exc_info=True)
def _do_send(self, reply: Reply, channel_id: str, thread_ts):
rtype = reply.type
content = reply.content
if rtype in (ReplyType.TEXT, ReplyType.INFO, ReplyType.ERROR):
text = str(content) if content is not None else ""
if not text:
return
# Slack caps a message around 40k chars; split conservatively
for chunk in _split_text(text, 3500):
self._client.chat_postMessage(channel=channel_id, text=chunk, thread_ts=thread_ts)
elif rtype == ReplyType.IMAGE:
# Already a local BytesIO; upload it directly
content.seek(0)
self._client.files_upload_v2(
channel=channel_id, file=content, filename="image.png", thread_ts=thread_ts,
)
elif rtype == ReplyType.IMAGE_URL:
url = str(content)
if url.startswith("file://"):
local = url[7:]
self._client.files_upload_v2(
channel=channel_id, file=local, thread_ts=thread_ts,
)
else:
# Post the URL as text; Slack will unfurl it as an image preview
self._client.chat_postMessage(channel=channel_id, text=url, thread_ts=thread_ts)
elif rtype in (ReplyType.VOICE, ReplyType.FILE):
local = content[7:] if isinstance(content, str) and content.startswith("file://") else content
caption = getattr(reply, "text_content", None) or None
self._client.files_upload_v2(
channel=channel_id, file=local, initial_comment=caption, thread_ts=thread_ts,
)
else:
# Fallback: send as plain text
self._client.chat_postMessage(channel=channel_id, text=str(content), thread_ts=thread_ts)
def _split_text(text: str, limit: int):
"""Split long text preferring line breaks to keep markdown structure intact."""
if len(text) <= limit:
yield text
return
buf = []
size = 0
for line in text.splitlines(keepends=True):
if size + len(line) > limit and buf:
yield "".join(buf)
buf, size = [], 0
# Hard-split single lines that exceed the limit
while len(line) > limit:
yield line[:limit]
line = line[limit:]
buf.append(line)
size += len(line)
if buf:
yield "".join(buf)

View File

@@ -0,0 +1,60 @@
"""
Slack message adapter.
Convert a Slack event payload into cow's unified ChatMessage.
File downloads are NOT performed here; the channel layer downloads files
on demand because it needs the bot token for authenticated download URLs.
"""
import os
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.utils import expand_path
from config import conf
class SlackMessage(ChatMessage):
"""Wrap a Slack event into the unified ChatMessage."""
def __init__(self, event: dict, is_group: bool = False, bot_user_id: str = "",
ctype: ContextType = ContextType.TEXT, content: str = ""):
super().__init__(event)
# Basic fields
self.msg_id = event.get("client_msg_id") or event.get("ts") or ""
try:
self.create_time = int(float(event.get("ts", 0)))
except (TypeError, ValueError):
self.create_time = 0
self.ctype = ctype
self.content = content
# Sender / chat info
from_user_id = event.get("user", "unknown")
channel_id = event.get("channel", "")
self.from_user_id = from_user_id
self.from_user_nickname = from_user_id
self.to_user_id = bot_user_id or "slack_bot"
self.to_user_nickname = bot_user_id or "slack_bot"
self.is_group = is_group
if is_group:
# Channel chat: other_user_id = channel_id, actual_user_id = sender id
self.other_user_id = channel_id
self.other_user_nickname = channel_id
self.actual_user_id = from_user_id
self.actual_user_nickname = from_user_id
else:
# DM: use channel_id so replies go back to the same DM channel
self.other_user_id = channel_id or from_user_id
self.other_user_nickname = from_user_id
# Whether the bot was triggered by @-mention (set by channel layer)
self.is_at = False
@staticmethod
def get_tmp_dir() -> str:
"""Local download directory, aligned with other channels (agent_workspace/tmp)."""
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
tmp_dir = os.path.join(workspace_root, "tmp")
os.makedirs(tmp_dir, exist_ok=True)
return tmp_dir

View File

View File

@@ -0,0 +1,719 @@
"""
Telegram channel via Bot API (long polling mode).
Features:
- Single chat & group chat (text / photo / voice / video / document)
- Group trigger: @mention or reply-to-bot (configurable)
- /cancel fast-path matches Web channel behaviour
- Auto-register bot commands menu on startup (mirrors Web slash menu)
- Optional HTTP/SOCKS5 proxy support for restricted networks
Implementation note:
python-telegram-bot is async-first. We run the bot inside a dedicated
thread with its own asyncio loop so the rest of cow (which is sync)
stays untouched. Inbound updates are dispatched onto cow's existing
sync ChatChannel.produce() pipeline; outbound send() schedules
coroutines back onto that loop via asyncio.run_coroutine_threadsafe.
"""
import asyncio
import os
import re
import threading
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.telegram.telegram_message import TelegramMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf
# Bot command menu, aligned with Web slash commands.
# Top-level commands only; sub-commands are entered with a space (e.g. "/skill list").
TELEGRAM_BOT_COMMANDS = [
("help", "Show command help"),
("status", "Show running status"),
("context", "View/clear conversation context (sub: clear)"),
("skill", "Manage skills (list/search/install/...)"),
("memory", "Manage memory (sub: dream)"),
("knowledge", "Manage knowledge base (list/on/off)"),
("config", "Show current config"),
("cancel", "Cancel running agent task"),
("logs", "Show recent logs"),
("version", "Show version"),
]
@singleton
class TelegramChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
self.bot_token = ""
self.bot_username = "" # used for @-mention matching
self._bot = None
self._application = None
self._loop = None
self._loop_thread = None
self._stop_event = threading.Event()
# Idempotent dedup; TG occasionally redelivers the same update on flaky networks
self._received_msgs = ExpiredDict(60 * 60 * 1)
# Disable group whitelist / prefix checks (we handle triggering ourselves
# in _should_reply_in_group), aligned with feishu / wecom_bot channels.
conf()["group_name_white_list"] = ["ALL_GROUP"]
conf()["single_chat_prefix"] = [""]
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def startup(self):
self.bot_token = conf().get("telegram_token", "")
if not self.bot_token:
err = "[Telegram] telegram_token is required"
logger.error(err)
self.report_startup_error(err)
return
try:
from telegram.ext import (
Application,
MessageHandler,
CommandHandler,
filters,
)
except ImportError:
err = (
"[Telegram] python-telegram-bot is not installed. "
"Run: pip install python-telegram-bot"
)
logger.error(err)
self.report_startup_error(err)
return
# Run the asyncio event loop in a dedicated thread so the sync cow body
# is untouched.
self._loop = asyncio.new_event_loop()
def _run_loop():
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._async_main(Application, MessageHandler, CommandHandler, filters))
except Exception as e:
logger.error(f"[Telegram] event loop crashed: {e}", exc_info=True)
self.report_startup_error(str(e))
finally:
try:
self._loop.close()
except Exception:
pass
logger.info("[Telegram] event loop exited")
self._loop_thread = threading.Thread(target=_run_loop, daemon=True, name="telegram-loop")
self._loop_thread.start()
# Block startup() until the loop thread exits, matching other channels'
# behaviour (startup is a blocking call).
self._loop_thread.join()
async def _async_main(self, Application, MessageHandler, CommandHandler, filters):
"""Build Application, register handlers, and run polling."""
builder = Application.builder().token(self.bot_token)
# Proxy: prefer telegram_proxy config, fall back to HTTPS_PROXY env var
proxy_url = conf().get("telegram_proxy", "") or os.environ.get("HTTPS_PROXY", "")
if proxy_url:
try:
builder = builder.proxy(proxy_url).get_updates_proxy(proxy_url)
logger.info(f"[Telegram] using proxy: {proxy_url}")
except Exception as e:
logger.warning(f"[Telegram] proxy config failed, fallback to direct: {e}")
# Media uploads (photo/voice/video/document) over a proxy can be slow,
# bump read/write/connect/pool timeouts.
builder = (
builder
.read_timeout(60)
.write_timeout(120)
.connect_timeout(30)
.pool_timeout(30)
)
application = builder.build()
self._application = application
self._bot = application.bot
# Fetch our own username (needed for @-mention matching in groups)
try:
me = await self._bot.get_me()
self.bot_username = me.username or ""
self.name = self.bot_username # ChatChannel uses self.name to strip @-mention
logger.info(f"[Telegram] Bot logged in as @{self.bot_username} (id={me.id})")
except Exception as e:
err = f"[Telegram] get_me failed: {e}"
logger.error(err)
self.report_startup_error(err)
return
# Register the command menu (failure is non-fatal)
if conf().get("telegram_register_commands", True):
try:
from telegram import BotCommand
cmds = [BotCommand(name, desc) for name, desc in TELEGRAM_BOT_COMMANDS]
await self._bot.set_my_commands(cmds)
logger.info(f"[Telegram] Registered {len(cmds)} bot commands")
except Exception as e:
logger.warning(f"[Telegram] set_my_commands failed: {e}")
# Handlers:
# 1) /cancel uses the fast-path
application.add_handler(CommandHandler("cancel", self._on_cancel))
# 2) Normal messages (text + media)
application.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, self._on_message))
# 3) Other slash commands are forwarded as plain text for the agent to handle
application.add_handler(MessageHandler(filters.COMMAND, self._on_command_passthrough))
# Start polling. drop_pending_updates avoids replaying backlog after restart.
# Transient "Server disconnected" / RemoteProtocolError during get_updates
# are common over proxies/flaky networks; PTB's network loop auto-retries,
# so we only need to keep the noise down (see _quiet_polling_network_errors).
self._quiet_polling_network_errors()
logger.info("[Telegram] Starting long polling...")
await application.initialize()
await application.start()
await application.updater.start_polling(
drop_pending_updates=True,
# Long-poll hold time on the server side; smaller value = reconnect more
# often but each hung connection fails faster.
timeout=30,
# Retry forever on transient get_updates network errors instead of giving up.
bootstrap_retries=-1,
)
self.report_startup_success()
logger.info("[Telegram] ✅ Telegram bot ready, polling for updates")
# Block until stop()
try:
while not self._stop_event.is_set():
await asyncio.sleep(0.5)
finally:
try:
await application.updater.stop()
await application.stop()
await application.shutdown()
except Exception as e:
logger.warning(f"[Telegram] shutdown error: {e}")
@staticmethod
def _quiet_polling_network_errors():
"""Downgrade PTB's noisy 'Exception happened while polling for updates' logs.
These transient get_updates errors (RemoteProtocolError / NetworkError /
TimedOut, typically over a proxy) are auto-retried by PTB's network loop,
so logging the full traceback at ERROR is just noise. We attach a filter
that drops these specific records while leaving real errors untouched.
"""
import logging
class _PollingNoiseFilter(logging.Filter):
_NEEDLES = (
"Exception happened while polling for updates",
"Server disconnected without sending a response",
)
def filter(self, record: logging.LogRecord) -> bool:
try:
msg = record.getMessage()
except Exception:
return True
if any(n in msg for n in self._NEEDLES):
# Keep a single-line breadcrumb at DEBUG, drop the traceback.
logger.debug(f"[Telegram] transient polling network error (auto-retrying): {msg.splitlines()[0]}")
return False
return True
noise_filter = _PollingNoiseFilter()
for name in ("telegram.ext.Updater", "telegram.ext._updater", "telegram.ext"):
logging.getLogger(name).addFilter(noise_filter)
def stop(self):
logger.info("[Telegram] stop() called")
self._stop_event.set()
if self._loop_thread and self._loop_thread.is_alive():
try:
self._loop_thread.join(timeout=10)
except Exception:
pass
logger.info("[Telegram] stop() completed")
# ------------------------------------------------------------------
# Inbound: telegram update -> ChatMessage -> ChatChannel.produce
# ------------------------------------------------------------------
async def _on_cancel(self, update, _context):
"""Fast-path: /cancel calls cancel_session directly without going through agent."""
try:
from agent.protocol import get_cancel_registry
session_id = self._compute_session_id(update)
cancelled = get_cancel_registry().cancel_session(session_id)
text = "Current task cancelled." if cancelled else "No running task to cancel."
await update.effective_message.reply_text(text)
logger.info(f"[Telegram] /cancel session={session_id}, cancelled={cancelled}")
except Exception as e:
logger.error(f"[Telegram] /cancel error: {e}", exc_info=True)
try:
await update.effective_message.reply_text(f"⚠️ /cancel failed: {e}")
except Exception:
pass
async def _on_command_passthrough(self, update, _context):
"""All non-/cancel commands fall through to plain message handling."""
await self._on_message(update, _context)
async def _on_message(self, update, _context):
"""Telegram update entry: parse message -> build ChatMessage -> produce()."""
try:
message = update.effective_message
chat = update.effective_chat
if not message or not chat:
return
# Idempotent dedup
msg_uid = f"{chat.id}:{message.message_id}"
if self._received_msgs.get(msg_uid):
return
self._received_msgs[msg_uid] = True
is_group = chat.type in ("group", "supergroup")
# Debug log: helpful when group messages are silently dropped
if is_group:
logger.debug(
f"[Telegram] group update received: chat_id={chat.id}, "
f"text={(message.text or message.caption or '')[:40]!r}, "
f"reply_to_bot={bool(message.reply_to_message and message.reply_to_message.from_user and message.reply_to_message.from_user.username == self.bot_username)}"
)
# Group trigger gate (silently drop if not triggered)
if is_group and not self._should_reply_in_group(update):
logger.debug(f"[Telegram] group message not triggered (need @{self.bot_username} or reply), skip")
return
# Parse message type + download media if needed.
# Media messages with caption return both the local path and the caption text.
ctype, content, caption = await self._parse_message(message)
if ctype is None:
logger.debug(f"[Telegram] unsupported message type, skip. msg={message}")
return
# Strip @bot mention for group text/caption
if is_group and self.bot_username:
if ctype == ContextType.TEXT and content:
content = self._strip_at_mention(content)
if caption:
caption = self._strip_at_mention(caption)
tg_msg = TelegramMessage(
update,
is_group=is_group,
bot_username=self.bot_username,
ctype=ctype,
content=content,
)
tg_msg.is_at = is_group # If we got here in a group, the bot is mentioned/replied
# File cache: standalone media goes into cache, the next text query attaches them
from channel.file_cache import get_file_cache
file_cache = get_file_cache()
session_id = self._compute_session_id(update)
# Media + caption together: treat as a complete query and bypass the cache
if ctype in (ContextType.IMAGE, ContextType.FILE) and caption:
tag = "image" if ctype == ContextType.IMAGE else "file"
merged_text = f"{caption}\n[{tag}: {content}]"
tg_msg.ctype = ContextType.TEXT
tg_msg.content = merged_text
ctype = ContextType.TEXT
logger.info(f"[Telegram] Media+caption merged for session {session_id}")
# fallthrough to the TEXT branch below
elif ctype == ContextType.IMAGE:
file_cache.add(session_id, content, file_type="image")
logger.info(f"[Telegram] Image cached for session {session_id}, waiting for query...")
return
elif ctype == ContextType.FILE:
file_cache.add(session_id, content, file_type="file")
logger.info(f"[Telegram] File cached for session {session_id}: {content}")
return
if ctype == ContextType.TEXT:
cached_files = file_cache.get(session_id)
if cached_files:
refs = []
for fi in cached_files:
ftype = fi["type"]
tag = ftype if ftype in ("image", "video") else "file"
refs.append(f"[{tag}: {fi['path']}]")
tg_msg.content = (tg_msg.content or "") + "\n" + "\n".join(refs)
file_cache.clear(session_id)
logger.info(f"[Telegram] Attached {len(cached_files)} cached file(s) to query")
# Dispatch to cow main pipeline (reuses ChatChannel._compose_context routing)
context = self._compose_context(
tg_msg.ctype,
tg_msg.content,
isgroup=is_group,
msg=tg_msg,
)
if context:
context["session_id"] = session_id
context["receiver"] = str(chat.id)
context["telegram_chat_id"] = chat.id
context["telegram_reply_to_msg_id"] = message.message_id if is_group else None
self.produce(context)
logger.debug(f"[Telegram] received: type={ctype}, content={str(tg_msg.content)[:80]}")
except Exception as e:
logger.error(f"[Telegram] _on_message error: {e}", exc_info=True)
async def _parse_message(self, message):
"""Parse a telegram message and return (ctype, content, caption).
- content is text for ContextType.TEXT, otherwise the local file path
- caption is the optional text accompanying a media message; empty for plain text
"""
caption = (message.caption or "").strip()
if message.photo:
largest = message.photo[-1]
path = await self._download_file(largest.file_id, suffix=".jpg")
return (ContextType.IMAGE, path, caption) if path else (None, None, "")
if message.voice or message.audio:
audio_obj = message.voice or message.audio
suffix = ".ogg" if message.voice else (
"." + (audio_obj.mime_type.split("/")[-1] if getattr(audio_obj, "mime_type", "") else "mp3")
)
path = await self._download_file(audio_obj.file_id, suffix=suffix)
return (ContextType.VOICE, path, caption) if path else (None, None, "")
if message.video or message.video_note:
video_obj = message.video or message.video_note
path = await self._download_file(video_obj.file_id, suffix=".mp4")
return (ContextType.FILE, path, caption) if path else (None, None, "")
if message.document:
doc = message.document
ext = ""
if doc.file_name and "." in doc.file_name:
ext = "." + doc.file_name.rsplit(".", 1)[-1]
path = await self._download_file(doc.file_id, suffix=ext, original_name=doc.file_name)
if not path:
return (None, None, "")
# Image-typed documents (user picked "send as file") are treated as images
mime = (doc.mime_type or "").lower()
if mime.startswith("image/"):
return (ContextType.IMAGE, path, caption)
return (ContextType.FILE, path, caption)
if message.text:
return (ContextType.TEXT, message.text.strip(), "")
return (None, None, "")
async def _download_file(self, file_id: str, suffix: str = "", original_name: str = ""):
"""Download via bot.get_file into the local tmp dir; return path or None on failure."""
try:
f = await self._bot.get_file(file_id)
tmp_dir = TelegramMessage.get_tmp_dir()
base = original_name or f"{file_id}{suffix or ''}"
# Prefix with file_id to avoid name collisions / weird chars
safe_name = f"{file_id}_{base}" if original_name else base
local_path = os.path.join(tmp_dir, safe_name)
await f.download_to_drive(custom_path=local_path)
logger.debug(f"[Telegram] downloaded file_id={file_id} -> {local_path}")
return local_path
except Exception as e:
logger.error(f"[Telegram] download_file failed (file_id={file_id}): {e}")
return None
# ------------------------------------------------------------------
# Group trigger logic
# ------------------------------------------------------------------
def _should_reply_in_group(self, update) -> bool:
"""Decide whether to reply to a group message based on configuration."""
mode = conf().get("telegram_group_trigger", "mention_or_reply")
if mode == "all":
return True
message = update.effective_message
if not message:
return False
# 1) Mentioned
if self.bot_username and self._is_mentioned(message, self.bot_username):
return True
# 2) Reply to a bot message
if mode == "mention_or_reply":
reply = message.reply_to_message
if reply and reply.from_user and reply.from_user.username == self.bot_username:
return True
return False
@staticmethod
def _is_mentioned(message, bot_username: str) -> bool:
"""Check whether entities/caption_entities contain a @mention of the bot."""
bot_at = "@" + bot_username.lower()
text = (message.text or message.caption or "").lower()
if bot_at in text:
return True
# Also check entities strictly to support text_mention (no-username @)
for ent in (message.entities or []) + (message.caption_entities or []):
if ent.type == "mention":
src = message.text or message.caption or ""
if src[ent.offset: ent.offset + ent.length].lower() == bot_at:
return True
return False
def _strip_at_mention(self, content: str) -> str:
"""Strip @bot_username from group text (case-insensitive)."""
if not content or not self.bot_username:
return content
pattern = re.compile(r"@" + re.escape(self.bot_username), re.IGNORECASE)
return pattern.sub("", content).strip()
@staticmethod
def _compute_session_id(update) -> str:
chat = update.effective_chat
user = update.effective_user
is_group = chat.type in ("group", "supergroup")
if is_group:
if conf().get("group_shared_session", True):
return f"tg_group_{chat.id}"
return f"tg_group_{chat.id}_{user.id}"
return f"tg_user_{user.id}"
# ------------------------------------------------------------------
# Override _compose_context: skip the parent's group whitelist/at checks
# (already handled in _on_message via _should_reply_in_group). Same idea
# as the feishu channel.
# ------------------------------------------------------------------
def _compose_context(self, ctype: ContextType, content, **kwargs):
context = Context(ctype, content)
context.kwargs = kwargs
if "channel_type" not in context:
context["channel_type"] = self.channel_type
if "origin_ctype" not in context:
context["origin_ctype"] = ctype
cmsg = context["msg"]
if cmsg.is_group:
if conf().get("group_shared_session", True):
context["session_id"] = cmsg.other_user_id
else:
context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
else:
context["session_id"] = cmsg.from_user_id
context["receiver"] = cmsg.other_user_id
if ctype == ContextType.TEXT:
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
if img_match_prefix:
content = content.replace(img_match_prefix, "", 1)
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = (content or "").strip()
if "desire_rtype" not in context and conf().get("always_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
elif ctype == ContextType.VOICE:
if "desire_rtype" not in context and (
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
):
context["desire_rtype"] = ReplyType.VOICE
return context
# ------------------------------------------------------------------
# Outbound: ChatChannel.send -> Telegram API
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
"""Called from cow's sync main thread; we marshal the coroutine onto the loop thread."""
if self._loop is None or self._bot is None:
logger.warning("[Telegram] bot not ready, drop reply")
return
chat_id = context.get("telegram_chat_id")
reply_to = context.get("telegram_reply_to_msg_id")
if chat_id is None:
logger.warning("[Telegram] no telegram_chat_id in context, drop reply")
return
coro = self._async_send(reply, chat_id, reply_to)
try:
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
# Media uploads through a proxy can be slow; let PTB's own timeouts win
future.result(timeout=180)
except Exception as e:
logger.error(f"[Telegram] send failed: {e}")
# Number of retries for transient network errors (proxy hiccups etc.)
_SEND_RETRIES = 2
_SEND_RETRY_BACKOFF = 2.0 # seconds
async def _send_with_retry(self, send_fn, *, label: str):
"""Run a single Telegram API call with retries for transient network errors."""
from telegram.error import NetworkError, TimedOut
last_err = None
for attempt in range(self._SEND_RETRIES + 1):
try:
return await send_fn()
except (NetworkError, TimedOut) as e:
last_err = e
if attempt >= self._SEND_RETRIES:
break
wait = self._SEND_RETRY_BACKOFF * (attempt + 1)
logger.warning(
f"[Telegram] {label} transient error (attempt {attempt + 1}/"
f"{self._SEND_RETRIES + 1}): {e}; retry in {wait}s"
)
await asyncio.sleep(wait)
raise last_err
async def _async_send(self, reply: Reply, chat_id, reply_to_msg_id):
try:
rtype = reply.type
content = reply.content
if rtype == ReplyType.TEXT or rtype == ReplyType.INFO or rtype == ReplyType.ERROR:
# Telegram caps a single text message at 4096 chars; auto-split
text = str(content) if content is not None else ""
if not text:
return
for chunk in _split_text(text, 4000):
await self._send_with_retry(
lambda c=chunk: self._bot.send_message(
chat_id=chat_id,
text=c,
reply_to_message_id=reply_to_msg_id,
# Avoid failing the whole send if reply_to was deleted
allow_sending_without_reply=True,
),
label="send_message",
)
elif rtype == ReplyType.IMAGE:
# Already a local BytesIO; send it directly
content.seek(0)
await self._send_with_retry(
lambda: self._bot.send_photo(
chat_id=chat_id,
photo=content,
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
),
label="send_photo",
)
elif rtype == ReplyType.IMAGE_URL:
url = str(content)
if url.startswith("file://"):
local = url[7:]
# Open inside the lambda so each retry gets a fresh stream
async def _send_local_photo():
with open(local, "rb") as f:
return await self._bot.send_photo(
chat_id=chat_id, photo=f,
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
)
await self._send_with_retry(_send_local_photo, label="send_photo(file)")
else:
await self._send_with_retry(
lambda: self._bot.send_photo(
chat_id=chat_id, photo=url,
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
),
label="send_photo(url)",
)
elif rtype == ReplyType.VOICE:
local = content[7:] if isinstance(content, str) and content.startswith("file://") else content
async def _send_voice():
with open(local, "rb") as f:
return await self._bot.send_voice(
chat_id=chat_id, voice=f,
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
)
await self._send_with_retry(_send_voice, label="send_voice")
elif rtype == ReplyType.FILE:
# Videos go through send_video, everything else through send_document
local = content[7:] if isinstance(content, str) and content.startswith("file://") else content
# File replies may carry an accompanying text caption
caption = getattr(reply, "text_content", None) or None
is_video = isinstance(local, str) and local.lower().endswith(
(".mp4", ".mov", ".avi", ".mkv", ".webm")
)
async def _send_file():
with open(local, "rb") as f:
if is_video:
return await self._bot.send_video(
chat_id=chat_id, video=f, caption=caption,
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
)
return await self._bot.send_document(
chat_id=chat_id, document=f, caption=caption,
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
)
await self._send_with_retry(_send_file, label="send_video" if is_video else "send_document")
else:
# Fallback: send as plain text
await self._send_with_retry(
lambda: self._bot.send_message(
chat_id=chat_id, text=str(content),
reply_to_message_id=reply_to_msg_id,
allow_sending_without_reply=True,
),
label="send_message(fallback)",
)
logger.info(f"[Telegram] sent reply (type={rtype}, chat_id={chat_id})")
except Exception as e:
logger.error(f"[Telegram] _async_send error: {e}", exc_info=True)
def _split_text(text: str, limit: int):
"""Split long text preferring line breaks to keep markdown structure intact."""
if len(text) <= limit:
yield text
return
buf = []
size = 0
for line in text.splitlines(keepends=True):
if size + len(line) > limit and buf:
yield "".join(buf)
buf, size = [], 0
# Hard-split single lines that exceed the limit
while len(line) > limit:
yield line[:limit]
line = line[limit:]
buf.append(line)
size += len(line)
if buf:
yield "".join(buf)

View File

@@ -0,0 +1,62 @@
"""
Telegram message adapter.
Convert a python-telegram-bot Update into cow's unified ChatMessage.
File downloads are NOT performed here; the channel layer triggers
bot.get_file() on demand because it requires the async event loop.
"""
import os
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.utils import expand_path
from config import conf
class TelegramMessage(ChatMessage):
"""Wrap a Telegram Update into the unified ChatMessage."""
def __init__(self, update, is_group: bool = False, bot_username: str = "",
ctype: ContextType = ContextType.TEXT, content: str = ""):
super().__init__(update)
message = update.effective_message
chat = update.effective_chat
user = update.effective_user
# Basic fields
self.msg_id = str(message.message_id) if message else ""
self.create_time = int(message.date.timestamp()) if message and message.date else 0
self.ctype = ctype
self.content = content
# Sender / chat info
from_user_id = str(user.id) if user else "unknown"
from_user_nick = (
user.full_name if user and user.full_name else (user.username if user else "unknown")
)
self.from_user_id = from_user_id
self.from_user_nickname = from_user_nick or from_user_id
self.to_user_id = bot_username or "telegram_bot"
self.to_user_nickname = bot_username or "telegram_bot"
self.is_group = is_group
if is_group:
# Group: other_user_id = group_id, actual_user_id = sender id
self.other_user_id = str(chat.id)
self.other_user_nickname = chat.title or str(chat.id)
self.actual_user_id = from_user_id
self.actual_user_nickname = self.from_user_nickname
else:
self.other_user_id = from_user_id
self.other_user_nickname = self.from_user_nickname
# Whether the bot was triggered by @-mention or reply (set by channel layer)
self.is_at = False
@staticmethod
def get_tmp_dir() -> str:
"""Local download directory, aligned with other channels (agent_workspace/tmp)."""
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
tmp_dir = os.path.join(workspace_root, "tmp")
os.makedirs(tmp_dir, exist_ok=True)
return tmp_dir

View File

@@ -1,4 +1,7 @@
import json
import os
import sys
import time
from bridge.context import *
from bridge.reply import Reply, ReplyType
@@ -8,6 +11,164 @@ from common.log import logger
from config import conf
class _Style:
"""ANSI escape codes for terminal styling. Disabled when not a tty."""
enabled = sys.stdout.isatty()
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
ITALIC = "\033[3m"
GRAY = "\033[90m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
@classmethod
def wrap(cls, text, *codes):
if not cls.enabled or not codes:
return text
return "".join(codes) + text + cls.RESET
class TerminalAgentRenderer:
"""Render agent stream events to the terminal in real time.
Reuses the same `on_event` mechanism as the web channel so the terminal
can show reasoning, tool calls and streaming answer text just like the web UI.
"""
def __init__(self):
self._reasoning_active = False
self._answer_active = False
self._has_output = False
# Track tool execution start time as a fallback when the event omits it
self._tool_started_at = {}
def _print(self, text, end="", flush=True):
sys.stdout.write(text)
if end:
sys.stdout.write(end)
if flush:
sys.stdout.flush()
self._has_output = True
def _close_section(self):
"""Finish the currently open streaming section (reasoning or answer)."""
if self._reasoning_active:
self._print("", end="\n")
self._reasoning_active = False
if self._answer_active:
self._print("", end="\n")
self._answer_active = False
def _format_arguments(self, arguments):
try:
if isinstance(arguments, (dict, list)):
text = json.dumps(arguments, ensure_ascii=False)
else:
text = str(arguments)
except Exception:
text = str(arguments)
# Keep tool input compact in the terminal
if len(text) > 300:
text = text[:300] + ""
return text
def handle_event(self, event: dict):
try:
self._handle_event(event)
except Exception as e:
logger.debug(f"[Terminal] render event error: {e}")
def _handle_event(self, event: dict):
event_type = event.get("type")
data = event.get("data", {}) or {}
if event_type == "agent_start":
self._print("\n" + _Style.wrap("Agent: ", _Style.BOLD, _Style.GREEN), end="\n")
elif event_type == "reasoning_update":
delta = data.get("delta", "")
if not delta:
return
if self._answer_active:
self._close_section()
if not self._reasoning_active:
self._print(_Style.wrap("💭 思考 ", _Style.DIM, _Style.MAGENTA), end="\n")
self._reasoning_active = True
self._print(_Style.wrap(delta, _Style.DIM, _Style.ITALIC))
elif event_type == "message_update":
delta = data.get("delta", "")
if not delta:
return
if self._reasoning_active:
self._close_section()
self._answer_active = True
self._print(delta)
elif event_type == "tool_execution_start":
self._close_section()
tool_name = data.get("tool_name", "tool")
tool_id = data.get("tool_call_id")
arguments = data.get("arguments", {})
self._tool_started_at[tool_id] = time.time()
header = _Style.wrap(f"🔧 {tool_name}", _Style.BOLD, _Style.CYAN)
args_str = self._format_arguments(arguments)
self._print(f"{header} {_Style.wrap(args_str, _Style.GRAY)}", end="\n")
elif event_type == "tool_execution_end":
tool_name = data.get("tool_name", "tool")
tool_id = data.get("tool_call_id")
status = data.get("status", "success")
result = data.get("result", "")
exec_time = data.get("execution_time")
if exec_time is None and tool_id in self._tool_started_at:
exec_time = time.time() - self._tool_started_at.pop(tool_id, time.time())
success = status == "success"
icon = "" if success else ""
color = _Style.GREEN if success else _Style.RED
result_str = str(result)
if len(result_str) > 500:
result_str = result_str[:500] + ""
# Indent multi-line tool output for readability
result_str = result_str.replace("\n", "\n ")
cost = f" ({exec_time:.2f}s)" if isinstance(exec_time, (int, float)) else ""
self._print(
_Style.wrap(f" {icon} {tool_name}{cost}", color) + " " + _Style.wrap(result_str, _Style.GRAY),
end="\n",
)
elif event_type == "file_to_send":
self._close_section()
file_path = data.get("path", "")
file_name = data.get("file_name", "")
label = file_name or file_path
self._print(_Style.wrap(f"📎 文件: {label}", _Style.BLUE), end="\n")
elif event_type == "error":
self._close_section()
err_msg = data.get("error") or "unknown error"
self._print(_Style.wrap(f"{err_msg}", _Style.BOLD, _Style.RED), end="\n")
elif event_type == "agent_cancelled":
self._close_section()
self._print(_Style.wrap("⏹ 已中止", _Style.YELLOW), end="\n")
elif event_type == "agent_end":
self._close_section()
def finish(self):
"""Ensure any open section is closed at the end of a turn."""
self._close_section()
class TerminalMessage(ChatMessage):
def __init__(
self,
@@ -29,17 +190,33 @@ class TerminalMessage(ChatMessage):
class TerminalChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE]
def __init__(self):
super().__init__()
# Per-request renderers keyed by request_id; used to detect whether
# agent text was already streamed so send() can avoid duplicate output.
self._renderers = {}
# Callback that restores TTY attributes on exit (set in startup).
self._restore_terminal = None
def send(self, reply: Reply, context: Context):
print("\nBot:")
request_id = context.get("request_id") if context else None
renderer = self._renderers.pop(request_id, None) if request_id else None
streamed = renderer is not None and renderer._has_output
if renderer is not None:
renderer.finish()
if reply.type == ReplyType.IMAGE:
from PIL import Image
image_storage = reply.content
image_storage.seek(0)
img = Image.open(image_storage)
if not streamed:
print("\nAgent: ")
print("<IMAGE>")
img.show()
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
elif reply.type == ReplyType.IMAGE_URL: # download image from url
import io
import requests
@@ -52,38 +229,122 @@ class TerminalChannel(ChatChannel):
image_storage.write(block)
image_storage.seek(0)
img = Image.open(image_storage)
if not streamed:
print("\nAgent: ")
print(img_url)
img.show()
else:
print(reply.content)
print("\nUser:", end="")
# When agent already streamed the answer, skip re-printing the
# final text to avoid duplication; just emit a trailing newline.
if streamed:
print()
else:
print("\nAgent: ")
print(reply.content)
print("\nUser: ", end="")
sys.stdout.flush()
return
def _silence_console_logging(self):
"""Mute console log output so background-thread logs (web/MCP/scheduler)
don't flood the interactive terminal. Logs still go to run.log in full.
Configurable via `terminal_log_level` (default ERROR). The file handler
is untouched, so run.log keeps the complete log.
"""
import logging
level_name = str(conf().get("terminal_log_level", "ERROR")).upper()
level = getattr(logging, level_name, logging.ERROR)
root_logger = logging.getLogger("log")
for handler in root_logger.handlers:
# Only raise the level of the stdout/stderr stream handler;
# keep FileHandler at the logger's level so run.log stays complete.
if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler):
handler.setLevel(level)
def _install_terminal_guard(self):
"""Save TTY attributes and register restore hooks so the terminal is
never left in a broken state (no echo / raw mode / leftover ANSI) after
the process exits, especially when Ctrl+C interrupts a blocking input().
"""
if not sys.stdin.isatty():
return
try:
import atexit
import termios
saved_attrs = termios.tcgetattr(sys.stdin.fileno())
def _restore():
try:
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, saved_attrs)
except Exception:
pass
try:
if _Style.enabled:
sys.stdout.write(_Style.RESET)
sys.stdout.flush()
except Exception:
pass
self._restore_terminal = _restore
atexit.register(_restore)
except Exception as e:
# termios is unavailable on Windows; skip the guard there.
logger.debug(f"[Terminal] terminal guard not installed: {e}")
self._restore_terminal = None
def startup(self):
context = Context()
logger.setLevel("WARN")
print("\nPlease input your question:\nUser:", end="")
self._silence_console_logging()
self._install_terminal_guard()
print("\nPlease input your question:\nUser: ", end="")
sys.stdout.flush()
msg_id = 0
while True:
try:
prompt = self.get_input()
except KeyboardInterrupt:
print("\nExiting...")
sys.exit()
except (KeyboardInterrupt, EOFError):
self._shutdown()
msg_id += 1
trigger_prefixs = conf().get("single_chat_prefix", [""])
if check_prefix(prompt, trigger_prefixs) is None:
prompt = trigger_prefixs[0] + prompt # 给没触发的消息加上触发前缀
prompt = trigger_prefixs[0] + prompt # add trigger prefix to untriggered messages
context = self._compose_context(ContextType.TEXT, prompt, msg=TerminalMessage(msg_id, prompt))
context["isgroup"] = False
if context:
# Attach an agent event renderer so reasoning / tool calls /
# streaming answer show up live in the terminal (web-like UX).
request_id = str(msg_id)
context["request_id"] = request_id
renderer = TerminalAgentRenderer()
self._renderers[request_id] = renderer
context["on_event"] = renderer.handle_event
self.produce(context)
else:
raise Exception("context is None")
def _shutdown(self):
"""Restore terminal state and terminate the whole process.
startup() runs in a daemon sub-thread, so sys.exit() would only kill
this thread and leave the main process (and web/MCP/scheduler threads)
alive, holding the terminal in a half-occupied state -> laggy input.
We reset any leftover ANSI styling and hard-exit the process instead.
"""
# Restore TTY attributes and reset any leftover ANSI styling
# (e.g. interrupted mid-stream output) before terminating.
if self._restore_terminal:
self._restore_terminal()
elif _Style.enabled:
sys.stdout.write(_Style.RESET)
sys.stdout.write("\nExiting...\n")
sys.stdout.flush()
# Hard-exit the entire process from a daemon thread.
os._exit(0)
def get_input(self):
"""
Multi-line input function

View File

@@ -47,11 +47,30 @@
This runs synchronously in <head> so the correct class is on <html>
before any CSS or body rendering occurs. -->
<script>
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'en',
// or '' when unrecognized so callers can fall through to the next source.
window.__cowNormalizeLang__ = function(raw) {
if (!raw) return '';
var v = String(raw).trim().toLowerCase();
if (v === 'auto') return '';
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
};
// Resolve the console language by priority:
// user choice (localStorage) -> backend-detected -> browser -> 'zh'.
window.__cowResolveLang__ = function() {
return window.__cowNormalizeLang__(localStorage.getItem('cow_lang'))
|| window.__cowNormalizeLang__(window.__COW_DEFAULT_LANG__)
|| window.__cowNormalizeLang__(navigator.language || (navigator.languages && navigator.languages[0]))
|| 'zh';
};
(function() {
// Backend-resolved default language (from cow_lang config / auto-detect).
window.__COW_DEFAULT_LANG__ = '{{COW_DEFAULT_LANG}}';
var theme = localStorage.getItem('cow_theme') || 'dark';
if (theme === 'dark') document.documentElement.classList.add('dark');
var lang = localStorage.getItem('cow_lang') || 'zh';
document.documentElement.setAttribute('lang', lang);
document.documentElement.setAttribute('lang', window.__cowResolveLang__());
})();
</script>
</head>
@@ -445,7 +464,7 @@
bg-primary-400 text-white hover:bg-primary-500
disabled:bg-slate-300 dark:disabled:bg-slate-600
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
disabled onclick="sendMessage()">
disabled>
<i class="fas fa-paper-plane text-sm"></i>
</button>
</div>
@@ -640,6 +659,31 @@
</div>
</div>
<!-- Language Config Card -->
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-9 h-9 rounded-lg bg-sky-50 dark:bg-sky-900/30 flex items-center justify-center">
<i class="fas fa-language text-sky-500 text-sm"></i>
</div>
<h3 class="font-semibold text-slate-800 dark:text-slate-100" data-i18n="config_language">语言</h3>
</div>
<div class="space-y-4">
<div>
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="config_language">语言</span>
<span class="cfg-tip" data-tip-key="config_language_hint"><i class="fas fa-circle-question"></i></span>
</label>
<div id="cfg-lang-select" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1367,3 +1367,35 @@
text-align: right;
}
.voice-pill audio { display: none; }
/* Send button toggles into a Stop button while an SSE stream is in flight.
Match the look of the disabled send button (light grey block + white
glyph) so it reads as the same visual element, just paused/idle from
sending perspective and clickable to stop. */
#send-btn.send-btn-cancel {
background-color: rgb(203 213 225) !important; /* slate-300, == disabled send-btn */
color: white !important;
}
#send-btn.send-btn-cancel:hover {
background-color: rgb(148 163 184) !important; /* slate-400 */
}
#send-btn.send-btn-cancel:disabled {
background-color: rgb(226 232 240) !important; /* slate-200, while stop is in flight */
color: white !important;
cursor: progress;
}
.dark #send-btn.send-btn-cancel {
background-color: rgb(71 85 105) !important; /* slate-600, == dark disabled send-btn */
color: white !important;
}
.dark #send-btn.send-btn-cancel:hover {
background-color: rgb(100 116 139) !important; /* slate-500 */
}
.dark #send-btn.send-btn-cancel:disabled {
background-color: rgb(51 65 85) !important; /* slate-700 */
color: rgb(203 213 225) !important;
}
.agent-cancelled-tag {
font-style: italic;
}

View File

@@ -91,9 +91,31 @@ const I18N = {
example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况',
example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能',
example_web_title: '指令中心', example_web_text: '查看全部命令',
slash_help: '显示命令帮助',
slash_status: '查看运行状态',
slash_context: '查看对话上下文',
slash_context_clear: '清除对话上下文',
slash_skill_list: '查看已安装技能',
slash_skill_list_remote: '浏览技能广场',
slash_skill_search: '搜索技能',
slash_skill_install: '安装技能 (名称或 GitHub URL)',
slash_skill_uninstall: '卸载技能',
slash_skill_info: '查看技能详情',
slash_skill_enable: '启用技能',
slash_skill_disable: '禁用技能',
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
slash_knowledge: '查看知识库统计',
slash_knowledge_list: '查看知识库文件树',
slash_knowledge_on: '开启知识库',
slash_knowledge_off: '关闭知识库',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: 'Agent 配置',
config_language: '语言', config_language_hint: '界面展示、命令文案、系统提示词等使用的语言(与右上角切换同步)',
config_model_advanced: '高级配置',
config_channel: '通道配置',
config_agent_enabled: 'Agent 模式',
@@ -265,9 +287,31 @@ const I18N = {
example_knowledge_title: 'Knowledge', example_knowledge_text: 'Show me the current knowledge base',
example_skill_title: 'Skills', example_skill_text: 'Show current tools and skills',
example_web_title: 'Commands', example_web_text: 'Show all commands',
slash_help: 'Show this help',
slash_status: 'Show running status',
slash_context: 'Show conversation context',
slash_context_clear: 'Clear conversation context',
slash_skill_list: 'List installed skills',
slash_skill_list_remote: 'Browse Skill Hub',
slash_skill_search: 'Search skills',
slash_skill_install: 'Install a skill (name or GitHub URL)',
slash_skill_uninstall: 'Uninstall a skill',
slash_skill_info: 'Show skill details',
slash_skill_enable: 'Enable a skill',
slash_skill_disable: 'Disable a skill',
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
slash_knowledge: 'Show knowledge base stats',
slash_knowledge_list: 'Show knowledge base file tree',
slash_knowledge_on: 'Enable knowledge base',
slash_knowledge_off: 'Disable knowledge base',
slash_config: 'Show current config',
slash_cancel: 'Abort the running Agent task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
input_placeholder: 'Type a message, or press / for commands',
config_title: 'Configuration', config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration', config_agent: 'Agent Configuration',
config_language: 'Language', config_language_hint: 'Language for the UI, command text, system prompts and more (synced with the top-right switch)',
config_model_advanced: 'Advanced',
config_channel: 'Channel Configuration',
config_agent_enabled: 'Agent Mode',
@@ -361,12 +405,39 @@ const I18N = {
}
};
let currentLang = localStorage.getItem('cow_lang') || 'zh';
// Resolve language by priority: user choice (localStorage) -> backend-detected
// (cow_lang) -> browser language -> 'zh'. Shares __cowResolveLang__ defined in
// chat.html; falls back to a local resolver if loaded standalone.
let currentLang = (typeof window.__cowResolveLang__ === 'function')
? window.__cowResolveLang__()
: (function () {
const norm = (raw) => {
if (!raw) return '';
const v = String(raw).trim().toLowerCase();
if (v === 'auto') return '';
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
};
return norm(localStorage.getItem('cow_lang'))
|| norm(window.__COW_DEFAULT_LANG__)
|| norm(navigator.language)
|| 'zh';
})();
function t(key) {
return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key;
}
// Resolve a localized label that may be either a plain string or
// a {zh, en} object returned by the backend.
function localizedLabel(label) {
if (label && typeof label === 'object') {
return label[currentLang] || label.en || label.zh || '';
}
return label || '';
}
function applyI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
@@ -385,14 +456,60 @@ function applyI18n() {
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
}
function toggleLanguage() {
currentLang = currentLang === 'zh' ? 'en' : 'zh';
// Single entry point for switching language. Updates the in-memory language,
// persists the user choice locally, re-renders the UI, and binds the choice to
// the backend `cow_lang` config so logs / agent replies / CLI follow suit.
function setLanguage(lang) {
const next = (lang === 'en') ? 'en' : 'zh';
if (next === currentLang) {
// Still persist + sync in case storage/backend drifted from the UI.
syncLanguageToBackend(next);
return;
}
currentLang = next;
localStorage.setItem('cow_lang', currentLang);
applyI18n();
_applyInputTooltips();
// Re-render views whose DOM is built in JS (data-i18n alone does not
// cover strings interpolated via t() into innerHTML).
try { rerenderDynamicViews(); } catch (e) {}
// Keep the language switch button and config selector visually in sync.
try { updateLangControls(); } catch (e) {}
syncLanguageToBackend(currentLang);
}
// Persist the language to the backend `cow_lang` config (best-effort; the UI
// has already switched locally, so a network failure is non-blocking).
function syncLanguageToBackend(lang) {
try {
fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { cow_lang: lang } })
}).catch(() => {});
} catch (e) {}
}
// Reflect the current language on both the top-right toggle and the config
// selector (if present), so the two entry points stay synchronized.
function updateLangControls() {
const langLabel = document.getElementById('lang-label');
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
// The config language picker is the custom .cfg-dropdown component. Only
// sync it once it has been initialized (i.e. the config panel was opened).
const sel = document.getElementById('cfg-lang-select');
if (sel && sel._ddValue !== undefined && sel._ddValue !== currentLang) {
sel._ddValue = currentLang;
const textEl = sel.querySelector('.cfg-dropdown-text');
if (textEl) textEl.textContent = currentLang === 'zh' ? '中文' : 'English';
sel.querySelectorAll('.cfg-dropdown-item').forEach(i => {
i.classList.toggle('active', i.dataset.value === currentLang);
});
}
}
function toggleLanguage() {
setLanguage(currentLang === 'zh' ? 'en' : 'zh');
}
// Refresh JS-rendered views after a language switch. Each branch uses the
@@ -1007,7 +1124,60 @@ const inputHistory = [];
let historyIdx = -1;
let historySavedDraft = '';
// While an SSE stream is in flight, the send button morphs into a cancel
// button. Only one in-flight request is supported at a time.
let activeRequestId = null;
let sendBtnMode = 'send'; // 'send' | 'cancel'
function setSendBtnCancelMode(requestId) {
activeRequestId = requestId;
sendBtnMode = 'cancel';
sendBtn.disabled = false;
sendBtn.classList.add('send-btn-cancel');
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
}
function resetSendBtnSendMode() {
activeRequestId = null;
sendBtnMode = 'send';
sendBtn.classList.remove('send-btn-cancel');
sendBtn.title = '';
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
updateSendBtnState();
}
function requestCancel() {
const reqId = activeRequestId;
if (!reqId) return;
fetch('/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: reqId, session_id: sessionId, lang: currentLang }),
}).catch(err => {
console.warn('[cancel] request failed', err);
});
// Optimistic UI lock so the click visibly registers before the SSE
// "cancelled" event arrives.
sendBtn.disabled = true;
sendBtn.title = (currentLang === 'zh' ? '已中止' : 'Cancelled');
}
// Button click is the only path to Cancel. Pressing Enter still calls
// sendMessage() so users can submit "/cancel" as a regular slash command.
sendBtn.addEventListener('click', () => {
if (sendBtnMode === 'cancel') {
requestCancel();
} else {
sendMessage();
}
});
function updateSendBtnState() {
if (sendBtnMode === 'cancel') {
// Don't downgrade a Cancel button on input edits.
return;
}
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
}
@@ -1236,27 +1406,30 @@ chatInput.addEventListener('compositionstart', () => { isComposing = true; });
chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
// ── Slash Command Menu ───────────────────────────────────────
// desc holds an i18n key, resolved via t() at render time so the menu follows
// the current UI language.
const SLASH_COMMANDS = [
{ cmd: '/help', desc: '显示命令帮助' },
{ cmd: '/status', desc: '查看运行状态' },
{ cmd: '/context', desc: '查看对话上下文' },
{ cmd: '/context clear', desc: '清除对话上下文' },
{ cmd: '/skill list', desc: '查看已安装技能' },
{ cmd: '/skill list --remote', desc: '浏览技能广场' },
{ cmd: '/skill search ', desc: '搜索技能' },
{ cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' },
{ cmd: '/skill uninstall ', desc: '卸载技能' },
{ cmd: '/skill info ', desc: '查看技能详情' },
{ cmd: '/skill enable ', desc: '启用技能' },
{ cmd: '/skill disable ', desc: '禁用技能' },
{ cmd: '/memory dream ', desc: '手动触发记忆蒸馏 (可指定天数, 默认3)' },
{ cmd: '/knowledge', desc: '查看知识库统计' },
{ cmd: '/knowledge list', desc: '查看知识库文件树' },
{ cmd: '/knowledge on', desc: '开启知识库' },
{ cmd: '/knowledge off', desc: '关闭知识库' },
{ cmd: '/config', desc: '查看当前配置' },
{ cmd: '/logs', desc: '查看最近日志' },
{ cmd: '/version', desc: '查看版本' },
{ cmd: '/help', desc: 'slash_help' },
{ cmd: '/status', desc: 'slash_status' },
{ cmd: '/context', desc: 'slash_context' },
{ cmd: '/context clear', desc: 'slash_context_clear' },
{ cmd: '/skill list', desc: 'slash_skill_list' },
{ cmd: '/skill list --remote', desc: 'slash_skill_list_remote' },
{ cmd: '/skill search ', desc: 'slash_skill_search' },
{ cmd: '/skill install ', desc: 'slash_skill_install' },
{ cmd: '/skill uninstall ', desc: 'slash_skill_uninstall' },
{ cmd: '/skill info ', desc: 'slash_skill_info' },
{ cmd: '/skill enable ', desc: 'slash_skill_enable' },
{ cmd: '/skill disable ', desc: 'slash_skill_disable' },
{ cmd: '/memory dream ', desc: 'slash_memory_dream' },
{ cmd: '/knowledge', desc: 'slash_knowledge' },
{ cmd: '/knowledge list', desc: 'slash_knowledge_list' },
{ cmd: '/knowledge on', desc: 'slash_knowledge_on' },
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
{ cmd: '/config', desc: 'slash_config' },
{ cmd: '/cancel', desc: 'slash_cancel' },
{ cmd: '/logs', desc: 'slash_logs' },
{ cmd: '/version', desc: 'slash_version' },
];
const slashMenu = document.getElementById('slash-menu');
@@ -1310,7 +1483,7 @@ function renderSlashItems() {
slashFiltered.map((c, i) =>
`<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` +
`<span class="cmd">${escapeHtml(c.cmd)}</span>` +
`<span class="desc">${escapeHtml(c.desc)}</span></div>`
`<span class="desc">${escapeHtml(t(c.desc))}</span></div>`
).join('');
const activeEl = slashMenu.querySelector('.slash-menu-item.active');
@@ -1525,6 +1698,7 @@ function sendVoiceMessage(text, audioUrl) {
stream: true,
timestamp: timestamp.toISOString(),
is_voice: true,
lang: currentLang,
};
const MAX_RETRIES = 2;
@@ -1538,7 +1712,12 @@ function sendVoiceMessage(text, audioUrl) {
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
if (data.stream) {
if (data.inline_reply) {
// Synchronous fast-path reply (e.g. /cancel); skip SSE.
loadingEl.remove();
addBotMessage(data.inline_reply, new Date());
} else if (data.stream) {
setSendBtnCancelMode(data.request_id);
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
} else {
loadingContainers[data.request_id] = loadingEl;
@@ -1546,6 +1725,7 @@ function sendVoiceMessage(text, audioUrl) {
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
})
.catch(err => {
@@ -1582,6 +1762,10 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) {
}
function sendMessage() {
// Do NOT branch on sendBtnMode here: Enter should always send (so
// typing "/cancel" submits normally). Cancel is wired only to the
// send button's pointer click — see send-btn listener above.
const text = chatInput.value.trim();
if (!text && pendingAttachments.length === 0) return;
@@ -1610,7 +1794,7 @@ function sendMessage() {
renderAttachmentPreview();
sendBtn.disabled = true;
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() };
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString(), lang: currentLang };
if (attachments.length > 0) {
body.attachments = attachments.map(a => ({
file_path: a.file_path,
@@ -1632,7 +1816,13 @@ function sendMessage() {
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
if (data.stream) {
if (data.inline_reply) {
// Channel handled synchronously (e.g. /cancel fast-path);
// render as a bot bubble and skip SSE entirely.
loadingEl.remove();
addBotMessage(data.inline_reply, new Date());
} else if (data.stream) {
setSendBtnCancelMode(data.request_id);
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
} else {
loadingContainers[data.request_id] = loadingEl;
@@ -1640,12 +1830,14 @@ function sendMessage() {
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
})
.catch(err => {
if (err.name === 'AbortError') {
loadingEl.remove();
addBotMessage(t('error_timeout'), new Date());
resetSendBtnSendMode();
return;
}
if (attempt < MAX_RETRIES) {
@@ -1655,6 +1847,7 @@ function sendMessage() {
}
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
});
}
@@ -1910,14 +2103,33 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
stepsEl.appendChild(wrap);
scrollChatToBottom();
} else if (item.type === 'cancelled') {
// Agent acknowledged the stop; mark the bubble. A trailing
// "done" still arrives with the partial answer.
ensureBotEl();
if (currentReasoningEl) {
finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText);
currentReasoningEl = null;
reasoningText = '';
}
if (!botEl.querySelector('.agent-cancelled-tag')) {
const tag = document.createElement('div');
tag.className = 'agent-cancelled-tag text-xs text-amber-600 dark:text-amber-400 mt-1';
tag.textContent = (currentLang === 'zh') ? '已中止' : 'Cancelled';
stepsEl.appendChild(tag);
}
resetSendBtnSendMode();
} else if (item.type === 'done') {
// Don't close the stream yet: the backend keeps it open
// for a short tail to deliver async attachments such as
// TTS audio (`voice_attach`). It will close the stream on
// its own via onerror once the tail expires.
done = true;
resetSendBtnSendMode();
const finalText = item.content || accumulatedText;
const finalTextRaw = item.content || accumulatedText;
const finalText = localizeCancelMarker(finalTextRaw);
if (!botEl && finalText) {
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
@@ -1925,7 +2137,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
} else if (botEl) {
contentEl.classList.remove('sse-streaming');
if (finalText) contentEl.innerHTML = renderMarkdown(finalText);
contentEl.dataset.rawMd = finalText || '';
contentEl.dataset.rawMd = finalTextRaw || '';
const copyBtn = botEl.querySelector('.copy-msg-btn');
if (copyBtn && finalText) copyBtn.style.display = '';
applyHighlighting(botEl);
@@ -1955,6 +2167,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
delete activeStreams[requestId];
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
addBotMessage(t('error_send'), new Date());
resetSendBtnSendMode();
}
};
@@ -1991,6 +2204,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
applyHighlighting(botEl);
bindChatKnowledgeLinks(botEl);
}
resetSendBtnSendMode();
};
}
@@ -2229,13 +2443,23 @@ function _renderSentFileFromToolResult(step) {
`<i class="fas fa-file-download" style="color:#6b7280;"></i> ${escapeHtml(fileName)}</a></div>`;
}
// Cosmetic translator for cancel markers persisted in history.
// History keeps the English canonical form for the LLM; only display is localized.
function localizeCancelMarker(text) {
if (!text) return text;
if (currentLang !== 'zh') return text;
return text
.replace(/_\(Cancelled by user\)_/g, '_(用户已中止)_')
.replace(/_\(Cancelled\)_/g, '_(已中止)_');
}
function createBotMessageEl(content, timestamp, requestId, msg) {
const el = document.createElement('div');
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
if (requestId) el.dataset.requestId = requestId;
let stepsHtml = '';
let displayContent = content;
let displayContent = localizeCancelMarker(content);
if (msg && msg.steps && msg.steps.length > 0) {
// New format: ordered steps with interleaved content
@@ -3164,7 +3388,7 @@ function initConfigView(data) {
configCurrentModel = data.model || '';
const providerEl = document.getElementById('cfg-provider');
const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: p.label }));
const providerOpts = Object.entries(configProviders).map(([pid, p]) => ({ value: pid, label: localizedLabel(p.label) }));
// if use_linkai is enabled, always select linkai as the provider
// Otherwise prefer bot_type from config, fall back to model-based detection
@@ -3182,6 +3406,18 @@ function initConfigView(data) {
document.getElementById('cfg-max-steps').value = data.agent_max_steps || 20;
document.getElementById('cfg-enable-thinking').checked = data.enable_thinking === true;
// Reflect the current UI language (already resolved, may include the user's
// local choice) on the selector so it stays in sync with the top-right toggle.
const langSel = document.getElementById('cfg-lang-select');
if (langSel) {
initDropdown(
langSel,
[{ value: 'zh', label: '中文' }, { value: 'en', label: 'English' }],
currentLang,
(val) => setLanguage(val)
);
}
const pwdInput = document.getElementById('cfg-password');
const maskedPwd = data.web_password_masked || '';
pwdInput.value = maskedPwd;
@@ -3914,7 +4150,7 @@ function renderVendorChip(p) {
bg-slate-50 dark:bg-white/5 hover:border-primary-300 dark:hover:border-primary-500/50
cursor-pointer transition-colors duration-150 text-left">
${renderProviderLogo(p, 28)}
<span class="flex-1 min-w-0 text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${escapeHtml(p.label)}</span>
<span class="flex-1 min-w-0 text-sm font-medium text-slate-800 dark:text-slate-100 truncate">${escapeHtml(localizedLabel(p.label))}</span>
<i class="fas fa-pen-to-square text-[11px] text-slate-400 dark:text-slate-500 group-hover:text-primary-500 transition-colors"></i>
</button>`;
}
@@ -3922,7 +4158,7 @@ function renderVendorChip(p) {
// Render a uniformly-styled logo for a provider. Tries an SVG asset first; if
// it 404s the <img> swaps itself for a monogram fallback via onerror.
function renderProviderLogo(p, sizePx) {
const initial = (p.label || p.id || '?').slice(0, 1).toUpperCase();
const initial = (localizedLabel(p.label) || p.id || '?').slice(0, 1).toUpperCase();
const sz = sizePx || 32;
const url = `${MODELS_PROVIDER_LOGO_PATH}/${encodeURIComponent(p.id)}.svg`;
const fallbackId = `pl-${p.id}-${Math.random().toString(36).slice(2, 8)}`;
@@ -3977,7 +4213,7 @@ function renderCapabilityHeaderTag(def, cap) {
function _searchProviderLabel(cap, providerId) {
const list = (cap && cap.providers) || [];
const hit = list.find(p => p.id === providerId);
return hit ? hit.label : providerId;
return hit ? localizedLabel(hit.label) : providerId;
}
// Search card body: strategy picker + (when fixed) provider picker + a
@@ -4103,7 +4339,7 @@ function _renderSearchSummary(body, cap) {
class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-md cursor-pointer
bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400
hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors">
<i class="fas fa-check text-[10px]"></i>${escapeHtml(p.label)}
<i class="fas fa-check text-[10px]"></i>${escapeHtml(localizedLabel(p.label))}
</button>
`).join('');
host.innerHTML = `
@@ -4150,7 +4386,7 @@ function openSearchAddProviderPicker(missingProviders) {
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer
bg-slate-50 dark:bg-white/5 hover:bg-slate-100 dark:hover:bg-white/10
text-sm text-slate-700 dark:text-slate-200 transition-colors">
<span>${escapeHtml(p.label)}</span>
<span>${escapeHtml(localizedLabel(p.label))}</span>
<i class="fas fa-chevron-right text-[10px] text-slate-400"></i>
</button>
`).join('');
@@ -4607,7 +4843,7 @@ function renderCapabilityHints(def, cap, body, currentProvider) {
// id ("linkai") when we know it. Falls back to the id when the
// provider isn't in our vendor table (rare).
const provMeta = modelsState.providers.find(p => p.id === fbProv);
const fbProvLabel = (provMeta && provMeta.label) || fbProv;
const fbProvLabel = (provMeta && localizedLabel(provMeta.label)) || fbProv;
const fbText = fbModel ? `${fbProvLabel} / ${fbModel}` : fbProvLabel;
slot.innerHTML = `
<p class="flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500 min-w-0">
@@ -4639,7 +4875,7 @@ function buildCapabilityProviderOptions(def, cap) {
const configured = !tracked || !!meta.configured;
return {
value: pid,
label: (meta && meta.label) || pid,
label: (meta && localizedLabel(meta.label)) || pid,
_tracked: tracked,
_configured: configured,
};
@@ -5069,7 +5305,7 @@ function openVendorModal(providerId, onSaved) {
const pickerEl = document.getElementById('vendor-modal-picker');
const pickerOpts = modelsState.providers.map(p => ({
value: p.id,
label: p.label,
label: localizedLabel(p.label),
_configured: !!p.configured,
}));
initDropdown(pickerEl, pickerOpts, defaultId, (val) => fillVendorModalForProvider(val));
@@ -5108,7 +5344,7 @@ function openVendorModal(providerId, onSaved) {
function fillVendorModalForProvider(providerId) {
const meta = modelsState.providers.find(p => p.id === providerId);
if (!meta) return;
document.getElementById('vendor-modal-title').textContent = meta.label;
document.getElementById('vendor-modal-title').textContent = localizedLabel(meta.label);
document.getElementById('vendor-modal-subtitle').textContent = meta.id;
// ----- API Base -----

View File

@@ -21,6 +21,7 @@ from channel.chat_channel import ChatChannel, check_prefix
from channel.chat_message import ChatMessage
from collections import OrderedDict
from common import const
from common import i18n
from common.log import logger
from common.singleton import singleton
from config import conf
@@ -28,8 +29,16 @@ from config import conf
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
def _get_web_password() -> str:
# Coerce to str so non-string values in config.json (e.g. numeric password) won't break comparisons
pwd = conf().get("web_password", "")
if pwd is None:
return ""
return str(pwd)
def _is_password_enabled():
return bool(conf().get("web_password", ""))
return bool(_get_web_password())
def _session_expire_seconds():
@@ -40,7 +49,7 @@ def _create_auth_token():
"""Create a stateless signed token: ``<timestamp_hex>.<hmac_hex>``."""
ts = format(int(time.time()), "x")
sig = hmac.new(
conf().get("web_password", "").encode(),
_get_web_password().encode(),
ts.encode(),
hashlib.sha256,
).hexdigest()
@@ -63,7 +72,7 @@ def _verify_auth_token(token):
if time.time() - ts > _session_expire_seconds():
return False
expected = hmac.new(
conf().get("web_password", "").encode(),
_get_web_password().encode(),
ts_hex.encode(),
hashlib.sha256,
).hexdigest()
@@ -85,6 +94,15 @@ def _require_auth():
json.dumps({"status": "error", "message": "Unauthorized"}))
# Localized text for /cancel system replies. Web is the only channel that
# honors a per-request `lang`; other channels reply in Chinese by default.
def _cancel_reply_text(cancelled: int, lang: str) -> str:
en = lang.startswith("en")
if cancelled > 0:
return "🛑 Cancelled" if en else "🛑 已中止"
return "Nothing to cancel." if en else "当前没有可中止的任务。"
def _get_upload_dir() -> str:
from common.utils import expand_path
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
@@ -429,6 +447,18 @@ class WebChannel(ChatChannel):
"timestamp": time.time(),
})
elif event_type == "agent_cancelled":
# Push an explicit cancelled SSE event so the frontend
# marks the bubble as stopped. A trailing "done" still
# arrives with the partial answer.
final_response = data.get("final_response", "")
q.put({
"type": "cancelled",
"content": final_response,
"request_id": request_id,
"timestamp": time.time(),
})
elif event_type == "agent_end":
# Safety net: if the agent finishes with an empty final_response,
# chat_channel skips _send_reply (because reply.content is empty),
@@ -448,7 +478,10 @@ class WebChannel(ChatChannel):
)
q.put({
"type": "done",
"content": "(模型未返回任何内容,请重试或换一种方式描述你的需求)",
"content": i18n.t(
"(模型未返回任何内容,请重试或换一种方式描述你的需求)",
"(The model returned no content. Please retry or rephrase your request.)",
),
"request_id": request_id,
"timestamp": time.time(),
})
@@ -748,6 +781,25 @@ class WebChannel(ChatChannel):
# desire_rtype concept used by other channels).
is_voice_input = bool(json_data.get('is_voice', False))
# Fast path for /cancel: bypass the session queue and SSE setup.
# Web frontend (stream=true) only listens to SSE, so we return an
# inline_reply payload to be rendered synchronously.
stripped_prompt = (prompt or "").strip().lower()
if stripped_prompt == "/cancel":
from agent.protocol import get_cancel_registry
cancelled = get_cancel_registry().cancel_session(session_id)
lang = (json_data.get('lang') or 'zh').lower()
msg_text = _cancel_reply_text(cancelled, lang)
logger.info(
f"[WebChannel] /cancel fast-path: session={session_id}, cancelled={cancelled}, lang={lang}"
)
return json.dumps({
"status": "success",
"request_id": "",
"stream": False,
"inline_reply": msg_text,
})
# Append file references to the prompt (same format as QQ channel)
if attachments:
file_refs = []
@@ -757,13 +809,13 @@ class WebChannel(ChatChannel):
if not fpath:
continue
if ftype == "image":
file_refs.append(f"[图片: {fpath}]")
file_refs.append(f"[{i18n.t('图片', 'Image')}: {fpath}]")
elif ftype == "video":
file_refs.append(f"[视频: {fpath}]")
file_refs.append(f"[{i18n.t('视频', 'Video')}: {fpath}]")
elif ftype == "directory":
file_refs.append(f"[目录: {fpath}]")
file_refs.append(f"[{i18n.t('目录', 'Directory')}: {fpath}]")
else:
file_refs.append(f"[文件: {fpath}]")
file_refs.append(f"[{i18n.t('文件', 'File')}: {fpath}]")
if file_refs:
prompt = prompt + "\n" + "\n".join(file_refs)
logger.info(f"[WebChannel] Attached {len(file_refs)} file(s) to message")
@@ -854,6 +906,11 @@ class WebChannel(ChatChannel):
if itype == "done":
post_done = True
post_deadline = time.time() + POST_DONE_TAIL_SECONDS
elif itype == "cancelled":
# Close SSE tail quickly after cancel; don't wait for the
# full TTS tail since the user already pressed Stop.
post_done = True
post_deadline = time.time() + 3
elif itype == "voice_attach":
# WSGI buffers the previous chunk until the next yield;
# shrink the tail so the generator wakes up quickly to
@@ -864,6 +921,59 @@ class WebChannel(ChatChannel):
finally:
self.sse_queues.pop(request_id, None)
def cancel_request(self):
"""
Cancel an in-flight agent run.
Body: {"request_id": "...", "session_id": "..."}
Either field is sufficient; request_id is preferred when known.
Always returns success even when nothing was running, so the
client's UX is idempotent.
"""
try:
from agent.protocol import get_cancel_registry
data = web.data()
try:
json_data = json.loads(data) if data else {}
except Exception:
json_data = {}
request_id = (json_data.get("request_id") or "").strip()
session_id = (json_data.get("session_id") or "").strip()
lang = (json_data.get("lang") or "zh").lower()
registry = get_cancel_registry()
cancelled = 0
if request_id:
if registry.cancel_request(request_id):
cancelled = 1
if cancelled == 0 and session_id:
cancelled = registry.cancel_session(session_id)
if request_id and request_id in self.sse_queues:
self.sse_queues[request_id].put({
"type": "cancelled",
"content": "🛑 Cancelled" if lang.startswith("en") else "🛑 已中止",
"request_id": request_id,
"timestamp": time.time(),
})
logger.info(
f"[WebChannel] cancel request: request_id={request_id!r}, "
f"session_id={session_id!r}, cancelled={cancelled}"
)
return json.dumps({
"status": "success",
"cancelled": cancelled,
})
except Exception as e:
logger.error(f"[WebChannel] cancel_request error: {e}")
return json.dumps({"status": "error", "message": str(e)})
def poll_response(self):
"""
Poll for responses using the session_id.
@@ -902,7 +1012,10 @@ class WebChannel(ChatChannel):
"""Serve the chat HTML page."""
file_path = os.path.join(os.path.dirname(__file__), 'chat.html') # 使用绝对路径
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
html = f.read()
# Inject the backend-resolved default language so the console can use
# it on first load (when the user has no saved cow_lang preference).
return html.replace("{{COW_DEFAULT_LANG}}", i18n.get_language())
def startup(self):
configured_host = conf().get("web_host", "")
@@ -912,26 +1025,30 @@ class WebChannel(ChatChannel):
self._cleanup_stale_voice_recordings()
# 打印可用渠道类型提示
# Print available channel types
logger.info(
"[WebChannel] 全部可用通道如下,可修改 config.json 配置文件中的 channel_type 字段进行切换,多个通道用逗号分隔:")
logger.info("[WebChannel] 1. weixin - 微信")
logger.info("[WebChannel] 2. web - 网页")
logger.info("[WebChannel] 3. terminal - 终端")
logger.info("[WebChannel] 4. feishu - 飞书")
logger.info("[WebChannel] 5. dingtalk - 钉钉")
logger.info("[WebChannel] 6. wecom_bot - 企微智能机器人")
logger.info("[WebChannel] 7. wechatcom_app - 企微自建应用")
logger.info("[WebChannel] 8. wechatmp - 个人公众号")
logger.info("[WebChannel] 9. wechatmp_service - 企业公众号")
logger.info("[WebChannel] ✅ Web控制台已运行")
logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}")
"[WebChannel] Available channels (edit `channel_type` in config.json to switch, separate multiple with commas):")
logger.info("[WebChannel] 1. web - Web")
logger.info("[WebChannel] 2. terminal - Terminal")
logger.info("[WebChannel] 3. weixin - WeChat")
logger.info("[WebChannel] 4. feishu - Feishu")
logger.info("[WebChannel] 5. dingtalk - DingTalk")
logger.info("[WebChannel] 6. wecom_bot - WeCom Bot")
logger.info("[WebChannel] 7. wechatcom_app - WeCom App")
logger.info("[WebChannel] 8. wechat_kf - WeChat Customer Service")
logger.info("[WebChannel] 9. wechatmp - WeChat Official Account")
logger.info("[WebChannel] 10. wechatmp_service - WeChat Official Account (Service)")
logger.info("[WebChannel] 11. telegram - Telegram")
logger.info("[WebChannel] 12. slack - Slack")
logger.info("[WebChannel] 13. discord - Discord")
logger.info("[WebChannel] ✅ Web console is running")
logger.info(f"[WebChannel] 🌐 Local access: http://localhost:{port}")
if is_public_bind:
logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (将YOUR_IP替换为服务器IP)")
logger.info(f"[WebChannel] 🌍 Server access: http://YOUR_IP:{port} (replace YOUR_IP with your server IP)")
if not _is_password_enabled():
logger.info("[WebChannel] ⚠️ 当前监听 0.0.0.0 且未设置 web_password,公网部署建议在 config.json 中配置访问密码")
logger.info("[WebChannel] ⚠️ Listening on 0.0.0.0 without web_password set; set an access password in config.json for public deployment")
else:
logger.info(f"[WebChannel] 🔒 当前仅监听 {host},仅本机可访问。如需公网访问,请将 web_host 改为 0.0.0.0 并配置 web_password 密码")
logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password")
try:
import webbrowser
@@ -959,6 +1076,7 @@ class WebChannel(ChatChannel):
'/api/voice/tts', 'VoiceTtsHandler',
'/poll', 'PollHandler',
'/stream', 'StreamHandler',
'/cancel', 'CancelHandler',
'/chat', 'ChatHandler',
'/config', 'ConfigHandler',
'/api/models', 'ModelsHandler',
@@ -1050,8 +1168,8 @@ class AuthLoginHandler:
data = json.loads(web.data())
except Exception:
return json.dumps({"status": "error", "message": "Invalid request"})
password = data.get("password", "")
expected = conf().get("web_password", "")
password = str(data.get("password", "") or "")
expected = _get_web_password()
if not hmac.compare_digest(password, expected):
logger.warning("[WebChannel] Invalid login attempt")
return json.dumps({"status": "error", "message": "Wrong password"})
@@ -1208,7 +1326,20 @@ class FileServeHandler:
file_path = params.path
if not file_path or not os.path.isabs(file_path):
raise web.notfound()
file_path = os.path.normpath(file_path)
# Resolve symlinks and confine access to the allowed root dirs,
# so this endpoint can't be abused to read arbitrary files (e.g. /etc/passwd, ~/.ssh).
# Defaults to the user home dir plus the agent workspace; set web_file_serve_root="/"
# to allow the whole filesystem.
file_path = os.path.realpath(file_path)
serve_root = conf().get("web_file_serve_root", "~") or "~"
allowed_roots = [
os.path.realpath(os.path.expanduser(serve_root)),
os.path.realpath(os.path.expanduser(conf().get("agent_workspace", "~/cow"))),
]
if os.sep not in allowed_roots and not any(
os.path.commonpath([file_path, root]) == root for root in allowed_roots
):
raise web.notfound()
if not os.path.isfile(file_path):
raise web.notfound()
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
@@ -1232,6 +1363,12 @@ class PollHandler:
return WebChannel().poll_response()
class CancelHandler:
def POST(self):
_require_auth()
return WebChannel().cancel_request()
class StreamHandler:
def GET(self):
_require_auth()
@@ -1258,6 +1395,8 @@ class ChatHandler:
cache_bust = str(int(time.time()))
html = html.replace('assets/js/console.js', f'assets/js/console.js?v={cache_bust}')
html = html.replace('assets/css/console.css', f'assets/css/console.css?v={cache_bust}')
# Inject the backend-resolved default language for first-load fallback.
html = html.replace("{{COW_DEFAULT_LANG}}", i18n.get_language())
return html
@@ -1266,7 +1405,7 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING,
const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
@@ -1274,6 +1413,7 @@ class ConfigHandler:
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
]
# Generic placeholder hints surfaced in the web console. We deliberately
@@ -1310,7 +1450,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_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET],
"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],
}),
("gemini", {
"label": "Gemini",
@@ -1329,7 +1469,7 @@ class ConfigHandler:
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
}),
("zhipu", {
"label": "智谱AI",
"label": {"zh": "智谱AI", "en": "GLM"},
"api_key_field": "zhipu_ai_api_key",
"api_base_key": "zhipu_ai_api_base",
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
@@ -1337,7 +1477,7 @@ class ConfigHandler:
"models": [const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
}),
("dashscope", {
"label": "通义千问",
"label": {"zh": "通义千问", "en": "Qwen"},
"api_key_field": "dashscope_api_key",
"api_base_key": None,
"api_base_default": None,
@@ -1345,7 +1485,7 @@ class ConfigHandler:
"models": [const.QWEN36_PLUS, const.QWEN37_MAX, const.QWEN35_PLUS, const.QWEN3_MAX],
}),
("doubao", {
"label": "豆包",
"label": {"zh": "豆包", "en": "Doubao"},
"api_key_field": "ark_api_key",
"api_base_key": "ark_base_url",
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
@@ -1361,13 +1501,21 @@ class ConfigHandler:
"models": [const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
}),
("qianfan", {
"label": "百度千帆",
"label": {"zh": "百度千帆", "en": "ERNIE"},
"api_key_field": "qianfan_api_key",
"api_base_key": "qianfan_api_base",
"api_base_default": "https://qianfan.baidubce.com/v2",
"api_base_placeholder": _PLACEHOLDER_QIANFAN,
"models": [const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K],
}),
("mimo", {
"label": {"zh": "小米 MiMo", "en": "MiMo"},
"api_key_field": "mimo_api_key",
"api_base_key": "mimo_api_base",
"api_base_default": "https://api.xiaomimimo.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.MIMO_V2_5_PRO, const.MIMO_V2_5],
}),
("linkai", {
"label": "LinkAI",
"api_key_field": "linkai_api_key",
@@ -1377,7 +1525,7 @@ class ConfigHandler:
"models": _RECOMMENDED_MODELS,
}),
("custom", {
"label": "自定义",
"label": {"zh": "自定义", "en": "Custom"},
"api_key_field": "custom_api_key",
"api_base_key": "custom_api_base",
"api_base_default": "",
@@ -1387,12 +1535,13 @@ class ConfigHandler:
])
EDITABLE_KEYS = {
"cow_lang",
"model", "bot_type", "use_linkai",
"open_ai_api_base", "deepseek_api_base", "qianfan_api_base", "claude_api_base", "gemini_api_base",
"zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", "custom_api_base",
"zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", "custom_api_base", "mimo_api_base",
"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",
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
"enable_thinking", "web_password",
}
@@ -1434,7 +1583,7 @@ class ConfigHandler:
"api_key_field": p.get("api_key_field"),
}
raw_pwd = local_config.get("web_password", "")
raw_pwd = str(local_config.get("web_password", "") or "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
return json.dumps({
@@ -1495,6 +1644,15 @@ class ConfigHandler:
logger.info(f"[WebChannel] Config updated: {list(applied.keys())}")
# Apply a language change immediately so backend logs, agent
# replies and CLI output switch without a restart.
if "cow_lang" in applied:
try:
i18n.resolve_language(applied["cow_lang"])
logger.info(f"[WebChannel] Language switched to: {i18n.get_language()}")
except Exception as lang_err:
logger.warning(f"[WebChannel] Failed to apply language: {lang_err}")
# Reset Bridge so that bot routing reflects the new config.
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
# even after the user switches bot_type / use_linkai / model in UI.
@@ -1533,7 +1691,7 @@ class ModelsHandler:
# Capability -> provider ids drawn from ConfigHandler.PROVIDER_MODELS.
_ASR_PROVIDERS = ["openai", "dashscope", "zhipu", "linkai"]
# Web-console white-list. Other vendors stay usable via direct config.
_TTS_PROVIDERS = ["openai", "minimax", "dashscope", "linkai"]
_TTS_PROVIDERS = ["openai", "minimax", "dashscope", "mimo", "linkai"]
# TTS engine catalog (speech models, not voice timbres). Entries are
# either a bare code or {value, hint?} when a friendly label helps.
@@ -1548,6 +1706,10 @@ class ModelsHandler:
"dashscope": [
{"value": "qwen3-tts-flash", "hint": "覆盖普通话、方言与主流外语"},
],
# 小米 MiMo TTS 系列,通过 chat completions 接口合成
"mimo": [
{"value": "mimo-v2.5-tts", "hint": "预置音色 · 支持唱歌模式"},
],
# Aggregating gateway: a single endpoint multiplexes several
# underlying TTS engines, selected via the `model` field.
# Each engine exposes its own voice catalog (see _TTS_PROVIDER_VOICES).
@@ -1667,6 +1829,18 @@ class ModelsHandler:
{"value": "Marcus", "hint": "陕西话 · 秦川"},
{"value": "Roy", "hint": "闽南语 · 阿杰"},
],
# 小米 MiMo 预置音色列表mimo-v2.5-tts文档
# https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/speech-synthesis-v2.5
"mimo": [
{"value": "冰糖", "hint": "中文 · 女声 · 冰糖"},
{"value": "茉莉", "hint": "中文 · 女声 · 茉莉"},
{"value": "苏打", "hint": "中文 · 男声 · 苏打"},
{"value": "白桦", "hint": "中文 · 男声 · 白桦"},
{"value": "Mia", "hint": "英文 · 女声 · Mia"},
{"value": "Chloe", "hint": "英文 · 女声 · Chloe"},
{"value": "Milo", "hint": "英文 · 男声 · Milo"},
{"value": "Dean", "hint": "英文 · 男声 · Dean"},
],
# Aggregating gateway: voices are scoped per engine model. The
# frontend picks the correct list based on the selected model so
# users don't see incompatible timbres for the active engine.
@@ -1791,7 +1965,7 @@ class ModelsHandler:
"doubao": [const.DOUBAO_SEED_2_PRO],
"moonshot": [const.KIMI_K2_6],
"dashscope": [const.QWEN36_PLUS, const.QWEN35_PLUS, const.QWEN3_MAX],
"claudeAPI": [const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS],
"claudeAPI": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"qianfan": [const.ERNIE_45_TURBO_VL],
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
@@ -1803,6 +1977,8 @@ class ModelsHandler:
# (see models/minimax/minimax_bot.py::call_vision); the M2.x chat
# family is text-only.
"minimax": [const.MINIMAX_TEXT_01],
# MiMo 原生全模态模型v2.5-pro / v2.5 支持图像/音频/视频输入
"mimo": [const.MIMO_V2_5_PRO, const.MIMO_V2_5],
# LinkAI proxies the underlying vendor; surface a curated set of
# multimodal models. Order: gpt-4.1-mini → gpt-5.4-mini as the
# cross-vendor baselines, then each vendor's recommended default.
@@ -1932,6 +2108,7 @@ class ModelsHandler:
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
("minimax", "minimax_api_key", const.MINIMAX_TEXT_01),
("mimo", "mimo_api_key", const.MIMO_V2_5_PRO),
]
@classmethod
@@ -2233,10 +2410,10 @@ class ModelsHandler:
_SEARCH_PROVIDERS = ("bocha", "qianfan", "zhipu", "linkai")
_SEARCH_PROVIDER_LABELS = {
"bocha": "博查",
"zhipu": "智谱",
"qianfan": "百度千帆",
"linkai": "LinkAI",
"bocha": {"zh": "博查", "en": "Bocha"},
"zhipu": {"zh": "智谱", "en": "GLM"},
"qianfan": {"zh": "百度千帆", "en": "ERNIE"},
"linkai": {"zh": "LinkAI", "en": "LinkAI"},
}
@classmethod
@@ -2756,6 +2933,18 @@ class ChannelsHandler:
{"key": "wechatcomapp_port", "label": "Port", "type": "number", "default": 9898},
],
}),
("wechat_kf", {
"label": {"zh": "微信客服", "en": "WeChat Customer Service"},
"icon": "fa-headset",
"color": "emerald",
"fields": [
{"key": "wechat_kf_corp_id", "label": "Corp ID", "type": "text"},
{"key": "wechat_kf_secret", "label": "Secret", "type": "secret"},
{"key": "wechat_kf_token", "label": "Token", "type": "secret"},
{"key": "wechat_kf_aes_key", "label": "AES Key", "type": "secret"},
{"key": "wechat_kf_port", "label": "Port", "type": "number", "default": 9888},
],
}),
("wechatmp", {
"label": {"zh": "公众号", "en": "WeChat MP"},
"icon": "fa-comment-dots",
@@ -2768,6 +2957,31 @@ class ChannelsHandler:
{"key": "wechatmp_port", "label": "Port", "type": "number", "default": 8080},
],
}),
("telegram", {
"label": {"zh": "Telegram", "en": "Telegram"},
"icon": "fa-paper-plane",
"color": "sky",
"fields": [
{"key": "telegram_token", "label": "Bot Token", "type": "secret"},
],
}),
("slack", {
"label": {"zh": "Slack", "en": "Slack"},
"icon": "fa-hashtag",
"color": "purple",
"fields": [
{"key": "slack_bot_token", "label": "Bot Token (xoxb-)", "type": "secret"},
{"key": "slack_app_token", "label": "App Token (xapp-)", "type": "secret"},
],
}),
("discord", {
"label": {"zh": "Discord", "en": "Discord"},
"icon": "fa-discord",
"color": "indigo",
"fields": [
{"key": "discord_token", "label": "Bot Token", "type": "secret"},
],
}),
])
@staticmethod

115
channel/wechat_kf/README.md Normal file
View File

@@ -0,0 +1,115 @@
# 微信客服WeChat Customer Service通道
> 与 `channel/wechatcom/`(企微自建应用)是两个**独立的 CoW 通道**
>
> - 自建应用:**面向企业内部成员**(员工通过企业微信 App 与机器人对话)。
> - 微信客服:**面向外部微信用户**(普通微信用户通过链接/二维码进入对话)。
>
> 但底层都基于"企微自建应用"——本通道是**通过把一个企微自建应用绑定到微信客服账号**来实现 AI 接管对外咨询,详见 [LinkAI 微信客服接入文档](https://docs.link-ai.tech/platform/link-app/wechat-customer-service)。
## 一、接入流程概览
```
┌─────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ 1. 企业微信后台 │ → │ 2. CoW 配置回调 │ → │ 3. 绑定微信客服 │
│ 创建一个自建应用 │ │ 端口 9888 │ │ 账号 │
└─────────────────────┘ └─────────────────────┘ └──────────────────┘
外部微信用户通过
链接/二维码 →
消息 → CoW Bot
```
> **重要**:建议**单独再创建一个企微自建应用**用于微信客服,**不要复用**已经接入员工内部使用的那个 `wechatcom_app` 应用,否则两个通道会争抢同一个回调地址。
## 二、企业微信后台配置
### 1. 创建企微自建应用
进入 企业微信管理后台 → **应用管理****创建应用**
### 2. 收集字段
| 字段 | 来源 | 对应 CoW 配置项 |
|---|---|---|
| 企业IDCorpId | 「我的企业」最下方 | `wechat_kf_corp_id` |
| Secret | 进入应用详情 → 点击「查看」(会推送到管理员手机端,在手机上查看) | `wechat_kf_secret` |
| Token | 应用「接收消息 → 设置API接收」 | `wechat_kf_token` |
| EncodingAESKey | 应用「接收消息 → 设置API接收」 | `wechat_kf_aes_key` |
> AgentId 在本通道**不需要**(消息发送走的是 `cgi-bin/kf/send_msg`,不依赖 agent_id
### 3. 配置回调地址 + 可信 IP
在应用「**接收消息 → 设置API接收**」里填:
- URL`http://<your-host>:9888/wxkf/`(公网必须可达)
- Token / EncodingAESKey与下方 `config.json` 一致
回到应用详情页,把服务器公网 IP 填入「**企业可信IP**」。
### 4. 绑定微信客服账号
进入 企业微信后台 → **微信客服** → 创建客服账号 → **将该账号绑定到上一步创建的企微自建应用**
绑定完成后,进入 **微信客服 → 微信客服账号详情** 页面,在「**接入链接**」一栏:
- 「复制链接」可拿到形如 `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be` 的访问链接
- 「生成二维码」可拿到对应二维码
把链接或二维码推给微信客户使用即可。
## 三、CoW 配置(`config.json`
```json
{
"channel_type": "wechat_kf",
"wechat_kf_corp_id": "ww1234567890abcdef",
"wechat_kf_secret": "<企微应用的 Secret>",
"wechat_kf_token": "<接收消息 Token>",
"wechat_kf_aes_key": "<EncodingAESKey>",
"wechat_kf_port": 9888
}
```
| 字段 | 说明 |
|---|---|
| `wechat_kf_corp_id` | 企业 ID |
| `wechat_kf_secret` | **绑定到微信客服**的那个企微自建应用的 Secret |
| `wechat_kf_token` | 该应用「接收消息」配置的 Token |
| `wechat_kf_aes_key` | 该应用「接收消息」配置的 EncodingAESKey |
| `wechat_kf_port` | 监听端口,默认 `9888` |
也支持环境变量:`WECHAT_KF_CORP_ID` / `WECHAT_KF_SECRET` / `WECHAT_KF_TOKEN` / `WECHAT_KF_AES_KEY`
## 四、运行
```bash
python app.py
```
启动后日志里会看到:
```
[wechat_kf] WeCom customer-service channel started
[wechat_kf] Listening on http://0.0.0.0:9888/wxkf/
```
回到企微后台「设置API接收」点击保存——会触发 `GET /wxkf/?...&echostr=...`CoW 通过 `crypto.check_signature` 校验后返回明文 `echostr`,验证成功。
## 五、支持的回复类型
| ReplyType | 是否支持 | 备注 |
|---|---|---|
| `TEXT` / `INFO` / `ERROR` | ✅ | 自动按 2048 字节切片分段发送 |
| `IMAGE`(本地) / `IMAGE_URL`(网络) | ✅ | 大图自动压缩到 10MB 以内 |
| `VOICE` | ✅ | 转 amr 后发送,>60s 自动切片 |
| `VIDEO_URL` | ✅ | 通过临时素材接口上传 |
| `FILE` | ✅ | |
## 六、参考文档
- [LinkAI 微信客服接入文档](https://docs.link-ai.tech/platform/link-app/wechat-customer-service)
- [企业微信开放接口 - 微信客服 - 接收消息](https://developer.work.weixin.qq.com/document/path/94670)
- [企业微信开放接口 - 微信客服 - 发送消息](https://developer.work.weixin.qq.com/document/path/95122)

View File

@@ -0,0 +1,603 @@
# -*- coding=utf-8 -*-
"""
WeChat Customer Service (微信客服) channel for CoW.
Differences from `channel/wechatcom/` (企微自建应用):
1. Audience: external WeChat users (not internal members).
2. Receiver fields: `external_userid` + `open_kfid` instead of a single
member `userid`.
3. Inbound flow: callback only delivers an event token, the actual
message bodies must be pulled via `cgi-bin/kf/sync_msg` with a
persistent cursor. See `wechat_kf_cursor_store.py`.
4. Outbound flow: messages are sent via `cgi-bin/kf/send_msg` (each
request must specify both `touser` and `open_kfid`); wechatpy has
no native helper, so we call the HTTP endpoint directly.
"""
import io
import json
import os
import threading
import time
import xml.etree.ElementTree as ET
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
import requests
import web
from wechatpy.enterprise import WeChatClient
from wechatpy.enterprise.crypto import WeChatCrypto
from wechatpy.enterprise.exceptions import InvalidCorpIdException
from wechatpy.exceptions import InvalidSignatureException, WeChatClientException
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel
from channel.file_cache import get_file_cache
from channel.wechat_kf.wechat_kf_cursor_store import CursorStore
from channel.wechat_kf.wechat_kf_message import WechatKfMessage
from common.log import logger
from common.singleton import singleton
from common.utils import (
compress_imgfile,
fsize,
remove_markdown_symbol,
split_string_by_utf8_length,
)
from config import conf
try:
from voice.audio_convert import any_to_amr, split_audio
except ImportError as e: # voice features optional
logger.debug(
"[wechat_kf] import voice.audio_convert failed, voice will be disabled: {}".format(e)
)
MAX_UTF8_LEN = 2048
KF_API_BASE = "https://qyapi.weixin.qq.com/cgi-bin/kf"
SYNC_MSG_LIMIT = 1000
@singleton
class WechatKfChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
self.corp_id = conf().get("wechat_kf_corp_id")
self.secret = conf().get("wechat_kf_secret")
self.token = conf().get("wechat_kf_token")
self.aes_key = conf().get("wechat_kf_aes_key")
self._http_server = None
logger.info(
"[wechat_kf] Initializing WeCom customer-service channel, corp_id: {}".format(
self.corp_id
)
)
self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id)
# Use the stock wechatpy WeChatClient so that the access_token is
# cached and only refreshed when actually expired (~2h). The local
# `WechatComAppClient` subclass has a broken background refresh
# loop that re-fetches every 60s and a `fetch_access_token()`
# override that may return a dict instead of a string, which
# corrupts URLs and triggers errcode 40014.
self.client = WeChatClient(self.corp_id, self.secret)
# Persist sync_msg cursor under the user's home dir by default,
# so it survives `tmp/` cleanups and cwd changes across restarts.
cursor_path = os.path.expanduser(
conf().get("wechat_kf_cursor_path") or "~/.wechat_kf_cursors.json"
)
self.cursor_store = CursorStore(cursor_path)
# WeCom requires the callback HTTP response to return within ~5s,
# otherwise it retries the same notification. sync_msg pulling
# can easily exceed that, so we dispatch it to a background pool
# and let `Query.POST` reply success immediately.
self._callback_executor = ThreadPoolExecutor(
max_workers=4, thread_name_prefix="wxkf-cb"
)
# Per-open_kfid lock: serialize sync_msg for the same kf account
# so that callback retries (or rapid-fire events) don't race on
# the same cursor and produce duplicate replies.
self._kf_locks: dict = defaultdict(threading.Lock)
self._kf_locks_guard = threading.Lock()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def startup(self):
urls = ("/wxkf/?", "channel.wechat_kf.wechat_kf_channel.Query")
app = web.application(urls, globals(), autoreload=False)
port = conf().get("wechat_kf_port", 9888)
logger.info("[wechat_kf] WeCom customer-service channel started")
logger.info("[wechat_kf] Listening on http://0.0.0.0:{}/wxkf/".format(port))
func = web.httpserver.StaticMiddleware(app.wsgifunc())
func = web.httpserver.LogMiddleware(func)
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
self._http_server = server
try:
server.start()
except (KeyboardInterrupt, SystemExit):
server.stop()
def stop(self):
if self._http_server:
try:
self._http_server.stop()
logger.info("[wechat_kf] HTTP server stopped")
except Exception as e:
logger.warning(f"[wechat_kf] Error stopping HTTP server: {e}")
self._http_server = None
try:
self._callback_executor.shutdown(wait=False)
except Exception as e:
logger.warning(f"[wechat_kf] Error shutting down callback executor: {e}")
# ------------------------------------------------------------------
# Outbound — implementing the abstract `send` contract
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
receiver = context["receiver"]
msg = context.kwargs.get("msg")
external_userid = context.get("external_userid") or (msg.external_userid if msg else None)
open_kfid = context.get("open_kfid") or (msg.open_kfid if msg else None)
if not external_userid or not open_kfid:
logger.error(
"[wechat_kf] missing external_userid or open_kfid, cannot send: "
f"external_userid={external_userid}, open_kfid={open_kfid}"
)
return
if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]:
reply_text = remove_markdown_symbol(reply.content)
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
if len(texts) > 1:
logger.info(
"[wechat_kf] text too long, split into {} parts".format(len(texts))
)
for i, text in enumerate(texts):
self._send_text(external_userid, open_kfid, text)
if i != len(texts) - 1:
time.sleep(0.5)
logger.info("[wechat_kf] Do send text to {}: {}".format(receiver, reply_text))
elif reply.type == ReplyType.VOICE:
file_path = reply.content
try:
amr_file = os.path.splitext(file_path)[0] + ".amr"
any_to_amr(file_path, amr_file)
duration, files = split_audio(amr_file, 60 * 1000)
if len(files) > 1:
logger.info(
"[wechat_kf] voice too long {}s > 60s, split into {} parts".format(
duration / 1000.0, len(files)
)
)
media_ids = []
for path in files:
with open(path, "rb") as f:
response = self.client.media.upload("voice", f)
logger.debug("[wechat_kf] upload voice response: {}".format(response))
media_ids.append(response["media_id"])
except ImportError as e:
logger.error("[wechat_kf] voice conversion failed: {}".format(e))
logger.error("[wechat_kf] please install pydub: pip install pydub")
return
except WeChatClientException as e:
logger.error("[wechat_kf] upload voice failed: {}".format(e))
return
try:
os.remove(file_path)
if amr_file != file_path:
os.remove(amr_file)
except Exception:
pass
for media_id in media_ids:
self._send_voice(external_userid, open_kfid, media_id)
time.sleep(1)
logger.info("[wechat_kf] sendVoice={}, receiver={}".format(reply.content, receiver))
elif reply.type == ReplyType.IMAGE_URL:
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
sz = fsize(image_storage)
if sz >= 10 * 1024 * 1024:
logger.info("[wechat_kf] image too large, compressing, sz={}".format(sz))
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
image_storage.seek(0)
try:
response = self.client.media.upload("image", image_storage)
except WeChatClientException as e:
logger.error("[wechat_kf] upload image failed: {}".format(e))
return
self._send_image(external_userid, open_kfid, response["media_id"])
logger.info("[wechat_kf] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.IMAGE:
image_storage = reply.content
sz = fsize(image_storage)
if sz >= 10 * 1024 * 1024:
logger.info("[wechat_kf] image too large, compressing, sz={}".format(sz))
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
image_storage.seek(0)
try:
response = self.client.media.upload("image", image_storage)
except WeChatClientException as e:
logger.error("[wechat_kf] upload image failed: {}".format(e))
return
self._send_image(external_userid, open_kfid, response["media_id"])
logger.info("[wechat_kf] sendImage, receiver={}".format(receiver))
elif reply.type == ReplyType.VIDEO_URL:
video_url = reply.content
try:
response = self.client.media.upload(
"video", requests.get(video_url, stream=True).content
)
except WeChatClientException as e:
logger.error("[wechat_kf] upload video failed: {}".format(e))
return
self._send_video(external_userid, open_kfid, response["media_id"])
logger.info("[wechat_kf] sendVideo url={}, receiver={}".format(video_url, receiver))
elif reply.type == ReplyType.FILE:
file_path = reply.content
try:
with open(file_path, "rb") as f:
response = self.client.media.upload(
"file", (os.path.basename(file_path), f.read())
)
except WeChatClientException as e:
logger.error("[wechat_kf] upload file failed: {}".format(e))
return
self._send_file(external_userid, open_kfid, response["media_id"])
logger.info("[wechat_kf] sendFile={}, receiver={}".format(file_path, receiver))
else:
logger.warning("[wechat_kf] unsupported reply type: {}".format(reply.type))
# ------------------------------------------------------------------
# Inbound — pull messages by cursor
# ------------------------------------------------------------------
def _get_kf_lock(self, open_kfid: str) -> threading.Lock:
with self._kf_locks_guard:
return self._kf_locks[open_kfid]
def submit_callback(self, token: str, open_kfid: str):
"""
Async entry point used by the HTTP handler. Submits the actual
sync_msg pulling to a background thread so the callback response
can return within WeCom's 5s deadline.
"""
try:
self._callback_executor.submit(self._run_callback, token, open_kfid)
except RuntimeError as e:
# Executor may be shut down during process exit; fall back
# to inline execution so we don't silently drop the event.
logger.warning(f"[wechat_kf] executor unavailable, run inline: {e}")
self._run_callback(token, open_kfid)
def _run_callback(self, token: str, open_kfid: str):
# Block on the per-kfid lock so retried callbacks queue up
# behind the in-flight one. The queued worker will then call
# sync_msg with the (already advanced) cursor, which is cheap
# when there is nothing new and still picks up any messages
# that arrived after the previous worker's last pull.
lock = self._get_kf_lock(open_kfid)
with lock:
try:
self.consume_callback(token, open_kfid)
except Exception as e:
logger.exception(f"[wechat_kf] consume_callback error: {e}")
def consume_callback(self, token: str, open_kfid: str):
"""
Called from the HTTP `Query.POST` handler whenever WeCom notifies
us that there are new messages for `open_kfid`. Pulls all new
messages via sync_msg and feeds them into `produce()`.
"""
existing_cursor = self.cursor_store.get(open_kfid)
# First-time bootstrap: always skip history, otherwise WeCom would
# replay up to 14 days of messages on the very first callback and
# flood every user with auto-replies.
if not existing_cursor:
self._initialize_cursor(token, open_kfid)
return
msgs = self._pull_messages(token, open_kfid, existing_cursor)
if not msgs:
return
file_cache = get_file_cache()
for raw in msgs:
try:
kf_msg = WechatKfMessage(msg=raw, client=self.client)
except NotImplementedError as e:
logger.debug("[wechat_kf] {}".format(e))
continue
session_id = kf_msg.from_user_id
# Cache lone images/files and wait for the user's follow-up
# text. Agent mode never reads memory.USER_IMAGE_CACHE, so
# without this the attachment is effectively lost.
if kf_msg.ctype in (ContextType.IMAGE, ContextType.FILE):
ftype = "image" if kf_msg.ctype == ContextType.IMAGE else "file"
try:
kf_msg.prepare() # download to local tmp path
file_cache.add(session_id, kf_msg.content, file_type=ftype)
logger.info(
"[wechat_kf] {} cached for session {}: {}".format(
ftype, session_id, kf_msg.content
)
)
except Exception as e:
logger.warning(f"[wechat_kf] cache {ftype} failed: {e}")
continue
# On a text turn, attach any pending images/files as references
# so the downstream agent can pick them up via the text content.
# Paths are already under agent_workspace/tmp (see
# WechatKfMessage._get_tmp_dir), so a relative ref also works.
if kf_msg.ctype == ContextType.TEXT:
cached_files = file_cache.get(session_id)
if cached_files:
refs = []
for fi in cached_files:
ftype, fpath = fi["type"], fi["path"]
if ftype == "image":
refs.append(f"[图片: {fpath}]")
else:
refs.append(f"[文件: {fpath}]")
kf_msg.content = kf_msg.content + "\n" + "\n".join(refs)
file_cache.clear(session_id)
context = self._compose_context(
kf_msg.ctype,
kf_msg.content,
isgroup=False,
msg=kf_msg,
)
if context:
self.produce(context)
time.sleep(0.05) # tiny gap between messages of the same batch
def _initialize_cursor(self, token: str, open_kfid: str):
"""
Drain all current messages for this `open_kfid` without producing
any context, just to advance the cursor to "now". This prevents
a fresh deployment from replying to up to ~14 days of history.
"""
next_cursor = ""
total_skipped = 0
while True:
data = self._call_sync_msg(token, open_kfid, next_cursor)
if data is None:
break
msg_list = data.get("msg_list") or []
total_skipped += len(msg_list)
cursor_after = data.get("next_cursor") or ""
if cursor_after:
self.cursor_store.set(open_kfid, cursor_after)
if not data.get("has_more"):
break
if not cursor_after or cursor_after == next_cursor:
break
next_cursor = cursor_after
logger.info(
"[wechat_kf] first-start bootstrap finished for open_kfid={}, "
"skipped {} historical messages".format(open_kfid, total_skipped)
)
def _pull_messages(self, token: str, open_kfid: str, next_cursor: Optional[str]) -> list:
"""Loop sync_msg until `has_more` is false. Returns raw msg dicts."""
collected = []
cursor = next_cursor or ""
while True:
data = self._call_sync_msg(token, open_kfid, cursor)
if data is None:
break
for item in data.get("msg_list") or []:
# Only consume messages from external users; ignore replies
# generated by our own kf account, otherwise we would loop
# back into ourselves.
if not item.get("external_userid"):
continue
if item.get("msgtype") in ("text", "image", "voice", "file"):
collected.append(item)
cursor_after = data.get("next_cursor") or ""
if cursor_after:
self.cursor_store.set(open_kfid, cursor_after)
if not data.get("has_more"):
break
if not cursor_after or cursor_after == cursor:
break
cursor = cursor_after
if collected:
collected = _dedup_image_text_pair(collected)
logger.info(
"[wechat_kf] pulled {} messages for open_kfid={}".format(len(collected), open_kfid)
)
return collected
def _call_sync_msg(self, token: str, open_kfid: str, cursor: str) -> Optional[dict]:
# `client.access_token` is the cached string property; do not use
# `fetch_access_token()` here — wechatpy returns the raw response
# dict from that call, which corrupts the query string.
url = f"{KF_API_BASE}/sync_msg?access_token={self.client.access_token}"
payload = {
"token": token,
"open_kfid": open_kfid,
"limit": SYNC_MSG_LIMIT,
}
if cursor:
payload["cursor"] = cursor
try:
resp = requests.post(url, json=payload, timeout=10).json()
except Exception as e:
logger.error(f"[wechat_kf] sync_msg request failed: {e}")
return None
if resp.get("errcode") != 0:
logger.error(
f"[wechat_kf] sync_msg errcode={resp.get('errcode')}, "
f"errmsg={resp.get('errmsg')}, open_kfid={open_kfid}"
)
return None
return resp
# ------------------------------------------------------------------
# Outbound HTTP wrappers (kf/send_msg)
# ------------------------------------------------------------------
def _post_send_msg(self, payload: dict) -> dict:
url = f"{KF_API_BASE}/send_msg?access_token={self.client.access_token}"
try:
resp = requests.post(url, json=payload, timeout=10).json()
except Exception as e:
logger.error(f"[wechat_kf] send_msg request failed: {e}")
return {"errcode": -1, "errmsg": str(e)}
if resp.get("errcode") != 0:
logger.error(f"[wechat_kf] send_msg failed, payload={payload}, resp={resp}")
return resp
def _send_text(self, external_userid: str, open_kfid: str, content: str) -> dict:
return self._post_send_msg({
"touser": external_userid,
"open_kfid": open_kfid,
"msgtype": "text",
"text": {"content": content},
})
def _send_image(self, external_userid: str, open_kfid: str, media_id: str) -> dict:
return self._post_send_msg({
"touser": external_userid,
"open_kfid": open_kfid,
"msgtype": "image",
"image": {"media_id": media_id},
})
def _send_voice(self, external_userid: str, open_kfid: str, media_id: str) -> dict:
return self._post_send_msg({
"touser": external_userid,
"open_kfid": open_kfid,
"msgtype": "voice",
"voice": {"media_id": media_id},
})
def _send_video(self, external_userid: str, open_kfid: str, media_id: str) -> dict:
return self._post_send_msg({
"touser": external_userid,
"open_kfid": open_kfid,
"msgtype": "video",
"video": {"media_id": media_id},
})
def _send_file(self, external_userid: str, open_kfid: str, media_id: str) -> dict:
return self._post_send_msg({
"touser": external_userid,
"open_kfid": open_kfid,
"msgtype": "file",
"file": {"media_id": media_id},
})
def _send_link(self, external_userid: str, open_kfid: str, link_data: dict) -> dict:
return self._post_send_msg({
"touser": external_userid,
"open_kfid": open_kfid,
"msgtype": "link",
"link": link_data,
})
def _dedup_image_text_pair(messages: list) -> list:
"""
A WeChat user often sends an image immediately followed by a text
question (e.g. "what's in this picture?"). Only when the batch is
exactly that 2-message image+text pair within a 5s window do we
collapse it into a single [image, text] turn. Otherwise return
every message so rapid-fire texts/images are all processed —
cursor freshness is already guaranteed by sync_msg.
"""
if not messages:
return []
if len(messages) == 2:
a, b = messages
types = {a["msgtype"], b["msgtype"]}
if types == {"image", "text"} and abs(a["send_time"] - b["send_time"]) <= 5:
img = a if a["msgtype"] == "image" else b
txt = b if a["msgtype"] == "image" else a
return [img, txt]
return messages
# ----------------------------------------------------------------------
# HTTP handlers (web.py)
# ----------------------------------------------------------------------
class Query:
def GET(self):
channel = WechatKfChannel()
params = web.input()
logger.info("[wechat_kf] verify params: {}".format(params))
try:
signature = params.msg_signature
timestamp = params.timestamp
nonce = params.nonce
echostr = params.echostr
echostr = channel.crypto.check_signature(signature, timestamp, nonce, echostr)
except (InvalidSignatureException, InvalidCorpIdException):
raise web.Forbidden()
return echostr
def POST(self):
channel = WechatKfChannel()
params = web.input()
try:
signature = params.msg_signature
timestamp = params.timestamp
nonce = params.nonce
raw_body = web.data()
decrypted = channel.crypto.decrypt_message(raw_body, signature, timestamp, nonce)
except (InvalidSignatureException, InvalidCorpIdException) as e:
logger.warning(f"[wechat_kf] invalid signature: {e}")
raise web.Forbidden()
# We need the Token + OpenKfId fields from the inner XML to call
# sync_msg. wechatpy's parsed object exposes neither, so we parse
# the raw XML directly.
try:
root = ET.fromstring(decrypted)
except ET.ParseError as e:
logger.error(f"[wechat_kf] xml parse error: {e}")
return "success"
msg_type = (root.findtext("MsgType") or "").strip()
event = (root.findtext("Event") or "").strip()
if msg_type != "event" or event != "kf_msg_or_event":
logger.debug(
f"[wechat_kf] ignored callback msg_type={msg_type}, event={event}"
)
return "success"
token = root.findtext("Token") or ""
open_kfid = root.findtext("OpenKfId") or ""
if not token or not open_kfid:
logger.warning(
f"[wechat_kf] callback missing token or open_kfid: {decrypted}"
)
return "success"
# Hand off to a background worker — WeCom requires the callback
# to return success within ~5 seconds, otherwise it will retry
# and we may race the same cursor window into duplicate replies.
channel.submit_callback(token, open_kfid)
return "success"

View File

@@ -0,0 +1,80 @@
# -*- coding=utf-8 -*-
"""
Local-file based persistence for WeCom customer-service `next_cursor`.
Why we need this:
The WeCom customer-service (微信客服) callback only notifies us that
"new messages exist". To actually fetch them we must call the
`cgi-bin/kf/sync_msg` endpoint with a `cursor` so that we only get
messages newer than the previously processed one. If we lose this
cursor (e.g. on process restart) WeCom will replay up to ~14 days of
history, which would cause the bot to flood users with duplicate
replies.
This implementation deliberately avoids any external dependency
(no Redis / no DB) — a single JSON file under the project's tmp dir is
enough for a CoW-style single-process deployment.
"""
import json
import os
import threading
from typing import Optional
from common.log import logger
class CursorStore:
"""Thread-safe per-`open_kfid` cursor store backed by a JSON file."""
def __init__(self, file_path: str):
self._file_path = file_path
self._lock = threading.Lock()
self._data = self._load()
def _load(self) -> dict:
try:
if os.path.exists(self._file_path):
with open(self._file_path, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception as e:
logger.warning(f"[wechat_kf] failed to load cursor file {self._file_path}: {e}")
return {}
def _flush_locked(self):
# Atomic write: write to *.tmp first then rename, avoid corruption on crash.
tmp_path = self._file_path + ".tmp"
try:
os.makedirs(os.path.dirname(self._file_path) or ".", exist_ok=True)
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False)
os.replace(tmp_path, self._file_path)
# Tighten permissions: cursor file lives in $HOME, restrict to owner.
# No-op on Windows.
try:
os.chmod(self._file_path, 0o600)
except Exception:
pass
except Exception as e:
logger.warning(f"[wechat_kf] failed to flush cursor file {self._file_path}: {e}")
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def get(self, open_kfid: str) -> Optional[str]:
with self._lock:
return self._data.get(open_kfid)
def set(self, open_kfid: str, cursor: str):
if not cursor:
return
with self._lock:
if self._data.get(open_kfid) == cursor:
return
self._data[open_kfid] = cursor
self._flush_locked()
def has(self, open_kfid: str) -> bool:
with self._lock:
return open_kfid in self._data

View File

@@ -0,0 +1,134 @@
# -*- coding=utf-8 -*-
"""
Adapter that turns a single `sync_msg` item from WeCom customer-service
into a CoW `ChatMessage` object.
"""
import os
import re
from wechatpy.enterprise import WeChatClient
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from common.utils import expand_path
from config import conf
def _get_tmp_dir() -> str:
"""Save under agent_workspace/tmp/ so agent tools (e.g. `read`) can
resolve a relative path like `tmp/xxx.pdf` against their own
workspace root. Mirrors the convention used by weixin / wecom_bot.
"""
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
tmp_dir = os.path.join(ws_root, "tmp")
os.makedirs(tmp_dir, exist_ok=True)
return tmp_dir
def _extract_filename(content_disposition: str) -> str:
"""Best-effort parse of `filename` / `filename*` from a Content-Disposition
header. Returns '' when nothing usable is found."""
if not content_disposition:
return ""
# RFC 5987 form: filename*=UTF-8''xxx
m = re.search(r"filename\*=(?:[^'\"]*'[^']*'\s*)?([^;]+)", content_disposition)
if m:
try:
from urllib.parse import unquote
return unquote(m.group(1).strip().strip('"'))
except Exception:
return m.group(1).strip().strip('"')
m = re.search(r'filename\s*=\s*"?([^";]+)"?', content_disposition)
return m.group(1).strip() if m else ""
class WechatKfMessage(ChatMessage):
"""
msg structure (from cgi-bin/kf/sync_msg):
{
"msgid": "...",
"send_time": 1700000000,
"origin": 3,
"msgtype": "text" | "image" | "voice" | ...,
"open_kfid": "wkxxxx",
"external_userid": "wmxxxx",
"text": {"content": "..."},
"image": {"media_id": "..."},
"voice": {"media_id": "..."},
...
}
"""
def __init__(self, msg: dict, client: WeChatClient = None, is_group: bool = False):
# NOTE: skip parent constructor because it expects a wechatpy parsed
# message object, while here we receive a raw dict from sync_msg.
super().__init__(msg)
self.is_group = is_group
self.msg_id = msg.get("msgid")
self.create_time = msg.get("send_time")
self.origin = msg.get("origin")
self.msgtype = msg.get("msgtype")
self.open_kfid = msg.get("open_kfid")
self.external_userid = msg.get("external_userid")
if self.msgtype == "text":
self.ctype = ContextType.TEXT
self.content = msg.get("text", {}).get("content", "")
elif self.msgtype == "image":
self.ctype = ContextType.IMAGE
media_id = msg.get("image", {}).get("media_id", "")
self.content = os.path.join(_get_tmp_dir(), media_id + ".jpg")
def download_image():
response = client.media.download(media_id)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechat_kf] Failed to download image, {response.content}")
self._prepare_fn = download_image
elif self.msgtype == "voice":
self.ctype = ContextType.VOICE
media_id = msg.get("voice", {}).get("media_id", "")
# WeCom returns amr by default; downstream voice pipeline will convert.
self.content = os.path.join(_get_tmp_dir(), media_id + ".amr")
def download_voice():
response = client.media.download(media_id)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechat_kf] Failed to download voice, {response.content}")
self._prepare_fn = download_voice
elif self.msgtype == "file":
self.ctype = ContextType.FILE
media_id = msg.get("file", {}).get("media_id", "")
# Provisional path; rewritten in download_file() once we have
# the original filename from Content-Disposition.
self.content = os.path.join(_get_tmp_dir(), media_id)
def download_file():
response = client.media.download(media_id)
if response.status_code == 200:
filename = _extract_filename(
response.headers.get("Content-Disposition", "")
) or media_id
self.content = os.path.join(_get_tmp_dir(), filename)
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechat_kf] Failed to download file, {response.content}")
self._prepare_fn = download_file
else:
raise NotImplementedError(
f"[wechat_kf] Unsupported message type: {self.msgtype}"
)
self.from_user_id = self.external_userid
self.to_user_id = self.open_kfid
self.other_user_id = self.external_userid

View File

@@ -103,14 +103,21 @@ class Query:
task_running = True
waiting_until = request_time + 4
while time.time() < waiting_until:
if from_user in channel.running:
time.sleep(0.1)
else:
if from_user not in channel.running:
task_running = False
break
# Task still running, but if it has already produced cached
# segments (e.g. multi-turn thinking output), return them now
# instead of forcing the user to wait for the whole task. The
# remaining segments are fetched by the user's next message.
if channel.cache_dict.get(from_user):
break
time.sleep(0.1)
reply_text = ""
if task_running:
# Only fall back to retry / "thinking" hint when the task is still
# running AND there is nothing cached to send yet.
if task_running and not channel.cache_dict.get(from_user):
if request_cnt < 3:
# waiting for timeout (the POST request will be closed by Wechat official server)
time.sleep(2)
@@ -131,8 +138,22 @@ class Query:
# Only one request can access to the cached data
try:
(reply_type, reply_content) = channel.cache_dict[from_user].pop(0)
if not channel.cache_dict[from_user]: # If popping the message makes the list empty, delete the user entry from cache
# WeChat passive reply allows only a single reply per request.
# To avoid forcing the user to send an extra message for every
# segment of multi-turn agent output, drain all consecutive
# cached text segments at once and merge them into one reply.
# Media (voice/image) can only be returned one at a time, so it
# stops the merge and is returned on its own.
cached = channel.cache_dict[from_user]
if cached[0][0] == "text":
reply_type = "text"
merged_parts = []
while cached and cached[0][0] == "text":
merged_parts.append(cached.pop(0)[1])
reply_content = "\n\n".join(merged_parts)
else:
(reply_type, reply_content) = cached.pop(0)
if not channel.cache_dict[from_user]: # If draining empties the list, delete the user entry from cache
del channel.cache_dict[from_user]
except IndexError:
return "success"

View File

@@ -134,10 +134,16 @@ class WechatMPChannel(ChatChannel):
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
if img_url.startswith("file://") or os.path.isfile(img_url):
# Local file produced by the agent (e.g. a generated image)
local_path = img_url[len("file://"):] if img_url.startswith("file://") else img_url
with open(local_path, "rb") as f:
image_storage.write(f.read())
else:
pic_res = requests.get(img_url, stream=True)
for block in pic_res.iter_content(1024):
image_storage.write(block)
image_storage.seek(0)
image_type = imghdr.what(image_storage)
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
@@ -258,10 +264,16 @@ class WechatMPChannel(ChatChannel):
logger.info("[wechatmp] Do send voice to {}".format(receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
if img_url.startswith("file://") or os.path.isfile(img_url):
# Local file produced by the agent (e.g. a generated image)
local_path = img_url[len("file://"):] if img_url.startswith("file://") else img_url
with open(local_path, "rb") as f:
image_storage.write(f.read())
else:
pic_res = requests.get(img_url, stream=True)
for block in pic_res.iter_content(1024):
image_storage.write(block)
image_storage.seek(0)
image_type = imghdr.what(image_storage)
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type

View File

@@ -440,6 +440,17 @@ class WecomBotChannel(ChatChannel):
state["current"] = ""
_push_stream(state, force=True)
elif event_type == "agent_cancelled":
# Flush partial output and strip trailing "---" separator
# left over from previous turn, to avoid a dangling divider.
if state["current"]:
state["committed"] += state["current"]
state["current"] = ""
state["committed"] = state["committed"].rstrip()
if state["committed"].endswith("---"):
state["committed"] = state["committed"][:-3].rstrip()
_push_stream(state, force=True)
return on_event
# ------------------------------------------------------------------

View File

@@ -47,14 +47,16 @@ def _load_credentials(cred_path: str) -> dict:
def _save_credentials(cred_path: str, data: dict):
"""Save credentials to JSON file."""
"""Atomically save credentials to JSON file (tmp + rename)."""
os.makedirs(os.path.dirname(cred_path), exist_ok=True)
with open(cred_path, "w") as f:
tmp_path = f"{cred_path}.tmp"
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2)
try:
os.chmod(cred_path, 0o600)
os.chmod(tmp_path, 0o600)
except Exception:
pass
os.replace(tmp_path, cred_path)
@singleton
@@ -73,7 +75,10 @@ class WeixinChannel(ChatChannel):
self.api = None
self._stop_event = threading.Event()
self._poll_thread = None
self._context_tokens = {} # user_id -> context_token
# user_id -> context_token. Guarded by _context_tokens_lock for any
# mutation that races with disk persistence.
self._context_tokens = {}
self._context_tokens_lock = threading.Lock()
self._received_msgs = ExpiredDict(60 * 60 * 7.1)
self._get_updates_buf = ""
self._credentials_path = ""
@@ -95,12 +100,19 @@ class WeixinChannel(ChatChannel):
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
# Always load credentials so we can restore context_tokens even when
# the bot token itself comes from config.
creds = _load_credentials(self._credentials_path)
if not token:
creds = _load_credentials(self._credentials_path)
token = creds.get("token", "")
if creds.get("base_url"):
base_url = creds["base_url"]
# Restore persisted context_tokens so scheduler can deliver pushes
# immediately after restart, without waiting for the user to ping
# the bot first.
self._restore_context_tokens_from_creds(creds)
if not token:
token, base_url = self._login_with_retry(base_url)
if not token:
@@ -140,11 +152,16 @@ class WeixinChannel(ChatChannel):
def _relogin(self) -> bool:
"""Re-login after session expiry. Returns True on success."""
base_url = self.api.base_url if self.api else DEFAULT_BASE_URL
if os.path.exists(self._credentials_path):
try:
os.remove(self._credentials_path)
except Exception:
pass
# Clearing the whole credentials file is intentional: the new login
# will issue a fresh `token` and persisted context_tokens belong to
# the previous bot identity, so they must not survive.
with self._context_tokens_lock:
self._context_tokens.clear()
if os.path.exists(self._credentials_path):
try:
os.remove(self._credentials_path)
except Exception:
pass
self.login_status = self.LOGIN_STATUS_WAITING
result = self._qr_login(base_url)
if not result:
@@ -156,9 +173,62 @@ class WeixinChannel(ChatChannel):
cdn_base_url=self.api.cdn_base_url if self.api else CDN_BASE_URL,
)
self.login_status = self.LOGIN_STATUS_OK
self._context_tokens.clear()
return True
# ── Context token persistence ──────────────────────────────────────
# ilink requires every outbound send to echo the context_token from the
# user's latest inbound message. We mirror the in-memory map into the
# credentials JSON so scheduled pushes survive process restarts.
# All mutation + disk IO is serialized via _context_tokens_lock so that
# concurrent updates can never lose each other's writes.
def _restore_context_tokens_from_creds(self, creds: dict) -> None:
if not isinstance(creds, dict):
return
tokens = creds.get("context_tokens")
if not isinstance(tokens, dict):
return
restored = 0
with self._context_tokens_lock:
for user_id, token in tokens.items():
if isinstance(user_id, str) and isinstance(token, str) and token:
self._context_tokens[user_id] = token
restored += 1
if restored:
logger.info(f"[Weixin] Restored {restored} context_tokens from credentials")
def _persist_context_tokens_locked(self) -> None:
"""Flush the token map to disk. Caller must hold _context_tokens_lock."""
if not self._credentials_path:
return
try:
creds = _load_credentials(self._credentials_path) or {}
creds["context_tokens"] = dict(self._context_tokens)
_save_credentials(self._credentials_path, creds)
except Exception as e:
logger.warning(f"[Weixin] Failed to persist context_tokens: {e}")
def _update_context_token(self, user_id: str, token: str) -> None:
"""Update the in-memory token for a user; flush to disk only on change."""
if not user_id or not token:
return
with self._context_tokens_lock:
if self._context_tokens.get(user_id) == token:
return
self._context_tokens[user_id] = token
self._persist_context_tokens_locked()
def _invalidate_context_token(self, user_id: str) -> None:
"""Drop the cached token for a user (used after -14 / send rejection)."""
if not user_id:
return
with self._context_tokens_lock:
if user_id not in self._context_tokens:
return
del self._context_tokens[user_id]
logger.info(f"[Weixin] Invalidated stale context_token for {user_id}")
self._persist_context_tokens_locked()
# ── QR Login ───────────────────────────────────────────────────────
@staticmethod
@@ -391,7 +461,7 @@ class WeixinChannel(ChatChannel):
context_token = raw_msg.get("context_token", "")
if context_token and from_user:
self._context_tokens[from_user] = context_token
self._update_context_token(from_user, context_token)
cdn_base_url = self.api.cdn_base_url if self.api else CDN_BASE_URL
try:
@@ -510,10 +580,30 @@ class WeixinChannel(ChatChannel):
return msg.context_token
return self._context_tokens.get(receiver, "")
def _check_send_response(self, resp, receiver: str) -> None:
"""Inspect a send-API response; drop stale context_token on -14.
ilink uses ret/errcode = -14 to signal that the session (and any
cached context_token) is no longer valid. The plugin keeps running
because the bot itself can re-login; we just need to forget the
per-user token so the next push won't retry forever.
"""
if not isinstance(resp, dict):
return
ret = resp.get("ret")
errcode = resp.get("errcode")
if ret == -14 or errcode == -14:
logger.warning(
f"[Weixin] Send returned -14 (session expired) for "
f"receiver={receiver}; dropping cached context_token"
)
self._invalidate_context_token(receiver)
def _send_text(self, text: str, receiver: str, context_token: str):
if len(text) <= TEXT_CHUNK_LIMIT:
try:
self.api.send_text(receiver, text, context_token)
resp = self.api.send_text(receiver, text, context_token)
self._check_send_response(resp, receiver)
logger.debug(f"[Weixin] Text sent to {receiver}, len={len(text)}")
except Exception as e:
logger.error(f"[Weixin] Failed to send text: {e}")
@@ -522,7 +612,8 @@ class WeixinChannel(ChatChannel):
chunks = self._split_text(text, TEXT_CHUNK_LIMIT)
for i, chunk in enumerate(chunks):
try:
self.api.send_text(receiver, chunk, context_token)
resp = self.api.send_text(receiver, chunk, context_token)
self._check_send_response(resp, receiver)
logger.debug(f"[Weixin] Text chunk {i+1}/{len(chunks)} sent to {receiver}, len={len(chunk)}")
except Exception as e:
logger.error(f"[Weixin] Failed to send text chunk {i+1}/{len(chunks)}: {e}")
@@ -556,13 +647,14 @@ class WeixinChannel(ChatChannel):
return
try:
result = upload_media_to_cdn(self.api, local_path, receiver, media_type=1)
self.api.send_image_item(
resp = self.api.send_image_item(
to=receiver,
context_token=context_token,
encrypt_query_param=result["encrypt_query_param"],
aes_key_b64=result["aes_key_b64"],
ciphertext_size=result["ciphertext_size"],
)
self._check_send_response(resp, receiver)
logger.info(f"[Weixin] Image sent to {receiver}")
except Exception as e:
logger.error(f"[Weixin] Image send failed: {e}")
@@ -575,7 +667,7 @@ class WeixinChannel(ChatChannel):
return
try:
result = upload_media_to_cdn(self.api, local_path, receiver, media_type=3)
self.api.send_file_item(
resp = self.api.send_file_item(
to=receiver,
context_token=context_token,
encrypt_query_param=result["encrypt_query_param"],
@@ -583,6 +675,7 @@ class WeixinChannel(ChatChannel):
file_name=os.path.basename(local_path),
file_size=result["raw_size"],
)
self._check_send_response(resp, receiver)
logger.info(f"[Weixin] File sent to {receiver}")
except Exception as e:
logger.error(f"[Weixin] File send failed: {e}")
@@ -595,13 +688,14 @@ class WeixinChannel(ChatChannel):
return
try:
result = upload_media_to_cdn(self.api, local_path, receiver, media_type=2)
self.api.send_video_item(
resp = self.api.send_video_item(
to=receiver,
context_token=context_token,
encrypt_query_param=result["encrypt_query_param"],
aes_key_b64=result["aes_key_b64"],
ciphertext_size=result["ciphertext_size"],
)
self._check_send_response(resp, receiver)
logger.info(f"[Weixin] Video sent to {receiver}")
except Exception as e:
logger.error(f"[Weixin] Video send failed: {e}")

View File

@@ -14,7 +14,7 @@ CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
# stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None
StreamFn = Callable[[str, Optional[str]], None]
# on_phase(msg) — coarse-grained progress for chat channels (Chinese)
# on_phase(msg) — coarse-grained progress for chat channels (localized via i18n)
PhaseFn = Callable[[str], None]
@@ -112,16 +112,27 @@ def run_install_browser(
stream: Optional callback ``(message, fg)`` for each line. ``fg`` is
``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output.
on_phase: Optional callback for coarse progress (e.g. push to chat);
messages are short Chinese status lines.
messages are short status lines localized via i18n.
Returns:
0 on success, 1 on fatal failure (pip or chromium install failed).
"""
from cli.utils import get_cli_language
# Import `common` only after get_cli_language() runs ensure_sys_path(),
# so it works when `cow` is invoked from outside the project directory.
get_cli_language() # resolve cow_lang so i18n.t reflects config
from common import i18n
_t = i18n.t
stream = stream or _default_stream
python = sys.executable
legacy_mode = False
_phase(on_phase, "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…")
_phase(on_phase, _t(
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
"🔧 Installing browser tool dependencies (a few minutes, please wait)…",
))
glibc = _get_glibc_version()
if glibc and glibc < GLIBC_THRESHOLD:
@@ -136,27 +147,36 @@ def run_install_browser(
stream("")
_phase(
on_phase,
f" 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}",
_t(
f" 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}",
f" Detected glibc {glibc_str} (older); installing compatible Playwright {PLAYWRIGHT_LEGACY_VERSION}.",
),
)
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
_phase(on_phase, "📦 [1/3] 正在安装 Playwright Python 包…")
_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)
if ret != 0:
stream("Failed to install playwright package.", "red")
_phase(on_phase, "❌ [1/3] Playwright Python 包安装失败。")
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
return 1
installed = _get_installed_version()
if installed:
stream(f" playwright {installed} installed.", "green")
stream("")
_phase(on_phase, f"✅ [1/3] Playwright 包已安装({installed or target_version})。")
_phase(on_phase, _t(
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
))
if sys.platform == "linux":
_phase(on_phase, "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo")
_phase(on_phase, _t(
"🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo",
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
))
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
if ret != 0:
@@ -183,14 +203,23 @@ def run_install_browser(
stream(" CJK font (wqy-zenhei) installed.", "green")
_phase(
on_phase,
"✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。",
_t(
"✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。",
"✅ [2/3] Linux deps and font steps executed (on permission issues, check the server log or run the suggested commands manually).",
),
)
else:
stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow")
_phase(on_phase, f" [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。")
_phase(on_phase, _t(
f" [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。",
f" [2/3] Skipping Linux-specific deps on this platform ({sys.platform}).",
))
stream("")
_phase(on_phase, "🌐 [3/3] 正在下载并安装 Chromium体积较大请耐心等待")
_phase(on_phase, _t(
"🌐 [3/3] 正在下载并安装 Chromium体积较大请耐心等待",
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
))
stream("[3/3] Installing Chromium browser...", "yellow")
cmd = [python, "-m", "playwright", "install", "chromium"]
@@ -209,27 +238,33 @@ def run_install_browser(
if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
stream(f" (using China mirror: {CHINA_MIRROR})", None)
_phase(on_phase, "📡 检测到国内 pip 源配置Chromium 将优先走国内镜像下载。")
_phase(on_phase, _t(
"📡 检测到国内 pip 源配置Chromium 将优先走国内镜像下载。",
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
))
ret = subprocess.call(cmd, env=env)
if ret != 0 and use_mirror:
stream(" Mirror download failed, retrying with official CDN...", "yellow")
_phase(on_phase, "⚠️ 镜像下载失败,正在改用官方源重试…")
_phase(on_phase, _t(
"⚠️ 镜像下载失败,正在改用官方源重试…",
"⚠️ Mirror download failed; retrying with the official CDN…",
))
env_no_mirror = os.environ.copy()
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
ret = subprocess.call(cmd, env=env_no_mirror)
if ret != 0:
stream("Failed to install Chromium.", "red")
_phase(on_phase, "❌ [3/3] Chromium 安装失败。")
_phase(on_phase, _t("❌ [3/3] Chromium 安装失败。", "❌ [3/3] Failed to install Chromium."))
return 1
stream("")
_phase(on_phase, "✅ [3/3] Chromium 已安装。")
_phase(on_phase, _t("✅ [3/3] Chromium 已安装。", "✅ [3/3] Chromium installed."))
stream("Verifying browser installation...", None)
_phase(on_phase, "🔍 正在验证 Playwright 能否正常加载…")
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL,
@@ -240,14 +275,20 @@ def run_install_browser(
" Consider upgrading your OS or using Docker.",
"yellow",
)
_phase(on_phase, "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。")
_phase(on_phase, _t(
"⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。",
"⚠️ Verification did not fully pass: the browser tool may still not work here; check the log or upgrade your system.",
))
else:
stream(" Verification passed.", "green")
_phase(on_phase, "✅ 验证通过。")
_phase(on_phase, _t("✅ 验证通过。", "✅ Verification passed."))
stream("")
stream("Browser tool ready! Restart CowAgent to enable it.", "green")
_phase(on_phase, "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。")
_phase(on_phase, _t(
"🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。",
"🎉 All steps finished. Restart CowAgent to use the browser tool.",
))
return 0

View File

@@ -8,11 +8,28 @@ from typing import Optional
import click
from cli.utils import get_project_root
from cli.utils import get_project_root, load_config_json
_IS_WIN = sys.platform == "win32"
def _is_terminal_only() -> bool:
"""Whether terminal is the only configured channel.
Terminal needs an interactive stdin/tty, which is incompatible with the
background daemon mode (stdout/stdin detached). When terminal is the only
channel, `start` must run in the foreground so it can own the tty.
"""
channel = load_config_json().get("channel_type", "")
if isinstance(channel, str):
names = [c.strip() for c in channel.split(",") if c.strip()]
elif isinstance(channel, (list, tuple)):
names = [str(c).strip() for c in channel if str(c).strip()]
else:
names = []
return names == ["terminal"]
def _get_pid_file():
return os.path.join(get_project_root(), ".cow.pid")
@@ -103,6 +120,12 @@ def start(foreground, no_logs):
python = sys.executable
# Terminal-only setups need an interactive tty; force foreground so the
# terminal channel can read stdin instead of fighting the shell over the tty.
if not foreground and _is_terminal_only():
foreground = True
click.echo("Detected terminal-only channel, starting in foreground...")
if foreground:
click.echo("Starting CowAgent in foreground...")
if _IS_WIN:
@@ -252,7 +275,14 @@ def update(ctx):
def status():
"""Show CowAgent running status."""
from cli import __version__
from cli.utils import load_config_json
from cli.utils import load_config_json, get_cli_language
# get_cli_language() calls ensure_sys_path(), which adds the project root
# to sys.path. Import `common` only AFTER that, otherwise it fails with
# ModuleNotFoundError when `cow` runs from outside the project dir.
get_cli_language() # resolve cow_lang so i18n.t reflects config
from common import i18n
_t = i18n.t
pid = _read_pid()
if pid:
@@ -260,17 +290,19 @@ def status():
else:
click.echo(click.style("● CowAgent is not running", fg="red"))
click.echo(f" 版本: v{__version__}")
click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}"))
cfg = load_config_json()
if cfg:
channel = cfg.get("channel_type", "unknown")
if isinstance(channel, list):
channel = ", ".join(channel)
click.echo(f" 通道: {channel}")
click.echo(f" 模型: {cfg.get('model', 'unknown')}")
click.echo(_t(f" 通道: {channel}", f" Channel: {channel}"))
click.echo(_t(f" 模型: {cfg.get('model', 'unknown')}", f" Model: {cfg.get('model', 'unknown')}"))
mode = "Chat" if cfg.get("agent") is False else "Agent"
click.echo(f" 模式: {mode}")
click.echo(_t(f" 模式: {mode}", f" Mode: {mode}"))
lang_label = "中文" if i18n.get_language() == "zh" else "English"
click.echo(_t(f" 语言: {lang_label}", f" Language: {lang_label}"))
@click.command()

View File

@@ -517,18 +517,26 @@ def _install_targz_bytes(content: bytes, name: str, skills_dir: str, result: Ins
def _print_install_success(name: str, source: str):
"""Print a unified install success message with description and source."""
from cli.utils import get_cli_language
# Import `common` only after get_cli_language() runs ensure_sys_path(),
# so it works when `cow` is invoked from outside the project directory.
get_cli_language() # resolve cow_lang so i18n.t reflects config
from common import i18n
_t = i18n.t
skills_dir = get_skills_dir()
config = load_skills_config()
display = config.get(name, {}).get("display_name", "")
desc = _read_skill_description(os.path.join(skills_dir, name))
click.echo(click.style(f"{name}", fg="green"))
if display and display != name:
click.echo(f" 名称: {display}")
click.echo(_t(f" 名称: {display}", f" Name: {display}"))
if desc:
if len(desc) > 60:
desc = desc[:57] + ""
click.echo(f" 描述: {desc}")
click.echo(f" 来源: {source}")
click.echo(_t(f" 描述: {desc}", f" Description: {desc}"))
click.echo(_t(f" 来源: {source}", f" Source: {source}"))
def _validate_skill_name(name: str):

View File

@@ -40,6 +40,22 @@ def load_config_json() -> dict:
return {}
def get_cli_language() -> str:
"""Resolve the CLI UI language using the shared i18n detector.
Reads the `cow_lang` field from config.json (defaults to "auto") and runs
the same detection used by the running app, so CLI output matches.
"""
ensure_sys_path()
try:
from common import i18n
configured = load_config_json().get("cow_lang", "auto")
return i18n.resolve_language(configured)
except Exception:
return "en"
def load_skills_config() -> dict:
"""Load skills_config.json from the custom skills directory."""
path = os.path.join(get_skills_dir(), "skills_config.json")

View File

@@ -15,6 +15,7 @@ ZHIPU_AI = "zhipu"
MOONSHOT = "moonshot"
MiniMax = "minimax"
DEEPSEEK = "deepseek"
MIMO = "mimo" # 小米 MiMo 大模型
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
MODELSCOPE = "modelscope"
@@ -29,8 +30,9 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
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_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 - Agent推荐模型
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推荐模型
@@ -140,6 +142,13 @@ KIMI_K2 = "kimi-k2"
KIMI_K2_5 = "kimi-k2.5"
KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default)
# 小米 MiMo
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐)
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频)
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 - 极速版
# Doubao (Volcengine Ark)
DOUBAO = "doubao"
DOUBAO_SEED_2_CODE = "doubao-seed-2-0-code-preview-260215"
@@ -182,8 +191,11 @@ MODEL_LIST = [
# MiniMax
MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
# 小米 MiMo
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
# Claude
CLAUDE3, CLAUDE_4_6_SONNET, CLAUDE_4_7_OPUS, 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_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",
@@ -232,3 +244,7 @@ DINGTALK = "dingtalk"
WECOM_BOT = "wecom_bot"
QQ = "qq"
WEIXIN = "weixin"
WECHAT_KF = "wechat_kf"
TELEGRAM = "telegram"
SLACK = "slack"
DISCORD = "discord"

177
common/i18n.py Normal file
View File

@@ -0,0 +1,177 @@
# encoding:utf-8
"""Lightweight global language detection and resolution.
This module is the single source of truth for the runtime UI language used
across the CLI, startup logs, error messages, agent prompts and channel
replies. It must NOT import project config (to avoid circular imports) and
must stay dependency-free so it can run at the earliest startup phase.
Resolution priority (highest first):
1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any
config key is overridable via its uppercase env var (e.g. COW_LANG=zh),
handled by config.load_config() before resolution. COW_LANG is a private
name to avoid clashing with the gettext-standard LANGUAGE variable.
2. macOS `defaults read -g AppleLocale` (system-level preference; a Chinese
system locale is a strong signal that beats a shell-default LANG)
3. Standard locale env vars: LC_ALL > LC_MESSAGES > LANG
4. Python locale module
5. Default -> English
A value of "auto" (the default) triggers detection (steps 2-5). Explicitly
setting "zh" or "en" locks the language and skips detection.
"""
import os
import subprocess
import sys
# Supported language codes
ZH = "zh"
EN = "en"
SUPPORTED = (ZH, EN)
DEFAULT_LANG = EN
# Resolved language cache; None until first resolution.
_resolved_lang = None
def _normalize(raw):
"""Map an arbitrary locale-ish string to a supported code, or None.
Only Chinese is detected explicitly; everything else (including unknown
or empty values) yields None so the caller can fall through to the next
detection source.
"""
if not raw:
return None
value = str(raw).strip().lower().replace("_", "-")
if value in ("auto", ""):
return None
# Chinese variants: zh, zh-cn, zh-hans, zh-hans-cn, zh-tw, zh-hk ...
if value.startswith("zh") or value.startswith("chinese"):
return ZH
if value.startswith("en") or value.startswith("english"):
return EN
return None
def _detect_from_env():
"""Detect language from standard locale environment variables.
Note: on macOS, `LANG` is often a shell default (e.g. en_US.UTF-8 set by
.zshrc) that does not reflect the user's real preference, so AppleLocale
is checked first (see detect_language). On Linux these vars are the
primary signal.
The cow_lang env override (COW_LANG=zh) is intentionally NOT read here:
it sets config["cow_lang"] and is handled via the explicit config path,
not auto-detection.
"""
for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
lang = _normalize(os.environ.get(key))
if lang:
return lang
return None
def _detect_from_macos():
"""macOS fallback: read the system-wide AppleLocale preference.
On macOS the terminal often does NOT export LANG, yet the system locale
is still meaningful (e.g. a Chinese Mac reports zh_CN). This recovers
that signal so Chinese users are not misdetected as English.
"""
if sys.platform != "darwin":
return None
try:
out = subprocess.run(
["defaults", "read", "-g", "AppleLocale"],
capture_output=True,
text=True,
timeout=2,
)
if out.returncode == 0:
return _normalize(out.stdout)
except Exception:
pass
return None
def _detect_from_python_locale():
"""Last-resort detection via Python's locale module."""
try:
import locale
for value in locale.getlocale():
lang = _normalize(value)
if lang:
return lang
except Exception:
pass
return None
def detect_language():
"""Run full auto-detection and return a supported language code.
Order (auto-detection only; explicit config["cow_lang"] is resolved
before this is reached):
1. macOS AppleLocale (system-level preference; a Chinese system locale
is a strong, low-false-positive signal that beats a shell-default
LANG like en_US.UTF-8)
2. locale env vars LC_ALL / LC_MESSAGES / LANG (primary signal on Linux)
3. Python locale module
4. default English
"""
return (
_detect_from_macos()
or _detect_from_env()
or _detect_from_python_locale()
or DEFAULT_LANG
)
def resolve_language(configured=None):
"""Resolve the effective language from a configured value.
`configured` is the raw `cow_lang` value from config.json (may be None,
"auto", "zh" or "en"). An explicit "zh"/"en" locks the result; "auto"
or empty triggers detection. The result is cached globally.
"""
global _resolved_lang
explicit = _normalize(configured)
if explicit:
_resolved_lang = explicit
else:
_resolved_lang = detect_language()
return _resolved_lang
def set_language(lang):
"""Force the resolved language (used by tests or per-request overrides)."""
global _resolved_lang
normalized = _normalize(lang)
_resolved_lang = normalized or DEFAULT_LANG
return _resolved_lang
def get_language():
"""Return the currently resolved language, detecting lazily if needed."""
global _resolved_lang
if _resolved_lang is None:
_resolved_lang = detect_language()
return _resolved_lang
def is_zh():
return get_language() == ZH
def t(zh_text, en_text):
"""Pick a string by the current language. Tiny inline-translation helper.
Intended for one-off strings where a full message catalog is overkill:
t("已中止", "Cancelled")
"""
return zh_text if get_language() == ZH else en_text

View File

@@ -117,6 +117,18 @@ def expand_path(path: str) -> str:
return expanded
def is_cloud_deployment() -> bool:
if os.environ.get("CLOUD_DEPLOYMENT_ID"):
return True
try:
from config import conf
if conf().get("cloud_deployment_id"):
return True
except Exception:
pass
return False
def get_cloud_headers(api_key: str) -> dict:
"""
Build standard headers for LinkAI API requests,

View File

@@ -1,5 +1,6 @@
{
"channel_type": "weixin",
"cow_lang": "auto",
"channel_type": "web",
"model": "deepseek-v4-flash",
"deepseek_api_key": "",
"deepseek_api_base": "https://api.deepseek.com/v1",

368
config.py
View File

@@ -7,196 +7,222 @@ import os
import pickle
from common.log import logger
from common import i18n
# 将所有可用的配置项写在字典里, 请使用小写字母
# 此处的配置值无实际意义程序不会读取此处的配置仅用于提示格式请将配置加入到config.json中
# All available config keys are listed in this dict (use lowercase keys).
# The values here are placeholders only; the program does NOT read them.
# They merely document the expected format — put real values in config.json.
available_setting = {
# openai api配置
# global UI language for CLI, startup logs, error messages, agent prompts
# and channel replies. Options: "auto" (detect from system locale, default),
# "zh" (Chinese) or "en" (English). An explicit value locks the language.
# value: auto/en/zh
"cow_lang": "auto",
# openai api config
"open_ai_api_key": "", # openai api key
# openai apibase,当use_azure_chatgpt为true时需要设置对应的api base
# openai api base; when use_azure_chatgpt is true, set the matching api base
"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")
"proxy": "", # openai使用的代理
# chatgpt模型, 当use_azure_chatgpt为true时其名称为Azuremodel deployment名称
"model": "gpt-3.5-turbo", # 可选择: gpt-4o, pt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini等模型,全部可选模型详见common/const.py文件
"bot_type": "", # 可选配置使用兼容openai格式的三方服务时候需填"openai""custom"custom模式下切换模型不会自动切换bot_type。bot具体名称详见common/const.py文件如不填根据model名称判断
"use_azure_chatgpt": False, # 是否使用azurechatgpt
"azure_deployment_id": "", # azure 模型部署名称
"azure_api_version": "", # azure api版本
# Bot触发配置
"single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复
"single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人
"single_chat_reply_suffix": "", # 私聊时自动回复的后缀,\n 可以换行
"group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复
"no_need_at": False, # 群聊回复时是否不需要艾特
"group_chat_reply_prefix": "", # 群聊时自动回复的前缀
"group_chat_reply_suffix": "", # 群聊时自动回复的后缀,\n 可以换行
"group_chat_keyword": [], # 群聊时包含该关键词则会触发机器人回复
"group_at_off": False, # 是否关闭群聊时@bot的触发
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表
"group_name_keyword_white_list": [], # 开启自动回复的群名称关键词列表
"group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称
"group_shared_session": False, # 群聊是否共享会话上下文所有成员共享。False时每个用户在群内有独立会话
"nick_name_black_list": [], # 用户昵称黑名单
"group_welcome_msg": "", # 配置新人进群固定欢迎语,不配置则使用随机风格欢迎
"trigger_by_self": False, # 是否允许机器人触发
"text_to_image": "dall-e-2", # 图片生成模型,可选 dall-e-2, dall-e-3
# Azure OpenAI dall-e-3 配置
"dalle3_image_style": "vivid", # 图片生成dalle3的风格,可选有 vivid, natural
"dalle3_image_quality": "hd", # 图片生成dalle3的质量,可选有 standard, hd
# Azure OpenAI DALL-E API 配置, 当use_azure_chatgpt为true时,用于将文字回复的资源和Dall-E的资源分开.
"azure_openai_dalle_api_base": "", # [可选] azure openai 用于回复图片的资源 endpoint默认使用 open_ai_api_base
"azure_openai_dalle_api_key": "", # [可选] azure openai 用于回复图片的资源 key默认使用 open_ai_api_key
"azure_openai_dalle_deployment_id":"", # [可选] azure openai 用于回复图片的资源 deployment id,默认使用 text_to_image
"image_proxy": True, # 是否需要图片代理国内访问LinkAI时需要
"image_create_prefix": ["", "", ""], # 开启图片回复的前缀
"concurrency_in_session": 1, # 同一会话最多有多少条消息在处理中大于1可能乱序
"image_create_size": "256x256", # 图片大小,可选有 256x256, 512x512, 1024x1024 (dall-e-3默认为1024x1024)
"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
"bot_type": "", # optional; for OpenAI-compatible third-party services set "openai" or "custom" (in custom mode switching model won't auto-switch bot_type). See common/const.py for bot names; inferred from model name if left empty
"use_azure_chatgpt": False, # whether to use Azure chatgpt
"azure_deployment_id": "", # azure model deployment name
"azure_api_version": "", # azure api version
# Bot trigger config
"single_chat_prefix": ["bot", "@bot"], # text must contain this prefix to trigger a reply in single chat
"single_chat_reply_prefix": "[bot] ", # auto-reply prefix in single chat, used to distinguish from a real person
"single_chat_reply_suffix": "", # auto-reply suffix in single chat; \n inserts a line break
"group_chat_prefix": ["@bot"], # messages containing this prefix trigger a reply in group chat
"no_need_at": False, # whether replying in group chat does not require an @mention
"group_chat_reply_prefix": "", # auto-reply prefix in group chat
"group_chat_reply_suffix": "", # auto-reply suffix in group chat; \n inserts a line break
"group_chat_keyword": [], # messages containing this keyword trigger a reply in group chat
"group_at_off": False, # whether to disable @bot triggering in group chat
"group_name_white_list": ["group1", "group2"], # group names where auto-reply is enabled
"group_name_keyword_white_list": [], # group-name keywords where auto-reply is enabled
"group_chat_in_one_session": ["group1"], # group names that share conversation context
"group_shared_session": False, # whether group chat shares conversation context (all members share). When False each user has an independent session in the group
"nick_name_black_list": [], # user nickname blacklist
"group_welcome_msg": "", # fixed welcome message for new group members; uses a random style when empty
"trigger_by_self": False, # whether the bot can be triggered by itself
"text_to_image": "dall-e-2", # image generation model, options: dall-e-2, dall-e-3
# Azure OpenAI dall-e-3 config
"dalle3_image_style": "vivid", # dalle3 image style, options: vivid, natural
"dalle3_image_quality": "hd", # dalle3 image quality, options: standard, hd
# Azure OpenAI DALL-E API config; when use_azure_chatgpt is true, separates the text-reply resource from the DALL-E resource
"azure_openai_dalle_api_base": "", # [optional] azure openai endpoint for image replies; defaults to open_ai_api_base
"azure_openai_dalle_api_key": "", # [optional] azure openai key for image replies; defaults to open_ai_api_key
"azure_openai_dalle_deployment_id":"", # [optional] azure openai deployment id for image replies; defaults to text_to_image
"image_proxy": True, # whether an image proxy is needed; required when accessing LinkAI from mainland China
"image_create_prefix": ["", "", ""], # prefixes that enable image replies
"concurrency_in_session": 1, # max number of in-flight messages per session; values >1 may cause out-of-order replies
"image_create_size": "256x256", # image size, options: 256x256, 512x512, 1024x1024 (dall-e-3 defaults to 1024x1024)
"group_chat_exit_group": False,
# chatgpt会话参数
"expires_in_seconds": 3600, # 无操作会话的过期时间
# 人格描述
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。",
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
# chatgpt限流配置
"rate_limit_chatgpt": 20, # chatgpt的调用频率限制
"rate_limit_dalle": 50, # openai dalle的调用频率限制
# chatgpt api参数 参考https://platform.openai.com/docs/api-reference/chat/create
# chatgpt session params
"expires_in_seconds": 3600, # idle session expiry time
# persona description (only used in chat mode)
"character_desc": "You are a helpful AI assistant. You aim to answer and solve any questions people have, and can communicate in multiple languages.",
"conversation_max_tokens": 1000, # max characters of context memory
# chatgpt rate limit config
"rate_limit_chatgpt": 20, # chatgpt call rate limit
"rate_limit_dalle": 50, # openai dalle call rate limit
# chatgpt api params, see https://platform.openai.com/docs/api-reference/chat/create
"temperature": 0.9,
"top_p": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"request_timeout": 180, # chatgpt请求超时时间openai接口默认设置为600对于难问题一般需要较长时间
"timeout": 120, # chatgpt重试超时时间,在这个时间内,将会自动重试
# Baidu 文心一言参数
"baidu_wenxin_model": "eb-instant", # 默认使用ERNIE-Bot-turbo模型
"request_timeout": 180, # chatgpt request timeout; the openai api defaults to 600, hard questions usually need longer
"timeout": 120, # chatgpt retry timeout; will auto-retry within this window
# Baidu Wenxin (ERNIE) params
"baidu_wenxin_model": "eb-instant", # defaults to the ERNIE-Bot-turbo model
"baidu_wenxin_api_key": "", # Baidu api key
"baidu_wenxin_secret_key": "", # Baidu secret key
"baidu_wenxin_prompt_enabled": False, # Enable prompt if you are using ernie character model
# Baidu Qianfan / ERNIE OpenAI-compatible API
"qianfan_api_key": "", # Baidu Qianfan API key in bce-v3 format
"qianfan_api_base": "https://qianfan.baidubce.com/v2", # Qianfan OpenAI-compatible API base
# 讯飞星火API
"xunfei_app_id": "", # 讯飞应用ID
"xunfei_api_key": "", # 讯飞 API key
"xunfei_api_secret": "", # 讯飞 API secret
"xunfei_domain": "", # 讯飞模型对应的domain参数Spark4.0 Ultra 4.0Ultra,其他模型详见: https://www.xfyun.cn/doc/spark/Web.html
"xunfei_spark_url": "", # 讯飞模型对应的请求地址,Spark4.0 Ultra wss://spark-api.xf-yun.com/v4.0/chat,其他模型参考详见: https://www.xfyun.cn/doc/spark/Web.html
# claude 配置
# Xunfei Spark API
"xunfei_app_id": "", # Xunfei app id
"xunfei_api_key": "", # Xunfei API key
"xunfei_api_secret": "", # Xunfei API secret
"xunfei_domain": "", # Xunfei model domain param; for Spark4.0 Ultra it is 4.0Ultra, see https://www.xfyun.cn/doc/spark/Web.html for others
"xunfei_spark_url": "", # Xunfei model request url; for Spark4.0 Ultra it is wss://spark-api.xf-yun.com/v4.0/chat, see https://www.xfyun.cn/doc/spark/Web.html for others
# claude config
"claude_api_cookie": "",
"claude_uuid": "",
# claude api key
"claude_api_key": "",
# 通义千问API, 获取方式查看文档 https://help.aliyun.com/document_detail/2587494.html
# Tongyi Qianwen API, see https://help.aliyun.com/document_detail/2587494.html for how to obtain
"qwen_access_key_id": "",
"qwen_access_key_secret": "",
"qwen_agent_key": "",
"qwen_app_id": "",
"qwen_node_id": "", # 流程编排模型用到的id如果没有用到qwen_node_id,请务必保持为空字符串
# 阿里灵积(通义新版sdk)模型api key
"qwen_node_id": "", # id used by workflow-orchestration models; keep it an empty string if qwen_node_id is unused
# Alibaba Lingji (Tongyi new sdk) model api key
"dashscope_api_key": "",
# Google Gemini Api Key
"gemini_api_key": "",
# Embedding 模型设置
"embedding_provider": "", # 显式指定厂商:openai / linkai / dashscope / doubao / zhipu ( bot_type 命名一致)
"embedding_model": "", # 留空使用厂商默认 model
"embedding_dimensions": 0, # 留空/0 使用厂商默认维度(推荐统一 1024
# 语音设置
"speech_recognition": True, # 是否开启语音识别
"group_speech_recognition": False, # 是否开启群组语音识别
"voice_reply_voice": False, # 是否使用语音回复语音,需要设置对应语音合成引擎的api key
"always_reply_voice": False, # 是否一直使用语音回复
"voice_to_text": "openai", # 语音识别引擎,支持openai,baidu,google,azure,xunfei,ali
"text_to_voice": "openai", # 语音合成引擎,支持openai,baidu,google,azure,xunfei,ali,pytts(offline),elevenlabs,edge(online)
# Embedding model config
"embedding_provider": "", # explicitly set the provider: openai / linkai / dashscope / doubao / zhipu (aligned with bot_type naming)
"embedding_model": "", # leave empty to use the provider's default model
"embedding_dimensions": 0, # leave empty/0 to use the provider's default dimension (1024 recommended for consistency)
# voice config
"speech_recognition": True, # whether to enable speech recognition
"group_speech_recognition": False, # whether to enable group speech recognition
"voice_reply_voice": False, # whether to reply to voice with voice; requires the matching TTS engine api key
"always_reply_voice": False, # whether to always reply with voice
"voice_to_text": "openai", # speech recognition engine: openai,baidu,google,azure,xunfei,ali
"text_to_voice": "openai", # TTS engine: openai,baidu,google,azure,xunfei,ali,pytts(offline),elevenlabs,edge(online)
"text_to_voice_model": "tts-1",
"tts_voice_id": "alloy",
# baidu 语音api配置 使用百度语音识别和语音合成时需要
# baidu voice api config; required when using Baidu speech recognition and TTS
"baidu_app_id": "",
"baidu_api_key": "",
"baidu_secret_key": "",
# 1536普通话(支持简单的英文识别) 1737英语 1637粤语 1837四川话 1936普通话远场
# 1536 Mandarin (with basic English) 1737 English 1637 Cantonese 1837 Sichuanese 1936 Mandarin far-field
"baidu_dev_pid": 1536,
# azure 语音api配置 使用azure语音识别和语音合成时需要
# azure voice api config; required when using Azure speech recognition and TTS
"azure_voice_api_key": "",
"azure_voice_region": "japaneast",
# elevenlabs 语音api配置
"xi_api_key": "", # 获取ap的方法可以参考https://docs.elevenlabs.io/api-reference/quick-start/authentication
"xi_voice_id": "", # ElevenLabs提供了9种英式、美式等英语发音id分别是“Adam/Antoni/Arnold/Bella/Domi/Elli/Josh/Rachel/Sam
# 服务时间限制
"chat_time_module": False, # 是否开启服务时间限制
"chat_start_time": "00:00", # 服务开始时间
"chat_stop_time": "24:00", # 服务结束时间
# 翻译api
"translate": "baidu", # 翻译api支持baidu, youdao
# baidu翻译api的配置
"baidu_translate_app_id": "", # 百度翻译apiappid
"baidu_translate_app_key": "", # 百度翻译api的秘钥
# youdao翻译api的配置
"youdao_translate_app_key": "", # 有道翻译api的应用ID
"youdao_translate_app_secret": "", # 有道翻译api的应用密钥
# wechatmp的配置
"wechatmp_token": "", # 微信公众平台的Token
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443
"wechatmp_app_id": "", # 微信公众平台的appID
"wechatmp_app_secret": "", # 微信公众平台的appsecret
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey加密模式需要
# wechatcom的通用配置
"wechatcom_corp_id": "", # 企业微信公司的corpID
# wechatcomapp的配置
"wechatcomapp_token": "", # 企业微信apptoken
"wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发
"wechatcomapp_secret": "", # 企业微信appsecret
"wechatcomapp_agent_id": "", # 企业微信appagent_id
"wechatcomapp_aes_key": "", # 企业微信appaes_key
# 飞书配置
"feishu_port": 80, # 飞书bot监听端口仅webhook模式需要
"feishu_app_id": "", # 飞书机器人应用APP Id
"feishu_app_secret": "", # 飞书机器人APP secret
"feishu_token": "", # 飞书 verification token仅webhook模式需要
"feishu_event_mode": "websocket", # 飞书事件接收模式: webhook(HTTP服务器) 或 websocket(长连接)
# 飞书流式回复(基于官方 cardkit 流式卡片 API需要机器人开通 cardkit:card:write 权限,且飞书客户端 7.20+
"feishu_stream_reply": True, # 是否开启流式回复(打字机效果)。失败/老客户端自动降级为非流式或升级提示
# 钉钉配置
"dingtalk_client_id": "", # 钉钉机器人Client ID
"dingtalk_client_secret": "", # 钉钉机器人Client Secret
# elevenlabs voice api config
"xi_api_key": "", # see https://docs.elevenlabs.io/api-reference/quick-start/authentication for how to obtain the api key
"xi_voice_id": "", # ElevenLabs offers 9 English voice ids: Adam/Antoni/Arnold/Bella/Domi/Elli/Josh/Rachel/Sam
# service time limit
"chat_time_module": False, # whether to enable service-time limiting
"chat_start_time": "00:00", # service start time
"chat_stop_time": "24:00", # service stop time
# translation api
"translate": "baidu", # translation api: baidu, youdao
# baidu translation api config
"baidu_translate_app_id": "", # baidu translation api appid
"baidu_translate_app_key": "", # baidu translation api secret key
# youdao translation api config
"youdao_translate_app_key": "", # youdao translation api app id
"youdao_translate_app_secret": "", # youdao translation api app secret
# wechatmp config
"wechatmp_token": "", # WeChat Official Account token
"wechatmp_port": 8080, # WeChat Official Account port; needs port forwarding to 80 or 443
"wechatmp_app_id": "", # WeChat Official Account appID
"wechatmp_app_secret": "", # WeChat Official Account appsecret
"wechatmp_aes_key": "", # WeChat Official Account EncodingAESKey; required in encrypted mode
# wechatcom shared config
"wechatcom_corp_id": "", # WeCom corp id
# wechatcomapp config
"wechatcomapp_token": "", # WeCom app token
"wechatcomapp_port": 9898, # WeCom app service port; no port forwarding needed
"wechatcomapp_secret": "", # WeCom app secret
"wechatcomapp_agent_id": "", # WeCom app agent_id
"wechatcomapp_aes_key": "", # WeCom app aes_key
# WeChat Customer Service (wechat_kf) config
"wechat_kf_corp_id": "", # corp_id of the company the WeChat Customer Service belongs to
"wechat_kf_token": "", # WeChat Customer Service callback token
"wechat_kf_port": 9888, # WeChat Customer Service callback service port
"wechat_kf_secret": "", # WeChat Customer Service app secret
"wechat_kf_aes_key": "", # WeChat Customer Service callback aes_key
"wechat_kf_cursor_path": "~/.wechat_kf_cursors.json", # path for persisting the WeChat Customer Service sync_msg cursor
# Feishu config
"feishu_port": 80, # Feishu bot listening port; only needed in webhook mode
"feishu_app_id": "", # Feishu bot app id
"feishu_app_secret": "", # Feishu bot app secret
"feishu_token": "", # Feishu verification token; only needed in webhook mode
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
# DingTalk config
"dingtalk_client_id": "", # DingTalk bot Client ID
"dingtalk_client_secret": "", # DingTalk bot Client Secret
"dingtalk_card_enabled": False,
# 企微智能机器人配置(长连接模式)
"wecom_bot_id": "", # 企微智能机器人BotID
"wecom_bot_secret": "", # 企微智能机器人长连接Secret
# 微信配置
"weixin_token": "", # 微信登录后获取的bot_token,留空则启动时自动扫码登录
# WeCom smart bot config (long connection mode)
"wecom_bot_id": "", # WeCom smart bot BotID
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
# 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)
"telegram_group_trigger": "mention_or_reply", # Group trigger: mention_or_reply(@ or reply, recommended) | mention_only(@ only) | all(every message)
"telegram_register_commands": True, # Auto-register the BotFather command menu on startup (aligned with web slash commands)
# Slack config (Socket Mode, no public IP required)
"slack_bot_token": "", # Bot User OAuth Token, like xoxb-...
"slack_app_token": "", # App-Level Token (generated after enabling Socket Mode), like xapp-...
"slack_group_trigger": "mention_or_reply", # Channel trigger: mention_or_reply(@ or reply in thread, recommended) | mention_only(@ only) | all(every message)
# Discord config (Gateway connection, no public IP required)
"discord_token": "", # Discord Bot Token (generated on the Bot page of the Developer Portal)
"discord_group_trigger": "mention_or_reply", # Channel trigger: mention_or_reply(@ or reply to bot, recommended) | mention_only(@ only) | all(every message)
# WeChat config
"weixin_token": "", # bot_token obtained after WeChat login; leave empty to auto scan-login on startup
"weixin_base_url": "https://ilinkai.weixin.qq.com", # Weixin ilink API base URL
"weixin_cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c", # CDN base URL
"weixin_credentials_path": "~/.weixin_cow_credentials.json", # credentials file path
# chatgpt指令自定义触发词
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头
# channel配置
"channel_type": "", # 通道类型,支持多渠道同时运行。单个: "feishu",多个: "feishu, dingtalk" ["feishu", "dingtalk"]。可选值: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app
"web_console": True, # 是否自动启动Web控制台默认启动。设为False可禁用
"subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app
"debug": False, # 是否开启debug模式开启后会打印更多日志
"appdata_dir": "", # 数据目录
# 插件配置
"plugin_trigger_prefix": "$", # 规范插件提供聊天相关指令的前缀,建议不要和管理员指令前缀"#"冲突
# 是否使用全局插件配置
# custom trigger words for chatgpt commands
"clear_memory_commands": ["#清除记忆"], # session-reset command; must start with #
# channel config
"channel_type": "", # channel type; supports running multiple channels at once. Single: "feishu", multiple: "feishu, dingtalk" or ["feishu", "dingtalk"]. Options: web,feishu,dingtalk,wecom_bot,weixin,wechatmp,wechatmp_service,wechatcom_app,wechat_kf,telegram,slack,discord
"web_console": True, # whether to auto-start the Web console (on by default). Set False to disable
"subscribe_msg": "", # subscribe message; supported by: wechatmp, wechatmp_service, wechatcom_app
"debug": False, # whether to enable debug mode; prints more logs when on
"appdata_dir": "", # data directory
# plugin config
"plugin_trigger_prefix": "$", # prefix for plugin chat commands; avoid clashing with the admin command prefix "#"
# whether to use the global plugin config
"use_global_plugin_config": False,
"max_media_send_count": 3, # 单次最大发送媒体资源的个数
"media_send_interval": 1, # 发送图片的事件间隔,单位秒
# 智谱AI 平台配置
"max_media_send_count": 3, # max number of media resources sent at once
"media_send_interval": 1, # interval between sending images, in seconds
# Zhipu AI platform config
"zhipu_ai_api_key": "",
"zhipu_ai_api_base": "https://open.bigmodel.cn/api/paas/v4",
"moonshot_api_key": "",
"moonshot_base_url": "https://api.moonshot.cn/v1",
# 豆包(火山方舟) 平台配置
# Doubao (Volcano Ark) platform config
"ark_api_key": "",
"ark_base_url": "https://ark.cn-beijing.volces.com/api/v3",
# 魔搭社区 平台配置
# ModelScope community platform config
"modelscope_api_key": "",
"modelscope_base_url": "https://api-inference.modelscope.cn/v1/chat/completions",
# LinkAI平台配置
# LinkAI platform config
"use_linkai": False,
"linkai_api_key": "",
"linkai_app_code": "",
@@ -209,18 +235,22 @@ available_setting = {
"Minimax_base_url": "",
"deepseek_api_key": "",
"deepseek_api_base": "https://api.deepseek.com/v1",
# Xiaomi MiMo LLM
"mimo_api_key": "",
"mimo_api_base": "https://api.xiaomimimo.com/v1",
"web_host": "", # Web console bind address; empty means auto
"web_port": 9899,
"web_password": "", # Web console password; empty means no authentication required
"web_session_expire_days": 30, # Auth session expiry in days
"agent": True, # 是否开启Agent模式
"agent_workspace": "~/cow", # agent工作空间路径用于存储skills、memory等
"agent_max_context_tokens": 50000, # Agent模式下最大上下文tokens
"agent_max_context_turns": 20, # Agent模式下最大上下文记忆轮次
"agent_max_steps": 20, # Agent模式下单次运行最大决策步数
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
"agent": True, # whether to enable Agent mode
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
"agent_max_context_tokens": 50000, # max context tokens in Agent mode
"agent_max_context_turns": 20, # max context memory turns in Agent mode
"agent_max_steps": 20, # max decision steps per run in Agent mode
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
"knowledge": True, # 是否开启知识库功能
"knowledge": True, # whether to enable the knowledge base feature
"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)
}
@@ -233,7 +263,7 @@ class Config(dict):
d = {}
for k, v in d.items():
self[k] = v
# user_datas: 用户数据key为用户名value为用户数据也是dict
# user_datas: per-user data; key is the username, value is the user's data (also a dict)
self.user_datas = {}
def __getitem__(self, key):
@@ -243,11 +273,11 @@ class Config(dict):
return super().__setitem__(key, value)
def get(self, key, default=None):
# 跳过以下划线开头的注释字段
# skip comment fields starting with an underscore
if key.startswith("_"):
return super().get(key, default)
# 如果key不在available_setting中,直接走dictget返回config.json中实际加载的值如不存在则返回default
# if the key is not in available_setting, fall back to dict.get and return the value actually loaded from config.json (or default if absent)
if key not in available_setting:
return super().get(key, default)
@@ -314,7 +344,7 @@ def drag_sensitive(config):
def load_config():
global config
# 打印 ASCII Logo
# print ASCII logo
logger.info(" ____ _ _ ")
logger.info(" / ___|_____ __ / \\ __ _ ___ _ __ | |_ ")
logger.info("| | / _ \\ \\ /\\ / // _ \\ / _` |/ _ \\ '_ \\| __|")
@@ -324,13 +354,13 @@ def load_config():
logger.info("")
config_path = "./config.json"
if not os.path.exists(config_path):
logger.info("配置文件不存在,将使用config-template.json模板")
logger.info("config file not found, falling back to config-template.json")
config_path = "./config-template.json"
config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
# 将json字符串反序列化为dict类型。
# Deserialize the json string into a dict.
# `object_pairs_hook` lets us catch users who accidentally typed the
# same key twice (e.g. two `"tools"` blocks) — json.loads would
# otherwise silently drop all but the last occurrence.
@@ -347,7 +377,7 @@ def load_config():
# Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config.
for name, value in os.environ.items():
name = name.lower()
# 跳过以下划线开头的注释字段
# skip comment fields starting with an underscore
if name.startswith("_"):
continue
if name in available_setting:
@@ -366,21 +396,26 @@ def load_config():
logger.setLevel(logging.DEBUG)
logger.debug("[INIT] set log level to DEBUG")
# Resolve the global UI language as early as possible so that every
# downstream layer (logs, CLI, agent prompts, channel replies) shares it.
resolved_lang = i18n.resolve_language(config.get("cow_lang", "auto"))
logger.info("[INIT] load config: {}".format(drag_sensitive(config)))
# 打印系统初始化信息
# print system initialization info
logger.info("[INIT] ========================================")
logger.info("[INIT] System Initialization")
logger.info("[INIT] ========================================")
logger.info("[INIT] Language: {}".format(resolved_lang))
logger.info("[INIT] Channel: {}".format(config.get("channel_type", "unknown")))
logger.info("[INIT] Model: {}".format(config.get("model", "unknown")))
# Agent模式信息
# Agent mode info
if config.get("agent", True):
workspace = config.get("agent_workspace", "~/cow")
logger.info("[INIT] Mode: Agent (workspace: {})".format(workspace))
else:
logger.info("[INIT] Mode: Chat (在config.json中设置 \"agent\":true 可启用Agent模式)")
logger.info("[INIT] Mode: Chat (set \"agent\":true in config.json to enable Agent mode)")
logger.info("[INIT] Debug: {}".format(config.get("debug", False)))
logger.info("[INIT] ========================================")
@@ -401,6 +436,8 @@ def load_config():
"minimax_api_base": "MINIMAX_API_BASE",
"deepseek_api_key": "DEEPSEEK_API_KEY",
"deepseek_api_base": "DEEPSEEK_API_BASE",
"mimo_api_key": "MIMO_API_KEY",
"mimo_api_base": "MIMO_API_BASE",
"qianfan_api_key": "QIANFAN_API_KEY",
"qianfan_api_base": "QIANFAN_API_BASE",
"zhipu_ai_api_key": "ZHIPU_AI_API_KEY",
@@ -420,6 +457,11 @@ def load_config():
"wechatmp_app_secret": "WECHATMP_APP_SECRET",
"wechatcomapp_agent_id": "WECHATCOMAPP_AGENT_ID",
"wechatcomapp_secret": "WECHATCOMAPP_SECRET",
"wechatcom_corp_id": "WECHATCOM_CORP_ID",
"wechat_kf_corp_id": "WECHAT_KF_CORP_ID",
"wechat_kf_secret": "WECHAT_KF_SECRET",
"wechat_kf_token": "WECHAT_KF_TOKEN",
"wechat_kf_aes_key": "WECHAT_KF_AES_KEY",
"qq_app_id": "QQ_APP_ID",
"qq_app_secret": "QQ_APP_SECRET",
"weixin_token": "WEIXIN_TOKEN",
@@ -582,8 +624,8 @@ plugin_config = {}
def write_plugin_config(pconf: dict):
"""
写入插件全局配置
:param pconf: 全量插件配置
Write the global plugin config.
:param pconf: the full plugin config
"""
global plugin_config
for k in pconf:
@@ -591,8 +633,8 @@ def write_plugin_config(pconf: dict):
def remove_plugin_config(name: str):
"""
移除待重新加载的插件全局配置
:param name: 待重载的插件名
Remove the global config of a plugin pending reload.
:param name: name of the plugin to reload
"""
global plugin_config
plugin_config.pop(name.lower(), None)
@@ -600,12 +642,12 @@ def remove_plugin_config(name: str):
def pconf(plugin_name: str) -> dict:
"""
根据插件名称获取配置
:param plugin_name: 插件名称
:return: 该插件的配置项
Get the config for a plugin by name.
:param plugin_name: plugin name
:return: the plugin's config
"""
return plugin_config.get(plugin_name.lower())
# 全局配置,用于存放全局生效的状态
# global config holding globally-effective state
global_config = {"admin_users": []}

View File

@@ -8,6 +8,7 @@ services:
ports:
- "9899:9899"
environment:
COW_LANG: 'auto'
CHANNEL_TYPE: 'weixin'
MODEL: 'deepseek-v4-flash'
DEEPSEEK_API_KEY: ''

30
docs/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Documentation
This directory contains the Mintlify documentation site for the project.
## Prerequisites
- Node.js v20.17.0 or higher (LTS recommended)
## Install the CLI (one-time, global)
```bash
npm i -g mint
```
## Run the docs locally
From this `docs/` directory:
```bash
mint dev
```
Then open http://localhost:3000 (or the port Mint reports if 3000 is in use).
> The first run downloads the Mint preview framework (~90 MB) into `~/.mintlify/`.
> Subsequent runs start instantly from the local cache.
## More
- Mintlify docs: https://www.mintlify.com/docs

View File

@@ -1,35 +1,35 @@
---
title: 钉钉
description: CowAgent 接入钉钉应用
title: DingTalk
description: Integrate CowAgent into DingTalk application
---
通过钉钉开放平台创建智能机器人应用,将 CowAgent 接入钉钉。
Integrate CowAgent into DingTalk by creating an intelligent robot app on the DingTalk Open Platform.
## 一、创建应用
## 1. Create App
1. 进入 [钉钉开发者后台](https://open-dev.dingtalk.com/fe/app#/corp/app),登录后点击 **创建应用**,填写应用相关信息:
1. Go to [DingTalk Developer Console](https://open-dev.dingtalk.com/fe/app#/corp/app), log in and click **Create App**, fill in the app information:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-create-app.png" width="800"/>
2. 点击添加应用能力,选择 **机器人** 能力,点击 **添加**
2. Click **Add App Capability**, select **Robot** capability and click **Add**:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-add-bot.png" width="800"/>
3. 配置机器人信息后点击 **发布**。发布后,点击 "**点击调试**",会自动创建测试群聊,可在客户端查看:
3. Configure the robot information and click **Publish**. After publishing, click "**Debug**" to automatically create a test group chat, which can be viewed in the client:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-config-bot.png" width="600"/>
4. 点击 **版本管理与发布**,创建新版本发布:
4. Click **Version Management & Release**, create a new version and publish:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-publish-bot.png" width="700"/>
## 二、项目配置
## 2. Project Configuration
1. 点击 **凭证与基础信息**,获取 `Client ID` `Client Secret`
1. Click **Credentials & Basic Info**, get the `Client ID` and `Client Secret`:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-get-secret.png" width="700"/>
2. 将以下配置加入项目根目录的 `config.json` 文件:
2. Add the following configuration to `config.json` in the project root:
```json
{
@@ -39,18 +39,20 @@ description: 将 CowAgent 接入钉钉应用
}
```
3. 安装依赖:
3. Install the dependency:
```bash
pip3 install dingtalk_stream
```
4. 启动项目后,在钉钉开发者后台点击 **事件订阅**,点击 **已完成接入,验证连接通道**,显示 **连接接入成功** 即表示配置完成:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-app-config.png" width="700"/>
4. After starting the project, go to the DingTalk Developer Console, click **Event Subscription**, then click **Connection verified, verify channel**. When "**Connection successful**" is displayed, the configuration is complete:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-event-sub.png" width="700"/>
## 三、使用
## 3. Usage
与机器人私聊或将机器人拉入企业群中均可开启对话:
Chat privately with the robot or add it to an enterprise group to start a conversation:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-hosting-demo.png" width="650"/>

93
docs/channels/discord.mdx Normal file
View File

@@ -0,0 +1,93 @@
---
title: Discord
description: Integrate CowAgent with a Discord Bot
---
> Integrate CowAgent into Discord via a Discord Bot using the **Gateway** (persistent WebSocket). Supports direct messages (DM) and server channels (triggered by @mention or replying to the bot). The Gateway uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box.
## 1. Setup
### Step 1: Create a Discord Application and Bot
1. Open the [Discord Developer Portal](https://discord.com/developers/applications), click **New Application**, enter a name (e.g. `CowAgent`), and create it.
2. Go to the **Bot** page in the left sidebar, click **Reset Token** to generate a Bot Token, then copy and store it safely (shown only once).
<Tip>
This token is your bot's password — keep it secret. If it leaks, click **Reset Token** again on the Bot page to regenerate it.
</Tip>
### Step 2: Enable the Message Content Intent
Reading message text in both DMs and channels depends on this privileged intent.
1. On the **Bot** page, find **Privileged Gateway Intents**.
2. Turn on **Message Content Intent** and save.
<Note>
Without this intent enabled, incoming message content will be empty and the bot will not respond.
</Note>
### Step 3: Invite the Bot to a Server
1. Go to **OAuth2 → URL Generator** in the left sidebar.
2. Under **Scopes**, check `bot`.
3. Under **Bot Permissions**, check at least: `Send Messages`, `Read Message History`, `Attach Files`, `View Channels`.
4. Copy the generated authorization URL at the bottom, open it in a browser, and authorize it for your target server.
<Note>
You can skip this step if you only need DMs, but you still need a DM channel with the bot (e.g. the user messages the bot directly).
</Note>
### Step 4: Connect to CowAgent
<Tabs>
<Tab title="Web Console (Recommended)">
Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Discord**, paste the Bot Token, and click connect.
</Tab>
<Tab title="Config File">
Add the following to `config.json` and start Cow:
```json
{
"channel_type": "discord",
"discord_token": "your-discord-bot-token",
"discord_group_trigger": "mention_or_reply"
}
```
| Key | Description | Default |
| --- | --- | --- |
| `discord_token` | Bot Token generated on the Bot page of the Developer Portal | - |
| `discord_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply to bot) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` |
</Tab>
</Tabs>
The integration is ready when you see logs like:
```
[Discord] Bot logged in as CowAgent#1234 (id=123456789)
[Discord] ✅ Discord bot ready, listening for messages
```
## 2. Capabilities
| Feature | Support |
| --- | --- |
| Direct message (DM) | ✅ |
| Server channel (@bot / reply to bot) | ✅ |
| Text messages | ✅ send / receive |
| Image messages | ✅ send / receive |
| File messages | ✅ send / receive (PDF / Word / Excel, etc.) |
<Note>
A single Discord message is capped at 2000 characters; long replies are automatically split across multiple messages by line breaks.
</Note>
## 3. Usage
Once connected:
- **Direct message (DM)**: find your bot in the server member list, click its avatar, and message it directly.
- **Channel**: in a channel where the bot is invited, trigger it with `@your-bot hello` or by **replying to one of the bot's messages**.
When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically.

View File

@@ -1,45 +1,44 @@
---
title: 飞书
description: CowAgent 接入飞书应用
title: Feishu (Lark)
description: Integrate CowAgent into Feishu via a custom enterprise app
---
> 通过飞书自建应用接入 CowAgent支持单聊与群聊@机器人),使用 WebSocket 长连接模式,无需公网 IP支持流式打字机回复、语音消息收发。
> Integrate CowAgent into Feishu via a custom enterprise app. Supports p2p chat and group chat (@bot), uses WebSocket long connection (no public IP needed), supports streaming typewriter replies and voice messages.
<Note>
接入需要是飞书企业用户且具有企业管理权限。
You need to be a Feishu enterprise user with admin privileges.
</Note>
## 一、接入方式
## 1. Setup
### 方式一:扫码一键接入(推荐)
启动 Cow 项目后在终端中即可完成扫码创建。或打开 Web 控制台本地链接http://127.0.0.1:9899 ),选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**,点击 **一键创建飞书应用**,使用 **飞书 App** 扫描二维码即可自动完成应用创建并接入:
### Option 1: One-click Scan to Create (Recommended)
No need to manually create an app on the Feishu Developer Platform. Start the Cow project, open the web console (default `http://127.0.0.1:9899/`), go to **Channels**, click **Add Channel**, choose **Feishu**, then under the **Scan QR** tab click **One-click Create Feishu App** and scan with the **Feishu App** to complete app creation and connection automatically.
<img src="https://cdn.link-ai.tech/doc/20260505181126.png" width="800"/>
<Note>
1. `lark-oapi` 依赖版本需要 >=1.5.5
2. 扫码创建出的应用会自动预置全部所需权限(消息收发、卡片读写、群聊事件等)和事件订阅,无需到开发者后台手动配置。
1. Requires `lark-oapi` 1.5.5.
2. The created app comes with all required permissions (messaging, card read/write, group events, etc.) and event subscriptions pre-configured — no manual setup on the developer console needed. Currently only the Feishu mainland version is supported (Lark international not yet supported).
</Note>
When starting from CLI without `feishu_app_id` configured, the QR code is also printed to the terminal.
### 方式二:手动创建接入
### Option 2: Manual Setup
需要先在飞书开放平台创建自建应用并配置权限,再通过 Web 控制台或配置文件接入。
Manually create a custom app on the Feishu Developer Platform, then connect via Web Console or config file.
**步骤一:创建应用**
**Step 1: Create the App**
1. 进入 [飞书开发平台](https://open.feishu.cn/app/),点击 **创建企业自建应用**
1. Go to [Feishu Developer Platform](https://open.feishu.cn/app/), click **Create Enterprise Custom App**:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
2. **添加应用能力** 中,为应用添加 **机器人** 能力:
2. In **Add App Capabilities**, add the **Bot** capability:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
3. **权限管理** 中,将以下权限粘贴到输入框,全选并 **批量开通**
3. In **Permission Management**, paste the following permissions and **Batch Enable** all:
```
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
@@ -47,18 +46,18 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
4. 在 **凭证与基础信息** 中获取 `App ID` `App Secret`
4. Get `App ID` and `App Secret` from **Credentials & Basic Info**:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
**步骤二:接入 CowAgent**
**Step 2: Connect to CowAgent**
<Tabs>
<Tab title="Web 控制台">
打开 Web 控制台,选择 **通道** 菜单,点击 **接入通道**,选择 **飞书**切换到「手动填写」Tab输入 App ID App Secret,点击接入即可。
<Tab title="Web Console">
Open the web console, go to **Channels**, click **Add Channel**, choose **Feishu**, switch to the **Manual** tab, enter App ID and App Secret, then click connect.
</Tab>
<Tab title="配置文件">
在 `config.json` 中添加以下配置后启动程序:
<Tab title="Config File">
Add the following to `config.json` and start the program:
```json
{
@@ -69,43 +68,43 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
}
```
| 参数 | 说明 | 默认值 |
| Parameter | Description | Default |
| --- | --- | --- |
| `feishu_app_id` | 飞书应用 App ID | - |
| `feishu_app_secret` | 飞书应用 App Secret | - |
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
| `feishu_app_id` | Feishu app App ID | - |
| `feishu_app_secret` | Feishu app App Secret | - |
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
</Tab>
</Tabs>
**步骤三:发布应用**
**Step 3: Publish the App**
1. 启动 Cow 项目后,在飞书开放平台点击 **事件与回调**,选择 **长连接** 模式并保存:
1. After Cow is running, go to **Events & Callbacks** in the Feishu Developer Platform, choose **Long Connection** mode and save:
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
## 二、功能说明
## 2. Features
| 功能 | 支持情况 |
| Feature | Status |
| --- | --- |
| 单聊 | ✅ |
| 群聊(@机器人) | ✅ |
| 文本消息 | ✅ 收发 |
| 图片消息 | ✅ 收发 |
| 语音消息 | ✅ 收发 |
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
| P2P chat | ✅ |
| Group chat (@bot) | ✅ |
| Text messages | ✅ send/receive |
| Image messages | ✅ send/receive |
| Voice messages | ✅ send/receive |
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
<Note>
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
</Note>
## 三、使用
## 3. Usage
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
After connection, search for the bot name in Feishu to start a chat.
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
To use in groups, add the bot to a group and @-mention it.

View File

@@ -1,39 +1,46 @@
---
title: 通道概览
description: CowAgent 支持的通道及能力矩阵
title: Channels Overview
description: Channels supported by CowAgent and their capability matrix
---
CowAgent 支持接入多种聊天通道,启动时通过 `channel_type` 切换。Web 控制台默认开启,可与其他接入通道并行运行。
CowAgent supports multiple chat channels. Switch between them at startup via `channel_type`. The Web Console is enabled by default and can run in parallel with other channels.
## 能力矩阵
## Capability Matrix
下表汇总各通道支持的入站消息类型、机器人回复类型与群聊能力,方便按场景选择。
The table below summarizes the inbound message types, bot reply types, and group chat capabilities supported by each channel, making it easy to choose by scenario.
| 通道 | 文本 | 图片 | 文件 | 语音 | 群聊 |
| Channel | Text | Image | File | Voice | Group Chat |
| --- | :-: | :-: | :-: | :-: | :-: |
| [微信](/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [Web 控制台](/channels/web) | ✅ | ✅ | ✅ | ✅ | |
| [飞书](/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [钉钉](/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [企微智能机器人](/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [WeChat](/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [Web Console](/channels/web) | ✅ | ✅ | ✅ | ✅ | |
| [Feishu](/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [DingTalk](/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [WeCom Bot](/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
| [企业微信应用](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [公众号](/channels/wechatmp) | ✅ | ✅ | | ✅ | |
| [WeCom App](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [Official Account](/channels/wechatmp) | ✅ | ✅ | | ✅ | |
| [WeChat Customer Service](/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
| [Telegram](/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Slack](/channels/slack) | ✅ | ✅ | ✅ | | ✅ |
| [Discord](/channels/discord) | ✅ | ✅ | ✅ | | ✅ |
- **图片 / 文件 / 语音**列表示通道支持收发对应消息类型,具体细节详见各通道文档
- **群聊**列指可识别并响应群消息
- The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details
- The **Group Chat** column indicates the ability to recognize and respond to group messages
<Tip>
每个通道的语音 / 图像能力依赖对应模型厂商的配置,详见 [模型概览](/models)。
The voice / image capabilities of each channel depend on the configuration of the corresponding model provider. See [Models Overview](/models/index) for details.
</Tip>
## 通道一览
## Channel List
- [Web 控制台](/channels/web) — 内置浏览器对话和管理面板,默认开启
- [微信](/channels/weixin) — 通过个人微信扫码登录
- [飞书](/channels/feishu) — 飞书自建机器人
- [钉钉](/channels/dingtalk) — 钉钉自建机器人
- [企微智能机器人](/channels/wecom-bot) — 企业微信智能机器人
- [QQ](/channels/qq) — QQ 官方机器人开放平台
- [企业微信应用](/channels/wecom) — 企业微信自建应用接入
- [公众号](/channels/wechatmp) — 微信公众号(订阅号 / 服务号)
- [Web Console](/channels/web) — built-in browser-based chat and management panel, enabled by default
- [WeChat](/channels/weixin) — log in via personal WeChat QR scan
- [Feishu](/channels/feishu) — Feishu custom bot
- [DingTalk](/channels/dingtalk) — DingTalk custom bot
- [WeCom Bot](/channels/wecom-bot) — WeCom AI Bot via WebSocket long connection
- [QQ](/channels/qq) — QQ Official Bot open platform
- [WeCom App](/channels/wecom) — WeCom custom app integration
- [Official Account](/channels/wechatmp) — WeChat Official Account (subscription / service)
- [Telegram](/channels/telegram) — global IM, 5-minute setup, no public IP needed
- [Slack](/channels/slack) — team collaboration IM, Socket Mode integration, no public IP needed
- [Discord](/channels/discord) — community IM, Gateway connection, no public IP needed

View File

@@ -1,44 +1,44 @@
---
title: QQ 机器人
description: CowAgent 接入 QQ 机器人(WebSocket 长连接模式)
title: QQ Bot
description: Connect CowAgent to QQ Bot (WebSocket long connection)
---
> 通过 QQ 开放平台的机器人接口接入 CowAgent支持 QQ 单聊、QQ 群聊(@机器人)、频道消息和频道私信,无需公网 IP使用 WebSocket 长连接模式。
> Connect CowAgent via QQ Open Platform's bot API, supporting QQ direct messages, group chats (@bot), guild channel messages, and guild DMs. No public IP required — uses WebSocket long connection.
<Note>
QQ 机器人通过 QQ 开放平台创建,使用 WebSocket 长连接接收消息,通过 OpenAPI 发送消息,无需公网 IP 和域名。
QQ Bot is created through the QQ Open Platform. It uses WebSocket long connection to receive messages and OpenAPI to send messages. No public IP or domain is required.
</Note>
## 一、创建 QQ 机器人
## 1. Create a QQ Bot
> 进入[QQ 开放平台](https://q.qq.com)QQ扫码登录如果未注册开放平台账号请先完成[账号注册](https://q.qq.com/#/register)
> Visit the [QQ Open Platform](https://q.qq.com), sign in with QQ. If you haven't registered, please complete [account registration](https://q.qq.com/#/register) first.
1.在 [QQ开放平台-机器人列表页](https://q.qq.com/#/apps),点击创建机器人:
1.Go to the [QQ Open Platform - Bot List](https://q.qq.com/#/apps), and click **Create Bot**:
<img src="https://cdn.link-ai.tech/doc/20260317162900.png" width="800"/>
2.填写机器人名称、头像等基本信息,完成创建:
2.Fill in the bot name, avatar, and other basic information to complete the creation:
<img src="https://cdn.link-ai.tech/doc/20260317163005.png" width="800"/>
3.点击进入机器人配置页面,选择**开发管理**菜单,完成以下步骤:
3.Enter the bot configuration page, go to **Development Management**, and complete the following steps:
- 复制并记录 **AppID**(机器人ID
- 生成并记录 **AppSecret**(机器人秘钥)
- Copy and save the **AppID** (Bot ID)
- Generate and save the **AppSecret** (Bot Secret)
<img src="https://cdn.link-ai.tech/doc/20260317164955.png" width="800"/>
## 二、配置和运行
## 2. Configuration and Running
### 方式一Web 控制台接入
### Option A: Web Console
启动 Cow项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **QQ 机器人**,填写上一步保存的 AppID AppSecret,点击接入即可。
Start the program and open the Web console (local access: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **QQ Bot**, fill in the AppID and AppSecret from the previous step, and click Connect.
<img src="https://cdn.link-ai.tech/doc/20260317165425.png" width="800"/>
### 方式二:配置文件接入
### Option B: Config File
`config.json` 中添加以下配置:
Add the following to your `config.json`:
```json
{
@@ -48,41 +48,41 @@ description: 将 CowAgent 接入 QQ 机器人WebSocket 长连接模式)
}
```
| 参数 | 说明 |
| Parameter | Description |
| --- | --- |
| `qq_app_id` | QQ 机器人的 AppID在开放平台开发管理中获取 |
| `qq_app_secret` | QQ 机器人的 AppSecret,在开放平台开发管理中获取 |
| `qq_app_id` | AppID of the QQ Bot, found in Development Management on the open platform |
| `qq_app_secret` | AppSecret of the QQ Bot, found in Development Management on the open platform |
配置完成后启动程序,日志显示 `[QQ] ✅ Connected successfully` 即表示连接成功。
After configuration, start the program. The log message `[QQ] ✅ Connected successfully` indicates a successful connection.
## 三、使用
## 3. Usage
在 QQ开放平台 - 管理 - **使用范围和人员** 菜单中使用QQ客户端扫描 "添加到群和消息列表" 的二维码即可开始与QQ机器人的聊天
In the QQ Open Platform, go to **Management → Usage Scope & Members**, scan the "Add to group and message list" QR code with your QQ client to start chatting with the bot:
<img src="https://cdn.link-ai.tech/doc/20260317165947.png" width="800"/>
对话效果:
Chat example:
<img src="https://cdn.link-ai.tech/doc/20260317171508.png" width="800"/>
## 四、功能说明
## 4. Supported Features
> 注意若需在群聊及频道中使用QQ机器人需完成发布上架审核并在使用范围配置权限使用范围。
> Note: To use the QQ bot in group chats and guild channels, you need to complete the publishing review and configure usage scope permissions.
| 功能 | 支持情况 |
| Feature | Status |
| --- | --- |
| QQ 单聊 | ✅ |
| QQ 群聊(@机器人) | ✅ |
| 频道消息(@机器人) | ✅ |
| 频道私信 | ✅ |
| 文本消息 | ✅ 收发 |
| 图片消息 | ✅ 收发(群聊和单聊) |
| 文件消息 | ✅ 发送(群聊和单聊) |
| 定时任务 | ✅ 主动推送(每月每用户限 4 条) |
| QQ Direct Messages | ✅ |
| QQ Group Chat (@bot) | ✅ |
| Guild Channel (@bot) | ✅ |
| Guild DM | ✅ |
| Text Messages | ✅ Send & Receive |
| Image Messages | ✅ Send & Receive (group & direct) |
| File Messages | ✅ Send (group & direct) |
| Scheduled Tasks | ✅ Active push (4 per user per month) |
## 五、注意事项
## 5. Notes
- **被动消息限制**QQ 单聊被动消息有效期为 60 分钟,每条消息最多回复 5 次QQ 群聊被动消息有效期为 5 分钟。
- **主动消息限制**:单聊和群聊每月主动消息上限为 4 条,在使用定时任务功能时需要注意这个限制
- **事件权限**:默认订阅 `GROUP_AND_C2C_EVENT`QQ群/单聊)和 `PUBLIC_GUILD_MESSAGES`(频道公域消息),如需其他事件类型请在开放平台申请权限。
- **Passive message limits**: QQ direct message replies are valid for 60 minutes (max 5 replies per message); group chat replies are valid for 5 minutes.
- **Active message limits**: Both direct and group chats have a monthly limit of 4 active messages. Keep this in mind when using the scheduled tasks feature.
- **Event permissions**: By default, `GROUP_AND_C2C_EVENT` (QQ group/direct) and `PUBLIC_GUILD_MESSAGES` (guild public messages) are subscribed. Apply for additional permissions on the open platform if needed.

118
docs/channels/slack.mdx Normal file
View File

@@ -0,0 +1,118 @@
---
title: Slack
description: Integrate CowAgent with a Slack App
---
> Integrate CowAgent into Slack via a Slack App in **Socket Mode**. Supports direct messages (DM) and channels (triggered by @mention or replying within a thread). Socket Mode uses a persistent WebSocket connection — no public IP or callback URL required, works out of the box.
## 1. Setup
### Step 1: Create a Slack App
1. Open the [Slack API apps page](https://api.slack.com/apps), click **Create New App** → **From scratch**.
2. Enter an **App Name** (e.g. `CowAgent`), pick the **Workspace** to install into, and create it.
### Step 2: Enable Socket Mode and get the App Token
1. In the left sidebar go to **Settings → Socket Mode** and turn on **Enable Socket Mode**.
2. You will be prompted to generate an **App-Level Token** with the `connections:write` scope. Save this token starting with `xapp-`.
<Tip>
Socket Mode receives events over a WebSocket connection, so you don't need to expose a public callback URL — ideal for local or intranet deployments.
</Tip>
### Step 3: Configure bot scopes and install
1. Go to **Features → OAuth & Permissions**, click **Add an OAuth Scope** under **Bot Token Scopes**, and add the following scopes one by one:
```
app_mentions:read
channels:history
chat:write
commands
files:read
files:write
groups:history
im:history
mpim:history
users:read
```
<Note>
`files:read` / `files:write` are used for sending/receiving images and files; omit them if you only need text conversations.
</Note>
2. Go to **Features → Event Subscriptions**, turn on **Enable Events**, and under **Subscribe to bot events** click **Add Bot User Event** to add:
```
app_mention
message.im
message.channels
```
<Note>
Add `message.groups` if you need to use the bot in private channels.
</Note>
3. Go to **Features → App Home**, enable **Messages Tab** under **Show Tabs**, and check **Allow users to send Slash commands and messages from the messages tab**. Otherwise the DM input box is disabled and users cannot message the bot.
4. Back in **OAuth & Permissions**, click **Install to Workspace**. After installing, copy the **Bot User OAuth Token** starting with `xoxb-`.
<Tip>
If the Slack client still shows "Sending messages to this app has been turned off", make sure you completed the App Home step above, then refresh or restart the Slack client (remove the app from your conversations and reopen it if needed).
</Tip>
### Step 4: Connect to CowAgent
<Tabs>
<Tab title="Web Console (Recommended)">
Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Slack**, paste the Bot Token (`xoxb-`) and App Token (`xapp-`), and click connect.
</Tab>
<Tab title="Config File">
Add the following to `config.json` and start Cow:
```json
{
"channel_type": "slack",
"slack_bot_token": "xoxb-xxxxxxxxxxxx",
"slack_app_token": "xapp-xxxxxxxxxxxx",
"slack_group_trigger": "mention_or_reply"
}
```
| Key | Description | Default |
| --- | --- | --- |
| `slack_bot_token` | Bot User OAuth Token, like `xoxb-...` | - |
| `slack_app_token` | App-Level Token (generated after enabling Socket Mode), like `xapp-...` | - |
| `slack_group_trigger` | Channel trigger: `mention_or_reply` (@ or reply in thread) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` |
</Tab>
</Tabs>
The integration is ready when you see logs like:
```
[Slack] Bot logged in as user_id=U0XXXXXXX, team=Txxxxxxxx
[Slack] ✅ Slack bot ready, listening for events
```
## 2. Capabilities
| Feature | Support |
| --- | --- |
| Direct message (DM) | ✅ |
| Channel (@bot / reply in thread) | ✅ |
| Text messages | ✅ send / receive |
| Image messages | ✅ send / receive |
| File messages | ✅ send / receive (PDF / Word / Excel, etc.) |
| Thread replies | ✅ replies are posted to the thread of the triggering message |
<Note>
Slack organizes conversations into threads. The bot posts replies into the thread of the triggering message, keeping channels tidy.
</Note>
## 3. Usage
Once connected:
- **Direct message (DM)**: find your App under **Apps** in the Slack sidebar and message it directly.
- **Channel**: invite the App into a channel (`/invite @your-app`), then trigger it with `@your-app hello`; continue the conversation by replying within the same thread.
When sending an image or file, you can **add a text caption** (description / question) in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically.

111
docs/channels/telegram.mdx Normal file
View File

@@ -0,0 +1,111 @@
---
title: Telegram
description: Integrate CowAgent with Telegram via the Bot API
---
> Integrate CowAgent into Telegram via the official Bot API. Supports private chat and group chat (triggered by @mention or replying to the bot). Uses Long Polling — no public IP required, works out of the box.
## 1. Setup
### Step 1: Create a Bot via BotFather
1. Open the official account [@BotFather](https://t.me/BotFather) in Telegram.
2. Send `/newbot` and follow the prompts:
- **Bot name** (display name, e.g. `My CowAgent Bot`)
- **Bot username** (must end with `bot`, e.g. `my_cowagent_bot`)
3. Once created, BotFather returns an **HTTP API Token** (e.g. `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`). Keep it safe.
<Tip>
The token is the password of your bot — never share it. If it leaks, send `/revoke` to `@BotFather` to reset it.
</Tip>
### Step 2: (Group chat only) Disable Privacy Mode
Skip this step if you only use private chat. Telegram bots run in **Privacy Mode** by default — in groups they can only see commands suffixed with `@bot` (e.g. `/start@your_bot`) and replies to bot messages; **plain `@bot hello` text messages are not delivered**, so the bot will appear unresponsive in groups.
Send the following to `@BotFather`:
1. `/setprivacy`
2. Pick the bot you just created
3. Choose `Disable`
<Note>
If the bot is still silent in groups after this, try removing it from the group and adding it back.
</Note>
### Step 3: Connect to CowAgent
<Tabs>
<Tab title="Web Console (Recommended)">
Open the Web Console (default `http://127.0.0.1:9899`), go to **Channels**, click **Add Channel**, choose **Telegram**, paste the Bot Token, and click connect.
</Tab>
<Tab title="Config File">
Add the following to `config.json` and start Cow:
```json
{
"channel_type": "telegram",
"telegram_token": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"telegram_group_trigger": "mention_or_reply"
}
```
| Key | Description | Default |
| --- | --- | --- |
| `telegram_token` | HTTP API Token returned by BotFather | - |
| `telegram_group_trigger` | Group trigger: `mention_or_reply` (@ or reply) / `mention_only` (@ only) / `all` (all messages) | `mention_or_reply` |
| `telegram_register_commands` | Whether to register the command menu with BotFather on startup | `true` |
</Tab>
</Tabs>
The integration is ready when you see logs like:
```
[Telegram] Bot logged in as @my_cowagent_bot (id=123456789)
[Telegram] Registered 10 bot commands
[Telegram] ✅ Telegram bot ready, polling for updates
```
## 2. Capabilities
| Feature | Support |
| --- | --- |
| Private chat | ✅ |
| Group chat (@bot / reply to bot) | ✅ |
| Text messages | ✅ send / receive |
| Image messages | ✅ send / receive |
| Voice messages | ✅ send / receive (OGG/Opus) |
| Video messages | ✅ send / receive |
| File messages | ✅ send / receive (PDF / Word / Excel, etc.) |
| Command menu | ✅ aligned with Web Console slash commands |
### Command Menu
On startup, the channel registers a command menu with BotFather. Typing `/` in Telegram shows a dropdown:
| Command | Description |
| --- | --- |
| `/help` | Show command help |
| `/status` | View runtime status |
| `/context` | View conversation context (`/context clear` to clear) |
| `/skill` | Skill management (`/skill list`, `/skill install`, ...) |
| `/memory` | Memory management (`/memory dream`) |
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
| `/config` | View current config |
| `/cancel` | Cancel the running Agent task |
| `/logs` | View recent logs |
| `/version` | Show version |
<Note>
Telegram's command menu only displays top-level commands; subcommands are entered with a space, e.g. `/skill list`, `/context clear`.
</Note>
## 3. Usage
Once connected:
- **Private chat**: search for your bot username (e.g. `@my_cowagent_bot`) in Telegram, click `Start` and chat away.
- **Group chat**: add the bot to a group, then trigger it with `@bot hello` or by **replying to one of the bot's messages**. If the bot doesn't respond in groups, double-check Privacy Mode in [Step 2](#step-2-group-chat-only-disable-privacy-mode).
When sending an image or file, you can **add a caption** (description / question) directly in the attachment input — the bot will answer based on both. Sending an attachment first and then a follow-up question also works; the two messages are merged automatically.

View File

@@ -1,11 +1,11 @@
---
title: Web 控制台
description: 通过 Web 控制台使用 CowAgent
title: Web Console
description: Use CowAgent through the Web Console
---
Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏览器即可与 Agent 对话,并支持在线管理模型、技能、记忆、通道等配置。
The Web Console is CowAgent's default channel. It runs automatically once started, letting you chat with the Agent in a browser and manage models, skills, memory, channels, and other configuration online.
## 配置
## Configuration
```json
{
@@ -17,78 +17,79 @@ Web 控制台是 CowAgent 的默认通道,启动后会自动运行,通过浏
}
```
| 参数 | 说明 | 默认值 |
| Parameter | Description | Default |
| --- | --- | --- |
| `channel_type` | 设为 `web` | `web` |
| `web_host` | Web 服务监听地址,默认监听 `127.0.0.1`(仅本机),如需公网访问请改为 `0.0.0.0` 并设置密码 | `""` |
| `web_port` | Web 服务监听端口 | `9899` |
| `web_password` | 访问密码,留空表示不启用密码保护;监听 `0.0.0.0` 时建议设置 | `""` |
| `web_session_expire_days` | 登录会话有效天数 | `30` |
| `enable_thinking` | 是否启用深度思考模式 | `false` |
| `channel_type` | Set to `web` | `web` |
| `web_host` | Web service listen address. Defaults to `127.0.0.1` (local only); set to `0.0.0.0` for public access and configure a password | `""` |
| `web_port` | Web service listen port | `9899` |
| `web_password` | Access password. Leave empty to disable password protection; recommended when listening on `0.0.0.0` | `""` |
| `web_session_expire_days` | Login session validity in days | `30` |
| `web_file_serve_root` | Root directory the web console can directly read/send files from. Defaults to the user home dir and agent workspace only; set to `/` to allow the whole filesystem | `"~"` |
| `enable_thinking` | Whether to enable deep thinking mode | `false` |
配置密码后,访问控制台时需先输入密码完成登录。登录状态默认保持 30 天,期间重启服务也无需重新登录。密码也支持在控制台的「配置」页面中在线修改。
Once a password is configured, you must enter it to log in when accessing the console. The login session is kept for 30 days by default, so restarting the service during that period does not require re-login. The password can also be changed online from the "Configuration" page in the console.
## 访问地址
## Access URL
启动项目后访问:
After starting the project, visit:
- 本地运行:`http://localhost:9899`
- 服务器运行:`http://<server-ip>:9899`
- Local: `http://localhost:9899`
- Server: `http://<server-ip>:9899`
<Note>
请确保服务器防火墙和安全组已放行对应端口。
Ensure the server firewall and security group allow the corresponding port.
</Note>
## 功能介绍
## Features
### 对话界面
### Chat Interface
支持流式输出,可实时展示 Agent 的思考过程Reasoning和工具调用过程Tool Calls),更直观地观察 Agent 的决策过程。深度思考功能可通过配置或控制台的「Agent 配置」开关控制。
Supports streaming output with real-time display of the Agent's reasoning process and tool calls, providing intuitive observation of the Agent's decision-making. Deep thinking can be toggled via configuration or the "Agent Configuration" switch in the console.
<img width="850" src="https://cdn.link-ai.tech/doc/20260227180120.png" />
#### 多会话管理
#### Multi-Session Management
对话界面支持多会话Session管理所有会话记录持久化存储在数据库中
The chat interface supports multi-session management. All session records are persistently stored in the database:
- **会话列表**:点击左侧历史会话图标可展开/收起会话列表面板,支持滚动加载全部历史会话
- **AI 生成标题**:新会话在首轮对话完成后,自动调用模型生成简短的会话摘要标题
- **新建会话**:点击会话列表顶部的「新对话」按钮或输入区的 `+` 按钮创建新会话
- **删除会话**:点击会话项的删除按钮,确认后永久删除该会话及其所有消息
- **清除上下文**:点击输入区的清除按钮,在当前会话中插入一条分隔线,分隔线以上的消息仍然展示但不再作为模型的上下文输入
- **Session List**: Click the history icon on the left to expand/collapse the session list panel, with scroll-to-load support for all historical sessions
- **AI-Generated Titles**: After the first exchange in a new session, the model is automatically called to generate a short summary title
- **New Session**: Click the "New Chat" button at the top of the session list or the `+` button in the input area to create a new session
- **Delete Session**: Click the delete button on a session item and confirm to permanently delete the session and all its messages
- **Clear Context**: Click the clear button in the input area to insert a divider in the current session. Messages above the divider are still displayed but no longer included as context for the model
### 模型管理
### Model Management
支持在线管理不同模型厂商的文本、图像、语音、向量模型配置,无需手动编辑配置文件:
Manage text, image, voice, and embedding model configurations for different providers online — no need to edit config files manually:
<img width="850" src="https://cdn.link-ai.tech/doc/20260521212949.png" />
### 技能管理
### Skill Management
支持在线查看和管理 Agent 技能Skills
View and manage Agent skills (Skills) online:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173403.png" />
### 记忆管理
### Memory Management
支持在线查看和管理 Agent 记忆:
View and manage Agent memory online:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173349.png" />
### 通道管理
### Channel Management
支持在线管理接入通道,支持实时连接/断开操作:
Manage connected channels online with real-time connect/disconnect operations:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173331.png" />
### 定时任务
### Scheduled Tasks
支持在线查看和管理定时任务包括一次性任务、固定间隔、Cron 表达式等多种调度方式的可视化管理:
View and manage scheduled tasks online, including one-time tasks, fixed intervals, and Cron expressions:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173704.png" />
### 日志
### Logs
支持在线实时查看 Agent 运行日志,便于监控运行状态和排查问题:
View Agent runtime logs in real time for monitoring and troubleshooting:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173514.png" />

130
docs/channels/wechat-kf.mdx Normal file
View File

@@ -0,0 +1,130 @@
---
title: WeChat Customer Service
description: Integrate CowAgent into WeChat Customer Service
---
By binding a WeCom custom enterprise app to a WeChat Customer Service account, CowAgent can take over inbound inquiries from external WeChat users and serve them through links or QR codes embedded in WeChat Mini Programs, Official Accounts, Video Channels, and Video Channel stores.
<Note>
WeChat Customer Service only supports Docker deployment or server Python deployment. A publicly reachable callback URL is required; local run mode is not supported.
</Note>
## 1. Prerequisites
Required resources:
1. A server with a public IP
2. A registered and verified WeCom account
3. WeChat Customer Service capability enabled
<Note>
It is recommended to create a **dedicated** WeCom custom app for Customer Service rather than reusing the existing `wechatcom_app` one — otherwise the two channels will compete for the same callback URL.
</Note>
## 2. Create a WeCom Custom App
1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#apps), go to **Application Management → Create Application**:
<img src="https://cdn.link-ai.tech/doc/20260228103156.png" width="480"/>
2. Click **My Enterprise** and find the **Corp ID** at the bottom of the page (it goes into `wechat_kf_corp_id`):
<img src="https://cdn.link-ai.tech/doc/wechatcom-hosting-companyid.png" width="600"/>
3. Open the app you just created and click **"View"** next to Secret. The Secret will be pushed to the admin's phone via the WeCom app, where it can be viewed:
<img src="https://cdn.link-ai.tech/doc/wechatcom-hosting-agent-secret.png" width="600"/>
4. Open the app's **Receive Messages → Set API Reception** page, click **"Random Generate"** to generate the **Token** and **EncodingAESKey**, and save them:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wechatcom-hosting-token-aeskey.jpg" width="600"/>
<Note>
Saving the API reception configuration will fail at this point because the program has not started yet. Come back to save it after the project is running.
</Note>
## 3. Configuration and Run
Fill in the 4 fields collected from the previous step (Corp ID / Secret / Token / EncodingAESKey):
<Tabs>
<Tab title="Web Console">
Start the Cow project and open the Web Console. Go to the **Channels** menu, click **Connect**, choose **WeChat Customer Service**, fill in Corp ID / Secret / Token / AES Key (port defaults to 9888, configurable), and click Connect.
<img src="https://cdn.link-ai.tech/doc/cow-weixinkefu-web-control.png" width="800"/>
</Tab>
<Tab title="Config File">
Add the following configuration to `config.json` (each parameter maps to a field shown in the screenshots above):
```json
{
"channel_type": "wechat_kf",
"wechat_kf_corp_id": "YOUR_CORP_ID",
"wechat_kf_secret": "YOUR_SECRET",
"wechat_kf_token": "YOUR_TOKEN",
"wechat_kf_aes_key": "YOUR_AES_KEY",
"wechat_kf_port": 9888
}
```
| Parameter | Description |
| --- | --- |
| `wechat_kf_corp_id` | Corp ID |
| `wechat_kf_secret` | Secret of the WeCom custom app bound to Customer Service |
| `wechat_kf_token` | Token from the API reception config |
| `wechat_kf_aes_key` | EncodingAESKey from the API reception config |
| `wechat_kf_port` | Listening port, default 9888 |
</Tab>
</Tabs>
After connecting, start the program (the Web Console method restarts the channel automatically). When the log shows `Listening on http://0.0.0.0:9888/wxkf/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group).
Then go back to **Receive Messages → Set API Reception** in the WeCom console and set the callback URL to `http://<your-host>:9888/wxkf/`, then click Save. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received:
<img src="https://cdn.link-ai.tech/doc/wechat-com_config.png" width="600"/>
<img src="https://cdn.link-ai.tech/doc/20260228103224.png" width="520"/>
<Warning>
If URL verification fails or the configuration is unsuccessful:
1. Ensure the server firewall is disabled and the security group allows the listening port (default 9888)
2. Carefully check that Token, Secret, EncodingAESKey and other parameters are consistent, and the URL format is correct
3. Verified WeCom accounts must use a filed domain matching the entity
</Warning>
## 4. Bind a WeChat Customer Service Account
In the WeCom Admin Console, go to **WeChat Customer Service**, create a customer service account, and bind it to the custom app you created above:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step1.jpg" width="600"/>
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step2.jpg" width="600"/>
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-step3.jpg" width="600"/>
After binding, go to **WeChat Customer Service → Account Details**, and under **"Access Link"**:
- Click **"Copy Link"** to get an access link like `https://work.weixin.qq.com/kfid/kfcd83e5896b9ba07be`
- Click **"Generate QR Code"** to get the corresponding QR code
Distribute the link or QR code to your WeChat customers:
<img src="https://cdn.link-ai.tech/doc/wechat-customer-service_use.png" width="600"/>
## 5. Usage
After WeChat users enter the customer service conversation via the link or QR code, they can chat with the AI across multiple turns, with support for text, image, and voice messages:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-chat-demo.jpg" width="900"/>
Beyond that, leveraging the official WeChat ecosystem, WeChat Customer Service can also be embedded into Official Accounts, Mini Programs, Video Channels and more. See the **WeChat Customer Service → Access Scenarios** section in the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#/app/servicer) for details:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/wxcustomer-hosting-interface-demo.png" width="800"/>
## FAQ
Make sure the following dependencies are installed:
```bash
pip install websocket-client pycryptodome
```

View File

@@ -1,22 +1,22 @@
---
title: 微信公众号
description: CowAgent 接入微信公众号
title: WeChat Official Account
description: Integrate CowAgent with WeChat Official Accounts
---
CowAgent 支持接入个人订阅号和企业服务号两种公众号类型。
CowAgent supports both personal subscription accounts and enterprise service accounts.
| 类型 | 要求 | 特点 |
| Type | Requirements | Features |
| --- | --- | --- |
| **个人订阅号** | 个人可申请 | 收到消息时会回复一条提示,回复生成后需用户主动发消息获取 |
| **企业服务号** | 企业申请,需通过微信认证开通客服接口 | 回复生成后可主动推送给用户 |
| **Personal Subscription** | Available to individuals | Sends a placeholder reply first; users must send a message to retrieve the full response |
| **Enterprise Service** | Enterprise with verified customer service API | Can proactively push replies to users |
<Note>
公众号仅支持服务器和 Docker 部署,不支持本地运行。需额外安装扩展依赖:`pip3 install -r requirements-optional.txt`
Official Accounts only support server and Docker deployment, not local run mode. Install extended dependencies: `pip3 install -r requirements-optional.txt`
</Note>
## 一、个人订阅号
## 1. Personal Subscription Account
`config.json` 中添加以下配置:
Add the following configuration to `config.json`:
```json
{
@@ -30,34 +30,34 @@ CowAgent 支持接入个人订阅号和企业服务号两种公众号类型。
}
```
### 配置步骤
### Setup Steps
这些配置需要和 [微信公众号后台](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev) 中的保持一致,进入页面后,在左侧菜单选择 **设置与开发 → 基本配置 → 服务器配置**,按下图进行配置:
These configurations must be consistent with the [WeChat Official Account Platform](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev). Navigate to **Settings & Development → Basic Configuration → Server Configuration** and configure as shown below:
<img src="https://cdn.link-ai.tech/doc/20260228103506.png" width="480"/>
1. 在公众平台启用开发者密码(对应配置 `wechatmp_app_secret`),并将服务器 IP 填入白名单
2. 按上图填写 `config.json` 中与公众号相关的配置,要与公众号后台的配置一致
3. 启动程序,启动后会监听 80 端口(若无权限监听,则在启动命令前加上 `sudo`;若 80 端口已被占用,则关闭该占用进程)
4. 在公众号后台 **启用服务器配置** 并提交,保存成功则表示已成功配置。注意 **"服务器地址(URL)"** 需要配置为 `http://{HOST}/wx` 的格式,其中 `{HOST}` 可以是服务器的 IP 或域名
1. Enable the developer secret on the platform (corresponds to `wechatmp_app_secret`), and add the server IP to the whitelist
2. Fill in the `config.json` with the official account parameters matching the platform configuration
3. Start the program, which listens on port 80 (use `sudo` if you don't have permission; stop any process occupying port 80)
4. **Enable server configuration** on the official account platform and submit. A successful save means the configuration is complete. Note that the **"Server URL"** must be in the format `http://{HOST}/wx`, where `{HOST}` can be the server IP or domain
随后关注公众号并发送消息即可看到以下效果:
After following the account and sending a message, you should see the following result:
<img src="https://cdn.link-ai.tech/doc/20260228103522.png" width="720"/>
由于受订阅号限制回复内容较短的情况下15s 内),可以立即完成回复,但耗时较长的回复则会先回复一句 "正在思考中",后续需要用户输入任意文字主动获取答案,而服务号则可以通过客服接口解决这一问题。
Due to subscription account limitations, short replies (within 15s) can be returned immediately, but longer replies will first send a "Thinking..." placeholder, requiring users to send any text to retrieve the answer. Enterprise service accounts can solve this with the customer service API.
<Tip>
**语音识别**:可利用微信自带的语音识别功能,需要在公众号管理页面的 "设置与开发 → 接口权限" 页面开启 "接收语音识别结果"。
**Voice Recognition**: You can use WeChat's built-in voice recognition. Enable "Receive Voice Recognition Results" under "Settings & Development → API Permissions" on the official account management page.
</Tip>
## 二、企业服务号
## 2. Enterprise Service Account
企业服务号与上述个人订阅号的接入过程基本相同,差异如下:
The setup process for enterprise service accounts is essentially the same as personal subscription accounts, with the following differences:
1. 在公众平台申请企业服务号并完成微信认证,在接口权限中确认已获得 **客服接口** 的权限
2. 在 `config.json` 中设置 `"channel_type": "wechatmp_service"`,其他配置与上述订阅号相同
3. 交互效果上,即使是较长耗时的回复,也可以主动推送给用户,无需用户手动获取
1. Register an enterprise service account on the platform and complete WeChat certification. Confirm that the **Customer Service API** permission has been granted
2. Set `"channel_type": "wechatmp_service"` in `config.json`; other configurations remain the same
3. Even for longer replies, they can be proactively pushed to users without requiring manual retrieval
```json
{

View File

@@ -1,56 +1,56 @@
---
title: 企微智能机器人
description: CowAgent 接入企业微信智能机器人(长连接模式)
title: WeCom Bot
description: Connect CowAgent to WeCom AI Bot (WebSocket long connection)
---
> 通过企业微信智能机器人接入CowAgent支持企业内部单聊和内部群聊无需公网 IP使用 WebSocket 长连接模式支持Markdown渲染和流式输出。
> Connect CowAgent via WeCom AI Bot, supporting both internal direct messages and group chats. No public IP required — uses a WebSocket long connection, with Markdown rendering and streaming output.
<Note>
智能机器人与企业微信自建应用是两种不同的接入方式。智能机器人使用 WebSocket 长连接,无需服务器公网 IP 和域名,配置更简单。
WeCom Bot and WeCom App are two different integration methods. WeCom Bot uses a WebSocket long connection and requires no public IP or domain, making setup much simpler.
</Note>
## 一、接入方式
## 1. Connection methods
### 方式一:扫码一键接入(推荐)
### Option A: One-click QR scan (recommended)
无需提前创建机器人,启动 Cow 项目后打开 Web 控制台本地链接http://127.0.0.1:9899/),选择 **通道** 菜单,点击**接入通道**,选择**企微智能机器人**,切换到「扫码接入」模式,使用**企业微信**扫码即可自动完成机器人创建和接入。
No need to create the bot ahead of time. Start CowAgent and open the Web console (local URL: http://127.0.0.1:9899/), go to the **Channels** tab, click **Connect Channel**, choose **WeCom Bot**, switch to **QR scan** mode, and scan the QR code with **WeCom** — bot creation and connection complete automatically.
<img src="https://cdn.link-ai.tech/doc/20260401121213.png" width="800"/>
<Note>
扫码成功后,可在企业微信工作台 - **智能机器人**页面对机器人进行进一步配置,包括修改名称、头像、可见范围等。
After a successful scan, you can further configure the bot (name, avatar, visibility scope, etc.) in **WeCom Workbench → AI Bot**.
</Note>
### 方式二:手动创建接入
### Option B: Manual creation
需要先在企业微信中创建智能机器人并获取 Bot ID Secret,再通过 Web 控制台或配置文件接入。
Create the AI Bot in WeCom and obtain the Bot ID and Secret, then connect via the Web console or config file.
**步骤一:创建智能机器人**
**Step 1: Create the AI Bot**
1. 打开企业微信客户端,进入工作台,点击**智能机器人**
1. Open the WeCom client, go to **Workbench**, and click **AI Bot**:
<img src="https://cdn.link-ai.tech/doc/20260316180959.png" width="800"/>
2. 点击创建机器人 - 手动创建:
2. Click **Create Bot → Manual Creation**:
<img src="https://cdn.link-ai.tech/doc/20260316181118.png" width="800"/>
3. 右侧窗口拖到最下方,选择**API模式创建**
3. Scroll to the bottom of the right panel and select **API Mode**:
<img src="https://cdn.link-ai.tech/doc/20260316181215.png" width="800"/>
4. 设置机器人名称、头像、可见范围,并选择**长连接模式**,记录下 **Bot ID** **Secret** 信息后点击保存。
4. Set the bot name, avatar, and visibility scope. Choose **Long Connection** mode, save the **Bot ID** and **Secret**, then click Save.
**步骤二:接入 CowAgent**
**Step 2: Connect to CowAgent**
<Tabs>
<Tab title="Web 控制台">
打开 Web 控制台,选择**通道**菜单,点击**接入通道**,选择**企微智能机器人**,切换到「手动填写」模式,输入 Bot ID Secret,点击接入即可。
<Tab title="Web Console">
Open the Web console, go to the **Channels** tab, click **Connect Channel**, choose **WeCom Bot**, switch to **Manual** mode, enter the Bot ID and Secret, and click Connect.
<img src="https://cdn.link-ai.tech/doc/20260316181711.png" width="800"/>
</Tab>
<Tab title="配置文件">
在 `config.json` 中添加以下配置后启动程序:
<Tab title="Config File">
Add the following to `config.json`, then start CowAgent:
```json
{
@@ -60,31 +60,31 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
}
```
| 参数 | 说明 |
| Parameter | Description |
| --- | --- |
| `wecom_bot_id` | 智能机器人的 BotID |
| `wecom_bot_secret` | 智能机器人的 Secret |
| `wecom_bot_id` | Bot ID of the AI Bot |
| `wecom_bot_secret` | Secret of the AI Bot |
</Tab>
</Tabs>
日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
The log line `[WecomBot] Subscribe success` confirms the connection is established.
## 二、功能说明
## 2. Supported features
| 功能 | 支持情况 |
| Feature | Status |
| --- | --- |
| 单聊 | ✅ |
| 群聊(@机器人) | ✅ |
| 文本消息 | ✅ 收发 |
| 图片消息 | ✅ 收发 |
| 文件消息 | ✅ 收发 |
| 流式回复 | ✅ |
| 定时任务主动推送 | ✅ |
| Direct chat | ✅ |
| Group chat (@bot) | ✅ |
| Text messages | ✅ Send / Receive |
| Image messages | ✅ Send / Receive |
| File messages | ✅ Send / Receive |
| Streaming replies | ✅ |
| Scheduled push messages | ✅ |
## 三、使用
## 3. Usage
在企业微信中搜索创建的机器人名称,即可开始单聊对话。
Search for the bot's name inside WeCom to start a direct chat.
如需在企微内部群聊中使用,将机器人添加到群中,@机器人发送消息即可。
To use the bot in an internal group chat, add it to the group and @-mention it.
<img src="https://cdn.link-ai.tech/doc/20260316182902.png" width="800"/>

View File

@@ -1,48 +1,48 @@
---
title: 企微自建应用
description: CowAgent 接入企业微信自建应用
title: WeCom
description: Integrate CowAgent into WeCom enterprise app
---
通过企业微信自建应用接入 CowAgent支持企业内部人员单聊使用。
Integrate CowAgent into WeCom through a custom enterprise app, supporting one-on-one chat for internal employees.
<Note>
企业微信只能使用 Docker 部署或服务器 Python 部署,不支持本地运行模式。
WeCom only supports Docker deployment or server Python deployment. Local run mode is not supported.
</Note>
## 一、准备
## 1. Prerequisites
需要的资源:
Required resources:
1. 一台服务器(有公网 IP
2. 注册一个企业微信(个人也可注册,但无法认证)
3. 认证企业微信还需要对应主体备案的域名
1. A server with public IP (overseas server, or domestic server with a proxy for international API access)
2. A registered WeCom account (individual registration is possible but cannot be certified)
3. Certified WeCom accounts additionally require a domain filed under the corresponding entity
## 二、创建企业微信应用
## 2. Create WeCom App
1. 在 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#profile) 点击 **我的企业**,在最下方获取 **企业ID**(后续填写到 `wechatcom_corp_id` 字段中)。
1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#profile), click **My Enterprise** and find the **Corp ID** at the bottom of the page. Save this ID for the `wechatcom_corp_id` configuration field.
2. 切换到 **应用管理**,点击创建应用:
2. Switch to **Application Management** and click Create Application:
<img src="https://cdn.link-ai.tech/doc/20260228103156.png" width="480"/>
3. 进入应用创建页面,记录 `AgentId` `Secret`
3. On the application creation page, record the `AgentId` and `Secret`:
<img src="https://cdn.link-ai.tech/doc/20260228103218.png" width="580"/>
4. 点击 **设置API接收**,配置应用接口:
4. Click **Set API Reception** to configure the application interface:
<img src="https://cdn.link-ai.tech/doc/20260228103211.png" width="520"/>
- URL 格式为 `http://ip:port/wxcomapp`(认证企业需使用备案域名)
- 随机获取 `Token` `EncodingAESKey` 并保存
- URL format: `http://ip:port/wxcomapp` (certified enterprises must use a filed domain)
- Generate random `Token` and `EncodingAESKey` and save them for the configuration file
<Note>
此时保存 API 接收配置会失败,因为程序还未启动,等项目运行后再回来保存。
The API reception configuration cannot be saved at this point because the program hasn't started yet. Come back to save it after the project is running.
</Note>
## 三、配置和运行
## 3. Configuration and Run
在 `config.json` 中添加以下配置(各参数与企业微信后台的对应关系见上方截图):
Add the following configuration to `config.json` (the mapping between each parameter and the WeCom console is shown in the screenshots above):
```json
{
@@ -57,41 +57,41 @@ description: 将 CowAgent 接入企业微信自建应用
}
```
| 参数 | 说明 |
| Parameter | Description |
| --- | --- |
| `wechatcom_corp_id` | 企业 ID |
| `wechatcomapp_token` | API 接收配置中的 Token |
| `wechatcomapp_secret` | 应用的 Secret |
| `wechatcomapp_agent_id` | 应用的 AgentId |
| `wechatcomapp_aes_key` | API 接收配置中的 EncodingAESKey |
| `wechatcomapp_port` | 监听端口,默认 9898 |
| `wechatcom_corp_id` | Corp ID |
| `wechatcomapp_token` | Token from API reception config |
| `wechatcomapp_secret` | App Secret |
| `wechatcomapp_agent_id` | App AgentId |
| `wechatcomapp_aes_key` | EncodingAESKey from API reception config |
| `wechatcomapp_port` | Listen port, default 9898 |
配置完成后启动程序。当后台日志显示 `http://0.0.0.0:9898/` 时说明程序运行成功,需要将该端口对外开放(如在云服务器安全组中放行)。
After configuration, start the program. When the log shows `http://0.0.0.0:9898/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group).
程序启动后,回到企业微信后台保存 **消息服务器配置**,保存成功后还需将服务器 IP 添加到 **企业可信IP** 中,否则无法收发消息:
After the program starts, return to the WeCom Admin Console to save the **Message Server Configuration**. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received:
<img src="https://cdn.link-ai.tech/doc/20260228103224.png" width="520"/>
<Warning>
如遇到 URL 配置回调不通过或配置失败:
1. 确保服务器防火墙关闭且安全组放行监听端口
2. 仔细检查 TokenSecret Key 等参数配置是否一致URL 格式是否正确
3. 认证企业微信需要配置与主体一致的备案域名
If the URL configuration callback fails or the configuration is unsuccessful:
1. Ensure the server firewall is disabled and the security group allows the listening port
2. Carefully check that Token, Secret Key and other parameter configurations are consistent, and that the URL format is correct
3. Certified WeCom accounts must configure a filed domain matching the entity
</Warning>
## 四、使用
## 4. Usage
在企业微信中搜索刚创建的应用名称,即可直接对话:
Search for the app name you just created in WeCom to start chatting directly. You can run multiple instances listening on different ports to create multiple WeCom apps:
<img src="https://cdn.link-ai.tech/doc/20260228103228.png" width="720"/>
如需让外部个人微信用户使用,可在 **我的企业 → 微信插件** 中分享邀请关注二维码,个人微信扫码关注后即可与应用对话:
To allow external personal WeChat users to use the app, go to **My Enterprise → WeChat Plugin**, share the invite QR code. After scanning and following, personal WeChat users can join and chat with the app:
<img src="https://cdn.link-ai.tech/doc/20260228103232.png" width="520"/>
## 常见问题
## FAQ
需要确保已安装以下依赖:
Make sure the following dependencies are installed:
```bash
pip install websocket-client pycryptodome

View File

@@ -1,21 +1,21 @@
---
title: 微信
description: CowAgent 接入个人微信(基于官方接口)
title: WeChat
description: Connect CowAgent to personal WeChat (via the official API)
---
> 接入个人微信扫码登录即可使用支持文本、图片、语音、文件、视频等消息的私聊收发。通过微信官方API进行接入无安全风险接入后会在会话中新增一个机器人助手不影响当前账号的使用。
> Connect CowAgent to your personal WeChat — scan to log in, no public IP required. Supports text, image, voice, file, and video messages in 1-on-1 chats. Backed by WeChat's official API; safe to use. After connecting, a bot assistant is added to your conversation list without affecting normal account usage.
## 一、配置和运行
## 1. Setup and run
### 方式一Web 控制台接入
### Option A: Web console
启动 Cow 项目后打开 Web 控制台 (本地链接为: http://127.0.0.1:9899/ ),选择 **通道** 菜单,点击 **接入通道**,选择 **微信**,点击接入后按照提示扫码登录。
Start CowAgent and open the Web console (local URL: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **WeChat**, and follow the prompts to scan in.
<img src="https://cdn.link-ai.tech/doc/20260322195114.png" width="800" />
### 方式二:配置文件接入
### Option B: Config file
在 `config.json` 中设置 `channel_type` `weixin`
Set `channel_type` to `weixin` in `config.json`:
```json
{
@@ -23,52 +23,49 @@ description: 将 CowAgent 接入个人微信(基于官方接口)
}
```
启动程序后,终端会显示二维码,使用微信扫码授权即可完成登录。
After starting CowAgent, a QR code is displayed in the terminal. Scan it with WeChat to complete login.
<img src="https://cdn.link-ai.tech/doc/20260322195509.png" width="800" />
<Note>
1. 兼容历史配置:`channel_type` 设为 `wx` 同样可以启用微信通道。
2. 注意微信客户端需要更新至 8.0.69 版本或以上
1. For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel.
2. The WeChat client must be on version **8.0.69** or higher.
</Note>
## 二、使用说明
## 2. Usage
微信扫码并进行授权确认后,即可完成接入并开始对话。接入微信后会在对话中创建出一个机器人助理,不会对已有账号的正常使用有任何影响。
Once authorized, the integration completes and you can start chatting. A bot assistant is created in your WeChat conversation list, leaving normal account usage unaffected.
> 你可以通过搜索"微信ClawBot"随时找到这个机器人,还可以修改这个机器人的头像、备注等信息,将机器人置顶在消息列表等。
> You can find the bot at any time by searching for **"微信ClawBot"**. You may also rename it, change its avatar, pin it to the top of your conversation list, and so on.
<img src="https://cdn.link-ai.tech/doc/83ae8251d896219fde4803f4205205be.jpg" width="250" />
## 3. Login
### QR code login
## 三、登录说明
On first startup, a QR code appears in the terminal (valid for around 2 minutes). Scan it with WeChat and confirm on your phone to log in.
### 扫码登录
- The QR code refreshes automatically when it expires
- The `qrcode` dependency is already included in `requirements.txt`, so the QR code renders directly in the terminal after install
首次启动时,终端会显示一个二维码(有效期约 2 分钟)。使用微信扫描二维码并在手机上确认后即可完成登录。
### Credential persistence
- 二维码过期后会自动刷新并重新显示
- `requirements.txt` 中已默认包含 `qrcode` 依赖,安装后可在终端直接渲染二维码图案
After a successful login, credentials are saved to `~/.weixin_cow_credentials.json`. Subsequent startups reuse the saved credentials with no need to re-scan.
### 凭证保存
To force a re-login, delete the credentials file and restart.
登录成功后,凭证会自动保存至 `~/.weixin_cow_credentials.json`,下次启动时无需重新扫码。
### Session expiry
如需重新登录,删除该凭证文件后重启程序即可。
When the WeChat session expires (errcode `-14`), CowAgent automatically clears old credentials and initiates a new QR login — no manual intervention required.
### Session 过期
## 4. Supported features
当微信 session 过期时errcode -14程序会自动清除旧凭证并重新发起扫码登录无需手动干预。
## 四、功能说明
| 功能 | 支持情况 |
| Feature | Status |
| --- | --- |
| 单聊 | ✅ |
| 文本消息 | ✅ 收发 |
| 图片消息 | ✅ 收发 |
| 文件消息 | ✅ 收发 |
| 视频消息 | ✅ 收发 |
| 语音消息 | ✅ 接收 (自带语音识别) |
| Direct messages | ✅ |
| Text messages | ✅ Send & Receive |
| Image messages | ✅ Send & Receive |
| File messages | ✅ Send & Receive |
| Video messages | ✅ Send & Receive |
| Voice messages | ✅ Receive (built-in speech recognition) |

View File

@@ -1,17 +1,17 @@
---
title: 常用命令
description: 查看状态、管理配置和上下文等常用命令
title: General Commands
description: View status, manage config, and control context with commonly used commands
---
以下命令支持在对话中使用 `/` 前缀,也支持在终端中使用 `cow` 前缀(部分命令仅对话可用)。
The following commands can be used in chat with the `/` prefix or in the terminal with the `cow` prefix (some are chat-only).
<Tip>
在 Web 控制台中输入 `/` 会自动弹出命令提示,支持键盘上下选择和 Tab 补全。
In the Web console, typing `/` brings up an autocomplete menu with keyboard navigation and Tab completion.
</Tip>
## help
显示所有可用命令的帮助信息。
Show help information for all available commands.
```text
/help
@@ -19,89 +19,83 @@ description: 查看状态、管理配置和上下文等常用命令
## status
查看当前会话和服务的运行状态,包括进程信息、模型配置、会话消息数量和已加载技能数量。
View current session and service status, including process info, model configuration, message count, and loaded skills.
```text
/status
```
输出示例:
## cancel
```
🐮 CowAgent Status
Abort the agent task currently running in this session. When the agent is busy with a long task (e.g. multi-turn tool calls or a long streaming response), send `/cancel` and the agent will stop before the next tool execution. Available across all channels — Web, WeChat, WeCom, Feishu, etc.
Process: PID 12345 | Running 2h 15m
Version: 2.0.4
Channel: web
Model: MiniMax-M2.5
Mode: agent
Session: 12 messages | 8 skills loaded
```text
/cancel
```
## config
查看或修改运行时配置。修改后立即生效,无需重启服务。
View or modify runtime configuration. Changes take effect immediately without restarting.
**查看所有可配置项:**
**View all configurable items:**
```text
/config
```
**查看单个配置项:**
**View a single item:**
```text
/config model
```
**修改配置项:**
**Modify a config item:**
```text
/config model deepseek-v4-flash
```
**支持修改的配置项:**
**Configurable items:**
| 配置项 | 说明 | 示例值 |
| Item | Description | Example |
| --- | --- | --- |
| `model` | AI 模型名称 | `deepseek-v4-flash` |
| `agent_max_context_tokens` | 最大上下文 tokens | `40000` |
| `agent_max_context_turns` | 最大上下文记忆轮次 | `30` |
| `agent_max_steps` | 单次任务最大决策步数 | `15` |
| `enable_thinking` | 是否启用深度思考模式 | `true` / `false` |
| `model` | AI model name | `deepseek-v4-flash` |
| `agent_max_context_tokens` | Max context tokens | `40000` |
| `agent_max_context_turns` | Max context memory turns | `30` |
| `agent_max_steps` | Max decision steps per task | `15` |
| `enable_thinking` | Enable deep thinking mode | `true` / `false` |
<Note>
修改 `model` 时,系统会自动匹配对应的模型调用方式。配置会写入 `config.json` 并持久保存。
When changing `model`, the system automatically matches the corresponding model API. Configuration is persisted to `config.json`.
</Note>
## context
查看当前会话的上下文信息,包括消息数量、内容长度等统计。
View current session context statistics, including message count and content length.
```text
/context
```
**清空当前会话上下文:**
**Clear current session context:**
```text
/context clear
```
<Tip>
清空上下文后Agent 会"忘记"之前的对话内容,适用于切换话题或释放上下文空间。
Clearing context makes the Agent "forget" previous conversation, useful for switching topics or freeing context space.
</Tip>
## logs
查看最近的服务日志,默认显示最近 20 行,最多 50 行。
View recent service logs. Shows the last 20 lines by default, up to 50.
```text
/logs
```
**指定行数:**
**Specify line count:**
```text
/logs 50
@@ -109,7 +103,7 @@ Session: 12 messages | 8 skills loaded
## version
显示当前 CowAgent 版本号。
Show the current CowAgent version.
```text
/version

View File

@@ -1,31 +1,31 @@
---
title: 命令总览
description: CowAgent 命令系统 — 终端 CLI 和对话命令
title: Commands Overview
description: CowAgent command system — Terminal CLI and chat commands
---
CowAgent 提供两种命令交互方式:
CowAgent provides two ways to interact via commands:
- **终端CLI** — 在系统终端中执行 `cow <命令>`,用于服务管理、技能管理等运维操作
- **对话命令** — 在对话中输入 `/<命令>` `cow <命令>`,用于查看状态、管理技能、调整配置等
- **Terminal CLI** — Run `cow <command>` in your system terminal for service management, skill management, and other operations
- **Chat Commands** — Type `/<command>` or `cow <command>` in any conversation to check status, manage skills, adjust configuration, etc.
## 终端命令
## Cow CLI
通过一键安装脚本部署后,`cow` 命令会自动可用。手动安装的用户需要在项目根目录下额外执行:
After deploying with the one-click install script, the `cow` command is automatically available. For manual installations, run:
```bash
pip install -e .
```
安装后即可在任意位置使用 `cow` 命令:
Then use the `cow` command from anywhere:
```bash
cow help
```
输出示例:
Example output:
```
CowAgent CLI
🐮 CowAgent CLI
Usage: cow <command>
@@ -49,48 +49,48 @@ Others:
version Show version
```
## 对话命令
## Chat Commands
在 Web 控制台或任意接入渠道的对话中,支持输入以 `/` 开头的命令:
In the Web console or any connected channel, type `/` to see command suggestions. Supported commands:
| 命令 | 说明 |
| Command | Description |
| --- | --- |
| `/help` | 显示命令帮助 |
| `/status` | 查看服务状态和配置 |
| `/config` | 查看或修改运行时配置 |
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30 |
| `/knowledge` | 查看知识库统计信息 |
| `/knowledge list` | 查看知识库目录结构 |
| `/knowledge on\|off` | 开启或关闭知识库 |
| `/context` | 查看当前会话上下文信息 |
| `/context clear` | 清空当前会话上下文 |
| `/logs` | 查看最近日志 |
| `/version` | 显示版本号 |
| `/help` | Show command help |
| `/status` | View service status and configuration |
| `/cancel` | Abort the currently running agent task |
| `/config` | View or modify runtime configuration |
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |
| `/knowledge` | View knowledge base statistics |
| `/knowledge list` | View knowledge base directory structure |
| `/knowledge on\|off` | Enable or disable knowledge base |
| `/context` | View current session context info |
| `/context clear` | Clear current session context |
| `/logs` | View recent logs |
| `/version` | Show version number |
<Tip>
对话命令中 `/start``/stop``/restart` 等服务管理命令会提示到终端中执行,因为它们涉及进程操作。
Service management commands like `/start`, `/stop`, `/restart` will prompt you to use them in the terminal instead, as they involve process operations.
</Tip>
## 命令对照表
## Command Availability
以下是各命令在终端和对话中的可用性:
| 命令 | 终端 (`cow`) | 对话 (`/`) |
| Command | Terminal (`cow`) | Chat (`/`) |
| --- | :---: | :---: |
| help | ✓ | ✓ |
| version | ✓ | ✓ |
| status | ✓ | ✓ |
| logs | ✓ | ✓ |
| cancel | ✗ | ✓ |
| config | ✗ | ✓ |
| context | — | ✓ |
| memory (子命令) | ✗ | ✓ |
| knowledge (子命令) | ✓ | ✓ |
| skill (子命令) | ✓ | ✓ |
| memory (subcommands) | ✗ | ✓ |
| knowledge (subcommands) | ✓ | ✓ |
| skill (subcommands) | ✓ | ✓ |
| start / stop / restart | ✓ | ✗ |
| update | ✓ | ✗ |
| install-browser | ✓ | ✗ |
<Note>
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
</Note>

View File

@@ -1,63 +1,49 @@
---
title: 记忆与知识库
description: 记忆蒸馏和知识库管理命令
title: Memory & Knowledge
description: Memory distillation and knowledge base management commands
---
## memory
管理 Agent 的长期记忆系统。
Manage the Agent's long-term memory system.
### memory dream
手动触发记忆蒸馏Deep Dream整理近期的天级记忆蒸馏合并到 MEMORY.md并生成梦境日记。
Manually trigger memory distillation (Deep Dream) — consolidate recent daily memories into MEMORY.md and generate a dream diary.
```text
/memory dream [N]
```
- `N`:整理近 N 天的记忆,默认 3 天,最大 30
- 蒸馏在后台异步执行,完成后会在对话中通知结果
- 无需等待 Agent 初始化,首次对话前即可使用
- `N`: Consolidate the last N days of memory (default 3, max 30)
- Runs asynchronously in the background; you'll be notified in chat when complete
- Works without Agent initialization — can be used before the first conversation
**示例:**
**Examples:**
```text
/memory dream # 整理近 3 天
/memory dream 7 # 整理近 7 天
/memory dream 30 # 整理近 30 天(全量)
/memory dream # Consolidate last 3 days
/memory dream 7 # Consolidate last 7 days
/memory dream 30 # Consolidate last 30 days (full)
```
蒸馏完成后Web 端会收到带有跳转链接的通知,可直接查看更新后的 MEMORY.md 和梦境日记。
On the Web console, the completion notification includes clickable links to view the updated MEMORY.md and dream diary.
<Tip>
系统每天 23:55 会自动执行一次蒸馏lookback 1 天)。手动触发适用于首次部署后的历史整理,或需要立即更新记忆时使用。
The system automatically runs distillation daily at 23:55 (lookback 1 day). Manual trigger is useful for consolidating historical memories after first deployment, or when you need an immediate memory update.
</Tip>
## knowledge
查看和管理个人知识库。默认显示知识库统计信息。
View and manage the personal knowledge base. Shows statistics by default.
```text
/knowledge
```
输出示例:
```
📚 知识库
- 状态:已开启
- 页面数12
- 总大小45.2 KB
- 分类明细:
- concepts/: 5 篇
- entities/: 4 篇
- sources/: 3 篇
```
### knowledge list
查看知识库目录树结构。
View the knowledge base directory tree.
```text
/knowledge list
@@ -65,7 +51,7 @@ description: 记忆蒸馏和知识库管理命令
### knowledge on / off
开启或关闭知识库。关闭后不再注入知识提示词和索引知识文件。
Enable or disable the knowledge base. When disabled, knowledge prompts and file indexing are not injected.
```text
/knowledge on
@@ -73,5 +59,5 @@ description: 记忆蒸馏和知识库管理命令
```
<Note>
终端 CLI `cow knowledge` `cow knowledge list` 可用,但 `on|off` 仅支持在对话中使用(需实时生效)。
In the terminal CLI, `cow knowledge` and `cow knowledge list` are available, but `on|off` is only supported in chat (requires runtime effect).
</Note>

View File

@@ -1,28 +1,28 @@
---
title: 进程管理
description: 使用 cow 命令管理 CowAgent 进程的启动、停止、重启、更新等操作
title: Process Management
description: Manage CowAgent process lifecycle with cow commands
---
进程管理命令用于控制 CowAgent 后台进程的生命周期。这些命令仅在终端中可用。
Process management commands control the CowAgent background process. These commands are only available in the terminal.
## start
启动 CowAgent 服务。默认以后台进程方式运行,并自动跟踪日志输出。
Start the CowAgent service. Runs as a background daemon by default and automatically tails logs.
```bash
cow start
```
**选项:**
**Options:**
| 选项 | 说明 |
| Option | Description |
| --- | --- |
| `-f`, `--foreground` | 前台运行,不以后台守护进程方式启动 |
| `--no-logs` | 启动后不自动跟踪日志 |
| `-f`, `--foreground` | Run in foreground, not as a background daemon |
| `--no-logs` | Don't tail logs after starting |
## stop
停止正在运行的 CowAgent 服务。
Stop the running CowAgent service.
```bash
cow stop
@@ -30,97 +30,86 @@ cow stop
## restart
重启 CowAgent 服务(先停止再启动)。
Restart the CowAgent service (stop then start).
```bash
cow restart
```
**选项:**
**Options:**
| 选项 | 说明 |
| Option | Description |
| --- | --- |
| `--no-logs` | 重启后不自动跟踪日志 |
| `--no-logs` | Don't tail logs after restart |
## update
更新代码并重启服务。自动执行以下流程:
Update code and restart the service. Automatically performs:
1. 拉取最新代码(`git pull`
2. 停止当前服务
3. 更新 Python 依赖
4. 重新安装 CLI
5. 启动服务
1. Pull latest code (`git pull`)
2. Stop current service
3. Update Python dependencies
4. Reinstall CLI
5. Start service
```bash
cow update
```
<Warning>
如果 `git pull` 失败(如存在本地未提交的修改),更新会中止,服务不受影响。
If `git pull` fails (e.g., uncommitted local changes), the update aborts and the service remains unaffected.
</Warning>
## status
查看 CowAgent 服务运行状态,包括进程信息、版本号、当前配置的模型和通道。
Check CowAgent service status, including process info, version, and current model/channel configuration.
```bash
cow status
```
输出示例:
```
🐮 CowAgent Status
Status: ● Running (PID: 12345)
Version: 2.0.4
Channel: web
Model: MiniMax-M2.5
Mode: agent
```
## logs
查看服务日志。
View service logs.
```bash
cow logs
```
**选项:**
**Options:**
| 选项 | 说明 | 默认值 |
| Option | Description | Default |
| --- | --- | --- |
| `-f`, `--follow` | 持续跟踪日志输出 | |
| `-n`, `--lines` | 显示最近 N 行 | 50 |
| `-f`, `--follow` | Continuously tail log output | No |
| `-n`, `--lines` | Show last N lines | 50 |
示例:
Examples:
```bash
# 查看最近 100 行日志
# View last 100 lines
cow logs -n 100
# 持续跟踪日志
# Continuously tail logs
cow logs -f
```
## install-browser
安装 Playwright Chromium 浏览器,用于启用 [浏览器工具](/tools/browser)
Install Playwright and Chromium browser for the [browser tool](/tools/browser).
```bash
cow install-browser
```
<Tip>
仅在需要使用浏览器工具(如网页浏览、截图等)时才需要安装。
Only needed when using browser tools (web browsing, screenshots, etc.).
</Tip>
## run.sh 兼容
## run.sh Compatibility
如果未安装 Cow CLI,也可以使用 `run.sh` 脚本管理服务:
If Cow CLI is not installed, you can use `run.sh` to manage the service:
| cow 命令 | run.sh 等效命令 |
| cow command | run.sh equivalent |
| --- | --- |
| `cow start` | `./run.sh start` |
| `cow stop` | `./run.sh stop` |
@@ -130,5 +119,5 @@ cow install-browser
| `cow logs` | `./run.sh logs` |
<Note>
推荐使用 `cow` 命令,它提供更简洁的语法和更丰富的功能。通过一键安装脚本部署时 `cow` 命令会自动安装。
The `cow` command is recommended — it provides cleaner syntax and richer features. It is automatically installed via the one-click install script.
</Note>

View File

@@ -1,190 +1,182 @@
---
title: 技能管理
description: 通过命令安装、卸载、启用、禁用和管理技能
title: Skill Management
description: Install, uninstall, enable, disable, and manage skills via commands
---
技能管理命令用于安装、查询和管理 CowAgent 的技能。在对话中使用 `/skill <子命令>`,在终端中使用 `cow skill <子命令>`。
Skill management commands are used to install, query, and manage CowAgent skills. Use `/skill <subcommand>` in chat or `cow skill <subcommand>` in the terminal.
## list
列出已安装的技能及其状态。
List installed skills and their status.
<CodeGroup>
```text 对话
```text Chat
/skill list
```
```bash 终端
```bash Terminal
cow skill list
```
</CodeGroup>
输出示例:
Example output:
```
📦 已安装的技能 (3/4)
📦 Installed skills (3/4)
✅ pptx
Use this skill any time a .pptx file is involved…
来源: cowhub
Source: cowhub
✅ skill-creator
Create, install, or update skills…
来源: builtin
Source: builtin
⏸️ image-vision (已禁用)
图片理解和视觉分析
来源: builtin
⏸️ image-vision (disabled)
Image understanding and visual analysis
Source: builtin
```
**浏览技能广场**(查看 Hub 上所有可安装的技能):
**Browse the Skill Hub** (view all available skills):
<CodeGroup>
```text 对话
```text Chat
/skill list --remote
```
```bash 终端
```bash Terminal
cow skill list --remote
```
</CodeGroup>
**选项:**
**Options:**
| 选项 | 说明 | 默认值 |
| Option | Description | Default |
| --- | --- | --- |
| `--remote`, `-r` | 浏览 Skill Hub 远程技能列表 | |
| `--page` | 远程列表分页页码 | 1 |
| `--remote`, `-r` | Browse Skill Hub remote skill list | No |
| `--page` | Page number for remote listing | 1 |
## search
在技能广场中搜索技能。
Search for skills on the Skill Hub.
<CodeGroup>
```text 对话
```text Chat
/skill search pptx
```
```bash 终端
```bash Terminal
cow skill search pptx
```
</CodeGroup>
## install
安装技能。通过统一的 `install` 命令,可一键安装来自 **Cow 技能广场、GitHubClawHub** 以及任意 URLzip 压缩包、SKILL.md 链接)上的技能,无需手动下载和配置。
Install skills with a single `install` command from Cow Skill Hub, GitHub, ClawHub, or any URL (zip archives, SKILL.md links) — no manual download or configuration required.
**从 Cow 技能广场安装(推荐):**
**From Skill Hub (recommended):**
<CodeGroup>
```text 对话
```text Chat
/skill install pptx
```
```bash 终端
```bash Terminal
cow skill install pptx
```
</CodeGroup>
** GitHub 安装:**
**From GitHub:**
<CodeGroup>
```text 对话
# 安装仓库中的所有技能(自动扫描包含 SKILL.md 的子目录)
```text Chat
# Install all skills in a repo (auto-discovers subdirectories with SKILL.md)
/skill install larksuite/cli
# 指定子目录,只安装单个技能
# Specify a subdirectory to install a single skill
/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
# 使用 # 指定子目录
# Use # to specify a subdirectory
/skill install larksuite/cli#skills/lark-minutes
```
```bash 终端
# 安装仓库中的所有技能(自动扫描包含 SKILL.md 的子目录)
```bash Terminal
# Install all skills in a repo (auto-discovers subdirectories with SKILL.md)
cow skill install larksuite/cli
# 指定子目录,只安装单个技能
# Specify a subdirectory to install a single skill
cow skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
# 使用 # 指定子目录
# Use # to specify a subdirectory
cow skill install larksuite/cli#skills/lark-minutes
```
</CodeGroup>
支持完整的 GitHub URL `owner/repo` 简写。对于 mono-repo一个仓库中包含多个技能不指定子目录时会自动发现并批量安装所有技能指定子目录时只安装该目录下的技能。
Supports full GitHub URLs and `owner/repo` shorthand. For mono-repos (multiple skills in one repository), omitting the subdirectory auto-discovers and batch-installs all skills; specifying a subdirectory installs only that skill.
** ClawHub 安装:**
**From ClawHub:**
<CodeGroup>
```text 对话
```text Chat
/skill install clawhub:baidu-search
```
```bash 终端
```bash Terminal
cow skill install clawhub:baidu-search
```
</CodeGroup>
**从 URL 安装:**
**From URL:**
<CodeGroup>
```text 对话
# 从 zip 压缩包安装(支持单个或批量)
```text Chat
# Install from a zip archive (single or batch)
/skill install https://cdn.link-ai.tech/skills/pptx.zip
# SKILL.md 链接安装
# Install from a SKILL.md link
/skill install https://example.com/path/to/SKILL.md
```
```bash 终端
# 从 zip 压缩包安装(支持单个或批量)
```bash Terminal
# Install from a zip archive (single or batch)
cow skill install https://cdn.link-ai.tech/skills/pptx.zip
# SKILL.md 链接安装
# Install from a SKILL.md link
cow skill install https://example.com/path/to/SKILL.md
```
</CodeGroup>
支持从 zip / tar.gz 压缩包 URL 安装,解压后自动扫描包含 `SKILL.md` 的目录,支持单个或批量安装。也支持直接从 `SKILL.md` 文件链接安装,会自动解析技能名称和描述。
安装成功后会显示技能名称、描述和来源,例如:
```
✅ baidu-search
百度搜索:使用百度搜索引擎检索信息…
来源: clawhub
```
Supports installing from zip / tar.gz archive URLs — automatically extracts and discovers directories containing `SKILL.md`, with support for single or batch install. Also supports installing directly from a `SKILL.md` file URL, automatically parsing the skill name and description.
## uninstall
卸载已安装的技能。
Uninstall an installed skill.
<CodeGroup>
```text 对话
```text Chat
/skill uninstall pptx
```
```bash 终端
```bash Terminal
cow skill uninstall pptx
```
</CodeGroup>
<Warning>
卸载操作会删除技能目录下的所有文件,此操作不可恢复。
Uninstalling deletes all files in the skill directory. This action cannot be undone.
</Warning>
## enable / disable
启用或禁用技能,禁用后技能不会被 Agent 调用。
Enable or disable a skill. Disabled skills will not be invoked by the Agent.
<CodeGroup>
```text 对话
```text Chat
/skill enable pptx
/skill disable pptx
```
```bash 终端
```bash Terminal
cow skill enable pptx
cow skill disable pptx
```
@@ -192,27 +184,27 @@ cow skill disable pptx
## info
查看已安装技能的详细信息,包括 `SKILL.md` 内容预览。
View details of an installed skill, including a preview of its `SKILL.md`.
<CodeGroup>
```text 对话
```text Chat
/skill info pptx
```
```bash 终端
```bash Terminal
cow skill info pptx
```
</CodeGroup>
## 技能来源
## Skill Sources
安装的技能会记录来源信息,可通过 `/skill list` 查看:
Installed skills track their origin, viewable via `/skill list`:
| 来源标识 | 说明 |
| Source | Description |
| --- | --- |
| `builtin` | 项目内置技能 |
| `cowhub` | CowAgent Skill Hub 安装 |
| `github` | GitHub URL 直接安装 |
| `clawhub` | ClawHub 安装 |
| `url` | SKILL.md URL 安装 |
| `local` | 本地创建的技能 |
| `builtin` | Built-in project skills |
| `cowhub` | Installed from CowAgent Skill Hub |
| `github` | Installed directly from a GitHub URL |
| `clawhub` | Installed from ClawHub |
| `url` | Installed from a SKILL.md URL |
| `local` | Locally created skills |

View File

@@ -33,17 +33,36 @@
"github": "https://github.com/zhayujie/CowAgent"
}
},
"redirects": [
{
"source": "/en/:slug*",
"destination": "/:slug*",
"permanent": true
}
],
"navigation": {
"languages": [
{
"language": "zh",
"language": "en",
"default": true,
"navbar": {
"links": [
{
"label": "Website",
"href": "https://cowagent.ai/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
}
]
},
"tabs": [
{
"tab": "项目介绍",
"tab": "Introduction",
"groups": [
{
"group": "概览",
"group": "Overview",
"pages": [
"intro/index",
"intro/architecture",
@@ -53,10 +72,10 @@
]
},
{
"tab": "快速开始",
"tab": "Get Started",
"groups": [
{
"group": "安装部署",
"group": "Installation",
"pages": [
"guide/quick-start",
"guide/manual-install",
@@ -66,22 +85,23 @@
]
},
{
"tab": "模型",
"tab": "Models",
"groups": [
{
"group": "模型配置",
"group": "Model Configuration",
"pages": [
"models/index",
"models/deepseek",
"models/minimax",
"models/claude",
"models/gemini",
"models/openai",
"models/minimax",
"models/glm",
"models/qwen",
"models/doubao",
"models/kimi",
"models/qianfan",
"models/mimo",
"models/linkai",
"models/coding-plan",
"models/custom"
@@ -90,16 +110,16 @@
]
},
{
"tab": "工具",
"tab": "Tools",
"groups": [
{
"group": "工具系统",
"group": "Tools System",
"pages": [
"tools/index"
]
},
{
"group": "内置工具",
"group": "Built-in Tools",
"pages": [
"tools/read",
"tools/write",
@@ -114,7 +134,7 @@
]
},
{
"group": "可选工具",
"group": "Optional Tools",
"pages": [
"tools/web-search",
"tools/vision",
@@ -122,7 +142,7 @@
]
},
{
"group": "MCP 工具",
"group": "MCP Tools",
"pages": [
"tools/mcp"
]
@@ -130,10 +150,10 @@
]
},
{
"tab": "技能",
"tab": "Skills",
"groups": [
{
"group": "技能系统",
"group": "Skills System",
"pages": [
"skills/index",
"skills/install",
@@ -142,7 +162,7 @@
]
},
{
"group": "内置技能",
"group": "Built-in Skills",
"pages": [
"skills/skill-creator",
"skills/knowledge-wiki",
@@ -152,10 +172,10 @@
]
},
{
"tab": "记忆",
"tab": "Memory",
"groups": [
{
"group": "记忆系统",
"group": "Memory System",
"pages": [
"memory/index",
"memory/context",
@@ -165,10 +185,10 @@
]
},
{
"tab": "知识",
"tab": "Knowledge",
"groups": [
{
"group": "知识库",
"group": "Knowledge Base",
"pages": [
"knowledge/index"
]
@@ -176,29 +196,33 @@
]
},
{
"tab": "通道",
"tab": "Channels",
"groups": [
{
"group": "接入渠道",
"group": "Platforms",
"pages": [
"channels/index",
"channels/weixin",
"channels/web",
"channels/telegram",
"channels/slack",
"channels/discord",
"channels/weixin",
"channels/feishu",
"channels/dingtalk",
"channels/wecom-bot",
"channels/qq",
"channels/wecom",
"channels/wechat-kf",
"channels/wechatmp"
]
}
]
},
{
"tab": "命令",
"tab": "CLI",
"groups": [
{
"group": "命令系统",
"group": "Command System",
"pages": [
"cli/index",
"cli/process",
@@ -210,12 +234,13 @@
]
},
{
"tab": "版本",
"tab": "Releases",
"groups": [
{
"group": "发布记录",
"group": "Release Notes",
"pages": [
"releases/overview",
"releases/v2.1.0",
"releases/v2.0.9",
"releases/v2.0.8",
"releases/v2.0.7",
@@ -233,193 +258,213 @@
]
},
{
"language": "en",
"language": "zh",
"navbar": {
"links": [
{
"label": "官网",
"href": "https://cowagent.ai/?lang=zh"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
}
]
},
"tabs": [
{
"tab": "Introduction",
"tab": "项目介绍",
"groups": [
{
"group": "Overview",
"group": "概览",
"pages": [
"en/intro/index",
"en/intro/architecture",
"en/intro/features"
"zh/intro/index",
"zh/intro/architecture",
"zh/intro/features"
]
}
]
},
{
"tab": "Get Started",
"tab": "快速开始",
"groups": [
{
"group": "Installation",
"group": "安装部署",
"pages": [
"en/guide/quick-start",
"en/guide/manual-install"
"zh/guide/quick-start",
"zh/guide/manual-install",
"zh/guide/upgrade"
]
}
]
},
{
"tab": "Models",
"tab": "模型",
"groups": [
{
"group": "Model Configuration",
"group": "模型配置",
"pages": [
"en/models/index",
"en/models/deepseek",
"en/models/minimax",
"en/models/claude",
"en/models/gemini",
"en/models/openai",
"en/models/glm",
"en/models/qwen",
"en/models/doubao",
"en/models/kimi",
"en/models/qianfan",
"en/models/linkai",
"en/models/coding-plan",
"en/models/custom"
"zh/models/index",
"zh/models/deepseek",
"zh/models/minimax",
"zh/models/claude",
"zh/models/gemini",
"zh/models/openai",
"zh/models/glm",
"zh/models/qwen",
"zh/models/doubao",
"zh/models/kimi",
"zh/models/qianfan",
"zh/models/mimo",
"zh/models/linkai",
"zh/models/coding-plan",
"zh/models/custom"
]
}
]
},
{
"tab": "Tools",
"tab": "工具",
"groups": [
{
"group": "Tools System",
"group": "工具系统",
"pages": [
"en/tools/index"
"zh/tools/index"
]
},
{
"group": "Built-in Tools",
"group": "内置工具",
"pages": [
"en/tools/read",
"en/tools/write",
"en/tools/edit",
"en/tools/ls",
"en/tools/bash",
"en/tools/send",
"en/tools/memory",
"en/tools/env-config",
"en/tools/web-fetch",
"en/tools/scheduler"
"zh/tools/read",
"zh/tools/write",
"zh/tools/edit",
"zh/tools/ls",
"zh/tools/bash",
"zh/tools/send",
"zh/tools/memory",
"zh/tools/env-config",
"zh/tools/web-fetch",
"zh/tools/scheduler"
]
},
{
"group": "Optional Tools",
"group": "可选工具",
"pages": [
"en/tools/web-search",
"en/tools/vision",
"en/tools/browser"
"zh/tools/web-search",
"zh/tools/vision",
"zh/tools/browser"
]
},
{
"group": "MCP Tools",
"group": "MCP 工具",
"pages": [
"en/tools/mcp"
"zh/tools/mcp"
]
}
]
},
{
"tab": "Skills",
"tab": "技能",
"groups": [
{
"group": "Skills System",
"group": "技能系统",
"pages": [
"en/skills/index",
"en/skills/install",
"en/skills/hub"
"zh/skills/index",
"zh/skills/install",
"zh/skills/create",
"zh/skills/hub"
]
},
{
"group": "Built-in Skills",
"group": "内置技能",
"pages": [
"en/skills/skill-creator",
"en/skills/knowledge-wiki",
"en/skills/image-generation"
"zh/skills/skill-creator",
"zh/skills/knowledge-wiki",
"zh/skills/image-generation"
]
}
]
},
{
"tab": "Memory",
"tab": "记忆",
"groups": [
{
"group": "Memory System",
"group": "记忆系统",
"pages": [
"en/memory/index",
"en/memory/context",
"en/memory/deep-dream"
"zh/memory/index",
"zh/memory/context",
"zh/memory/deep-dream"
]
}
]
},
{
"tab": "Knowledge",
"tab": "知识",
"groups": [
{
"group": "Knowledge Base",
"group": "知识库",
"pages": [
"en/knowledge/index"
"zh/knowledge/index"
]
}
]
},
{
"tab": "Channels",
"tab": "通道",
"groups": [
{
"group": "Platforms",
"group": "接入渠道",
"pages": [
"en/channels/index",
"en/channels/weixin",
"en/channels/web",
"en/channels/feishu",
"en/channels/dingtalk",
"en/channels/wecom-bot",
"en/channels/qq",
"en/channels/wecom",
"en/channels/wechatmp"
"zh/channels/index",
"zh/channels/weixin",
"zh/channels/web",
"zh/channels/feishu",
"zh/channels/dingtalk",
"zh/channels/wecom-bot",
"zh/channels/qq",
"zh/channels/wecom",
"zh/channels/wechat-kf",
"zh/channels/wechatmp",
"zh/channels/telegram",
"zh/channels/slack",
"zh/channels/discord"
]
}
]
},
{
"tab": "CLI",
"tab": "命令",
"groups": [
{
"group": "Command System",
"group": "命令系统",
"pages": [
"en/cli/index",
"en/cli/process",
"en/cli/skill",
"en/cli/memory-knowledge",
"en/cli/chat"
"zh/cli/index",
"zh/cli/process",
"zh/cli/skill",
"zh/cli/memory-knowledge",
"zh/cli/general"
]
}
]
},
{
"tab": "Releases",
"tab": "版本",
"groups": [
{
"group": "Release Notes",
"group": "发布记录",
"pages": [
"en/releases/overview",
"en/releases/v2.0.9",
"en/releases/v2.0.8",
"en/releases/v2.0.7",
"en/releases/v2.0.6",
"en/releases/v2.0.5",
"en/releases/v2.0.4",
"en/releases/v2.0.3",
"en/releases/v2.0.2",
"en/releases/v2.0.1",
"en/releases/v2.0.0"
"zh/releases/overview",
"zh/releases/v2.1.0",
"zh/releases/v2.0.9",
"zh/releases/v2.0.8",
"zh/releases/v2.0.7",
"zh/releases/v2.0.6",
"zh/releases/v2.0.5",
"zh/releases/v2.0.4",
"zh/releases/v2.0.3",
"zh/releases/v2.0.2",
"zh/releases/v2.0.1",
"zh/releases/v2.0.0"
]
}
]
@@ -428,6 +473,18 @@
},
{
"language": "ja",
"navbar": {
"links": [
{
"label": "ウェブサイト",
"href": "https://cowagent.ai/"
},
{
"label": "GitHub",
"href": "https://github.com/zhayujie/CowAgent"
}
]
},
"tabs": [
{
"tab": "紹介",
@@ -472,6 +529,7 @@
"ja/models/doubao",
"ja/models/kimi",
"ja/models/qianfan",
"ja/models/mimo",
"ja/models/linkai",
"ja/models/coding-plan",
"ja/models/custom"
@@ -579,7 +637,11 @@
"ja/channels/wecom-bot",
"ja/channels/qq",
"ja/channels/wecom",
"ja/channels/wechatmp"
"ja/channels/wechat-kf",
"ja/channels/wechatmp",
"ja/channels/telegram",
"ja/channels/slack",
"ja/channels/discord"
]
}
]
@@ -606,6 +668,7 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
"ja/releases/v2.1.0",
"ja/releases/v2.0.9",
"ja/releases/v2.0.8",
"ja/releases/v2.0.7",

View File

@@ -1,58 +0,0 @@
---
title: DingTalk
description: Integrate CowAgent into DingTalk application
---
Integrate CowAgent into DingTalk by creating an intelligent robot app on the DingTalk Open Platform.
## 1. Create App
1. Go to [DingTalk Developer Console](https://open-dev.dingtalk.com/fe/app#/corp/app), log in and click **Create App**, fill in the app information:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-create-app.png" width="800"/>
2. Click **Add App Capability**, select **Robot** capability and click **Add**:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-add-bot.png" width="800"/>
3. Configure the robot information and click **Publish**. After publishing, click "**Debug**" to automatically create a test group chat, which can be viewed in the client:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-config-bot.png" width="600"/>
4. Click **Version Management & Release**, create a new version and publish:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-publish-bot.png" width="700"/>
## 2. Project Configuration
1. Click **Credentials & Basic Info**, get the `Client ID` and `Client Secret`:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-get-secret.png" width="700"/>
2. Add the following configuration to `config.json` in the project root:
```json
{
"channel_type": "dingtalk",
"dingtalk_client_id": "YOUR_CLIENT_ID",
"dingtalk_client_secret": "YOUR_CLIENT_SECRET"
}
```
3. Install the dependency:
```bash
pip3 install dingtalk_stream
```
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-app-config.png" width="700"/>
4. After starting the project, go to the DingTalk Developer Console, click **Event Subscription**, then click **Connection verified, verify channel**. When "**Connection successful**" is displayed, the configuration is complete:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-event-sub.png" width="700"/>
## 3. Usage
Chat privately with the robot or add it to an enterprise group to start a conversation:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/dingtalk-hosting-demo.png" width="650"/>

View File

@@ -1,107 +0,0 @@
---
title: Feishu (Lark)
description: Integrate CowAgent into Feishu via a custom enterprise app
---
> Integrate CowAgent into Feishu via a custom enterprise app. Supports p2p chat and group chat (@bot), uses WebSocket long connection (no public IP needed), supports streaming typewriter replies and voice messages.
<Note>
You need to be a Feishu enterprise user with admin privileges.
</Note>
## 1. Setup
### Option 1: One-click Scan to Create (Recommended)
No need to manually create an app on the Feishu Developer Platform. Start the Cow project, open the web console (default `http://127.0.0.1:9899/`), go to **Channels**, click **Add Channel**, choose **Feishu**, then under the **Scan QR** tab click **One-click Create Feishu App** and scan with the **Feishu App** to complete app creation and connection automatically.
<Note>
The created app comes with all required permissions (messaging, card read/write, group events, etc.) and event subscriptions pre-configured. Currently only the Feishu mainland version is supported (Lark international not yet supported).
</Note>
When starting from CLI without `feishu_app_id` configured, the QR code is also printed to the terminal.
### Option 2: Manual Setup
Manually create a custom app on the Feishu Developer Platform, then connect via Web Console or config file.
**Step 1: Create the App**
1. Go to [Feishu Developer Platform](https://open.feishu.cn/app/), click **Create Enterprise Custom App**:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-create-app.jpg" width="500"/>
2. In **Add App Capabilities**, add the **Bot** capability:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-add-bot.jpg" width="800"/>
3. In **Permission Management**, paste the following permissions and **Batch Enable** all:
```
im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p2p_msg,im:message.p2p_msg:readonly,im:message:send_as_bot,im:resource,cardkit:card:write
```
<img src="https://cdn.link-ai.tech/doc/feishu-hosting-add-auth2.png" width="800"/>
4. Get `App ID` and `App Secret` from **Credentials & Basic Info**:
<img src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/feishu-hosting-appid-secret.jpg" width="800"/>
**Step 2: Connect to CowAgent**
<Tabs>
<Tab title="Web Console">
Open the web console, go to **Channels**, click **Add Channel**, choose **Feishu**, switch to the **Manual** tab, enter App ID and App Secret, then click connect.
</Tab>
<Tab title="Config File">
Add the following to `config.json` and start the program:
```json
{
"channel_type": "feishu",
"feishu_app_id": "YOUR_APP_ID",
"feishu_app_secret": "YOUR_APP_SECRET",
"feishu_stream_reply": true
}
```
| Parameter | Description | Default |
| --- | --- | --- |
| `feishu_app_id` | Feishu app App ID | - |
| `feishu_app_secret` | Feishu app App Secret | - |
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
</Tab>
</Tabs>
**Step 3: Publish the App**
1. After Cow is running, go to **Events & Callbacks** in the Feishu Developer Platform, choose **Long Connection** mode and save:
<img src="https://cdn.link-ai.tech/doc/202601311731183.png" width="600"/>
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
## 2. Features
| Feature | Status |
| --- | --- |
| P2P chat | ✅ |
| Group chat (@bot) | ✅ |
| Text messages | ✅ send/receive |
| Image messages | ✅ send/receive |
| Voice messages | ✅ send/receive |
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
<Note>
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
</Note>
## 3. Usage
After connection, search for the bot name in Feishu to start a chat.
To use in groups, add the bot to a group and @-mention it.

View File

@@ -1,39 +0,0 @@
---
title: Channels Overview
description: Channels supported by CowAgent and their capability matrix
---
CowAgent supports multiple chat channels. Switch between them at startup via `channel_type`. The Web Console is enabled by default and can run in parallel with other channels.
## Capability Matrix
The table below summarizes the inbound message types, bot reply types, and group chat capabilities supported by each channel, making it easy to choose by scenario.
| Channel | Text | Image | File | Voice | Group Chat |
| --- | :-: | :-: | :-: | :-: | :-: |
| [WeChat](/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [Web Console](/channels/web) | ✅ | ✅ | ✅ | ✅ | |
| [Feishu](/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [DingTalk](/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [WeCom Smart Bot](/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [QQ](/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
| [WeCom App](/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [Official Account](/channels/wechatmp) | ✅ | ✅ | | ✅ | |
- The **Image / File / Voice** columns indicate that the channel can send and receive the corresponding message types; see each channel's docs for details
- The **Group Chat** column indicates the ability to recognize and respond to group messages
<Tip>
The voice / image capabilities of each channel depend on the configuration of the corresponding model provider. See [Models Overview](/models) for details.
</Tip>
## Channel List
- [Web Console](/channels/web) — built-in browser-based chat and management panel, enabled by default
- [WeChat](/channels/weixin) — log in via personal WeChat QR scan
- [Feishu](/channels/feishu) — Feishu custom bot
- [DingTalk](/channels/dingtalk) — DingTalk custom bot
- [WeCom Smart Bot](/channels/wecom-bot) — WeCom smart robot
- [QQ](/channels/qq) — QQ official bot open platform
- [WeCom App](/channels/wecom) — WeCom custom app integration
- [Official Account](/channels/wechatmp) — WeChat Official Account (subscription / service account)

View File

@@ -1,88 +0,0 @@
---
title: QQ Bot
description: Connect CowAgent to QQ Bot (WebSocket long connection)
---
> Connect CowAgent via QQ Open Platform's bot API, supporting QQ direct messages, group chats (@bot), guild channel messages, and guild DMs. No public IP required — uses WebSocket long connection.
<Note>
QQ Bot is created through the QQ Open Platform. It uses WebSocket long connection to receive messages and OpenAPI to send messages. No public IP or domain is required.
</Note>
## 1. Create a QQ Bot
> Visit the [QQ Open Platform](https://q.qq.com), sign in with QQ. If you haven't registered, please complete [account registration](https://q.qq.com/#/register) first.
1.Go to the [QQ Open Platform - Bot List](https://q.qq.com/#/apps), and click **Create Bot**:
<img src="https://cdn.link-ai.tech/doc/20260317162900.png" width="800"/>
2.Fill in the bot name, avatar, and other basic information to complete the creation:
<img src="https://cdn.link-ai.tech/doc/20260317163005.png" width="800"/>
3.Enter the bot configuration page, go to **Development Management**, and complete the following steps:
- Copy and save the **AppID** (Bot ID)
- Generate and save the **AppSecret** (Bot Secret)
<img src="https://cdn.link-ai.tech/doc/20260317164955.png" width="800"/>
## 2. Configuration and Running
### Option A: Web Console
Start the program and open the Web console (local access: http://127.0.0.1:9899/). Go to the **Channels** tab, click **Connect Channel**, select **QQ Bot**, fill in the AppID and AppSecret from the previous step, and click Connect.
<img src="https://cdn.link-ai.tech/doc/20260317165425.png" width="800"/>
### Option B: Config File
Add the following to your `config.json`:
```json
{
"channel_type": "qq",
"qq_app_id": "YOUR_APP_ID",
"qq_app_secret": "YOUR_APP_SECRET"
}
```
| Parameter | Description |
| --- | --- |
| `qq_app_id` | AppID of the QQ Bot, found in Development Management on the open platform |
| `qq_app_secret` | AppSecret of the QQ Bot, found in Development Management on the open platform |
After configuration, start the program. The log message `[QQ] ✅ Connected successfully` indicates a successful connection.
## 3. Usage
In the QQ Open Platform, go to **Management → Usage Scope & Members**, scan the "Add to group and message list" QR code with your QQ client to start chatting with the bot:
<img src="https://cdn.link-ai.tech/doc/20260317165947.png" width="800"/>
Chat example:
<img src="https://cdn.link-ai.tech/doc/20260317171508.png" width="800"/>
## 4. Supported Features
> Note: To use the QQ bot in group chats and guild channels, you need to complete the publishing review and configure usage scope permissions.
| Feature | Status |
| --- | --- |
| QQ Direct Messages | ✅ |
| QQ Group Chat (@bot) | ✅ |
| Guild Channel (@bot) | ✅ |
| Guild DM | ✅ |
| Text Messages | ✅ Send & Receive |
| Image Messages | ✅ Send & Receive (group & direct) |
| File Messages | ✅ Send (group & direct) |
| Scheduled Tasks | ✅ Active push (4 per user per month) |
## 5. Notes
- **Passive message limits**: QQ direct message replies are valid for 60 minutes (max 5 replies per message); group chat replies are valid for 5 minutes.
- **Active message limits**: Both direct and group chats have a monthly limit of 4 active messages. Keep this in mind when using the scheduled tasks feature.
- **Event permissions**: By default, `GROUP_AND_C2C_EVENT` (QQ group/direct) and `PUBLIC_GUILD_MESSAGES` (guild public messages) are subscribed. Apply for additional permissions on the open platform if needed.

View File

@@ -1,94 +0,0 @@
---
title: Web Console
description: Use CowAgent through the Web Console
---
The Web Console is CowAgent's default channel. It runs automatically once started, letting you chat with the Agent in a browser and manage models, skills, memory, channels, and other configuration online.
## Configuration
```json
{
"channel_type": "web",
"web_host": "0.0.0.0",
"web_port": 9899,
"web_password": "",
"enable_thinking": false
}
```
| Parameter | Description | Default |
| --- | --- | --- |
| `channel_type` | Set to `web` | `web` |
| `web_host` | Web service listen address. Defaults to `127.0.0.1` (local only); set to `0.0.0.0` for public access and configure a password | `""` |
| `web_port` | Web service listen port | `9899` |
| `web_password` | Access password. Leave empty to disable password protection; recommended when listening on `0.0.0.0` | `""` |
| `web_session_expire_days` | Login session validity in days | `30` |
| `enable_thinking` | Whether to enable deep thinking mode | `false` |
Once a password is configured, you must enter it to log in when accessing the console. The login session is kept for 30 days by default, so restarting the service during that period does not require re-login. The password can also be changed online from the "Configuration" page in the console.
## Access URL
After starting the project, visit:
- Local: `http://localhost:9899`
- Server: `http://<server-ip>:9899`
<Note>
Ensure the server firewall and security group allow the corresponding port.
</Note>
## Features
### Chat Interface
Supports streaming output with real-time display of the Agent's reasoning process and tool calls, providing intuitive observation of the Agent's decision-making. Deep thinking can be toggled via configuration or the "Agent Configuration" switch in the console.
<img width="850" src="https://cdn.link-ai.tech/doc/20260227180120.png" />
#### Multi-Session Management
The chat interface supports multi-session management. All session records are persistently stored in the database:
- **Session List**: Click the history icon on the left to expand/collapse the session list panel, with scroll-to-load support for all historical sessions
- **AI-Generated Titles**: After the first exchange in a new session, the model is automatically called to generate a short summary title
- **New Session**: Click the "New Chat" button at the top of the session list or the `+` button in the input area to create a new session
- **Delete Session**: Click the delete button on a session item and confirm to permanently delete the session and all its messages
- **Clear Context**: Click the clear button in the input area to insert a divider in the current session. Messages above the divider are still displayed but no longer included as context for the model
### Model Management
Manage text, image, voice, and embedding model configurations for different providers online — no need to edit config files manually:
<img width="850" src="https://cdn.link-ai.tech/doc/20260521212949.png" />
### Skill Management
View and manage Agent skills (Skills) online:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173403.png" />
### Memory Management
View and manage Agent memory online:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173349.png" />
### Channel Management
Manage connected channels online with real-time connect/disconnect operations:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173331.png" />
### Scheduled Tasks
View and manage scheduled tasks online, including one-time tasks, fixed intervals, and Cron expressions:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173704.png" />
### Logs
View Agent runtime logs in real time for monitoring and troubleshooting:
<img width="850" src="https://cdn.link-ai.tech/doc/20260227173514.png" />

View File

@@ -1,72 +0,0 @@
---
title: WeChat Official Account
description: Integrate CowAgent with WeChat Official Accounts
---
CowAgent supports both personal subscription accounts and enterprise service accounts.
| Type | Requirements | Features |
| --- | --- | --- |
| **Personal Subscription** | Available to individuals | Sends a placeholder reply first; users must send a message to retrieve the full response |
| **Enterprise Service** | Enterprise with verified customer service API | Can proactively push replies to users |
<Note>
Official Accounts only support server and Docker deployment, not local run mode. Install extended dependencies: `pip3 install -r requirements-optional.txt`
</Note>
## 1. Personal Subscription Account
Add the following configuration to `config.json`:
```json
{
"channel_type": "wechatmp",
"single_chat_prefix": [""],
"wechatmp_app_id": "wx73f9******d1e48",
"wechatmp_app_secret": "YOUR_APP_SECRET",
"wechatmp_aes_key": "",
"wechatmp_token": "YOUR_TOKEN",
"wechatmp_port": 80
}
```
### Setup Steps
These configurations must be consistent with the [WeChat Official Account Platform](https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev). Navigate to **Settings & Development → Basic Configuration → Server Configuration** and configure as shown below:
<img src="https://cdn.link-ai.tech/doc/20260228103506.png" width="480"/>
1. Enable the developer secret on the platform (corresponds to `wechatmp_app_secret`), and add the server IP to the whitelist
2. Fill in the `config.json` with the official account parameters matching the platform configuration
3. Start the program, which listens on port 80 (use `sudo` if you don't have permission; stop any process occupying port 80)
4. **Enable server configuration** on the official account platform and submit. A successful save means the configuration is complete. Note that the **"Server URL"** must be in the format `http://{HOST}/wx`, where `{HOST}` can be the server IP or domain
After following the account and sending a message, you should see the following result:
<img src="https://cdn.link-ai.tech/doc/20260228103522.png" width="720"/>
Due to subscription account limitations, short replies (within 15s) can be returned immediately, but longer replies will first send a "Thinking..." placeholder, requiring users to send any text to retrieve the answer. Enterprise service accounts can solve this with the customer service API.
<Tip>
**Voice Recognition**: You can use WeChat's built-in voice recognition. Enable "Receive Voice Recognition Results" under "Settings & Development → API Permissions" on the official account management page.
</Tip>
## 2. Enterprise Service Account
The setup process for enterprise service accounts is essentially the same as personal subscription accounts, with the following differences:
1. Register an enterprise service account on the platform and complete WeChat certification. Confirm that the **Customer Service API** permission has been granted
2. Set `"channel_type": "wechatmp_service"` in `config.json`; other configurations remain the same
3. Even for longer replies, they can be proactively pushed to users without requiring manual retrieval
```json
{
"channel_type": "wechatmp_service",
"single_chat_prefix": [""],
"wechatmp_app_id": "YOUR_APP_ID",
"wechatmp_app_secret": "YOUR_APP_SECRET",
"wechatmp_aes_key": "",
"wechatmp_token": "YOUR_TOKEN",
"wechatmp_port": 80
}
```

View File

@@ -1,73 +0,0 @@
---
title: WeCom Bot
description: Connect CowAgent to WeCom AI Bot (WebSocket long connection)
---
Connect CowAgent via WeCom AI Bot, supporting both direct messages and group chats. No public IP required — uses WebSocket long connection with Markdown rendering and streaming output.
<Note>
WeCom Bot and WeCom App are two different integration methods. WeCom Bot uses WebSocket long connection, requiring no public IP or domain, making it easier to set up.
</Note>
## 1. Create an AI Bot
1. Open the WeCom client, go to **Workbench**, and click **AI Bot**:
<img src="https://cdn.link-ai.tech/doc/20260316180959.png" width="800"/>
2. Click **Create Bot** → **Manual Creation**:
<img src="https://cdn.link-ai.tech/doc/20260316181118.png" width="600"/>
3. Scroll to the bottom of the right panel and select **API Mode**:
<img src="https://cdn.link-ai.tech/doc/20260316181215.png" width="600"/>
4. Set the bot name, avatar, and visibility scope. Select **Long Connection** mode, note down the **Bot ID** and **Secret**, then click Save.
## 2. Configuration
### Option A: Web Console
Start the program and open the Web console (local access: http://127.0.0.1:9899). Go to the **Channels** tab, click **Connect Channel**, select **WeCom Bot**, fill in the Bot ID and Secret from the previous step, and click Connect.
<img src="https://cdn.link-ai.tech/doc/20260316181711.png" width="600"/>
### Option B: Config File
Add the following to your `config.json`:
```json
{
"channel_type": "wecom_bot",
"wecom_bot_id": "YOUR_BOT_ID",
"wecom_bot_secret": "YOUR_SECRET"
}
```
| Parameter | Description |
| --- | --- |
| `wecom_bot_id` | Bot ID of the AI Bot |
| `wecom_bot_secret` | Secret for the AI Bot |
After configuration, start the program. The log message `[WecomBot] Subscribe success` indicates a successful connection.
## 3. Supported Features
| Feature | Status |
| --- | --- |
| Direct Messages | ✅ |
| Group Chat (@bot) | ✅ |
| Text Messages | ✅ Send & Receive |
| Image Messages | ✅ Send & Receive |
| File Messages | ✅ Send & Receive |
| Streaming Reply | ✅ |
| Scheduled Push | ✅ |
## 4. Usage
Search for the bot name in WeCom to start a direct conversation.
To use in group chats, add the bot to a group and @mention it to send messages.
<img src="https://cdn.link-ai.tech/doc/20260316182902.png" width="800"/>

View File

@@ -1,98 +0,0 @@
---
title: WeCom
description: Integrate CowAgent into WeCom enterprise app
---
Integrate CowAgent into WeCom through a custom enterprise app, supporting one-on-one chat for internal employees.
<Note>
WeCom only supports Docker deployment or server Python deployment. Local run mode is not supported.
</Note>
## 1. Prerequisites
Required resources:
1. A server with public IP (overseas server, or domestic server with a proxy for international API access)
2. A registered WeCom account (individual registration is possible but cannot be certified)
3. Certified WeCom accounts additionally require a domain filed under the corresponding entity
## 2. Create WeCom App
1. In the [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#profile), click **My Enterprise** and find the **Corp ID** at the bottom of the page. Save this ID for the `wechatcom_corp_id` configuration field.
2. Switch to **Application Management** and click Create Application:
<img src="https://cdn.link-ai.tech/doc/20260228103156.png" width="480"/>
3. On the application creation page, record the `AgentId` and `Secret`:
<img src="https://cdn.link-ai.tech/doc/20260228103218.png" width="580"/>
4. Click **Set API Reception** to configure the application interface:
<img src="https://cdn.link-ai.tech/doc/20260228103211.png" width="520"/>
- URL format: `http://ip:port/wxcomapp` (certified enterprises must use a filed domain)
- Generate random `Token` and `EncodingAESKey` and save them for the configuration file
<Note>
The API reception configuration cannot be saved at this point because the program hasn't started yet. Come back to save it after the project is running.
</Note>
## 3. Configuration and Run
Add the following configuration to `config.json` (the mapping between each parameter and the WeCom console is shown in the screenshots above):
```json
{
"channel_type": "wechatcom_app",
"single_chat_prefix": [""],
"wechatcom_corp_id": "YOUR_CORP_ID",
"wechatcomapp_token": "YOUR_TOKEN",
"wechatcomapp_secret": "YOUR_SECRET",
"wechatcomapp_agent_id": "YOUR_AGENT_ID",
"wechatcomapp_aes_key": "YOUR_AES_KEY",
"wechatcomapp_port": 9898
}
```
| Parameter | Description |
| --- | --- |
| `wechatcom_corp_id` | Corp ID |
| `wechatcomapp_token` | Token from API reception config |
| `wechatcomapp_secret` | App Secret |
| `wechatcomapp_agent_id` | App AgentId |
| `wechatcomapp_aes_key` | EncodingAESKey from API reception config |
| `wechatcomapp_port` | Listen port, default 9898 |
After configuration, start the program. When the log shows `http://0.0.0.0:9898/`, the program is running successfully. You need to open this port externally (e.g., allow it in the cloud server security group).
After the program starts, return to the WeCom Admin Console to save the **Message Server Configuration**. After saving successfully, you also need to add the server IP to **Enterprise Trusted IPs**, otherwise messages cannot be sent or received:
<img src="https://cdn.link-ai.tech/doc/20260228103224.png" width="520"/>
<Warning>
If the URL configuration callback fails or the configuration is unsuccessful:
1. Ensure the server firewall is disabled and the security group allows the listening port
2. Carefully check that Token, Secret Key and other parameter configurations are consistent, and that the URL format is correct
3. Certified WeCom accounts must configure a filed domain matching the entity
</Warning>
## 4. Usage
Search for the app name you just created in WeCom to start chatting directly. You can run multiple instances listening on different ports to create multiple WeCom apps:
<img src="https://cdn.link-ai.tech/doc/20260228103228.png" width="720"/>
To allow external personal WeChat users to use the app, go to **My Enterprise → WeChat Plugin**, share the invite QR code. After scanning and following, personal WeChat users can join and chat with the app:
<img src="https://cdn.link-ai.tech/doc/20260228103232.png" width="520"/>
## FAQ
Make sure the following dependencies are installed:
```bash
pip install websocket-client pycryptodome
```

View File

@@ -1,72 +0,0 @@
---
title: WeChat
description: Connect CowAgent to personal WeChat
---
> Connect CowAgent to your personal WeChat. Simply scan a QR code to log in — no public IP required. Supports text, image, voice, file, and video messages.
## 1. Configuration
### Option A: Web Console
Start the program and open the Web console (local access: http://127.0.0.1:9899). Go to the **Channels** tab, click **Connect Channel**, select **WeChat**, and follow the prompts to scan the QR code.
### Option B: Config File
Set `channel_type` to `weixin` in your `config.json`:
```json
{
"channel_type": "weixin"
}
```
After starting the program, a QR code will be displayed in the terminal. Scan it with WeChat and confirm on your phone to complete login.
<Note>
For backward compatibility, setting `channel_type` to `wx` also activates the WeChat channel.
</Note>
## 2. Parameters
| Parameter | Description | Default |
| --- | --- | --- |
| `channel_type` | Set to `weixin` or `wx` | — |
Login credentials are automatically saved to `~/.weixin_cow_credentials.json`. To force a re-login, delete this file and restart.
## 3. Login
### QR Code Login
On first startup, a QR code is displayed in the terminal (valid for approximately 2 minutes). Scan it with WeChat and confirm on your phone.
- The QR code automatically refreshes when it expires
- The `qrcode` dependency is already included in `requirements.txt`, enabling QR code rendering directly in the terminal
### Credential Persistence
After successful login, credentials are saved to `~/.weixin_cow_credentials.json`. Subsequent startups will reuse the saved credentials without requiring a new scan.
To force a re-login, delete the credentials file and restart the program.
### Session Expiry
When the WeChat session expires (errcode -14), the program automatically clears old credentials and initiates a new QR login — no manual intervention required.
## 4. Supported Features
| Feature | Status |
| --- | --- |
| Direct Messages | ✅ |
| Text Messages | ✅ Send & Receive |
| Image Messages | ✅ Send & Receive |
| File Messages | ✅ Send & Receive |
| Video Messages | ✅ Send & Receive |
| Voice Messages | ✅ Receive |
## 5. Notes
1. Ensure network access to `ilinkai.weixin.qq.com`.
2. Media files (images, files, videos) are transferred via CDN with AES-128-ECB encryption, handled automatically by the program.
3. A stable network connection is recommended to avoid frequent disconnections that would require re-scanning.

View File

@@ -1,102 +0,0 @@
---
title: General Commands
description: View status, manage config, and control context with commonly used commands
---
The following commands can be used in chat with the `/` prefix or in the terminal with the `cow` prefix (some are chat-only).
<Tip>
In the Web console, typing `/` brings up an autocomplete menu with keyboard navigation and Tab completion.
</Tip>
## help
Show help information for all available commands.
```text
/help
```
## status
View current session and service status, including process info, model configuration, message count, and loaded skills.
```text
/status
```
## config
View or modify runtime configuration. Changes take effect immediately without restarting.
**View all configurable items:**
```text
/config
```
**View a single item:**
```text
/config model
```
**Modify a config item:**
```text
/config model deepseek-v4-flash
```
**Configurable items:**
| Item | Description | Example |
| --- | --- | --- |
| `model` | AI model name | `deepseek-v4-flash` |
| `agent_max_context_tokens` | Max context tokens | `40000` |
| `agent_max_context_turns` | Max context memory turns | `30` |
| `agent_max_steps` | Max decision steps per task | `15` |
| `enable_thinking` | Enable deep thinking mode | `true` / `false` |
<Note>
When changing `model`, the system automatically matches the corresponding model API. Configuration is persisted to `config.json`.
</Note>
## context
View current session context statistics, including message count and content length.
```text
/context
```
**Clear current session context:**
```text
/context clear
```
<Tip>
Clearing context makes the Agent "forget" previous conversation, useful for switching topics or freeing context space.
</Tip>
## logs
View recent service logs. Shows the last 20 lines by default, up to 50.
```text
/logs
```
**Specify line count:**
```text
/logs 50
```
## version
Show the current CowAgent version.
```text
/version
```

View File

@@ -1,94 +0,0 @@
---
title: Commands Overview
description: CowAgent command system — Terminal CLI and chat commands
---
CowAgent provides two ways to interact via commands:
- **Terminal CLI** — Run `cow <command>` in your system terminal for service management, skill management, and other operations
- **Chat Commands** — Type `/<command>` or `cow <command>` in any conversation to check status, manage skills, adjust configuration, etc.
## Cow CLI
After deploying with the one-click install script, the `cow` command is automatically available. For manual installations, run:
```bash
pip install -e .
```
Then use the `cow` command from anywhere:
```bash
cow help
```
Example output:
```
🐮 CowAgent CLI
Usage: cow <command>
Service:
start Start the CowAgent service
stop Stop the CowAgent service
restart Restart the CowAgent service
update Update code and restart service
status Show service status
logs View service logs
Skills:
skill Manage skills (list / search / install / uninstall ...)
Memory & Knowledge:
memory Memory distillation (dream)
knowledge View knowledge base stats and structure
Others:
help Show this help message
version Show version
```
## Chat Commands
In the Web console or any connected channel, type `/` to see command suggestions. Supported commands:
| Command | Description |
| --- | --- |
| `/help` | Show command help |
| `/status` | View service status and configuration |
| `/config` | View or modify runtime configuration |
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |
| `/knowledge` | View knowledge base statistics |
| `/knowledge list` | View knowledge base directory structure |
| `/knowledge on\|off` | Enable or disable knowledge base |
| `/context` | View current session context info |
| `/context clear` | Clear current session context |
| `/logs` | View recent logs |
| `/version` | Show version number |
<Tip>
Service management commands like `/start`, `/stop`, `/restart` will prompt you to use them in the terminal instead, as they involve process operations.
</Tip>
## Command Availability
| Command | Terminal (`cow`) | Chat (`/`) |
| --- | :---: | :---: |
| help | ✓ | ✓ |
| version | ✓ | ✓ |
| status | ✓ | ✓ |
| logs | ✓ | ✓ |
| config | ✗ | ✓ |
| context | — | ✓ |
| memory (subcommands) | ✗ | ✓ |
| knowledge (subcommands) | ✓ | ✓ |
| skill (subcommands) | ✓ | ✓ |
| start / stop / restart | ✓ | ✗ |
| update | ✓ | ✗ |
| install-browser | ✓ | ✗ |
<Note>
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
</Note>

View File

@@ -1,63 +0,0 @@
---
title: Memory & Knowledge
description: Memory distillation and knowledge base management commands
---
## memory
Manage the Agent's long-term memory system.
### memory dream
Manually trigger memory distillation (Deep Dream) — consolidate recent daily memories into MEMORY.md and generate a dream diary.
```text
/memory dream [N]
```
- `N`: Consolidate the last N days of memory (default 3, max 30)
- Runs asynchronously in the background; you'll be notified in chat when complete
- Works without Agent initialization — can be used before the first conversation
**Examples:**
```text
/memory dream # Consolidate last 3 days
/memory dream 7 # Consolidate last 7 days
/memory dream 30 # Consolidate last 30 days (full)
```
On the Web console, the completion notification includes clickable links to view the updated MEMORY.md and dream diary.
<Tip>
The system automatically runs distillation daily at 23:55 (lookback 1 day). Manual trigger is useful for consolidating historical memories after first deployment, or when you need an immediate memory update.
</Tip>
## knowledge
View and manage the personal knowledge base. Shows statistics by default.
```text
/knowledge
```
### knowledge list
View the knowledge base directory tree.
```text
/knowledge list
```
### knowledge on / off
Enable or disable the knowledge base. When disabled, knowledge prompts and file indexing are not injected.
```text
/knowledge on
/knowledge off
```
<Note>
In the terminal CLI, `cow knowledge` and `cow knowledge list` are available, but `on|off` is only supported in chat (requires runtime effect).
</Note>

View File

@@ -1,123 +0,0 @@
---
title: Process Management
description: Manage CowAgent process lifecycle with cow commands
---
Process management commands control the CowAgent background process. These commands are only available in the terminal.
## start
Start the CowAgent service. Runs as a background daemon by default and automatically tails logs.
```bash
cow start
```
**Options:**
| Option | Description |
| --- | --- |
| `-f`, `--foreground` | Run in foreground, not as a background daemon |
| `--no-logs` | Don't tail logs after starting |
## stop
Stop the running CowAgent service.
```bash
cow stop
```
## restart
Restart the CowAgent service (stop then start).
```bash
cow restart
```
**Options:**
| Option | Description |
| --- | --- |
| `--no-logs` | Don't tail logs after restart |
## update
Update code and restart the service. Automatically performs:
1. Pull latest code (`git pull`)
2. Stop current service
3. Update Python dependencies
4. Reinstall CLI
5. Start service
```bash
cow update
```
<Warning>
If `git pull` fails (e.g., uncommitted local changes), the update aborts and the service remains unaffected.
</Warning>
## status
Check CowAgent service status, including process info, version, and current model/channel configuration.
```bash
cow status
```
## logs
View service logs.
```bash
cow logs
```
**Options:**
| Option | Description | Default |
| --- | --- | --- |
| `-f`, `--follow` | Continuously tail log output | No |
| `-n`, `--lines` | Show last N lines | 50 |
Examples:
```bash
# View last 100 lines
cow logs -n 100
# Continuously tail logs
cow logs -f
```
## install-browser
Install Playwright and Chromium browser for the [browser tool](/en/tools/browser).
```bash
cow install-browser
```
<Tip>
Only needed when using browser tools (web browsing, screenshots, etc.).
</Tip>
## run.sh Compatibility
If Cow CLI is not installed, you can use `run.sh` to manage the service:
| cow command | run.sh equivalent |
| --- | --- |
| `cow start` | `./run.sh start` |
| `cow stop` | `./run.sh stop` |
| `cow restart` | `./run.sh restart` |
| `cow update` | `./run.sh update` |
| `cow status` | `./run.sh status` |
| `cow logs` | `./run.sh logs` |
<Note>
The `cow` command is recommended — it provides cleaner syntax and richer features. It is automatically installed via the one-click install script.
</Note>

View File

@@ -1,192 +0,0 @@
---
title: Skill Management
description: Install, uninstall, enable, disable, and manage skills via commands
---
Skill management commands are used to install, query, and manage CowAgent skills. Use `/skill <subcommand>` in chat or `cow skill <subcommand>` in the terminal.
## list
List installed skills and their status.
<CodeGroup>
```text Chat
/skill list
```
```bash Terminal
cow skill list
```
</CodeGroup>
**Browse the Skill Hub** (view all available skills):
<CodeGroup>
```text Chat
/skill list --remote
```
```bash Terminal
cow skill list --remote
```
</CodeGroup>
**Options:**
| Option | Description | Default |
| --- | --- | --- |
| `--remote`, `-r` | Browse Skill Hub remote skill list | No |
| `--page` | Page number for remote listing | 1 |
## search
Search for skills on the Skill Hub.
<CodeGroup>
```text Chat
/skill search pptx
```
```bash Terminal
cow skill search pptx
```
</CodeGroup>
## install
Install skills with a single `install` command from Cow Skill Hub, GitHub, ClawHub, or any URL (zip archives, SKILL.md links) — no manual download or configuration required.
**From Skill Hub (recommended):**
<CodeGroup>
```text Chat
/skill install pptx
```
```bash Terminal
cow skill install pptx
```
</CodeGroup>
**From GitHub:**
<CodeGroup>
```text Chat
# Install all skills in a repo (auto-discovers subdirectories with SKILL.md)
/skill install larksuite/cli
# Specify a subdirectory to install a single skill
/skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
# Use # to specify a subdirectory
/skill install larksuite/cli#skills/lark-minutes
```
```bash Terminal
# Install all skills in a repo (auto-discovers subdirectories with SKILL.md)
cow skill install larksuite/cli
# Specify a subdirectory to install a single skill
cow skill install https://github.com/larksuite/cli/tree/main/skills/lark-im
# Use # to specify a subdirectory
cow skill install larksuite/cli#skills/lark-minutes
```
</CodeGroup>
Supports full GitHub URLs and `owner/repo` shorthand. For mono-repos (multiple skills in one repository), omitting the subdirectory auto-discovers and batch-installs all skills; specifying a subdirectory installs only that skill.
**From ClawHub:**
<CodeGroup>
```text Chat
/skill install clawhub:baidu-search
```
```bash Terminal
cow skill install clawhub:baidu-search
```
</CodeGroup>
**From URL:**
<CodeGroup>
```text Chat
# Install from a zip archive (single or batch)
/skill install https://cdn.link-ai.tech/skills/pptx.zip
# Install from a SKILL.md link
/skill install https://example.com/path/to/SKILL.md
```
```bash Terminal
# Install from a zip archive (single or batch)
cow skill install https://cdn.link-ai.tech/skills/pptx.zip
# Install from a SKILL.md link
cow skill install https://example.com/path/to/SKILL.md
```
</CodeGroup>
Supports installing from zip / tar.gz archive URLs — automatically extracts and discovers directories containing `SKILL.md`, with support for single or batch install. Also supports installing directly from a `SKILL.md` file URL, automatically parsing the skill name and description.
## uninstall
Uninstall an installed skill.
<CodeGroup>
```text Chat
/skill uninstall pptx
```
```bash Terminal
cow skill uninstall pptx
```
</CodeGroup>
<Warning>
Uninstalling deletes all files in the skill directory. This action cannot be undone.
</Warning>
## enable / disable
Enable or disable a skill. Disabled skills will not be invoked by the Agent.
<CodeGroup>
```text Chat
/skill enable pptx
/skill disable pptx
```
```bash Terminal
cow skill enable pptx
cow skill disable pptx
```
</CodeGroup>
## info
View details of an installed skill, including a preview of its `SKILL.md`.
<CodeGroup>
```text Chat
/skill info pptx
```
```bash Terminal
cow skill info pptx
```
</CodeGroup>
## Skill Sources
Installed skills track their origin, viewable via `/skill list`:
| Source | Description |
| --- | --- |
| `builtin` | Built-in project skills |
| `cowhub` | Installed from CowAgent Skill Hub |
| `github` | Installed directly from a GitHub URL |
| `clawhub` | Installed from ClawHub |
| `url` | Installed from a SKILL.md URL |
| `local` | Locally created skills |

View File

@@ -1,146 +0,0 @@
---
title: Manual Install
description: Deploy CowAgent manually (source code / Docker)
---
## Source Code Deployment
### 1. Clone the project
```bash
git clone https://github.com/zhayujie/CowAgent
cd CowAgent/
```
<Tip>
For network issues, use the mirror: https://gitee.com/zhayujie/CowAgent
</Tip>
### 2. Install dependencies
Core dependencies (required):
```bash
pip3 install -r requirements.txt
```
Optional dependencies (recommended):
```bash
pip3 install -r requirements-optional.txt
```
### 3. Install Cow CLI
Install the command-line tool for managing services and skills:
```bash
pip3 install -e .
```
Then use the `cow` command:
```bash
cow help
```
<Note>
This step is recommended. After installation you can use `cow start`, `cow stop`, `cow update` to manage the service, and `cow skill` to manage skills. Without the CLI, you can use `./run.sh` or `python3 app.py` to run.
</Note>
### 4. Configure
Copy the config template and edit:
```bash
cp config-template.json config.json
```
Fill in model API keys, channel type, and other settings in `config.json`. See the [model docs](/en/models/index) for details.
### 5. Run
**Using Cow CLI (recommended):**
```bash
cow start
```
**Or run locally in foreground:**
```bash
python3 app.py
```
By default, the Web console starts. Access `http://localhost:9899` to chat.
**Background run on server (without CLI):**
```bash
nohup python3 app.py & tail -f nohup.out
```
<Tip>
If deploying on a server, open port `9899` in your firewall or security group to access the Web console. It's recommended to restrict access to specific IPs for security.
</Tip>
## Docker Deployment
Docker deployment does not require cloning source code or installing dependencies. For Agent mode, source deployment is recommended for broader system access.
<Note>
Requires [Docker](https://docs.docker.com/engine/install/) and docker-compose.
</Note>
**1. Download config**
```bash
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
```
Edit `docker-compose.yml` with your configuration.
**2. Start container**
```bash
sudo docker compose up -d
```
**3. View logs**
```bash
sudo docker logs -f chatgpt-on-wechat
```
<Tip>
If deploying on a server, open port `9899` in your firewall or security group to access the Web console. It's recommended to restrict access to specific IPs for security.
</Tip>
## Core Configuration
```json
{
"channel_type": "web",
"model": "deepseek-v4-flash",
"deepseek_api_key": "",
"agent": true,
"agent_workspace": "~/cow",
"agent_max_context_tokens": 40000,
"agent_max_context_turns": 30,
"agent_max_steps": 15
}
```
| Parameter | Description | Default |
| --- | --- | --- |
| `channel_type` | Channel type | `web` |
| `model` | Model name | `deepseek-v4-flash` |
| `agent` | Enable Agent mode | `true` |
| `agent_workspace` | Agent workspace path | `~/cow` |
| `agent_max_context_tokens` | Max context tokens | `40000` |
| `agent_max_context_turns` | Max context turns | `30` |
| `agent_max_steps` | Max decision steps per task | `15` |
<Tip>
Full configuration options are in the project [`config.py`](https://github.com/zhayujie/CowAgent/blob/master/config.py).
</Tip>

View File

@@ -1,54 +0,0 @@
---
title: One-click Install
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).
## Install Command
<Tabs>
<Tab title="Linux / macOS">
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
</Tab>
<Tab title="Windows (PowerShell)">
```powershell
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
```
</Tab>
</Tabs>
The script automatically performs these steps:
1. Check Python environment (requires Python 3.7+)
2. Install required tools (git, curl, etc.)
3. Clone project to `~/CowAgent`
4. Install Python dependencies and Cow CLI
5. Guided configuration for AI model and channel
6. Start service
By default, the Web console starts after installation. Access `http://localhost:9899` to begin chatting.
## Management Commands
After installation, use the `cow` command to manage the service:
| Command | Description |
| --- | --- |
| `cow start` | Start service |
| `cow stop` | Stop service |
| `cow restart` | Restart service |
| `cow status` | Check run status |
| `cow logs` | View real-time logs |
| `cow update` | Update code and restart |
| `cow install-browser` | Install browser tool dependencies |
See the [Commands documentation](/en/cli/index) for more details.
<Note>
If the `cow` command is not available, you can use `./run.sh <command>` (Linux/macOS) or `.\scripts\run.ps1 <command>` (Windows) as a fallback. Both are functionally equivalent.
</Note>

View File

@@ -1,83 +0,0 @@
---
title: Architecture
description: CowAgent 2.0 system architecture and core design
---
CowAgent 2.0 has evolved from a simple chatbot into a super intelligent assistant with Agent architecture, featuring autonomous thinking, task planning, long-term memory, and skill extensibility.
## System Architecture
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" />
| 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 |
| **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 |
| **Channels** | Message channel layer for receiving and sending messages. Supports Web console, WeChat, Feishu, DingTalk, WeCom, WeChat Official Account, and more with a unified protocol |
| **CLI** | Command-line system providing terminal commands (`cow`) and chat commands (`/`) for process management, skill installation, configuration, knowledge base management, and more |
## Agent Mode Workflow
When Agent mode is enabled, CowAgent runs as an autonomous agent with the following workflow:
1. **Receive Message** — Receive user input through channels
2. **Understand Intent** — Analyze task requirements and context
3. **Plan Task** — Break complex tasks into multiple steps
4. **Invoke Tools** — Select and execute appropriate tools for each step
5. **Update Memory & Knowledge** — Store important information in long-term memory and organize structured knowledge into the knowledge base
6. **Return Result** — Send execution results back to the user
## Workspace Directory Structure
The Agent workspace is located at `~/cow` by default and stores system prompts, memory files, and skill files:
```
~/cow/
├── system.md # Agent system prompt
├── user.md # User profile
├── MEMORY.md # Core memory
├── memory/ # Long-term memory storage
│ └── YYYY-MM-DD.md # Daily memory
├── knowledge/ # Personal knowledge base
│ ├── index.md # Knowledge index
│ └── <category>/ # Topic-based pages
└── skills/ # Custom skills
├── skill-1/
└── skill-2/
```
Secret keys are stored separately in `~/.cow` directory for security:
```
~/.cow/
└── .env # Secret keys for skills
```
## Core Configuration
Configure Agent mode parameters in `config.json`:
```json
{
"agent": true,
"agent_workspace": "~/cow",
"agent_max_context_tokens": 40000,
"agent_max_context_turns": 30,
"agent_max_steps": 15
}
```
| Parameter | Description | Default |
| --- | --- | --- |
| `agent` | Enable Agent mode | `true` |
| `agent_workspace` | Workspace path | `~/cow` |
| `agent_max_context_tokens` | Max context tokens | `40000` |
| `agent_max_context_turns` | Max context turns | `30` |
| `agent_max_steps` | Max decision steps per task | `15` |
| `knowledge` | Enable personal knowledge base | `true` |

View File

@@ -1,139 +0,0 @@
---
title: Features
description: CowAgent long-term memory, task planning, skills system, CLI commands, and browser tool in detail
---
## 1. Long-term Memory
The memory system enables the Agent to remember important information over time, using a three-tier memory flow: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term), forming a complete memory lifecycle.
On first launch, the Agent proactively asks the user for key information and records it in the workspace (default `~/cow`) — including agent settings, user identity, and memory files.
In subsequent long-term conversations, the Agent intelligently stores or retrieves memory as needed, continuously updating its own settings, user preferences, and memory files. **Deep Dream** distillation runs daily, consolidating scattered daily memories into refined long-term memory and generating a narrative-style dream diary.
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260203000455.png" width="800" />
</Frame>
See [Long-term Memory](/en/memory) and [Deep Dream](/en/memory/deep-dream) for details.
## 2. Personal Knowledge Base
> The knowledge base system enables the Agent to continuously accumulate and organize structured knowledge. Unlike memory which records along a timeline, the knowledge base is organized by topics, transforming articles, conversation insights, and learning materials into interconnected Markdown pages that form a continuously growing knowledge network.
The Agent automatically organizes valuable information from conversations into knowledge pages, maintaining cross-references and indexes. The Web console provides document browsing and knowledge graph visualization. Knowledge is stored in `~/cow/knowledge/` within the workspace.
- **Auto-organization**: The Agent autonomously extracts and organizes structured knowledge during conversations, maintaining indexes and cross-references
- **Knowledge graph**: Automatically builds a knowledge graph from cross-references between pages, with interactive graph visualization in the Web console
- **Chat integration**: Knowledge document links referenced in Agent replies can be clicked directly in the Web console for viewing
- **CLI management**: Use `/knowledge` commands to view stats, browse directory, and toggle the feature with `/knowledge on|off`
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260413105435.png" width="800" />
</Frame>
See [Personal Knowledge Base](/en/knowledge) for details.
## 3. Task Planning and Tool Use
Tools are the core of how the Agent accesses operating system resources. The Agent intelligently selects and invokes tools based on task requirements, performing file read/write, command execution, scheduled tasks, and more. Built-in tools are implemented in the project's `agent/tools/` directory.
**Key tools:** file read/write/edit, Bash terminal, browser, file send, scheduler, memory search, web search, environment config, and more.
### 3.1 Terminal and File Access
Access to the OS terminal and file system is the most fundamental and core capability. Many other tools and skills build on top of this. Users can interact with the Agent from a mobile device to operate resources on their personal computer or server:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202181130.png" width="800" />
</Frame>
### 3.2 Programming Capability
Combining programming and system access, the Agent can execute the complete **Vibecoding workflow** — from information search, asset generation, coding, testing, deployment, Nginx configuration, to publishing — all triggered by a single command from your phone:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260318211018.png" width="800" />
</Frame>
### 3.3 Scheduled Tasks
The `scheduler` tool enables dynamic scheduled tasks, supporting **one-time tasks, fixed intervals, and Cron expressions**. Tasks can be triggered as either a **fixed message send** or an **Agent dynamic task** execution:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
</Frame>
### 3.4 Browser
The built-in `browser` tool allows the Agent to control a Chromium browser to visit web pages, fill forms, click elements, and take screenshots, with support for dynamic JS-rendered pages. Run `cow install-browser` to install with one command, automatically adapting to server (headless) and desktop environments:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260401110103.png" width="800" />
</Frame>
### 3.5 Environment Variable Management
Secrets required by skills are stored in an environment variable file, managed by the `env_config` tool. You can update secrets through conversation, with built-in security protection and desensitization:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202234939.png" width="800" />
</Frame>
## 4. Skills System
The Skills system provides infinite extensibility for the Agent. Each Skill consists of a description file, execution scripts (optional), and resources (optional), describing how to complete specific types of tasks. Skills allow the Agent to follow instructions for complex workflows, invoke tools, or integrate third-party systems.
- **[Skill Hub](https://skills.cowagent.ai/):** An open skill marketplace featuring official, community, and third-party skills. Install with one command.
- **Built-in skills:** Located in the project's `skills/` directory, including skill creator, image recognition, LinkAI agent, web fetch, and more. Built-in skills are automatically enabled based on dependency conditions (API keys, system commands, etc.).
- **Custom skills:** Created by users through conversation, stored in the workspace (`~/cow/skills/`), capable of implementing any complex business process or third-party integration.
Install skills: `/skill install <name>` or `cow skill install <name>`, supporting Skill Hub, GitHub, ClawHub, URL, and more.
### 4.1 Creating Skills
The `skill-creator` skill enables rapid skill creation through conversation. You can ask the Agent to codify a workflow as a skill, or send any API documentation and examples for the Agent to complete the integration directly:
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202202247.png" width="800" />
</Frame>
### 4.2 Web Search and Image Recognition
- **Web search:** Built-in `web_search` tool, supports multiple search engines. Configure `BOCHA_API_KEY` or `LINKAI_API_KEY` to enable.
- **Image recognition:** Built-in `openai-image-vision` skill, supports `gpt-4.1-mini`, `gpt-4.1`, and other models. Requires `OPENAI_API_KEY`.
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202213219.png" width="800" />
</Frame>
### 4.3 Skill Hub
Visit [skills.cowagent.ai](https://skills.cowagent.ai/) to browse all available skills, or use commands in conversation:
```text
/skill list --remote # Browse Skill Hub
/skill search <keyword> # Search skills
/skill install <name> # Install with one command
```
Also supports installing skills from GitHub, ClawHub, LinkAI, and other third-party platforms. See [Install Skills](/en/skills/install) for details.
<img src="https://cdn.link-ai.tech/doc/20260401110103.png" width="750" />
## 5. CLI Command System
CowAgent provides two command interaction methods, covering service management, skill installation, configuration, and more:
- **Terminal CLI:** Run `cow <command>` in the system terminal, supporting `start`, `stop`, `restart`, `update`, `status`, `logs`, `skill`, etc.
- **Chat commands:** Type `/<command>` in conversation. The Web console shows a command menu when you type `/`.
```bash
cow start # Start service
cow stop # Stop service
cow update # Update and restart
cow skill install pptx # Install a skill
cow install-browser # Install browser tool
```
See [Command Overview](https://docs.cowagent.ai/en/cli) for details.

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