Compare commits

...

224 Commits
2.1.0 ... 2.1.3

Author SHA1 Message Date
zhayujie
bf0c26d3c4 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-07-08 15:56:20 +08:00
zhayujie
65970c564c feat: release 2.1.3 2026-07-08 15:55:56 +08:00
zhayujie
ce09efe640 Merge pull request #2943 from weijun-xia/fix/read-negative-offset
fix(read): correct off-by-one for negative offset with trailing newline
2026-07-08 10:59:30 +08:00
zhayujie
adaf9a7813 fix(i18n): correct language label glyphs 2026-07-08 10:58:41 +08:00
zhayujie
06f9492518 Merge pull request #2935 from anomixer/master
feat(i18n): 新增繁體中文 (zh-tw) 支援,並新增 Web 登出按鈕與動態顯示
2026-07-08 10:42:05 +08:00
zhayujie
530042675e Merge pull request #2942 from weijun-xia/fix/edit-fuzzy-preserve-whitespace
fix(edit): keep untouched lines intact on fuzzy match
2026-07-08 10:39:08 +08:00
zhayujie
ca404aeb24 fix(desktop): stop backend writing into the .app bundle 2026-07-08 01:59:08 +08:00
anomixer
a6c975f92c refactor(i18n): reply to upstream's #2935 feedback 2026-07-08 01:53:27 +08:00
anomixer
dd3cadbd81 Merge branch 'zhayujie:master' into master 2026-07-07 23:21:17 +08:00
zhayujie
efbabfcace feat: 2.1.3 pre-release 2026-07-07 22:32:10 +08:00
zhayujie
09c71ef1d9 feat(desktop): add colored icons to welcome suggestion cards 2026-07-07 22:28:11 +08:00
zhayujie
0bb8208f36 fix(desktop): restore install-dir choice on Windows first install 2026-07-07 21:48:43 +08:00
zhayujie
56571c77ca fix(desktop): Windows auto-update installs but doesn't relaunch 2026-07-07 20:14:11 +08:00
zhayujie
6c353d389b fix(desktop): switch Windows to oneClick NSIS 2026-07-07 18:22:10 +08:00
zhayujie
583217d396 fix(desktop): smoother update UX 2026-07-07 18:08:05 +08:00
xiaweiwei67-stack
93162d2f10 fix(edit): preserve file indentation when oldText is unindented
The fuzzy fallback anchored every line with a leading [ \t]* that
greedily consumed the file's indentation into the matched region, so a
no-indent oldText dropped the edited line's indentation. Fold leading
whitespace into the match only when oldText was itself indented on the
first line, mirroring exact-substring semantics. Add a regression test.
2026-07-07 17:24:58 +08:00
zhayujie
2e74295807 fix(desktop): auto-update install now actually replaces the app 2026-07-07 17:23:10 +08:00
zhayujie
93bf8844de Merge pull request #2941 from xiaweiwei67-stack/fix/bash-tempfile-utf8
fix(bash): write large-output temp file as UTF-8
2026-07-07 17:00:01 +08:00
xiaweiwei67-stack
fcc520df47 fix(read): correct off-by-one for negative offset with trailing newline
The Read tool documents that a negative offset reads from the end (-N = last N lines). Content is split on newline, so a file ending in a newline produces a trailing empty element and total_file_lines is one too high. Every negative offset was therefore off by one: offset=-1 returned the empty string after the final newline instead of the last line, and -N returned N-1 real lines.

Exclude the trailing empty element when computing the start line for negative offsets. Adds regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:53:35 +08:00
xiaweiwei67-stack
a94f4e3c18 fix(edit): keep untouched lines intact on fuzzy match
The Edit tool falls back to a whitespace-tolerant fuzzy match when oldText does not match byte-for-byte. On a fuzzy hit it replaced text inside a whitespace-normalized copy of the whole file and wrote that copy back, so every untouched line lost its original indentation (runs of spaces/tabs collapsed to a single space). For indentation-sensitive files such as Python this silently corrupts the file.

Locate the fuzzy match in the original content with a whitespace-flexible regex and return offsets into that original content, so only the matched region is replaced. Adds regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:50:16 +08:00
xiaweiwei67-stack
de9e7f0e84 fix(bash): write large-output temp file as UTF-8
When a command's output exceeds DEFAULT_MAX_BYTES the Bash tool spills the full output to a temp file. The file was opened in text mode without an explicit encoding, so it used the platform locale encoding (cp936/GBK on Chinese Windows). Output containing emoji or other characters not representable in that codepage raised UnicodeEncodeError, which propagated out and turned a successful command (exit code 0) into a tool error, discarding all output.

Open the temp file with encoding='utf-8', matching the sibling temp file written in _rewrite_long_python_c. Adds a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:45:37 +08:00
zhayujie
ed5b2d6ce6 fix: mac zip artifacts 2026-07-07 16:28:48 +08:00
zhayujie
2cd3fa7981 Merge pull request #2940 from fengyl07/feat/mcp-tool-retrieval
feat(mcp): on-demand vector retrieval for large MCP tool sets
2026-07-07 16:22:50 +08:00
fengyl07
f01cc3a0b4 test(mcp): cover retrieval helpers and executor injection paths 2026-07-07 16:01:38 +08:00
fengyl07
51bf09208d feat(agent): inject retrieved MCP tools in stream executor 2026-07-07 16:01:26 +08:00
fengyl07
bf0831a664 feat(mcp): cache MCP tool vectors with lazy embedding in ToolManager 2026-07-07 16:00:55 +08:00
fengyl07
96b1fccf76 feat(mcp): add stateless on-demand tool retrieval module 2026-07-07 16:00:31 +08:00
anomixer
4b57971d33 feat(i18n): support Traditional Chinese (zh-tw) for console, logs and docs 2026-07-07 15:52:32 +08:00
zhayujie
d531e14fbf feat(desktop): mac zip auto-update 2026-07-07 15:11:55 +08:00
zhayujie
8df38a23d2 fix(ci): run electron-builder in its own step so Windows produces installers 2026-07-07 12:32:04 +08:00
zhayujie
38105e6539 fix(desktop/ci): Windows build silently skipped + mac-x64 dmg not registered 2026-07-07 11:49:48 +08:00
zhayujie
14c6577d51 fix(desktop): make update badge/panel re-openable 2026-07-07 11:44:25 +08:00
zhayujie
f051a58db5 fix(desktop): add updater logging + allow prerelease 2026-07-06 20:16:20 +08:00
zhayujie
825d990312 fix(desktop): restore sent images/files from history after restart 2026-07-06 19:34:43 +08:00
zhayujie
cb31013584 build(desktop): decouple macOS notarization from CI into 3-stage release 2026-07-06 19:24:55 +08:00
zhayujie
dd74d1dabe Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-07-06 18:37:33 +08:00
zhayujie
75f3952ac6 feat(desktop): render sent images/videos/files 2026-07-06 18:35:33 +08:00
zhayujie
37423fbb31 Merge pull request #2936 from fengyl07/fix/read-env-bypass-2913
fix(read): block /proc/environ credential bypass (#2913)
2026-07-06 16:02:59 +08:00
fengyl07
00c3436d48 test(read): add regression tests for credential-path bypass (#2913) 2026-07-06 15:30:13 +08:00
fengyl07
377b4e5cb8 fix(read): block /proc/environ aliases and symlink bypass of credential file 2026-07-06 15:30:13 +08:00
zhayujie
a427586b89 fix(desktop): notarize .app in afterSign hook with fault-tolerant polling 2026-07-06 15:04:12 +08:00
zhayujie
a951494489 fix(desktop): notarize the .app zip 2026-07-06 10:19:54 +08:00
zhayujie
a871c0437d feat: add deep_dream_enabled config toggle 2026-07-05 21:31:16 +08:00
zhayujie
013960cd5a feat: update release flow 2026-07-05 21:10:43 +08:00
zhayujie
60aebf41a8 fix: optimize notarize script 2026-07-05 20:28:02 +08:00
zhayujie
2cf521e57e fix: exception handling in config eval 2026-07-05 12:35:24 +08:00
zhayujie
dad3a84efb Merge pull request #2927 from shunfeng8421/fix/eval-ast-literal-eval
security: replace eval() with ast.literal_eval + document pickle risk
2026-07-05 12:17:54 +08:00
zhayujie
ae864c7ff9 fix(desktop): notarize dmg in a retryable step to survive poll timeouts 2026-07-05 11:23:43 +08:00
zhayujie
3b33114a40 fix(desktop): sign embedded backend via mac.binaries for notarization 2026-07-03 14:59:31 +08:00
zhayujie
e0f49ac619 fix(desktop): sign backend by cert SHA-1 to fix CI keychain lookup 2026-07-03 12:16:44 +08:00
zhayujie
01ec49afd2 build(desktop): enable macOS signing & notarization for release 2026-07-03 12:01:42 +08:00
zhayujie
b44154fe02 desktop: add brand logo to the top-left corner on Windows/Linux 2026-07-01 18:38:45 +08:00
zhayujie
b8dad38622 desktop: fix model config dropdowns and provider listing 2026-07-01 16:28:40 +08:00
zhayujie
80fea77c86 feat(model): support claude-sonnet-5 2026-07-01 10:31:06 +08:00
zhayujie
e5f3eb48d4 desktop: make backend port deterministic (fixed + pre-launch cleanup) and reorder footer menu 2026-06-30 20:05:01 +08:00
zhayujie
ca876b0c65 fix(desktop): isolate backend port from the web console 2026-06-30 10:48:33 +08:00
zhayujie
0a762b8c08 fix(desktop): bundle cli command modules 2026-06-30 10:03:12 +08:00
zhayujie
fd90a89b45 desktop: improve channels page and titlebar UI 2026-06-29 19:52:46 +08:00
zhayujie
f82eb39d23 fix(desktop): stop send button icon flicker 2026-06-29 18:00:46 +08:00
zhayujie
2786148153 fix(desktop): match send button to web 2026-06-29 16:33:10 +08:00
zhayujie
2959cfea32 feat(desktop): align chat UI with web console 2026-06-29 12:17:35 +08:00
zhayujie
e536232963 fix(desktop): bundle document parsing libs 2026-06-29 11:07:08 +08:00
shunfeng8421
778d78cebe security: replace eval() with ast.literal_eval + document pickle risk
- config.py:418 — Replace eval(value) with ast.literal_eval() for
  environment variable config overrides. ast.literal_eval only parses
  Python literals and cannot execute arbitrary code, preventing
  environment-variable-based code injection.

- config.py:310-328 — Add security notes on pickle.load/dump usage.
  Pickle is safe here (local appdata file, same-process write/read),
  but notes suggest JSON migration or HMAC signing for future hardening.

Fixes: potential RCE via controlled environment variables
2026-06-29 07:33:33 +08:00
zhayujie
538281da51 feat(desktop): support plugin commands 2026-06-27 12:19:12 +08:00
zhayujie
12cd626949 fix(web): reclaim orphaned SSE streams to prevent fd leak #2924 2026-06-27 11:32:19 +08:00
zhayujie
ff64a7930e Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-06-27 11:14:00 +08:00
zhayujie
5d726fe340 fix(desktop): run bundled backend from writable data dir 2026-06-27 11:13:39 +08:00
zhayujie
e1834124d4 Merge pull request #2918 from Tunnello/fix-azure-openai-stream
fix: azure openai stream
2026-06-27 10:59:40 +08:00
Eric L
f49e965736 fix: azure openai stream config 2026-06-26 20:54:25 +08:00
Eric L
936eaf5939 fix: azure openai stream 2026-06-25 21:13:50 +08:00
zhayujie
7047b30e27 feat(models): support doubao-seed-2.1 series 2026-06-25 11:53:24 +08:00
zhayujie
5c67e970d1 feat: sync knowledge management to desktop 2026-06-25 11:43:19 +08:00
zhayujie
8023c4e8b7 feat(knowledge): consolidate actions into a New menu and refine docs layout 2026-06-25 11:19:33 +08:00
zhayujie
641b84519c Merge branch 'pr-2915' 2026-06-25 11:02:55 +08:00
zhayujie
0c8cb974e2 feat(knowledge): auto-maintain index.md, improve import UX, fix embedding provider
- Auto-rebuild knowledge/index.md from the real directory tree on
  create/import so it never drifts or loses documents (no longer relies
  on the agent hand-writing it).
- Auto-open the created/imported document in the tree after success.
- Add create_document status message, shorten EN action buttons, and
  localize the "insert template" content.
- Show filename for protected system files (index.md/log.md) in the tree
  instead of their H1 heading.
- Reuse a shared embedding-provider factory so knowledge index sync also
  gets vectors instead of degrading to keyword-only search.
2026-06-25 11:02:18 +08:00
zhayujie
915edbe145 fix(tools): make web SSRF protection opt-in, disabled by default 2026-06-24 19:40:55 +08:00
zhayujie
0c20c5c159 Merge pull request #2917 from zhayujie/feat-cow-desktop
feat: CowAgent desktop client
2026-06-24 19:26:06 +08:00
zhayujie
6211e63f90 fix(desktop): avoid false init failure after long background 2026-06-24 19:19:24 +08:00
zhayujie
44b61684ed feat(desktop): first-run onboarding, OS-language default, native polish 2026-06-24 16:38:51 +08:00
zhayujie
ab6f49a822 fix(desktop): correct electron-updater import for commonjs 2026-06-24 11:01:45 +08:00
zhayujie
02517e4a01 fix(ci): never mark pre-release versions as latest 2026-06-24 10:32:12 +08:00
zhayujie
2599966cf7 fix(ci): migrate retired macos-13 runner to macos-15-intel 2026-06-24 10:11:18 +08:00
zhayujie
6c68931892 fix(ci): publish per-platform instead of all-or-nothing 2026-06-24 10:04:05 +08:00
zhayujie
41855ed511 fix(ci): only enable mac signing when certificate secret is set
GitHub injects unset secrets as empty strings, and electron-builder treats
an empty CSC_LINK as a (broken) certificate path, aborting the mac build
with "desktop not a file". Export the signing vars only when non-empty so
unsigned builds fall back cleanly, matching local behavior. Windows builds
already passed; guarded the same way for consistency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:57:01 +08:00
zhayujie
02bc91f4af fix(ci): track desktop backend packaging sources
The global build/ gitignore rule was silently excluding the PyInstaller
spec, desktop requirements, and build script under desktop/build/, so the
release workflow failed at "pip install -r requirements-desktop.txt" with
a missing-file error. Re-include just those source files while keeping the
build outputs (dist/, build-work/) and local venv ignored.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:46:14 +08:00
zhayujie
e9352e6984 feat(desktop): add auto-update via electron-updater + manual CI trigger 2026-06-23 20:37:27 +08:00
zhayujie
ec4c36f450 feat(desktop): redirect writable data to ~/.cow for packaged app
Introduce get_data_root() driven by the COW_DATA_DIR env var so the
packaged desktop build stores config.json, run.log, user data and
WeChat credentials under ~/.cow — surviving app updates and keeping the
app bundle read-only. Source deployments leave COW_DATA_DIR unset and
fall back to the repo root, so existing behavior is unchanged.
2026-06-23 17:22:53 +08:00
yangziyu-hhh
9ea0017778 Merge remote-tracking branch 'upstream/master'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-06-23 17:18:24 +08:00
yangziyu-hhh
f1cdc2d2cc feat(knowledge): add document creation and import 2026-06-22 17:58:33 +08:00
zhayujie
215ed24401 feat(desktop): add log viewer entry points (NavRail + Help menu) 2026-06-22 16:30:11 +08:00
zhayujie
c432681b2b fix(desktop): prevent backend zombie process and IPC listener leaks 2026-06-22 15:46:49 +08:00
zhayujie
49452e035d feat(desktop): native desktop enhancements
- Add a minimal application menu with common items and shortcuts:
  New Chat (Cmd+N), Settings (Cmd+,), Reload, etc.
- Add system tray with show window, new chat, and quit; click icon to restore
- Add single-instance lock so relaunching focuses the existing window
- Implement close-to-tray: closing the window hides it; only a real quit destroys it
- Explicitly define Window menu's Close Window bound to Cmd+W / Ctrl+W to reliably trigger hide-to-tray
- Listen for menu-action in the renderer to handle menu-triggered new chat / open settings
2026-06-22 15:32:22 +08:00
zhayujie
5db2998e3d fix: update web console version 2026-06-22 13:04:15 +08:00
zhayujie
d1336b872e feat(desktop): rework Skills page with semantic theming 2026-06-21 19:33:11 +08:00
zhayujie
e1e29b32e9 feat(desktop): add QR scan login for channels 2026-06-21 17:27:18 +08:00
zhayujie
214dcaf141 feat(desktop): rework Scheduled Tasks page with edit & delete 2026-06-20 17:34:50 +08:00
zhayujie
77a196de8b feat(desktop): rework Memory page to match web console 2026-06-20 17:08:23 +08:00
zhayujie
108d04398b feat(desktop): implement Knowledge Base page 2026-06-20 16:35:06 +08:00
zhayujie
0bc0f2b930 fix(evolution): prevent MCP tool re-injection into restricted review agent #2904 2026-06-20 15:17:11 +08:00
zhayujie
c9c16298ec feat(desktop): merge settings and models into tabs; improve model config and chat scroll
Merge the "Models" menu item into the "Settings" page with top tabs
(Basic / Models), reducing one nav entry. Basic settings now only handles
provider + model selection; API key/base are consolidated into the Models
tab. Unconfigured providers are marked in the dropdown and guide the user
to configure them.

The Models tab covers all 7 capabilities (chat/vision/image/asr/tts/
embedding/search) with vendor credentials and routing. The vendor section
follows the web client's unified design: built-in and custom providers
share one grid, "set as default" is removed, and credentials are configured
via modals.

- Add settings/ directory: SettingsPage (tab shell), BasicSettings, ModelsTab,
  CapabilityCard, primitives, modelsHelpers; remove the old ConfigPage
- Rewrite ModelEntry/ModelProvider/CapabilityState/SearchCapabilityState in
  types.ts to match the real /api/models shape (mixed string|{value,hint})
- Fix empty chat model dropdown: chat has no provider_models, so fall back
  to the top-level providers[].models
- Fix chat scroll sliding from top on session switch: snap instantly to
  bottom on switch, smooth-scroll only for streaming updates
- Rename the Knowledge menu label and align model/config copy with the web
  client (vendor credentials, main model, etc.)
- Clean up orphaned i18n keys
2026-06-20 11:35:47 +08:00
zhayujie
2ef31d5d33 fix(desktop): adapt remaining pages to updated types 2026-06-20 00:40:15 +08:00
zhayujie
e9d9b566a4 feat(desktop): chat core with streaming, sessions, tool steps and markdown-it rendering 2026-06-20 00:40:11 +08:00
zhayujie
3baa3252bc feat(desktop): align API client and types with latest web backend endpoints 2026-06-20 00:39:47 +08:00
zhayujie
90d9db0f83 feat(desktop): platform-aware shell, design tokens and three-column layout 2026-06-20 00:39:38 +08:00
zhayujie
8bff4f1658 Merge branch 'master' into feat-cow-desktop 2026-06-19 21:35:29 +08:00
zhayujie
a0e20ef311 Merge branch 'pr-2909' 2026-06-19 21:07:32 +08:00
zhayujie
6996215d3b feat(models): custom model explanation standardization 2026-06-19 21:06:56 +08:00
zhayujie
03ffa2db7d refactor(web): drop hardcoded preset models for custom providers & fix handler bug
- Remove vendor-specific preset model lists for custom embedding/vision
  providers; users now type the model id manually (lists vary per vendor).
- Strip third-party vendor names from comments.
- Fix AttributeError in _set_vision/_set_embedding: the provider constants
  live on ModelsHandler, not ConfigHandler, so selecting a built-in
  provider crashed. Also replace the fragile [:-1] slice with an explicit
  non-custom filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 20:59:10 +08:00
zhayujie
75e3110e8c Merge pull request #2905 from Jiangrong-W/harness-fix/net-egress-cowagent-browser-navigate-no-internal-url-guard
fix(browser): block link-local / cloud-metadata navigation (SSRF guard)
2026-06-19 20:36:28 +08:00
christop
033480eef1 fix(browser): block link-local / cloud-metadata navigation (SSRF guard)
The browser tool navigates to a model-supplied URL via Playwright
page.goto and then auto-snapshots the page back to the model. The
http(s) navigate path performed no filtering, so a model tool call
(including under prompt injection) could point the agent-driven browser
at the cloud-metadata endpoint (169.254.169.254) and read the
credentials back through the snapshot.

Unlike the vision/web_fetch tools, the browser legitimately needs local
pages — a dev server on localhost / 127.0.0.1 / a LAN IP — so a blanket
"block all internal" policy is the wrong default here. The guard is
therefore narrow: it resolves the hostname and rejects only link-local
addresses (169.254.0.0/16, which includes the 169.254.169.254 metadata
endpoint, and IPv6 fe80::/10) plus the AWS IPv6 IMDS address
(fd00:ec2::254), before navigation. Loopback and RFC1918/LAN stay
reachable so local dev works out of the box.

Only http/https targets are validated; the documented non-HTTP scheme
handling (about:/data:/file:) is unchanged. An operator who deliberately
needs the link-local/metadata target can opt out with
tools.browser.allow_private_targets = true.

tests/test_security_ssrf_browser_navigate.py asserts link-local/metadata
targets are blocked while loopback, RFC1918/LAN and public targets
navigate through (browser service and DNS are stubbed; no real
browser/network).

Signed-off-by: christop <825583681@qq.com>
2026-06-19 20:15:27 +08:00
HnBigVolibear
8ddfcbb125 feat(web): add custom provider support for embedding & vision models, and fix memory_get Windows path bug!
1. Embedding model: support custom provider
   - Add "custom" entry to EMBEDDING_VENDORS with supports_dim_param=False
   - Parse custom:<id> credentials and model fallback in agent_initializer
   - Expand custom_providers as custom:<id> entries in Web UI dropdown

2. Vision model: support custom provider
   - Add custom:<id> routing in _route_by_provider_id
   - Add _build_custom_provider reading credentials from custom_providers
   - Expand custom_providers in Web UI dropdown, add validation in _set_vision

3. Fix memory_get Windows path validation bug!
   - str.startswith(path+'/') always False on Windows due to backslashes, So All Users can not use "memory_get" tool in Windows.
   - Use os.path.realpath + os.sep, consistent with MemoryService

4. Fix historical needsModel:false bug preventing embedding provider switch
   - Change embedding needsModel to true in console.js
   - Support custom:<id> resolution in cow_cli /memory status, also for adding custom provider support

Closes #2908, Closes #2880
2026-06-19 18:35:44 +08:00
zhayujie
a5aaecc48d Merge pull request #2901 from 6vision/master
docs(wecom_bot): mention webhook (callback) mode in channel docs
2026-06-19 17:12:13 +08:00
yangziyu-hhh
84d6848e67 feat(knowledge): add document creation and import 2026-06-18 16:06:41 +08:00
zhayujie
01373465b0 fix(web): correct Bridge import path in MessageDeleteHandler #2902 2026-06-17 22:04:24 +08:00
6vision
a1e733080d docs(wecom_bot): mention webhook (callback) mode in channel docs 2026-06-17 20:37:51 +08:00
zhayujie
3b3ef715bb feat: update 2.1.2 release docs 2026-06-17 19:09:47 +08:00
zhayujie
47b2bf9d46 Merge pull request #2900 from Jr61-star/harness-fix/net-egress-cowagent-web-fetch-no-private-ip-guard
fix(web_fetch): add SSRF guard for model-supplied URLs
2026-06-17 17:47:48 +08:00
christop
ea47f3097e fix(web_fetch): add SSRF guard for model-supplied URLs
web_fetch fetched any http/https URL a model emitted, checking only the
scheme. It performed requests.get(..., allow_redirects=True) with no
hostname resolution check and no private/loopback/link-local/cloud-metadata
filtering, and never re-validated redirect targets. A model (including one
under prompt injection) could make CowAgent fetch 127.0.0.1, RFC1918,
169.254.169.254 or other internal endpoints and return their bodies into
the conversation; a public URL could also 302-bounce into a private target.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Trigger: background idle scan, fires when a session is idle >= N min AND
  (>= N turns OR context usage > 80%). In-memory cursor reviews only new
  messages so a session never re-learns old content.
- Isolated review agent: same model, restricted toolset, hard write-guard
  confining edits to the workspace (built-in skills are protected).
- Safety: file-level backup before edits + evolution_undo tool; notify the
  user ONLY when a workspace file actually changed (no-nag rule); capped
  concurrency.
- Records to memory/evolution/<date>.md, surfaced in the memory UI's
  renamed "Self-Evolution" tab (merged with dream diaries).
- Hide internal [SCHEDULED]/[EVOLUTION]/backup_id markers from chat history
  display (also fixes scheduler marker leakage) while keeping them in stored
  content for undo.
- Flat config: self_evolution_enabled (default off until release),
  self_evolution_idle_minutes (15), self_evolution_min_turns (6).
- Tests: tests/test_evolution.py (stub + real model modes, 7 scenarios).
2026-06-07 18:55:33 +08:00
zhayujie
0e4da1d1c5 feat(cli): show project path in cow status 2026-06-06 19:06:19 +08:00
zhayujie
72847e0711 feat(i18n): order channel list by UI language 2026-06-06 19:00:38 +08:00
zhayujie
3c19614c74 refactor(web-console): polish message actions on bubbles after #2865 2026-06-06 16:07:31 +08:00
zhayujie
a2e4955116 Merge pull request #2865 from core-power/feat/web-console-improvements
feat: message management and code block enhancements
2026-06-06 15:54:28 +08:00
PF4YZYNS\admin
c62175c06b - Add edit/delete/regenerate for user and bot messages
- Add language labels and copy buttons to code blocks
- Enhance drag-and-drop to full chat view
- Fix data consistency bugs in message operations
- Use RLock to prevent deadlock in conversation store"
2026-06-05 18:51:35 +08:00
zhayujie
fde4b6f590 Merge pull request #2863 from orbisai0security/fix/bash-credential-path-v2
fix(bash): narrow credential-file block to ~/.cow/.env only
2026-06-05 15:46:59 +08:00
zhayujie
3d7c68bac6 fix(wechatmp): reject webhook requests when wechatmp_token is empty 2026-06-05 15:14:28 +08:00
zhayujie
72a477f10c fix(models): route mimo-* models to MiMo bot in agent mode 2026-06-05 14:46:16 +08:00
OrbisAI Security
2a16c562a8 fix(bash): narrow credential-file block to ~/.cow/.env only
Replace the broad `~/.cow` directory check with a regex that matches
only the credential file path (`\.cow[/\\]\.env`), so legitimate access
to other `~/.cow/` subdirectories (e.g. skills) is no longer blocked.

Drop the incomplete env/printenv blocking rule per reviewer feedback.

Rewrite test_invariant_bash.py to use the correct Bash().execute()
API and cover both the blocked and allowed cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:36:22 +05:30
zhayujie
2b670e73f3 docs: update README.md 2026-06-04 23:17:37 +08:00
zhayujie
3994594019 docs: update badge in README.md 2026-06-04 22:43:45 +08:00
zhayujie
39c9386b54 Merge pull request #2859 from xliu123321/fix/mcp-stdio-windows
fix(mcp): replace select.select with queue.Queue for cross-platform stdio I/O
2026-06-04 11:51:08 +08:00
liusk
4cc57cc08d fix(mcp): enable concurrent calls for SSE and streamable-http transports
_stdio_send (single pipe) must remain serialized under _call_lock,
but SSE and streamable-http use independent HTTP requests and can
safely execute concurrently across sessions.

- Scope _call_lock to stdio transport only
- Add _http_lock with double-checked pattern to protect _http_session_id
  initialization during concurrent streamable-http requests
2026-06-04 11:44:35 +08:00
liusk
639a3eac1e fix(mcp): replace select.select with queue.Queue for cross-platform stdio I/O
- Use reader thread + queue.Queue instead of select.select() which does not
  work with pipes on Windows (only sockets)
- Make MCP server timeout configurable via mcp.json (default 120s)
- Validate JSON-RPC response id to skip stale responses from timed-out calls
- Log MCP server stderr at WARNING level instead of DEBUG for visibility
2026-06-04 09:10:48 +08:00
zhayujie
79323358e5 feat: add X-Title header for linkai request 2026-06-03 17:42:57 +08:00
zhayujie
cdb093c74a fix(i18n): refine auto language fallback for deployments 2026-06-03 16:09:15 +08:00
zhayujie
f6f3ce5f05 fix(i18n): refine auto language fallback for deployments 2026-06-03 15:33:29 +08:00
zhayujie
4805f3d4d3 fix(agent): register cancel token in ChatService stream run 2026-06-03 14:47:11 +08:00
zhayujie
1d797cdaf5 feat(channel): support telegram/slack/discord credential mapping 2026-06-03 11:26:36 +08:00
zhayujie
4d8458669c chore(install): simplify model menu, add MiMo option 2026-06-02 17:10:26 +08:00
zhayujie
92ec9653e5 feat(models): support qwen3.7-plus multi-modal model 2026-06-02 16:38:17 +08:00
zhayujie
e861d98007 feat(models): support ASR model selection in web console 2026-06-02 15:05:35 +08:00
zhayujie
a97eeb1fd9 Merge pull request #2857 from nightwhite/codex/fix-asr-model-hot-switch
Fix ASR model persistence in models API
2026-06-02 14:54:02 +08:00
nightwhite
cd88b23b5d fix: persist ASR model in models API 2026-06-02 13:01:20 +08:00
zhayujie
33eabf937b Merge pull request #2853 from Wyh-max-star/WYH
chore:add group task board plugin source
2026-06-02 10:38:29 +08:00
zhayujie
beb5df16a3 Merge pull request #2855 from octo-patch/feature/upgrade-minimax-m3
feat(minimax): add MiniMax-M3 as default, drop older M2.5/M2.1/M2
2026-06-02 10:30:42 +08:00
octo-patch
7fa743f01a feat(minimax): add MiniMax-M3, set as default, drop M2.5/M2.1/M2
- Add MINIMAX_M3 = "MiniMax-M3" constant and put it first in MODEL_LIST
- Default MinimaxBot model: MiniMax-M2.7 -> MiniMax-M3
- Keep MiniMax-M2.7 and MiniMax-M2.7-highspeed as legacy options
- Drop MINIMAX_M2_5 / MINIMAX_M2_1 / MINIMAX_M2_1_LIGHTNING / MINIMAX_M2
- Update web console recommended/provider model lists
- Update README capability table and docs/models index (en/zh/ja)
- Update docs/models/minimax.mdx and coding-plan.mdx MiniMax section
- Update run.sh / run.ps1 installer default and menu hint
- Update zh CLI status sample output
- Update unit tests to assert new M3 default and constant

TTS (speech-2.*) and API base URL remain unchanged.
2026-06-01 21:30:38 +08:00
zhayujie
1f6859d78f feat: update CLI version to 2.1.0 2026-06-01 16:59:19 +08:00
zhayujie
2853735472 docs: update README.md 2026-06-01 16:46:16 +08:00
Wyh-max-star
04d28f9d2d chore:add group task board plugin source 2026-05-31 20:52:42 +08:00
zhayujie
3bc6e89b74 feat: cow desktop first version 2026-03-21 16:11:05 +08:00
266 changed files with 36289 additions and 1163 deletions

View File

@@ -1,6 +1,6 @@
<!--
Thanks for your contribution! Please write this PR in English.
【中文开发者】请使用英文填写,感谢 ❤️
推荐使用英文填写,感谢 ❤️
-->
## What does this PR do?
@@ -16,6 +16,7 @@ Thanks for your contribution! Please write this PR in English.
## Checklist
- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md)
- [ ] I tested this change locally
- [ ] Code comments and docs are in English
- [ ] Linked related issue (if any): closes #

116
.github/scripts/register-releases.mjs vendored Normal file
View File

@@ -0,0 +1,116 @@
// Build the D1 upsert SQL for a desktop release from the files in a directory.
//
// Each mac release has TWO artifacts that map to a SINGLE D1 row:
// - <name>-<arch>.dmg -> manual download (filename / size / sha512)
// - <name>-<arch>.zip -> auto-update (update_filename / update_size /
// update_sha512)
// electron-updater's MacUpdater can only consume a zip, never a dmg, so the
// feed serves the zip while the website serves the dmg. Windows has only the
// .exe (stored in the main columns; it's both the download and the update).
//
// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves,
// because two replaces on the same primary key would drop whichever came first.
//
// Usage:
// node register-releases.mjs --dir dist --version 1.2.0 \
// --sql out.sql [--latest]
//
// --latest mark these rows is_latest=1 AND clear the previous latest for
// each platform (used by the publish/promote workflow). Without it
// rows are written unpublished (is_latest=0) — the build stage.
//
// sha512 is base64 (the exact format electron-updater validates).
import { execSync } from 'node:child_process'
import fs from 'node:fs'
function arg(name, fallback = undefined) {
const i = process.argv.indexOf(`--${name}`)
if (i === -1) return fallback
const next = process.argv[i + 1]
// Boolean flag (no value or next token is another flag).
if (next === undefined || next.startsWith('--')) return true
return next
}
const dir = arg('dir', 'dist')
const version = arg('version')
const sqlPath = arg('sql', 'd1.sql')
const makeLatest = arg('latest', false) === true
if (!version) {
console.error('register-releases: --version is required')
process.exit(1)
}
const sha512 = (f) =>
execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, {
shell: '/bin/bash',
})
.toString()
.trim()
// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe).
const q = (s) => String(s).replace(/'/g, "''")
// platform -> { main: {key,size,sha}, upd: {key,size,sha} }
const rows = {}
for (const base of fs.readdirSync(dir)) {
const f = `${dir}/${base}`
if (fs.statSync(f).isDirectory()) continue
let platform
let slot
if (/arm64\.dmg$/.test(base)) {
platform = 'mac-arm64'
slot = 'main'
} else if (/x64\.dmg$/.test(base)) {
platform = 'mac-x64'
slot = 'main'
} else if (/arm64\.zip$/.test(base)) {
platform = 'mac-arm64'
slot = 'upd'
} else if (/x64\.zip$/.test(base)) {
platform = 'mac-x64'
slot = 'upd'
} else if (/\.exe$/.test(base)) {
platform = 'win'
slot = 'main'
} else {
console.log('Skipping unrecognized artifact:', base)
continue
}
rows[platform] ||= {}
rows[platform][slot] = {
key: `v${version}/${base}`,
size: fs.statSync(f).size,
sha: sha512(f),
}
}
if (Object.keys(rows).length === 0) {
console.error('register-releases: no recognized artifacts in', dir)
process.exit(1)
}
const isLatest = makeLatest ? 1 : 0
const sql = []
for (const [platform, r] of Object.entries(rows)) {
const m = r.main || { key: '', size: 0, sha: '' }
const u = r.upd || { key: '', size: 0, sha: '' }
if (makeLatest) {
// Clear the previous latest for this platform before promoting the new row.
sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`)
}
sql.push(
`INSERT OR REPLACE INTO releases ` +
`(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` +
`VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` +
`'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});`
)
}
fs.writeFileSync(sqlPath, sql.join('\n') + '\n')
console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`)

154
.github/workflows/publish-desktop.yml vendored Normal file
View File

@@ -0,0 +1,154 @@
name: Publish Desktop
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
# to "live". By this point:
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
# registered them in D1 as unpublished (is_latest=0);
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
# dmgs and re-uploaded the stapled bytes to R2.
#
# This workflow, triggered manually with the version to publish:
# 1. pulls every artifact for that version back from R2,
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
# 3. flips is_latest=1 for that version (clearing the previous latest per
# platform) UNLESS it's a pre-release, which is recorded but never latest,
# 4. creates/updates the GitHub Release and attaches the installers.
#
# Run only after the mac dmgs for this version are notarized + re-uploaded.
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
type: string
required: true
make_latest:
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
type: boolean
default: true
github_release:
description: "Create/update the GitHub Release and attach installers."
type: boolean
default: true
permissions:
contents: write
jobs:
publish:
name: Publish ${{ github.event.inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Guard on Cloudflare secrets
id: guard
env:
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -z "$CF_TOKEN" ]; then
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
exit 1
fi
- name: Resolve version artifacts from D1
id: rows
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ github.event.inputs.version }}
run: |
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
# Read them back so we know exactly which objects to pull from R2.
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
echo "$out"
echo "$out" | node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(0, "utf8"));
const rows = (Array.isArray(data) ? data : [data])
.flatMap(r => (r.results || []));
if (!rows.length) {
console.error("No D1 rows for this version — did stage 1 (build) run?");
process.exit(1);
}
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
fs.writeFileSync("rows.json", JSON.stringify(rows));
'
- name: Download version artifacts from R2
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
run: |
mkdir -p dist
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
# auto-update file (update_filename: zip) for every row. Keys are
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
base="$(basename "$key")"
r2key="desktop/${key}"
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
--file "dist/${base}" --remote
done
echo "Downloaded:"; ls -la dist
- name: Reminder — mac dmgs must be notarized before publishing
run: |
# Stapling can only be validated on macOS (xcrun stapler validate),
# which this Linux runner doesn't have. The authoritative check runs in
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
# This step is just a loud reminder in the log.
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
- name: Update D1 (recompute sha512 + set latest)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ github.event.inputs.version }}
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
run: |
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
# become latest, so the site keeps serving the last stable build.
case "$VER" in
*-*) is_pre=1 ;;
*) is_pre=0 ;;
esac
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
latest_flag="--latest"; echo "==> Publishing $VER as latest."
else
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
fi
# Re-hash the real (stapled) bytes and re-store every row with both the
# dmg (manual) and mac zip (auto-update) columns. Same script as the
# build stage; --latest also clears the previous latest per platform.
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
echo "==> D1 statements:"; cat d1.sql
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
- name: Create/update GitHub Release and attach installers
if: github.event.inputs.github_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VER: ${{ github.event.inputs.version }}
run: |
tag="v${VER}"
case "$VER" in
*-*) prerelease="--prerelease" ;;
*) prerelease="" ;;
esac
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
--title "$tag" --generate-notes $prerelease
fi
# --clobber so re-runs overwrite the stapled/updated assets. The mac
# zip is the auto-update artifact; attach it too so the GitHub Release
# is a complete mirror (nullglob avoids errors when a type is absent).
shopt -s nullglob
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
--repo "$GITHUB_REPOSITORY" --clobber

301
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,301 @@
name: Release Desktop
# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
# so the website keeps serving the previous release. It does NOT notarize
# (Apple's notary service stalls this large bundle for hours) and does NOT
# create a GitHub Release.
#
# Full flow:
# 1. (this workflow) build + upload to R2 + D1 as unpublished.
# 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to
# notarize + staple + re-upload the stapled dmgs to R2.
# 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub
# Release assets — makes the version live on the site.
#
# Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a
# build, so cutting a release tag never rebuilds installers or overwrites R2.
on:
workflow_dispatch:
inputs:
version:
description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path."
type: string
default: "0.0.0-dev"
publish_r2:
description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)"
type: boolean
default: false
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
# Don't cancel the other platforms if one fails — we want to see all
# failures in a single run.
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-14
platform: mac
arch: arm64
eb_flags: --mac --arm64
- name: macOS x64
os: macos-15-intel
platform: mac
arch: x64
eb_flags: --mac --x64
- name: Windows x64
os: windows-latest
platform: win
arch: x64
eb_flags: --win --x64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version
# Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0).
# Manual dispatch: use the provided version input.
id: ver
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
ref="${GITHUB_REF_NAME:-}"
echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build Python backend (PyInstaller)
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r desktop/build/requirements-desktop.txt
pip install pyinstaller
# Run from repo root so the spec's relative datas resolve correctly.
pyinstaller desktop/build/cowagent-backend.spec \
--noconfirm \
--distpath desktop/build/dist \
--workpath desktop/build/build-work
- name: Install desktop deps
working-directory: desktop
run: npm ci
- name: Write version into package.json
working-directory: desktop
shell: bash
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
# Compile renderer + main in its OWN step, alone, so the npm.cmd batch
# wrapper (see the note on the build step below) can't take out anything
# after it.
- name: Compile (vite + tsc)
working-directory: desktop
shell: bash
run: npm run build
- name: Build & publish (electron-builder)
working-directory: desktop
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Signing secrets are passed through as-is; we only export them to the
# environment below when non-empty. An empty CSC_LINK would make
# electron-builder try to load a bogus certificate and fail, so unset
# is the correct state for unsigned builds.
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
# Pick the signing cert for THIS platform only. The mac and win secrets
# are both present in the job env, but a mac cert must never leak into a
# Windows build (electron-builder would try to load it and fail), and
# vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD
# pair, so we set it per-platform. An empty CSC_LINK is treated by
# electron-builder as a broken cert path, so we leave it entirely unset
# for an unsigned build.
#
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
# (an `unset` can return non-zero and abort under errexit).
case "${{ matrix.platform }}" in
mac)
if [ -n "$MAC_CSC_LINK" ]; then
export CSC_LINK="$MAC_CSC_LINK"
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
fi
;;
win)
if [ -n "$WIN_CSC_LINK" ]; then
export CSC_LINK="$WIN_CSC_LINK"
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
fi
;;
esac
# Never let electron-builder publish: our publish target is a generic
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
# installers to R2 and register them in D1 ourselves (publish-r2 job).
# `--publish never` still emits the latest*.yml files.
#
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
# inject mac.binaries (the backend Mach-O files to hardened-sign for
# notarization) — it's a pure no-op on Windows. Passing --config on
# Windows was what silently broke the Windows build (it produced no
# installer while the job still reported success; Windows worked fine
# before --config was introduced). So Windows uses the plain
# package.json build config and only mac uses the dynamic one.
#
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
# make bash return before the wrapped process finishes. node skips it.
case "${{ matrix.platform }}" in
mac) config_arg="--config electron-builder.js" ;;
*) config_arg="" ;;
esac
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
# Upload artifacts regardless of outcome, so a failed run still surfaces
# the built installers (and, on success, the notarized+stapled dmg).
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
# One bundle per platform/arch so the publish job can collect them all.
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.exe
desktop/release/*.yml
desktop/release/*.blockmap
if-no-files-found: ignore
retention-days: 7
# Mirror the release installers to R2 (CDN-backed) and register them in D1 so
# cowagent.ai/download/{platform}/latest can resolve and count downloads.
# Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets
# are configured, so it never blocks unsigned/dry builds.
publish-r2:
name: Publish to R2 + D1
# Require every platform in the build matrix to succeed before publishing,
# so a release on R2/D1 is always complete (all installers present) rather
# than partial. needs: build already gates on all matrix jobs succeeding.
needs: build
runs-on: ubuntu-latest
# Run on a tag push, or on a manual dispatch when publish_r2 is checked.
if: >-
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Guard on Cloudflare secrets
id: guard
env:
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -n "$CF_TOKEN" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish."
fi
- name: Derive version
if: steps.guard.outputs.enabled == 'true'
id: ver
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
fi
- name: Download all build artifacts
if: steps.guard.outputs.enabled == 'true'
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage installers
if: steps.guard.outputs.enabled == 'true'
id: stage
run: |
mkdir -p dist
# Flatten installers + their .blockmap (used by electron-updater for
# differential downloads) from every per-platform artifact dir. The
# .yml feed is generated dynamically by the /update Function from D1,
# so the yml files themselves don't need to go to R2.
# .zip is the mac auto-update artifact (electron-updater's MacUpdater
# can ONLY consume zip, not dmg — the dmg is for manual downloads).
find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \;
echo "Staged files:"; ls -la dist
# When the whole matrix failed there's nothing to publish; flag it so
# the R2/D1 steps skip instead of writing an empty/partial release.
if [ -n "$(ls -A dist 2>/dev/null)" ]; then
echo "has_files=true" >> "$GITHUB_OUTPUT"
else
echo "has_files=false" >> "$GITHUB_OUTPUT"
echo "::warning::No installers found in any artifact — skipping R2/D1 publish."
fi
- name: Upload installers to R2
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ steps.ver.outputs.version }}
run: |
# Reuse the existing cow-skills bucket under a desktop/ prefix; this
# is served by the cdn.cowagent.ai custom domain.
for f in dist/*; do
base="$(basename "$f")"
key="desktop/v${VER}/${base}"
echo "==> Uploading $base -> r2://cow-skills/$key"
npx --yes wrangler@latest r2 object put "cow-skills/$key" \
--file "$f" --remote
done
- name: Register release rows in D1
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ steps.ver.outputs.version }}
run: |
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
# /download/<p>/latest keeps serving the previous release and the new
# version stays invisible on the site. macOS dmgs still need to be
# notarized+stapled locally (build/notarize-dmg.sh) before they're
# safe to ship. Promotion to latest happens later, only after
# notarization, via the separate "Publish Desktop" workflow.
echo "==> Registering $VER as unpublished (is_latest=0)."
# Build one upsert per (version, platform) carrying both the dmg
# (manual download) and the mac zip (auto-update) columns. See
# .github/scripts/register-releases.mjs for the mapping. No --latest
# here: rows stay unpublished until the publish workflow promotes them.
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
echo "==> D1 statements:"; cat d1.sql
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql

32
.github/workflows/test-windows-bash.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Windows Bash Streaming Tests
on:
workflow_dispatch:
pull_request:
paths:
- "agent/tools/bash/bash.py"
- "tests/test_bash_streaming.py"
- ".github/workflows/test-windows-bash.yml"
jobs:
windows-bash-tests:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pytest
python -m pip install -r requirements.txt
- name: Run Windows Bash streaming tests
run: python -m pytest tests/test_bash_streaming.py -v

17
.gitignore vendored
View File

@@ -45,3 +45,20 @@ dist/
build/
*.egg-info/
.cow.pid
# Desktop backend packaging: keep the source files (spec/requirements/script)
# tracked even though the generic build/ rule above ignores them, but never
# track the build outputs or local venv.
!desktop/build/
desktop/build/*
!desktop/build/cowagent-backend.spec
!desktop/build/requirements-desktop.txt
!desktop/build/build-backend.sh
!desktop/build/entitlements.mac.plist
!desktop/build/notarize-dmg.sh
# Icon authoring scratch dir: intermediate assets used to produce the final
# icons. Only the finished icons under desktop/resources/ should be committed.
desktop/resources/.icon-work/
.wrangler/

View File

@@ -1,13 +1,21 @@
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/CowAgent" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square" alt="Stars"></a> <br/>
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/"><img src="https://img.shields.io/badge/Docs-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a>
</p>
**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.
<p align="center">
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center">
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/zh/README-Hant.md">繁體中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
</p>
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering.
CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms.
@@ -16,6 +24,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
<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://cowagent.ai/download/">💻 Download</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
</p>
@@ -28,6 +37,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached |
| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval |
| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing |
| [Evolution](https://docs.cowagent.ai/memory/self-evolution) | Self-Evolution reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use |
| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation |
| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration |
| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack |
@@ -39,7 +49,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
## 🏗️ Architecture
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
@@ -86,6 +96,8 @@ cow skill install <name> # install a skill
cow install-browser # install browser automation
```
> 💻 Desktop client: download the **[CowAgent Desktop client](https://cowagent.ai/download/)** (macOS / Windows) — the backend is bundled, ready to use out of the box.
<br/>
## 🤖 Models
@@ -94,15 +106,15 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-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 | ✅ | ✅ | ✅ | | ✅ | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -190,6 +202,12 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
## 🏷 Changelog
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo).
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening.
@@ -238,9 +256,9 @@ For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https:/
## 🛠️ Development & Contributing
Contributions are welcome — add a new channel by following the [Feishu channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit).
All kinds of contributions are welcome — new features, bug fixes, performance improvements, docs, or sharing your own skills on the [Skill Hub](https://skills.cowagent.ai/submit). See [CONTRIBUTING.md](/CONTRIBUTING.md) to get started, then open an Issue to discuss or send a PR directly.
⭐ Star the project to follow updates, and feel free to open PRs and Issues.
⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome.
## 🌟 Contributors

View File

@@ -49,6 +49,16 @@ class ChatService:
agent.model.channel_type = channel_type or ""
agent.model.session_id = session_id or ""
# Build a context so context-aware tools (e.g. scheduler) can resolve the
# receiver/session. This streaming path bypasses agent_bridge.agent_reply,
# so the attach step that normally happens there must be done here too.
context = self._build_context(query, session_id, channel_type)
self._attach_context_aware_tools(agent, context)
# Mark this session as mid-run so the self-evolution idle scan does not
# fire concurrently when a single turn runs longer than idle_minutes.
self._mark_run_active(agent, True)
# State shared between the event callback and this method
state = _StreamState()
@@ -171,6 +181,12 @@ class ChatService:
from agent.protocol.agent_stream import AgentStreamExecutor
# Register a cancel token so /cancel can abort this in-flight run.
# IM channels key on session_id (no per-turn request_id here).
from agent.protocol import get_cancel_registry
registry = get_cancel_registry()
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
executor = AgentStreamExecutor(
agent=agent,
model=agent.model,
@@ -180,6 +196,7 @@ class ChatService:
on_event=on_event,
messages=messages_copy,
max_context_turns=max_context_turns,
cancel_event=cancel_event,
)
try:
@@ -191,6 +208,15 @@ class ChatService:
agent.messages.clear()
logger.info("[ChatService] Cleared agent message history after executor recovery")
raise
finally:
# Clear the mid-run flag so idle scans can review this session again.
self._mark_run_active(agent, False)
# Release cancel token to keep the registry bounded.
if session_id:
try:
registry.unregister(session_id)
except Exception:
pass
# Sync executor messages back to agent (thread-safe).
# The executor may have trimmed context, making its list shorter than
@@ -254,10 +280,68 @@ class ChatService:
# Execute post-process tools
agent._execute_post_process_tools()
# Record this user turn for the self-evolution idle trigger. This
# streaming path bypasses agent_bridge.agent_reply, so the activity must
# be noted here, otherwise idle scans never see any signal to evolve.
self._note_evolution_turn(agent, context)
logger.info(f"[ChatService] Agent run completed: session={session_id}")
@staticmethod
def _build_context(query: str, session_id: str, channel_type: str):
"""Build a Context for tool resolution on the streaming chat path.
receiver falls back to session_id; the scheduler's delivery keys on
session_id as the receiver.
"""
from bridge.context import Context, ContextType
# Pass an explicit kwargs dict: Context's default kwargs is a shared
# mutable default, so omitting it would leak fields across sessions.
ctx = Context(ContextType.TEXT, query, kwargs={})
ctx["session_id"] = session_id
ctx["receiver"] = session_id
ctx["isgroup"] = False
ctx["channel_type"] = channel_type or ""
return ctx
@staticmethod
def _attach_context_aware_tools(agent, context):
"""Attach the current context to tools that need it (scheduler)."""
try:
if not (context and getattr(agent, "tools", None)):
return
for tool in agent.tools:
if tool.name == "scheduler":
from agent.tools.scheduler.integration import attach_scheduler_to_tool
attach_scheduler_to_tool(tool, context)
break
except Exception as e:
logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}")
@staticmethod
def _mark_run_active(agent, active):
"""Toggle the self-evolution mid-run flag for this session's agent."""
try:
from agent.evolution.trigger import mark_run_active
mark_run_active(agent, active)
except Exception:
pass
@staticmethod
def _note_evolution_turn(agent, context):
"""Record a user turn so the self-evolution idle trigger has signal."""
try:
from agent.evolution.trigger import note_user_turn
ch = (context.get("channel_type") or "") if context else ""
rcv = (context.get("receiver") or "") if context else ""
is_group = bool(context.get("isgroup")) if context else False
# Only single chats get a proactive push target; group push is noisy.
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
except Exception:
pass
@staticmethod
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
try:

View File

@@ -0,0 +1,19 @@
"""
Self-evolution subsystem for CowAgent.
Runs a lightweight, isolated review pass after a conversation goes idle to
decide whether anything is worth durably learning (memory / skill) or whether
an unfinished task can be pushed forward. Conservative by design: most
conversations should produce no change at all.
Public entry points:
from agent.evolution import get_evolution_config
from agent.evolution.trigger import start_evolution_trigger, note_user_turn
"""
from agent.evolution.config import EvolutionConfig, get_evolution_config
__all__ = [
"EvolutionConfig",
"get_evolution_config",
]

102
agent/evolution/backup.py Normal file
View File

@@ -0,0 +1,102 @@
"""File backup / rollback support for self-evolution.
Before the evolution agent edits MEMORY.md or a skill file, we snapshot the
current state into ``memory/.evolution_backups/<backup_id>/`` so a later "undo"
can restore it. File-level restore only — simple and reliable.
"""
from __future__ import annotations
import json
import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from common.log import logger
_BACKUP_DIRNAME = ".evolution_backups"
_MANIFEST_NAME = "manifest.json"
# Keep only the most recent N backups to bound disk usage.
_MAX_BACKUPS = 10
def _backups_root(workspace_dir: Path) -> Path:
return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME
def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]:
"""Snapshot ``files`` (those that exist) under a new backup id.
Returns the backup_id, or None when there is nothing to back up.
"""
existing = [Path(f) for f in files if Path(f).exists()]
if not existing:
return None
backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000)
root = _backups_root(workspace_dir)
target = root / backup_id
try:
target.mkdir(parents=True, exist_ok=True)
ws = Path(workspace_dir)
manifest = []
for idx, src in enumerate(existing):
# Store under a flat index plus the relative path so restore knows
# where it came from, even for nested skill files.
try:
rel = str(src.relative_to(ws))
except ValueError:
rel = src.name
dst = target / f"{idx}.bak"
shutil.copy2(src, dst)
manifest.append({"rel": rel, "bak": f"{idx}.bak"})
(target / _MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
_prune_old_backups(root)
# Caller logs a combined backup+review line; keep this at debug.
logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))")
return backup_id
except Exception as e:
logger.warning(f"[Evolution] Failed to create backup: {e}")
return None
def restore_backup(workspace_dir: Path, backup_id: str) -> bool:
"""Restore all files captured under ``backup_id``. Returns success."""
if not backup_id:
return False
target = _backups_root(workspace_dir) / backup_id
manifest_path = target / _MANIFEST_NAME
if not manifest_path.exists():
logger.warning(f"[Evolution] Backup not found: {backup_id}")
return False
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
ws = Path(workspace_dir)
for entry in manifest:
bak = target / entry["bak"]
dst = ws / entry["rel"]
if bak.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bak, dst)
logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))")
return True
except Exception as e:
logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}")
return False
def _prune_old_backups(root: Path) -> None:
"""Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological)."""
try:
dirs = sorted(
[d for d in root.iterdir() if d.is_dir()],
key=lambda p: p.name,
)
for old in dirs[:-_MAX_BACKUPS]:
shutil.rmtree(old, ignore_errors=True)
except Exception as e:
logger.debug(f"[Evolution] Backup prune skipped: {e}")

76
agent/evolution/config.py Normal file
View File

@@ -0,0 +1,76 @@
"""Configuration for the self-evolution subsystem.
Reads flat ``self_evolution_*`` keys from config.json. All fields have safe
defaults so the feature degrades gracefully when keys are absent.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# Defaults — conservative (see executor module docstring). Disabled by default
# until release; enable via ``self_evolution_enabled``.
DEFAULT_ENABLED = False
DEFAULT_IDLE_MINUTES = 10
DEFAULT_MIN_TURNS = 6
# Max review steps for the isolated evolution agent. Kept small (not exposed as
# config): the review is meant to be cheap and focused, not a long autonomous run.
DEFAULT_MAX_STEPS = 12
@dataclass
class EvolutionConfig:
"""Resolved self-evolution settings."""
enabled: bool = DEFAULT_ENABLED
idle_minutes: int = DEFAULT_IDLE_MINUTES
min_turns: int = DEFAULT_MIN_TURNS
max_steps: int = DEFAULT_MAX_STEPS
@property
def idle_seconds(self) -> int:
return max(60, self.idle_minutes * 60)
def _as_bool(value: Any, fallback: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"):
return True
if v in ("false", "0", "no", "off"):
return False
return fallback
def _as_pos_int(value: Any, fallback: int) -> int:
try:
n = int(value)
return n if n > 0 else fallback
except (TypeError, ValueError):
return fallback
def get_evolution_config() -> EvolutionConfig:
"""Build EvolutionConfig from the live config.json ``self_evolution_*`` keys."""
try:
from config import conf
c = conf()
except Exception:
c = {}
def _get(key, default):
try:
return c.get(key, default)
except Exception:
return default
return EvolutionConfig(
enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED),
idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES),
min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS),
max_steps=DEFAULT_MAX_STEPS,
)

556
agent/evolution/executor.py Normal file
View File

@@ -0,0 +1,556 @@
"""Self-evolution executor.
Runs an isolated review agent over an idle conversation's transcript and, if a
clear signal is found, lets it edit memory / skills via a restricted toolset.
Conservative by design: most runs return ``[SILENT]`` and change nothing.
Flow:
1. Build a transcript from the session's new (since last pass) messages.
2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id.
3. Run an isolated agent (same model, restricted tools, evolution prompt).
4. If output is [SILENT], or no workspace file actually changed -> done.
5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into
the user session (so the main agent can honor "undo"), and push the
summary to the user's channel.
Reuses existing infrastructure (AgentBridge.create_agent, ToolManager,
remember_scheduled_output, channel_factory) rather than introducing a fork.
"""
from __future__ import annotations
import re
import threading
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from common.log import logger
from agent.evolution.backup import create_backup
from agent.evolution.config import get_evolution_config
from agent.evolution.prompts import (
EVOLUTION_MARKER,
EVOLUTION_SYSTEM_PROMPT,
SILENT_TOKEN,
build_review_user_message,
)
from agent.evolution.record import append_session_evolution
# Tools the isolated evolution agent is allowed to use. Everything else is
# withheld so a review pass can only read context, run workspace scripts, and
# edit memory/skill files. bash is needed by skill-creator's init script and is
# confined to the workspace by _BashWorkspaceGuard.
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"}
# Cap concurrent evolution passes so a burst of idle sessions can't spawn many
# background model runs at once. Extra sessions simply wait for the next scan.
_MAX_CONCURRENT = 2
_running_lock = threading.Lock()
_running_count = 0
def _builtin_skill_names() -> set:
"""Names of skills shipped with the product (project-root ``skills/``).
These are protected: the evolution agent must never edit them, even though
a same-named copy exists in the workspace at runtime. The project dir is the
authoritative list of what counts as built-in.
"""
try:
# executor.py -> agent/evolution -> agent -> project root
project_root = Path(__file__).resolve().parents[2]
builtin_dir = project_root / "skills"
if not builtin_dir.is_dir():
return set()
names = set()
for entry in builtin_dir.iterdir():
if entry.is_dir() and not entry.name.startswith("."):
names.add(entry.name)
return names
except Exception:
return set()
def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str:
"""Render the session messages into a compact text transcript."""
lines: List[str] = []
for msg in messages:
role = msg.get("role", "")
if role not in ("user", "assistant"):
continue
content = msg.get("content", "")
text = _extract_text(content)
if not text.strip():
continue
speaker = "User" if role == "user" else "Assistant"
lines.append(f"{speaker}: {text.strip()}")
transcript = "\n".join(lines)
# Keep the most RECENT context if oversized (tail is most relevant).
if len(transcript) > max_chars:
transcript = "...(earlier omitted)...\n" + transcript[-max_chars:]
return transcript
def _extract_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
return "\n".join(parts)
return ""
def _select_tools(all_tools: list) -> list:
return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS]
# Tools whose writes must be confined to the workspace during evolution.
_WRITE_TOOLS = {"write", "edit"}
class _WorkspaceWriteGuard:
"""Wraps a write/edit tool so it can ONLY write inside the workspace.
Hard engineering guard (not prompt-based): any write resolving outside the
workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This
protects built-in skills regardless of what the model attempts.
"""
def __init__(self, inner, workspace_dir: str):
self._inner = inner
self._ws = Path(workspace_dir).resolve()
# Mirror the attributes the agent runtime reads off a tool.
self.name = inner.name
self.description = inner.description
self.params = inner.params
def __getattr__(self, item):
return getattr(self._inner, item)
def execute_tool(self, params):
# The agent runtime calls execute_tool (not execute); route it through
# our guarded execute so the path checks always run.
try:
return self.execute(params)
except Exception as e:
logger.error(f"[Evolution] guarded tool error: {e}")
from agent.tools.base_tool import ToolResult
return ToolResult.fail(f"Error: {e}")
def execute(self, args):
path = (args.get("path") or "").strip()
if path:
try:
resolved = Path(self._inner._resolve_path(path)).resolve()
from agent.tools.base_tool import ToolResult
# Confine writes to the workspace. This protects the product's
# bundled skills (which live outside the workspace) from ever
# being modified, no matter what path the model attempts.
if self._ws not in resolved.parents and resolved != self._ws:
return ToolResult.fail(
"Error: evolution may only write inside the workspace; "
f"path '{path}' is outside and was blocked."
)
except Exception:
pass
return self._inner.execute(args)
class _BashWorkspaceGuard:
"""Wraps the bash tool so evolution can only run commands inside the
workspace.
Evolution needs bash for skill-creator's init script, but it runs
unattended in the background, so a raw shell is too broad. This guard:
- forces the command to execute with cwd = workspace,
- rejects commands that reference an absolute path or ``..`` segment
pointing OUTSIDE the workspace (the common ways to escape it).
It is a coarse textual check, not a sandbox — paired with the model's
instruction to only run skill-creator scripts, it keeps writes local.
"""
def __init__(self, inner, workspace_dir: str):
self._inner = inner
self._ws = Path(workspace_dir).resolve()
# Pin the shell's working directory to the workspace.
try:
self._inner.cwd = str(self._ws)
except Exception:
pass
self.name = inner.name
self.description = inner.description
self.params = inner.params
def __getattr__(self, item):
return getattr(self._inner, item)
def execute_tool(self, params):
try:
return self.execute(params)
except Exception as e:
logger.error(f"[Evolution] guarded bash error: {e}")
from agent.tools.base_tool import ToolResult
return ToolResult.fail(f"Error: {e}")
def _escapes_workspace(self, command: str) -> bool:
# Absolute paths that are not under the workspace.
for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command):
try:
resolved = Path(tok).resolve()
except Exception:
continue
if self._ws != resolved and self._ws not in resolved.parents:
return True
# Parent-dir traversal that climbs above the workspace.
for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command):
try:
resolved = (self._ws / tok).resolve()
except Exception:
continue
if self._ws != resolved and self._ws not in resolved.parents:
return True
return False
def execute(self, args):
from agent.tools.base_tool import ToolResult
command = (args.get("command") or "").strip()
if command and self._escapes_workspace(command):
return ToolResult.fail(
"Error: evolution may only run commands inside the workspace; "
"this command references a path outside it and was blocked."
)
return self._inner.execute(args)
def _guard_tools(tools: list, workspace_dir: str) -> list:
"""Wrap write/edit/bash tools with workspace guards; leave others as-is."""
guarded = []
for t in tools:
name = getattr(t, "name", None)
if name in _WRITE_TOOLS:
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
elif name == "bash":
guarded.append(_BashWorkspaceGuard(t, workspace_dir))
else:
guarded.append(t)
return guarded
# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is
# watched too: evolution may rarely refine the assistant's persona/style there.
_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output")
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
# dream diary, none of which count as a user-facing change signal.
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
# Files the skill subsystem maintains automatically (the enable/disable index).
# Not an evolution result, so a rewrite must not count as a change signal.
_WATCH_IGNORE_NAMES = ("skills_config.json",)
def _workspace_snapshot(workspace_dir) -> dict:
"""Map relative path -> (mtime, size) for watched files. Cheap, no reads."""
ws = Path(workspace_dir)
snap: dict = {}
for name in _WATCH_SUBDIRS:
root = ws / name
if root.is_file():
try:
st = root.stat()
snap[name] = (st.st_mtime, st.st_size)
except OSError:
pass
continue
if not root.is_dir():
continue
for p in root.rglob("*"):
if not p.is_file():
continue
if p.name in _WATCH_IGNORE_NAMES:
continue
try:
st = p.stat()
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
except OSError:
pass
# Watch the daily memory files (memory/*.md and per-user dailies) since
# evolution now records learnings there. Skip backups/dreams bookkeeping.
mem_dir = ws / "memory"
if mem_dir.is_dir():
for p in mem_dir.rglob("*.md"):
rel_parts = p.relative_to(mem_dir).parts
if rel_parts and rel_parts[0] in _MEMORY_IGNORE:
continue
try:
st = p.stat()
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
except OSError:
pass
return snap
def _workspace_changed(workspace_dir, pre: dict) -> bool:
"""True if any watched file was added, removed, or modified since ``pre``."""
return _workspace_snapshot(workspace_dir) != pre
def run_evolution_for_session(
agent_bridge,
session_id: str,
channel_type: str = "",
receiver: str = "",
user_id: Optional[str] = None,
idle_minutes: float = 0.0,
) -> bool:
"""Run one evolution pass for a session. Returns True if it changed anything.
Safe to call from a background thread. All failures are swallowed and
logged — evolution must never disrupt the main pipeline.
"""
cfg = get_evolution_config()
if not cfg.enabled:
return False
# Concurrency gate: bound how many evolution passes run at once.
global _running_count
with _running_lock:
if _running_count >= _MAX_CONCURRENT:
logger.info(
f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); "
f"skipping session={session_id} this scan"
)
return False
_running_count += 1
try:
agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent
if not agent:
return False
with agent.messages_lock:
all_messages = list(agent.messages)
total_msgs = len(all_messages)
# In-memory evolution cursor: only review messages added since the last
# pass so a long session doesn't re-judge (and re-write) old content.
# Stored on the agent instance; lost on restart (acceptable — at worst
# one redundant pass right after a restart, gated by the file-change
# check downstream so it won't double-write identical memory).
done = int(getattr(agent, "_evo_done_msg_count", 0))
if done > total_msgs:
done = 0 # history was trimmed/reset; start fresh
new_messages = all_messages[done:]
transcript = _build_transcript(new_messages)
if not transcript.strip():
# Routine no-op: the per-minute scan hits every idle session. Advance
# the cursor so we don't re-scan the same tail; no log (pure noise).
agent._evo_done_msg_count = total_msgs
return False
logger.info(
f"[Evolution] ▶ Reviewing session={session_id} "
f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, "
f"~{len(transcript)} chars)"
)
# Resolve workspace + files to snapshot for undo.
from agent.memory.config import get_default_memory_config
mem_cfg = get_default_memory_config()
workspace_dir = mem_cfg.get_workspace()
if user_id:
memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md"
else:
memory_file = Path(workspace_dir) / "MEMORY.md"
skills_dir = mem_cfg.get_skills_dir()
# Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected
# built-in skills are excluded from backup because they must never be
# edited in the first place.
protected_names = _builtin_skill_names()
# Back up both MEMORY.md and today's daily file: evolution now writes to
# the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe
# if the model ever edits it.
today_daily = Path(workspace_dir) / "memory" / (
datetime.now().strftime("%Y-%m-%d") + ".md"
)
if user_id:
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
datetime.now().strftime("%Y-%m-%d") + ".md"
)
# AGENT.md (persona) is backed up too so a rare persona edit is undoable.
# Persona is workspace-global (not per-user): it always lives at the
# workspace root, regardless of user_id.
agent_file = Path(workspace_dir) / "AGENT.md"
backup_files = [Path(memory_file), today_daily, agent_file]
if skills_dir.exists():
for skill_md in skills_dir.rglob("SKILL.md"):
# The skill dir is the SKILL.md's parent (or an ancestor for
# collections); guard by checking the immediate top-level dir.
try:
top = skill_md.relative_to(skills_dir).parts[0]
except (ValueError, IndexError):
continue
if top in protected_names:
continue
backup_files.append(skill_md)
backup_id = create_backup(workspace_dir, backup_files)
_backup_n = sum(1 for f in backup_files if Path(f).exists())
# Snapshot the whole workspace (path -> mtime/size) so we can reliably
# detect ANY file change — including new output files written when
# finishing an unfinished task, which are not in backup_files.
pre_snapshot = _workspace_snapshot(workspace_dir)
# Build the isolated review agent: same model, restricted tools, with a
# hard guard that confines all writes to the workspace (protects the
# project's bundled skills from ever being modified).
review_tools = _guard_tools(
_select_tools(list(getattr(agent, "tools", []) or [])),
str(workspace_dir),
)
review_agent = agent_bridge.create_agent(
system_prompt="",
tools=review_tools,
description="Self-evolution review agent",
max_steps=cfg.max_steps,
workspace_dir=str(workspace_dir),
skill_manager=getattr(agent, "skill_manager", None),
memory_manager=getattr(agent, "memory_manager", None),
enable_skills=True,
runtime_info=getattr(agent, "runtime_info", None),
)
# Mark this as a restricted review agent so runtime MCP reconciliation
# (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools
# that _select_tools()/_guard_tools() intentionally withheld. Without this
# flag the review boundary would be re-opened on the first LLM turn.
review_agent._evolution_restricted = True
# Reuse the live model so it follows the user's configured model.
review_agent.model = agent.model
# Inject the evolution task brief AFTER the full system prompt: the agent
# gets the full context (tools, workspace, user preferences, memory, time)
# AND its evolution-specific instructions on top, instead of one
# overwriting the other.
review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT
logger.info(
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
)
user_msg = build_review_user_message(transcript, protected_skills=list(protected_names))
result = review_agent.run_stream(user_msg, clear_history=True)
result = (result or "").strip()
# These messages are now reviewed; advance the cursor so the next pass
# only looks at messages added after this point (silent or not).
agent._evo_done_msg_count = total_msgs
# Respect an explicit silent verdict: empty, exactly [SILENT], or text
# that STARTS with [SILENT] means the model chose to stay quiet.
if not result or result.startswith(SILENT_TOKEN):
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
return False
# Anti-nag backstop: if the model wrote a summary but actually changed no
# watched file, stay silent — never notify about work that didn't happen.
if not _workspace_changed(workspace_dir, pre_snapshot):
logger.info(
f"[Evolution] ✗ session={session_id}: text produced but no file "
f"changed — staying silent"
)
return False
# The model produced a real summary. Strip any stray [SILENT] tokens it
# left mid-text, then notify.
result = result.replace(SILENT_TOKEN, "").strip()
if not result:
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
return False
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id)
# Inject an [EVOLUTION] note so the main agent can honor "undo".
_inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id)
# The injection appended its own messages ([SCHEDULED]/[EVOLUTION]).
# Advance the cursor past them so the next scan does not treat
# evolution's own bookkeeping as new user content and re-trigger.
try:
with agent.messages_lock:
agent._evo_done_msg_count = len(agent.messages)
except Exception:
pass
# Push the summary to the user's channel. The "did a file actually
# change" gate above is the only throttle we need: real evolutions are
# rare, so no extra opt-in switch or daily-count limit is required.
if channel_type and receiver:
_notify_user(channel_type, receiver, result)
return True
except Exception as e:
logger.warning(f"[Evolution] Run failed for session={session_id}: {e}")
return False
finally:
with _running_lock:
_running_count -= 1
def _inject_evolution_record(
agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str]
) -> None:
"""Add an [EVOLUTION] note to the user session so the main agent can undo."""
try:
note = f"{EVOLUTION_MARKER} {summary}"
if backup_id:
note += f"\n(backup_id: {backup_id}; to undo, restore this backup)"
# Reuse the scheduler-output injection path: isolated execution, only a
# compact record lands in the user session.
agent_bridge.remember_scheduled_output(
session_id=session_id,
content=note,
channel_type=channel_type,
task_description="self-evolution",
)
except Exception as e:
logger.debug(f"[Evolution] Failed to inject evolution record: {e}")
def _notify_user(channel_type: str, receiver: str, summary: str) -> None:
"""Push the evolution summary to the user's channel as a new message."""
try:
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.channel_factory import create_channel
context = Context(ContextType.TEXT, summary)
context["receiver"] = receiver
context["isgroup"] = False
context["session_id"] = receiver
# Channels that reply to an original message need msg=None for a fresh push.
if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"):
context["msg"] = None
if channel_type == "feishu":
context["receive_id_type"] = "open_id"
channel = create_channel(channel_type)
if not channel:
return
# Web is request-response: a background push needs a synthetic request_id
# plus a request->session mapping so the channel can route the message to
# the user's polling queue (same approach the scheduler uses).
if channel_type == "web":
import uuid
request_id = f"evolution_{uuid.uuid4().hex[:8]}"
context["request_id"] = request_id
if hasattr(channel, "request_to_session"):
channel.request_to_session[request_id] = receiver
channel.send(Reply(ReplyType.TEXT, summary), context)
logger.info(f"[Evolution] Notified user via {channel_type}")
except Exception as e:
logger.warning(f"[Evolution] Failed to notify user: {e}")

170
agent/evolution/prompts.py Normal file
View File

@@ -0,0 +1,170 @@
"""Prompts for the self-evolution review agent.
The system prompt is intentionally English-only: it governs the agent's
internal reasoning and is more stable / cheaper to maintain in one language.
The user-facing summary the agent produces should follow the user's own
language (instructed at the end of the prompt).
Design goals (see ref/hermes-agent background_review for inspiration):
- Default to doing NOTHING. Evolution is the exception, not the rule.
- Signal types: skill, unfinished task, memory, knowledge.
- An explicit "do NOT capture" list to avoid self-poisoning over time.
- Generic examples only — never bake in domain-specific business terms.
"""
# Sentinel the agent emits when there is nothing worth evolving.
SILENT_TOKEN = "[SILENT]"
# Marker prefix for the evolution record injected into the user session, so the
# main chat agent can recognize past evolutions and honor an "undo" request.
EVOLUTION_MARKER = "[EVOLUTION]"
EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant.
You are given a transcript of a conversation that just went idle. Your job is to
decide whether anything from it is worth durably learning so future
conversations go better — and if so, to make that change.
# Top principle: default to doing NOTHING
Most ordinary conversations need no evolution. Only act when there is a CLEAR
signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying
silent is the normal, correct outcome — not a failure.
Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual
chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not
explore files, do not write a summary, do not be polite. Just `[SILENT]`.
IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a
tool (write/edit) in this pass. If you did not change any file, you MUST output
exactly `[SILENT]` — never describe a change you only intended to make.
# Signals worth acting on (act only if at least one clearly appears)
SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles
them. When their signal is clear, act; do not be shy here.
1. SKILL — two cases:
a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a
missing step/section, a wrong or outdated detail, an error in its
content), or its OUTPUT repeatedly misses something the user flagged. Read
the relevant skill file under the skills directory and make a small
incremental edit so it never recurs.
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
no existing skill covers and the user is likely to want again. Follow the
`skill-creator` skill's conventions (read its SKILL.md for the required
structure), then create `skills/<name>/SKILL.md` by WRITING the file
directly with the write tool — this is the simplest reliable path. (bash
is available and confined to the workspace if a helper script is truly
needed, but a direct write is preferred.) Only create when the workflow is
genuinely reusable — not for a one-off task.
CRITICAL — fix the SOURCE, do not just remember the symptom: when the root
cause of a problem lives IN a skill file itself (its instructions, content,
or configuration are wrong/outdated), the correct action is to EDIT that
skill so the problem cannot recur. Recording the corrected fact in memory
does NOT prevent recurrence — only fixing the skill does. Never log "skill X
has wrong detail Y" as a memory note in place of editing skill X.
2. UNFINISHED TASK — a specific deliverable you promised but didn't produce,
AND you already have everything needed to finish it. DO IT now with the
available tools and produce the result (e.g. write the file you said you'd
write). If key info is missing, or the task is merely waiting on the user's
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
You only ever notify the user as a side effect of having actually done work.
3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main
assistant already writes memory during the chat, and a nightly pass plus
context-overflow saves are dedicated safety nets — so memory is almost always
already covered without you. Skip unless the main assistant clearly missed a
durable fact that belongs in no skill AND would visibly change future replies.
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
conversation. Treat it as precious: edit it in place to CORRECT a wrong
fact, or append a new durable preference/decision/lesson — but do so
SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles
routine consolidation).
- For a NEW fact that is important but not yet clearly lasting, append ONE
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
daily file is the safe place — but first ask whether this really belongs
in a skill.
- PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal
about the assistant's own identity/personality/style, make a small edit to
AGENT.md; never for user/world facts, and when in doubt do nothing.
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
conversation, never copy what the main assistant already recorded.
- If it is already captured anywhere (check MEMORY.md AND the daily file
first), do NOTHING.
4. KNOWLEDGE — only if the conversation produced durable, reusable reference
knowledge on a topic (the kind worth looking up again) that the main
assistant did NOT already save to `knowledge/`. Add or update the relevant
file there. Like memory, this is the exception: skip routine Q&A, and if the
topic is already covered in `knowledge/`, do NOTHING rather than duplicate.
# Do NOT capture (these poison future behavior)
- Environment failures: missing binaries, unset credentials, uninstalled
packages, "command not found". The user can fix these; they are not durable
rules.
- Negative claims about tools or features ("tool X does not work"). These
harden into refusals the agent cites against itself later.
- One-off task narratives (e.g. summarizing today's content). Not a class of
reusable work.
- Transient errors that resolved on retry within the conversation.
# Execution constraints
- Before changing memory or a skill, READ the current content first and make a
small INCREMENTAL edit. Never fabricate, never rewrite large sections.
- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's
daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded
in EITHER (even if worded differently), do NOT add it again. The main
assistant likely already wrote it during the chat — only add what is
genuinely new or a correction not yet reflected anywhere.
- You may only edit files inside the workspace. Built-in skills shipped with
the product live outside it and are write-protected; do not try to edit them.
- Make at most the few edits the signals justify; do not go looking for work.
# Output
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
- Otherwise, after performing the edits, output a short user-facing summary in
the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain
everyday words — NOT a developer report. No need to expose internal details
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let
the user know they can undo it.
"""
def build_review_user_message(transcript: str, protected_skills: list = None) -> str:
"""Wrap the conversation transcript as the review agent's user message.
``protected_skills`` lists skill names that must never be edited (built-in
skills shipped with the product). Surfaced so the agent avoids them.
"""
protected_note = ""
if protected_skills:
names = ", ".join(sorted(protected_skills))
protected_note = (
"\n\nPROTECTED skills (built-in — never edit these): "
f"{names}\n"
)
try:
from common import i18n
lang_name = "中文" if i18n.is_zh() else "English"
except Exception:
lang_name = "中文"
return (
"Here is the conversation transcript that just went idle. Review it per "
"your instructions. Acting is the exception: the main value is fixing or "
"creating a skill and finishing promised work. Memory and knowledge are "
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
"not already covered."
f"{protected_note}\n"
f"The summary should preferably be written in: {lang_name}\n"
"<transcript>\n"
f"{transcript}\n"
"</transcript>"
)

55
agent/evolution/record.py Normal file
View File

@@ -0,0 +1,55 @@
"""Self-evolution record log.
Session-level evolutions are appended to their OWN per-day file under
``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary
in ``memory/dreams/``). Each day's file accumulates one short section per
evolution pass — tagged with a timestamp and a backup id for undo — so the
memory UI can surface "what the agent learned/changed today" on one timeline
without ever mixing into the dream diary or the main conversation memory.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional
from common.log import logger
def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path:
base = Path(workspace_dir) / "memory"
if user_id:
return base / "users" / user_id / "evolution"
return base / "evolution"
def append_session_evolution(
workspace_dir: Path,
summary: str,
backup_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> None:
"""Append a session-evolution entry to today's evolution log."""
if not summary or not summary.strip():
return
try:
evo_dir = _evolution_dir(workspace_dir, user_id)
evo_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime("%Y-%m-%d")
log_file = evo_dir / f"{today}.md"
ts = datetime.now().strftime("%H:%M")
header = f"## {ts}"
body = summary.strip()
if backup_id:
body += f"\n\n_backup_id: {backup_id}_"
# Create with a title if the file is new, otherwise append a section.
if not log_file.exists():
log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8")
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"\n{header}\n\n{body}\n")
logger.info(f"[Evolution] Recorded session evolution to {log_file.name}")
except Exception as e:
logger.warning(f"[Evolution] Failed to record session evolution: {e}")

151
agent/evolution/trigger.py Normal file
View File

@@ -0,0 +1,151 @@
"""Idle-based evolution trigger.
A single background thread periodically scans live agent sessions and runs an
evolution pass for any session that is idle for >= idle_minutes AND has enough
accumulated signal, where "enough signal" is EITHER:
- >= min_turns user turns since the last evolution, OR
- the live context has grown past _CONTEXT_RATIO of the agent's token budget
(mirrors how OpenClacky / Claude Code consolidate under context pressure).
Turn counting is per user turn (not per message), measured from the last
evolution (or session start). After a pass runs, the baseline resets so a long
session can evolve multiple times without re-judging old content.
Per-session evolution state is stored on the agent instance via lightweight
attributes set by AgentBridge.agent_reply (see _note_user_turn).
"""
from __future__ import annotations
import threading
import time
from common.log import logger
from agent.evolution.config import get_evolution_config
from agent.evolution.executor import run_evolution_for_session
_SCAN_INTERVAL_SECONDS = 60
# Context-pressure trigger: evolve once the live context exceeds this fraction
# of the agent's token budget, even if min_turns hasn't been reached. Kept as a
# module constant (not user config) for now. Fallback budget matches
# agent_initializer / config.py (agent_max_context_tokens default = 50000).
_CONTEXT_RATIO = 0.8
_FALLBACK_CONTEXT_BUDGET = 50000
def _context_pressure_reached(agent) -> bool:
"""True if the agent's live context exceeds _CONTEXT_RATIO of its budget.
Uses the agent's own (estimated) token accounting so behavior matches the
existing context-trimming path. Best-effort: any error -> False.
"""
try:
with agent.messages_lock:
messages = list(agent.messages)
if not messages:
return False
est = sum(agent._estimate_message_tokens(m) for m in messages)
budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET
return est / budget > _CONTEXT_RATIO
except Exception:
return False
def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
"""Record activity for a session's agent. Called once per real user turn.
Maintains, on the agent instance:
_evo_last_active : epoch seconds of the last user turn
_evo_turns : user turns since the last evolution
_evo_channel_type : originating channel (for later notify)
_evo_receiver : push target for notify
"""
try:
agent._evo_last_active = time.time()
agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1
if channel_type:
agent._evo_channel_type = channel_type
if receiver:
agent._evo_receiver = receiver
except Exception:
pass
def mark_run_active(agent, active: bool) -> None:
"""Flag whether the agent is mid-run, so idle scans skip a busy session.
Without this, a single run that lasts longer than idle_minutes would let
the scanner fire an evolution pass concurrently with the live turn.
"""
try:
agent._evo_run_active = bool(active)
if active:
agent._evo_last_active = time.time()
except Exception:
pass
def start_evolution_trigger(agent_bridge) -> None:
"""Start the idle-scan thread once per process (idempotent)."""
if getattr(agent_bridge, "_evolution_trigger_started", False):
return
agent_bridge._evolution_trigger_started = True
t = threading.Thread(
target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger"
)
t.start()
logger.info("[Evolution] Idle trigger started")
def _scan_loop(agent_bridge) -> None:
while True:
try:
time.sleep(_SCAN_INTERVAL_SECONDS)
cfg = get_evolution_config()
if not cfg.enabled:
continue
_scan_once(agent_bridge, cfg)
except Exception as e:
logger.warning(f"[Evolution] Scan loop error: {e}")
time.sleep(_SCAN_INTERVAL_SECONDS)
def _scan_once(agent_bridge, cfg) -> None:
now = time.time()
# Snapshot to avoid holding the dict while running long evolutions.
sessions = list(getattr(agent_bridge, "agents", {}).items())
for session_id, agent in sessions:
try:
# Skip sessions whose agent is mid-run: a long turn must not be
# reviewed while it is still producing the answer.
if getattr(agent, "_evo_run_active", False):
continue
last_active = getattr(agent, "_evo_last_active", 0)
turns = int(getattr(agent, "_evo_turns", 0))
# Enough signal = enough turns OR enough context pressure.
enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent)
if not enough_signal:
continue
idle = now - last_active if last_active > 0 else -1
if last_active <= 0 or idle < cfg.idle_seconds:
continue
channel_type = getattr(agent, "_evo_channel_type", "") or ""
receiver = getattr(agent, "_evo_receiver", "") or ""
# Reset baseline BEFORE running so a long pass / new messages during
# it don't double-trigger; turns accrue fresh from here.
agent._evo_turns = 0
run_evolution_for_session(
agent_bridge,
session_id=session_id,
channel_type=channel_type,
receiver=receiver,
idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0,
)
except Exception as e:
logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}")

View File

@@ -12,11 +12,17 @@ Knowledge file layout (under workspace_root):
import os
import re
import asyncio
import shutil
import threading
from pathlib import Path
from typing import Optional
from typing import Optional, Iterable
from urllib.parse import quote
from common.log import logger
from config import conf
from agent.memory.config import MemoryConfig
from agent.memory.manager import MemoryManager
class KnowledgeService:
@@ -25,9 +31,389 @@ class KnowledgeService:
Operates directly on the filesystem.
"""
def __init__(self, workspace_root: str):
self.workspace_root = workspace_root
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
PROTECTED_FILES = {"index.md", "log.md"}
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
IMPORT_EXTENSIONS = {".md", ".txt"}
MAX_IMPORT_FILES = 100
MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024
MAX_IMPORT_TOTAL_SIZE = 200 * 1024 * 1024
def __init__(self, workspace_root: str, memory_manager=None):
self.workspace_root = os.path.abspath(workspace_root)
self.knowledge_dir = os.path.join(self.workspace_root, "knowledge")
self._memory_manager = memory_manager
def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None,
allow_missing: bool = True) -> tuple:
if not isinstance(rel_path, str) or not rel_path.strip():
raise ValueError("path is required")
rel_path = rel_path.replace("\\", "/").strip("/")
parts = rel_path.split("/")
if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts):
raise ValueError("invalid path")
if kind == "document" and not rel_path.lower().endswith(".md"):
raise ValueError("document path must end with .md")
root = Path(self.knowledge_dir).resolve()
candidate = root.joinpath(*parts)
# Resolve the nearest existing ancestor so a symlink cannot be used
# to escape when the final destination does not exist yet.
ancestor = candidate
while not ancestor.exists() and ancestor != root:
ancestor = ancestor.parent
try:
ancestor.resolve().relative_to(root)
except ValueError:
raise ValueError("path outside knowledge dir")
if candidate.exists():
try:
candidate.resolve().relative_to(root)
except ValueError:
raise ValueError("path outside knowledge dir")
elif not allow_missing:
raise FileNotFoundError(f"path not found: {rel_path}")
return rel_path, candidate
def _ensure_not_protected(self, rel_path: str):
if rel_path in self.PROTECTED_FILES:
raise ValueError(f"protected knowledge file: {rel_path}")
def _manager(self):
if self._memory_manager is None:
# Reuse the shared embedding provider selection so knowledge index
# sync gets vectors too, instead of degrading to keyword-only.
from agent.memory.embedding import create_default_embedding_provider
embedding_provider = create_default_embedding_provider()
self._memory_manager = MemoryManager(
MemoryConfig(workspace_root=self.workspace_root),
embedding_provider=embedding_provider,
)
return self._memory_manager
@staticmethod
def _run_sync(coro):
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
result = []
error = []
def runner():
try:
result.append(asyncio.run(coro))
except Exception as exc:
error.append(exc)
thread = threading.Thread(target=runner)
thread.start()
thread.join()
if error:
raise error[0]
return result[0] if result else None
def _sync_index(self, old_paths: Iterable[str], force: bool = False):
old_paths = sorted(set(old_paths))
if not old_paths and not force:
return
manager = self._manager()
for rel_path in old_paths:
manager.storage.delete_by_path(f"knowledge/{rel_path}")
manager.mark_dirty()
self._run_sync(manager.sync())
@staticmethod
def _extract_title(md_path: Path, fallback: str) -> str:
"""Read a markdown file's H1 title, falling back to the file stem."""
try:
with open(md_path, "r", encoding="utf-8") as f:
for _ in range(20):
line = f.readline()
if not line:
break
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip() or fallback
except Exception:
pass
return fallback
def rebuild_index_md(self) -> bool:
"""Regenerate knowledge/index.md from the actual directory tree.
Keeps the index in sync with real files so it never drifts or loses
documents. Returns True when the file was (re)written.
"""
root = Path(self.knowledge_dir)
if not root.is_dir():
return False
def collect(dir_path: Path) -> list:
# Return sorted (rel_path, title) tuples for *.md under dir_path,
# excluding protected files at the knowledge root and dot files.
entries = []
for md in sorted(dir_path.rglob("*.md")):
rel = md.relative_to(root).as_posix()
if any(part.startswith(".") for part in md.relative_to(root).parts):
continue
if rel in self.PROTECTED_FILES:
continue
entries.append((rel, self._extract_title(md, md.stem)))
return entries
all_entries = collect(root)
def link(rel: str) -> str:
# Encode each path segment so spaces / special chars stay valid in
# markdown links, while keeping the slashes between segments.
encoded = "/".join(quote(part) for part in rel.split("/"))
return f"./{encoded}"
lines = ["# 知识库目录", ""]
# Root-level documents first (no category dir).
root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel]
for rel, title in root_docs:
lines.append(f"- [{title}]({link(rel)})")
if root_docs:
lines.append("")
# Group remaining documents by their top-level category.
categories = {}
for rel, title in all_entries:
if "/" not in rel:
continue
category = rel.split("/", 1)[0]
categories.setdefault(category, []).append((rel, title))
for category in sorted(categories.keys()):
lines.append(f"## {category}")
for rel, title in categories[category]:
lines.append(f"- [{title}]({link(rel)})")
lines.append("")
content = "\n".join(lines).rstrip() + "\n"
index_path = root / "index.md"
try:
index_path.write_text(content, encoding="utf-8")
return True
except Exception as exc:
logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}")
return False
def _sanitize_document_name(self, filename: str) -> str:
name = os.path.basename((filename or "").replace("\\", "/")).strip()
if not name:
raise ValueError("filename is required")
stem, ext = os.path.splitext(name)
if ext.lower() not in self.IMPORT_EXTENSIONS:
raise ValueError(f"unsupported file type: {ext or name}")
if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem):
raise ValueError("invalid filename")
safe_name = f"{stem}.md"
self._ensure_not_protected(safe_name)
return safe_name
@staticmethod
def _decode_document_content(content) -> str:
if isinstance(content, str):
return content
if not isinstance(content, (bytes, bytearray)):
raise ValueError("document content is required")
return bytes(content).decode("utf-8-sig", errors="replace")
def _resolve_import_destination(self, target_category: str, filename: str,
conflict_strategy: str) -> tuple:
target_rel, target_full = self._resolve_path(target_category, kind="category")
if not target_full.is_dir():
raise FileNotFoundError(f"category not found: {target_rel}")
safe_name = self._sanitize_document_name(filename)
destination = target_full / safe_name
rel_path = f"{target_rel}/{safe_name}"
if destination.exists():
if conflict_strategy == "skip":
return rel_path, destination, "skip"
if conflict_strategy == "rename":
stem = destination.stem
suffix = destination.suffix
for index in range(1, 1000):
candidate = target_full / f"{stem}-{index}{suffix}"
if not candidate.exists():
candidate_rel = f"{target_rel}/{candidate.name}"
return candidate_rel, candidate, "write"
raise FileExistsError(f"target already exists: {rel_path}")
if conflict_strategy != "overwrite":
raise ValueError("invalid conflict strategy")
return rel_path, destination, "write"
def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE:
raise ValueError("file too large")
if full_path.exists() and not overwrite:
raise FileExistsError(f"target already exists: {rel_path}")
old_paths = [rel_path] if full_path.exists() else []
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content or "", encoding="utf-8")
# Keep index.md in sync before reindexing so it is indexed too.
self.rebuild_index_md()
self._sync_index(old_paths, force=True)
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
def import_documents(self, target_category: str, files: Iterable[dict],
conflict_strategy: str = "skip") -> dict:
if not isinstance(files, list):
raise ValueError("files must be a list")
if len(files) > self.MAX_IMPORT_FILES:
raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}")
results = []
old_paths = []
imported = skipped = failed = 0
total_size = 0
for item in files:
filename = item.get("filename") if isinstance(item, dict) else None
try:
content_bytes = item.get("content") if isinstance(item, dict) else None
size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"")
total_size += size
if total_size > self.MAX_IMPORT_TOTAL_SIZE:
raise ValueError("import batch too large")
if size > self.MAX_IMPORT_FILE_SIZE:
raise ValueError("file too large")
rel_path, destination, mode = self._resolve_import_destination(
target_category, filename, conflict_strategy
)
if mode == "skip":
skipped += 1
results.append({"filename": filename, "path": rel_path, "status": "skipped",
"reason": "target_exists"})
continue
old_exists = destination.exists()
content = self._decode_document_content(content_bytes)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")
if old_exists:
old_paths.append(rel_path)
imported += 1
results.append({"filename": filename, "path": rel_path, "status": "imported",
"overwritten": old_exists})
except Exception as exc:
failed += 1
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
if imported:
# Keep index.md in sync before reindexing so it is indexed too.
self.rebuild_index_md()
self._sync_index(old_paths, force=True)
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
def create_category(self, path: str) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category")
if full_path.exists():
return {"path": rel_path, "created": False, "reason": "already_exists"}
full_path.mkdir(parents=True)
return {"path": rel_path, "created": True}
def rename_category(self, path: str, new_path: str) -> dict:
old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False)
new_rel, new_full = self._resolve_path(new_path, kind="category")
if not old_full.is_dir():
raise ValueError(f"not a category: {old_rel}")
if new_full.exists():
raise FileExistsError(f"target already exists: {new_rel}")
old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/")
for p in old_full.rglob("*.md") if p.is_file()]
new_full.parent.mkdir(parents=True, exist_ok=True)
try:
old_full.rename(new_full)
except FileNotFoundError:
return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"}
except FileExistsError:
raise FileExistsError(f"target already exists: {new_rel}")
old_paths = [f"{old_rel}/{p}" for p in old_documents]
self._sync_index(old_paths)
return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)}
def delete_category(self, path: str, confirm: bool = False) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category")
if not full_path.exists():
return {"path": rel_path, "deleted": False, "reason": "not_found"}
if not full_path.is_dir():
raise ValueError(f"not a category: {rel_path}")
knowledge_root = Path(self.knowledge_dir).resolve()
documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/")
for p in full_path.rglob("*.md") if p.is_file()]
if any(p in self.PROTECTED_FILES for p in documents):
raise ValueError("category contains protected knowledge files")
if any(full_path.iterdir()) and not confirm:
raise ValueError("category is not empty; confirmation is required")
try:
shutil.rmtree(full_path)
except FileNotFoundError:
return {"path": rel_path, "deleted": False, "reason": "not_found"}
self._sync_index(documents)
return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)}
def delete_documents(self, paths: Iterable[str]) -> dict:
if not isinstance(paths, list):
raise ValueError("paths must be a list")
results = []
deleted = []
for path in paths:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if not full_path.exists():
deleted.append(rel_path)
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
continue
if not full_path.is_file():
raise ValueError(f"not a document: {rel_path}")
try:
full_path.unlink()
deleted.append(rel_path)
results.append({"path": rel_path, "deleted": True})
except FileNotFoundError:
deleted.append(rel_path)
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
self._sync_index(deleted)
return {"results": results, "deleted": sum(1 for item in results if item["deleted"])}
def move_documents(self, paths: Iterable[str], target_category: str) -> dict:
if not isinstance(paths, list):
raise ValueError("paths must be a list")
target_rel, target_full = self._resolve_path(target_category, kind="category")
if not target_full.is_dir():
raise FileNotFoundError(f"category not found: {target_rel}")
results = []
moved_old_paths = []
for path in paths:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if not full_path.exists():
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
continue
destination = target_full / full_path.name
new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/")
if destination.exists():
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
"target": new_rel})
continue
try:
os.link(full_path, destination)
full_path.unlink()
moved_old_paths.append(rel_path)
results.append({"path": rel_path, "moved": True, "target": new_rel})
except FileExistsError:
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
"target": new_rel})
except FileNotFoundError:
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
self._sync_index(moved_old_paths)
return {"results": results, "moved": len(moved_old_paths)}
# ------------------------------------------------------------------
# list — directory tree with stats
@@ -98,14 +484,18 @@ class KnowledgeService:
if not is_root:
stats["pages"] += 1
stats["size"] += size
title = name.replace(".md", "")
try:
with open(full, "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line.startswith("# "):
title = first_line[2:].strip()
except Exception:
pass
# Prefer the H1 heading as a readable title for normal docs.
# System files (index.md / log.md) keep their filename so the
# tree never hides what they actually are.
title = name[:-3]
if name not in self.PROTECTED_FILES:
try:
with open(full, "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line.startswith("# "):
title = first_line[2:].strip() or title
except Exception:
pass
files.append({"name": name, "title": title, "size": size})
return files, children
@@ -121,15 +511,8 @@ class KnowledgeService:
:raises ValueError: if path is invalid or escapes knowledge dir
:raises FileNotFoundError: if file does not exist
"""
if not rel_path or ".." in rel_path:
raise ValueError("invalid path")
full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path))
allowed = os.path.normpath(self.knowledge_dir)
if not full_path.startswith(allowed + os.sep) and full_path != allowed:
raise ValueError("path outside knowledge dir")
if not os.path.isfile(full_path):
rel_path, full_path = self._resolve_path(rel_path, kind="document")
if not full_path.is_file():
raise FileNotFoundError(f"file not found: {rel_path}")
with open(full_path, "r", encoding="utf-8") as f:
@@ -228,13 +611,35 @@ class KnowledgeService:
result = self.build_graph()
return {"action": action, "code": 200, "message": "success", "payload": result}
elif action == "create_category":
result = self.create_category(payload.get("path"))
elif action == "rename_category":
result = self.rename_category(payload.get("path"), payload.get("new_path"))
elif action == "delete_category":
result = self.delete_category(payload.get("path"), payload.get("confirm", False))
elif action == "delete_documents":
result = self.delete_documents(payload.get("paths") or [])
elif action == "move_documents":
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
elif action == "create_document":
result = self.create_document(payload.get("path"), payload.get("content", ""),
payload.get("overwrite", False))
elif action == "import_documents":
result = self.import_documents(
payload.get("target_category"),
payload.get("files") or [],
payload.get("conflict_strategy", "skip"),
)
else:
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
return {"action": action, "code": 200, "message": "success", "payload": result}
except ValueError as e:
return {"action": action, "code": 403, "message": str(e), "payload": None}
except FileNotFoundError as e:
return {"action": action, "code": 404, "message": str(e), "payload": None}
except FileExistsError as e:
return {"action": action, "code": 409, "message": str(e), "payload": None}
except Exception as e:
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
return {"action": action, "code": 500, "message": str(e), "payload": None}

View File

@@ -7,7 +7,7 @@ conversation history persistence (SQLite).
from agent.memory.manager import MemoryManager
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
from agent.memory.embedding import create_embedding_provider
from agent.memory.embedding import create_embedding_provider, create_default_embedding_provider
from agent.memory.conversation_store import ConversationStore, get_conversation_store
from agent.memory.summarizer import ensure_daily_memory_file
@@ -17,6 +17,7 @@ __all__ = [
'get_default_memory_config',
'set_global_memory_config',
'create_embedding_provider',
'create_default_embedding_provider',
'ConversationStore',
'get_conversation_store',
'ensure_daily_memory_file',

View File

@@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db
from __future__ import annotations
import json
import re
import sqlite3
import threading
import time
@@ -109,6 +110,48 @@ def _extract_display_text(content: Any) -> str:
return ""
# Internal markers written into the session for the agent's own bookkeeping
# (scheduler injection / self-evolution undo). They must stay in the stored
# content (the LLM reads them, e.g. to find a backup_id for undo) but should
# never be shown verbatim to the user in the chat history UI.
_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task")
_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]"
def _is_internal_user_marker(text: str) -> bool:
"""True if a user-turn text is an internal injection marker (hide from UI)."""
t = (text or "").lstrip()
return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS)
def _is_evolution_text(text: str) -> bool:
"""True if assistant text is a self-evolution summary (before cleaning)."""
return (text or "").lstrip().startswith(_EVOLUTION_DISPLAY_MARKER)
def _clean_display_text(text: str) -> str:
"""Strip internal markers from assistant text for user-facing display.
Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)``
undo hint. The raw stored message is untouched, so undo + LLM context still
work; only the rendered chat bubble is cleaned.
"""
if not text:
return text
cleaned = text
stripped = cleaned.lstrip()
if stripped.startswith(_EVOLUTION_DISPLAY_MARKER):
cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip()
# Drop a trailing backup_id undo hint line, e.g.
# "(backup_id: 20260607-...; to undo, restore this backup)"
cleaned = re.sub(
r"\n*\(backup_id:[^\)]*\)\s*$",
"",
cleaned,
).rstrip()
return cleaned
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
"""
Extract tool_use blocks from an assistant message content.
@@ -210,7 +253,10 @@ def _group_into_display_turns(
if user_row:
content, created_at, _u_extras = user_row
text = _extract_display_text(content)
if text:
# Hide internal injection markers (scheduler / self-evolution) so the
# user never sees a synthetic "[SCHEDULED] self-evolution" bubble;
# the assistant reply that follows is still rendered.
if text and not _is_internal_user_marker(text):
turns.append({"role": "user", "content": text, "created_at": created_at})
# Build an ordered list of steps preserving the original sequence:
@@ -265,6 +311,18 @@ def _group_into_display_turns(
step["result"] = tr.get("result", "")
step["is_error"] = tr.get("is_error", False)
# Detect a self-evolution bubble BEFORE cleaning the marker away, so the
# UI can flag it even though the visible text stays clean.
is_evolution = _is_evolution_text(final_text)
# Clean internal markers from the user-facing assistant text. Applies to
# both the final content and the mirrored content step so the rendered
# bubble shows clean text while the stored message keeps the markers.
final_text = _clean_display_text(final_text)
for step in steps:
if step.get("type") == "content":
step["content"] = _clean_display_text(step.get("content", ""))
if steps or final_text:
turn = {
"role": "assistant",
@@ -272,6 +330,8 @@ def _group_into_display_turns(
"steps": steps,
"created_at": final_ts or (user_row[1] if user_row else 0),
}
if is_evolution:
turn["kind"] = "evolution"
if merged_extras:
turn["extras"] = merged_extras
turns.append(turn)
@@ -291,7 +351,7 @@ class ConversationStore:
def __init__(self, db_path: Path):
self._db_path = db_path
self._lock = threading.Lock()
self._lock = threading.RLock() # Use RLock to allow reentrant locking
self._init_db()
# ------------------------------------------------------------------
@@ -509,6 +569,65 @@ class ConversationStore:
finally:
conn.close()
def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
"""Return the seq numbers of the latest visible user message and the
latest assistant message in a session.
A "visible" user message is one whose content is real user text
(not just a tool_result block), so tool-execution turns do not
shadow the actual user query.
Returns:
Dict with keys ``user_seq`` and ``bot_seq``; either may be None
when no matching message exists.
"""
result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
with self._lock:
conn = self._connect()
try:
# Latest assistant message (cheap: single row by seq DESC).
row = conn.execute(
"SELECT seq FROM messages "
"WHERE session_id = ? AND role = 'assistant' "
"ORDER BY seq DESC LIMIT 1",
(session_id,),
).fetchone()
if row:
result["bot_seq"] = int(row[0])
# Latest visible user message: scan recent user rows and
# skip pure tool_result entries.
rows = conn.execute(
"SELECT seq, content FROM messages "
"WHERE session_id = ? AND role = 'user' "
"ORDER BY seq DESC LIMIT 20",
(session_id,),
).fetchall()
for seq, content_raw in rows:
try:
content = json.loads(content_raw)
except Exception:
result["user_seq"] = int(seq)
break
if isinstance(content, list):
has_text = any(
isinstance(b, dict) and b.get("type") == "text"
for b in content
)
has_tool_result = any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in content
)
if has_text and not has_tool_result:
result["user_seq"] = int(seq)
break
else:
result["user_seq"] = int(seq)
break
finally:
conn.close()
return result
def clear_session(self, session_id: str) -> None:
"""Delete all messages and the session record for a given session_id."""
with self._lock:
@@ -524,6 +643,109 @@ class ConversationStore:
finally:
conn.close()
def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int:
"""Delete a user message and/or its corresponding assistant reply.
The assistant reply is identified as all messages between user_seq
and the next visible user message (or end of session).
Args:
session_id: Session identifier.
user_seq: The seq number of the user message.
delete_user: If True (default), delete the user message too.
If False, only delete assistant reply (for regenerate scenarios).
cascade: If True, also delete all subsequent turns after this one.
Used by edit-message which removes this turn and everything after.
Returns:
Number of message rows deleted.
"""
with self._lock:
conn = self._connect()
try:
with conn:
# Verify this is a user message
row = conn.execute(
"SELECT role FROM messages WHERE session_id = ? AND seq = ?",
(session_id, user_seq),
).fetchone()
if not row or row[0] != "user":
return 0
if cascade:
# Delete from this message to end of session
start_seq = user_seq if delete_user else user_seq + 1
end_seq_row = conn.execute(
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
(session_id,),
).fetchone()
end_seq = (end_seq_row[0] or user_seq) + 1
else:
# Find the next visible user message seq (exclude tool_result)
# Use batched query to avoid loading too many rows at once
next_user_seq = None
batch_size = 100
offset = 0
while True:
batch = conn.execute(
"""
SELECT seq, content FROM messages
WHERE session_id = ? AND seq > ? AND role = 'user'
ORDER BY seq ASC
LIMIT ? OFFSET ?
""",
(session_id, user_seq, batch_size, offset),
).fetchall()
if not batch:
break
for seq, content in batch:
try:
content_obj = json.loads(content)
except Exception:
content_obj = content
if _is_visible_user_message(content_obj):
next_user_seq = seq
break
if next_user_seq is not None:
break
offset += batch_size
# Determine the end boundary for deletion
if next_user_seq is not None:
end_seq = next_user_seq
else:
end_seq_row = conn.execute(
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
(session_id,),
).fetchone()
end_seq = (end_seq_row[0] or user_seq) + 1
# Determine the start boundary for deletion
start_seq = user_seq if delete_user else user_seq + 1
# Delete messages from start_seq to end_seq (exclusive)
cur = conn.execute(
"DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?",
(session_id, start_seq, end_seq),
)
deleted = cur.rowcount
# Update session msg_count
conn.execute(
"""
UPDATE sessions
SET msg_count = (
SELECT COUNT(*) FROM messages WHERE session_id = ?
)
WHERE session_id = ?
""",
(session_id, session_id),
)
return deleted
finally:
conn.close()
def prune_scheduled_messages(
self,
session_id: str,
@@ -1053,3 +1275,4 @@ def get_conversation_store() -> ConversationStore:
_store_instance = ConversationStore(db_path)
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
return _store_instance

View File

@@ -16,6 +16,7 @@ from agent.memory.embedding.provider import (
OpenAIEmbeddingProvider,
create_embedding_provider,
)
from agent.memory.embedding.factory import create_default_embedding_provider
from agent.memory.embedding.rebuild import (
RebuildResult,
clear_index,
@@ -33,6 +34,7 @@ __all__ = [
"EmbeddingProvider",
"OpenAIEmbeddingProvider",
"create_embedding_provider",
"create_default_embedding_provider",
"RebuildResult",
"clear_index",
"rebuild_in_process",

View File

@@ -0,0 +1,209 @@
"""
Shared embedding provider factory.
Resolves the embedding provider purely from config.json, so every caller
(agent initialization, knowledge base sync, index rebuild, ...) selects the
same provider instead of silently degrading to keyword-only search.
Two paths:
A. Default (no `embedding_provider` in config.json):
Auto-init OpenAI -> LinkAI fallback.
B. Explicit (`embedding_provider` is set):
Initialize the requested vendor with unified dim (default per vendor).
"""
import os
from typing import Optional
from common.log import logger
# Track whether the embedding model log has been printed in this process,
# so we avoid spamming it once per session/caller.
_embedding_logged: bool = False
def create_default_embedding_provider():
"""Build the embedding provider from config, or None for keyword-only mode."""
from config import conf
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
if not explicit_provider:
return _init_legacy_provider()
return _init_explicit_provider(explicit_provider)
def _init_legacy_provider():
"""Legacy auto-init path: OpenAI -> LinkAI."""
from agent.memory.embedding.provider import create_embedding_provider
from config import conf
embedding_provider = None
embedding_model = None
openai_api_key = conf().get("open_ai_api_key", "")
openai_api_base = conf().get("open_ai_api_base", "")
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="openai",
model=model,
api_key=openai_api_key,
api_base=openai_api_base or "https://api.openai.com/v1",
)
embedding_model = f"openai/{model}"
except Exception as e:
logger.warning(f"[EmbeddingFactory] OpenAI embedding failed: {e}")
if embedding_provider is None:
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="linkai",
model=model,
api_key=linkai_api_key,
api_base=f"{linkai_api_base}/v1",
)
embedding_model = f"linkai/{model}"
except Exception as e:
logger.warning(f"[EmbeddingFactory] LinkAI embedding failed: {e}")
if embedding_provider is not None and embedding_model:
_log_provider_once(f"{embedding_model} (dim={embedding_provider.dimensions})")
return embedding_provider
def _init_explicit_provider(provider_key: str):
"""Explicit-provider path: build the configured vendor."""
from agent.memory.embedding.provider import EMBEDDING_VENDORS, create_embedding_provider
from config import conf
# Custom providers ("custom:<id>") resolve credentials from custom_providers.
resolved_provider_key = provider_key
if provider_key.startswith("custom:"):
resolved_provider_key = "custom"
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
if meta is None:
logger.error(
f"[EmbeddingFactory] Unknown embedding_provider '{provider_key}'. "
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
f"Memory will run in keyword-only mode."
)
return None
api_key = _resolve_api_key(provider_key)
api_base = _resolve_api_base(provider_key, meta["default_base_url"])
if not api_key:
logger.error(
f"[EmbeddingFactory] embedding_provider='{provider_key}' is set but its "
f"API key is missing. Memory will run in keyword-only mode."
)
return None
model = (conf().get("embedding_model") or "").strip()
# Custom providers without a model fall back to the provider's default.
if not model and resolved_provider_key == "custom":
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry and entry.get("model"):
model = entry["model"]
if not model and resolved_provider_key != "custom":
model = meta["default_model"]
try:
cfg_dim = int(conf().get("embedding_dimensions") or 0)
except (TypeError, ValueError):
cfg_dim = 0
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
try:
provider = create_embedding_provider(
provider=resolved_provider_key,
model=model,
api_key=api_key,
api_base=api_base,
dimensions=dim,
)
except Exception as e:
logger.error(
f"[EmbeddingFactory] Failed to init embedding provider "
f"'{provider_key}/{model}': {e}"
)
return None
_log_provider_once(f"{provider_key}/{model} (dim={provider.dimensions})")
return provider
def _resolve_api_key(provider_key: str) -> str:
"""Pick the API key for an explicit embedding provider from config."""
from config import conf
if provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry:
return entry.get("api_key", "")
return ""
key_map = {
"openai": "open_ai_api_key",
"linkai": "linkai_api_key",
"dashscope": "dashscope_api_key",
"doubao": "ark_api_key",
"zhipu": "zhipu_ai_api_key",
}
field = key_map.get(provider_key)
if not field:
return ""
value = conf().get(field, "") or ""
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
return ""
return value
def _resolve_api_base(provider_key: str, default_base: str) -> str:
"""Pick the API base for an explicit embedding provider from config."""
from config import conf
if provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry and entry.get("api_base"):
return entry["api_base"]
return default_base
base_map = {
"openai": "open_ai_api_base",
"linkai": "linkai_api_base",
"doubao": "ark_base_url",
"zhipu": "zhipu_ai_api_base",
}
field = base_map.get(provider_key)
if not field:
return default_base
value = (conf().get(field) or "").strip()
if not value:
return default_base
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
return f"{value.rstrip('/')}/v1"
return value
def _log_provider_once(detail: str):
global _embedding_logged
if not _embedding_logged:
logger.info(f"[EmbeddingFactory] Embedding model in use: {detail}")
_embedding_logged = True

View File

@@ -7,10 +7,14 @@ Supports multiple OpenAI-compatible embedding vendors:
- dashscope (Aliyun Tongyi text-embedding-v4)
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
- zhipu (ZhipuAI embedding-3)
- custom (any OpenAI-compatible endpoint)
Vendor keys here intentionally match the project's bot_type constants in
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
Custom providers (bot_type "custom" or "custom:<id>") reuse the same
OpenAI-compatible REST client with user-supplied api_key / api_base.
All providers share a single OpenAI-compatible REST client. Vendor-specific
behaviors (truncation, query instruction prefix) are configured via metadata.
"""
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
"query_instruction": "",
"max_batch_size": 64,
},
# Custom provider — any OpenAI-compatible /embeddings endpoint. The
# user must supply api_key + api_base + model via the web console
# (stored in custom_providers list or legacy custom_api_key / custom_api_base).
# Dimensions defaults to 1024 but can be overridden via config's
# embedding_dimensions. No dim-param support assumption — safest
# default for unknown endpoints.
"custom": {
"default_base_url": "",
"default_model": "",
"default_dimensions": 1024,
"supports_dim_param": False,
"needs_client_truncate": False,
"needs_client_normalize": True,
"query_instruction": "",
"max_batch_size": 64,
},
}
@@ -472,10 +492,19 @@ def create_embedding_provider(
)
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
resolved_model = model or meta["default_model"]
resolved_base = api_base or meta["default_base_url"]
# Custom providers require explicit api_base and model — they cannot
# fall back to OpenAI defaults like built-in vendors do.
if provider == "custom":
if not resolved_base:
raise ValueError("Custom embedding provider requires an api_base URL")
if not resolved_model:
raise ValueError("Custom embedding provider requires a model name")
return OpenAIEmbeddingProvider(
model=model or meta["default_model"],
model=resolved_model,
api_key=api_key,
api_base=api_base or meta["default_base_url"],
api_base=resolved_base,
extra_headers=extra_headers,
dimensions=final_dim,
supports_dim_param=meta["supports_dim_param"],

View File

@@ -163,10 +163,9 @@ def main() -> int:
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
from bridge.agent_initializer import AgentInitializer
from agent.memory.embedding import create_default_embedding_provider
initializer = AgentInitializer(bridge=None, agent_bridge=None)
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
embedding_provider = create_default_embedding_provider()
if embedding_provider is None:
logger.error(
"[RebuildIndex] No embedding provider could be initialized. "

View File

@@ -34,13 +34,18 @@ class MemoryService:
# ------------------------------------------------------------------
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
"""
List memory or dream files with metadata (without content).
List memory, dream, or evolution files with metadata (without content).
Args:
category: ``"memory"`` (default) — MEMORY.md + daily files;
``"dream"`` — dream diary files from memory/dreams/
``"dream"`` — dream diary files from memory/dreams/;
``"evolution"`` — self-evolution logs from memory/evolution/
merged with the nightly dream diaries, so
one tab shows everything the agent learned.
"""
if category == "dream":
if category == "evolution":
files = self._list_evolution_files()
elif category == "dream":
files = self._list_dream_files()
else:
files = self._list_memory_files()
@@ -93,6 +98,26 @@ class MemoryService:
return files
def _list_evolution_files(self) -> List[dict]:
"""Self-evolution logs (memory/evolution/*.md) merged with the nightly
dream diaries (memory/dreams/*.md), newest first.
Both are surfaced under the unified "Self-Evolution" tab. A file's
``type`` records its origin so the reader can resolve the right dir.
"""
files: List[dict] = []
for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")):
sub_dir = os.path.join(self.memory_dir, sub)
if not os.path.isdir(sub_dir):
continue
for name in os.listdir(sub_dir):
full = os.path.join(sub_dir, name)
if os.path.isfile(full) and name.endswith(".md"):
files.append(self._file_info(full, name, ftype))
# Sort newest first by filename (date-named); ties favor evolution.
files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True)
return files
# ------------------------------------------------------------------
# content — read a single file
# ------------------------------------------------------------------
@@ -101,7 +126,7 @@ class MemoryService:
Read the full content of a memory or dream file.
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
:param category: ``"memory"`` or ``"dream"``
:param category: ``"memory"``, ``"dream"`` or ``"evolution"``
:return: dict with ``filename`` and ``content``
:raises FileNotFoundError: if the file does not exist
"""
@@ -125,7 +150,7 @@ class MemoryService:
Dispatch a memory management action.
:param action: ``list`` or ``content``
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``)
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``)
:return: protocol-compatible response dict
"""
payload = payload or {}
@@ -166,6 +191,7 @@ class MemoryService:
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md``
- ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md``
- ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md``
Raises ValueError if the resolved path escapes the allowed directory.
"""
@@ -173,6 +199,8 @@ class MemoryService:
base_dir = self.workspace_root
elif category == "dream":
base_dir = os.path.join(self.memory_dir, "dreams")
elif category == "evolution":
base_dir = os.path.join(self.memory_dir, "evolution")
else:
base_dir = self.memory_dir

View File

@@ -825,9 +825,10 @@ class MemoryStorage:
return []
def delete_by_path(self, path: str):
"""Delete all chunks from a file"""
"""Delete all chunks and file metadata for a path."""
with self._lock:
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
self.conn.commit()
def get_file_hash(self, path: str) -> Optional[str]:

View File

@@ -419,6 +419,17 @@ class MemoryFlushManager:
lookback_days: How many days of daily files to read (default 1 for scheduled, 3 for manual)
force: Skip input-hash dedup check (used by manual /memory dream trigger)
"""
# Config guard for scheduled runs. Manual trigger (force=True) always
# runs since it is an explicit user action.
if not force:
try:
from config import conf
if not conf().get("deep_dream_enabled", True):
logger.info("[DeepDream] deep_dream_enabled=false, skipping")
return False
except Exception:
pass
if not self.llm_model:
logger.warning("[DeepDream] No LLM model available, skipping")
return False
@@ -462,13 +473,12 @@ class MemoryFlushManager:
daily_content=daily_content or "(no recent daily records)",
)
from agent.protocol.models import LLMRequest
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
input_chars = len(memory_content) + len(daily_content)
dream_max_tokens = max(2000, min(input_chars, 8000))
# No output cap: the prompt already keeps MEMORY.md concise (~50
# items), so a hard max_tokens would only risk truncating a large
# rewrite. Let the model use its default output budget.
request = LLMRequest(
messages=[{"role": "user", "content": user_msg}],
temperature=0.3,
max_tokens=dream_max_tokens,
stream=False,
system=_dream_system_prompt(),
)

View File

@@ -52,6 +52,11 @@ class Agent:
self.workspace_dir = workspace_dir # Workspace directory
self.enable_skills = enable_skills # Skills enabled flag
self.runtime_info = runtime_info # Runtime info for dynamic time update
# Optional extra instructions appended AFTER the rebuilt full system
# prompt. Used by the self-evolution review agent to add its task brief
# on top of the full context (tools, workspace, user preferences, time)
# so it both follows the user's preferences and knows its evolution job.
self.extra_system_suffix = None
# Initialize skill manager
self.skill_manager = None
@@ -120,15 +125,20 @@ class Agent:
except Exception:
lang = "zh"
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
return builder.build(
full = builder.build(
tools=self.tools,
context_files=context_files,
skill_manager=self.skill_manager,
memory_manager=self.memory_manager,
runtime_info=self.runtime_info,
)
if self.extra_system_suffix:
full = f"{full}\n\n{self.extra_system_suffix}"
return full
except Exception as e:
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
if self.extra_system_suffix:
return f"{self.system_prompt}\n\n{self.extra_system_suffix}"
return self.system_prompt
def refresh_skills(self):

View File

@@ -347,11 +347,14 @@ class AgentStreamExecutor:
Returns:
Final response text
"""
# Log user message with model info
# Log user message with model info. Truncate very long messages (e.g.
# injected transcripts / large prompts) so logs stay readable.
thinking_enabled = self._is_thinking_enabled()
thinking_label = " | 💭 thinking" if thinking_enabled else ""
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {user_message}")
_log_msg = user_message if len(user_message) <= 500 else (
user_message[:500] + f" …(+{len(user_message) - 500} chars)"
)
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}")
# Add user message (Claude format - use content blocks for consistency)
self.messages.append({
@@ -376,6 +379,12 @@ class AgentStreamExecutor:
self._emit_event("agent_start")
# Reset the run-scoped MCP tool-retrieval accumulator. On-demand tool
# retrieval only grows this set within a run, so a tool that already
# produced a tool_use never disappears from the schema mid-run (which
# would make Claude/MiniMax raise a message-format error).
self._retrieved_mcp_names = set()
final_response = ""
turn = 0
@@ -699,6 +708,70 @@ class AgentStreamExecutor:
return final_response
def _select_tools_for_injection(self) -> list:
"""Decide which tools to inject into the current LLM turn.
Built-in tools are ALWAYS injected in full (skills and core flows hard
depend on them). MCP tools are also injected in full UNLESS on-demand
retrieval is enabled AND the MCP tool count exceeds the configured
threshold — then only the most relevant MCP tools are injected, unioned
with those already selected earlier in this run (only-grows, so a tool
that already produced a tool_use never vanishes from the schema).
Degrades safely: disabled feature, no embedding provider, embedding
failure, count below threshold, or any error → inject all tools. Tools
are never silently dropped.
"""
all_tools = list(self.tools.values())
try:
from config import conf
if not conf().get("mcp_tool_retrieval_enabled", False):
return all_tools
from agent.tools.mcp.mcp_tool import McpTool
mcp_tools = [t for t in all_tools if isinstance(t, McpTool)]
builtin_tools = [t for t in all_tools if not isinstance(t, McpTool)]
threshold = int(conf().get("mcp_tool_retrieval_threshold", 20) or 20)
if len(mcp_tools) <= threshold:
return all_tools
top_k = int(conf().get("mcp_tool_retrieval_top_k", 10) or 10)
from agent.tools import ToolManager
from agent.tools.mcp.tool_retrieval import (
build_retrieval_query,
select_mcp_tools,
)
tm = ToolManager()
tool_vectors = tm.get_mcp_tool_vectors()
query = build_retrieval_query(self.messages)
query_vector = tm.embed_query(query)
selected = select_mcp_tools(
query_vector,
tool_vectors,
top_k,
getattr(self, "_retrieved_mcp_names", set()),
)
if selected is None:
# No provider / empty index / error → full injection.
return all_tools
# Persist the accumulated selection for subsequent turns.
self._retrieved_mcp_names = selected
selected_mcp = [t for t in mcp_tools if t.name in selected]
logger.info(
f"[ToolRetrieval] Injecting {len(builtin_tools)} built-in + "
f"{len(selected_mcp)}/{len(mcp_tools)} MCP tool(s) (top_k={top_k})"
)
return builtin_tools + selected_mcp
except Exception as e:
logger.debug(f"[ToolRetrieval] full injection (retrieval skipped): {e}")
return all_tools
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
"""
@@ -739,7 +812,7 @@ class AgentStreamExecutor:
tools_schema = None
if self.tools:
tools_schema = []
for tool in self.tools.values():
for tool in self._select_tools_for_injection():
input_schema = tool.params
try:
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
@@ -1171,10 +1244,21 @@ class AgentStreamExecutor:
# Set tool context
tool.model = self.model
tool.context = self.agent
tool.progress_callback = lambda message: self._emit_event(
"tool_execution_progress",
{
"tool_call_id": tool_id,
"tool_name": tool_name,
"message": message,
}
)
# Execute tool
start_time = time.time()
result: ToolResult = tool.execute_tool(arguments)
try:
result: ToolResult = tool.execute_tool(arguments)
finally:
tool.progress_callback = None
execution_time = time.time() - start_time
result_dict = {
@@ -1716,4 +1800,4 @@ class AgentStreamExecutor:
not as a message. The AgentLLMModel will handle this.
"""
# Don't add system message here - it will be handled separately by the LLM adapter
return self.messages
return self.messages

View File

@@ -34,6 +34,27 @@ class SkillService:
"""
self.manager = skill_manager
def _safe_skill_dir(self, name: str) -> str:
"""Derive and validate the skill directory path.
Ensures the resolved path stays within the custom_dir root,
preventing path traversal via names like ``../escaped``.
:raises ValueError: if the name would escape the skills root.
"""
if not name or not name.strip():
raise ValueError("skill name is required")
# Reject obvious traversal components.
if ".." in name or name.startswith("/") or name.startswith("\\"):
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
root = os.path.realpath(self.manager.custom_dir)
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
raise ValueError(
f"skill name {name!r} resolves outside the skills directory"
)
return skill_dir
# ------------------------------------------------------------------
# query
# ------------------------------------------------------------------
@@ -107,7 +128,7 @@ class SkillService:
if not files:
raise ValueError("skill files list is empty")
skill_dir = os.path.join(self.manager.custom_dir, name)
skill_dir = self._safe_skill_dir(name)
tmp_dir = skill_dir + ".tmp"
if os.path.exists(tmp_dir):
@@ -146,7 +167,7 @@ class SkillService:
raise ValueError("package url is required")
url = files[0]["url"]
skill_dir = os.path.join(self.manager.custom_dir, name)
skill_dir = self._safe_skill_dir(name)
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "package.zip")
@@ -217,7 +238,7 @@ class SkillService:
if not name:
raise ValueError("skill name is required")
skill_dir = os.path.join(self.manager.custom_dir, name)
skill_dir = self._safe_skill_dir(name)
if os.path.exists(skill_dir):
shutil.rmtree(skill_dir)
logger.info(f"[SkillService] delete: removed directory {skill_dir}")

View File

@@ -14,6 +14,9 @@ from agent.tools.send.send import Send
from agent.tools.memory.memory_search import MemorySearchTool
from agent.tools.memory.memory_get import MemoryGetTool
# Import self-evolution tools
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
# Import tools with optional dependencies
def _import_optional_tools():
"""Import tools that have optional dependencies"""
@@ -135,6 +138,7 @@ __all__ = [
'Send',
'MemorySearchTool',
'MemoryGetTool',
'EvolutionUndoTool',
'EnvConfig',
'SchedulerTool',
'WebSearch',

View File

@@ -38,6 +38,16 @@ class BaseTool:
description: str = "Base tool"
params: dict = {} # Store JSON Schema
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
progress_callback = None
def report_progress(self, message: str):
callback = getattr(self, "progress_callback", None)
if not callback:
return
try:
callback(str(message))
except Exception as e:
logger.debug(f"[{self.name}] progress callback failed: {e}")
@classmethod
def get_json_schema(cls) -> dict:

View File

@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
import os
import re
import signal
import sys
import subprocess
import tempfile
import threading
import time
from typing import Dict, Any
from agent.tools.base_tool import BaseTool, ToolResult
@@ -19,6 +22,10 @@ class Bash(BaseTool):
"""Tool for executing bash commands"""
_IS_WIN = sys.platform == "win32"
_PROGRESS_MAX_BYTES = 4 * 1024
_PROGRESS_INTERVAL = 0.5
# cmd.exe command line limit is ~8191 chars; rewrite python -c above this.
_WIN_CMD_SAFE_LEN = 7000
name: str = "bash"
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
@@ -69,8 +76,8 @@ SAFETY:
if not command:
return ToolResult.fail("Error: command parameter is required")
# Security check: Prevent accessing sensitive config files
if "~/.cow/.env" in command or "~/.cow" in command:
# Security check: Prevent direct access to the credential file
if re.search(r'\.cow[/\\]\.env', command):
return ToolResult.fail(
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
)
@@ -106,25 +113,35 @@ SAFETY:
else:
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
# Temp script written for long `python -c` commands (Windows only),
# cleaned up after execution.
temp_script_path = None
# On Windows, convert $VAR references to %VAR% for cmd.exe
if self._IS_WIN:
env["PYTHONIOENCODING"] = "utf-8"
command = self._convert_env_vars_for_windows(command, dotenv_vars)
# cmd.exe has an ~8191 char command line limit. Long
# `python -c "..."` commands silently fail, so spill the inline
# code into a temp .py file and run that instead.
if len(command) > self._WIN_CMD_SAFE_LEN:
command, temp_script_path = self._rewrite_long_python_c(command)
if command and not command.strip().lower().startswith("chcp"):
command = f"chcp 65001 >nul 2>&1 && {command}"
result = subprocess.run(
command,
shell=True,
cwd=self.cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
env=env,
)
try:
result = self._run_streaming(
command,
timeout,
env,
dotenv_vars,
)
finally:
if temp_script_path:
try:
os.remove(temp_script_path)
except OSError:
pass
logger.debug(f"[Bash] Exit code: {result.returncode}")
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
@@ -185,8 +202,12 @@ SAFETY:
total_bytes = len(output.encode('utf-8'))
if total_bytes > DEFAULT_MAX_BYTES:
# Save full output to temp file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
# Save full output to temp file. encoding='utf-8' is required:
# the default text-mode encoding is the platform locale (e.g.
# cp936/GBK on Chinese Windows), which raises UnicodeEncodeError
# for output containing emoji or other non-locale characters and
# would discard an otherwise successful command result.
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f:
f.write(output)
temp_file_path = f.name
@@ -236,6 +257,105 @@ SAFETY:
except Exception as e:
return ToolResult.fail(f"Error executing command: {str(e)}")
def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict):
process = subprocess.Popen(
command,
shell=True,
cwd=self.cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
start_new_session=not self._IS_WIN,
)
stdout_chunks, stderr_chunks = [], []
recent = bytearray()
recent_lock = threading.Lock()
def drain(stream, chunks):
while True:
chunk = os.read(stream.fileno(), 4096)
if not chunk:
break
chunks.append(chunk)
with recent_lock:
recent.extend(chunk)
if len(recent) > self._PROGRESS_MAX_BYTES:
del recent[:-self._PROGRESS_MAX_BYTES]
readers = [
threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True),
threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True),
]
for reader in readers:
reader.start()
started = time.monotonic()
last_reported_at = started
last_snapshot = None
try:
while process.poll() is None:
now = time.monotonic()
elapsed = now - started
if elapsed >= timeout:
self._kill_process(process)
raise subprocess.TimeoutExpired(command, timeout)
if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL:
with recent_lock:
snapshot = bytes(recent).decode("utf-8", errors="replace")
snapshot = self._redact_progress(snapshot, dotenv_vars)
if snapshot and snapshot != last_snapshot:
self.report_progress(snapshot)
last_snapshot = snapshot
last_reported_at = now
time.sleep(0.1)
finally:
if process.poll() is None:
self._kill_process(process)
process.wait()
join_deadline = time.monotonic() + 5
for reader in readers:
reader.join(timeout=max(0, join_deadline - time.monotonic()))
from types import SimpleNamespace
return SimpleNamespace(
returncode=process.returncode,
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
)
def _kill_process(self, process):
if self._IS_WIN:
try:
result = subprocess.run(
["taskkill", "/F", "/T", "/PID", str(process.pid)],
capture_output=True,
timeout=5,
)
if result.returncode != 0 and process.poll() is None:
process.kill()
except (OSError, subprocess.SubprocessError):
if process.poll() is None:
process.kill()
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except (PermissionError, ProcessLookupError):
if process.poll() is None:
process.kill()
@staticmethod
def _redact_progress(text: str, dotenv_vars: dict) -> str:
text = re.sub(
r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+',
lambda match: f"{match.group(1)}=[REDACTED]",
text,
)
for value in dotenv_vars.values():
value = str(value or "")
if len(value) >= 6:
text = text.replace(value, "[REDACTED]")
return text
def _get_safety_warning(self, command: str) -> str:
"""
Get safety warning for absolutely catastrophic commands only.
@@ -293,3 +413,43 @@ SAFETY:
return m.group(0)
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
@staticmethod
def _rewrite_long_python_c(command: str):
"""
Rewrite `python -c "<code>"` into `python <tempfile>` to bypass the
cmd.exe command line length limit on Windows.
Returns (new_command, temp_file_path). On any parse failure the original
command and None are returned, so behavior is unchanged when unmatched.
"""
# Match: <python|python3|py> [flags] -c "<code>" (single or double quoted)
m = re.search(
r'^(?P<prefix>.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)'
r'(?P<quote>["\'])(?P<code>.*)(?P=quote)\s*(?P<suffix>.*)$',
command,
re.DOTALL,
)
if not m:
return command, None
quote = m.group("quote")
code = m.group("code")
# Reverse common shell-level escaping of the quote char inside the code.
code = code.replace("\\" + quote, quote)
try:
fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(code)
except OSError:
return command, None
prefix = m.group("prefix")
# Drop the trailing "-c " from the prefix, keep the interpreter + flags.
interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip()
suffix = m.group("suffix").strip()
new_command = f'{interp} "{path}"'
if suffix:
new_command += f' {suffix}'
return new_command, path

View File

@@ -15,15 +15,24 @@ Launch modes (configured under `tools.browser` in config.json):
- fresh: Set `persistent` to false to fall back to a clean context every run.
"""
import ipaddress
import json
import os
import socket
from typing import Dict, Any, Optional
from urllib.parse import urlparse
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.browser.browser_service import BrowserService
from common.log import logger
# Cloud-metadata endpoints worth blocking even though they are not link-local.
# (169.254.169.254 — AWS/GCP/Azure IMDS — is already covered by is_link_local;
# fd00:ec2::254 is the AWS IPv6 IMDS address.)
_CLOUD_METADATA_IPS = frozenset({ipaddress.ip_address("fd00:ec2::254")})
class BrowserTool(BaseTool):
"""Single tool exposing all browser actions via an 'action' parameter."""
@@ -121,6 +130,61 @@ class BrowserTool(BaseTool):
BrowserTool._shared_service = self._service
return self._service
def _allow_private_targets(self) -> bool:
"""Whether the link-local / cloud-metadata guard is disabled.
Defaults to False (guard active). Loopback and RFC1918/LAN targets are
always reachable so local dev servers work out of the box; this opt-out
only lifts the remaining block on link-local / cloud-metadata targets,
for an operator who deliberately needs them, by setting
``allow_private_targets: true`` under ``tools.browser`` in config.json.
"""
return bool(self.config.get("allow_private_targets", False))
@staticmethod
def _validate_url_safe(url: str) -> None:
"""Reject URLs that target link-local / cloud-metadata addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that are
link-local (169.254.0.0/16 — which includes the 169.254.169.254
cloud-metadata endpoint — and IPv6 fe80::/10) or a known IPv6
cloud-metadata address. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Loopback and RFC1918/LAN targets are intentionally left reachable:
unlike the vision/web_fetch tools, the browser legitimately opens local
pages (a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP), so a
blanket "block all internal" policy would break that core workflow.
Raises:
ValueError: if the URL targets a disallowed address.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)
# Block only the high-risk targets — link-local (incl. the
# 169.254.169.254 cloud-metadata endpoint) and the IPv6 metadata
# address. Loopback and RFC1918/LAN stay reachable for local dev.
if ip.is_link_local or ip in _CLOUD_METADATA_IPS:
raise ValueError(
f"URL resolves to a link-local / cloud-metadata address "
f"({ip_str}), request blocked for security"
)
def execute(self, args: Dict[str, Any]) -> ToolResult:
action = args.get("action", "").strip().lower()
if not action:
@@ -148,6 +212,16 @@ class BrowserTool(BaseTool):
# 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
# SSRF guard: for http(s) targets, reject hosts that resolve to
# link-local / cloud-metadata addresses before the browser navigates
# (and then auto-snapshots the page back to the model). Loopback and
# RFC1918/LAN are allowed so local dev servers work. Non-HTTP schemes
# (about:/data:/file:/chrome:) are not network-egress targets here.
if url.split(":", 1)[0].lower() in ("http", "https") and not self._allow_private_targets():
try:
self._validate_url_safe(url)
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
timeout = args.get("timeout", 30000)
service = self._get_service()
result = service.navigate(url, timeout=timeout)

View File

@@ -0,0 +1,3 @@
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
__all__ = ["EvolutionUndoTool"]

View File

@@ -0,0 +1,58 @@
"""Evolution undo tool.
Lets the main chat agent roll back a previous self-evolution when the user asks
("undo the last learning"). The rollback itself is a deterministic FILE RESTORE
from the snapshot taken before the evolution — the model only supplies the
backup_id it reads from the [EVOLUTION] record in the conversation. No LLM-driven
re-editing is involved, so a restore can never make things worse.
"""
from agent.tools.base_tool import BaseTool, ToolResult
class EvolutionUndoTool(BaseTool):
"""Restore memory/skill files to the state before a self-evolution."""
name: str = "evolution_undo"
description: str = (
"Undo a previous self-evolution (self-learning) by restoring the "
"memory/skill files to their state before that learning. Use this when "
"the user asks to undo / revert / roll back the last self-learning. "
"Find the backup_id in the most recent [EVOLUTION] record in the "
"conversation and pass it here."
)
params: dict = {
"type": "object",
"properties": {
"backup_id": {
"type": "string",
"description": (
"The backup_id from the [EVOLUTION] record to restore "
"(e.g. '20260607-155551-850')."
),
}
},
"required": ["backup_id"],
}
def execute(self, args: dict):
backup_id = (args.get("backup_id") or "").strip()
if not backup_id:
return ToolResult.fail("Error: backup_id is required")
try:
from agent.memory.config import get_default_memory_config
from agent.evolution.backup import restore_backup
workspace_dir = get_default_memory_config().get_workspace()
ok = restore_backup(workspace_dir, backup_id)
if ok:
return ToolResult.success(
f"Restored memory/skills to the state before evolution "
f"{backup_id}. The previous self-learning has been undone."
)
return ToolResult.fail(
f"Could not find or restore backup {backup_id}. It may have "
f"expired or already been rolled back."
)
except Exception as e:
return ToolResult.fail(f"Error during undo: {e}")

View File

@@ -7,7 +7,7 @@ without any external MCP SDK dependency.
import json
import os
import select
import queue
import subprocess
import threading
import urllib.request
@@ -34,6 +34,8 @@ class McpClient:
self.config = config
self.name: str = config.get("name", "unknown")
raw_transport: str = config.get("type", "stdio")
# Per-server timeout for tool calls (default 120s, suitable for data queries)
self._timeout: int = int(config.get("timeout", 120))
# Normalize streamable-http aliases to a single internal key
self.transport: str = (
"streamable-http"
@@ -43,6 +45,7 @@ class McpClient:
# stdio state
self._proc: Optional[subprocess.Popen] = None
self._read_queue: queue.Queue = queue.Queue()
# SSE state
self._sse_url: Optional[str] = None
@@ -56,7 +59,13 @@ class McpClient:
# Shared state
self._next_id = 1
self._id_lock = threading.Lock()
# _call_lock serializes all requests on the single stdio pipe.
# SSE and streamable-http use independent HTTP requests, so they
# do not acquire this lock (see _send_request).
self._call_lock = threading.Lock()
# _http_lock protects _http_session_id initialization across
# concurrent streamable-http requests.
self._http_lock = threading.Lock()
self._initialized = False
# ------------------------------------------------------------------
@@ -172,6 +181,9 @@ class McpClient:
threading.Thread(
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
).start()
threading.Thread(
target=self._drain_stdout, daemon=True, name=f"mcp-stdout-{self.name}"
).start()
return self._handshake()
@@ -179,14 +191,35 @@ class McpClient:
for line in self._proc.stderr:
line = line.strip()
if line:
logger.debug(f"[MCP:{self.name}] stderr: {line}")
logger.warning(f"[MCP:{self.name}] stderr: {line}")
def _readline_with_timeout(self, timeout: int = 30) -> str:
"""Read one line from stdio stdout with a hard timeout."""
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
if not ready:
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {timeout}s")
return self._proc.stdout.readline()
def _drain_stdout(self):
"""Background thread: read lines from stdout and put them into the queue."""
try:
for line in self._proc.stdout:
self._read_queue.put(line)
except Exception:
pass
finally:
try:
self._read_queue.put("")
except Exception:
pass
def _readline_with_timeout(self, timeout: Optional[int] = None) -> str:
"""Read one line from stdio stdout with a hard timeout (cross-platform).
Uses the per-server timeout from mcp.json config when no explicit
timeout is provided.
"""
effective = timeout if timeout is not None else self._timeout
try:
line = self._read_queue.get(timeout=effective)
except queue.Empty:
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {effective}s")
if not line:
raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly")
return line
def _stdio_send(self, message: dict) -> dict:
"""Send a JSON-RPC message over stdio and read the response."""
@@ -194,6 +227,7 @@ class McpClient:
self._proc.stdin.write(raw)
self._proc.stdin.flush()
expected_id = message.get("id")
while True:
line = self._readline_with_timeout()
if not line:
@@ -208,6 +242,14 @@ class McpClient:
if "id" not in data:
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
continue
# Verify response id matches request id to avoid consuming a stale
# response left over from a previously failed/timed-out request.
if data.get("id") != expected_id:
logger.warning(
f"[MCP:{self.name}] Stale response id={data.get('id')} "
f"(expected {expected_id}), skipping"
)
continue
return data
# ------------------------------------------------------------------
@@ -302,8 +344,12 @@ class McpClient:
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._http_session_id:
headers["Mcp-Session-Id"] = self._http_session_id
# Read session id under lock to avoid racing with the
# initialization write below during concurrent requests.
with self._http_lock:
sid = self._http_session_id
if sid:
headers["Mcp-Session-Id"] = sid
headers.update(self._http_headers)
req = urllib.request.Request(
@@ -329,8 +375,13 @@ class McpClient:
with resp:
# Capture session id assigned by the server (if any)
session_id = resp.headers.get("Mcp-Session-Id")
# Double-checked lock: only the first response sets the
# session id, preventing concurrent initializers from
# overwriting each other.
if session_id and not self._http_session_id:
self._http_session_id = session_id
with self._http_lock:
if not self._http_session_id:
self._http_session_id = session_id
status = resp.status if hasattr(resp, "status") else resp.getcode()
@@ -409,15 +460,18 @@ class McpClient:
message = self._build_request(method, params)
with self._call_lock:
if self.transport == "stdio":
# stdio transport uses a single pipe and must be serialized.
# SSE and streamable-http use independent HTTP requests and
# can safely run concurrently across sessions.
if self.transport == "stdio":
with self._call_lock:
return self._stdio_send(message)
elif self.transport == "sse":
return self._sse_send(message)
elif self.transport == "streamable-http":
return self._streamable_http_send(message)
else:
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
elif self.transport == "sse":
return self._sse_send(message)
elif self.transport == "streamable-http":
return self._streamable_http_send(message)
else:
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
def _send_notification(self, method: str, params: dict):
"""Fire-and-forget notification (no response expected)."""

View File

@@ -0,0 +1,159 @@
# encoding:utf-8
"""
On-demand MCP tool retrieval.
Pure, stateless selection helpers used by the streaming executor to decide
which MCP tools to inject into a given LLM turn. Vector precompute + caching
live in ToolManager (the tool-lifecycle owner, a process-wide singleton);
only the context-aware selection lives here, because only the executor knows
the conversation context.
Invariants (per maintainer review of the feature proposal):
* Built-in tools are never handled here — the caller injects them in full.
* Any failure / missing input returns None so the caller falls back to
full injection; tools must never be silently dropped.
* Selection is union-accumulated across turns by the caller (only-grows),
so a tool that already produced a tool_use in the message history can
never disappear from the schema mid-run (which would make Claude/MiniMax
raise a message-format error).
"""
import math
from typing import Dict, List, Optional, Sequence, Set
try:
import numpy as np
_HAS_NUMPY = True
except ImportError:
_HAS_NUMPY = False
# How many trailing messages to concatenate into the retrieval query. Tool
# needs drift across a multi-turn tool-call loop, so a single (initial) user
# query is not enough; a short recent window captures the drift without
# bloating the query with stale context.
DEFAULT_QUERY_MESSAGES = 5
def build_retrieval_query(messages: list, max_messages: int = DEFAULT_QUERY_MESSAGES) -> str:
"""Concatenate the text of the most recent messages into a retrieval query.
Only ``text`` content blocks are kept; ``tool_use`` / ``tool_result`` blocks
are skipped so the query stays short and focused on natural-language intent
rather than large serialized tool payloads.
Args:
messages: Claude-style message list, each ``{"role", "content"}`` where
content is either a string or a list of typed blocks.
max_messages: Size of the trailing window to consider.
Returns:
A single string (possibly empty if no text is found).
"""
if not messages:
return ""
parts: List[str] = []
for message in messages[-max_messages:]:
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, str):
if content.strip():
parts.append(content.strip())
continue
if isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
text = block.get("text", "")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
return "\n".join(parts)
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
"""Cosine similarity of two equal-length vectors; 0.0 on degenerate input."""
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def select_mcp_tools(
query_vector: Optional[Sequence[float]],
tool_vectors: Dict[str, Sequence[float]],
top_k: int,
already_selected: Optional[Set[str]] = None,
) -> Optional[Set[str]]:
"""Return the accumulated set of MCP tool names to inject this turn.
Computes cosine similarity between ``query_vector`` and each candidate
tool vector, keeps the ``top_k`` best, and unions them with
``already_selected`` so the injected set only ever grows within a run.
Args:
query_vector: Embedding of the current retrieval query, or None.
tool_vectors: ``{mcp_tool_name: vector}`` for candidate MCP tools.
top_k: Max number of tools to add from this turn's ranking.
already_selected: Names accumulated in previous turns of this run.
Returns:
The union set of tool names to inject, or None to signal
"fall back to full injection" (no query vector, empty/invalid index,
or any unexpected error). This function never raises.
"""
accumulated: Set[str] = set(already_selected) if already_selected else set()
if not query_vector or not tool_vectors or top_k <= 0:
return None
try:
expected_dim = len(query_vector)
# Only rank candidates whose vector dimensionality matches the query.
# A dimension mismatch means the index was built with a different
# embedding model; ranking across dims is meaningless.
candidates = {
name: vec
for name, vec in tool_vectors.items()
if vec and len(vec) == expected_dim
}
if not candidates:
return None
ranked = _rank_by_similarity(query_vector, candidates)
for name, _score in ranked[:top_k]:
accumulated.add(name)
return accumulated
except Exception:
# Selection must never break the agent — fall back to full injection.
return None
def _rank_by_similarity(
query_vector: Sequence[float],
candidates: Dict[str, Sequence[float]],
) -> List[tuple]:
"""Return ``[(name, score), ...]`` sorted by descending cosine similarity.
Uses numpy when available (vectorized, matching the memory-search path),
with a pure-Python fallback so the feature works without numpy installed.
"""
names = list(candidates.keys())
if _HAS_NUMPY:
matrix = np.array([candidates[n] for n in names], dtype=np.float32) # (N, D)
q_vec = np.array(query_vector, dtype=np.float32) # (D,)
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
order = np.argsort(sims)[::-1]
return [(names[i], float(sims[i])) for i in order]
scored = [(n, cosine_similarity(query_vector, candidates[n])) for n in names]
scored.sort(key=lambda x: x[1], reverse=True)
return scored

View File

@@ -4,6 +4,8 @@ Memory get tool
Allows agents to read specific sections from memory files
"""
import os
from agent.tools.base_tool import BaseTool
@@ -87,8 +89,13 @@ class MemoryGetTool(BaseTool):
file_path = (workspace_dir / path).resolve()
workspace_resolved = workspace_dir.resolve()
if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved:
# Use os.path.realpath + os.sep for cross-platform path validation.
# str(Path).startswith(str + '/') fails on Windows where Path uses
# backslashes — see MemoryService._resolve_path for the same pattern.
real_file = os.path.realpath(str(file_path))
real_workspace = os.path.realpath(str(workspace_resolved))
if real_file != real_workspace and not real_file.startswith(real_workspace + os.sep):
return ToolResult.fail(f"Error: Access denied: path outside workspace")
if not file_path.exists():

View File

@@ -4,6 +4,7 @@ Supports text files, images (jpg, png, gif, webp), and PDF files
"""
import os
import re
from typing import Dict, Any
from pathlib import Path
@@ -12,11 +13,17 @@ from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_L
from common.utils import expand_path
# Paths whose CONTENT mirrors the process environment (and thus any secrets
# loaded from ~/.cow/.env). Reading them bypasses the env_config boundary.
# Matches /proc/self/environ, /proc/thread-self/environ and /proc/<pid>/environ.
_PROC_ENVIRON_RE = re.compile(r"^/proc/(\d+|self|thread-self)/environ$")
class Read(BaseTool):
"""Tool for reading file contents"""
name: str = "read"
description: str = f"Read or inspect file contents. For text/PDF files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
description: str = f"Read or inspect file contents. For text/PDF/Word/Excel/PPT files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
params: dict = {
"type": "object",
@@ -79,9 +86,9 @@ class Read(BaseTool):
# Resolve path
absolute_path = self._resolve_path(path)
# Security check: Prevent reading sensitive config files
env_config_path = expand_path("~/.cow/.env")
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
# Security check: block credential files and their aliases.
# See issue #2913 (/proc/self/environ bypass) and #2863 (scope).
if self._is_credential_path(absolute_path):
return ToolResult.fail(
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
)
@@ -140,7 +147,39 @@ class Read(BaseTool):
if os.path.isabs(path):
return path
return os.path.abspath(os.path.join(self.cwd, path))
def _is_credential_path(self, absolute_path: str) -> bool:
"""Return True if *absolute_path* points at protected credential data.
Beyond the literal ~/.cow/.env file, this also blocks two real bypass
surfaces reported in issue #2913:
1. /proc/<pid|self|thread-self>/environ — a second view of the
process environment that leaks secrets loaded from ~/.cow/.env.
2. Symlinks resolving to ~/.cow/.env; the previous exact abspath
match kept the link target and could be bypassed.
Scope is kept deliberately narrow (only the credential file and its
environ aliases) so this does NOT re-broaden the block that #2863
intentionally narrowed to ~/.cow/.env.
"""
# Compare on both the normalized path and the symlink-resolved path,
# in POSIX form so the /proc regex matches regardless of os.sep.
candidates = set()
try:
candidates.add(os.path.normpath(absolute_path).replace(os.sep, "/"))
candidates.add(os.path.realpath(absolute_path).replace(os.sep, "/"))
except OSError:
candidates.add(absolute_path.replace(os.sep, "/"))
# 1. /proc environ aliases (checked on raw and symlink-resolved forms).
for candidate in candidates:
if _PROC_ENVIRON_RE.match(candidate):
return True
# 2. The credential file itself, following symlinks on both sides.
env_real = os.path.realpath(expand_path("~/.cow/.env")).replace(os.sep, "/")
return env_real in candidates
def _return_file_metadata(self, absolute_path: str, file_type: str, file_size: int) -> ToolResult:
"""
Return file metadata for non-readable files (video, audio, binary, etc.)
@@ -258,8 +297,15 @@ class Read(BaseTool):
if offset is not None:
if offset < 0:
# Negative offset: read from end
# -20 means "last 20 lines" → start from (total - 20)
start_line = max(0, total_file_lines + offset)
# -20 means "last 20 lines" → start from (total - 20).
# A file ending in "\n" produces a trailing empty element
# from split('\n'); exclude it so offset=-1 returns the
# real last line instead of the empty string after the
# final newline (and -N returns N real lines).
effective_lines = total_file_lines
if all_lines and all_lines[-1] == '':
effective_lines -= 1
start_line = max(0, effective_lines + offset)
else:
# Positive offset: read from start (1-indexed)
start_line = max(0, offset - 1) # Convert to 0-indexed

View File

@@ -182,8 +182,15 @@ class TaskStore:
if enabled_only:
task_list = [t for t in task_list if t.get("enabled", True)]
# Sort by next_run_at
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
# Sort by enabled status (enabled first), then by next_run_at
def sort_key(t):
enabled = t.get("enabled", True)
next_run = t.get("next_run_at", "")
# Enabled tasks first (0), disabled tasks second (1)
# Then sort by next_run_at (empty string sorts last)
return (0 if enabled else 1, next_run if next_run else "9999-12-31")
task_list.sort(key=sort_key)
return task_list

View File

@@ -54,6 +54,11 @@ class Send(BaseTool):
if not path:
return ToolResult.fail("Error: path parameter is required")
# Pass through remote URLs directly (no local file check): the client
# renders the link inline, so no download is needed.
if path.lower().startswith(("http://", "https://")):
return self._build_url_result(path, message)
# Resolve path
absolute_path = self._resolve_path(path)
@@ -112,6 +117,46 @@ class Send(BaseTool):
return ToolResult.success(result)
def _build_url_result(self, url: str, message: str) -> ToolResult:
"""Build a file_to_send result for a remote http(s) URL.
The URL is passed through as both ``path`` and ``url`` so downstream
channels render it inline without downloading it locally.
"""
# Infer file type from the URL path extension (ignore query string).
from urllib.parse import urlparse
url_path = urlparse(url).path
file_ext = Path(url_path).suffix.lower()
file_name = Path(url_path).name or "file"
if file_ext in self.image_extensions:
file_type = "image"
mime_type = self._get_image_mime_type(file_ext)
elif file_ext in self.video_extensions:
file_type = "video"
mime_type = self._get_video_mime_type(file_ext)
elif file_ext in self.audio_extensions:
file_type = "audio"
mime_type = self._get_audio_mime_type(file_ext)
elif file_ext in self.document_extensions:
file_type = "document"
mime_type = self._get_document_mime_type(file_ext)
else:
# Default to image: most pass-through URLs are generated images.
file_type = "image"
mime_type = "image/jpeg"
result = {
"type": "file_to_send",
"file_type": file_type,
"path": url,
"url": url,
"file_name": file_name,
"mime_type": mime_type,
"message": message or f"正在发送 {file_name}",
}
return ToolResult.success(result)
def _resolve_path(self, path: str) -> str:
"""Resolve path to absolute path"""
path = expand_path(path)

View File

@@ -71,6 +71,22 @@ class ToolManager:
if not hasattr(self, '_mcp_active_configs'):
# server_name -> normalized config dict, for diff-based reload.
self._mcp_active_configs: dict = {}
if not hasattr(self, '_mcp_tool_vectors'):
# mcp_tool_name -> embedding vector, used by on-demand tool
# retrieval. Populated lazily on first retrieval so users who
# never enable the feature pay zero embedding cost.
self._mcp_tool_vectors: dict = {}
if not hasattr(self, '_mcp_vector_lock'):
# Guards incremental index builds so concurrent turns don't
# double-embed the same newly-loaded MCP tools.
self._mcp_vector_lock = threading.Lock()
if not hasattr(self, '_embedding_provider_initialized'):
# The embedding provider is created once, lazily, and reused for
# both tool-index and per-query embeddings. None means keyword-only
# mode (no provider configured) — retrieval then falls back to full
# injection at the caller.
self._embedding_provider_initialized = False
self._embedding_provider = None
def load_tools(self, tools_dir: str = "", config_dict=None):
"""
@@ -523,6 +539,16 @@ class ToolManager:
if agent is None or not hasattr(agent, "tools"):
return ([], [])
# Never re-inject MCP tools into a restricted Self-Evolution review agent.
# The review agent is created with a deliberately reduced, workspace-guarded
# toolset; silently re-adding configured MCP tools here would bypass that
# policy boundary (see agent/evolution/executor.py). The flag may live on
# the agent itself (Agent) or on the wrapping stream executor's .agent.
if getattr(agent, "_evolution_restricted", False) or getattr(
getattr(agent, "agent", None), "_evolution_restricted", False
):
return ([], [])
from agent.tools.mcp.mcp_tool import McpTool
current = self._mcp_tool_instances
registry_names = set(current.keys())
@@ -564,6 +590,91 @@ class ToolManager:
return (sorted(added), sorted(removed))
# ------------------------------------------------------------------
# On-demand MCP tool retrieval support
#
# The vector index and the embedding provider are owned here (singleton,
# process-wide, aligned with the MCP tool lifecycle). The context-aware
# selection itself lives in agent.tools.mcp.tool_retrieval, driven by the
# executor which is the only place that knows the conversation context.
# ------------------------------------------------------------------
def count_mcp_tools(self) -> int:
"""Return the number of currently loaded MCP tools."""
return len(self._mcp_tool_instances)
def get_mcp_tool_vectors(self) -> dict:
"""Return ``{mcp_tool_name: vector}`` for currently loaded MCP tools.
Lazily embeds any MCP tools not yet in the cache (MCP servers load
asynchronously, so tools may appear over time). Returns an empty dict
when no embedding provider is available or embedding fails — the caller
then falls back to full injection. Never raises.
"""
try:
self._ensure_mcp_tool_vectors()
except Exception as e:
logger.debug(f"[ToolManager] MCP tool vector build skipped: {e}")
return dict(self._mcp_tool_vectors)
def embed_query(self, text: str):
"""Embed a retrieval query with the shared provider.
Returns the embedding vector, or None if no provider is available or
the call fails (caller falls back to full injection). Never raises.
"""
if not text:
return None
provider = self._get_embedding_provider()
if provider is None:
return None
try:
return provider.embed_query(text)
except Exception as e:
logger.debug(f"[ToolManager] query embedding failed: {e}")
return None
def _ensure_mcp_tool_vectors(self) -> None:
"""Incrementally embed MCP tools that are not yet cached."""
# Snapshot to avoid concurrent-mutation while the async loader runs.
current = dict(self._mcp_tool_instances)
missing = [name for name in current if name not in self._mcp_tool_vectors]
if not missing:
return
provider = self._get_embedding_provider()
if provider is None:
return
with self._mcp_vector_lock:
# Re-check under lock: another thread may have filled these in.
missing = [name for name in current if name not in self._mcp_tool_vectors]
if not missing:
return
texts = [self._mcp_tool_embed_text(current[name]) for name in missing]
vectors = provider.embed_batch(texts)
for name, vec in zip(missing, vectors):
self._mcp_tool_vectors[name] = vec
@staticmethod
def _mcp_tool_embed_text(tool) -> str:
"""Build the text that represents an MCP tool for embedding."""
name = getattr(tool, "name", "") or ""
description = getattr(tool, "description", "") or ""
return f"{name}: {description}".strip()
def _get_embedding_provider(self):
"""Lazily create and cache the shared embedding provider (or None)."""
if not self._embedding_provider_initialized:
try:
from agent.memory.embedding import create_default_embedding_provider
self._embedding_provider = create_default_embedding_provider()
except Exception as e:
logger.warning(f"[ToolManager] embedding provider init failed: {e}")
self._embedding_provider = None
self._embedding_provider_initialized = True
return self._embedding_provider
def create_tool(self, name: str) -> BaseTool:
"""
Get a new instance of a tool by name.

View File

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

View File

@@ -111,20 +111,46 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
content_for_replacement=content
)
# Try fuzzy match
fuzzy_content = normalize_for_fuzzy_match(content)
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
index = fuzzy_content.find(fuzzy_old_text)
if index != -1:
# Fuzzy match successful, use normalized content for replacement
return FuzzyMatchResult(
found=True,
index=index,
match_length=len(fuzzy_old_text),
content_for_replacement=fuzzy_content
)
# Fuzzy match: the exact substring was not found, most likely because the
# whitespace differs (indentation, spaces around operators, trailing
# spaces). Locate the region in the ORIGINAL content using a
# whitespace-flexible pattern and return offsets into that original
# content.
#
# This must NOT replace inside a whitespace-normalized copy of the file:
# doing so previously returned the normalized copy as
# content_for_replacement, which caused the whole file to be rewritten
# with collapsed indentation (every untouched line got reformatted).
stripped = old_text.strip('\n')
if stripped.strip():
source_lines = stripped.split('\n')
line_patterns = []
for i, line in enumerate(source_lines):
tokens = line.split()
if not tokens:
line_patterns.append(r'[ \t]*')
continue
# Tolerate any run of blanks between tokens.
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
# First-line leading whitespace is folded into the match only when
# old_text itself was indented here; otherwise it stays OUTSIDE the
# match so a no-indent old_text preserves (does not swallow and drop)
# the file's existing indentation -- mirroring an exact substring
# match. Inner lines always tolerate indentation: it sits inside the
# matched region and is re-supplied by new_text.
if i > 0 or line[:1] in (' ', '\t'):
core = r'[ \t]*' + core
line_patterns.append(core + r'[ \t]*')
pattern = '\n'.join(line_patterns)
match = re.search(pattern, content)
if match:
return FuzzyMatchResult(
found=True,
index=match.start(),
match_length=match.end() - match.start(),
content_for_replacement=content
)
# Not found
return FuzzyMatchResult(found=False)

View File

@@ -0,0 +1,96 @@
"""
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
SSRF protection is OPT-IN and disabled by default, because legitimate use
cases (local dev servers, LAN services, proxy fake-ip resolution) need to
reach non-public addresses. Enable it by setting the config option
``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
When enabled, a URL is only considered safe when it uses an http/https
scheme, has a hostname, that hostname resolves, and every resolved address
is a public (internet-routable) address. Loopback, private (RFC1918 / ULA),
link-local (incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise
reserved addresses are rejected, for both IPv4 and IPv6.
"""
import ipaddress
import os
import socket
from urllib.parse import urlparse
def _ssrf_protection_enabled() -> bool:
"""Return True only when SSRF protection is explicitly turned on.
Disabled by default. Reads the env var first, then falls back to the
global config; any failure to read config is treated as "disabled" so
the guard never breaks normal fetching.
"""
env = os.getenv("WEB_SECURITY_SSRF_PROTECTION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
try:
from config import conf
return bool(conf().get("web_security_ssrf_protection", False))
except Exception:
return False
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
"""Return True if the address is not safe to connect to (non-public)."""
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
)
def assert_public_ip(ip_str: str) -> None:
"""Raise ValueError if the given literal IP is a non-public address.
No-op when SSRF protection is disabled (the default). Used to re-validate
the concrete address a redirect resolved to.
"""
if not _ssrf_protection_enabled():
return
ip = ipaddress.ip_address(ip_str)
if _is_blocked_ip(ip):
raise ValueError(
f"URL resolves to a non-public address ({ip_str}), "
f"request blocked for security"
)
def validate_url_safe(url: str) -> None:
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
No-op when SSRF protection is disabled (the default). When enabled,
resolves the hostname to its IP address(es) and blocks any that fall
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Raises:
ValueError: if the URL targets a disallowed address.
"""
if not _ssrf_protection_enabled():
return
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
assert_public_ip(sockaddr[0])

View File

@@ -26,13 +26,14 @@ from typing import Any, Dict, List, Optional
import requests
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.utils.url_safety import validate_url_safe
from common import const
from common.log import logger
from config import conf
DEFAULT_MODEL = const.GPT_41_MINI
DEFAULT_TIMEOUT = 60
MAX_TOKENS = 1000
DEFAULT_TIMEOUT = 180
MAX_TOKENS = 4000
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
SUPPORTED_EXTENSIONS = {
@@ -51,8 +52,8 @@ _MAIN_MODEL_PROVIDER_NAME = "MainModel"
_DISCOVERABLE_MODELS = [
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN36_PLUS, "DashScope"),
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_SONNET_5, "Claude"),
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
@@ -161,7 +162,7 @@ class Vision(BaseTool):
"Error: No model available for Vision.\n"
"The main model does not support vision and no other API keys are configured.\n"
"Options:\n"
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.6-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
" 1. Switch to a multimodal model (e.g. claude-sonnet-5, qwen3.7-plus, gemini-2.0-flash, ernie-4.5-turbo-vl)\n"
" 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
)
@@ -330,6 +331,12 @@ class Vision(BaseTool):
- None : unknown provider id, or the bot can't be created.
Caller falls through to model-name-based routing.
"""
# Custom OpenAI-compatible providers — read credentials from
# custom_providers list, same pattern as embedding.
if provider_id.startswith("custom:"):
p = self._build_custom_provider(provider_id, user_model)
return [p] if p else None
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
if not display_name:
return None
@@ -595,6 +602,34 @@ class Vision(BaseTool):
model_override=preferred_model,
)
def _build_custom_provider(self, provider_id: str, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
"""Build a VisionProvider from a custom:<id> entry in custom_providers.
Uses the standard OpenAI /chat/completions endpoint — any
OpenAI-compatible multimodal endpoint works."""
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_id)
if not custom_id:
return None
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if not entry:
logger.warning(f"[Vision] custom provider '{provider_id}' not found in custom_providers")
return None
api_key = (entry.get("api_key") or "").strip()
api_base = (entry.get("api_base") or "").strip()
if not api_key or not api_base:
logger.warning(f"[Vision] custom provider '{provider_id}' missing api_key or api_base")
return None
model = preferred_model or entry.get("model") or ""
if not model:
logger.warning(f"[Vision] custom provider '{provider_id}' has no model configured")
return None
return VisionProvider(
name=entry.get("name") or provider_id,
api_key=api_key,
api_base=self._ensure_v1(api_base.rstrip("/")),
model_override=model,
)
def _call_via_bot(self, model: str, question: str, image_content: dict,
provider: Optional[VisionProvider] = None) -> ToolResult:
"""
@@ -654,6 +689,22 @@ class Vision(BaseTool):
return api_base
return api_base.rstrip("/") + "/v1"
@staticmethod
def _validate_url_safe(url: str) -> None:
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that fall
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Delegates to the shared ``agent.tools.utils.url_safety`` helper so the
same guard protects every tool that fetches model-supplied URLs.
Raises:
ValueError: if the URL targets a disallowed address.
"""
validate_url_safe(url)
def _build_image_content(self, image: str) -> dict:
"""
Build the image_url content block.
@@ -661,6 +712,7 @@ class Vision(BaseTool):
so every bot backend can consume them without extra downloads.
"""
if image.startswith(("http://", "https://")):
self._validate_url_safe(image)
return self._download_to_data_url(image)
if not os.path.isfile(image):

View File

@@ -16,11 +16,15 @@ import requests
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.utils.truncate import truncate_head, format_size
from agent.tools.utils.url_safety import validate_url_safe
from common.log import logger
DEFAULT_TIMEOUT = 30
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
# Cap on how many redirects we follow; each hop's target is re-validated
# against the SSRF guard so a public URL cannot bounce us into an internal one.
MAX_REDIRECTS = 10
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
@@ -107,23 +111,65 @@ class WebFetch(BaseTool):
if parsed.scheme not in ("http", "https"):
return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)")
# SSRF guard: reject URLs that resolve to private/loopback/link-local/
# cloud-metadata addresses before any request is issued.
try:
validate_url_safe(url)
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
if _is_document_url(url):
return self._fetch_document(url)
return self._fetch_webpage(url)
# ---- Safe request helper ----
@staticmethod
def _safe_get(url: str, **kwargs) -> requests.Response:
"""Issue a GET request while re-validating every redirect hop (SSRF guard).
Auto-redirect is disabled and each hop is followed manually so the
target of every redirect is re-resolved and checked against the SSRF
guard. This prevents a public URL from 3xx-bouncing into a private,
loopback, link-local or cloud-metadata address. ``kwargs`` are passed
through to ``requests.get`` (e.g. ``stream``).
Raises:
ValueError: if any hop resolves to a non-public address.
"""
kwargs.pop("allow_redirects", None)
current = url
for _ in range(MAX_REDIRECTS + 1):
response = requests.get(
current,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
allow_redirects=False,
**kwargs,
)
if not response.is_redirect and not response.is_permanent_redirect:
return response
location = response.headers.get("Location")
if not location:
return response
# Resolve the redirect target relative to the current URL, then
# re-validate it before following.
current = requests.compat.urljoin(current, location)
validate_url_safe(current)
response.close()
raise ValueError(f"Too many redirects (>{MAX_REDIRECTS})")
# ---- Web page fetching ----
def _fetch_webpage(self, url: str) -> ToolResult:
"""Fetch and extract readable text from an HTML web page."""
parsed = urlparse(url)
try:
response = requests.get(
url,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
allow_redirects=True,
)
response = self._safe_get(url)
response.raise_for_status()
except requests.Timeout:
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
@@ -131,6 +177,8 @@ class WebFetch(BaseTool):
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
except requests.HTTPError as e:
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
except Exception as e:
return ToolResult.fail(f"Error: Failed to fetch URL: {e}")
@@ -158,13 +206,7 @@ class WebFetch(BaseTool):
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
try:
response = requests.get(
url,
headers=DEFAULT_HEADERS,
timeout=DEFAULT_TIMEOUT,
stream=True,
allow_redirects=True,
)
response = self._safe_get(url, stream=True)
response.raise_for_status()
content_length = int(response.headers.get("Content-Length", 0))
@@ -191,6 +233,9 @@ class WebFetch(BaseTool):
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
except requests.HTTPError as e:
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
except ValueError as e:
self._cleanup_file(local_path)
return ToolResult.fail(f"Error: {e}")
except Exception as e:
self._cleanup_file(local_path)
return ToolResult.fail(f"Error: Failed to download file: {e}")

32
app.py
View File

@@ -15,6 +15,11 @@ import threading
_channel_mgr = None
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
# loaded in a background thread (so command plugins like cow_cli/godcmd work
# without slowing startup), while MCP warmup is still skipped to keep it fast.
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
def get_channel_manager():
return _channel_mgr
@@ -76,7 +81,15 @@ class ChannelManager:
self._primary_channel = channels[0][1]
if first_start:
PluginManager().load_plugins()
if DESKTOP_MODE:
# Load plugins in the background so command plugins
# (cow_cli / godcmd, e.g. /status, #help) work in the
# desktop client, without blocking web-service readiness.
threading.Thread(
target=PluginManager().load_plugins, daemon=True
).start()
else:
PluginManager().load_plugins()
# Cloud client is optional. It is only started when
# use_linkai=True AND cloud_deployment_id is set.
@@ -236,6 +249,9 @@ def _clear_singleton_cache(channel_name: str):
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
const.QQ: "channel.qq.qq_channel.QQChannel",
const.TELEGRAM: "channel.telegram.telegram_channel.TelegramChannel",
const.SLACK: "channel.slack.slack_channel.SlackChannel",
const.DISCORD: "channel.discord.discord_channel.DiscordChannel",
const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
"wx": "channel.weixin.weixin_channel.WeixinChannel",
}
@@ -361,10 +377,18 @@ def run():
_sync_builtin_skills()
# Kick off MCP server loading in the background so first-message
# latency isn't dominated by npx package downloads.
_warmup_mcp_tools()
# latency isn't dominated by npx package downloads. Skipped in desktop
# mode (MCP relies on external npx/uvx runtimes that aren't bundled).
if not DESKTOP_MODE:
_warmup_mcp_tools()
_warmup_scheduler()
if DESKTOP_MODE:
# Defer the (heavy) AgentBridge/scheduler warmup to a background
# thread so the web API becomes available within a couple seconds.
# The scheduler still starts; it just doesn't block UI readiness.
threading.Thread(target=_warmup_scheduler, daemon=True).start()
else:
_warmup_scheduler()
logger.info(f"[App] Starting channels: {channel_names}")

View File

@@ -78,6 +78,7 @@ class AgentLLMModel(LLMModel):
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
("ernie", const.QIANFAN),
("mimo-", const.MIMO),
]
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
@@ -294,6 +295,14 @@ class AgentBridge:
self.scheduler_initialized = True
except Exception as e:
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
# Start the self-evolution idle trigger (idempotent, daemon thread).
try:
from agent.evolution.trigger import start_evolution_trigger
start_evolution_trigger(self)
except Exception as e:
logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}")
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
"""
Create the super agent with COW integration
@@ -382,7 +391,49 @@ class AgentBridge:
"""Initialize agent for a specific session"""
agent = self.initializer.initialize_agent(session_id=session_id)
self.agents[session_id] = agent
def sync_session_messages_from_store(self, session_id: str) -> int:
"""Reload an agent's in-memory ``messages`` list from the persistent
conversation store.
Used after an external mutation (e.g. user edits / deletes a message
via the web console) so the agent's next turn sees the same history
as the database. The operation is a no-op when the agent has not been
instantiated yet for the session.
Returns:
Number of messages now held in the agent's memory. Returns -1 if
the agent does not exist or has no compatible ``messages`` attr.
"""
if not session_id or session_id not in self.agents:
return -1
agent = self.agents[session_id]
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
return -1
try:
from agent.memory import get_conversation_store
store = get_conversation_store()
# No turn cap here: we want a faithful mirror of what the store
# has for this session after deletion.
remaining = store.load_messages(session_id, max_turns=10**6)
except Exception as e:
logger.warning(
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
)
return -1
with agent.messages_lock:
agent.messages.clear()
for msg in remaining:
agent.messages.append({
"role": msg["role"],
"content": msg["content"],
})
count = len(agent.messages)
logger.info(
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
)
return count
def agent_reply(self, query: str, context: Context = None,
on_event=None, clear_history: bool = False) -> Reply:
"""
@@ -464,6 +515,24 @@ class AgentBridge:
)
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
# Eagerly persist the user message BEFORE running the agent so the
# session and the user's bubble are immediately visible — even if
# the user switches away or refreshes before the reply finishes.
# The reply (assistant/tool messages) is appended once the run
# completes; the final persist skips this already-stored user turn.
pre_persisted = self._pre_persist_user_message(
session_id, query, context, clear_history
)
# Mark this session as mid-run so the self-evolution idle scan does
# not fire concurrently when a single turn runs longer than
# idle_minutes.
try:
from agent.evolution.trigger import mark_run_active
mark_run_active(agent, True)
except Exception:
pass
try:
# Use agent's run_stream method with event handler
response = agent.run_stream(
@@ -473,6 +542,13 @@ class AgentBridge:
cancel_event=cancel_event,
)
finally:
# Clear the mid-run flag so idle scans can review this session.
try:
from agent.evolution.trigger import mark_run_active
mark_run_active(agent, False)
except Exception:
pass
# Restore original tools
if context and context.get("is_scheduled_task"):
agent.tools = original_tools
@@ -490,7 +566,11 @@ class AgentBridge:
# Persist new messages generated during this run
if session_id:
channel_type = (context.get("channel_type") or "") if context else ""
new_messages = getattr(agent, '_last_run_new_messages', [])
new_messages = list(getattr(agent, '_last_run_new_messages', []))
# The leading user turn was already persisted eagerly above;
# drop it here so it isn't stored twice.
if pre_persisted and new_messages and new_messages[0].get("role") == "user":
new_messages = new_messages[1:]
if new_messages:
self._persist_messages(session_id, list(new_messages), channel_type)
else:
@@ -504,6 +584,23 @@ class AgentBridge:
except Exception as e:
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}")
# Record this user turn for the self-evolution idle trigger. Skip
# scheduler-injected / scheduled-task sessions so internal runs do
# not count as user activity.
if session_id and not session_id.startswith("scheduler_") and not (
context and context.get("is_scheduled_task")
):
try:
from agent.evolution.trigger import note_user_turn
ch = (context.get("channel_type") or "") if context else ""
rcv = (context.get("receiver") or "") if context else ""
is_group = bool(context.get("isgroup")) if context else False
# Only enable proactive push for single chats (group push is
# noisy); group sessions still evolve, just without notify.
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
except Exception:
pass
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
# sync any new/removed MCP tools into the live agent in the
# background. Off the critical path so user latency is unaffected;
@@ -587,11 +684,21 @@ class AgentBridge:
"""
file_type = file_info.get("file_type", "file")
file_path = file_info.get("path")
# Remote URLs are passed through as-is; local paths get a file:// prefix
# so the channel can read them from disk.
remote_url = file_info.get("url", "")
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
def _to_channel_url(p: str) -> str:
if is_remote:
return remote_url
if p and p.lower().startswith(("http://", "https://")):
return p
return f"file://{p}"
# For images, use IMAGE_URL type (channel will handle upload)
if file_type == "image":
# Convert local path to file:// URL for channel processing
file_url = f"file://{file_path}"
file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending image: {file_url}")
reply = Reply(ReplyType.IMAGE_URL, file_url)
# Attach text message if present (for channels that support text+image)
@@ -601,7 +708,7 @@ class AgentBridge:
# For all file types (document, video, audio), use FILE type
if file_type in ["document", "video", "audio"]:
file_url = f"file://{file_path}"
file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
@@ -611,7 +718,7 @@ class AgentBridge:
return reply
# For all other file types (tar.gz, zip, etc.), also use FILE type
file_url = f"file://{file_path}"
file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
@@ -689,6 +796,48 @@ class AgentBridge:
except Exception as e:
logger.warning(f"[AgentBridge] Failed to sync API keys: {e}")
def _pre_persist_user_message(
self, session_id: str, query: str, context: Context, clear_history: bool
) -> bool:
"""Persist the user's message before the agent runs.
This makes a brand-new session (and the user's bubble) visible even if
the reply hasn't finished — switching away or refreshing no longer
loses the in-flight session. Returns True when the user turn was
stored, so the caller can skip it in the post-run persist.
Best-effort: any failure is swallowed and reported as not-persisted.
"""
if not session_id or not query:
return False
# Only real user turns: skip scheduler-injected / scheduled-task runs.
if session_id.startswith("scheduler_") or (
context and context.get("is_scheduled_task")
):
return False
try:
from config import conf
if not conf().get("conversation_persistence", True):
return False
from agent.memory import get_conversation_store
store = get_conversation_store()
# clear_history starts a fresh transcript: wipe the store first so
# the eager user turn becomes seq 0, matching in-memory state.
if clear_history:
store.clear_session(session_id)
channel_type = (context.get("channel_type") or "") if context else ""
user_msg = {
"role": "user",
"content": [{"type": "text", "text": query}],
}
store.append_messages(session_id, [user_msg], channel_type=channel_type)
return True
except Exception as e:
logger.warning(
f"[AgentBridge] Failed to pre-persist user message for session={session_id}: {e}"
)
return False
def _persist_messages(
self, session_id: str, new_messages: list, channel_type: str = ""
) -> None:

View File

@@ -17,10 +17,6 @@ from common.utils import expand_path
# Module-level lock to serialize scheduler init across concurrent sessions
_scheduler_init_lock = threading.Lock()
# Track whether the embedding model log has been printed in this process,
# so we avoid spamming it once per session.
_embedding_logged: bool = False
class AgentInitializer:
"""
@@ -306,186 +302,16 @@ class AgentInitializer:
"""
Initialize the embedding provider for memory.
Two paths:
Delegates to the shared factory so agent init, knowledge sync and
index rebuild all select the same provider:
A. Default (no `embedding_provider` in config.json):
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
keep working.
Auto-init OpenAI -> LinkAI fallback.
B. Explicit (`embedding_provider` is set):
Initialize the requested vendor with unified dim (default 1024).
If the index was built with a different dim, vector search will
quietly return no results (cosine returns 0) and keyword search
takes over until the user runs /memory rebuild-index.
Initialize the requested vendor.
"""
from agent.memory import create_embedding_provider
from config import conf
from agent.memory import create_default_embedding_provider
return create_default_embedding_provider()
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
if not explicit_provider:
return self._init_embedding_provider_legacy(session_id=session_id)
return self._init_embedding_provider_explicit(
memory_config, explicit_provider, session_id=session_id,
)
def _init_embedding_provider_legacy(self, session_id: Optional[str] = None):
"""Legacy auto-init path: OpenAI -> LinkAI. Preserved verbatim for compat."""
from agent.memory import create_embedding_provider
from config import conf
embedding_provider = None
embedding_model = None
openai_api_key = conf().get("open_ai_api_key", "")
openai_api_base = conf().get("open_ai_api_base", "")
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="openai",
model=model,
api_key=openai_api_key,
api_base=openai_api_base or "https://api.openai.com/v1"
)
embedding_model = f"openai/{model}"
except Exception as e:
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
if embedding_provider is None:
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="linkai",
model=model,
api_key=linkai_api_key,
api_base=f"{linkai_api_base}/v1"
)
embedding_model = f"linkai/{model}"
except Exception as e:
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
if embedding_provider is not None and embedding_model:
global _embedding_logged
if not _embedding_logged:
logger.info(
f"[AgentInitializer] Embedding model in use: {embedding_model} "
f"(dim={embedding_provider.dimensions})"
)
_embedding_logged = True
return embedding_provider
def _init_embedding_provider_explicit(
self,
memory_config,
provider_key: str,
session_id: Optional[str] = None,
):
"""Explicit-provider path: build the configured vendor.
If the index was built with a different dim, vector search will
silently return no results (cosine returns 0 for mismatched dims)
and keyword search takes over. Users switch vendors by running
/memory rebuild-index — see docs.
"""
from agent.memory import create_embedding_provider
from agent.memory.embedding import EMBEDDING_VENDORS
from config import conf
meta = EMBEDDING_VENDORS.get(provider_key)
if meta is None:
logger.error(
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
f"Memory will run in keyword-only mode."
)
return None
api_key = self._resolve_embedding_api_key(provider_key)
api_base = self._resolve_embedding_api_base(provider_key, meta["default_base_url"])
if not api_key:
logger.error(
f"[AgentInitializer] embedding_provider='{provider_key}' is set but its "
f"API key is missing. Memory will run in keyword-only mode."
)
return None
model = (conf().get("embedding_model") or "").strip() or meta["default_model"]
try:
cfg_dim = int(conf().get("embedding_dimensions") or 0)
except (TypeError, ValueError):
cfg_dim = 0
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
try:
provider = create_embedding_provider(
provider=provider_key,
model=model,
api_key=api_key,
api_base=api_base,
dimensions=dim,
)
except Exception as e:
logger.error(
f"[AgentInitializer] Failed to init embedding provider "
f"'{provider_key}/{model}': {e}"
)
return None
global _embedding_logged
if not _embedding_logged:
logger.info(
f"[AgentInitializer] Embedding model in use: "
f"{provider_key}/{model} (dim={provider.dimensions})"
)
_embedding_logged = True
return provider
@staticmethod
def _resolve_embedding_api_key(provider_key: str) -> str:
"""Pick the API key for an explicit embedding provider from config."""
from config import conf
key_map = {
"openai": "open_ai_api_key",
"linkai": "linkai_api_key",
"dashscope": "dashscope_api_key",
"doubao": "ark_api_key",
"zhipu": "zhipu_ai_api_key",
}
field = key_map.get(provider_key)
if not field:
return ""
value = conf().get(field, "") or ""
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
return ""
return value
@staticmethod
def _resolve_embedding_api_base(provider_key: str, default_base: str) -> str:
"""Pick the API base for an explicit embedding provider from config."""
from config import conf
base_map = {
"openai": "open_ai_api_base",
"linkai": "linkai_api_base",
"doubao": "ark_base_url",
"zhipu": "zhipu_ai_api_base",
}
field = base_map.get(provider_key)
if not field:
return default_base
value = (conf().get(field) or "").strip()
if not value:
return default_base
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
return f"{value.rstrip('/')}/v1"
return value
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
"""Sync memory database"""
try:
@@ -524,6 +350,14 @@ class AgentInitializer:
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
continue
# Skip evolution_undo when self-evolution is disabled: with no
# evolution there is nothing to roll back, so the tool is dead weight.
if tool_name == "evolution_undo":
from agent.evolution.config import get_evolution_config
if not get_evolution_config().enabled:
logger.debug("[AgentInitializer] evolution_undo skipped - self-evolution disabled")
continue
# Special handling for EnvConfig tool
if tool_name == "env_config":
from agent.tools import EnvConfig

View File

@@ -519,7 +519,10 @@ class ChatChannel(Channel):
def cancel_session(self, session_id):
with self.lock:
if session_id in self.sessions:
for future in self.futures[session_id]:
# futures[session_id] is only created in consume() when a task is
# dispatched, so it may be absent if cancel happens right after
# produce() but before the first dispatch. Default to [].
for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
def cancel_all_session(self):
with self.lock:
for session_id in self.sessions:
for future in self.futures[session_id]:
for future in self.futures.get(session_id, []):
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:

View File

@@ -47,12 +47,15 @@
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',
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'zh-Hant' / '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();
var v = String(raw).trim().toLowerCase().replace('_', '-');
if (v === 'auto') return '';
// Handle Traditional Chinese variants first (more specific)
if (v === 'zh-hant' || v.indexOf('zh-hant-') === 0 || v === 'zh-tw' || v === 'zh-hk') return 'zh-Hant';
// Then Simplified Chinese
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
@@ -267,14 +270,29 @@
<div class="flex-1"></div>
<!-- Language Toggle -->
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
cursor-pointer transition-colors duration-150"
onclick="toggleLanguage()">
<i class="fas fa-globe text-xs"></i>
<span id="lang-label">EN</span>
</button>
<!-- Language Selector (dropdown) -->
<div id="lang-selector" class="relative">
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
cursor-pointer transition-colors duration-150"
onclick="toggleLangMenu(event)">
<i class="fas fa-globe text-xs"></i>
<span id="lang-label">EN</span>
<i class="fas fa-chevron-down text-[10px] opacity-60"></i>
</button>
<div id="lang-menu" class="hidden absolute right-0 mt-1 min-w-[120px] py-1 rounded-lg z-50
bg-white dark:bg-slate-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10">
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh"
onclick="selectLanguage('zh')">简体中文</button>
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh-Hant"
onclick="selectLanguage('zh-Hant')">繁體中文</button>
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="en"
onclick="selectLanguage('en')">English</button>
</div>
</div>
<!-- Theme Toggle -->
<button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
@@ -285,7 +303,7 @@
</button>
<!-- Docs Link -->
<a href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
<a id="docs-link" href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
cursor-pointer transition-colors duration-150" title="Documentation">
<i class="fas fa-book text-base"></i>
@@ -304,6 +322,14 @@
cursor-pointer transition-colors duration-150" title="GitHub">
<i class="fab fa-github text-lg"></i>
</a>
<!-- Logout Button (hidden by default) -->
<button id="logout-btn-header" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 dark:hover:text-red-400
cursor-pointer transition-colors duration-150 hidden"
onclick="handleLogout()" title="Logout" data-i18n-title="logout">
<i class="fas fa-sign-out-alt text-base"></i>
</button>
</header>
<!-- Content Area -->
@@ -620,6 +646,18 @@
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">
<span data-i18n="config_self_evolution">Self-Evolution</span>
<span class="cfg-tip" data-tip-key="config_self_evolution_hint"><i class="fas fa-circle-question"></i></span>
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input id="cfg-self-evolution" type="checkbox" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-checked:bg-primary-400 rounded-full
after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
</label>
</div>
<div class="flex items-center justify-end gap-3 pt-1">
<span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button id="cfg-agent-save"
@@ -760,7 +798,7 @@
</button>
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
class="memory-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
<i class="fas fa-moon mr-1.5"></i><span data-i18n="memory_tab_dreams">梦境日记</span>
<i class="fas fa-seedling mr-1.5"></i><span data-i18n="memory_tab_dreams">自主进化</span>
</button>
</div>
</div>
@@ -817,17 +855,18 @@
<!-- VIEW: Knowledge -->
<!-- ====================================================== -->
<div id="view-knowledge" class="view">
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10">
<div class="w-full max-w-[1600px] mx-auto">
<div class="flex-1 min-h-0 overflow-y-auto md:overflow-hidden p-4 md:p-8 lg:p-10 md:flex md:flex-col">
<div class="w-full max-w-[1600px] mx-auto md:flex-1 md:min-h-0 md:flex md:flex-col">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6 md:flex-shrink-0">
<div>
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">浏览和探索你的知识库</p>
</div>
<div class="flex items-center gap-2">
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
@@ -838,6 +877,28 @@
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
</button>
</div>
<div id="knowledge-new-menu" class="relative">
<button onclick="toggleKnowledgeNewMenu(event)"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-plus"></i><span data-i18n="knowledge_new">新建</span><i class="fas fa-chevron-down text-[9px] ml-0.5"></i>
</button>
<div id="knowledge-new-menu-list"
class="hidden absolute right-0 mt-1.5 w-44 z-50 bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-lg shadow-lg py-1">
<button onclick="createKnowledgeCategory(); closeKnowledgeNewMenu()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<i class="fas fa-folder-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_category">新建分类</span>
</button>
<button onclick="createKnowledgeDocument(); closeKnowledgeNewMenu()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<i class="fas fa-file-circle-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_document">新建文档</span>
</button>
<button onclick="selectKnowledgeImportFiles(); closeKnowledgeNewMenu()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<i class="fas fa-file-arrow-up w-3.5 text-slate-400"></i><span data-i18n="knowledge_import_documents">导入文档</span>
</button>
</div>
</div>
<input id="knowledge-import-input" type="file" class="hidden" multiple accept=".md,.txt,text/markdown,text/plain">
</div>
</div>
@@ -860,12 +921,12 @@
</div>
<!-- Documents panel -->
<div id="knowledge-panel-docs" class="hidden">
<div class="flex flex-col md:flex-row gap-4 md:gap-6" style="min-height: calc(100vh - 220px)">
<div id="knowledge-panel-docs" class="hidden md:flex-1 md:min-h-0">
<div class="flex flex-col md:flex-row gap-4 md:gap-6 md:h-full">
<!-- File tree -->
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10">
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0 md:h-full">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
<input id="knowledge-search" type="text" placeholder="Search..."
@@ -873,19 +934,19 @@
oninput="filterKnowledgeTree(this.value)">
</div>
</div>
<div id="knowledge-tree" class="p-2 overflow-y-auto max-h-[50vh] md:max-h-[calc(100vh-300px)]"></div>
<div id="knowledge-tree" class="p-2 overflow-y-auto flex-1 max-h-[50vh] md:max-h-none"></div>
</div>
</div>
<!-- Content viewer -->
<div class="flex-1 min-w-0">
<div class="flex-1 min-w-0 md:h-full">
<div id="knowledge-content-placeholder"
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500">
class="flex flex-col items-center justify-center py-20 md:h-full text-slate-400 dark:text-slate-500 bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10">
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
</div>
<div id="knowledge-content-viewer" class="hidden">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10">
<div id="knowledge-content-viewer" class="hidden md:h-full">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<button onclick="knowledgeMobileBack()" class="md:hidden p-1 -ml-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
<i class="fas fa-arrow-left text-xs"></i>
</button>
@@ -894,8 +955,7 @@
<span id="knowledge-viewer-path" class="text-xs text-slate-400 dark:text-slate-500 ml-auto font-mono truncate hidden md:inline"></span>
</div>
<div id="knowledge-viewer-body"
class="p-4 md:p-5 overflow-y-auto text-sm msg-content text-slate-700 dark:text-slate-200"
style="max-height: calc(100vh - 280px)"></div>
class="p-4 md:p-5 overflow-y-auto flex-1 max-h-[60vh] md:max-h-none text-sm msg-content text-slate-700 dark:text-slate-200"></div>
</div>
</div>
</div>
@@ -983,6 +1043,14 @@
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
</div>
<div class="flex items-center gap-2">
<button id="task-refresh-btn" onclick="refreshTasksView()"
class="px-3 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5
text-sm font-medium cursor-pointer transition-colors duration-150">
<i class="fas fa-refresh text-xs"></i>
</button>
</div>
</div>
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
@@ -1056,6 +1124,62 @@
</div><!-- /main-content -->
</div><!-- /app -->
<!-- Knowledge Action Dialog -->
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div id="knowledge-dialog-card" class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
<i id="knowledge-dialog-icon" class="fas fa-folder text-emerald-500"></i>
</div>
<div>
<h3 id="knowledge-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100"></h3>
<p id="knowledge-dialog-subtitle" class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"></p>
</div>
</div>
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
<input id="knowledge-dialog-input" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
<div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" 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>
<textarea id="knowledge-dialog-textarea" rows="8"
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"></textarea>
<div id="knowledge-document-form" class="hidden space-y-3">
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/15 border border-emerald-100 dark:border-emerald-800/40 px-3 py-2">
<div id="knowledge-document-category-label" class="text-[11px] text-emerald-600 dark:text-emerald-400 mb-0.5"></div>
<div id="knowledge-document-path-preview" class="text-xs font-mono text-emerald-700 dark:text-emerald-300 break-all"></div>
</div>
<div>
<label id="knowledge-document-filename-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
<input id="knowledge-document-filename" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"
placeholder="note.md">
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label id="knowledge-document-content-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300"></label>
<button id="knowledge-document-template" type="button" class="text-xs text-primary-500 hover:text-primary-600"></button>
</div>
<textarea id="knowledge-document-content" rows="14"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"
placeholder="# Title&#10;&#10;Write your notes here..."></textarea>
</div>
</div>
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
<button id="knowledge-dialog-cancel" class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5">取消</button>
<button id="knowledge-dialog-submit" class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium disabled:opacity-50">确定</button>
</div>
</div>
</div>
<!-- Confirm Dialog -->
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
@@ -1153,6 +1277,240 @@
</div>
</div>
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-sliders text-primary-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
<input id="custom-provider-base" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="https://...../v1">
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors">
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="custom-provider-modal-delete"
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden"
data-i18n="models_custom_delete">删除</button>
<span id="custom-provider-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
<button id="custom-provider-modal-cancel"
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 text-sm font-medium
hover:bg-slate-50 dark:hover:bg-white/5
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="custom-provider-modal-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>
</div>
</div>
<!-- Task Edit Modal -->
<div id="task-edit-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
<i class="fas fa-clock text-primary-500"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base" data-i18n="task_edit_title">编辑定时任务</h3>
<p id="task-edit-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
</div>
</div>
<div class="space-y-4">
<!-- 任务名称和启用状态 -->
<div class="flex gap-4 items-end">
<div class="flex-1">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_name">任务名称</span>
</label>
<input id="task-edit-name" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors"
placeholder="任务名称">
</div>
<div class="flex items-center gap-2 pb-[2px]">
<label class="text-xs font-medium text-slate-600 dark:text-slate-400">
<span data-i18n="task_enabled">启用</span>
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="task-edit-enabled" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
</label>
</div>
</div>
<!-- 调度类型 + 调度值 -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_schedule_type">调度类型</span>
</label>
<select id="task-edit-schedule-type"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
appearance-none bg-no-repeat bg-right"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
<option value="cron" data-i18n="task_schedule_cron">Cron 表达式</option>
<option value="interval" data-i18n="task_schedule_interval">固定间隔</option>
<option value="once" data-i18n="task_schedule_once">一次性任务</option>
</select>
</div>
<!-- Cron 表达式 -->
<div id="task-edit-cron-wrap">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_cron_expression">Cron 表达式</span>
</label>
<input id="task-edit-cron-expression" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="0 9 * * *">
</div>
<!-- 固定间隔 -->
<div id="task-edit-interval-wrap" class="hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_interval_seconds">间隔秒数</span>
</label>
<input id="task-edit-interval-seconds" type="number" min="60"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors"
placeholder="3600">
</div>
<!-- 一次性任务时间 -->
<div id="task-edit-once-wrap" class="hidden">
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_once_time">执行时间</span>
</label>
<input id="task-edit-once-time" type="datetime-local" step="1"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
cursor-pointer"
onclick="this.showPicker && this.showPicker()">
</div>
</div>
<!-- Cron/Interval 提示 -->
<p id="task-edit-cron-hint" class="text-xs text-slate-400 dark:text-slate-500">
<span data-i18n="task_cron_hint">格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00</span>
</p>
<p id="task-edit-interval-hint" class="text-xs text-slate-400 dark:text-slate-500 hidden">
<span data-i18n="task_interval_hint">最小 60 秒,例如 3600 表示每小时执行一次</span>
</p>
<!-- 动作类型 + 通道类型 -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_action_type">动作类型</span>
</label>
<select id="task-edit-action-type"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
appearance-none bg-no-repeat bg-right"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
<option value="send_message" data-i18n="task_action_send_message">发送消息</option>
<option value="agent_task" data-i18n="task_action_agent_task">AI 任务</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span data-i18n="task_channel_type">通道类型</span>
</label>
<select id="task-edit-channel-type"
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors
appearance-none bg-no-repeat bg-right
disabled:opacity-100 disabled:text-slate-800 dark:disabled:text-slate-300 disabled:bg-slate-100 dark:disabled:bg-[#252525] disabled:cursor-not-allowed"
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
</select>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
<span data-i18n="task_channel_hint">选择定时消息发送的通道</span>
</p>
</div>
</div>
<!-- 隐藏的接收者ID字段自动填充 -->
<input id="task-edit-receiver" type="hidden" value="">
<!-- 消息内容/任务描述 -->
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
<span id="task-edit-content-label" data-i18n="task_message_content">消息内容</span>
</label>
<textarea id="task-edit-content" rows="3"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 transition-colors resize-none"
placeholder="输入消息内容或任务描述"></textarea>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
<button id="task-edit-modal-delete"
class="px-4 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
cursor-pointer transition-colors duration-150 hidden"
data-i18n="task_delete_btn">删除任务</button>
<span id="task-edit-modal-status"
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
<button id="task-edit-modal-cancel"
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
text-slate-600 dark:text-slate-300 text-sm font-medium
hover:bg-slate-50 dark:hover:bg-white/5
cursor-pointer transition-colors duration-150"
data-i18n="cancel">取消</button>
<button id="task-edit-modal-save"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
data-i18n="save">保存</button>
</div>
</div>
</div>
<script defer src="assets/js/console.js"></script>
</body>
</html>

View File

@@ -244,6 +244,52 @@
}
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
/* Rename button: shares the look of the delete button, sits to its left.
Negative right margin tightens the gap to the delete button only. */
.session-rename {
flex-shrink: 0;
margin-right: -6px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #9ca3af;
font-size: 11px;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.session-item:hover .session-rename { opacity: 1; }
.session-rename:hover {
color: #4ABE6E;
background: rgba(74, 190, 110, 0.12);
}
.dark .session-rename:hover { background: rgba(74, 190, 110, 0.18); }
/* Inline title editor */
.session-title-input {
flex: 1;
min-width: 0;
font-size: 13px;
font-family: inherit;
color: #111827;
background: #ffffff;
border: 1px solid #4ABE6E;
border-radius: 6px;
padding: 2px 6px;
outline: none;
}
.dark .session-title-input {
color: #e5e5e5;
background: rgba(255, 255, 255, 0.06);
border-color: #4ABE6E;
}
/* Context Divider */
.context-divider {
display: flex;
@@ -605,6 +651,18 @@
color: inherit;
}
.tool-error-text { color: #f87171; }
.tool-live-output:empty { display: none; }
.tool-streaming .tool-live-output:not(:empty)::after {
content: ' ';
display: inline-block;
width: 0.45em;
height: 1em;
margin-left: 0.15em;
vertical-align: -0.15em;
background: currentColor;
animation: tool-cursor-blink 1s steps(1) infinite;
}
@keyframes tool-cursor-blink { 50% { opacity: 0; } }
/* Log level highlighting */
.log-line { display: block; }
@@ -854,6 +912,14 @@
font-size: 11px;
flex-shrink: 0;
}
/* "Custom" row is an add-new action: trailing + instead of ✓. */
.vendor-picker-add-mark {
margin-left: auto;
padding-left: 12px;
color: #94a3b8;
font-size: 11px;
flex-shrink: 0;
}
/* Chat Input */
#chat-input {
@@ -1191,6 +1257,34 @@
background: #EDFDF3;
color: #228547;
}
.knowledge-actions {
display: flex;
gap: 2px;
margin-left: auto;
opacity: 0;
transition: opacity 0.15s;
}
.knowledge-tree-file:hover .knowledge-actions,
.knowledge-tree-group-btn:hover .knowledge-actions,
.knowledge-tree-file:focus-within .knowledge-actions,
.knowledge-tree-group-btn:focus-within .knowledge-actions {
opacity: 1;
}
.knowledge-action {
padding: 3px 5px;
border-radius: 5px;
color: #94a3b8;
font-size: 9px;
}
.knowledge-action:hover {
color: #228547;
background: rgba(34, 133, 71, 0.08);
}
.knowledge-action.danger:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.dark .knowledge-tree-file:hover {
background: rgba(255,255,255,0.06);
color: #e2e8f0;
@@ -1199,6 +1293,18 @@
background: rgba(74, 190, 110, 0.1);
color: #4ABE6E;
}
.knowledge-import-drag-over {
outline: 2px dashed rgba(74, 190, 110, 0.55);
outline-offset: 4px;
border-radius: 14px;
}
#knowledge-dialog-card.knowledge-document-dialog {
max-width: 760px;
}
#knowledge-document-content {
min-height: 320px;
line-height: 1.55;
}
/* Graph legend */
.knowledge-graph-legend {
@@ -1399,3 +1505,181 @@
.agent-cancelled-tag {
font-style: italic;
}
/* =====================================================================
Code Block Enhancements
===================================================================== */
.code-block-wrapper {
position: relative;
margin: 1em 0;
border-radius: 8px;
overflow: hidden;
background: #f8f9fa;
border: 1px solid #e2e8f0;
}
.dark .code-block-wrapper {
background: #1e293b;
border-color: #334155;
}
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em 1em;
background: #e2e8f0;
border-bottom: 1px solid #cbd5e1;
font-size: 0.85em;
}
.dark .code-block-header {
background: #0f172a;
border-bottom-color: #334155;
}
.code-block-lang {
color: #64748b;
font-weight: 500;
text-transform: lowercase;
}
.dark .code-block-lang {
color: #94a3b8;
}
.code-copy-btn {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
padding: 0.25em 0.5em;
border-radius: 4px;
transition: all 0.2s;
font-size: 0.9em;
}
.code-copy-btn:hover {
background: rgba(100, 116, 139, 0.1);
color: #475569;
}
.dark .code-copy-btn {
color: #94a3b8;
}
.dark .code-copy-btn:hover {
background: rgba(148, 163, 184, 0.1);
color: #cbd5e1;
}
.code-block-wrapper pre {
margin: 0;
border-radius: 0;
border: none;
}
/* =====================================================================
Drag and Drop Overlay
===================================================================== */
/* Anchor the absolutely-positioned overlay to the chat view. */
#view-chat {
position: relative;
}
.drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(59, 130, 246, 0.1);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.drag-overlay.active {
opacity: 1;
}
.drag-overlay.hidden {
display: none;
}
.drag-overlay-content {
background: white;
border: 3px dashed #3b82f6;
border-radius: 16px;
padding: 3em 4em;
text-align: center;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
animation: bounce 1s ease infinite;
}
.dark .drag-overlay-content {
background: #1e293b;
border-color: #60a5fa;
}
.drag-overlay-content i {
font-size: 4em;
color: #3b82f6;
margin-bottom: 0.5em;
}
.dark .drag-overlay-content i {
color: #60a5fa;
}
.drag-overlay-content p {
font-size: 1.5em;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.dark .drag-overlay-content p {
color: #f1f5f9;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* =====================================================================
Message Action Buttons
===================================================================== */
.edit-msg-btn,
.delete-msg-btn,
.regenerate-msg-btn {
opacity: 0;
transition: opacity 0.2s, color 0.2s;
}
.user-message-group:hover .edit-msg-btn,
.user-message-group:hover .delete-msg-btn,
.bot-message-group:hover .regenerate-msg-btn {
opacity: 1;
}
.edit-msg-btn:hover,
.regenerate-msg-btn:hover {
color: #3b82f6 !important;
}
.delete-msg-btn:hover {
color: #ef4444 !important;
}
.edit-msg-btn:disabled,
.delete-msg-btn:disabled {
cursor: not-allowed !important;
opacity: 0.35 !important;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,15 @@ def verify_server(data):
nonce = data.nonce
echostr = data.get("echostr", None)
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写
# Reject when token is empty: an empty token reduces signature verification
# to a predictable hash over attacker-controlled values.
if not token:
raise web.Forbidden("wechatmp_token is not configured")
check_signature(token, signature, timestamp, nonce)
return echostr
except InvalidSignatureException:
raise web.Forbidden("Invalid signature")
except web.Forbidden:
raise
except Exception as e:
raise web.Forbidden(str(e))

View File

@@ -12,16 +12,19 @@ import hashlib
import json
import math
import os
import re
import threading
import time
import uuid
import requests
import web
import websocket
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
from common.expired_dict import ExpiredDict
from common.log import logger
@@ -32,6 +35,9 @@ from config import conf
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
HEARTBEAT_INTERVAL = 30
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
# Fixed URL path for the callback (webhook) HTTP server. The bot's
# receive-message URL must point at this path, e.g. http://host:9892/wecombot
CALLBACK_PATH = "/wecombot"
def _escape_control_chars_inside_json_strings(s: str) -> str:
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
self._pending_lock = threading.Lock()
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
# Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
self.mode = "websocket"
self._crypt = None
self._http_server = None
# stream_id -> {"committed", "current", "finished", "images", "last_access"}
self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min)
self._callback_lock = threading.Lock()
conf()["group_name_white_list"] = ["ALL_GROUP"]
conf()["single_chat_prefix"] = [""]
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
# ------------------------------------------------------------------
def startup(self):
self.mode = conf().get("wecom_bot_mode", "websocket")
if self.mode == "webhook":
self._startup_callback()
return
self.bot_id = conf().get("wecom_bot_id", "")
self.bot_secret = conf().get("wecom_bot_secret", "")
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
pass
self._ws = None
self._connected = False
if self._http_server:
try:
self._http_server.stop()
logger.info("[WecomBot] Callback HTTP server stopped")
except Exception as e:
logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
self._http_server = None
# ------------------------------------------------------------------
# WebSocket connection
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
def _gen_req_id(self) -> str:
return uuid.uuid4().hex[:16]
# ------------------------------------------------------------------
# Callback (webhook) mode
# ------------------------------------------------------------------
def _startup_callback(self):
"""Start an HTTP server that receives encrypted callbacks (webhook mode).
The bot's "接收消息" URL in the WeCom admin console should point at this
server (any path is accepted). Verification (GET) and message delivery
(POST) are both handled by ``WecomBotCallbackController``.
"""
token = conf().get("wecom_bot_token", "")
aes_key = conf().get("wecom_bot_encoding_aes_key", "")
if not token or not aes_key:
err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
logger.error(err)
self.report_startup_error(err)
return
try:
# Enterprise-internal smart bot: receive_id is an empty string.
self._crypt = WecomBotCrypt(token, aes_key, "")
except Exception as e:
err = f"[WecomBot] invalid callback credentials: {e}"
logger.error(err)
self.report_startup_error(err)
return
port = int(conf().get("wecom_bot_port", 9892))
logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}, path {CALLBACK_PATH} ...")
# Only serve the fixed callback path; everything else 404s instead of being
# treated as a (signature-failing) WeCom callback.
urls = (re.escape(CALLBACK_PATH), "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
app = web.application(urls, globals(), autoreload=False)
func = web.httpserver.StaticMiddleware(app.wsgifunc())
func = web.httpserver.LogMiddleware(func)
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
self._http_server = server
self.report_startup_success()
try:
server.start()
except (KeyboardInterrupt, SystemExit):
server.stop()
def _new_callback_stream(self, response_url: str = "") -> str:
"""Create a new stream state and return its id."""
stream_id = uuid.uuid4().hex[:16]
now = time.time()
with self._callback_lock:
self._callback_streams[stream_id] = {
"committed": "",
"current": "",
"finished": False,
"images": [], # list of (base64_str, md5_str), flushed only at finish
"image_urls": [], # public http(s) image urls (usable in response_url markdown)
"image_pending": False, # an image reply is being prepared; don't finish on text yet
"last_access": now,
"created_at": now,
"response_url": response_url or "",
"delivered": False, # final answer handed to WeCom via a poll
"url_sent": False, # final answer pushed via response_url (active reply)
}
return stream_id
def _callback_handle_message(self, data: dict) -> dict:
"""Handle a freshly-received user message in callback mode.
Produces the context for async processing and returns the initial passive
reply (a stream packet with finish=false) so WeCom starts polling for the
agent's streamed answer. Returns ``None`` when there's nothing to reply
(e.g. an image/file silently cached for the next query).
"""
msg_id = data.get("msgid", "")
if msg_id and self.received_msgs.get(msg_id):
logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
return None
if msg_id:
self.received_msgs[msg_id] = True
chattype = data.get("chattype", "single")
is_group = chattype == "group"
default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
result = self._build_context(data, is_group, default_aeskey=default_aeskey)
if not result:
return None
context, wecom_msg = result
# response_url lets us actively reply once within 1h, used as a fallback
# when the agent finishes after WeCom stops polling (max ~6min window).
response_url = data.get("response_url", "") or ""
stream_id = self._new_callback_stream(response_url=response_url)
wecom_msg.stream_id = stream_id
context["wecom_stream_id"] = stream_id
context["on_event"] = self._make_callback_stream_callback(stream_id)
self.produce(context)
# First passive reply: register the stream id, WeCom will poll for updates.
return {
"msgtype": "stream",
"stream": {"id": stream_id, "finish": False, "content": ""},
}
def _callback_handle_stream_poll(self, data: dict) -> dict:
"""Handle a "流式消息刷新" poll: return the latest accumulated content."""
stream_id = data.get("stream", {}).get("id", "")
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if state is None:
# Unknown / expired stream: tell WeCom we're done to stop polling.
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
state["last_access"] = time.time()
if state.get("url_sent"):
# Final answer already pushed via response_url; finish silently.
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
# We never force-finish on a timer: while a task is still running the
# bubble should keep spinning until either the task finishes or the
# user cancels. If WeCom's 6min window closes before completion, the
# answer is delivered later via response_url instead.
finished = state["finished"]
content = state["committed"] + state["current"]
images = state["images"] if finished else []
if finished:
state["delivered"] = True
logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")
stream = {"id": stream_id, "finish": finished, "content": content}
if images:
stream["msg_item"] = [
{"msgtype": "image", "image": {"base64": b64, "md5": md5}}
for (b64, md5) in images
]
return {"msgtype": "stream", "stream": stream}
def _make_callback_stream_callback(self, stream_id: str):
"""Build an on_event callback that accumulates agent output into stream state.
Mirrors the websocket streaming behaviour: intermediate turns (text before
a tool call) are committed with a '---' separator; WeCom reads the full
accumulated content on each poll.
"""
def on_event(event: dict):
event_type = event.get("type")
edata = event.get("data", {})
cancelled = False
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state:
return
if event_type == "turn_start":
state["current"] = ""
elif event_type == "message_update":
delta = edata.get("delta", "")
if delta:
state["current"] += delta
elif event_type == "message_end":
tool_calls = edata.get("tool_calls", [])
if tool_calls:
if state["current"].strip():
state["committed"] += state["current"].strip() + "\n\n---\n\n"
state["current"] = ""
else:
state["committed"] += state["current"]
state["current"] = ""
elif event_type == "agent_cancelled":
# Mechanism 1: a cancelled run never reaches send(), so finalize
# its stream here to stop the "···" bubble immediately.
if state["current"]:
state["committed"] += state["current"]
state["current"] = ""
state["committed"] = state["committed"].rstrip()
if state["committed"].endswith("---"):
state["committed"] = state["committed"][:-3].rstrip()
if not state["committed"].strip():
state["committed"] = "🛑 已中止"
state["finished"] = True
state["last_access"] = time.time()
cancelled = True
if cancelled:
# Outside the lock: response_url fallback re-acquires it.
self._schedule_response_url_fallback(stream_id)
return on_event
# ------------------------------------------------------------------
# Subscribe & heartbeat
# ------------------------------------------------------------------
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
chattype = body.get("chattype", "single")
is_group = chattype == "group"
result = self._build_context(body, is_group)
if not result:
return
context, wecom_msg = result
wecom_msg.req_id = req_id
if req_id:
context["on_event"] = self._make_stream_callback(req_id)
self.produce(context)
def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
"""Parse a wecom message body into a Context, applying file-cache logic.
Shared by both the websocket (long-connection) and callback (webhook)
receive paths. Returns ``(context, wecom_msg)`` when the message should be
handed to the agent, or ``None`` when it was consumed (cached image/file,
parse failure, etc.).
"""
try:
wecom_msg = WecomBotMessage(body, is_group=is_group)
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
except NotImplementedError as e:
logger.warning(f"[WecomBot] {e}")
return
return None
except Exception as e:
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
return
wecom_msg.req_id = req_id
return None
# File cache logic (same pattern as feishu)
from channel.file_cache import get_file_cache
@@ -314,13 +541,13 @@ class WecomBotChannel(ChatChannel):
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
logger.info(f"[WecomBot] Image cached for session {session_id}")
return
return None
if wecom_msg.ctype == ContextType.FILE:
wecom_msg.prepare()
file_cache.add(session_id, wecom_msg.content, file_type="file")
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
return
return None
if wecom_msg.ctype == ContextType.TEXT:
cached_files = file_cache.get(session_id)
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
msg=wecom_msg,
no_need_at=True,
)
if context:
if req_id:
context["on_event"] = self._make_stream_callback(req_id)
self.produce(context)
if not context:
return None
return context, wecom_msg
# ------------------------------------------------------------------
# Event callback
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
return context
# ------------------------------------------------------------------
# Callback (webhook) send: write the final reply into the stream state
# so the next "流式消息刷新" poll returns it with finish=true.
# ------------------------------------------------------------------
def _callback_send(self, reply: Reply, context: Context):
msg = context.get("msg")
stream_id = getattr(msg, "stream_id", None) if msg else None
if not stream_id:
stream_id = context.get("wecom_stream_id")
if not stream_id:
logger.warning("[WecomBot] callback send without stream_id, dropping reply")
return
if reply.type == ReplyType.TEXT:
self._callback_finalize_text(stream_id, reply.content)
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
self._callback_finalize_image(stream_id, reply.content)
elif reply.type == ReplyType.FILE:
# Passive callback replies only support text + image (base64); files
# are not supported by the protocol, so append a notice to whatever
# text the agent already streamed (do not drop it).
text = getattr(reply, "text_content", "") or ""
note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)"
self._callback_finalize_text(stream_id, note, append=True)
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
text = getattr(reply, "text_content", "") or ""
note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)"
self._callback_finalize_text(stream_id, note, append=True)
else:
self._callback_finalize_text(stream_id, str(reply.content))
def _callback_get_or_create_state(self, stream_id: str) -> dict:
state = self._callback_streams.get(stream_id)
if state is None:
now = time.time()
state = {
"committed": "",
"current": "",
"finished": False,
"images": [],
"image_urls": [],
"image_pending": False,
"last_access": now,
"created_at": now,
"response_url": "",
"delivered": False,
"url_sent": False,
}
self._callback_streams[stream_id] = state
return state
def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
with self._callback_lock:
state = self._callback_get_or_create_state(stream_id)
accumulated = (state["committed"] + state["current"]).strip()
if append and accumulated:
state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
else:
state["committed"] = accumulated if accumulated else (content or "")
state["current"] = ""
state["last_access"] = time.time()
# Don't finish synchronously: chat_channel splits an image-with-caption
# reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
# text finished the stream immediately, WeCom would close it before the
# image arrives. Defer the finish so a trailing image can merge in.
self._schedule_text_finish(stream_id)
def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
def _run():
time.sleep(delay)
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state or state["finished"] or state.get("image_pending"):
return # already finished, or an image reply is on its way
state["finished"] = True
state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id)
threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
# Mark the image as pending up front (before the slow load/compress) so a
# preceding text finalize won't close the stream while we work.
with self._callback_lock:
self._callback_get_or_create_state(stream_id)["image_pending"] = True
b64md5 = self._load_image_base64(img_path_or_url)
with self._callback_lock:
state = self._callback_get_or_create_state(stream_id)
accumulated = (state["committed"] + state["current"]).strip()
state["current"] = ""
if b64md5:
state["images"].append(b64md5)
state["committed"] = accumulated
# Remember the public url (if any) so the response_url fallback
# can embed it as markdown when the poll window has closed.
if img_path_or_url.startswith(("http://", "https://")):
state["image_urls"].append(img_path_or_url)
else:
state["committed"] = accumulated or "[图片发送失败]"
state["finished"] = True
state["image_pending"] = False
state["last_access"] = time.time()
self._schedule_response_url_fallback(stream_id)
# ------------------------------------------------------------------
# Active reply fallback (response_url): rescue replies that finish after
# WeCom stops polling (the passive stream window is ~6 min from the user's
# message). A short delay lets an in-flight poll deliver first; only if no
# poll picks up the finished answer do we push it actively via response_url.
# ------------------------------------------------------------------
def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
def _run():
time.sleep(delay)
with self._callback_lock:
state = self._callback_streams.get(stream_id)
if not state:
return
if state.get("delivered") or state.get("url_sent"):
return # a poll already delivered (or fallback already ran)
response_url = state.get("response_url") or ""
if not response_url:
logger.warning(
f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
)
return
content = (state["committed"] + state["current"]).strip()
image_urls = list(state.get("image_urls") or [])
has_images = bool(state.get("images"))
state["url_sent"] = True
self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)
threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()
def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
"""Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
md = content or ""
if image_urls:
md += ("\n\n" if md else "") + "\n".join(f"![]({u})" for u in image_urls)
elif has_images:
md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)"
if not md:
md = "(处理完成)"
payload = {"msgtype": "markdown", "markdown": {"content": md}}
try:
resp = requests.post(response_url, json=payload, timeout=15)
logger.info(
f"[WecomBot] response_url active reply sent for {stream_id}: "
f"status={resp.status_code}, body={resp.text[:200]}"
)
except Exception as e:
logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")
def _load_image_base64(self, img_path_or_url: str):
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
local_path = img_path_or_url
if local_path.startswith("file://"):
local_path = local_path[7:]
# Temp files we create here (downloads/conversions/compressions) must be
# cleaned up afterwards; the caller's original local file must not be.
temp_files = []
try:
if local_path.startswith(("http://", "https://")):
try:
resp = requests.get(local_path, timeout=30)
resp.raise_for_status()
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
with open(tmp_path, "wb") as f:
f.write(resp.content)
temp_files.append(tmp_path)
local_path = tmp_path
except Exception as e:
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
return None
if not os.path.exists(local_path):
logger.error(f"[WecomBot] Image file not found: {local_path}")
return None
formatted = self._ensure_image_format(local_path)
if not formatted:
return None
if formatted != local_path:
temp_files.append(formatted)
local_path = formatted
# Unlike the long-connection path (which uploads and sends only a tiny
# media_id), the callback reply embeds the whole image as base64 inside
# an AES-encrypted body that is returned on EVERY poll. Empirically a
# ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the
# finish packet and poll forever, so cap well below that.
callback_max_size = 512 * 1024
if os.path.getsize(local_path) > callback_max_size:
compressed = self._compress_image(local_path, callback_max_size)
if compressed:
temp_files.append(compressed)
local_path = compressed
else:
logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)")
try:
with open(local_path, "rb") as f:
raw = f.read()
return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
except Exception as e:
logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
return None
finally:
for path in temp_files:
try:
os.remove(path)
except OSError:
pass
# ------------------------------------------------------------------
# Send reply
# ------------------------------------------------------------------
def send(self, reply: Reply, context: Context):
if self.mode == "webhook":
self._callback_send(reply, context)
return
msg = context.get("msg")
is_group = context.get("isgroup", False)
receiver = context.get("receiver", "")
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
else:
logger.error("[WecomBot] Failed to get media_id from finish response")
return media_id
class WecomBotCallbackController:
"""HTTP controller for wecom bot callback (webhook) mode.
- GET : URL verification (echo the decrypted echostr).
- POST : encrypted message / stream-refresh / event callbacks; returns an
encrypted passive reply (or "success" for an empty reply).
"""
@staticmethod
def _channel() -> "WecomBotChannel":
return WecomBotChannel()
def GET(self):
channel = self._channel()
params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
if not channel._crypt:
return "wecom bot callback not ready"
ret, echo = channel._crypt.verify_url(
params.msg_signature, params.timestamp, params.nonce, params.echostr
)
if ret != 0:
logger.error(f"[WecomBot] URL verify failed: ret={ret}")
return "verify fail"
if isinstance(echo, bytes):
echo = echo.decode("utf-8")
return echo
def POST(self):
channel = self._channel()
if not channel._crypt:
return "success"
params = web.input(msg_signature="", timestamp="", nonce="")
body = web.data()
ret, plain = channel._crypt.decrypt_msg(
body, params.msg_signature, params.timestamp, params.nonce
)
if ret != 0:
logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
return "success"
try:
data = json.loads(plain)
except Exception as e:
logger.error(f"[WecomBot] callback json parse failed: {e}")
return "success"
msgtype = data.get("msgtype", "")
# Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
# callbacks here (poll completion is logged in the stream-poll handler).
if msgtype != "stream":
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
try:
if msgtype == "stream":
reply = channel._callback_handle_stream_poll(data)
elif msgtype == "event":
event_type = data.get("event", {}).get("eventtype", "")
logger.info(f"[WecomBot] callback event: {event_type}")
reply = None
elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
reply = channel._callback_handle_message(data)
else:
logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
reply = None
except Exception as e:
logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
reply = None
if not reply:
# Empty reply package is acceptable.
return "success"
plain_reply = json.dumps(reply, ensure_ascii=False)
ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
if ret != 0:
logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
return "success"
web.header("Content-Type", "application/json; charset=utf-8")
return json.dumps(enc, ensure_ascii=False)

View File

@@ -0,0 +1,203 @@
"""
WeCom (企业微信) smart-bot callback message encryption/decryption.
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
AI bot callback (webhook) mode. The bot's receive-message callback delivers
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
same way before being returned in the HTTP response.
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
"""
import base64
import hashlib
import random
import socket
import struct
import time
from Crypto.Cipher import AES
from common.log import logger
# Error codes (mirrors the official ierror.py)
WXBizMsgCrypt_OK = 0
WXBizMsgCrypt_ValidateSignature_Error = -40001
WXBizMsgCrypt_ParseJson_Error = -40002
WXBizMsgCrypt_ComputeSignature_Error = -40003
WXBizMsgCrypt_IllegalAesKey = -40004
WXBizMsgCrypt_ValidateCorpid_Error = -40005
WXBizMsgCrypt_EncryptAES_Error = -40006
WXBizMsgCrypt_DecryptAES_Error = -40007
WXBizMsgCrypt_IllegalBuffer = -40008
WXBizMsgCrypt_EncodeBase64_Error = -40009
WXBizMsgCrypt_DecodeBase64_Error = -40010
WXBizMsgCrypt_GenReturnJson_Error = -40011
class FormatException(Exception):
pass
def _gen_sha1(token, timestamp, nonce, encrypt):
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
try:
if isinstance(encrypt, bytes):
encrypt = encrypt.decode("utf-8")
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
sortlist.sort()
sha = hashlib.sha1()
sha.update("".join(sortlist).encode("utf-8"))
return WXBizMsgCrypt_OK, sha.hexdigest()
except Exception as e:
logger.error(f"[WecomBot] compute signature error: {e}")
return WXBizMsgCrypt_ComputeSignature_Error, None
class _PKCS7Encoder:
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
block_size = 32
def encode(self, text: bytes) -> bytes:
text_length = len(text)
amount_to_pad = self.block_size - (text_length % self.block_size)
if amount_to_pad == 0:
amount_to_pad = self.block_size
pad = bytes([amount_to_pad])
return text + pad * amount_to_pad
def decode(self, decrypted: bytes) -> bytes:
pad = decrypted[-1]
if pad < 1 or pad > 32:
pad = 0
return decrypted[:-pad] if pad else decrypted
class _Prpcrypt:
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
def __init__(self, key: bytes):
self.key = key
self.mode = AES.MODE_CBC
def encrypt(self, text: str, receive_id: str):
text_bytes = text.encode()
# 16-byte random prefix + network-order length + body + receive_id
text_bytes = (
self._get_random_str()
+ struct.pack("I", socket.htonl(len(text_bytes)))
+ text_bytes
+ receive_id.encode()
)
text_bytes = _PKCS7Encoder().encode(text_bytes)
try:
cryptor = AES.new(self.key, self.mode, self.key[:16])
ciphertext = cryptor.encrypt(text_bytes)
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
except Exception as e:
logger.error(f"[WecomBot] AES encrypt error: {e}")
return WXBizMsgCrypt_EncryptAES_Error, None
def decrypt(self, text, receive_id: str):
try:
cryptor = AES.new(self.key, self.mode, self.key[:16])
plain_text = cryptor.decrypt(base64.b64decode(text))
except Exception as e:
logger.error(f"[WecomBot] AES decrypt error: {e}")
return WXBizMsgCrypt_DecryptAES_Error, None
try:
pad = plain_text[-1]
content = plain_text[16:-pad]
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
json_content = content[4 : json_len + 4].decode("utf-8")
from_receive_id = content[json_len + 4 :].decode("utf-8")
except Exception as e:
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
return WXBizMsgCrypt_IllegalBuffer, None
if from_receive_id != receive_id:
logger.error(
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
)
return WXBizMsgCrypt_ValidateCorpid_Error, None
return WXBizMsgCrypt_OK, json_content
@staticmethod
def _get_random_str() -> bytes:
return str(random.randint(1000000000000000, 9999999999999999)).encode()
class WecomBotCrypt:
"""High-level helper for verifying URLs and (de)crypting callback messages."""
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
try:
self.key = base64.b64decode(encoding_aes_key + "=")
assert len(self.key) == 32
except Exception:
raise FormatException("[WecomBot] invalid EncodingAESKey")
self.token = token
self.receive_id = receive_id
def verify_url(self, msg_signature, timestamp, nonce, echostr):
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
if ret != 0:
return ret, None
if signature != msg_signature:
return WXBizMsgCrypt_ValidateSignature_Error, None
pc = _Prpcrypt(self.key)
return pc.decrypt(echostr, self.receive_id)
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
"""Encrypt a passive-reply JSON string and return the full response JSON.
Returns (ret, response_dict). On success ret==0 and response_dict is a
dict with encrypt/msgsignature/timestamp/nonce fields.
"""
pc = _Prpcrypt(self.key)
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
if ret != 0:
return ret, None
encrypt = encrypt.decode("utf-8")
if timestamp is None:
timestamp = str(int(time.time()))
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
if ret != 0:
return ret, None
return WXBizMsgCrypt_OK, {
"encrypt": encrypt,
"msgsignature": signature,
"timestamp": timestamp,
"nonce": nonce,
}
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
"""Verify signature and decrypt the encrypted callback payload.
``post_data`` may be the raw request body (bytes/str) containing
``{"encrypt": "..."}`` or the already-extracted encrypt string.
Returns (ret, plaintext_json_str).
"""
import json
encrypt = None
if isinstance(post_data, (bytes, bytearray)):
post_data = post_data.decode("utf-8")
if isinstance(post_data, str):
try:
encrypt = json.loads(post_data).get("encrypt")
except Exception:
encrypt = post_data
elif isinstance(post_data, dict):
encrypt = post_data.get("encrypt")
if not encrypt:
return WXBizMsgCrypt_ParseJson_Error, None
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
if ret != 0:
return ret, None
if signature != msg_signature:
logger.error("[WecomBot] callback signature not match")
return WXBizMsgCrypt_ValidateSignature_Error, None
pc = _Prpcrypt(self.key)
return pc.decrypt(encrypt, self.receive_id)

View File

@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
class WecomBotMessage(ChatMessage):
"""Message wrapper for wecom bot (websocket long-connection mode)."""
def __init__(self, msg_body: dict, is_group: bool = False):
def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""):
super().__init__(msg_body)
self.msg_id = msg_body.get("msgid")
self.create_time = msg_body.get("create_time")
self.is_group = is_group
# In callback (webhook) mode the media bodies carry no per-message aeskey;
# the download url is encrypted with the bot's EncodingAESKey instead.
self._default_aeskey = default_aeskey
msg_type = msg_body.get("msgtype")
from_userid = msg_body.get("from", {}).get("userid", "")
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.IMAGE
image_info = msg_body.get("image", {})
image_url = image_info.get("url", "")
aeskey = image_info.get("aeskey", "")
aeskey = image_info.get("aeskey", "") or self._default_aeskey
tmp_dir = _get_tmp_dir()
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
elif item_type == "image":
img_info = item.get("image", {})
img_url = img_info.get("url", "")
img_aeskey = img_info.get("aeskey", "")
img_aeskey = img_info.get("aeskey", "") or self._default_aeskey
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
try:
img_data = _decrypt_media(img_url, img_aeskey)
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.FILE
file_info = msg_body.get("file", {})
file_url = file_info.get("url", "")
aeskey = file_info.get("aeskey", "")
aeskey = file_info.get("aeskey", "") or self._default_aeskey
tmp_dir = _get_tmp_dir()
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
self.content = base_path
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
self.ctype = ContextType.FILE
video_info = msg_body.get("video", {})
video_url = video_info.get("url", "")
aeskey = video_info.get("aeskey", "")
aeskey = video_info.get("aeskey", "") or self._default_aeskey
tmp_dir = _get_tmp_dir()
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")

View File

@@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf
from config import conf, get_weixin_credentials_path
MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY = 30
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
token = conf().get("weixin_token", "")
self._credentials_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
self._credentials_path = get_weixin_credentials_path()
# Always load credentials so we can restore context_tokens even when
# the bot token itself comes from config.

View File

@@ -1 +1 @@
2.0.9
2.1.3

View File

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

View File

@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
def _pip_install(package_spec: str, stream: StreamFn) -> int:
"""Install a package, retrying with --user on permission failure."""
"""Install a package, preferring prebuilt wheels; retry with --user on perm error."""
python = sys.executable
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
base = [python, "-m", "pip", "install", "--prefer-binary"]
ret = subprocess.call(base + [package_spec])
if ret != 0:
stream(" Retrying with --user flag...", "yellow")
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
ret = subprocess.call(base + ["--user", package_spec])
return ret
@@ -155,6 +156,22 @@ def run_install_browser(
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
if sys.platform == "win32":
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
ret = subprocess.call(
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
)
if ret != 0:
stream(
" Could not pre-install a prebuilt greenlet wheel.\n"
" playwright may try to build greenlet from source, which needs\n"
" Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/",
"yellow",
)
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream)

View File

@@ -195,6 +195,120 @@ def restart(ctx, no_logs):
ctx.invoke(start, no_logs=no_logs)
# Detached relay that survives the caller's process tree. Run via `python -c`
# with start_new_session=True so it keeps going after the agent's bash child
# (and the main app it kills) both die. Flow: self-check the new code FIRST
# (import app); abort without touching the old process if it fails. Only when
# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL
# fallback), then start a fresh app.py and write the pid.
_RELAY_SCRIPT = r"""
import os, sys, time, signal, subprocess
root, python, app_py, pid_file, log_file = sys.argv[1:6]
old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
def alive(pid):
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
def log(msg):
try:
with open(log_file, "a") as f:
f.write("[self-restart] " + msg + "\n")
except OSError:
pass
# 0) Self-check: make sure the new code actually loads BEFORE killing anything.
# `import app` exercises top-level imports / syntax of the entry module. If it
# fails, abort and leave the running service untouched — never end up with the
# old process killed and the new one unable to start.
check = subprocess.run(
[python, "-c", "import app"], cwd=root,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
if check.returncode != 0:
detail = (check.stdout or b"").decode("utf-8", "replace").strip()
log("self-check FAILED, aborting restart; service left running:\n" + detail)
sys.exit(1)
log("self-check passed")
# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state).
if alive(old_pid):
try:
os.kill(old_pid, signal.SIGTERM)
except OSError:
pass
# 2) Wait up to ~15s for it to go, then force-kill as a backstop.
for _ in range(150):
if not alive(old_pid):
break
time.sleep(0.1)
else:
try:
os.kill(old_pid, signal.SIGKILL)
except OSError:
pass
time.sleep(0.5)
# 3) Start a fresh instance, detached, logging to the same file.
with open(log_file, "a") as f:
proc = subprocess.Popen(
[python, app_py], cwd=root,
stdout=f, stderr=f, start_new_session=True,
)
with open(pid_file, "w") as f:
f.write(str(proc.pid))
log("restarted, new pid=" + str(proc.pid))
"""
@click.command(name="self-restart", hidden=True)
def self_restart():
"""Restart from inside the running agent (detached; survives parent death).
Intended to be invoked by the agent itself (e.g. via bash after editing its
own code), not by users — so it is hidden from `cow help`. Unlike `restart`,
the actual stop+start runs in a detached relay process that outlives the
agent's bash child, which would otherwise die together with the main app it
kills.
"""
if _IS_WIN:
click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True)
sys.exit(1)
root = get_project_root()
app_py = os.path.join(root, "app.py")
if not os.path.exists(app_py):
click.echo("Error: app.py not found in project root.", err=True)
sys.exit(1)
python = sys.executable
pid = _read_pid() or 0
subprocess.Popen(
[
python, "-c", _RELAY_SCRIPT,
root, python, app_py, _get_pid_file(), _get_log_file(), str(pid),
],
cwd=root,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
click.echo(click.style(
"✓ Restart scheduled. The service will stop and come back in a few seconds.",
fg="green",
))
@click.command()
@click.pass_context
def update(ctx):
@@ -275,7 +389,7 @@ def update(ctx):
def status():
"""Show CowAgent running status."""
from cli import __version__
from cli.utils import load_config_json, get_cli_language
from cli.utils import load_config_json, get_cli_language, get_project_root
# get_cli_language() calls ensure_sys_path(), which adds the project root
# to sys.path. Import `common` only AFTER that, otherwise it fails with
@@ -292,6 +406,11 @@ def status():
click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}"))
# Project path bound to this `cow` CLI — disambiguates which checkout the
# command actually controls when the user has multiple clones.
project_root = get_project_root()
click.echo(_t(f" 路径: {project_root}", f" Path: {project_root}"))
cfg = load_config_json()
if cfg:
channel = cfg.get("channel_type", "unknown")

View File

@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from linkai import LinkAIClient, PushMsg
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root, get_weixin_credentials_path
from plugins import PluginManager
import threading
import time
@@ -34,7 +34,9 @@ chat_client: LinkAIClient
CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"}
# channelType -> config key mapping for app credentials
# channelType -> config key mapping for app credentials.
# secret_key may be "" for single-token channels (e.g. telegram/discord).
# For slack, appId carries bot_token and appSecret carries app_token.
CREDENTIAL_MAP = {
"feishu": ("feishu_app_id", "feishu_app_secret"),
"dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"),
@@ -43,6 +45,9 @@ CREDENTIAL_MAP = {
"wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"),
"wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"),
"wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"),
"telegram": ("telegram_token", ""),
"slack": ("slack_bot_token", "slack_app_token"),
"discord": ("discord_token", ""),
}
@@ -167,6 +172,11 @@ class CloudClient(LinkAIClient):
if key in available_setting and config.get(key) is not None:
local_config[key] = config.get(key)
# Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true")
# to a real bool so the evolution config parser reads it correctly.
if config.get("self_evolution_enabled") is not None:
local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled"))
# Voice settings
reply_voice_mode = config.get("reply_voice_mode")
if reply_voice_mode:
@@ -326,9 +336,7 @@ class CloudClient(LinkAIClient):
@staticmethod
def _remove_weixin_credentials():
"""Remove the weixin token credentials file so next connect triggers QR login."""
cred_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
cred_path = get_weixin_credentials_path()
try:
if os.path.exists(cred_path):
os.remove(cred_path)
@@ -336,6 +344,20 @@ class CloudClient(LinkAIClient):
except Exception as e:
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
# ------------------------------------------------------------------
# value helpers
# ------------------------------------------------------------------
@staticmethod
def _to_bool(value) -> bool:
"""Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1")."""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in ("y", "yes", "true", "1", "on")
return False
# ------------------------------------------------------------------
# channel credentials helpers
# ------------------------------------------------------------------
@@ -357,7 +379,8 @@ class CloudClient(LinkAIClient):
local_config[id_key] = app_id
os.environ[id_key.upper()] = str(app_id)
changed = True
if app_secret is not None and local_config.get(secret_key) != app_secret:
# secret_key may be empty for single-token channels (e.g. telegram/discord)
if secret_key and app_secret is not None and local_config.get(secret_key) != app_secret:
local_config[secret_key] = app_secret
os.environ[secret_key.upper()] = str(app_secret)
changed = True
@@ -372,9 +395,10 @@ class CloudClient(LinkAIClient):
return
id_key, secret_key = cred
local_config.pop(id_key, None)
local_config.pop(secret_key, None)
os.environ.pop(id_key.upper(), None)
os.environ.pop(secret_key.upper(), None)
if secret_key:
local_config.pop(secret_key, None)
os.environ.pop(secret_key.upper(), None)
# ------------------------------------------------------------------
# channel_type list helpers
@@ -848,6 +872,10 @@ def _build_config():
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
"agent_max_steps": local_conf.get("agent_max_steps"),
# Self-evolution switch reported so the console can reflect state
"self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N",
"self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"),
"self_evolution_min_turns": local_conf.get("self_evolution_min_turns"),
"channelType": local_conf.get("channel_type"),
}
@@ -862,25 +890,16 @@ def _build_config():
if plugin_config.get("Godcmd"):
config["admin_password"] = plugin_config.get("Godcmd").get("password")
# Add channel-specific app credentials
# Add channel-specific app credentials based on CREDENTIAL_MAP.
# For multi-channel channel_type (comma-separated), the first matched type wins.
current_channel_type = local_conf.get("channel_type", "")
if current_channel_type == "feishu":
config["app_id"] = local_conf.get("feishu_app_id")
config["app_secret"] = local_conf.get("feishu_app_secret")
elif current_channel_type == "dingtalk":
config["app_id"] = local_conf.get("dingtalk_client_id")
config["app_secret"] = local_conf.get("dingtalk_client_secret")
elif current_channel_type in ("wechatmp", "wechatmp_service"):
config["app_id"] = local_conf.get("wechatmp_app_id")
config["app_secret"] = local_conf.get("wechatmp_app_secret")
elif current_channel_type == "wecom_bot":
config["app_id"] = local_conf.get("wecom_bot_id")
config["app_secret"] = local_conf.get("wecom_bot_secret")
elif current_channel_type == "qq":
config["app_id"] = local_conf.get("qq_app_id")
config["app_secret"] = local_conf.get("qq_app_secret")
elif current_channel_type == "wechatcom_app":
config["app_id"] = local_conf.get("wechatcomapp_agent_id")
config["app_secret"] = local_conf.get("wechatcomapp_secret")
for ch_type in CloudClient._parse_channel_types({"channel_type": current_channel_type}):
cred = CREDENTIAL_MAP.get(ch_type)
if not cred:
continue
id_key, secret_key = cred
config["app_id"] = local_conf.get(id_key)
config["app_secret"] = local_conf.get(secret_key) if secret_key else ""
break
return config

View File

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

View File

@@ -7,6 +7,11 @@ 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.
Supported language codes (BCP 47 compliant):
- "zh" (Simplified Chinese)
- "zh-Hant" (Traditional Chinese, script-based tag per Unicode CLDR)
- "en" (English)
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),
@@ -19,7 +24,10 @@ Resolution priority (highest first):
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.
setting "zh", "zh-Hant", or "en" locks the language and skips detection.
Note: For backwards compatibility, zh-tw, zh-hk, and other regional variants
are automatically normalized to zh-Hant during detection.
"""
import os
@@ -28,10 +36,85 @@ import sys
# Supported language codes
ZH = "zh"
ZH_HANT = "zh-Hant"
EN = "en"
SUPPORTED = (ZH, EN)
SUPPORTED = (ZH, ZH_HANT, EN)
DEFAULT_LANG = EN
# Mapped Simplified to Traditional characters in this codebase
_SIMPLIFIED = "与专业东丢两严个丰临为举么义乐乔习书乱于云产亲仅从仓们价众优伙会伟传体余侧倾储儿关兴兽内册写冲决况准减几凭凯击划则刚创删别剥办务动区华协单占厂历压参双发变叙台号后吗听启员响嚣团国图场坏块声处备复够头夹妩姗娇娱婶学宝实宠审宽对导将尝尽层属币师带帧帮幂干并广庆库应开异弃张弹强归当录彦彻征径忆态总悦惯戏战户执扩扫抛抢护报拟拥拦择换据数断无旧时昵显晓暂术机杂权条来杰极构枪标树样档桦梦检欢残毕气汇汉汤没泄泼洁测浏润涩淀渊温游湾湿滞满滤灵灿炀点炼烦热爱爷状独猪环现瑶电画监盖盘着睁码础确离种积称稳竞笔签简类粤紧纠红约级纪纯纳线组细织终绍经结绘给络绝统继绪续维综缀缓编缩网罗羁职联聪脑脚脱腾舰艺节苍苏范荐获萝蔼虑补装见观规视览觉触计订认讨让训议讯记讲许论设访证识诉诊词译试诚话询该详语误说请读谁调谢谨谱贝负责贤败账货质费资赋赖赘轩转轮软轻载较辑输边达过运还这进远违连迟适选递逻遥邮邻采释里鉴针钉钟钥钮钱铁链销锁错锤键镜长闭问闲间闺闻闽阅队阳际陆陕险随隐难静韩页项顺须顾预领频题额风飞饭饰馆馈馏马驻驿验骤鱼鸡麦齐"
_TRADITIONAL = "與專業東丟兩嚴個豐臨為舉麼義樂喬習書亂於雲產親僅從倉們價眾優夥會偉傳體餘側傾儲兒關興獸內冊寫沖決況準減幾憑凱擊劃則剛創刪別剝辦務動區華協單佔廠歷壓參雙發變敘臺號後嗎聽啟員響囂團國圖場壞塊聲處備復夠頭夾嫵姍嬌娛嬸學寶實寵審寬對導將嘗盡層屬幣師帶幀幫冪幹並廣慶庫應開異棄張彈強歸當錄彥徹徵徑憶態總悅慣戲戰戶執擴掃拋搶護報擬擁攔擇換據數斷無舊時暱顯曉暫術機雜權條來傑極構槍標樹樣檔樺夢檢歡殘畢氣匯漢湯沒洩潑潔測瀏潤澀澱淵溫遊灣溼滯滿濾靈燦煬點煉煩熱愛爺狀獨豬環現瑤電畫監蓋盤著睜碼礎確離種積稱穩競筆籤簡類粵緊糾紅約級紀純納線組細織終紹經結繪給絡絕統繼緒續維綜綴緩編縮網羅羈職聯聰腦腳脫騰艦藝節蒼蘇範薦獲蘿藹慮補裝見觀規視覽覺觸計訂認討讓訓議訊記講許論設訪證識訴診詞譯試誠話詢該詳語誤說請讀誰調謝謹譜貝負責賢敗帳貨質費資檔案影片圖片連結資料資訊支援排程執行帳號密碼憑證埠服務啟用管道終端機控制台"
_CHAR_MAP = None
_PHRASE_MAP = {
"默认": "預設",
"内存": "記憶體",
"配置": "設定",
"进程": "處理程序",
"目录": "目錄",
"文件夹": "資料夾",
"文件": "檔案",
"视频": "影片",
"图片": "圖片",
"影象": "影像",
"图像": "影像",
"链接": "連結",
"数据": "資料",
"信息": "資訊",
"支持": "支援",
"定时": "排程",
"运行": "執行",
"账号": "帳號",
"密码": "密碼",
"凭据": "憑證",
"端口": "",
"服务": "服務",
"激活": "啟用",
"通道": "管道",
"终端": "終端機",
"主控台": "控制台",
"创建": "建立",
"计算机": "電腦",
}
def to_traditional(text):
"""Convert Simplified Chinese text to Traditional Chinese.
Uses a two-tier approach:
1. Phrase-level mapping for project-specific terms (e.g., "配置""設定")
2. OpenCC library (opencc-python-reimplemented) if available for high-quality
context-aware conversion, with fallback to built-in character mapping
This function is designed to work without external dependencies. If OpenCC
is not installed, it falls back to a curated 450-character mapping table
plus 30+ technical term mappings that cover common project vocabulary.
For production use with zh-Hant language, installing OpenCC is recommended:
pip install opencc-python-reimplemented
"""
if not text:
return text
# Replace phrases first
for s, t_phrase in _PHRASE_MAP.items():
text = text.replace(s, t_phrase)
try:
from opencc import OpenCC
cc = OpenCC('s2twp')
return cc.convert(text)
except Exception:
pass
global _CHAR_MAP
if _CHAR_MAP is None:
_CHAR_MAP = dict(zip(_SIMPLIFIED, _TRADITIONAL))
# Replace characters
return "".join(_CHAR_MAP.get(c, c) for c in text)
# Resolved language cache; None until first resolution.
_resolved_lang = None
@@ -48,7 +131,10 @@ def _normalize(raw):
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 ...
# Traditional Chinese variants: zh-tw, zh-hk, zh-hant, zh-hant-tw, zh-hant-hk...
if value.startswith("zh-tw") or value.startswith("zh-hk") or "hant" in value:
return ZH_HANT
# General or Simplified Chinese variants: zh, zh-cn, zh-hans...
if value.startswith("zh") or value.startswith("chinese"):
return ZH
if value.startswith("en") or value.startswith("english"):
@@ -124,6 +210,8 @@ def detect_language():
3. Python locale module
4. default English
"""
if os.environ.get("CLOUD_DEPLOYMENT_ID"):
return ZH
return (
_detect_from_macos()
or _detect_from_env()
@@ -165,7 +253,7 @@ def get_language():
def is_zh():
return get_language() == ZH
return get_language() in (ZH, ZH_HANT)
def t(zh_text, en_text):
@@ -174,4 +262,7 @@ def t(zh_text, en_text):
Intended for one-off strings where a full message catalog is overkill:
t("已中止", "Cancelled")
"""
return zh_text if get_language() == ZH else en_text
lang = get_language()
if lang == ZH_HANT:
return to_traditional(zh_text)
return zh_text if lang == ZH else en_text

View File

@@ -1,8 +1,21 @@
import logging
import os
import sys
import io
def _log_path():
# Mirror config.get_data_root() without importing config (avoids a circular
# import, since config imports this module). The desktop build sets
# COW_DATA_DIR (e.g. ~/.cow); source deployments fall back to CWD.
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
data_dir = os.path.expanduser(data_dir)
os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, "run.log")
return "run.log"
def _reset_logger(log):
for handler in log.handlers:
handler.close()
@@ -20,15 +33,28 @@ def _reset_logger(log):
datefmt="%Y-%m-%d %H:%M:%S",
)
)
file_handle = logging.FileHandler("run.log", encoding="utf-8")
file_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
log.addHandler(file_handle)
log.addHandler(console_handle)
# File logging is best-effort: if the log path isn't writable (e.g. a
# packaged app installed under Program Files run by a non-admin user, with
# an unwritable CWD), fall back to console-only instead of crashing the
# whole process at import time.
try:
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
file_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
log.addHandler(file_handle)
except OSError:
console_handle.handle(
logging.LogRecord(
"log", logging.WARNING, __file__, 0,
"[log] file logging disabled (log path not writable): %s",
(_log_path(),), None,
)
)
def _get_logger():

View File

@@ -1,18 +1,21 @@
import os
import pathlib
from common.utils import expand_path
from config import conf
class TmpDir(object):
"""A temporary directory that is deleted when the object is destroyed."""
"""Temporary directory for transient artifacts (e.g. synthesized voice).
tmpFilePath = pathlib.Path("./tmp/")
Resolves to ``<agent_workspace>/tmp`` (default ``~/cow/tmp``) so temp files
land inside the agent workspace instead of a CWD-relative ``./tmp``, which
is unreliable for the packaged desktop app where CWD is undefined.
"""
def __init__(self):
pathExists = os.path.exists(self.tmpFilePath)
if not pathExists:
os.makedirs(self.tmpFilePath)
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
self.tmpFilePath = os.path.join(ws_root, "tmp")
os.makedirs(self.tmpFilePath, exist_ok=True)
def path(self):
return str(self.tmpFilePath) + "/"

View File

@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
img = Image.open(file)
rgb_image = img.convert("RGB")
quality = 95
min_quality = 10
while True:
out_buf = io.BytesIO()
rgb_image.save(out_buf, "JPEG", quality=quality)
if fsize(out_buf) <= max_size:
if fsize(out_buf) <= max_size or quality <= min_quality:
# Stop at min_quality: further decrements would pass an invalid
# quality (<1) to PIL and the loop would otherwise never terminate
# for images that cannot be compressed below max_size.
return out_buf
quality -= 5

View File

@@ -40,5 +40,9 @@
"agent_max_steps": 20,
"enable_thinking": false,
"reasoning_effort": "high",
"knowledge": true
"knowledge": true,
"self_evolution_enabled": true,
"mcp_tool_retrieval_enabled": false,
"mcp_tool_retrieval_threshold": 20,
"mcp_tool_retrieval_top_k": 10
}

140
config.py
View File

@@ -1,10 +1,12 @@
# encoding:utf-8
import ast
import copy
import json
import logging
import os
import pickle
import sys
from common.log import logger
from common import i18n
@@ -24,8 +26,11 @@ available_setting = {
"open_ai_api_base": "https://api.openai.com/v1",
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom")
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
# Each item: {"id": "3f2a9c1b", "name": "my-provider", "api_key": "sk-...", "api_base": "https://api.example.com/v1", "model": "model-name"}
"custom_providers": [],
"proxy": "", # proxy used by openai
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
@@ -180,6 +185,11 @@ available_setting = {
# WeCom smart bot config (long connection mode)
"wecom_bot_id": "", # WeCom smart bot BotID
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
# WeCom smart bot transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
"wecom_bot_mode": "websocket",
"wecom_bot_token": "", # webhook mode: Token configured on the bot's receive-message URL
"wecom_bot_encoding_aes_key": "", # webhook mode: EncodingAESKey configured on the bot's receive-message URL
"wecom_bot_port": 9892, # webhook mode: local HTTP server port for the receive-message URL
# Telegram config
"telegram_token": "", # Bot token from @BotFather
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)
@@ -251,8 +261,21 @@ available_setting = {
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
"knowledge": True, # whether to enable the knowledge base feature
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
"self_evolution_enabled": False, # switch to enable/disable self-evolution
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
# Deep Dream: nightly memory distillation into MEMORY.md + dream diary.
"deep_dream_enabled": True, # scheduled deep dream switch; manual /memory dream is unaffected
"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)
# On-demand MCP tool retrieval: when many MCP tools are connected, inject
# only the most query-relevant ones instead of all of them. Built-in tools
# are always injected in full; degrades to full injection when disabled,
# below threshold, or when no embedding provider is available.
"mcp_tool_retrieval_enabled": False, # switch for on-demand MCP tool retrieval
"mcp_tool_retrieval_threshold": 20, # only retrieve when MCP tool count exceeds this
"mcp_tool_retrieval_top_k": 10, # max relevant MCP tools injected per turn
}
@@ -294,6 +317,12 @@ class Config(dict):
self.user_datas[user] = {}
return self.user_datas[user]
# SECURITY NOTE: pickle.load() can execute arbitrary code during
# deserialization. This is safe as long as user_datas.pkl is trusted
# (local app data directory, written only by this process). For a future
# hardening pass, consider migrating to JSON (json.load/json.dump) if the
# data structures are JSON-serializable, or adding an HMAC signature to
# detect tampering of the pickle file.
def load_user_datas(self):
try:
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
@@ -307,6 +336,8 @@ class Config(dict):
def save_user_datas(self):
try:
# SECURITY: pickle.dump output should only be loaded by this same
# process. See note on load_user_datas() above.
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
pickle.dump(self.user_datas, f)
logger.info("[Config] User datas saved.")
@@ -317,24 +348,37 @@ class Config(dict):
config = Config()
def _mask_value(val):
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
if not isinstance(val, str) or len(val) <= 8:
return val
return val[0:3] + "*" * 5 + val[-3:]
def _mask_sensitive_recursive(obj):
"""Recursively mask values whose keys contain 'key' or 'secret'."""
if isinstance(obj, dict):
masked = {}
for k, v in obj.items():
if ("key" in k or "secret" in k) and isinstance(v, str):
masked[k] = _mask_value(v)
else:
masked[k] = _mask_sensitive_recursive(v)
return masked
elif isinstance(obj, list):
return [_mask_sensitive_recursive(item) for item in obj]
return obj
def drag_sensitive(config):
try:
if isinstance(config, str):
conf_dict: dict = json.loads(config)
conf_dict_copy = copy.deepcopy(conf_dict)
for key in conf_dict_copy:
if "key" in key or "secret" in key:
if isinstance(conf_dict_copy[key], str):
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
return json.dumps(conf_dict_copy, indent=4)
elif isinstance(config, dict):
config_copy = copy.deepcopy(config)
for key in config:
if "key" in key or "secret" in key:
if isinstance(config_copy[key], str):
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
return config_copy
return _mask_sensitive_recursive(config)
except Exception as e:
logger.exception(e)
return config
@@ -352,10 +396,16 @@ def load_config():
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
logger.info(" |___/ ")
logger.info("")
config_path = "./config.json"
# User config lives in the data root: source deployments use CWD (./), while
# the desktop build points COW_DATA_DIR at ~/.cow so config survives updates.
config_path = os.path.join(get_data_root(), "config.json")
if not os.path.exists(config_path):
logger.info("config file not found, falling back to config-template.json")
config_path = "./config-template.json"
# Resolve the template via get_resource_root() so it works both from
# source and from a frozen (PyInstaller) bundle, where the template
# ships inside the bundle (sys._MEIPASS) and CWD may differ.
template_path = os.path.join(get_resource_root(), "config-template.json")
config_path = template_path if os.path.exists(template_path) else "./config-template.json"
config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
@@ -383,11 +433,19 @@ def load_config():
if name in available_setting:
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
try:
config[name] = eval(value)
# SECURITY: Use ast.literal_eval instead of eval().
# ast.literal_eval only parses Python literals (strings, numbers,
# tuples, lists, dicts, booleans, None) and CANNOT execute
# arbitrary code, preventing environment-variable injection.
config[name] = ast.literal_eval(value)
except Exception:
if value == "false":
# literal_eval can raise ValueError/SyntaxError for non-literal
# strings, but also TypeError/RecursionError on malformed input
# (e.g. unhashable dict keys); catch broadly to avoid crashing
# startup, and fall back to treating the value as a plain string.
if value.lower() == "false":
config[name] = False
elif value == "true":
elif value.lower() == "true":
config[name] = True
else:
config[name] = value
@@ -595,6 +653,34 @@ def get_root():
return os.path.dirname(os.path.abspath(__file__))
def get_resource_root():
"""Directory holding bundled read-only resources (e.g. config-template.json).
Under PyInstaller, data files live in sys._MEIPASS (the onedir _internal
folder), which differs from get_root() — the latter is used for writable
user data and should stay next to the executable, not inside the bundle.
"""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return sys._MEIPASS
return os.path.dirname(os.path.abspath(__file__))
def get_data_root():
"""Directory for writable user data (config.json, user_datas.pkl, run.log).
The desktop build sets COW_DATA_DIR (e.g. ~/.cow) so data lives in the
user's home rather than inside the read-only app bundle and survives app
updates. When unset (source deployment), it falls back to get_root(), so
existing behavior is unchanged.
"""
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
data_dir = os.path.expanduser(data_dir)
os.makedirs(data_dir, exist_ok=True)
return data_dir
return get_root()
def read_file(path):
with open(path, mode="r", encoding="utf-8-sig") as f:
return f.read()
@@ -605,13 +691,29 @@ def conf():
def get_appdata_dir():
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
data_path = os.path.join(get_data_root(), conf().get("appdata_dir", ""))
if not os.path.exists(data_path):
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
os.makedirs(data_path)
return data_path
def get_weixin_credentials_path():
"""Resolve the Weixin credentials (token) file path.
Honors an explicit ``weixin_credentials_path`` from config. Otherwise the
packaged desktop build (COW_DATA_DIR set) keeps it under the data dir
(~/.cow) so all user data stays together, while source deployments retain
the legacy ~/.weixin_cow_credentials.json default unchanged.
"""
configured = conf().get("weixin_credentials_path")
if configured:
return os.path.expanduser(configured)
if os.environ.get("COW_DATA_DIR"):
return os.path.join(get_data_root(), "weixin_credentials.json")
return os.path.expanduser("~/.weixin_cow_credentials.json")
def subscribe_msg():
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
msg = conf().get("subscribe_msg", "")

6
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
release/
*.log
.DS_Store
Thumbs.db

81
desktop/README.md Normal file
View File

@@ -0,0 +1,81 @@
# CowAgent Desktop
Cross-platform desktop client for CowAgent, built with Electron + React + TypeScript.
## Development
### Prerequisites
- Node.js 18+
- npm or yarn
- Python 3.7+ (for the backend)
### Setup
```bash
cd desktop
npm install
```
### Run in Development
Start the renderer dev server and Electron together:
```bash
npm run dev
```
Or run them separately:
```bash
# Terminal 1: Start Vite dev server
npm run dev:renderer
# Terminal 2: Start Electron (after renderer is ready)
npm run dev:main
```
The app will automatically start the Python backend from the parent directory.
### Build
```bash
# Build for current platform
npm run dist
# Build for macOS only
npm run dist:mac
# Build for Windows only
npm run dist:win
```
Build outputs are placed in the `release/` directory.
## Architecture
```
desktop/
├── src/
│ ├── main/ # Electron main process
│ │ ├── index.ts # Window management, IPC
│ │ ├── python-manager.ts # Python backend lifecycle
│ │ └── preload.ts # Context bridge for renderer
│ └── renderer/ # React UI (Vite)
│ └── src/
│ ├── api/ # HTTP client for backend APIs
│ ├── components/ # Reusable UI components
│ ├── hooks/ # React hooks
│ ├── pages/ # Page components
│ └── types.ts # TypeScript types
├── resources/ # App icons
├── package.json # Dependencies and build config
└── vite.config.ts # Vite config
```
### How it Works
1. Electron main process starts and creates the app window
2. It spawns the Python backend (`app.py`) as a child process
3. The React UI communicates with the Python backend via HTTP APIs
4. SSE (Server-Sent Events) is used for streaming chat responses and live logs

79
desktop/build/build-backend.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Build the desktop backend into a self-contained onedir bundle via PyInstaller.
# Run from anywhere; paths are resolved relative to the repo root.
#
# Usage:
# bash desktop/build/build-backend.sh # build
# PYTHON=python3.11 bash desktop/build/build-backend.sh # pick interpreter
#
# Output: desktop/build/dist/cowagent-backend/ (folder with the executable)
set -euo pipefail
# --- resolve paths --------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
VENV_DIR="$BUILD_DIR/.venv-build"
# Prefer Python 3.11 when available: on 3.13+ web.py must be installed from a
# GitHub git source (the PyPI build fails), which is flaky on some networks.
# 3.11 installs web.py straight from PyPI and has the best PyInstaller support.
if [ -z "${PYTHON:-}" ]; then
for cand in \
"/Library/Frameworks/Python.framework/Versions/3.11/bin/python3.11" \
"python3.11" \
"python3.12" \
"python3"; do
if command -v "$cand" >/dev/null 2>&1; then
PYTHON="$cand"
break
fi
done
fi
# Prefer Python 3.11: it installs web.py from PyPI (no GitHub clone) and avoids
# 3.13's removed-cgi compatibility shims. Override with PYTHON=... if needed.
pick_python() {
if [ -n "${PYTHON:-}" ]; then echo "$PYTHON"; return; fi
for c in python3.11 python3.12 python3.10 python3; do
if command -v "$c" >/dev/null 2>&1; then echo "$c"; return; fi
done
echo python3
}
PYTHON="$(pick_python)"
echo "==> Repo root: $ROOT"
echo "==> Using Python: $($PYTHON --version 2>&1) ($PYTHON)"
# --- isolated build venv --------------------------------------------------
if [ ! -d "$VENV_DIR" ]; then
echo "==> Creating build venv at $VENV_DIR"
"$PYTHON" -m venv "$VENV_DIR"
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
echo "==> Installing build dependencies"
pip install -q --upgrade pip
# Don't leave a half-populated venv behind if deps fail (e.g. flaky network):
# the next run would otherwise reuse a broken venv.
if ! pip install -q -r "$BUILD_DIR/requirements-desktop.txt"; then
echo "!! Dependency install failed. Removing the build venv so a retry starts clean." >&2
deactivate || true
rm -rf "$VENV_DIR"
exit 1
fi
pip install -q pyinstaller
# --- run pyinstaller from repo root so relative datas resolve -------------
cd "$ROOT"
echo "==> Running PyInstaller (onedir)"
pyinstaller "$BUILD_DIR/cowagent-backend.spec" \
--noconfirm \
--distpath "$BUILD_DIR/dist" \
--workpath "$BUILD_DIR/build-work"
echo ""
echo "==> Done. Bundle at: $BUILD_DIR/dist/cowagent-backend/"
du -sh "$BUILD_DIR/dist/cowagent-backend/" 2>/dev/null || true
echo "==> Smoke test: COW_DESKTOP=1 \"$BUILD_DIR/dist/cowagent-backend/cowagent-backend\""

View File

@@ -0,0 +1,194 @@
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec for the CowAgent desktop backend (onedir).
Produces a self-contained `cowagent-backend` folder that the Electron app
spawns directly, so end users don't need Python installed.
onedir is chosen over onefile because the backend reads data files via paths
relative to the source tree (e.g. config-template.json, skills/, chat.html);
onedir preserves that layout, while onefile's temp-extraction would break it.
Build from the repo root:
pyinstaller desktop/build/cowagent-backend.spec --noconfirm
"""
import os
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# Resolve the repo root from the spec's own location (desktop/build/ -> root),
# independent of the current working directory. PyInstaller exposes SPECPATH.
ROOT = os.path.abspath(os.path.join(SPECPATH, '..', '..'))
def rp(*parts):
"""Absolute path under the repo root."""
return os.path.join(ROOT, *parts)
# --- Hidden imports -------------------------------------------------------
# Channels are imported dynamically by channel_factory via string names, so
# PyInstaller's static analysis can't see them. List every channel we ship
# (Feishu is intentionally excluded — lark-oapi is dropped from the desktop
# build to save ~116MB).
hiddenimports = [
# channels (dynamic import in channel/channel_factory.py)
'channel.web.web_channel',
'channel.terminal.terminal_channel',
'channel.weixin.weixin_channel',
'channel.wechatmp.wechatmp_channel',
'channel.wechatcom.wechatcomapp_channel',
'channel.wechat_kf.wechat_kf_channel',
'channel.dingtalk.dingtalk_channel',
'channel.wecom_bot.wecom_bot_channel',
'channel.qq.qq_channel',
'channel.telegram.telegram_channel',
'channel.slack.slack_channel',
'channel.discord.discord_channel',
]
# Agent tools and model providers are imported lazily in places; collect their
# submodules so nothing is missed at runtime.
hiddenimports += collect_submodules('agent.tools')
hiddenimports += collect_submodules('models')
hiddenimports += collect_submodules('voice')
hiddenimports += collect_submodules('bridge')
# Plugin framework + plugins. WebChannel -> ChatChannel imports
# `from plugins import *`, and desktop mode loads plugins (in a background
# thread) so command plugins like cow_cli/godcmd (/status, #help) work. Plugin
# modules are imported dynamically by name in scan_plugins(), so list them
# explicitly. The `cli` package is a cow_cli dependency (`from cli import ...`).
hiddenimports += [
'plugins',
'plugins.event',
'plugins.plugin',
'plugins.plugin_manager',
]
hiddenimports += collect_submodules('plugins')
# `cli` powers cow_cli's slash commands (`cow skill install`, `cow status`, …).
# Its command modules are imported lazily inside functions, so static analysis
# misses them. collect_submodules('cli') alone proved unreliable (a build can
# end up with `cli` but not `cli.commands`), so list the command modules
# explicitly AND ship the package as data (see datas) as a belt-and-suspenders.
hiddenimports += collect_submodules('cli')
hiddenimports += [
'cli',
'cli.cli',
'cli.utils',
'cli.commands',
'cli.commands.skill',
'cli.commands.process',
'cli.commands.context',
'cli.commands.install',
'cli.commands.knowledge',
]
# Third-party SDKs that use lazy/conditional imports internally.
hiddenimports += collect_submodules('dashscope')
hiddenimports += [
'tiktoken_ext',
'tiktoken_ext.openai_public',
]
# Document parsing libs. The read / web_fetch tools import these lazily inside
# functions (e.g. `from pypdf import PdfReader`), so PyInstaller's static
# analysis misses them and they'd be dropped from the bundle — leaving the
# desktop client unable to read PDF/Word/Excel/PPT. List them explicitly.
hiddenimports += [
'pypdf',
'docx', # python-docx
'pptx', # python-pptx
'openpyxl',
]
hiddenimports += collect_submodules('pypdf')
hiddenimports += collect_submodules('docx')
hiddenimports += collect_submodules('pptx')
hiddenimports += collect_submodules('openpyxl')
# --- Data files -----------------------------------------------------------
# Runtime-read files/dirs that must travel with the executable. Paths are
# (source, dest_dir_in_bundle).
datas = [
(rp('config-template.json'), '.'),
(rp('skills'), 'skills'),
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
# (it doesn't rely solely on imports), so ship the package directory too.
(rp('plugins'), 'plugins'),
# Ship the `cli` package as loose files too: onedir adds _internal to
# sys.path, so `import cli.commands.*` resolves even if PyInstaller's
# submodule collection misses the lazily-imported command modules.
(rp('cli'), 'cli'),
# Web console served on the backend port: ship chat.html plus its static
# assets (~1.9MB) so the browser-based console works as a debug/fallback
# entry alongside the Electron UI.
(rp('channel', 'web', 'chat.html'), 'channel/web'),
(rp('channel', 'web', 'static'), 'channel/web/static'),
]
# Some libraries (tiktoken encodings, etc.) ship data files.
datas += collect_data_files('tiktoken_ext', include_py_files=False)
# python-docx / python-pptx bundle template files (default.docx / default.pptx,
# content-type XML) inside their packages; they're loaded at import/parse time,
# so ship them or document parsing fails in the frozen build.
datas += collect_data_files('docx')
datas += collect_data_files('pptx')
# --- Excludes -------------------------------------------------------------
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
# mode), tests/docs, and dev-only packages.
excludes = [
'lark_oapi', # Feishu — ~116MB, excluded from desktop build
'tests',
'pip',
'wheel',
'pytest',
'playwright', # browser tool is opt-in, not bundled
]
block_cipher = None
a = Analysis(
[rp('app.py')],
pathex=[ROOT],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='cowagent-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name='cowagent-backend',
)

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron needs JIT and writable/executable memory for V8. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- PyInstaller backend loads many unsigned/third-party dylibs. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- Allow spawning the bundled backend and other child processes. -->
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

147
desktop/build/notarize-dmg.sh Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
#
# STAGE 2 of the decoupled release pipeline: notarize signed dmgs locally.
#
# CI (stage 1) already produced code-signed, hardened-runtime dmgs and mirrored
# them to R2 as unpublished. Apple's notary service keeps this large PyInstaller
# bundle "In Progress" for hours, so we notarize here — off the CI clock — and
# staple the ticket straight onto the dmg (users download it ready-to-run).
#
# What it does for each dmg passed on the command line:
# 1. submit the dmg to the notary service ONCE (--no-wait) and remember the id,
# 2. poll that SAME id until Accepted/Invalid; network errors are ignored and
# retried, and it NEVER resubmits (avoids piling up duplicate submissions),
# 3. staple the ticket onto the dmg,
# 4. (optional) re-upload the stapled dmg to R2, overwriting the unpublished
# copy, so the CDN serves the notarized bytes.
#
# Auth: uses a stored keychain profile (default: cow-notary). Create it once via
# xcrun notarytool store-credentials cow-notary \
# --apple-id <id> --team-id <team> --password <app-specific-password>
#
# Usage:
# # notarize + staple only (no upload):
# desktop/build/notarize-dmg.sh path/to/CowAgent-1.2.3-arm64.dmg [more.dmg ...]
#
# # notarize + staple + re-upload to R2 (needs wrangler + Cloudflare creds):
# VER=1.2.3 UPLOAD=1 desktop/build/notarize-dmg.sh *.dmg
#
# Env:
# PROFILE keychain profile name (default: cow-notary)
# UPLOAD set to 1 to re-upload stapled dmgs to R2
# VER version string for the R2 key desktop/v${VER}/<file> (required if UPLOAD=1)
# R2_BUCKET R2 bucket (default: cow-skills)
# POLL_SECONDS status poll interval (default: 60)
# MAX_WAIT_MINUTES give up polling after this long (default: 720 = 12h)
#
set -euo pipefail
PROFILE="${PROFILE:-cow-notary}"
R2_BUCKET="${R2_BUCKET:-cow-skills}"
POLL_SECONDS="${POLL_SECONDS:-60}"
MAX_WAIT_MINUTES="${MAX_WAIT_MINUTES:-720}"
if [ "$#" -eq 0 ]; then
echo "usage: $0 <dmg> [dmg ...]" >&2
echo " set UPLOAD=1 and VER=<version> to also re-upload stapled dmgs to R2" >&2
exit 2
fi
if [ "${UPLOAD:-0}" = "1" ] && [ -z "${VER:-}" ]; then
echo "error: UPLOAD=1 requires VER=<version> (used for the R2 key)" >&2
exit 2
fi
log() { echo "[notarize-dmg] $*"; }
notarize_one() {
local dmg="$1"
if [ ! -f "$dmg" ]; then
log "SKIP: not a file: $dmg"
return 1
fi
# If it's already stapled (e.g. re-run), skip straight to (optional) upload.
if xcrun stapler validate "$dmg" >/dev/null 2>&1; then
log "$dmg already stapled — skipping notarization."
else
log "submitting $dmg (no-wait)..."
local submit_out submission_id
submit_out="$(xcrun notarytool submit "$dmg" \
--keychain-profile "$PROFILE" --no-wait --output-format json)"
submission_id="$(echo "$submit_out" | /usr/bin/plutil -extract id raw - 2>/dev/null || true)"
if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then
# Fallback parse without plutil (json is single-line).
submission_id="$(echo "$submit_out" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')"
fi
if [ -z "$submission_id" ]; then
log "ERROR: could not parse submission id from:"
echo "$submit_out" >&2
return 1
fi
log "submission id: $submission_id (polling same id, never resubmitting)"
local deadline status ts
deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 ))
while :; do
status=""
status="$(xcrun notarytool info "$submission_id" \
--keychain-profile "$PROFILE" --output-format json 2>/dev/null \
| sed -n 's/.*"status":"\([^"]*\)".*/\1/p' || true)"
ts="$(date +%H:%M:%S)"
log "[$ts] status: ${status:-<query failed, retrying>}"
case "$status" in
Accepted) break ;;
Invalid|Rejected)
log "notarization $status — fetching log:"
xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true
return 1
;;
esac
if [ "$(date +%s)" -ge "$deadline" ]; then
log "ERROR: not finished after ${MAX_WAIT_MINUTES} min (id: $submission_id)."
log "NOT resubmitting. Check later: xcrun notarytool info $submission_id --keychain-profile $PROFILE"
return 1
fi
sleep "$POLL_SECONDS"
done
log "Accepted; stapling ticket to $dmg"
local staple_try=1
until xcrun stapler staple "$dmg"; do
if [ "$staple_try" -ge 3 ]; then
log "ERROR: stapling failed after 3 attempts"
return 1
fi
log "staple failed, retrying in 15s..."
sleep 15
staple_try=$((staple_try + 1))
done
xcrun stapler validate "$dmg"
log "$dmg notarized + stapled."
fi
if [ "${UPLOAD:-0}" = "1" ]; then
local base key
base="$(basename "$dmg")"
key="desktop/v${VER}/${base}"
log "re-uploading stapled dmg -> r2://${R2_BUCKET}/${key}"
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
--file "$dmg" --remote
log "uploaded $base"
fi
}
rc=0
for dmg in "$@"; do
echo "======================================================================"
notarize_one "$dmg" || rc=1
done
if [ "$rc" -ne 0 ]; then
log "one or more dmgs failed — see output above."
exit 1
fi
log "all done."

View File

@@ -0,0 +1,52 @@
# Desktop backend dependencies (slimmed down from the full requirements).
#
# Goal: keep the package light. The desktop client only needs the web channel
# (which Electron talks to) plus the agent core; the remaining IM channels are
# cheap (~27MB total) so we keep them, but Feishu's `lark-oapi` (~116MB) is
# dropped — it is by far the heaviest dependency and not needed for a C-end
# desktop app. Feishu is hidden in desktop mode (see COW_DESKTOP in app.py).
# ---- core ----
numpy>=1.21
aiohttp>=3.8.6,<3.10
requests>=2.28.2
chardet>=5.1.0
Pillow
python-dotenv>=1.0.0
PyYAML>=6.0
croniter>=2.0.0
click>=8.0
qrcode
json-repair
# ---- web framework (web channel) ----
# web.py 0.62 fails to build on Python 3.13+ (cgi removed); use the GitHub fix.
web.py; python_version < "3.13"
web.py @ git+https://github.com/webpy/webpy.git ; python_version >= "3.13"
legacy-cgi; python_version >= "3.13"
# ---- AI model SDKs ----
zai-sdk
dashscope
tenacity # used by some dashscope submodules (retry logic)
google-generativeai
tiktoken>=0.3.2
# ---- voice (TTS/ASR) — kept per product decision ----
pydub>=0.25.1
gTTS>=2.3.1
# ---- document parsing (web_fetch / knowledge) ----
pypdf
python-docx
openpyxl
python-pptx
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
wechatpy
pycryptodome
dingtalk_stream
websocket-client>=1.4.0
python-telegram-bot
slack_bolt
discord.py

View File

@@ -0,0 +1,83 @@
/**
* Dynamic electron-builder config.
*
* We keep the base config in package.json's "build" field and extend it here
* only to populate `mac.binaries` — the list of extra Mach-O files that must
* be signed with hardened runtime + entitlements.
*
* Why this is needed:
* The Python backend is a PyInstaller onedir bundle shipped via extraResources
* into Contents/Resources/backend/. electron-builder only hands the top-level
* `.app` to codesign, which does NOT deep-sign the ~180 nested .so/.dylib
* files under Resources/. Left unsigned (or without hardened runtime), Apple
* notarization rejects the whole app.
*
* `mac.binaries` is the officially supported way to sign extra binaries: they
* are signed inside electron-builder's own signing pass, AFTER it has created
* the temporary keychain and imported the Developer ID cert (from CSC_LINK).
* A previous afterPack approach failed because afterPack runs BEFORE that
* keychain exists, so `codesign` couldn't find the identity.
*
* Paths are resolved relative to the `.app` at signing time. We enumerate the
* pre-build backend source dir (build/dist/cowagent-backend, produced by
* PyInstaller before packaging) — its layout mirrors the in-app copy — and map
* each Mach-O to its in-app relative path.
*/
const { execFileSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const config = require('./package.json').build
// PyInstaller output that gets copied into the app at
// Contents/Resources/backend/cowagent-backend (see extraResources).
const backendSrc = path.join(__dirname, 'build', 'dist', 'cowagent-backend')
const inAppPrefix = path.join('Contents', 'Resources', 'backend', 'cowagent-backend')
function isMachO(file) {
try {
return execFileSync('file', ['-b', file], { encoding: 'utf8' }).includes('Mach-O')
} catch {
return false
}
}
function collectBackendBinaries() {
if (!fs.existsSync(backendSrc)) {
console.warn(`[electron-builder.js] backend not found at ${backendSrc}; mac.binaries left empty`)
return []
}
const rels = []
const walk = (dir) => {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name)
const st = fs.lstatSync(full)
if (st.isSymbolicLink()) continue
if (st.isDirectory()) {
walk(full)
continue
}
if (isMachO(full)) {
// Map source path -> in-app relative path (resolved against the .app).
const rel = path.relative(backendSrc, full)
rels.push(path.join(inAppPrefix, rel))
}
}
}
walk(backendSrc)
return rels
}
if (process.platform === 'darwin') {
const binaries = collectBackendBinaries()
console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`)
// Sign the backend binaries here, but do NOT notarize in CI: Apple's notary
// service routinely keeps this large PyInstaller bundle "In Progress" for
// hours, which no CI job can afford to block on. Notarization is decoupled
// into a manual local step (build/notarize-dmg.sh) run after the CI produces
// the signed dmg. The dmg is code-signed and hardened-runtime enabled here,
// so it only needs the notarization ticket stapled afterwards.
config.mac = { ...config.mac, binaries, notarize: false }
}
module.exports = config

8300
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

117
desktop/package.json Normal file
View File

@@ -0,0 +1,117 @@
{
"name": "cowagent-desktop",
"version": "2.1.3",
"description": "CowAgent Desktop Client - AI Agent on your desktop",
"main": "dist/main/index.js",
"author": "CowAgent",
"license": "MIT",
"scripts": {
"dev": "npm run build && electron .",
"dev:hot": "concurrently \"npm run dev:renderer\" \"sleep 2 && npm run dev:main\"",
"dev:main": "tsc -p tsconfig.main.json && electron .",
"dev:renderer": "vite",
"build": "npm run build:renderer && npm run build:main",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build",
"dist": "npm run build && electron-builder",
"dist:mac": "npm run build && electron-builder --mac",
"dist:win": "npm run build && electron-builder --win"
},
"dependencies": {
"electron-updater": "^6.8.9",
"highlight.js": "^11.11.1",
"lucide-react": "^1.21.0",
"markdown-it": "^14.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2",
"vite": "^6.0.3"
},
"build": {
"appId": "com.cowagent.desktop",
"productName": "CowAgent",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"resources/**/*"
],
"extraResources": [
{
"from": "build/dist/cowagent-backend",
"to": "backend/cowagent-backend"
},
{
"from": "resources",
"to": ".",
"filter": [
"icon.png"
]
}
],
"mac": {
"category": "public.app-category.productivity",
"icon": "resources/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"notarize": false,
"artifactName": "${productName}-${version}-${arch}.${ext}",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": [
"arm64",
"x64"
]
}
]
},
"win": {
"icon": "resources/icon.ico",
"artifactName": "${productName}-Setup-${version}-${arch}.${ext}",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"publish": {
"provider": "generic",
"url": "https://cowagent.ai/update/"
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
desktop/resources/icon.icns Normal file

Binary file not shown.

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
desktop/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

354
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,354 @@
import { app, BrowserWindow, shell, ipcMain, dialog, nativeImage } from 'electron'
import path from 'path'
import fs from 'fs'
import http from 'http'
import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
// where the default Electron binary would otherwise report "Electron".
app.setName('CowAgent')
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed.
let isQuitting = false
const isDev = !app.isPackaged
const VITE_DEV_PORTS = [5173, 5174, 5175, 5176]
function probePort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://localhost:${port}`, (res) => {
resolve(res.statusCode !== undefined)
})
req.on('error', () => resolve(false))
req.setTimeout(500, () => { req.destroy(); resolve(false) })
})
}
async function findViteDevServer(): Promise<string | null> {
for (const port of VITE_DEV_PORTS) {
if (await probePort(port)) {
return `http://localhost:${port}`
}
}
return null
}
function getIconPath(ext: string = 'png'): string | undefined {
const iconFile = `icon.${ext}`
const iconPath = isDev
? path.resolve(__dirname, '../../resources', iconFile)
: path.join(process.resourcesPath, iconFile)
if (fs.existsSync(iconPath)) return iconPath
return undefined
}
const isMac = process.platform === 'darwin'
const isWin = process.platform === 'win32'
// Persisted window bounds
const windowStateFile = () => path.join(app.getPath('userData'), 'window-state.json')
function loadWindowState(): { width: number; height: number; x?: number; y?: number } {
try {
const raw = fs.readFileSync(windowStateFile(), 'utf-8')
const s = JSON.parse(raw)
if (typeof s.width === 'number' && typeof s.height === 'number') return s
} catch {
/* first run or unreadable */
}
return { width: 1280, height: 800 }
}
function saveWindowState() {
if (!mainWindow || mainWindow.isDestroyed()) return
if (mainWindow.isMinimized() || mainWindow.isFullScreen()) return
const b = mainWindow.getBounds()
try {
fs.writeFileSync(windowStateFile(), JSON.stringify(b))
} catch {
/* ignore */
}
}
function createWindow() {
const state = loadWindowState()
mainWindow = new BrowserWindow({
width: state.width,
height: state.height,
x: state.x,
y: state.y,
minWidth: 900,
minHeight: 600,
// macOS: native traffic lights inset into our custom titlebar.
// Windows: fully frameless; we render custom window controls in-app.
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
trafficLightPosition: isMac ? { x: 14, y: 16 } : undefined,
frame: isMac ? undefined : false,
backgroundColor: '#0e0e10',
icon: getIconPath(),
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
const persist = () => saveWindowState()
mainWindow.on('resize', persist)
mainWindow.on('move', persist)
mainWindow.on('maximize', emitMaximizeState)
mainWindow.on('unmaximize', emitMaximizeState)
const rendererHtml = path.join(__dirname, '../renderer/index.html')
if (isDev) {
findViteDevServer().then((devUrl) => {
if (devUrl) {
console.log(`[Electron] Loading Vite dev server: ${devUrl}`)
mainWindow?.loadURL(devUrl)
mainWindow?.webContents.openDevTools()
} else if (fs.existsSync(rendererHtml)) {
console.log('[Electron] Vite dev server not found, loading built files')
mainWindow?.loadFile(rendererHtml)
} else {
console.error('[Electron] No renderer available. Run "npm run build:renderer" first.')
}
})
} else {
mainWindow.loadFile(rendererHtml)
}
// Surface renderer-side console output and load failures to the main-process
// stdout. Without this, "stuck on initializing" hangs are invisible from the
// terminal because all renderer logs stay in the (closed) devtools.
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
})
mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.error(`[renderer] did-fail-load ${code} ${desc} ${url}`)
})
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
// Close-to-tray: hide the window instead of destroying it, so the tray's
// "Show" can bring it back. Only a real Quit (menu/tray/Cmd+Q) destroys it.
mainWindow.on('close', (e) => {
if (!isQuitting) {
e.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
}
function getBackendPath(): string {
if (isDev) {
return path.resolve(__dirname, '../../..')
}
return path.join(process.resourcesPath, 'backend')
}
async function startBackend() {
const backendPath = getBackendPath()
pythonBackend = new PythonBackend(backendPath)
pythonBackend.on('ready', (port: number) => {
console.log(`[backend] ready on port ${port}`)
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
})
pythonBackend.on('error', (error: string) => {
// Mirror to the main-process stdout too: otherwise backend startup errors
// are only visible in the renderer devtools, making `npm run dev` hangs
// impossible to diagnose from the terminal.
console.error(`[backend] error: ${error}`)
mainWindow?.webContents.send('backend-status', { status: 'error', error })
})
pythonBackend.on('log', (line: string) => {
console.log(`[backend] ${line}`)
mainWindow?.webContents.send('backend-log', line)
})
await pythonBackend.start()
}
function setupIPC() {
ipcMain.handle('get-backend-port', () => {
return pythonBackend?.getPort() ?? null
})
ipcMain.handle('get-backend-status', () => {
return pythonBackend?.getStatus() ?? 'stopped'
})
ipcMain.handle('restart-backend', async () => {
await pythonBackend?.restart()
return true
})
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('select-file', async (_event, filters?: Electron.FileFilter[]) => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
})
return result.canceled ? null : result.filePaths[0]
})
// Open a local file with the OS default app; falls back to revealing it in
// the file manager when no handler exists. Returns '' on success.
ipcMain.handle('open-path', async (_event, targetPath: string) => {
if (!targetPath) return 'empty path'
const err = await shell.openPath(targetPath)
if (err) shell.showItemInFolder(targetPath)
return err
})
// Custom window controls (used by Windows frameless titlebar)
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
ipcMain.handle('window-maximize', () => {
if (!mainWindow) return false
if (mainWindow.isMaximized()) mainWindow.unmaximize()
else mainWindow.maximize()
return mainWindow.isMaximized()
})
ipcMain.handle('window-close', () => mainWindow?.close())
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
// Current app version, shown in the NavRail footer.
ipcMain.handle('get-app-version', () => app.getVersion())
// Auto-update controls (renderer-driven: check, then opt-in download/install).
// The renderer passes its current UI language so downloads can be routed to
// the China CDN mirror (zh) or R2 (others).
ipcMain.handle('update-check', (_event, lang?: string) => {
setUpdateLanguage(lang)
checkForUpdates()
})
ipcMain.handle('update-download', (_event, lang?: string) => {
setUpdateLanguage(lang)
startDownload()
})
ipcMain.handle('update-install', () => {
// Let the window actually close so the app can fully quit — otherwise the
// close-to-tray handler preventDefault()s it, the process stays alive, and
// Squirrel.Mac can't swap the app bundle (the update silently no-ops and
// relaunching still shows the old version).
isQuitting = true
quitAndInstall()
})
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
// to pick a sensible default UI language on first run before any paint.
ipcMain.on('get-system-locale', (event) => {
event.returnValue = app.getLocale() || app.getSystemLocale?.() || ''
})
}
function emitMaximizeState() {
const max = mainWindow?.isMaximized() ?? false
mainWindow?.webContents.send('window-maximize-changed', max)
}
// Single-instance lock: focus the existing window instead of opening a second app.
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
})
}
app.whenReady().then(async () => {
// Set Dock icon on macOS (PNG is most reliable for nativeImage)
if (process.platform === 'darwin') {
const pngPath = getIconPath('png')
if (pngPath) {
const icon = nativeImage.createFromPath(pngPath)
if (!icon.isEmpty()) {
app.dock.setIcon(icon)
console.log('[Electron] Dock icon set:', pngPath)
} else {
console.warn('[Electron] Dock icon loaded but empty:', pngPath)
}
} else {
console.warn('[Electron] Dock icon not found in resources/')
}
}
setupIPC()
createWindow()
buildAppMenu(() => mainWindow)
// No menu-bar tray on macOS — the Dock + window controls are enough there.
// Keep the tray on Windows/Linux where minimizing to a tray icon is expected.
if (!isMac) {
createTray({
getWindow: () => mainWindow,
iconPath: getIconPath('png'),
onQuit: () => {
isQuitting = true
app.quit()
},
})
}
await startBackend()
// Wire auto-update: a first silent check a few seconds after launch (so it
// doesn't compete with backend startup), then poll every 4 hours so a
// long-running window still surfaces new releases. autoDownload is off, so a
// found update only lights the badge + opens the panel for the user to opt in.
initUpdater(() => mainWindow)
setTimeout(() => checkForUpdates(), 5000)
const UPDATE_POLL_MS = 4 * 60 * 60 * 1000
setInterval(() => checkForUpdates(), UPDATE_POLL_MS)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else {
mainWindow?.show()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
isQuitting = true
saveWindowState()
destroyTray()
pythonBackend?.stop()
})

112
desktop/src/main/menu.ts Normal file
View File

@@ -0,0 +1,112 @@
import { app, Menu, BrowserWindow, shell } from 'electron'
import type { MenuItemConstructorOptions } from 'electron'
const isMac = process.platform === 'darwin'
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
const DOCS_URL = 'https://docs.cowagent.ai'
// Send a menu-triggered action to the renderer (e.g. new chat, open settings).
function emit(win: BrowserWindow | null, action: string) {
win?.webContents.send('menu-action', action)
}
/**
* Build a minimal, purpose-built application menu. We intentionally drop most of
* Electron's verbose defaults and keep only items that are actually useful for
* this app, plus the shortcuts users expect (New Chat, Settings, Reload, etc).
*/
export function buildAppMenu(getWindow: () => BrowserWindow | null) {
const win = () => getWindow()
const appMenu: MenuItemConstructorOptions[] = isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ label: 'Settings…', accelerator: 'Cmd+,', click: () => emit(win(), 'open-settings') },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []
const fileMenu: MenuItemConstructorOptions = {
label: 'File',
submenu: [
{ label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: () => emit(win(), 'new-chat') },
...(!isMac
? ([
{ label: 'Settings', accelerator: 'Ctrl+,', click: () => emit(win(), 'open-settings') },
{ type: 'separator' },
{ role: 'quit' },
] as MenuItemConstructorOptions[])
: []),
],
}
const editMenu: MenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
}
const viewMenu: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
}
const windowMenu: MenuItemConstructorOptions = {
label: 'Window',
submenu: [
{ role: 'minimize' },
...(isMac ? ([{ role: 'zoom' }] as MenuItemConstructorOptions[]) : []),
{ type: 'separator' },
// Explicit Close so Cmd/Ctrl+W reliably triggers our close-to-tray hide.
{ label: 'Close Window', accelerator: 'CmdOrCtrl+W', click: () => win()?.close() },
],
}
const helpMenu: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{ label: 'View Logs', click: () => emit(win(), 'view-logs') },
{ type: 'separator' },
{ label: 'Documentation', click: () => shell.openExternal(DOCS_URL) },
{ label: 'Skill Hub', click: () => shell.openExternal(SKILL_HUB_URL) },
],
}
const template: MenuItemConstructorOptions[] = [
...appMenu,
fileMenu,
editMenu,
viewMenu,
windowMenu,
helpMenu,
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}

View File

@@ -0,0 +1,67 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
getBackendStatus: () => ipcRenderer.invoke('get-backend-status'),
restartBackend: () => ipcRenderer.invoke('restart-backend'),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
openPath: (targetPath: string) => ipcRenderer.invoke('open-path', targetPath) as Promise<string>,
// Each listener registrar returns an unsubscribe fn so renderers can clean
// up on unmount / effect re-run and avoid accumulating duplicate handlers.
onBackendStatus: (callback: (data: { status: string; port?: number; error?: string }) => void) => {
const handler = (_event: unknown, data: { status: string; port?: number; error?: string }) => callback(data)
ipcRenderer.on('backend-status', handler)
return () => ipcRenderer.removeListener('backend-status', handler)
},
onBackendLog: (callback: (line: string) => void) => {
const handler = (_event: unknown, line: string) => callback(line)
ipcRenderer.on('backend-log', handler)
return () => ipcRenderer.removeListener('backend-log', handler)
},
// Window controls (custom titlebar on Windows)
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
onMaximizeChange: (callback: (maximized: boolean) => void) => {
const handler = (_event: unknown, max: boolean) => callback(max)
ipcRenderer.on('window-maximize-changed', handler)
return () => ipcRenderer.removeListener('window-maximize-changed', handler)
},
// App menu / shortcut actions forwarded from the main process.
onMenuAction: (callback: (action: string) => void) => {
const handler = (_event: unknown, action: string) => callback(action)
ipcRenderer.on('menu-action', handler)
return () => ipcRenderer.removeListener('menu-action', handler)
},
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
// Auto-update: trigger checks/download/install and subscribe to status. The
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
downloadUpdate: (lang?: string) => ipcRenderer.invoke('update-download', lang),
installUpdate: () => ipcRenderer.invoke('update-install'),
onUpdateStatus: (callback: (status: unknown) => void) => {
const handler = (_event: unknown, status: unknown) => callback(status)
ipcRenderer.on('update-status', handler)
return () => ipcRenderer.removeListener('update-status', handler)
},
platform: process.platform,
// OS UI language (e.g. "zh-CN"), read synchronously so the renderer can pick
// a default language on first run. Falls back to '' if unavailable.
systemLocale: (() => {
try {
return ipcRenderer.sendSync('get-system-locale') as string
} catch {
return ''
}
})(),
})

View File

@@ -0,0 +1,378 @@
import { ChildProcess, spawn } from 'child_process'
import { EventEmitter } from 'events'
import path from 'path'
import os from 'os'
import fs from 'fs'
import http from 'http'
import net from 'net'
// Writable data dir for the packaged app (config.json, run.log, user data).
// Lives in the user's home so it survives app updates and avoids writing into
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
// Fixed port for the desktop backend. Deliberately not 9899 (the web console's
// default) so a source-run `python app.py` never collides with the packaged
// app. This is a SINGLE SOURCE OF TRUTH shared with the renderer (see
// useBackend.ts BACKEND_PORT): the backend is always told to bind exactly here
// via COW_WEB_PORT, and the renderer always talks to exactly here. We do NOT
// fall back to an OS-random port, because the renderer could never guess it —
// instead we proactively free this port before launch (see freePort()).
export const DESKTOP_BACKEND_PORT = 9876
export class PythonBackend extends EventEmitter {
private process: ChildProcess | null = null
private backendPath: string
private port: number = DESKTOP_BACKEND_PORT
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
constructor(backendPath: string) {
super()
this.backendPath = backendPath
}
getPort(): number {
return this.port
}
getStatus(): string {
return this.status
}
/**
* Locate the packaged onedir backend executable shipped with the app.
* Returns null when not present (e.g. during local development), so we can
* fall back to running app.py with a system/venv Python.
*/
private findBundledBackend(): string | null {
const exeName = process.platform === 'win32' ? 'cowagent-backend.exe' : 'cowagent-backend'
const candidates = [
path.join(this.backendPath, 'cowagent-backend', exeName),
path.join(this.backendPath, exeName),
]
for (const p of candidates) {
if (fs.existsSync(p)) {
return p
}
}
return null
}
private findPython(): string {
const venvPaths = [
path.join(this.backendPath, '.venv', 'bin', 'python'),
path.join(this.backendPath, '.venv', 'Scripts', 'python.exe'),
path.join(this.backendPath, 'venv', 'bin', 'python'),
path.join(this.backendPath, 'venv', 'Scripts', 'python.exe'),
]
for (const p of venvPaths) {
if (fs.existsSync(p)) {
return p
}
}
return process.platform === 'win32' ? 'python' : 'python3'
}
/**
* Read an explicit `web_port` from config.json, if the user pinned one. The
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
* repo path. Returns null when unset, so the caller can auto-pick a free port
* instead of fighting over a fixed one.
*/
private readConfiguredPort(dataDir: string): number | null {
try {
const configPath = path.join(dataDir, 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const p = Number(config.web_port)
if (Number.isInteger(p) && p > 0 && p < 65536) {
return p
}
}
} catch {
// ignore — fall through to auto-selection
}
return null
}
/**
* Resolve the port to bind. The whole point is determinism: the renderer must
* be able to reach the backend WITHOUT guessing, so we use exactly one fixed
* port (DESKTOP_BACKEND_PORT) unless the user explicitly pinned a web_port.
* We never auto-roll to a random port — instead start() proactively frees the
* fixed port. The returned value is the single source of truth handed to both
* the backend (COW_WEB_PORT) and the renderer (getBackendPort IPC).
*/
private resolvePort(dataDir: string): number {
const pinned = this.readConfiguredPort(dataDir)
return pinned !== null ? pinned : DESKTOP_BACKEND_PORT
}
/** True if we can bind 127.0.0.1:port right now (i.e. it's free). */
private isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const tester = net
.createServer()
.once('error', () => resolve(false))
.once('listening', () => {
tester.close(() => resolve(true))
})
.listen(port, '127.0.0.1')
})
}
/**
* Make sure our fixed port is usable before launch by killing whatever is
* holding it (almost always a stale backend from a previous run that didn't
* shut down cleanly). We only ever target a process actually listening on
* 127.0.0.1:<port>, so we won't touch unrelated apps. Best-effort: if we
* can't free it we still try to bind and let the backend surface EADDRINUSE.
*/
private async freePort(port: number): Promise<void> {
if (await this.isPortFree(port)) {
return
}
this.emit('log', `Port ${port} is busy — clearing stale process before launch`)
const pids = await this.findListenerPids(port)
for (const pid of pids) {
// Never signal ourselves (Electron could, in theory, be the listener).
if (pid === process.pid) continue
try {
process.kill(pid, 'SIGTERM')
} catch {
// already gone / no permission — ignore
}
}
// Give the OS a beat to release the socket, then force-kill leftovers.
await new Promise((r) => setTimeout(r, 600))
if (!(await this.isPortFree(port))) {
for (const pid of await this.findListenerPids(port)) {
if (pid === process.pid) continue
try {
process.kill(pid, 'SIGKILL')
} catch {
// ignore
}
}
await new Promise((r) => setTimeout(r, 400))
}
}
/** PIDs listening on 127.0.0.1:<port>, via lsof (POSIX) / netstat (Windows). */
private findListenerPids(port: number): Promise<number[]> {
return new Promise((resolve) => {
const isWin = process.platform === 'win32'
const cmd = isWin ? 'netstat' : 'lsof'
const args = isWin
? ['-ano', '-p', 'tcp']
: ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']
let out = ''
try {
const child = spawn(cmd, args)
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
child.on('error', () => resolve([]))
child.on('close', () => {
const pids = new Set<number>()
if (isWin) {
// Match lines like: TCP 127.0.0.1:9876 ... LISTENING 12345
for (const line of out.split('\n')) {
if (!/LISTENING/i.test(line)) continue
if (!new RegExp(`[:.]${port}\\b`).test(line)) continue
const pid = Number(line.trim().split(/\s+/).pop())
if (Number.isInteger(pid) && pid > 0) pids.add(pid)
}
} else {
for (const tok of out.split(/\s+/)) {
const pid = Number(tok)
if (Number.isInteger(pid) && pid > 0) pids.add(pid)
}
}
resolve([...pids])
})
} catch {
resolve([])
}
})
}
async start(): Promise<void> {
if (this.status === 'ready' || this.status === 'starting') {
return
}
this.status = 'starting'
// Prefer the packaged self-contained backend (production); fall back to
// running app.py with a Python interpreter (local development).
const bundled = this.findBundledBackend()
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
// Always launch our OWN backend (re-entrancy is guarded above by the status
// check, so we never double-spawn for this instance). We don't reuse
// whatever happens to be on the port: that's how the app previously attached
// to a source-run web console and read the wrong config. The port is fixed
// (or the user's pinned web_port) — never random — so the renderer always
// knows it. We then proactively free that port (kill stale listeners)
// before spawning, so a leftover process from a previous run can't block us.
this.port = this.resolvePort(dataDir)
await this.freePort(this.port)
let command: string
let args: string[]
let cwd: string
if (bundled) {
command = bundled
args = []
// Run from the writable data dir (~/.cow), NOT the install dir. When the
// app is installed under Program Files, a non-admin user has no write
// permission to the executable's folder, so any relative-path write
// during startup would crash the backend (works only as admin). The
// bundle reads its read-only resources via sys._MEIPASS, so cwd is free
// to point elsewhere.
try {
fs.mkdirSync(COW_DATA_DIR, { recursive: true })
} catch {
// ignore — get_data_root() also ensures the dir on the Python side
}
cwd = COW_DATA_DIR
this.emit('log', `Starting bundled backend: ${bundled} (cwd=${cwd})`)
} else {
const pythonPath = this.findPython()
const appPath = path.join(this.backendPath, 'app.py')
if (!fs.existsSync(appPath)) {
this.status = 'error'
this.emit('error', `app.py not found at ${appPath}`)
return
}
command = pythonPath
args = [appPath]
cwd = this.backendPath
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
}
this.process = spawn(command, args, {
cwd,
// COW_DESKTOP enables the lighter desktop runtime (no plugins, no MCP).
// COW_DATA_DIR (packaged only) redirects writable data to ~/.cow so the
// app bundle stays read-only; dev runs omit it and keep using the repo.
env: {
...process.env,
PYTHONUNBUFFERED: '1',
COW_DESKTOP: '1',
// The shell owns the port: tell the backend to bind exactly here so the
// two sides can never disagree (and we avoid the 9899 web-console clash).
COW_WEB_PORT: String(this.port),
...(bundled ? { COW_DATA_DIR } : {}),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
this.process.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean)
for (const line of lines) {
this.emit('log', line)
}
})
this.process.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean)
for (const line of lines) {
this.emit('log', line)
}
})
this.process.on('exit', (code) => {
// If the backend dies before it ever became ready, surface an error now
// instead of letting waitForReady spin for the full timeout. A clean exit
// (code 0/null, e.g. our own stop()) just marks stopped.
const wasReady = this.status === 'ready'
this.status = 'stopped'
this.emit('log', `Python process exited with code ${code}`)
if (!wasReady && code !== 0 && code !== null) {
this.status = 'error'
this.emit('error', `Backend exited during startup (code ${code})`)
}
})
this.process.on('error', (err) => {
this.status = 'error'
this.emit('error', `Failed to start Python: ${err.message}`)
})
await this.waitForReady()
}
private waitForReady(): Promise<void> {
return new Promise((resolve) => {
// Wall-clock deadline rather than an attempt counter: if the machine
// sleeps/suspends, the 1s timers stretch out and a counter would give up
// far too early. Time-based bounding tracks real elapsed time instead.
const timeoutMs = 120_000
const startedAt = Date.now()
const check = () => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
if (res.statusCode === 200) {
this.status = 'ready'
this.emit('log', `Backend ready on port ${this.port}`)
this.emit('ready', this.port)
resolve()
} else {
retry()
}
})
req.on('error', () => retry())
req.setTimeout(2000, () => {
req.destroy()
retry()
})
}
const retry = () => {
// Backend already settled: ready (done), or stopped/errored by the exit
// handler (don't keep polling a dead process — the error was emitted).
if (this.status === 'ready' || this.status === 'stopped' || this.status === 'error') {
resolve()
return
}
if (Date.now() - startedAt >= timeoutMs) {
this.status = 'error'
this.emit('error', `Backend failed to start within ${Math.round(timeoutMs / 1000)} seconds`)
resolve()
return
}
setTimeout(check, 1000)
}
setTimeout(check, 2000)
})
}
stop(): void {
const proc = this.process
if (proc) {
proc.kill('SIGTERM')
// Keep a local ref so the SIGKILL fallback can still reach the process
// even after we clear `this.process`; otherwise a stuck backend would
// never be force-killed and leak as a zombie.
setTimeout(() => {
if (!proc.killed) {
proc.kill('SIGKILL')
}
}, 5000)
this.process = null
}
this.status = 'stopped'
}
async restart(): Promise<void> {
this.stop()
await new Promise((resolve) => setTimeout(resolve, 2000))
await this.start()
}
}

59
desktop/src/main/tray.ts Normal file
View File

@@ -0,0 +1,59 @@
import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron'
let tray: Tray | null = null
interface TrayDeps {
getWindow: () => BrowserWindow | null
// Colored icon used on Windows/Linux trays.
iconPath?: string
// Called when the user picks "Quit" so the app can fully exit.
onQuit: () => void
}
// Build a system tray icon with a minimal menu (Windows/Linux only — macOS
// uses the Dock instead). Lets users restore the window after closing it to the
// background and start a new chat quickly.
export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null {
if (tray) return tray
if (!iconPath) return null
let image = nativeImage.createFromPath(iconPath)
if (image.isEmpty()) return null
// Tray icons render small; resize to avoid an oversized image on some platforms.
image = image.resize({ width: 18, height: 18 })
tray = new Tray(image)
tray.setToolTip(app.name)
const showWindow = () => {
const win = getWindow()
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show CowAgent', click: showWindow },
{
label: 'New Chat',
click: () => {
showWindow()
getWindow()?.webContents.send('menu-action', 'new-chat')
},
},
{ type: 'separator' },
{ label: 'Quit', click: onQuit },
])
tray.setContextMenu(contextMenu)
// Single click restores the window (common Windows/Linux behavior).
tray.on('click', showWindow)
return tray
}
export function destroyTray() {
tray?.destroy()
tray = null
}

245
desktop/src/main/updater.ts Normal file
View File

@@ -0,0 +1,245 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs'
import path from 'path'
// electron-updater is CommonJS: its members live on module.exports, with no
// meaningful default export. Under module=commonjs + esModuleInterop, a named
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
// whereas `import pkg from 'electron-updater'` yields undefined.
import { autoUpdater } from 'electron-updater'
// Status payloads pushed to the renderer over the 'update-status' channel.
// The renderer drives the NavRail badge + update panel from these.
export type UpdateStatus =
| { state: 'checking' }
| { state: 'available'; version: string; notes?: string }
| { state: 'not-available' }
| { state: 'downloading'; percent: number }
| { state: 'downloaded'; version: string }
| { state: 'error'; message: string }
let getWindow: () => BrowserWindow | null = () => null
// The update feed. Both entries hit the same Pages Function
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
// downloads to the China CDN mirror instead of R2. The feed metadata is
// identical either way, so we can freely switch the feed URL between attempts
// to fall back from one download origin to the other.
const FEED_BASE = 'https://cowagent.ai/update/'
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
// Which origin the current session prefers, derived from the app UI language
// (zh -> China mirror). Downloads that fail on the preferred origin retry once
// on the other one before surfacing an error.
let preferChina = false
// Guard so a single download only ever falls back once (avoids ping-pong).
let downloadFellBack = false
function applyFeedUrl(): void {
const url = feedUrlFor(preferChina)
try {
autoUpdater.setFeedURL({ provider: 'generic', url })
log(`feed url set: ${url} (preferChina=${preferChina})`)
} catch (err) {
log(`feed url set failed: ${(err as Error)?.message || String(err)}`)
}
}
// Called from the check/download IPC with the renderer's current UI language.
export function setUpdateLanguage(lang: string | undefined): void {
const china = (lang || '').toLowerCase().startsWith('zh')
if (china !== preferChina) {
preferChina = china
if (app.isPackaged) applyFeedUrl()
}
}
// Persist update logs to a file so a user hitting a silent "spinner never
// resolves" can just send us userData/logs/updater.log. We can't rely on a
// logging dep, so this is a tiny append-only writer, plus console for the
// in-app log view / terminal.
let logFile: string | null = null
function initLogFile() {
try {
const dir = path.join(app.getPath('userData'), 'logs')
fs.mkdirSync(dir, { recursive: true })
logFile = path.join(dir, 'updater.log')
} catch {
logFile = null
}
}
function log(...parts: unknown[]) {
const line = `[${new Date().toISOString()}] [updater] ${parts
.map((p) => (typeof p === 'string' ? p : safeStringify(p)))
.join(' ')}`
// Console: shows up in the terminal (dev) and the packaged app's stdout.
console.log(line)
if (logFile) {
try {
fs.appendFileSync(logFile, line + '\n')
} catch {
// ignore disk errors — logging must never break the updater
}
}
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
function send(status: UpdateStatus) {
getWindow()?.webContents.send('update-status', status)
}
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
getWindow = windowGetter
initLogFile()
log(`init: appVersion=${app.getVersion()} packaged=${app.isPackaged} logFile=${logFile ?? '<none>'}`)
// In dev (not packaged) there's no update feed; skip wiring entirely so
// electron-updater doesn't throw on the missing app-update.yml.
if (!app.isPackaged) {
log('not packaged — updater wiring skipped')
return
}
// User-driven flow: we surface "available" and let the user opt in to the
// download, rather than pulling bytes silently in the background.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// The desktop channel ships pre-release-tagged builds (e.g. 0.0.8-test), so a
// current version like 0.0.7-test must be allowed to compare against, and be
// offered, other pre-release versions. Without this electron-updater's semver
// compare can silently skip pre-releases and neither "available" nor
// "not-available" fires — the UI just spins forever.
autoUpdater.allowPrerelease = true
autoUpdater.allowDowngrade = false
// Point at the preferred origin up front (defaults to R2; switched to the CN
// mirror once the renderer reports a zh UI language via setUpdateLanguage).
applyFeedUrl()
// Route electron-updater's own internal logging to our file too, so we
// capture the feed URL, parsed versions and any stack traces it logs.
autoUpdater.logger = {
info: (m: unknown) => log('eu-info:', m),
warn: (m: unknown) => log('eu-warn:', m),
error: (m: unknown) => log('eu-error:', m),
debug: (m: unknown) => log('eu-debug:', m),
} as unknown as typeof autoUpdater.logger
autoUpdater.on('checking-for-update', () => {
log(`checking-for-update: current=${app.getVersion()}`)
send({ state: 'checking' })
})
autoUpdater.on('update-available', (info) => {
log(`update-available: current=${app.getVersion()} remote=${info.version} -> update needed`)
send({
state: 'available',
version: info.version,
notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
})
})
autoUpdater.on('update-not-available', (info) => {
log(`update-not-available: current=${app.getVersion()} remote=${info?.version ?? '<unknown>'} -> up to date`)
send({ state: 'not-available' })
})
autoUpdater.on('download-progress', (p) => {
log(`download-progress: ${Math.round(p.percent)}% (${p.transferred}/${p.total} bytes, ${Math.round(p.bytesPerSecond / 1024)} KB/s)`)
send({ state: 'downloading', percent: Math.round(p.percent) })
})
autoUpdater.on('update-downloaded', (info) => {
log(`update-downloaded: version=${info.version} -> ready to install`)
send({ state: 'downloaded', version: info.version })
})
autoUpdater.on('error', (err) => {
const message = err == null ? 'unknown' : err.message || String(err)
log(`error: ${message}`, err instanceof Error && err.stack ? err.stack : '')
send({ state: 'error', message })
})
}
// Silent check shortly after launch. When not packaged there's no update feed,
// but a manual click should still get visible feedback instead of looking dead:
// reply "not-available" so the menu can show "up to date".
export function checkForUpdates(): void {
if (!app.isPackaged) {
// Dev-only UI harness: set COW_MOCK_UPDATE=1 to simulate an available
// update so the update panel/menu interactions can be exercised in
// `npm run dev` (where there's no real feed). Never runs in a packaged app.
if (process.env.COW_MOCK_UPDATE) {
const version = process.env.COW_MOCK_UPDATE_VERSION || '9.9.9'
log(`checkForUpdates: not packaged, MOCK available version=${version}`)
send({ state: 'available', version })
return
}
log('checkForUpdates: not packaged, replying not-available')
send({ state: 'not-available' })
return
}
log(`checkForUpdates: requesting feed, current=${app.getVersion()}`)
autoUpdater.checkForUpdates().catch((err) => {
const message = err?.message || String(err)
log(`checkForUpdates: request failed: ${message}`, err instanceof Error && err.stack ? err.stack : '')
send({ state: 'error', message })
})
}
export function startDownload(): void {
if (!app.isPackaged) return
downloadFellBack = false
log(`startDownload: user requested download (preferChina=${preferChina})`)
attemptDownload()
}
// Download from the current origin; on failure, switch to the OTHER origin once
// and retry. This is the client-side "mirrors back each other" fallback: R2 and
// the China CDN hold identical bytes, so a slow/blocked origin can be swapped
// transparently without the user noticing.
function attemptDownload(): void {
autoUpdater.downloadUpdate().catch((err) => {
const message = err?.message || String(err)
log(`startDownload: failed on ${preferChina ? 'CN' : 'R2'}: ${message}`, err instanceof Error && err.stack ? err.stack : '')
if (!downloadFellBack) {
downloadFellBack = true
preferChina = !preferChina
applyFeedUrl()
log(`startDownload: retrying on ${preferChina ? 'CN' : 'R2'} mirror`)
// Re-check first so electron-updater re-reads the feed from the new origin
// before downloading (its cached updateInfo is origin-agnostic here, but a
// fresh check keeps the internal state consistent).
autoUpdater
.checkForUpdates()
.then(() => autoUpdater.downloadUpdate())
.catch((err2) => {
const m2 = err2?.message || String(err2)
log(`startDownload: fallback also failed: ${m2}`, err2 instanceof Error && err2.stack ? err2.stack : '')
send({ state: 'error', message: m2 })
})
return
}
send({ state: 'error', message })
})
}
export function quitAndInstall(): void {
if (!app.isPackaged) return
log('quitAndInstall: relaunching to install update')
// Drop window-all-closed handlers first: a lingering handler can keep the
// process alive and stop the installer from replacing files / relaunching
// (a documented electron-updater gotcha, esp. on Windows NSIS).
app.removeAllListeners('window-all-closed')
// isSilent=TRUE on Windows. Our installer is now ASSISTED (nsis.oneClick=false
// + allowToChangeInstallationDirectory) so the FIRST install shows the
// directory/mode wizard. But an UPDATE must NOT re-show that wizard — isSilent
// skips it and updates in place. isForceRunAfter=true relaunches after the
// silent update. (The old assisted+silent force-run bug, #2179, was fixed
// upstream in PR #2278; we're on electron-updater 6.8.9, well past it.)
// setImmediate + removeAllListeners are the documented prerequisites for the
// relaunch to fire reliably. macOS ignores isSilent entirely.
setImmediate(() => autoUpdater.quitAndInstall(true, true))
}

21
desktop/src/renderer/assets.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
// Type declarations for static asset imports handled by Vite.
declare module '*.png' {
const src: string
export default src
}
declare module '*.jpg' {
const src: string
export default src
}
declare module '*.jpeg' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare module '*.webp' {
const src: string
export default src
}

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
<title>CowAgent</title>
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
<link rel="stylesheet" href="./vendor/fontawesome/css/all.min.css" />
<script>
// Resolve theme before first paint to avoid flash-of-wrong-theme.
(function () {
try {
var pref = localStorage.getItem('cow_theme') || 'dark';
var resolved = pref === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
if (resolved === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="h-screen overflow-hidden">
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,143 @@
import React, { useState, useCallback, useEffect } from 'react'
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
import { History } from 'lucide-react'
import NavRail from './layout/NavRail'
import SessionList from './layout/SessionList'
import WindowControls from './layout/WindowControls'
import StatusScreen from './components/StatusScreen'
import { useBackend } from './hooks/useBackend'
import { usePlatform } from './hooks/usePlatform'
import { useUIStore } from './store/uiStore'
import { useSessionStore } from './store/sessionStore'
import { initUpdateListener } from './store/updateStore'
import { useOnboardingStore } from './store/onboardingStore'
import OnboardingWizard from './components/OnboardingWizard'
import apiClient from './api/client'
import { t } from './i18n'
import ChatPage from './pages/ChatPage'
import SettingsPage from './pages/SettingsPage'
import KnowledgePage from './pages/KnowledgePage'
import SkillsPage from './pages/SkillsPage'
import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
import TasksPage from './pages/TasksPage'
import LogsPage from './pages/LogsPage'
const App: React.FC = () => {
const backend = useBackend()
const location = useLocation()
const navigate = useNavigate()
const { isWin, isMac } = usePlatform()
const { sessionsCollapsed, toggleSessions, navCollapsed } = useUIStore()
const onboardingOpen = useOnboardingStore((s) => s.open)
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
const [, forceUpdate] = useState(0)
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl])
// First-run check: once the backend is ready, decide whether to show the
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
// configured (and not dismissed earlier this session); no persisted flag.
useEffect(() => {
if (backend.status !== 'ready') return
let cancelled = false
apiClient
.getModels()
.then((data) => {
if (cancelled) return
const chat = data.capabilities?.chat
// "Configured" needs a chat provider+model AND that provider's API key
// set. A default config can ship a model name with no key, which
// shouldn't count as ready — otherwise we'd skip onboarding for users
// who still need to enter a key.
const providerId = chat?.current_provider
const provider = data.providers?.find((p) => p.id === providerId)
const keyReady = !!provider && (provider.configured || (provider.is_custom && !!provider.custom_name))
const configured = !!providerId && !!chat?.current_model && keyReady
maybeOpenOnboarding(configured)
})
.catch(() => {
// If models can't be loaded, fall back to the flag-only decision.
if (!cancelled) maybeOpenOnboarding(false)
})
return () => {
cancelled = true
}
}, [backend.status, maybeOpenOnboarding])
// Subscribe to auto-update status from the main process (no-op in dev).
useEffect(() => initUpdateListener(), [])
// Handle app-menu / shortcut actions forwarded from the main process.
useEffect(() => {
const off = window.electronAPI?.onMenuAction?.((action) => {
if (action === 'new-chat') {
useSessionStore.getState().newSession()
navigate('/')
} else if (action === 'open-settings') {
navigate('/settings')
} else if (action === 'view-logs') {
navigate('/logs')
}
})
return off
}, [navigate])
const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), [])
if (backend.status !== 'ready') {
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
}
const isChat = location.pathname === '/'
const showSessions = isChat && !sessionsCollapsed
return (
<div className="flex h-screen overflow-hidden bg-base text-content">
{onboardingOpen && <OnboardingWizard onDone={handleLangChange} />}
<NavRail onLangChange={handleLangChange} />
{showSessions && <SessionList />}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Top titlebar strip — drag region + Windows controls */}
<header className="h-[44px] flex items-center gap-1 px-2 flex-shrink-0 titlebar-drag bg-base border-b border-default">
{isChat && sessionsCollapsed && (
<button
onClick={toggleSessions}
title={t('session_history')}
// Keep aligned with the SessionList history button: only nudge
// right of the macOS traffic lights when the nav rail is collapsed
// (otherwise the lights stay within the rail and don't overlap).
className={`titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${isMac ? 'mt-1' : ''} ${isMac && navCollapsed ? 'ml-2' : ''}`}
>
<History size={16} />
</button>
)}
<div className="flex-1 min-w-0" />
{isWin && <WindowControls />}
</header>
{/* Content */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/knowledge" element={<KnowledgePage baseUrl={backend.baseUrl} />} />
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
<Route path="/settings" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
{/* Legacy /models route now lives as a tab inside settings */}
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
</Routes>
</div>
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,422 @@
import type {
ConfigData,
ChannelInfo,
ChannelAction,
SkillInfo,
ToolInfo,
MemoryItem,
MemoryCategory,
MemoryPage,
SchedulerTask,
Attachment,
SessionsPage,
HistoryPage,
ModelsData,
ModelsAction,
KnowledgeList,
KnowledgeGraph,
KnowledgeAction,
KnowledgeImportPayload,
} from '../types'
interface ApiResult {
status: string
message?: string
}
class ApiClient {
private baseUrl = 'http://127.0.0.1:9876'
setBaseUrl(url: string) {
this.baseUrl = url
}
getBaseUrl() {
return this.baseUrl
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
// Send cookies for future web_password auth support
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json()
}
// ---------------------------------------------------------
// Chat / messages
// ---------------------------------------------------------
async sendMessage(
sessionId: string,
message: string,
opts?: { stream?: boolean; attachments?: Attachment[]; isVoice?: boolean; lang?: string }
): Promise<{ status: string; request_id: string; stream: boolean; inline_reply?: string }> {
return this.request('/message', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
message,
stream: opts?.stream ?? true,
attachments: opts?.attachments,
is_voice: opts?.isVoice ?? false,
lang: opts?.lang,
}),
})
}
async poll(sessionId: string): Promise<{
status: string
has_content: boolean
content?: string
request_id?: string
timestamp?: number
}> {
return this.request('/poll', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
})
}
async cancel(opts: { requestId?: string; sessionId?: string; lang?: string }): Promise<{ status: string; cancelled: number }> {
return this.request('/cancel', {
method: 'POST',
body: JSON.stringify({ request_id: opts.requestId, session_id: opts.sessionId, lang: opts.lang }),
})
}
createSSEStream(requestId: string): EventSource {
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
}
async deleteMessage(opts: {
sessionId: string
userSeq: number
deleteUser?: boolean
cascade?: boolean
}): Promise<{ status: string; deleted: number }> {
return this.request('/api/messages/delete', {
method: 'POST',
body: JSON.stringify({
session_id: opts.sessionId,
user_seq: opts.userSeq,
delete_user: opts.deleteUser ?? true,
cascade: opts.cascade ?? false,
}),
})
}
// ---------------------------------------------------------
// Upload / files
// ---------------------------------------------------------
async uploadFile(file: File, sessionId?: string): Promise<{
status: string
file_path: string
file_name: string
file_type: string
preview_url: string
}> {
const formData = new FormData()
formData.append('file', file)
if (sessionId) formData.append('session_id', sessionId)
const res = await fetch(`${this.baseUrl}/upload`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
getFileUrl(previewUrl: string): string {
if (/^https?:\/\//.test(previewUrl)) return previewUrl
return `${this.baseUrl}${previewUrl}`
}
getServeFileUrl(absPath: string): string {
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
}
// ---------------------------------------------------------
// Sessions
// ---------------------------------------------------------
async getSessions(page = 1, pageSize = 50): Promise<SessionsPage> {
return this.request<{ status: string } & SessionsPage>(`/api/sessions?page=${page}&page_size=${pageSize}`)
}
async deleteSession(sessionId: string): Promise<ApiResult> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
}
async renameSession(sessionId: string, title: string): Promise<ApiResult> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PUT',
body: JSON.stringify({ title }),
})
}
async generateSessionTitle(sessionId: string, userMessage: string, assistantReply?: string): Promise<{ status: string; title: string }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/generate_title`, {
method: 'POST',
body: JSON.stringify({ user_message: userMessage, assistant_reply: assistantReply }),
})
}
async clearContext(sessionId: string): Promise<{ status: string; context_start_seq: number }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' })
}
async getHistory(sessionId: string, page = 1, pageSize = 20): Promise<HistoryPage> {
return this.request<{ status: string } & HistoryPage>(
`/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=${pageSize}`
)
}
// ---------------------------------------------------------
// Config
// ---------------------------------------------------------
async getConfig(): Promise<ConfigData> {
return this.request<{ status: string } & ConfigData>('/config')
}
async updateConfig(updates: Record<string, unknown>): Promise<{ status: string; applied: Record<string, unknown> }> {
return this.request('/config', {
method: 'POST',
body: JSON.stringify({ updates }),
})
}
// ---------------------------------------------------------
// Models console
// ---------------------------------------------------------
async getModels(): Promise<ModelsData> {
return this.request<{ status: string } & ModelsData>('/api/models')
}
async modelsAction(action: ModelsAction): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/models', {
method: 'POST',
body: JSON.stringify(action),
})
}
// ---------------------------------------------------------
// Channels
// ---------------------------------------------------------
async getChannels(): Promise<ChannelInfo[]> {
const data = await this.request<{ status: string; channels: ChannelInfo[] }>('/api/channels')
return data.channels
}
async channelAction(
action: ChannelAction,
channel: string,
config?: Record<string, unknown>
): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/channels', {
method: 'POST',
body: JSON.stringify({ action, channel, config }),
})
}
// Weixin QR login
async getWeixinQr(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; source?: string; message?: string }> {
return this.request('/api/weixin/qrlogin')
}
async weixinQrAction(action: 'poll' | 'refresh'): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/weixin/qrlogin', {
method: 'POST',
body: JSON.stringify({ action }),
})
}
// Feishu one-click register
async getFeishuRegister(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; expire_in?: number; message?: string }> {
return this.request('/api/feishu/register')
}
async feishuRegisterPoll(): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/feishu/register', {
method: 'POST',
body: JSON.stringify({ action: 'poll' }),
})
}
// ---------------------------------------------------------
// Tools & skills
// ---------------------------------------------------------
async getTools(): Promise<ToolInfo[]> {
const data = await this.request<{ status: string; tools: ToolInfo[] }>('/api/tools')
return data.tools
}
async getSkills(): Promise<SkillInfo[]> {
const data = await this.request<{ status: string; skills: SkillInfo[] }>('/api/skills')
return data.skills
}
async toggleSkill(name: string, action: 'open' | 'close'): Promise<ApiResult> {
return this.request('/api/skills', {
method: 'POST',
body: JSON.stringify({ action, name }),
})
}
// ---------------------------------------------------------
// Memory
// ---------------------------------------------------------
async getMemoryList(page = 1, pageSize = 20, category: MemoryCategory = 'memory'): Promise<MemoryPage> {
return this.request<{ status: string } & MemoryPage>(
`/api/memory?page=${page}&page_size=${pageSize}&category=${category}`
)
}
async getMemoryContent(filename: string, category: MemoryCategory = 'memory'): Promise<string> {
const data = await this.request<{ status: string; content: string }>(
`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`
)
return data.content
}
// ---------------------------------------------------------
// Knowledge
// ---------------------------------------------------------
async getKnowledgeList(): Promise<KnowledgeList> {
return this.request<{ status: string } & KnowledgeList>('/api/knowledge/list')
}
async readKnowledge(path: string): Promise<{ status: string; content: string; path: string }> {
return this.request(`/api/knowledge/read?path=${encodeURIComponent(path)}`)
}
async getKnowledgeGraph(): Promise<KnowledgeGraph> {
return this.request<KnowledgeGraph>('/api/knowledge/graph')
}
async knowledgeAction(req: KnowledgeAction): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/knowledge/action', {
method: 'POST',
body: JSON.stringify(req),
})
}
// Bulk import: upload .md/.txt files into a target category (multipart).
async importKnowledge(
files: File[],
targetCategory: string
): Promise<{ status: string; message?: string; payload?: KnowledgeImportPayload }> {
const formData = new FormData()
formData.append('target_category', targetCategory)
formData.append('conflict_strategy', 'rename')
files.forEach((file) => formData.append('files', file, file.name))
const res = await fetch(`${this.baseUrl}/api/knowledge/import`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
// ---------------------------------------------------------
// Scheduler
// ---------------------------------------------------------
async getSchedulerTasks(): Promise<SchedulerTask[]> {
const data = await this.request<{ status: string; tasks: SchedulerTask[] }>('/api/scheduler')
return data.tasks
}
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/toggle', {
method: 'POST',
body: JSON.stringify({ task_id: taskId, enabled }),
})
}
async updateTask(taskId: string, updates: Partial<Pick<SchedulerTask, 'name' | 'enabled' | 'schedule' | 'action'>>): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/update', {
method: 'POST',
body: JSON.stringify({ task_id: taskId, ...updates }),
})
}
async deleteTask(taskId: string): Promise<ApiResult> {
return this.request('/api/scheduler/delete', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
// ---------------------------------------------------------
// Voice
// ---------------------------------------------------------
async voiceAsr(audio: File | Blob): Promise<{ status: string; text?: string; audio_url?: string; message?: string }> {
const formData = new FormData()
formData.append('file', audio, 'recording.webm')
const res = await fetch(`${this.baseUrl}/api/voice/asr`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
async voiceTts(text: string, sessionId?: string): Promise<{ status: string; audio_url?: string; message?: string }> {
return this.request('/api/voice/tts', {
method: 'POST',
body: JSON.stringify({ text, session_id: sessionId }),
})
}
// ---------------------------------------------------------
// Logs / version
// ---------------------------------------------------------
createLogStream(): EventSource {
return new EventSource(`${this.baseUrl}/api/logs`)
}
async getVersion(): Promise<string> {
const data = await this.request<{ version: string }>('/api/version')
return data.version
}
// ---------------------------------------------------------
// Auth (web_password) — placeholder for future use
// ---------------------------------------------------------
async authCheck(): Promise<{ status: string; auth_required: boolean; authenticated?: boolean }> {
return this.request('/auth/check')
}
async authLogin(password: string): Promise<ApiResult> {
return this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
}
async authLogout(): Promise<ApiResult> {
return this.request('/auth/logout', { method: 'POST' })
}
}
export const apiClient = new ApiClient()
export default apiClient

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -0,0 +1,398 @@
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { Plus, Paperclip, Square, X, File as FileIcon, Loader2, Trash2 } from 'lucide-react'
import { t } from '../i18n'
import type { Attachment } from '../types'
import apiClient from '../api/client'
import { PaperPlaneIcon } from './icons'
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
interface SlashCommand {
cmd: string
desc: string
// 'new'/'clear' run a local action; 'send' (default) is a completion that
// gets sent to the backend as a normal message (handled by command plugins).
action?: 'new' | 'clear'
}
interface ChatInputProps {
onSend: (message: string, attachments: Attachment[]) => void
onNewChat: () => void
onStop: () => void
onClearContext: () => void
isStreaming: boolean
sessionId: string
}
const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, onNewChat, onStop, onClearContext, isStreaming, sessionId },
ref
) {
const [text, setText] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [slashOpen, setSlashOpen] = useState(false)
const [slashIndex, setSlashIndex] = useState(0)
const composingRef = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Local actions ('new'/'clear') plus completion commands handled by backend
// command plugins (cow_cli/godcmd). Commands ending with a space expect an
// argument, so selecting them keeps focus in the input instead of sending.
const slashCommands: SlashCommand[] = [
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
{ cmd: '/clear', desc: t('slash_clear'), action: 'clear' },
{ cmd: '/help', desc: t('slash_help') },
{ cmd: '/status', desc: t('slash_status') },
{ cmd: '/context', desc: t('slash_context') },
{ cmd: '/skill list', desc: t('slash_skill_list') },
{ cmd: '/skill search ', desc: t('slash_skill_search') },
{ cmd: '/skill install ', desc: t('slash_skill_install') },
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
{ cmd: '/knowledge', desc: t('slash_knowledge') },
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
{ cmd: '/config', desc: t('slash_config') },
{ cmd: '/cancel', desc: t('slash_cancel') },
{ cmd: '/logs', desc: t('slash_logs') },
{ cmd: '/version', desc: t('slash_version') },
]
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
// Resize the textarea to fit its content (single line = 42px, capped at
// 180px). Keep overflow hidden until we hit the cap, so an empty/short input
// never shows a scrollbar (matches the web console behavior).
const autoSize = (el: HTMLTextAreaElement | null) => {
if (!el) return
el.style.height = '42px'
const h = Math.min(el.scrollHeight, 180)
el.style.height = h + 'px'
el.style.overflowY = el.scrollHeight > 180 ? 'auto' : 'hidden'
}
const resetHeight = () => {
const el = textareaRef.current
if (!el) return
el.style.height = '42px'
el.style.overflowY = 'hidden'
}
// Sync the height once on mount so the very first render matches the 42px
// single-line height instead of the browser's default textarea size.
useEffect(() => {
autoSize(textareaRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Allow the parent to load a draft (e.g. when editing a past user message).
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
setText(draft)
setAttachments(atts)
requestAnimationFrame(() => {
const el = textareaRef.current
if (el) {
el.focus()
autoSize(el)
}
})
})
const runSlash = (c: SlashCommand) => {
setSlashOpen(false)
if (c.action === 'new') {
setText('')
resetHeight()
onNewChat()
return
}
if (c.action === 'clear') {
setText('')
resetHeight()
onClearContext()
return
}
// Completion command. If it expects an argument (trailing space), keep it
// in the input so the user can type the argument; otherwise send it now.
const needsArg = c.cmd.endsWith(' ')
if (needsArg) {
setText(c.cmd)
requestAnimationFrame(() => textareaRef.current?.focus())
} else {
onSend(c.cmd.trim(), [])
setText('')
resetHeight()
}
}
const handleSubmit = useCallback(() => {
const trimmed = text.trim()
if (!trimmed && attachments.length === 0) return
if (isStreaming) return
onSend(trimmed, attachments)
setText('')
setAttachments([])
setSlashOpen(false)
resetHeight()
}, [text, attachments, isStreaming, onSend])
const handleKeyDown = (e: React.KeyboardEvent) => {
// Slash menu navigation
if (slashOpen && filtered.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSlashIndex((i) => (i + 1) % filtered.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSlashIndex((i) => (i - 1 + filtered.length) % filtered.length)
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
runSlash(filtered[slashIndex])
return
}
if (e.key === 'Escape') {
setSlashOpen(false)
return
}
}
// Don't submit while IME is composing (Chinese input)
if (e.key === 'Enter' && !e.shiftKey && !composingRef.current) {
e.preventDefault()
handleSubmit()
}
}
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value
setText(v)
autoSize(e.target)
// open slash menu when the input starts with "/" and has no space
setSlashOpen(v.startsWith('/') && !v.includes(' '))
setSlashIndex(0)
}
const uploadFiles = async (files: File[]) => {
if (!files.length) return
setUploading(true)
try {
for (const file of files) {
const result = await apiClient.uploadFile(file, sessionId)
if (result.status === 'success') {
setAttachments((prev) => [
...prev,
{
file_path: result.file_path,
file_name: result.file_name,
file_type: result.file_type as Attachment['file_type'],
preview_url: result.preview_url,
},
])
}
}
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
}
}
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files) await uploadFiles(Array.from(files))
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const files = Array.from(e.dataTransfer.files || [])
if (files.length) uploadFiles(files)
}
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
const files: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const f = item.getAsFile()
if (f) files.push(f)
}
}
if (files.length) {
e.preventDefault()
uploadFiles(files)
}
}
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
// keep slash index in range
useEffect(() => {
if (slashIndex >= filtered.length) setSlashIndex(0)
}, [filtered.length, slashIndex])
const canSend = !isStreaming && (!!text.trim() || attachments.length > 0)
return (
<div className="flex-shrink-0 border-t border-default bg-surface px-4 py-3">
<div
className={`max-w-3xl mx-auto relative rounded-2xl transition-all ${
dragOver ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface' : ''
}`}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{dragOver && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-2xl bg-accent-soft text-accent text-sm font-medium pointer-events-none">
{t('input_placeholder')}
</div>
)}
{/* Slash command menu */}
{slashOpen && filtered.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1.5 max-h-80 overflow-y-auto rounded-xl border border-default bg-elevated shadow-xl z-30 p-1.5">
<div className="px-2.5 pt-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-content-tertiary">
{t('slash_menu_title')}
</div>
{filtered.map((c, i) => (
<button
key={c.cmd}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => runSlash(c)}
className={`w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-lg text-left cursor-pointer transition-colors ${
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
}`}
>
<span
className={`text-[13px] font-medium font-mono whitespace-nowrap ${
i === slashIndex ? 'text-accent' : 'text-content-secondary'
}`}
>
{c.cmd}
</span>
<span className="text-xs text-content-tertiary whitespace-nowrap truncate">{c.desc}</span>
</button>
))}
</div>
)}
{/* Attachment preview */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{attachments.map((att, i) => (
<div key={i} className="relative">
{att.file_type === 'image' && att.preview_url ? (
<div className="relative">
<img
src={apiClient.getFileUrl(att.preview_url)}
alt={att.file_name}
className="w-16 h-16 rounded-lg object-cover border border-default"
/>
<button
onClick={() => removeAttachment(i)}
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
>
<X size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-inset border border-default rounded-lg text-xs text-content-secondary max-w-[180px] relative pr-7">
<FileIcon size={12} />
<span className="truncate">{att.file_name}</span>
<button
onClick={() => removeAttachment(i)}
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
>
<X size={10} />
</button>
</div>
)}
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<div className="flex items-center flex-shrink-0 gap-0.5 pb-0.5">
<button
onClick={onNewChat}
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors"
title={t('session_new')}
>
<Plus size={18} />
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors disabled:opacity-50"
title={t('chat_attach')}
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
</button>
<button
onClick={onClearContext}
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-danger hover:bg-danger-soft cursor-pointer transition-colors"
title={t('chat_clear_context')}
>
<Trash2 size={18} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
onChange={handleFileSelect}
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md"
/>
<textarea
ref={textareaRef}
id="chat-input"
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
placeholder={t('input_placeholder')}
rows={1}
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-strong bg-inset text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent text-sm leading-relaxed transition-colors resize-none overflow-y-hidden"
/>
{isStreaming ? (
<button
onClick={onStop}
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-surface-2 text-content hover:bg-inset cursor-pointer transition-colors"
title={t('msg_stop')}
>
<Square size={15} className="fill-current" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!canSend}
className="flex-shrink-0 w-[42px] h-[42px] flex items-center justify-center rounded-btn bg-accent text-white hover:bg-accent-hover disabled:bg-surface-2 disabled:text-content-disabled disabled:cursor-not-allowed cursor-pointer transition-none [&_*]:transition-none"
title={t('chat_send')}
>
<PaperPlaneIcon size={15} />
</button>
)}
</div>
</div>
</div>
)
})
export default ChatInput

View File

@@ -0,0 +1,407 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import type { KnowledgeGraph as KnowledgeGraphData } from '../types'
interface SimNode {
id: string
label: string
category: string
x: number
y: number
vx: number
vy: number
fx: number | null
fy: number | null
degree: number
}
interface KnowledgeGraphProps {
data: KnowledgeGraphData
onSelect: (id: string, label: string) => void
}
// d3.schemeTableau10 — keep the web client's palette for visual parity.
const TABLEAU10 = [
'#4e79a7',
'#f28e2c',
'#e15759',
'#76b7b2',
'#59a14f',
'#edc949',
'#af7aa1',
'#ff9da7',
'#9c755f',
'#bab0ab',
]
const nodeRadius = (degree: number) => Math.max(4, Math.min(12, 4 + degree * 1.4))
// A dependency-free force-directed graph with wheel zoom, canvas pan and node
// drag. The physics loop writes positions DIRECTLY to the DOM (like d3) instead
// of calling setState per frame, so React never re-renders during the
// simulation — this is what keeps it from flickering.
const KnowledgeGraph: React.FC<KnowledgeGraphProps> = ({ data, onSelect }) => {
const wrapRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const sizeRef = useRef({ w: 800, h: 560 })
const [hover, setHover] = useState<string | null>(null)
// Bumping this re-arms the physics loop (used while dragging) without
// rebuilding the model, preserving current node positions.
const [warmTick, setWarmTick] = useState(0)
// View transform (pan + zoom).
const viewRef = useRef({ k: 1, x: 0, y: 0 })
// Build the immutable model once per data change.
const model = useMemo(() => {
const degree = new Map<string, number>()
data.links.forEach((l) => {
degree.set(l.source, (degree.get(l.source) || 0) + 1)
degree.set(l.target, (degree.get(l.target) || 0) + 1)
})
const categories = Array.from(new Set(data.nodes.map((n) => n.category || 'default')))
const colorOf = (cat: string) => TABLEAU10[categories.indexOf(cat) % TABLEAU10.length]
const n = data.nodes.length || 1
const { w, h } = sizeRef.current
const cx = w / 2
const cy = h / 2
const nodes: SimNode[] = data.nodes.map((nd, i) => {
const angle = (i / n) * Math.PI * 2
const radius = Math.min(w, h) * 0.32
return {
id: nd.id,
label: nd.label,
category: nd.category || 'default',
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
vx: 0,
vy: 0,
fx: null,
fy: null,
degree: degree.get(nd.id) || 0,
}
})
const valid = new Set(nodes.map((x) => x.id))
const byId = new Map(nodes.map((x) => [x.id, x]))
const links = data.links
.filter((l) => valid.has(l.source) && valid.has(l.target))
.map((l, i) => ({ key: i, a: byId.get(l.source)!, b: byId.get(l.target)! }))
const adjacency = new Map<string, Set<string>>()
data.links.forEach((l) => {
if (!valid.has(l.source) || !valid.has(l.target)) return
if (!adjacency.has(l.source)) adjacency.set(l.source, new Set())
if (!adjacency.has(l.target)) adjacency.set(l.target, new Set())
adjacency.get(l.source)!.add(l.target)
adjacency.get(l.target)!.add(l.source)
})
return { nodes, links, adjacency, categories, colorOf, byId }
}, [data])
// DOM refs for imperative position updates.
const rootRef = useRef<SVGGElement>(null)
const lineEls = useRef(new Map<number, SVGLineElement>())
const groupEls = useRef(new Map<string, SVGGElement>())
// Track container size in a ref; never triggers a re-render on its own.
useEffect(() => {
const el = wrapRef.current
if (!el) return
const apply = () => {
sizeRef.current = { w: el.clientWidth || 800, h: el.clientHeight || 560 }
}
apply()
const ro = new ResizeObserver(apply)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Physics loop. Restarts only when the model (data) changes. Writes to DOM.
// Uses d3-style alpha cooling so it always settles and stops the rAF.
useEffect(() => {
const { nodes, links } = model
if (nodes.length === 0) return
let raf = 0
let alive = true
// Global cooling factor; decays toward 0 and scales how far nodes move.
let alpha = 1
const alphaDecay = 0.018
const alphaMin = 0.005
const paint = () => {
links.forEach(({ key, a, b }) => {
const el = lineEls.current.get(key)
if (!el) return
el.setAttribute('x1', String(a.x))
el.setAttribute('y1', String(a.y))
el.setAttribute('x2', String(b.x))
el.setAttribute('y2', String(b.y))
})
nodes.forEach((node) => {
const el = groupEls.current.get(node.id)
if (el) el.setAttribute('transform', `translate(${node.x},${node.y})`)
})
}
const step = () => {
if (!alive) return
const { w, h } = sizeRef.current
const cx = w / 2
const cy = h / 2
const repulsion = 9000
const springLen = 80
const spring = 0.04
const centering = 0.012
const dragging = nodes.some((node) => node.fx != null)
// Reset accumulated velocity each tick (alpha-scaled displacement) so the
// system can't accumulate energy and oscillate.
nodes.forEach((node) => {
node.vx = 0
node.vy = 0
})
// Repulsion + collision: nodes push apart, never overlap their radii.
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i]
const ra = nodeRadius(a.degree)
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j]
let dx = a.x - b.x
let dy = a.y - b.y
let d2 = dx * dx + dy * dy
if (d2 < 0.01) {
dx = Math.random() - 0.5
dy = Math.random() - 0.5
d2 = 0.01
}
let d = Math.sqrt(d2)
let f = repulsion / d2
// Hard collision: strongly separate if closer than combined radii.
const minDist = ra + nodeRadius(b.degree) + 14
if (d < minDist) f += (minDist - d) * 0.6
a.vx += (dx / d) * f
a.vy += (dy / d) * f
b.vx -= (dx / d) * f
b.vy -= (dy / d) * f
}
}
links.forEach(({ a, b }) => {
const dx = b.x - a.x
const dy = b.y - a.y
const d = Math.sqrt(dx * dx + dy * dy) || 1
const f = (d - springLen) * spring
a.vx += (dx / d) * f
a.vy += (dy / d) * f
b.vx -= (dx / d) * f
b.vy -= (dy / d) * f
})
// Weak centering so the whole graph stays in view without collapsing.
nodes.forEach((node) => {
node.vx += (cx - node.x) * centering
node.vy += (cy - node.y) * centering
})
// Apply alpha-scaled displacement; pinned nodes stay put. Cap per-tick
// movement so strong initial forces don't fling nodes off-screen.
const maxStep = 30
nodes.forEach((node) => {
if (node.fx != null) {
node.x = node.fx
node.y = node.fy as number
return
}
let dx = node.vx * alpha
let dy = node.vy * alpha
const m = Math.hypot(dx, dy)
if (m > maxStep) {
dx = (dx / m) * maxStep
dy = (dy / m) * maxStep
}
node.x += dx
node.y += dy
})
paint()
alpha += (0 - alpha) * alphaDecay
// Keep running while cooling, or while a node is being dragged.
if (alpha > alphaMin || dragging) {
raf = requestAnimationFrame(step)
}
}
raf = requestAnimationFrame(step)
return () => {
alive = false
cancelAnimationFrame(raf)
}
// warmTick re-arms the loop on demand (e.g. while dragging) without
// rebuilding the model, so positions are preserved.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, warmTick])
// Apply the view transform imperatively (no re-render needed).
const applyView = () => {
const v = viewRef.current
if (rootRef.current) rootRef.current.setAttribute('transform', `translate(${v.x},${v.y}) scale(${v.k})`)
}
useEffect(applyView)
// Convert a pointer event to graph (pre-transform) coordinates.
const toGraph = (clientX: number, clientY: number) => {
const rect = svgRef.current!.getBoundingClientRect()
const v = viewRef.current
return { x: (clientX - rect.left - v.x) / v.k, y: (clientY - rect.top - v.y) / v.k }
}
// Wheel zoom centered on the cursor (matches d3.zoom scaleExtent [0.2, 5]).
const onWheel = (e: React.WheelEvent) => {
e.preventDefault()
const v = viewRef.current
const rect = svgRef.current!.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const factor = Math.exp(-e.deltaY * 0.0015)
const k = Math.min(5, Math.max(0.2, v.k * factor))
viewRef.current = { k, x: px - ((px - v.x) / v.k) * k, y: py - ((py - v.y) / v.k) * k }
applyView()
}
// Drag: on a node moves the node, on background pans the canvas.
const dragRef = useRef<
| { mode: 'node'; node: SimNode; moved: boolean }
| { mode: 'pan'; startX: number; startY: number; ox: number; oy: number }
| null
>(null)
const kick = () => setWarmTick((v) => v + 1)
const onPointerDownNode = (e: React.PointerEvent, node: SimNode) => {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toGraph(e.clientX, e.clientY)
node.fx = p.x
node.fy = p.y
dragRef.current = { mode: 'node', node, moved: false }
kick()
}
const onPointerDownBg = (e: React.PointerEvent) => {
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const v = viewRef.current
dragRef.current = { mode: 'pan', startX: e.clientX, startY: e.clientY, ox: v.x, oy: v.y }
}
const onPointerMove = (e: React.PointerEvent) => {
const drag = dragRef.current
if (!drag) return
if (drag.mode === 'node') {
const p = toGraph(e.clientX, e.clientY)
drag.node.fx = p.x
drag.node.fy = p.y
drag.moved = true
// Keep the loop warm for live dragging.
const el = groupEls.current.get(drag.node.id)
if (el) el.setAttribute('transform', `translate(${p.x},${p.y})`)
} else {
viewRef.current = { k: viewRef.current.k, x: drag.ox + (e.clientX - drag.startX), y: drag.oy + (e.clientY - drag.startY) }
applyView()
}
}
const onPointerUp = (e: React.PointerEvent, node?: SimNode) => {
const drag = dragRef.current
if (drag?.mode === 'node') {
drag.node.fx = null
drag.node.fy = null
if (node && !drag.moved) onSelect(node.id, node.label)
kick()
}
dragRef.current = null
try {
;(e.target as Element).releasePointerCapture(e.pointerId)
} catch {
/* noop */
}
}
const { nodes, links, adjacency, categories, colorOf } = model
const { w, h } = sizeRef.current
const isDimmed = (id: string) => hover != null && hover !== id && !adjacency.get(hover)?.has(id)
const isLinkActive = (aId: string, bId: string) => hover === aId || hover === bId
return (
<div ref={wrapRef} className="w-full h-full relative overflow-hidden">
<svg
ref={svgRef}
width={w}
height={h}
className="select-none block cursor-grab active:cursor-grabbing"
onWheel={onWheel}
onPointerDown={onPointerDownBg}
onPointerMove={onPointerMove}
onPointerUp={(e) => onPointerUp(e)}
>
<g ref={rootRef}>
{links.map(({ key, a, b }) => {
const active = isLinkActive(a.id, b.id)
return (
<line
key={key}
ref={(el) => {
if (el) lineEls.current.set(key, el)
else lineEls.current.delete(key)
}}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="#94a3b8"
strokeOpacity={hover ? (active ? 0.8 : 0.1) : 0.3}
strokeWidth={1}
/>
)
})}
{nodes.map((n) => {
const r = nodeRadius(n.degree)
const dim = isDimmed(n.id)
return (
<g
key={n.id}
ref={(el) => {
if (el) groupEls.current.set(n.id, el)
else groupEls.current.delete(n.id)
}}
transform={`translate(${n.x},${n.y})`}
className="cursor-pointer"
opacity={dim ? 0.2 : 1}
onMouseEnter={() => setHover(n.id)}
onMouseLeave={() => setHover(null)}
onPointerDown={(e) => onPointerDownNode(e, n)}
onPointerUp={(e) => onPointerUp(e, n)}
>
<circle r={r} fill={colorOf(n.category)} stroke="#fff" strokeWidth={1.5} />
{(hover === n.id || n.degree >= 3) && (
<text x={r + 4} y={3} className="fill-content-secondary" fontSize={9} style={{ pointerEvents: 'none' }}>
{n.label.length > 15 ? n.label.slice(0, 14) + '…' : n.label}
</text>
)}
</g>
)
})}
</g>
</svg>
{/* Category legend, mirrors the web client. */}
{categories.length > 0 && (
<div className="absolute bottom-3 left-3 flex flex-wrap gap-x-3 gap-y-1 max-w-[60%] rounded-lg bg-surface px-3 py-2 border border-subtle shadow-sm">
{categories.map((cat) => (
<span key={cat} className="inline-flex items-center gap-1.5 text-[11px] text-content-secondary">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: colorOf(cat) }} />
{cat}
</span>
))}
</div>
)}
</div>
)
}
export default KnowledgeGraph

View File

@@ -0,0 +1,109 @@
import React, { useMemo, useRef, useCallback } from 'react'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import { t } from '../i18n'
/**
* Markdown renderer aligned 1:1 with the web console (markdown-it + highlight.js
* + GitHub themes). Using the same engine guarantees identical line-break,
* linkify and code-highlight behavior across web and desktop.
*/
const md: MarkdownIt = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch {
/* fall through */
}
}
try {
return hljs.highlightAuto(str).value
} catch {
return ''
}
},
})
// Open links in a new tab safely.
const defaultLinkOpen =
md.renderer.rules.link_open ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrPush(['target', '_blank'])
tokens[idx].attrPush(['rel', 'noopener noreferrer'])
return defaultLinkOpen(tokens, idx, options, env, self)
}
// Wrap fenced code blocks so we can render a header (lang + copy button).
const defaultFence =
md.renderer.rules.fence ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.fence = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const info = token.info ? token.info.trim().split(/\s+/)[0] : ''
// Ensure the `hljs` class is present so the GitHub theme background/base
// color applies (markdown-it only adds language-* by default).
let rendered = defaultFence(tokens, idx, options, env, self)
if (rendered.includes('<code class="')) {
rendered = rendered.replace('<code class="', '<code class="hljs ')
} else {
rendered = rendered.replace('<code>', '<code class="hljs">')
}
return (
`<div class="code-block-wrapper">` +
`<div class="code-block-header">` +
`<span class="code-block-lang">${info || 'text'}</span>` +
`<button type="button" class="code-copy-btn" data-code-id="cb-${idx}" aria-label="Copy code">${t('msg_copy')}</button>` +
`</div>` +
rendered +
`</div>`
)
}
interface MarkdownProps {
content: string
}
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
const rootRef = useRef<HTMLDivElement>(null)
const html = useMemo(() => md.render(content || ''), [content])
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const btn = target.closest('.code-copy-btn') as HTMLElement | null
if (!btn) return
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
if (!pre) return
navigator.clipboard.writeText(pre.textContent || '')
const original = btn.textContent
btn.textContent = t('msg_copied')
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = original
btn.classList.remove('copied')
}, 1600)
}, [])
return (
<div
ref={rootRef}
className="msg-content text-sm text-content leading-relaxed break-words"
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export default Markdown

View File

@@ -0,0 +1,203 @@
import React, { useState } from 'react'
import { Copy, Check, RefreshCw, Trash2, File as FileIcon, Sprout } from 'lucide-react'
import type { ChatMessage } from '../types'
import { t } from '../i18n'
import apiClient from '../api/client'
import Markdown from './Markdown'
import MessageSteps, { ThinkingStep } from './MessageSteps'
interface MessageBubbleProps {
message: ChatMessage
onRegenerate?: (id: string) => void
onEdit?: (id: string) => void
onDelete?: (msg: ChatMessage) => void
/** Fired when an inline image/video finishes loading, so the parent can
* re-scroll to the bottom (async media changes bubble height after mount). */
onMediaLoad?: () => void
}
function fmtTime(ts: number): string {
if (!ts) return ''
const d = new Date(ts * 1000)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boolean; children: React.ReactNode }> = ({
onClick,
title,
danger,
children,
}) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md cursor-pointer transition-colors text-content-tertiary ${
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface-2'
}`}
>
{children}
</button>
)
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete, onMediaLoad }) => {
const isUser = message.role === 'user'
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(message.content)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
}
// Open a sent file: prefer the local path via Electron (Finder / default
// app); fall back to the served URL in a browser when unavailable.
const openAttachment = (att: { abs_path?: string; preview_url?: string; file_path: string }) => {
if (att.abs_path && window.electronAPI?.openPath) {
window.electronAPI.openPath(att.abs_path)
return
}
window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')
}
if (isUser) {
return (
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-1.5 justify-end max-w-[75%]">
{message.attachments.map((att, i) =>
att.file_type === 'image' && att.preview_url ? (
<img
key={i}
src={apiClient.getFileUrl(att.preview_url)}
alt={att.file_name}
className="max-w-[180px] max-h-[150px] rounded-xl object-cover border border-default"
/>
) : (
<div key={i} className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary">
<FileIcon size={13} />
{att.file_name}
</div>
)
)}
</div>
)}
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-accent text-white">
<div className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
</div>
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
{/* Edit entry hidden: editing a past question cascade-deletes all
subsequent turns, which surprises users. Kept off until we support
non-destructive editing. */}
{onDelete && message.userSeq != null && (
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
<Trash2 size={13} />
</HoverAction>
)}
</div>
</div>
)
}
// Assistant
const showCursor = message.isStreaming && !message.content && (!message.steps || message.steps.length === 0)
const hasSteps = !!(message.steps && message.steps.length > 0)
const hasLiveReasoning = !!(message.reasoning && message.isStreaming)
return (
<div className="group flex gap-3 px-4 sm:px-6 py-2">
<img src="./logo.jpg" alt="CowAgent" className="w-7 h-7 rounded-lg flex-shrink-0 mt-1" />
<div className="flex-1 min-w-0 max-w-[calc(100%-2.5rem)]">
<div className="inline-block w-full rounded-2xl border border-default bg-surface px-4 py-3">
{message.kind === 'evolution' && (
<div className="inline-flex items-center gap-1 mb-1.5 text-[11px] text-content-tertiary">
<Sprout size={11} />
{t('msg_self_learned')}
</div>
)}
{/* Steps area (thinking / tools / intermediate content), web-aligned:
muted, separated from the final answer by a dashed divider. */}
{(hasSteps || hasLiveReasoning) && (
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
{hasSteps && <MessageSteps steps={message.steps!} />}
</div>
)}
{/* Final answer */}
{message.content && <Markdown content={message.content} />}
{/* Media attachments sent via the `send` tool (images / files). */}
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{message.attachments.map((att, i) =>
att.file_type === 'image' ? (
<img
key={i}
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
alt={att.file_name}
onLoad={() => onMediaLoad?.()}
onClick={() => window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')}
className="max-w-[320px] w-full rounded-xl border border-default cursor-zoom-in"
/>
) : att.file_type === 'video' ? (
<video
key={i}
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
controls
onLoadedData={() => onMediaLoad?.()}
className="max-w-[360px] w-full rounded-xl border border-default"
/>
) : (
<button
key={i}
type="button"
onClick={() => openAttachment(att)}
className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary hover:text-content cursor-pointer"
>
<FileIcon size={13} />
{att.file_name}
</button>
)
)}
</div>
)}
{showCursor && (
<div className="flex items-center gap-1 py-0.5">
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</div>
)}
{message.isStreaming && message.content && (
<span className="inline-block w-[6px] h-[14px] bg-accent ml-0.5 align-middle animate-blink" />
)}
{message.isCancelled && <div className="text-xs text-warning mt-1">{t('msg_cancelled')}</div>}
{message.error && <div className="text-xs text-danger mt-1">{message.error}</div>}
</div>
{/* Hover actions (only when finished) */}
{!message.isStreaming && (message.content || message.error) && (
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
<HoverAction onClick={copy} title={t('msg_copy')}>
{copied ? <Check size={13} /> : <Copy size={13} />}
</HoverAction>
{onRegenerate && (
<HoverAction onClick={() => onRegenerate(message.id)} title={t('msg_regenerate')}>
<RefreshCw size={13} />
</HoverAction>
)}
</div>
)}
</div>
</div>
)
}
export default MessageBubble

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