Compare commits

..

151 Commits

Author SHA1 Message Date
zhayujie
7d8751f04b Merge pull request #2961 from AaronZ345/agent/feishu-scheduler-cards 2026-07-17 16:50:13 +08:00
zhayujie
3ee7b164d4 Merge branch 'feishu-progress-2963' 2026-07-17 16:33:42 +08:00
zhayujie
d7abbd3baa Merge pull request #2963 from AaronZ345/agent/feishu-rich-progress-card
feat(feishu): add rich single-card progress
2026-07-17 16:16:53 +08:00
zhayujie
0db8a24441 Merge pull request #2962 from AaronZ345/agent/feishu-markdown-cards
feat(feishu): render Markdown replies as cards
2026-07-17 15:59:17 +08:00
zhayujie
2f86e9b608 docs: update kimi k3 docs 2026-07-17 14:55:00 +08:00
AaronZ345
9d8c6557ae feat(feishu): add scheduler action cards 2026-07-17 12:54:26 +08:00
AaronZ345
257eb5d598 feat(feishu): add rich progress cards 2026-07-17 12:54:26 +08:00
AaronZ345
9f63453e67 feat(feishu): render markdown replies as cards 2026-07-17 12:54:26 +08:00
zhayujie
5858bda854 Merge pull request #2959 from AaronZ345/fix/scheduler-preserve-hidden-fields
fix(scheduler): preserve hidden action fields on web edits
2026-07-17 12:13:23 +08:00
AaronZ345
c784c89fe3 fix(scheduler): preserve hidden fields on web edits 2026-07-17 11:58:44 +08:00
zhayujie
186dae059e feat(model): support kimi-k3 2026-07-17 11:35:07 +08:00
zhayujie
ded3d2c929 Merge branch 'agent/scheduler-silent-mode' 2026-07-17 10:53:46 +08:00
zhayujie
b55c592714 feat: refine scheduler silent mode to avoid mislabeling delivery tasks 2026-07-17 10:53:29 +08:00
zhayujie
1d226979d1 perf(browser): speed up navigation + fix desktop knowledge viewer links 2026-07-16 23:32:06 +08:00
zhayujie
6fad628551 feat(desktop): support win-legacy desktop CI 2026-07-16 23:02:37 +08:00
zhayujie
8b426ed71d feat: optimize desktop client style
- light theme colors, fix chat cancel logic, add clear-context feedback, polish thinking step icon/order, and align channels empty state with the web console
2026-07-16 19:31:56 +08:00
zhayujie
c7060c147d fix: windows desktop workflow 2026-07-16 17:00:21 +08:00
zhayujie
db49532211 feat(desktop): publish win7 client in workflow 2026-07-16 16:41:27 +08:00
zhayujie
1a52de241d fix: playwright version in win7 2026-07-16 16:19:49 +08:00
zhayujie
b35501e6ad feat(desktop): add win7 workflow 2026-07-16 15:08:15 +08:00
zhayujie
7f8f690497 refactor(tools): remove dead playwright-missing branches 2026-07-16 14:56:23 +08:00
zhayujie
356b02ac79 Merge pull request #2955 from zhayujie/feat-desktop-browser
feat(browser): reuse system Chrome/Edge, bundle playwright for desktop
2026-07-14 18:03:22 +08:00
zhayujie
5d55ec0f8c feat(browser): reuse system Chrome/Edge, bundle playwright for desktop 2026-07-14 18:02:27 +08:00
AaronZ345
d5fdd644cf feat: add silent scheduler agent tasks 2026-07-14 12:08:37 +08:00
zhayujie
eeb4b7981e feat(model): add gpt-5.6-luna, terra, sol 2026-07-14 11:27:03 +08:00
zhayujie
5f1c98881d feat(model): restore the claude-fable-5 model 2026-07-14 11:07:36 +08:00
zhayujie
94d0f56689 fix(desktop): make Windows dry-run signing work 2026-07-13 17:49:50 +08:00
zhayujie
4d690341a7 feat(desktop): add Windows code signing 2026-07-13 16:40:32 +08:00
zhayujie
d8c419227c Merge pull request #2950 from zhayujie/feat-mcp-oauth
feat(mcp): auto OAuth authorization for remote MCP servers
2026-07-13 12:01:32 +08:00
zhayujie
8c7cda89dc feat(mcp): support OAuth authorization for remote MCP servers 2026-07-13 12:00:14 +08:00
zhayujie
42a5cf9538 Merge pull request #2945 from weijun-xia/fix/edit-unify-fuzzy-uniqueness
refactor(edit): unify fuzzy uniqueness check with the fuzzy matcher
2026-07-12 23:09:51 +08:00
zhayujie
996406eb2a fix(desktop): resolve real login-shell PATH for backend 2026-07-10 01:03:04 +08:00
zhayujie
b98fbae6f6 fix(desktop): support web_password auth and fix password settings 2026-07-08 20:15:16 +08:00
zhayujie
4d87703e31 feat: update 2.1.3 docs 2026-07-08 16:43:15 +08:00
zhayujie
9ef64b7858 feat: release 2.1.3 2026-07-08 16:16:24 +08:00
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
weijun-xia
ed36ca99c0 refactor(edit): unify fuzzy uniqueness check with the fuzzy matcher
The uniqueness guard counted occurrences via normalize_for_fuzzy_match,
while fuzzy_find_text located matches with a whitespace-flexible regex,
so the two could disagree. Extract the pattern builder as a single
source of truth (_build_fuzzy_pattern) and add count_matches, which
counts with the same exact-then-fuzzy strategy used to locate and
replace. This is the optional follow-up suggested in the review of #2942.

Adds regression tests for exact and fuzzy multi-match rejection.
2026-07-08 11:13:30 +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
6vision
a1e733080d docs(wecom_bot): mention webhook (callback) mode in channel docs 2026-06-17 20:37:51 +08:00
zhayujie
3bc6e89b74 feat: cow desktop first version 2026-03-21 16:11:05 +08:00
210 changed files with 29859 additions and 677 deletions

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

@@ -0,0 +1,122 @@
// 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 (/win7.*\.exe$/i.test(base)) {
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
// segment so it never collides with the standard win exe in the same
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
platform = 'win-legacy'
slot = 'main'
} 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}`)

230
.github/workflows/release-win7.yml vendored Normal file
View File

@@ -0,0 +1,230 @@
name: Release Desktop (Win7 legacy)
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
#
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
#
# To support Win7 we must pin BOTH halves to the last versions that still
# target it:
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
# - Python 3.8 (last CPython to support Win7)
#
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
# then — exactly like the main pipeline — uploads it to R2 and registers a
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
# stays invisible until promoted, so it can't accidentally get served to Win10
# users). Because it's stamped with the SAME version as the standard release,
# the download page shows both Windows builds under one version row, and the
# /update feed serves each build to its own clients. Delete this file whenever
# legacy Windows support is no longer worth maintaining — the main pipeline is
# unaffected.
#
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
on:
workflow_dispatch:
inputs:
version:
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
type: string
default: "0.0.0"
permissions:
contents: read
jobs:
build:
name: Build Windows x64 (Win7 legacy)
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Python 3.8 is the last CPython that supports Windows 7. A backend built
# with it (via PyInstaller) still runs on Win7 even though the CI host is
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
# OS, not the build machine's.
- name: Set up Python 3.8
uses: actions/setup-python@v5
with:
python-version: "3.8"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build Python backend (PyInstaller, Python 3.8)
shell: bash
run: |
python -m pip install --upgrade pip
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
# for this legacy build. We rewrite them into a throwaway requirements
# file so the repo's source stays untouched (main pipeline keeps its
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
sed 's/^playwright==.*/playwright==1.48.0/' \
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
pip install -r /tmp/requirements-win7.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 "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
# this out of package.json so the repo's committed deps stay on Electron 33
# for the main pipeline. electron-builder reads the installed Electron
# version from node_modules, so this is all that's needed to package v22.
- name: Pin Electron to 22 (last Win7-capable)
working-directory: desktop
run: npm install --no-save electron@22.3.27
- name: Compile (vite + tsc)
working-directory: desktop
shell: bash
run: npm run build
# Same signing setup as the main pipeline: download the signtool CLI (URL
# from a repo variable so nothing is hardcoded in a public workflow). Only
# runs when a URL is configured; otherwise the build stays unsigned but
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
- name: Download Windows signing CLI
if: vars.SIGNTOOL_CLI_URL != ''
shell: bash
env:
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
run: |
mkdir -p "$RUNNER_TEMP/signtool"
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
if [ -z "$exe" ]; then
echo "signtool.exe not found in downloaded archive" >&2
find "$RUNNER_TEMP/signtool" -type f >&2
exit 1
fi
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
echo "resolved signtool: $exe"
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
# hook the main pipeline uses (signs app + backend + installer via the
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
# installer is still produced (unsigned). --publish never emits the exe
# without touching any feed. Invoke via node (not npx) to avoid the
# Windows npx.cmd wrapper returning early (see release.yml).
#
# -c.win.artifactName injects a "win7" segment into the file name
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
# builds differ by ${arch}: it keeps the legacy exe from colliding with
# the standard win exe in the same v<version>/ folder, and lets
# register-releases.mjs map it to the win-legacy platform by name.
- name: Build installer (electron-builder, Electron 22)
working-directory: desktop
shell: bash
env:
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
run: |
node node_modules/electron-builder/cli.js --win --x64 \
--config electron-builder.win.js \
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
--publish never
# Collect the installer + its blockmap (differential updates). The .yml
# feed is NOT uploaded: the /update Function generates it dynamically from
# D1 (same as the main pipeline), so it isn't needed here.
- name: Upload installer artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: cowagent-win7-x64
path: |
desktop/release/*.exe
desktop/release/*.blockmap
if-no-files-found: warn
retention-days: 7
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
# wrangler@latest works (the build job pins Node 20 for Electron 22).
#
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
# build — its "win7" name segment keeps them distinct — and register-releases
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
# publish workflow). Because the version matches the standard release, the
# download page merges both Windows builds into one version row.
publish:
name: Publish to R2 + D1
needs: build
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 [ -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 (use the artifact instead)."
fi
- name: Download build artifact
if: steps.guard.outputs.enabled == 'true'
uses: actions/download-artifact@v4
with:
name: cowagent-win7-x64
path: dist
- name: Upload installer to R2
if: steps.guard.outputs.enabled == 'true'
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' }}
VER: ${{ github.event.inputs.version }}
run: |
shopt -s nullglob
for f in dist/*.exe dist/*.blockmap; do
base="$(basename "$f")"
key="desktop/v${VER}/${base}"
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
--file "$f" --remote
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
done
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
- name: Register release row in D1
if: steps.guard.outputs.enabled == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ github.event.inputs.version }}
run: |
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

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

@@ -0,0 +1,336 @@
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
# Download the Windows signing CLI. The URL comes from a repo variable, so
# nothing about the signing setup is hardcoded in a public workflow. Only
# runs on the Windows leg and only when a URL is set; otherwise the build
# stays unsigned. SIGNTOOL_PATH is exported for the next step's
# electron-builder.win.js to invoke.
- name: Download Windows signing CLI
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
shell: bash
env:
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
run: |
mkdir -p "$RUNNER_TEMP/signtool"
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
# Unzip and locate the signtool executable regardless of nesting.
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
if [ -z "$exe" ]; then
echo "signtool.exe not found in downloaded archive" >&2
find "$RUNNER_TEMP/signtool" -type f >&2
exit 1
fi
# Normalize to a Windows-style path for execFileSync in Node.
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
echo "resolved signtool: $exe"
- 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 }}
# Windows code signing via the signing CLI. Credentials are
# secrets; SIGNTOOL_PATH was exported by the download step above.
# COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
# with a self-signed cert before buying a real one — no quota used.
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
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).
# macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
# a local .pfx (EV private keys can't be exported since 2023); it signs
# via the CLI wired into electron-builder.win.js instead, using the
# SIGNTOOL_* env already set above — nothing to export here.
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
;;
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: each platform loads its OWN dynamic config.
# mac -> electron-builder.js (injects mac.binaries for signing)
# win -> electron-builder.win.js (wires the sign hook; electron-builder
# signs the app, backend and installer)
# HISTORY: passing --config on Windows previously broke the build (no
# installer, job still green). That happened because the MAC config
# (electron-builder.js) was a no-op on Windows yet still disturbed the
# run. The fix is a DEDICATED win config that correctly extends
# config.win — not sharing the mac one. If a build ever runs WITHOUT
# signing configured, electron-builder.win.js still returns the base
# config unchanged (sign hook just skips), so the installer is still
# produced.
#
# 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" ;;
win) config_arg="--config electron-builder.win.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

17
.gitignore vendored
View File

@@ -45,3 +45,20 @@ dist/
build/ build/
*.egg-info/ *.egg-info/
.cow.pid .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

@@ -12,7 +12,7 @@
</p> </p>
<p align="center"> <p align="center">
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>] [English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/zh/README-Hant.md">繁體中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
</p> </p>
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, 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 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.
@@ -24,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/intro/index">📖 Docs</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 Quick Start</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://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> <a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
</p> </p>
@@ -95,6 +96,8 @@ cow skill install <name> # install a skill
cow install-browser # install browser automation 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/> <br/>
## 🤖 Models ## 🤖 Models
@@ -103,14 +106,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding | | Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | | | [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | | [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | | [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | | [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ | | [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | | | [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | | [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | | [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | | [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
@@ -199,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
## 🏷 Changelog ## 🏷 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.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.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.

View File

@@ -424,6 +424,11 @@ def run_evolution_for_session(
enable_skills=True, enable_skills=True,
runtime_info=getattr(agent, "runtime_info", None), 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. # Reuse the live model so it follows the user's configured model.
review_agent.model = agent.model review_agent.model = agent.model
# Inject the evolution task brief AFTER the full system prompt: the agent # Inject the evolution task brief AFTER the full system prompt: the agent

View File

@@ -17,6 +17,7 @@ import shutil
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional, Iterable from typing import Optional, Iterable
from urllib.parse import quote
from common.log import logger from common.log import logger
from config import conf from config import conf
@@ -32,6 +33,10 @@ class KnowledgeService:
PROTECTED_FILES = {"index.md", "log.md"} PROTECTED_FILES = {"index.md", "log.md"}
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]') 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): def __init__(self, workspace_root: str, memory_manager=None):
self.workspace_root = os.path.abspath(workspace_root) self.workspace_root = os.path.abspath(workspace_root)
@@ -75,7 +80,14 @@ class KnowledgeService:
def _manager(self): def _manager(self):
if self._memory_manager is None: if self._memory_manager is None:
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root)) # 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 return self._memory_manager
@staticmethod @staticmethod
@@ -100,9 +112,9 @@ class KnowledgeService:
raise error[0] raise error[0]
return result[0] if result else None return result[0] if result else None
def _sync_index(self, old_paths: Iterable[str]): def _sync_index(self, old_paths: Iterable[str], force: bool = False):
old_paths = sorted(set(old_paths)) old_paths = sorted(set(old_paths))
if not old_paths: if not old_paths and not force:
return return
manager = self._manager() manager = self._manager()
for rel_path in old_paths: for rel_path in old_paths:
@@ -110,6 +122,195 @@ class KnowledgeService:
manager.mark_dirty() manager.mark_dirty()
self._run_sync(manager.sync()) 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: def create_category(self, path: str) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category") rel_path, full_path = self._resolve_path(path, kind="category")
if full_path.exists(): if full_path.exists():
@@ -283,14 +484,18 @@ class KnowledgeService:
if not is_root: if not is_root:
stats["pages"] += 1 stats["pages"] += 1
stats["size"] += size stats["size"] += size
title = name.replace(".md", "") # Prefer the H1 heading as a readable title for normal docs.
try: # System files (index.md / log.md) keep their filename so the
with open(full, "r", encoding="utf-8") as f: # tree never hides what they actually are.
first_line = f.readline().strip() title = name[:-3]
if first_line.startswith("# "): if name not in self.PROTECTED_FILES:
title = first_line[2:].strip() try:
except Exception: with open(full, "r", encoding="utf-8") as f:
pass 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}) files.append({"name": name, "title": title, "size": size})
return files, children return files, children
@@ -416,6 +621,15 @@ class KnowledgeService:
result = self.delete_documents(payload.get("paths") or []) result = self.delete_documents(payload.get("paths") or [])
elif action == "move_documents": elif action == "move_documents":
result = self.move_documents(payload.get("paths") or [], payload.get("target_category")) 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: else:
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
return {"action": action, "code": 200, "message": "success", "payload": result} return {"action": action, "code": 200, "message": "success", "payload": result}

View File

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

View File

@@ -16,6 +16,7 @@ from agent.memory.embedding.provider import (
OpenAIEmbeddingProvider, OpenAIEmbeddingProvider,
create_embedding_provider, create_embedding_provider,
) )
from agent.memory.embedding.factory import create_default_embedding_provider
from agent.memory.embedding.rebuild import ( from agent.memory.embedding.rebuild import (
RebuildResult, RebuildResult,
clear_index, clear_index,
@@ -33,6 +34,7 @@ __all__ = [
"EmbeddingProvider", "EmbeddingProvider",
"OpenAIEmbeddingProvider", "OpenAIEmbeddingProvider",
"create_embedding_provider", "create_embedding_provider",
"create_default_embedding_provider",
"RebuildResult", "RebuildResult",
"clear_index", "clear_index",
"rebuild_in_process", "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) - dashscope (Aliyun Tongyi text-embedding-v4)
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark) - doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
- zhipu (ZhipuAI embedding-3) - zhipu (ZhipuAI embedding-3)
- custom (any OpenAI-compatible endpoint)
Vendor keys here intentionally match the project's bot_type constants in Vendor keys here intentionally match the project's bot_type constants in
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI). 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 All providers share a single OpenAI-compatible REST client. Vendor-specific
behaviors (truncation, query instruction prefix) are configured via metadata. behaviors (truncation, query instruction prefix) are configured via metadata.
""" """
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
"query_instruction": "", "query_instruction": "",
"max_batch_size": 64, "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"] 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( return OpenAIEmbeddingProvider(
model=model or meta["default_model"], model=resolved_model,
api_key=api_key, api_key=api_key,
api_base=api_base or meta["default_base_url"], api_base=resolved_base,
extra_headers=extra_headers, extra_headers=extra_headers,
dimensions=final_dim, dimensions=final_dim,
supports_dim_param=meta["supports_dim_param"], 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] Workspace: {workspace_root}")
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}") 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 = create_default_embedding_provider()
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
if embedding_provider is None: if embedding_provider is None:
logger.error( logger.error(
"[RebuildIndex] No embedding provider could be initialized. " "[RebuildIndex] No embedding provider could be initialized. "

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) 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) 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: if not self.llm_model:
logger.warning("[DeepDream] No LLM model available, skipping") logger.warning("[DeepDream] No LLM model available, skipping")
return False return False

View File

@@ -379,6 +379,12 @@ class AgentStreamExecutor:
self._emit_event("agent_start") 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 = "" final_response = ""
turn = 0 turn = 0
@@ -702,6 +708,70 @@ class AgentStreamExecutor:
return final_response 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, def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]: _overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
""" """
@@ -742,7 +812,7 @@ class AgentStreamExecutor:
tools_schema = None tools_schema = None
if self.tools: if self.tools:
tools_schema = [] tools_schema = []
for tool in self.tools.values(): for tool in self._select_tools_for_injection():
input_schema = tool.params input_schema = tool.params
try: try:
dynamic = (tool.get_json_schema() or {}).get("parameters") or {} dynamic = (tool.get_json_schema() or {}).get("parameters") or {}

View File

@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
Terminal = _optional_tools.get('Terminal') Terminal = _optional_tools.get('Terminal')
# BrowserTool (requires playwright) # BrowserTool: playwright is soft-imported inside browser_service, so this
# import always succeeds even without playwright. Readiness (playwright pkg /
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
def _import_browser_tool(): def _import_browser_tool():
from common.log import logger from common.log import logger
try: try:
from agent.tools.browser.browser_tool import BrowserTool from agent.tools.browser.browser_tool import BrowserTool
return BrowserTool return BrowserTool
except ImportError as e:
logger.info(
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
return None
except Exception as e: except Exception as e:
logger.error(f"[Tools] BrowserTool failed to load: {e}") logger.error(f"[Tools] BrowserTool failed to load: {e}")
return None return None

View File

@@ -202,8 +202,12 @@ SAFETY:
total_bytes = len(output.encode('utf-8')) total_bytes = len(output.encode('utf-8'))
if total_bytes > DEFAULT_MAX_BYTES: if total_bytes > DEFAULT_MAX_BYTES:
# Save full output to temp file # Save full output to temp file. encoding='utf-8' is required:
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f: # 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) f.write(output)
temp_file_path = f.name temp_file_path = f.name

View File

@@ -0,0 +1,290 @@
"""
Browser environment detection and capability resolution.
Centralizes everything about *where* a usable browser engine comes from, so
both the runtime (browser_service) and the installer (cli/commands/install)
agree on the same decisions:
- Whether the `playwright` Python package is importable.
- Whether a system Chrome / Edge is installed (Playwright can drive it via
the `channel="chrome"/"msedge"` launcher, no download needed).
- Where Playwright's own Chromium download lives (redirected to the writable
data dir so it survives frozen/desktop app updates).
Resolution priority (see resolve_engine):
1. system-chrome -> drive the user's installed Chrome / Edge (zero download)
2. playwright-chromium -> Playwright's own Chromium, if already downloaded
3. none -> nothing usable yet; caller should trigger onboarding
"""
import os
import sys
import shutil
from typing import Optional, Dict, Any
from common.log import logger
# Playwright browser channels we accept for the "system-chrome" mode, in
# preference order. "chrome" covers stable Google Chrome; "msedge" is the
# Chromium-based Edge shipped on every Windows 10/11.
_PREFERRED_CHANNELS = ("chrome", "msedge", "chrome-beta", "msedge-beta")
def get_data_root() -> str:
"""Writable data root (~/.cow on desktop, else CWD-based).
Mirrors the logic in common/log.py without importing config, to avoid a
circular import. The desktop build sets COW_DATA_DIR; source deployments
fall back to the current working directory.
"""
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
return os.path.expanduser(data_dir)
return os.getcwd()
def browsers_download_dir() -> str:
"""Directory Playwright downloads its Chromium into.
We pin it under the writable data root (~/.cow/ms-playwright) rather than
Playwright's default (~/.cache/ms-playwright or %USERPROFILE%). This keeps
the frozen desktop build self-contained and makes the download survive app
updates. Set as PLAYWRIGHT_BROWSERS_PATH for both install and runtime.
"""
return os.path.join(get_data_root(), "ms-playwright")
def apply_browsers_path_env() -> None:
"""Point Playwright at our pinned download dir via env var (idempotent).
Only set it when not already provided by the user, so power users can
override the location. Must run before importing playwright's launcher.
"""
if not os.environ.get("PLAYWRIGHT_BROWSERS_PATH"):
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
def is_frozen() -> bool:
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
In this mode sys.executable is the frozen exe (no pip), so the installer
must skip `pip install` and only download the browser binary.
"""
return bool(getattr(sys, "frozen", False))
def is_desktop() -> bool:
"""True when running as the Electron desktop client (dev or packaged).
The desktop shell always sets COW_DESKTOP=1 (see python-manager.ts), both in
`npm run dev` (runs app.py with the user's Python) and in the packaged build
(frozen exe). Desktop users have no `cow` CLI, so onboarding must point them
at the in-chat `/install-browser` command rather than a terminal command.
"""
return os.environ.get("COW_DESKTOP") == "1"
def has_playwright_package() -> bool:
"""True if the `playwright` Python package can be imported."""
try:
import playwright # noqa: F401
return True
except Exception:
return False
def _windows_program_dirs() -> list:
dirs = []
for var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
val = os.environ.get(var)
if val:
dirs.append(val)
return dirs
def detect_system_chrome() -> Optional[Dict[str, str]]:
"""Locate an installed Chromium-based browser Playwright can drive.
Returns a dict {"channel": <playwright channel>, "path": <exe path>} for
the first match, or None. The `channel` is what we hand to Playwright's
launcher; `path` is only informational (Playwright resolves the channel on
its own, but we keep the path for logging / onboarding messages).
"""
candidates = []
if sys.platform == "darwin":
candidates = [
("chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
("msedge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
("chrome-beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"),
]
elif sys.platform == "win32":
prog_dirs = _windows_program_dirs()
for base in prog_dirs:
candidates.append(("chrome", os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")))
candidates.append(("msedge", os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe")))
else:
# Linux: rely on PATH lookups for the common binaries.
path_lookups = [
("chrome", "google-chrome"),
("chrome", "google-chrome-stable"),
("chrome", "chromium"),
("chrome", "chromium-browser"),
("msedge", "microsoft-edge"),
]
for channel, binary in path_lookups:
found = shutil.which(binary)
if found:
return {"channel": channel, "path": found}
for channel, path in candidates:
if path and os.path.exists(path):
return {"channel": channel, "path": path}
return None
def has_downloaded_chromium() -> bool:
"""True if Playwright already has a Chromium download available.
We check our pinned download dir for a chromium-* folder. This is a
lightweight heuristic (avoids importing/launching Playwright just to probe)
and matches how Playwright lays browsers out on disk.
"""
download_dir = browsers_download_dir()
if not os.path.isdir(download_dir):
return False
try:
for name in os.listdir(download_dir):
# Playwright names its browser dirs like "chromium-1140",
# "chromium_headless_shell-1140".
if name.startswith("chromium"):
return True
except OSError:
pass
return False
def resolve_engine(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Decide which browser engine to use, given config and environment.
Returns a dict describing the launch strategy:
{
"mode": "system-chrome" | "playwright-chromium" | "none",
"channel": Optional[str], # for system-chrome
"path": Optional[str], # for system-chrome (informational)
"has_playwright": bool,
"reason": str, # human-readable, for logging / onboarding
}
Config keys under tools.browser that influence this:
- engine: "auto" (default) | "system-chrome" | "chromium"
Force a specific engine. "auto" prefers system Chrome, then falls
back to a downloaded Chromium.
- prefer_system_browser: bool (default True). When False under "auto",
skip system Chrome and go straight to Playwright's Chromium.
"""
config = config or {}
apply_browsers_path_env()
has_pw = has_playwright_package()
engine_pref = str(config.get("engine", "auto")).strip().lower()
prefer_system = config.get("prefer_system_browser", True)
if not has_pw:
return {
"mode": "none",
"channel": None,
"path": None,
"has_playwright": False,
"reason": "playwright package not available",
}
system = None
if engine_pref in ("auto", "system-chrome") and prefer_system:
system = detect_system_chrome()
if engine_pref == "system-chrome":
# Explicitly requested: use system Chrome if found, else report none.
if system:
return {
"mode": "system-chrome",
"channel": system["channel"],
"path": system["path"],
"has_playwright": True,
"reason": f"using system browser ({system['channel']})",
}
return {
"mode": "none",
"channel": None,
"path": None,
"has_playwright": True,
"reason": "engine=system-chrome but no Chrome/Edge found",
}
if engine_pref == "chromium":
# Explicitly requested Playwright's own Chromium.
if has_downloaded_chromium():
return {
"mode": "playwright-chromium",
"channel": None,
"path": None,
"has_playwright": True,
"reason": "using downloaded Playwright Chromium",
}
return {
"mode": "none",
"channel": None,
"path": None,
"has_playwright": True,
"reason": "engine=chromium but Chromium not downloaded yet",
}
# auto: system Chrome first, then downloaded Chromium.
if system:
return {
"mode": "system-chrome",
"channel": system["channel"],
"path": system["path"],
"has_playwright": True,
"reason": f"auto: using system browser ({system['channel']})",
}
if has_downloaded_chromium():
return {
"mode": "playwright-chromium",
"channel": None,
"path": None,
"has_playwright": True,
"reason": "auto: using downloaded Playwright Chromium",
}
return {
"mode": "none",
"channel": None,
"path": None,
"has_playwright": True,
"reason": "no system Chrome/Edge and no downloaded Chromium",
}
def capability_summary(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""High-level browser capability status, for onboarding / diagnostics.
Combines resolve_engine with raw detection flags so the UI / tool layer can
craft a helpful message (e.g. "Chrome detected, click to enable" vs
"no browser, will download ~150MB").
"""
engine = resolve_engine(config)
system = detect_system_chrome()
return {
"ready": engine["mode"] != "none",
"engine": engine,
"has_playwright": engine["has_playwright"],
"has_system_chrome": system is not None,
"system_chrome": system,
"has_downloaded_chromium": has_downloaded_chromium(),
"is_frozen": is_frozen(),
"is_desktop": is_desktop(),
"browsers_dir": browsers_download_dir(),
}

View File

@@ -326,12 +326,19 @@ class BrowserService:
# - persistent: launch with launch_persistent_context using a user_data_dir # - persistent: launch with launch_persistent_context using a user_data_dir
# so cookies / login state survive across runs (default). # so cookies / login state survive across runs (default).
# - fresh: classic launch + new_context, clean state every run. # - fresh: classic launch + new_context, clean state every run.
#
# Within persistent/fresh, the actual Chromium binary is resolved by
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
# download) is preferred, falling back to Playwright's own downloaded
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
# when driving a system browser, else None (bundled Chromium).
cdp_endpoint = self._config.get("cdp_endpoint") or "" cdp_endpoint = self._config.get("cdp_endpoint") or ""
persistent_flag = self._config.get("persistent", True) persistent_flag = self._config.get("persistent", True)
user_data_dir_cfg = self._config.get("user_data_dir") user_data_dir_cfg = self._config.get("user_data_dir")
if user_data_dir_cfg is None: if user_data_dir_cfg is None:
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
self._channel: Optional[str] = None
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else "" self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
if self._cdp_endpoint: if self._cdp_endpoint:
self._launch_mode = "cdp" self._launch_mode = "cdp"
@@ -343,6 +350,22 @@ class BrowserService:
self._launch_mode = "fresh" self._launch_mode = "fresh"
self._user_data_dir = "" self._user_data_dir = ""
# Resolve which browser engine to drive (system Chrome vs downloaded
# Chromium). Deferred detection failures are surfaced at launch time.
if self._launch_mode != "cdp":
try:
from agent.tools.browser.browser_env import resolve_engine
engine = resolve_engine(self._config)
if engine["mode"] == "system-chrome":
self._channel = engine["channel"]
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
elif engine["mode"] == "playwright-chromium":
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
else:
logger.info(f"[Browser] No ready engine yet: {engine['reason']}")
except Exception as e:
logger.debug(f"[Browser] Engine resolution skipped: {e}")
# Idle auto-release # Idle auto-release
idle_cfg = self._config.get("idle_timeout") idle_cfg = self._config.get("idle_timeout")
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
@@ -428,11 +451,30 @@ class BrowserService:
def _launch_browser(self): def _launch_browser(self):
"""Launch / connect Chromium on the background thread.""" """Launch / connect Chromium on the background thread."""
# Point Playwright at our pinned download dir before any launch so a
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
try:
from agent.tools.browser.browser_env import apply_browsers_path_env
apply_browsers_path_env()
except Exception as e:
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
if self._headless is None: if self._headless is None:
headless_cfg = self._config.get("headless") headless_cfg = self._config.get("headless")
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless() self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
launch_args = ["--disable-dev-shm-usage"] launch_args = [
"--disable-dev-shm-usage",
# Trim first-launch overhead: skip the first-run wizard, the default
# browser prompt, and Chrome's background/component network chatter.
# These have no effect on page interaction but noticeably speed up
# cold starts and each navigation.
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,OptimizationHints",
]
if self._headless: if self._headless:
launch_args.append("--no-sandbox") launch_args.append("--no-sandbox")
@@ -475,12 +517,20 @@ class BrowserService:
logger.info("[Browser] Browser ready") logger.info("[Browser] Browser ready")
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
"""Classic launch: brand new Chromium with an empty context.""" """Classic launch: brand new Chromium with an empty context.
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
self._browser = self._playwright.chromium.launch( When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
headless=self._headless, the user's installed system browser instead of its own Chromium.
args=launch_args, """
) engine_label = f"system:{self._channel}" if self._channel else "chromium"
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
launch_kwargs: Dict[str, Any] = {
"headless": self._headless,
"args": launch_args,
}
if self._channel:
launch_kwargs["channel"] = self._channel
self._browser = self._playwright.chromium.launch(**launch_kwargs)
self._context = self._browser.new_context( self._context = self._browser.new_context(
viewport=viewport, viewport=viewport,
user_agent=user_agent, user_agent=user_agent,
@@ -491,18 +541,25 @@ class BrowserService:
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
"""Launch Chromium with a persistent user_data_dir so login state survives.""" """Launch Chromium with a persistent user_data_dir so login state survives."""
os.makedirs(self._user_data_dir, exist_ok=True) os.makedirs(self._user_data_dir, exist_ok=True)
engine_label = f"system:{self._channel}" if self._channel else "chromium"
logger.info( logger.info(
f"[Browser] Launching Chromium (persistent, headless={self._headless}, " f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
f"profile={self._user_data_dir})" f"profile={self._user_data_dir})"
) )
persistent_kwargs: Dict[str, Any] = {
"user_data_dir": self._user_data_dir,
"headless": self._headless,
"args": launch_args,
"viewport": viewport,
"user_agent": user_agent,
}
# When driving a system browser, let it use its real UA instead of the
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
if self._channel:
persistent_kwargs["channel"] = self._channel
persistent_kwargs.pop("user_agent", None)
try: try:
self._context = self._playwright.chromium.launch_persistent_context( self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
user_data_dir=self._user_data_dir,
headless=self._headless,
args=launch_args,
viewport=viewport,
user_agent=user_agent,
)
except Exception as e: except Exception as e:
# Profile is locked when another Chromium instance already holds it. # Profile is locked when another Chromium instance already holds it.
msg = str(e).lower() msg = str(e).lower()
@@ -687,11 +744,15 @@ class BrowserService:
except Exception as e: except Exception as e:
return {"error": f"Navigation failed: {e}"} return {"error": f"Navigation failed: {e}"}
# SPAs keep long-lived connections (websockets, polling, analytics) and
# rarely reach true "networkidle", so waiting the full timeout is wasted
# time. domcontentloaded already gives a usable DOM; give the page a
# short grace period for initial render/XHR, then proceed.
try: try:
page.wait_for_load_state("networkidle", timeout=8000) page.wait_for_load_state("networkidle", timeout=1500)
except Exception: except Exception:
pass pass
page.wait_for_timeout(500) page.wait_for_timeout(300)
try: try:
title = page.title() title = page.title()

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. - fresh: Set `persistent` to false to fall back to a clean context every run.
""" """
import ipaddress
import json import json
import os import os
import socket
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from urllib.parse import urlparse
from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.browser.browser_service import BrowserService from agent.tools.browser.browser_service import BrowserService
from common.log import logger 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): class BrowserTool(BaseTool):
"""Single tool exposing all browser actions via an 'action' parameter.""" """Single tool exposing all browser actions via an 'action' parameter."""
@@ -121,6 +130,95 @@ class BrowserTool(BaseTool):
BrowserTool._shared_service = self._service BrowserTool._shared_service = self._service
return 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 _check_engine_ready(self) -> Optional[ToolResult]:
"""Return an actionable onboarding message if no browser engine is ready.
Returns None when a system Chrome/Edge or a downloaded Chromium is
available (so the tool can proceed). Otherwise returns a ToolResult with
clear guidance so the agent asks the user to enable the browser instead
of surfacing a raw Playwright launch error. CDP mode is exempt (the
endpoint is external and validated at connect time).
"""
if self.config.get("cdp_endpoint"):
return None
try:
from agent.tools.browser.browser_env import capability_summary
summary = capability_summary(self.config)
except Exception as e:
logger.debug(f"[Browser] capability probe failed: {e}")
return None
if summary.get("ready"):
return None
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
# in-chat `/install-browser` command. Source / web / server installs use
# the `cow install-browser` terminal command.
install_hint = (
"reply `/install-browser`" if summary.get("is_desktop")
else "run `cow install-browser` in a terminal"
)
return ToolResult.fail(
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
"skipped automatically if Google Chrome is already installed). "
"Do not retry until the user confirms."
)
def execute(self, args: Dict[str, Any]) -> ToolResult: def execute(self, args: Dict[str, Any]) -> ToolResult:
action = args.get("action", "").strip().lower() action = args.get("action", "").strip().lower()
if not action: if not action:
@@ -131,6 +229,13 @@ class BrowserTool(BaseTool):
valid = ", ".join(sorted(self._ACTION_MAP.keys())) valid = ", ".join(sorted(self._ACTION_MAP.keys()))
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}") return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
# Preflight: on desktop the playwright package is bundled but the browser
# binary may be missing; return actionable onboarding instead of a cryptic
# launch failure.
not_ready = self._check_engine_ready()
if not_ready is not None:
return not_ready
try: try:
return handler(self, args) return handler(self, args)
except Exception as e: except Exception as e:
@@ -148,6 +253,16 @@ class BrowserTool(BaseTool):
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc. # Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
if "://" not in url and not url.startswith(("about:", "data:")): if "://" not in url and not url.startswith(("about:", "data:")):
url = "https://" + url 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) timeout = args.get("timeout", 30000)
service = self._get_service() service = self._get_service()
result = service.navigate(url, timeout=timeout) result = service.navigate(url, timeout=timeout)

View File

@@ -13,7 +13,7 @@ from agent.tools.utils.diff import (
detect_line_ending, detect_line_ending,
normalize_to_lf, normalize_to_lf,
restore_line_endings, restore_line_endings,
normalize_for_fuzzy_match, count_matches,
fuzzy_find_text, fuzzy_find_text,
generate_diff_string generate_diff_string
) )
@@ -110,10 +110,10 @@ class Edit(BaseTool):
"The old text must match exactly including all whitespace and newlines." "The old text must match exactly including all whitespace and newlines."
) )
# Calculate occurrence count (use fuzzy normalized content for consistency) # Count occurrences with the same matcher used to locate and
fuzzy_content = normalize_for_fuzzy_match(normalized_content) # replace (fuzzy_find_text), so the uniqueness guard cannot
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text) # disagree with what actually gets replaced.
occurrences = fuzzy_content.count(fuzzy_old_text) occurrences = count_matches(normalized_content, normalized_old_text)
if occurrences > 1: if occurrences > 1:
return ToolResult.fail( return ToolResult.fail(

View File

@@ -21,6 +21,48 @@ from common.log import logger
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"} _STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
# Optional callback invoked after an OAuth authorization completes, so the
# tool manager can bring the newly-authorized server online. Signature:
# reload_fn(server_name: str) -> None. Installed by the tool manager.
_reload_callback = None
def set_reload_callback(fn) -> None:
"""Register a callback fired after a server's OAuth flow succeeds."""
global _reload_callback
_reload_callback = fn
def notify_server_authorized(server_name: str) -> None:
"""Called by the web callback once tokens are stored for a server."""
fn = _reload_callback
if fn is None:
logger.debug(f"[MCP:{server_name}] Authorized but no reload callback registered")
return
try:
fn(server_name)
except Exception as e:
logger.warning(f"[MCP:{server_name}] reload callback failed: {e}")
def _oauth_redirect_uri() -> str:
"""Build the OAuth redirect URI served by the web console callback.
Priority: explicit mcp_oauth_redirect_base config, otherwise the local
web console address (127.0.0.1:<web_port>). Both point at the shared
/mcp/oauth/callback route.
"""
try:
from config import conf
base = (conf().get("mcp_oauth_redirect_base") or "").strip().rstrip("/")
if not base:
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
base = f"http://127.0.0.1:{port}"
except Exception:
base = "http://127.0.0.1:9899"
return f"{base}/mcp/oauth/callback"
class McpClient: class McpClient:
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports.""" """Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
@@ -56,6 +98,13 @@ class McpClient:
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization) self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
# OAuth state (streamable-http only). Lazily created when the server
# responds with 401 and the user has not supplied a static token.
self._oauth = None # OAuthHandler instance
# Set to True once a 401 could not be satisfied and the user must
# complete the browser authorization. Callers can surface this state.
self.needs_auth: bool = False
# Shared state # Shared state
self._next_id = 1 self._next_id = 1
self._id_lock = threading.Lock() self._id_lock = threading.Lock()
@@ -325,13 +374,118 @@ class McpClient:
if isinstance(extra_headers, dict): if isinstance(extra_headers, dict):
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()} self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
# Restore any previously stored OAuth credentials for this server so a
# restart reuses the token instead of forcing re-authorization.
self._maybe_load_oauth()
return self._handshake() return self._handshake()
# ------------------------------------------------------------------
# OAuth helpers (streamable-http only)
# ------------------------------------------------------------------
def _has_static_auth(self) -> bool:
"""True when the user supplied their own Authorization header."""
return any(k.lower() == "authorization" for k in self._http_headers)
def _maybe_load_oauth(self) -> None:
"""Attach an OAuthHandler when stored credentials exist for this server."""
if self._has_static_auth():
return
try:
from agent.tools.mcp.mcp_oauth import OAuthHandler, load_server_record
except Exception:
return
rec = load_server_record(self.name)
# Only create a handler when we have something to reuse; otherwise it
# is created lazily on the first 401.
if rec.get("access_token") or rec.get("client_id"):
self._oauth = OAuthHandler(
server_name=self.name,
resource_url=self._http_url,
redirect_uri=_oauth_redirect_uri(),
scope=self.config.get("scope", ""),
)
def _current_bearer(self) -> Optional[str]:
"""Return a valid access token, refreshing if needed."""
if self._oauth is None:
return None
return self._oauth.get_valid_access_token()
def _begin_oauth(self, www_authenticate: str = "") -> None:
"""Kick off the OAuth flow after a 401: discover, register, prompt user."""
if self._has_static_auth():
return
try:
from agent.tools.mcp.mcp_oauth import OAuthHandler
except Exception as e:
logger.warning(f"[MCP:{self.name}] OAuth module unavailable: {e}")
return
if self._oauth is None:
self._oauth = OAuthHandler(
server_name=self.name,
resource_url=self._http_url,
redirect_uri=_oauth_redirect_uri(),
scope=self.config.get("scope", ""),
)
if not self._oauth.ensure_registered(www_authenticate):
logger.warning(
f"[MCP:{self.name}] OAuth discovery/registration failed; "
f"cannot authorize automatically"
)
return
auth_url = self._oauth.build_authorization_url()
if not auth_url:
logger.warning(f"[MCP:{self.name}] Failed to build authorization URL")
return
self.needs_auth = True
logger.warning(
f"[MCP:{self.name}] ⚠️ Authorization required. Open this URL in a "
f"browser to authorize, then this server will come online automatically:\n"
f" {auth_url}"
)
# On a machine with a local browser (desktop/dev), open it directly.
if os.environ.get("COW_DESKTOP") == "1" or not os.environ.get("COW_HEADLESS"):
try:
import webbrowser
webbrowser.open(auth_url)
except Exception:
pass
def _streamable_http_send(self, message: dict) -> dict: def _streamable_http_send(self, message: dict) -> dict:
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped).""" """POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
return self._streamable_http_post(message, expect_response=True) return self._streamable_http_post(message, expect_response=True)
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict: def _handle_401(self, err, message: dict, expect_response: bool, retried: bool) -> dict:
"""Handle a 401: refresh the token and retry once, else begin OAuth."""
www_auth = ""
try:
www_auth = err.headers.get("WWW-Authenticate", "") or ""
except Exception:
pass
try:
err.read()
except Exception:
pass
# First try a silent refresh with the stored refresh token.
if not retried and self._oauth is not None and self._oauth.refresh():
logger.info(f"[MCP:{self.name}] Token refreshed after 401, retrying")
return self._streamable_http_post(message, expect_response, _retried=True)
# No usable token — start (or restart) the interactive OAuth flow.
self._begin_oauth(www_auth)
raise IOError(
f"[MCP:{self.name}] streamable-http HTTP 401: authorization required "
f"(complete the OAuth flow to enable this server)"
)
def _streamable_http_post(self, message: dict, expect_response: bool, _retried: bool = False) -> dict:
""" """
POST a JSON-RPC message over Streamable HTTP. POST a JSON-RPC message over Streamable HTTP.
@@ -351,6 +505,12 @@ class McpClient:
if sid: if sid:
headers["Mcp-Session-Id"] = sid headers["Mcp-Session-Id"] = sid
headers.update(self._http_headers) headers.update(self._http_headers)
# Inject OAuth bearer token when we have one (unless the user set a
# static Authorization header, which takes precedence).
if not self._has_static_auth():
token = self._current_bearer()
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request( req = urllib.request.Request(
self._http_url, self._http_url,
@@ -362,6 +522,9 @@ class McpClient:
try: try:
resp = urllib.request.urlopen(req, timeout=30) resp = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
# 401 is the spec-compliant "needs authorization" signal.
if e.code == 401 and not self._has_static_auth():
return self._handle_401(e, message, expect_response, _retried)
# Surface the server-provided error body for easier debugging # Surface the server-provided error body for easier debugging
detail = "" detail = ""
try: try:

View File

@@ -0,0 +1,466 @@
"""
MCP OAuth 2.1 client (authorization code + PKCE) with zero external deps.
Implements the subset of the MCP authorization spec needed to connect to
remote MCP servers that guard their endpoint behind OAuth (e.g. Xmind):
1. Metadata discovery via RFC 9728 (protected-resource) + RFC 8414
(authorization-server) .well-known documents.
2. Dynamic Client Registration (RFC 7591) to obtain a client_id.
3. PKCE (RFC 7636, S256) authorization-code flow.
4. Token exchange + refresh, persisted to ~/.cow/mcp_oauth.json.
The actual browser round-trip is completed out-of-band: McpClient generates
an authorization URL, the user opens it, and the web console callback
(/mcp/oauth/callback) feeds the returned code back into finish_authorization().
"""
import base64
import hashlib
import json
import os
import secrets
import threading
import time
import urllib.parse
import urllib.request
import urllib.error
from typing import Optional
from common.log import logger
# ------------------------------------------------------------------
# Token store: ~/.cow/mcp_oauth.json {server_name: {...credentials...}}
# ------------------------------------------------------------------
_STORE_LOCK = threading.Lock()
def _store_path() -> str:
base = os.path.expanduser("~/.cow")
try:
os.makedirs(base, exist_ok=True)
except OSError:
pass
return os.path.join(base, "mcp_oauth.json")
def _load_store() -> dict:
path = _store_path()
if not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception as e:
logger.warning(f"[MCP-OAuth] Failed to read token store: {e}")
return {}
def _save_store(store: dict) -> None:
path = _store_path()
tmp = f"{path}.tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(store, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
# Credentials file: restrict to owner read/write when possible.
try:
os.chmod(path, 0o600)
except OSError:
pass
except Exception as e:
logger.warning(f"[MCP-OAuth] Failed to persist token store: {e}")
def load_server_record(server_name: str) -> dict:
with _STORE_LOCK:
return dict(_load_store().get(server_name, {}))
def save_server_record(server_name: str, record: dict) -> None:
with _STORE_LOCK:
store = _load_store()
store[server_name] = record
_save_store(store)
def clear_server_record(server_name: str) -> None:
with _STORE_LOCK:
store = _load_store()
if server_name in store:
store.pop(server_name, None)
_save_store(store)
# ------------------------------------------------------------------
# Pending authorizations, keyed by the OAuth `state` param.
# Populated when an authorization URL is generated; consumed by the
# web callback when the browser redirects back with ?code&state.
# ------------------------------------------------------------------
_PENDING_LOCK = threading.Lock()
_PENDING: dict = {} # state -> {"handler": OAuthHandler, "created": ts}
_PENDING_TTL = 600 # seconds
def _register_pending(state: str, handler: "OAuthHandler") -> None:
with _PENDING_LOCK:
_prune_pending_locked()
_PENDING[state] = {"handler": handler, "created": time.time()}
def _prune_pending_locked() -> None:
now = time.time()
stale = [s for s, v in _PENDING.items() if now - v["created"] > _PENDING_TTL]
for s in stale:
_PENDING.pop(s, None)
def pop_pending(state: str) -> Optional["OAuthHandler"]:
with _PENDING_LOCK:
_prune_pending_locked()
entry = _PENDING.pop(state, None)
return entry["handler"] if entry else None
def has_pending() -> bool:
with _PENDING_LOCK:
_prune_pending_locked()
return bool(_PENDING)
# ------------------------------------------------------------------
# HTTP helpers (stdlib only)
# ------------------------------------------------------------------
_UA = "CowAgent-MCP-OAuth/1.0"
def _http_get_json(url: str, timeout: int = 15) -> Optional[dict]:
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": _UA})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
except urllib.error.HTTPError as e:
logger.debug(f"[MCP-OAuth] GET {url} -> HTTP {e.code}")
return None
except Exception as e:
logger.debug(f"[MCP-OAuth] GET {url} failed: {e}")
return None
def _http_post_form(url: str, fields: dict, timeout: int = 20) -> dict:
body = urllib.parse.urlencode(fields).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"User-Agent": _UA,
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
def _http_post_json(url: str, payload: dict, timeout: int = 20) -> dict:
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": _UA,
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
# ------------------------------------------------------------------
# Discovery (RFC 9728 + RFC 8414)
# ------------------------------------------------------------------
def _origin(url: str) -> str:
p = urllib.parse.urlparse(url)
return f"{p.scheme}://{p.netloc}"
def discover_metadata(resource_url: str, www_authenticate: str = "") -> Optional[dict]:
"""
Resolve the authorization server metadata for a protected MCP resource.
Returns a dict with at least authorization_endpoint + token_endpoint,
plus registration_endpoint when the server supports DCR. Returns None
when discovery fails.
"""
as_metadata_url = _parse_resource_metadata_url(www_authenticate)
# 1) Protected-resource metadata (RFC 9728) to locate the auth server.
auth_server = None
prm = None
if as_metadata_url:
prm = _http_get_json(as_metadata_url)
if prm is None:
origin = _origin(resource_url)
prm = _http_get_json(f"{origin}/.well-known/oauth-protected-resource")
if prm and isinstance(prm.get("authorization_servers"), list) and prm["authorization_servers"]:
auth_server = prm["authorization_servers"][0]
# 2) Authorization-server metadata (RFC 8414). Fall back to the resource
# origin when the resource did not advertise a separate auth server.
base = auth_server or _origin(resource_url)
asm = _fetch_as_metadata(base)
if not asm:
return None
if not asm.get("authorization_endpoint") or not asm.get("token_endpoint"):
logger.warning("[MCP-OAuth] Authorization server metadata missing required endpoints")
return None
# Derive the scope to request. Prefer the resource's required_scopes
# (RFC 9728), then its scopes_supported, then the auth server's
# scopes_supported. Stored so callers don't have to configure it.
discovered_scope = ""
if prm:
scopes = prm.get("required_scopes") or prm.get("scopes_supported")
if isinstance(scopes, list) and scopes:
discovered_scope = " ".join(str(s) for s in scopes)
if not discovered_scope and isinstance(asm.get("scopes_supported"), list) and asm["scopes_supported"]:
discovered_scope = " ".join(str(s) for s in asm["scopes_supported"])
if discovered_scope:
asm["_discovered_scope"] = discovered_scope
return asm
def _parse_resource_metadata_url(www_authenticate: str) -> Optional[str]:
"""Extract resource_metadata="..." from a WWW-Authenticate: Bearer header."""
if not www_authenticate:
return None
# naive but sufficient parse for `resource_metadata="URL"`
marker = "resource_metadata="
idx = www_authenticate.find(marker)
if idx < 0:
return None
rest = www_authenticate[idx + len(marker):].strip()
if rest.startswith('"'):
end = rest.find('"', 1)
return rest[1:end] if end > 0 else None
# unquoted, up to comma/space
for sep in (",", " "):
if sep in rest:
rest = rest.split(sep, 1)[0]
return rest or None
def _fetch_as_metadata(base: str) -> Optional[dict]:
"""Try both RFC 8414 and OIDC well-known locations."""
base = base.rstrip("/")
candidates = [
f"{base}/.well-known/oauth-authorization-server",
f"{base}/.well-known/openid-configuration",
]
for url in candidates:
data = _http_get_json(url)
if data and data.get("authorization_endpoint"):
return data
return None
# ------------------------------------------------------------------
# PKCE
# ------------------------------------------------------------------
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _make_pkce() -> tuple:
verifier = _b64url(secrets.token_bytes(32))
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
# ------------------------------------------------------------------
# OAuthHandler: per-server OAuth state machine
# ------------------------------------------------------------------
class OAuthHandler:
"""Drives the OAuth flow and token lifecycle for a single MCP server."""
def __init__(self, server_name: str, resource_url: str, redirect_uri: str,
scope: str = "", client_name: str = "CowAgent"):
self.server_name = server_name
self.resource_url = resource_url
self.redirect_uri = redirect_uri
self.scope = scope
self.client_name = client_name
rec = load_server_record(server_name)
self.metadata: dict = rec.get("metadata", {})
self.client_id: Optional[str] = rec.get("client_id")
self.client_secret: Optional[str] = rec.get("client_secret")
self.access_token: Optional[str] = rec.get("access_token")
self.refresh_token: Optional[str] = rec.get("refresh_token")
self.expires_at: float = float(rec.get("expires_at", 0) or 0)
self._verifier: Optional[str] = None
# --- persistence -------------------------------------------------
def _persist(self) -> None:
save_server_record(self.server_name, {
"resource_url": self.resource_url,
"metadata": self.metadata,
"client_id": self.client_id,
"client_secret": self.client_secret,
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": self.expires_at,
})
# --- token access ------------------------------------------------
def get_valid_access_token(self, leeway: int = 60) -> Optional[str]:
"""Return a usable access token, refreshing proactively when near expiry."""
if not self.access_token:
return None
if self.expires_at and time.time() >= self.expires_at - leeway:
if not self.refresh():
return None
return self.access_token
def refresh(self) -> bool:
"""Refresh the access token using the stored refresh token."""
if not self.refresh_token or not self.metadata.get("token_endpoint"):
return False
fields = {
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
"client_id": self.client_id or "",
}
if self.client_secret:
fields["client_secret"] = self.client_secret
try:
resp = _http_post_form(self.metadata["token_endpoint"], fields)
except Exception as e:
logger.warning(f"[MCP-OAuth:{self.server_name}] refresh failed: {e}")
return False
return self._absorb_token_response(resp)
# --- authorization-code flow ------------------------------------
def ensure_registered(self, www_authenticate: str = "") -> bool:
"""Discover metadata + register a client if not already done."""
if not self.metadata.get("authorization_endpoint"):
meta = discover_metadata(self.resource_url, www_authenticate)
if not meta:
return False
self.metadata = meta
# Adopt the scope discovered from metadata when the user didn't set one.
if not self.scope and self.metadata.get("_discovered_scope"):
self.scope = self.metadata["_discovered_scope"]
logger.info(f"[MCP-OAuth:{self.server_name}] Using discovered scope: {self.scope}")
if not self.client_id:
if not self._register_client():
return False
self._persist()
return True
def _register_client(self) -> bool:
reg_endpoint = self.metadata.get("registration_endpoint")
if not reg_endpoint:
logger.warning(
f"[MCP-OAuth:{self.server_name}] No registration_endpoint; "
f"DCR unavailable. Provide client_id manually."
)
return False
payload = {
"client_name": self.client_name,
"redirect_uris": [self.redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
if self.scope:
payload["scope"] = self.scope
try:
resp = _http_post_json(reg_endpoint, payload)
except Exception as e:
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR failed: {e}")
return False
client_id = resp.get("client_id")
if not client_id:
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR returned no client_id")
return False
self.client_id = client_id
self.client_secret = resp.get("client_secret")
logger.info(f"[MCP-OAuth:{self.server_name}] Registered client_id={client_id}")
return True
def build_authorization_url(self) -> Optional[str]:
"""Create an authorization URL and register this handler as pending."""
if not self.metadata.get("authorization_endpoint") or not self.client_id:
return None
self._verifier, challenge = _make_pkce()
state = secrets.token_urlsafe(24)
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
}
if self.scope:
params["scope"] = self.scope
# Advertise the resource we intend to access (RFC 8707).
params["resource"] = self.resource_url
_register_pending(state, self)
return f"{self.metadata['authorization_endpoint']}?{urllib.parse.urlencode(params)}"
def finish_authorization(self, code: str) -> bool:
"""Exchange an authorization code for tokens."""
if not self.metadata.get("token_endpoint") or not self._verifier:
return False
fields = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"client_id": self.client_id or "",
"code_verifier": self._verifier,
"resource": self.resource_url,
}
if self.client_secret:
fields["client_secret"] = self.client_secret
try:
resp = _http_post_form(self.metadata["token_endpoint"], fields)
except Exception as e:
logger.warning(f"[MCP-OAuth:{self.server_name}] token exchange failed: {e}")
return False
ok = self._absorb_token_response(resp)
self._verifier = None
return ok
def _absorb_token_response(self, resp: dict) -> bool:
access = resp.get("access_token")
if not access:
logger.warning(f"[MCP-OAuth:{self.server_name}] token response missing access_token: {resp}")
return False
self.access_token = access
if resp.get("refresh_token"):
self.refresh_token = resp["refresh_token"]
expires_in = resp.get("expires_in")
self.expires_at = time.time() + int(expires_in) if expires_in else 0
self._persist()
logger.info(f"[MCP-OAuth:{self.server_name}] Access token stored")
return True

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 Allows agents to read specific sections from memory files
""" """
import os
from agent.tools.base_tool import BaseTool from agent.tools.base_tool import BaseTool
@@ -87,8 +89,13 @@ class MemoryGetTool(BaseTool):
file_path = (workspace_dir / path).resolve() file_path = (workspace_dir / path).resolve()
workspace_resolved = workspace_dir.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") return ToolResult.fail(f"Error: Access denied: path outside workspace")
if not file_path.exists(): 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 os
import re
from typing import Dict, Any from typing import Dict, Any
from pathlib import Path 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 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): class Read(BaseTool):
"""Tool for reading file contents""" """Tool for reading file contents"""
name: str = "read" 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 = { params: dict = {
"type": "object", "type": "object",
@@ -79,9 +86,9 @@ class Read(BaseTool):
# Resolve path # Resolve path
absolute_path = self._resolve_path(path) absolute_path = self._resolve_path(path)
# Security check: Prevent reading sensitive config files # Security check: block credential files and their aliases.
env_config_path = expand_path("~/.cow/.env") # See issue #2913 (/proc/self/environ bypass) and #2863 (scope).
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path): if self._is_credential_path(absolute_path):
return ToolResult.fail( return ToolResult.fail(
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only." "Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
) )
@@ -140,7 +147,39 @@ class Read(BaseTool):
if os.path.isabs(path): if os.path.isabs(path):
return path return path
return os.path.abspath(os.path.join(self.cwd, 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: 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.) 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 is not None:
if offset < 0: if offset < 0:
# Negative offset: read from end # Negative offset: read from end
# -20 means "last 20 lines" → start from (total - 20) # -20 means "last 20 lines" → start from (total - 20).
start_line = max(0, total_file_lines + offset) # 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: else:
# Positive offset: read from start (1-indexed) # Positive offset: read from start (1-indexed)
start_line = max(0, offset - 1) # Convert to 0-indexed start_line = max(0, offset - 1) # Convert to 0-indexed

View File

@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution") logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
return True # agent ran but produced nothing; don't loop return True # agent ran but produced nothing; don't loop
if action.get("silent", False):
logger.info(
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
)
return True
from channel.channel_factory import create_channel from channel.channel_factory import create_channel
channel = create_channel(channel_type) channel = create_channel(channel_type)
if not channel: if not channel:

View File

@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
"schedule_value": { "schedule_value": {
"type": "string", "type": "string",
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)" "description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
},
"silent": {
"type": "boolean",
"default": False,
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
} }
}, },
"required": ["action"] "required": ["action"]
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
"channel_type": self.config.get("channel_type", "unknown"), "channel_type": self.config.get("channel_type", "unknown"),
"notify_session_id": notify_session_id, "notify_session_id": notify_session_id,
} }
# silent only applies to ai_task; fixed messages always deliver
if kwargs.get("silent", False):
action["silent"] = True
# 针对钉钉单聊,额外存储 sender_staff_id # 针对钉钉单聊,额外存储 sender_staff_id
msg = context.kwargs.get("msg") msg = context.kwargs.get("msg")
@@ -216,14 +224,17 @@ class SchedulerTool(BaseTool):
content_desc = f"💬 固定消息: {message}" content_desc = f"💬 固定消息: {message}"
else: else:
content_desc = f"🤖 AI任务: {ai_task}" content_desc = f"🤖 AI任务: {ai_task}"
# Warn the user at creation time so a mistaken silent flag is easy to spot
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
return ( return (
f"✅ 定时任务创建成功\n\n" f"✅ 定时任务创建成功\n\n"
f"📋 任务ID: {task_id}\n" f"📋 任务ID: {task_id}\n"
f"📝 名称: {name}\n" f"📝 名称: {name}\n"
f"⏰ 调度: {schedule_desc}\n" f"⏰ 调度: {schedule_desc}\n"
f"👤 接收者: {receiver_desc}\n" f"👤 接收者: {receiver_desc}\n"
f"{content_desc}\n" f"{content_desc}{silent_desc}\n"
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}" f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
) )

View File

@@ -54,6 +54,11 @@ class Send(BaseTool):
if not path: if not path:
return ToolResult.fail("Error: path parameter is required") 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 # Resolve path
absolute_path = self._resolve_path(path) absolute_path = self._resolve_path(path)
@@ -112,6 +117,46 @@ class Send(BaseTool):
return ToolResult.success(result) 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: def _resolve_path(self, path: str) -> str:
"""Resolve path to absolute path""" """Resolve path to absolute path"""
path = expand_path(path) path = expand_path(path)

View File

@@ -71,6 +71,22 @@ class ToolManager:
if not hasattr(self, '_mcp_active_configs'): if not hasattr(self, '_mcp_active_configs'):
# server_name -> normalized config dict, for diff-based reload. # server_name -> normalized config dict, for diff-based reload.
self._mcp_active_configs: dict = {} 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): def load_tools(self, tools_dir: str = "", config_dict=None):
""" """
@@ -136,14 +152,7 @@ class ToolManager:
except ImportError as e: except ImportError as e:
# Handle missing dependencies with helpful messages # Handle missing dependencies with helpful messages
error_msg = str(e) error_msg = str(e)
if "playwright" in error_msg: if "markdownify" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
logger.warning( logger.warning(
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n" f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
f" Install with: pip install markdownify" f" Install with: pip install markdownify"
@@ -206,14 +215,7 @@ class ToolManager:
except ImportError as e: except ImportError as e:
# Handle missing dependencies with helpful messages # Handle missing dependencies with helpful messages
error_msg = str(e) error_msg = str(e)
if "playwright" in error_msg: if "markdownify" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
logger.warning( logger.warning(
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n" f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
f" Install with: pip install markdownify" f" Install with: pip install markdownify"
@@ -245,14 +247,7 @@ class ToolManager:
# If there are missing tools, record warnings # If there are missing tools, record warnings
if missing_tools: if missing_tools:
for tool_name in missing_tools: for tool_name in missing_tools:
if tool_name == "browser": if tool_name == "google_search":
logger.warning(
f"[ToolManager] Browser tool is configured but not loaded.\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif tool_name == "google_search":
logger.warning( logger.warning(
f"[ToolManager] Google Search tool is configured but may need API key.\n" f"[ToolManager] Google Search tool is configured but may need API key.\n"
f" Get API key from: https://serper.dev\n" f" Get API key from: https://serper.dev\n"
@@ -450,21 +445,30 @@ class ToolManager:
the others, and never raises out of the worker thread. the others, and never raises out of the worker thread.
""" """
try: try:
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry, set_reload_callback
from agent.tools.mcp.mcp_tool import McpTool from agent.tools.mcp.mcp_tool import McpTool
registry = McpClientRegistry() registry = McpClientRegistry()
self._mcp_registry = registry self._mcp_registry = registry
# Let the OAuth web callback bring a server online once authorized.
set_reload_callback(self.reload_mcp_server)
for cfg in mcp_servers_config: for cfg in mcp_servers_config:
server_name = cfg.get("name", "<unnamed>") server_name = cfg.get("name", "<unnamed>")
try: try:
client = McpClient(cfg) client = McpClient(cfg)
if not client.initialize(): if not client.initialize():
self._mcp_status[server_name] = "failed" if getattr(client, "needs_auth", False):
logger.warning( self._mcp_status[server_name] = "needs_auth"
f"[MCP] Server '{server_name}' failed to initialize — skipping" logger.info(
) f"[MCP] Server '{server_name}' needs authorization — "
f"waiting for the user to complete the OAuth flow"
)
else:
self._mcp_status[server_name] = "failed"
logger.warning(
f"[MCP] Server '{server_name}' failed to initialize — skipping"
)
continue continue
tool_schemas = client.list_tools() tool_schemas = client.list_tools()
@@ -502,6 +506,28 @@ class ToolManager:
except Exception as e: except Exception as e:
logger.warning(f"[ToolManager] MCP background loader crashed: {e}") logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
def reload_mcp_server(self, server_name: str) -> None:
"""Re-initialize a single MCP server (e.g. after OAuth authorization).
Tears down any existing client for the server and starts it again in
the background, so a freshly-stored access token is picked up and the
server's tools become available on the next message.
"""
with self._mcp_lock:
cfg = self._mcp_active_configs.get(server_name)
if not cfg:
logger.warning(f"[MCP] reload requested for unknown server '{server_name}'")
return
logger.info(f"[MCP] Reloading server '{server_name}' after authorization")
self._teardown_mcp_server(server_name)
self._mcp_status[server_name] = "pending"
threading.Thread(
target=self._load_mcp_tools_async,
args=([cfg],),
daemon=True,
name=f"mcp-reload-{server_name}",
).start()
def list_mcp_status(self) -> dict: def list_mcp_status(self) -> dict:
"""Return {server_name: status} snapshot for UI / debugging.""" """Return {server_name: status} snapshot for UI / debugging."""
return dict(self._mcp_status) return dict(self._mcp_status)
@@ -523,6 +549,16 @@ class ToolManager:
if agent is None or not hasattr(agent, "tools"): if agent is None or not hasattr(agent, "tools"):
return ([], []) 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 from agent.tools.mcp.mcp_tool import McpTool
current = self._mcp_tool_instances current = self._mcp_tool_instances
registry_names = set(current.keys()) registry_names = set(current.keys())
@@ -564,6 +600,91 @@ class ToolManager:
return (sorted(added), sorted(removed)) 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: def create_tool(self, name: str) -> BaseTool:
""" """
Get a new instance of a tool by name. Get a new instance of a tool by name.

View File

@@ -15,6 +15,7 @@ from .diff import (
normalize_to_lf, normalize_to_lf,
restore_line_endings, restore_line_endings,
normalize_for_fuzzy_match, normalize_for_fuzzy_match,
count_matches,
fuzzy_find_text, fuzzy_find_text,
generate_diff_string, generate_diff_string,
FuzzyMatchResult FuzzyMatchResult
@@ -39,6 +40,7 @@ __all__ = [
'normalize_to_lf', 'normalize_to_lf',
'restore_line_endings', 'restore_line_endings',
'normalize_for_fuzzy_match', 'normalize_for_fuzzy_match',
'count_matches',
'fuzzy_find_text', 'fuzzy_find_text',
'generate_diff_string', 'generate_diff_string',
'FuzzyMatchResult', 'FuzzyMatchResult',

View File

@@ -93,6 +93,40 @@ class FuzzyMatchResult:
self.content_for_replacement = content_for_replacement self.content_for_replacement = content_for_replacement
def _build_fuzzy_pattern(old_text: str) -> Optional[str]:
"""
Build the whitespace-flexible regex used to locate ``old_text`` fuzzily.
Returns ``None`` when ``old_text`` has no non-whitespace content to match.
This is the single source of truth for fuzzy matching, so that *finding* a
match (:func:`fuzzy_find_text`) and *counting* occurrences
(:func:`count_matches`) always use the exact same rules.
"""
stripped = old_text.strip('\n')
if not stripped.strip():
return None
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]*')
return '\n'.join(line_patterns)
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult: def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
""" """
Find text in content, try exact match first, then fuzzy match Find text in content, try exact match first, then fuzzy match
@@ -110,25 +144,54 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
match_length=len(old_text), match_length=len(old_text),
content_for_replacement=content content_for_replacement=content
) )
# Try fuzzy match # Fuzzy match: the exact substring was not found, most likely because the
fuzzy_content = normalize_for_fuzzy_match(content) # whitespace differs (indentation, spaces around operators, trailing
fuzzy_old_text = normalize_for_fuzzy_match(old_text) # spaces). Locate the region in the ORIGINAL content using a
# whitespace-flexible pattern and return offsets into that original
index = fuzzy_content.find(fuzzy_old_text) # content.
if index != -1: #
# Fuzzy match successful, use normalized content for replacement # This must NOT replace inside a whitespace-normalized copy of the file:
return FuzzyMatchResult( # doing so previously returned the normalized copy as
found=True, # content_for_replacement, which caused the whole file to be rewritten
index=index, # with collapsed indentation (every untouched line got reformatted).
match_length=len(fuzzy_old_text), pattern = _build_fuzzy_pattern(old_text)
content_for_replacement=fuzzy_content if pattern is not None:
) 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 # Not found
return FuzzyMatchResult(found=False) return FuzzyMatchResult(found=False)
def count_matches(content: str, old_text: str) -> int:
"""
Count occurrences of ``old_text`` using the SAME strategy as
:func:`fuzzy_find_text`: an exact substring when one is present, otherwise
the whitespace-flexible fuzzy regex.
The edit tool's uniqueness guard must agree with the matcher that actually
performs the replacement. Counting through a separate normalization pass
(the previous approach) could disagree with the regex used to locate and
replace, so both paths now share :func:`_build_fuzzy_pattern`.
"""
if not old_text:
return 0
# Mirror fuzzy_find_text: prefer exact matching when it applies.
if content.find(old_text) != -1:
return content.count(old_text)
pattern = _build_fuzzy_pattern(old_text)
if pattern is None:
return 0
return len(re.findall(pattern, content))
def generate_diff_string(old_content: str, new_content: str) -> dict: def generate_diff_string(old_content: str, new_content: str) -> dict:
""" """
Generate unified diff string Generate unified diff string

View File

@@ -1,18 +1,41 @@
""" """
Shared SSRF guard utilities for tools that fetch model-supplied URLs. Shared SSRF guard utilities for tools that fetch model-supplied URLs.
A URL is only considered safe when it uses an http/https scheme, has a SSRF protection is OPT-IN and disabled by default, because legitimate use
hostname, that hostname resolves, and every resolved address is a public cases (local dev servers, LAN services, proxy fake-ip resolution) need to
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local reach non-public addresses. Enable it by setting the config option
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved ``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
addresses are rejected, for both IPv4 and IPv6.
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 ipaddress
import os
import socket import socket
from urllib.parse import urlparse 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: def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
"""Return True if the address is not safe to connect to (non-public).""" """Return True if the address is not safe to connect to (non-public)."""
return ( return (
@@ -28,8 +51,11 @@ def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
def assert_public_ip(ip_str: str) -> None: def assert_public_ip(ip_str: str) -> None:
"""Raise ValueError if the given literal IP is a non-public address. """Raise ValueError if the given literal IP is a non-public address.
Used to re-validate the concrete address a redirect resolved to. 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) ip = ipaddress.ip_address(ip_str)
if _is_blocked_ip(ip): if _is_blocked_ip(ip):
raise ValueError( raise ValueError(
@@ -41,13 +67,17 @@ def assert_public_ip(ip_str: str) -> None:
def validate_url_safe(url: str) -> None: def validate_url_safe(url: str) -> None:
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard). """Reject URLs that target private/loopback/link-local addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that fall 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) into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution. schemes, or hosts that fail DNS resolution.
Raises: Raises:
ValueError: if the URL targets a disallowed address. ValueError: if the URL targets a disallowed address.
""" """
if not _ssrf_protection_enabled():
return
parsed = urlparse(url) parsed = urlparse(url)
if parsed.scheme not in ("http", "https"): if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}") raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")

View File

@@ -53,7 +53,7 @@ _DISCOVERABLE_MODELS = [
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"), ("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"), ("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"), ("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"), ("claude_api_key", const.CLAUDEAPI, const.CLAUDE_SONNET_5, "Claude"),
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"), ("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"), ("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"), ("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
@@ -162,7 +162,7 @@ class Vision(BaseTool):
"Error: No model available for Vision.\n" "Error: No model available for Vision.\n"
"The main model does not support vision and no other API keys are configured.\n" "The main model does not support vision and no other API keys are configured.\n"
"Options:\n" "Options:\n"
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.7-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" " 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")" " 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
) )
@@ -331,6 +331,12 @@ class Vision(BaseTool):
- None : unknown provider id, or the bot can't be created. - None : unknown provider id, or the bot can't be created.
Caller falls through to model-name-based routing. 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) display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
if not display_name: if not display_name:
return None return None
@@ -596,6 +602,34 @@ class Vision(BaseTool):
model_override=preferred_model, 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, def _call_via_bot(self, model: str, question: str, image_content: dict,
provider: Optional[VisionProvider] = None) -> ToolResult: provider: Optional[VisionProvider] = None) -> ToolResult:
""" """

29
app.py
View File

@@ -15,6 +15,11 @@ import threading
_channel_mgr = None _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(): def get_channel_manager():
return _channel_mgr return _channel_mgr
@@ -76,7 +81,15 @@ class ChannelManager:
self._primary_channel = channels[0][1] self._primary_channel = channels[0][1]
if first_start: 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 # Cloud client is optional. It is only started when
# use_linkai=True AND cloud_deployment_id is set. # use_linkai=True AND cloud_deployment_id is set.
@@ -364,10 +377,18 @@ def run():
_sync_builtin_skills() _sync_builtin_skills()
# Kick off MCP server loading in the background so first-message # Kick off MCP server loading in the background so first-message
# latency isn't dominated by npx package downloads. # latency isn't dominated by npx package downloads. Skipped in desktop
_warmup_mcp_tools() # 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}") logger.info(f"[App] Starting channels: {channel_names}")

View File

@@ -684,11 +684,21 @@ class AgentBridge:
""" """
file_type = file_info.get("file_type", "file") file_type = file_info.get("file_type", "file")
file_path = file_info.get("path") 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) # For images, use IMAGE_URL type (channel will handle upload)
if file_type == "image": if file_type == "image":
# Convert local path to file:// URL for channel processing file_url = _to_channel_url(file_path)
file_url = f"file://{file_path}"
logger.info(f"[AgentBridge] Sending image: {file_url}") logger.info(f"[AgentBridge] Sending image: {file_url}")
reply = Reply(ReplyType.IMAGE_URL, file_url) reply = Reply(ReplyType.IMAGE_URL, file_url)
# Attach text message if present (for channels that support text+image) # Attach text message if present (for channels that support text+image)
@@ -698,7 +708,7 @@ class AgentBridge:
# For all file types (document, video, audio), use FILE type # For all file types (document, video, audio), use FILE type
if file_type in ["document", "video", "audio"]: 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}") logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
reply = Reply(ReplyType.FILE, file_url) reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path)) reply.file_name = file_info.get("file_name", os.path.basename(file_path))
@@ -708,7 +718,7 @@ class AgentBridge:
return reply return reply
# For all other file types (tar.gz, zip, etc.), also use FILE type # 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}") logger.info(f"[AgentBridge] Sending generic file: {file_url}")
reply = Reply(ReplyType.FILE, file_url) reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path)) reply.file_name = file_info.get("file_name", os.path.basename(file_path))

View File

@@ -17,10 +17,6 @@ from common.utils import expand_path
# Module-level lock to serialize scheduler init across concurrent sessions # Module-level lock to serialize scheduler init across concurrent sessions
_scheduler_init_lock = threading.Lock() _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: class AgentInitializer:
""" """
@@ -306,186 +302,16 @@ class AgentInitializer:
""" """
Initialize the embedding provider for memory. 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): A. Default (no `embedding_provider` in config.json):
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices Auto-init OpenAI -> LinkAI fallback.
keep working.
B. Explicit (`embedding_provider` is set): B. Explicit (`embedding_provider` is set):
Initialize the requested vendor with unified dim (default 1024). Initialize the requested vendor.
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.
""" """
from agent.memory import create_embedding_provider from agent.memory import create_default_embedding_provider
from config import conf 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): def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
"""Sync memory database""" """Sync memory database"""
try: try:

View File

@@ -28,6 +28,13 @@ from bridge.context import ContextType
from bridge.reply import Reply, ReplyType from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix from channel.chat_channel import ChatChannel, check_prefix
from channel.feishu.feishu_message import FeishuMessage from channel.feishu.feishu_message import FeishuMessage
from channel.feishu.feishu_static_card import build_text_delivery
from channel.feishu.feishu_progress_card import FeishuProgressState
from channel.feishu.feishu_scheduler_card import (
build_scheduler_card,
handle_scheduler_action,
tasks_for_receivers,
)
from common import utils from common import utils
from common.expired_dict import ExpiredDict from common.expired_dict import ExpiredDict
from common.log import logger from common.log import logger
@@ -350,6 +357,10 @@ class FeiShuChanel(ChatChannel):
def _startup_websocket(self): def _startup_websocket(self):
"""启动长连接接收事件(websocket模式)""" """启动长连接接收事件(websocket模式)"""
_ensure_lark_imported() _ensure_lark_imported()
from lark_oapi.event.callback.model.p2_card_action_trigger import (
P2CardActionTriggerResponse,
)
logger.debug("[FeiShu] Starting in websocket mode...") logger.debug("[FeiShu] Starting in websocket mode...")
# 创建事件处理器 # 创建事件处理器
@@ -372,10 +383,25 @@ class FeiShuChanel(ChatChannel):
except Exception as e: except Exception as e:
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True) logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
def handle_card_action(data):
"""Handle Card 2.0 button callbacks and update the card in place."""
try:
event_dict = json.loads(lark.JSON.marshal(data))
response = self._handle_card_action_event(event_dict.get("event", {}))
return P2CardActionTriggerResponse(response)
except Exception as e:
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
return P2CardActionTriggerResponse(
{"toast": {"type": "error", "content": "Task update failed"}}
)
# 构建事件分发器 # 构建事件分发器
event_handler = lark.EventDispatcherHandler.builder("", "") \ event_handler = (
.register_p2_im_message_receive_v1(handle_message_event) \ lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(handle_message_event)
.register_p2_card_action_trigger(handle_card_action)
.build() .build()
)
def start_client_with_retry(): def start_client_with_retry():
"""Run ws client in this thread with its own event loop to avoid conflicts.""" """Run ws client in this thread with its own event loop to avoid conflicts."""
@@ -470,6 +496,97 @@ class FeiShuChanel(ChatChannel):
# so reaching here means the bot was indeed mentioned. # so reaching here means the bot was indeed mentioned.
return True return True
def _get_scheduler_task_store(self):
"""Reuse the live scheduler store, with a path-compatible fallback."""
from agent.tools.scheduler.integration import get_task_store
task_store = get_task_store()
if task_store is not None:
return task_store
from agent.tools.scheduler.task_store import TaskStore
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
task_store = self._get_scheduler_task_store()
receivers = {feishu_msg.other_user_id}
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
card = build_scheduler_card(tasks)
headers = {
"Authorization": "Bearer " + feishu_msg.access_token,
"Content-Type": "application/json",
}
content = json.dumps(card, ensure_ascii=False)
if is_group and feishu_msg.msg_id:
url = (
"https://open.feishu.cn/open-apis/im/v1/messages/"
f"{feishu_msg.msg_id}/reply"
)
response = requests.post(
url,
headers=headers,
json={"msg_type": "interactive", "content": content},
timeout=(5, 10),
)
else:
url = "https://open.feishu.cn/open-apis/im/v1/messages"
response = requests.post(
url,
headers=headers,
params={"receive_id_type": receive_id_type},
json={
"receive_id": feishu_msg.other_user_id,
"msg_type": "interactive",
"content": content,
},
timeout=(5, 10),
)
result = response.json()
if result.get("code") == 0:
logger.info("[FeiShu] scheduler card sent")
return True
logger.error(
"[FeiShu] scheduler card failed, "
f"code={result.get('code')}, msg={result.get('msg')}"
)
return False
def _handle_card_action_event(self, event: dict) -> dict:
"""Apply a scheduler card action within its chat/operator ownership scope."""
action = event.get("action") or {}
value = action.get("value") or {}
if value.get("cowagent") != "scheduler":
return {}
context = event.get("context") or {}
operator = event.get("operator") or {}
callback_receivers = {
receiver
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
if receiver
}
target_receiver = value.get("receiver")
allowed_receivers = (
{target_receiver}
if target_receiver and target_receiver in callback_receivers
else set()
)
response = handle_scheduler_action(
value,
self._get_scheduler_task_store(),
allowed_receivers,
)
logger.info(
"[FeiShu] scheduler card action handled, "
f"action={value.get('action')}, task_id={value.get('task_id')}"
)
return response
def _handle_message_event(self, event: dict): def _handle_message_event(self, event: dict):
""" """
处理消息事件的核心逻辑 处理消息事件的核心逻辑
@@ -521,6 +638,11 @@ class FeiShuChanel(ChatChannel):
if not feishu_msg: if not feishu_msg:
return return
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
logger.warning("[FeiShu] /tasks card delivery failed")
return
# 处理文件缓存逻辑 # 处理文件缓存逻辑
from channel.file_cache import get_file_cache from channel.file_cache import get_file_cache
file_cache = get_file_cache() file_cache = get_file_cache()
@@ -628,7 +750,13 @@ class FeiShuChanel(ChatChannel):
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...") logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
reply_content = reply.content reply_content = reply.content
content_key = "text" content_key = "text"
if reply.type == ReplyType.IMAGE_URL: prepared_content_json = None
if reply.type == ReplyType.TEXT:
msg_type, prepared_content_json = build_text_delivery(
reply.content,
enabled=conf().get("feishu_markdown_card", True),
)
elif reply.type == ReplyType.IMAGE_URL:
# 图片上传 # 图片上传
reply_content = self._upload_image_url(reply.content, access_token) reply_content = self._upload_image_url(reply.content, access_token)
if not reply_content: if not reply_content:
@@ -690,7 +818,11 @@ class FeiShuChanel(ChatChannel):
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
# Build content JSON # Build content JSON
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False) content_json = prepared_content_json or (
json.dumps(reply_content, ensure_ascii=False)
if content_key is None
else json.dumps({content_key: reply_content}, ensure_ascii=False)
)
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}") logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
if can_reply: if can_reply:
@@ -714,10 +846,377 @@ class FeiShuChanel(ChatChannel):
res = res.json() res = res.json()
if res.get("code") == 0: if res.get("code") == 0:
logger.info(f"[FeiShu] send message success") logger.info(f"[FeiShu] send message success")
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
logger.warning(
"[FeiShu] Markdown card failed, falling back to text, "
f"code={res.get('code')}, msg={res.get('msg')}"
)
fallback_data = {
"msg_type": "text",
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
}
if can_reply:
fallback_res = requests.post(
url=url,
headers=headers,
json=fallback_data,
timeout=(5, 10),
)
else:
fallback_data["receive_id"] = context.get("receiver")
fallback_res = requests.post(
url=url,
headers=headers,
params=params,
json=fallback_data,
timeout=(5, 10),
)
fallback_body = fallback_res.json()
if fallback_body.get("code") == 0:
logger.info("[FeiShu] text fallback sent successfully")
else:
logger.error(
"[FeiShu] text fallback failed, "
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
)
else: else:
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}") logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
def _make_feishu_stream_callback(self, context, access_token): def _make_feishu_stream_callback(self, context, access_token):
"""Route to progress or plain streaming callback based on config.
feishu_progress_card 默认关闭:普通对话走原有的打字机文本卡片
(_make_feishu_stream_callback_plain)。开启后才使用带状态头、
Reasoning/Tools 面板与耗时的富进度卡片。
"""
if conf().get("feishu_progress_card", False):
return self._make_feishu_stream_callback_progress(context, access_token)
return self._make_feishu_stream_callback_plain(context, access_token)
def _make_feishu_stream_callback_progress(self, context, access_token):
"""
基于飞书官方"流式更新卡片"API 实现打字机回复。
流程:
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
随后用 POST /im/v1/messages或 reply以 card_id 把卡片发出去
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
(流式模式下不受 10 QPS 限制)
3. message_end本轮触发工具调用→ 原地刷新 Reasoning / Tools 面板,
后续 turn 继续复用同一张卡片
4. agent_end → 用 final_response、最终状态和耗时整卡更新关闭 streaming_mode
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
前提条件:
- 机器人已开通 cardkit:card:write 权限
- 飞书客户端 7.20+
失败降级:
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
"""
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
progress_state = FeishuProgressState()
card_id = [None]
message_id = [None]
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
# 事件各自触发一次创建+发送,导致发出多张卡片。
init_in_flight = [False]
# 一旦初始化失败就长期标记为 disabled本次回复不再尝试任何流式调用
disabled = [False]
lock = threading.Lock()
# ---- 异步推送队列 ----------------------------------------------------
# 同步 requests.put 单次 100~300ms会阻塞 LLM stream 线程读下一个 chunk。
# 把推送丢给独立 worker 线程消费 queue回调本身只做内存追加立即返回。
# 队列里只放"最新累积文本"的快照worker 用 deduplication 避免重复推同一个
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
import queue as _queue
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
def _push_worker():
while True:
snapshot = push_queue.get()
if snapshot is None:
push_queue.task_done()
return
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
merged_count = 1
stop = False
while True:
try:
nxt = push_queue.get_nowait()
except _queue.Empty:
break
merged_count += 1
if nxt is None:
stop = True
break
snapshot = nxt
try:
_stream_update_text(snapshot)
finally:
for _ in range(merged_count):
push_queue.task_done()
if stop:
return
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
push_thread.start()
def _drain_push_queue():
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
try:
push_queue.join()
except Exception:
pass
msg = context.get("msg")
is_group = context.get("isgroup", False)
receiver = context.get("receiver")
receive_id_type = context.get("receive_id_type", "open_id")
headers = {
"Authorization": "Bearer " + access_token,
"Content-Type": "application/json; charset=utf-8",
}
# 卡片中富文本组件的 element_id后续所有 PUT 流式更新都打到这个组件
ELEMENT_ID = "stream_md"
# 操作序号,每次 PUT 必须严格递增(飞书要求)
sequence = [0]
def _next_sequence():
with lock:
sequence[0] += 1
return sequence[0]
def _build_card_json():
"""Build the initial streaming Card 2.0 payload."""
with lock:
card = progress_state.build_card(streaming=True)
return json.dumps(card, ensure_ascii=False)
def _create_and_send_card():
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
try:
# 步骤 1: 创建卡片实体
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
create_body = {"type": "card_json", "data": _build_card_json()}
res = requests.post(
create_url, headers=headers, json=create_body, timeout=(5, 10)
)
res_json = res.json()
if res_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: create card failed "
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
)
with lock:
disabled[0] = True
return
cid = res_json["data"]["card_id"]
with lock:
card_id[0] = cid
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply单聊直接 send
content_payload = json.dumps(
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
)
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
if can_reply:
send_url = (
f"https://open.feishu.cn/open-apis/im/v1/messages/"
f"{msg.msg_id}/reply"
)
send_body = {"msg_type": "interactive", "content": content_payload}
send_res = requests.post(
send_url, headers=headers, json=send_body, timeout=(5, 10)
)
else:
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
params = {"receive_id_type": receive_id_type}
send_body = {
"receive_id": receiver,
"msg_type": "interactive",
"content": content_payload,
}
send_res = requests.post(
send_url, headers=headers, params=params, json=send_body,
timeout=(5, 10),
)
send_json = send_res.json()
if send_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
)
with lock:
disabled[0] = True
return
mid = send_json["data"]["message_id"]
with lock:
message_id[0] = mid
logger.info(
f"[FeiShu] Stream: card created and sent, "
f"card_id={cid}, message_id={mid}"
)
except Exception as e:
logger.warning(
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
)
with lock:
disabled[0] = True
finally:
with lock:
init_in_flight[0] = False
def _stream_update_text(full_text):
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
with lock:
cid = card_id[0]
if not cid:
return
url = (
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
f"{cid}/elements/{ELEMENT_ID}/content"
)
body = {
"content": full_text,
"sequence": _next_sequence(),
}
try:
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
res_json = res.json()
if res_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: update text failed: {res_json}"
)
except Exception as e:
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
def _update_full_card(streaming: bool):
"""Refresh panels, header, footer and streaming state in one update."""
with lock:
cid = card_id[0]
full_card = progress_state.build_card(streaming=streaming)
if not cid:
return
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
put_body = {
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
"sequence": _next_sequence(),
}
try:
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
res_json = res.json()
if res_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: full card update failed: {res_json}"
)
except Exception as e:
logger.warning(
f"[FeiShu] Stream: full card update exception: {e}"
)
def on_event(event: dict):
event_type = event.get("type")
data = event.get("data", {})
# 一旦降级,本次回复不再做任何流式操作
with lock:
if disabled[0]:
return
if event_type == "agent_start":
with lock:
progress_state.consume(event)
if card_id[0] is None and not init_in_flight[0]:
init_in_flight[0] = True
_create_and_send_card()
return
if event_type in ("turn_start", "reasoning_update"):
with lock:
progress_state.consume(event)
return
if event_type == "message_update":
delta = data.get("delta", "")
if not delta:
return
# 第一段:判断是否需要初始化(创建卡片 + 发送)
need_init = False
with lock:
if card_id[0] is None and not init_in_flight[0]:
init_in_flight[0] = True
need_init = True
if need_init:
_create_and_send_card()
# 初始化失败已标记 disabled下次循环直接 return
with lock:
if disabled[0]:
return
# 第二段:累加文本,把快照丢给 push worker 异步推送。
# 这里不能直接 requests.put否则会阻塞 LLM stream 线程读下一个 chunk
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms累积起来非常卡
snapshot = ""
should_push = False
with lock:
progress_state.consume(event)
if card_id[0]:
snapshot = progress_state.current_text
should_push = True
if should_push:
push_queue.put(snapshot)
elif event_type == "message_end":
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
tool_calls = data.get("tool_calls", []) or []
with lock:
progress_state.consume(event)
if not tool_calls:
return
_drain_push_queue()
_update_full_card(streaming=True)
elif event_type == "agent_cancelled":
with lock:
progress_state.consume(event)
elif event_type == "agent_end":
# Finalize the same card with a status header and elapsed footer.
with lock:
progress_state.consume(event)
final_text = progress_state.current_text
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
context["feishu_streamed"] = True
if not has_card and not init_busy:
with lock:
init_in_flight[0] = True
_create_and_send_card()
with lock:
if disabled[0]:
return
_drain_push_queue()
_stream_update_text(final_text)
_update_full_card(streaming=False)
push_queue.put(None)
return on_event
def _make_feishu_stream_callback_plain(self, context, access_token):
""" """
基于飞书官方"流式更新卡片"API 实现打字机回复。 基于飞书官方"流式更新卡片"API 实现打字机回复。
@@ -1565,6 +2064,7 @@ class FeishuController:
FAILED_MSG = '{"success": false}' FAILED_MSG = '{"success": false}'
SUCCESS_MSG = '{"success": true}' SUCCESS_MSG = '{"success": true}'
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1" MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
CARD_ACTION_TYPE = "card.action.trigger"
def GET(self): def GET(self):
return "Feishu service start success!" return "Feishu service start success!"
@@ -1581,15 +2081,27 @@ class FeishuController:
varify_res = {"challenge": request.get("challenge")} varify_res = {"challenge": request.get("challenge")}
return json.dumps(varify_res) return json.dumps(varify_res)
# 2.消息接收处理 # 2. Verify callbacks. Card callbacks may carry the verification
# token 校验 # token in event.token while message events carry it in the header.
header = request.get("header") header = request.get("header") or {}
if not header or header.get("token") != channel.feishu_token: event = request.get("event") or {}
event_type = header.get("event_type") or request.get("type")
callback_token = (
header.get("token")
or event.get("token")
or request.get("token")
)
if callback_token != channel.feishu_token:
return self.FAILED_MSG return self.FAILED_MSG
# 处理消息事件 if event_type == self.CARD_ACTION_TYPE and event:
event = request.get("event") return json.dumps(
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event: channel._handle_card_action_event(event),
ensure_ascii=False,
)
# 3. Handle message events.
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
channel._handle_message_event(event) channel._handle_message_event(event)
return self.SUCCESS_MSG return self.SUCCESS_MSG

View File

@@ -0,0 +1,204 @@
"""State and Card 2.0 rendering for a Feishu agent run."""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
_MAX_PANEL_STEPS = 10
_MAX_STEP_CHARS = 800
class FeishuProgressState:
"""Reduce CowAgent stream events into one renderable Feishu card state."""
def __init__(self, started_at: Optional[float] = None):
self.started_at = time.monotonic() if started_at is None else started_at
self.status = "running"
self.turns = 0
self.current_text = ""
self._reasoning_buffer = ""
self.reasoning_steps: List[str] = []
self.tool_steps: List[Dict[str, str]] = []
self.cancelled = False
def consume(self, event: Dict[str, Any]) -> None:
"""Consume one event emitted by ``AgentStreamHandler``."""
event_type = event.get("type")
data = event.get("data") or {}
if event_type == "turn_start":
self._mark_running_tools_done()
turn = data.get("turn")
if isinstance(turn, int):
self.turns = max(self.turns, turn)
else:
self.turns += 1
if self.turns > 1:
self.current_text = ""
return
if event_type == "reasoning_update":
self._reasoning_buffer += str(data.get("delta") or "")
return
if event_type == "message_update":
self.current_text += str(data.get("delta") or "")
return
if event_type == "message_end":
self._commit_reasoning()
for tool_call in data.get("tool_calls") or []:
self.tool_steps.append(
{
"summary": _format_tool_call(tool_call),
"status": "running",
}
)
return
if event_type == "agent_cancelled":
self.cancelled = True
self.status = "stopped"
return
if event_type == "agent_end":
self._commit_reasoning()
self._mark_running_tools_done()
cancelled = self.cancelled or bool(data.get("cancelled"))
if cancelled:
self.status = "stopped"
self.current_text = self.current_text.rstrip() or "_(stopped)_"
else:
self.status = "done"
final_response = data.get("final_response")
if final_response:
self.current_text = str(final_response)
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
"""Render the current state as a Feishu Card 2.0 object."""
title, template = {
"running": ("Working", "blue"),
"done": ("Done", "green"),
"stopped": ("Stopped", "grey"),
"error": ("Error", "red"),
}.get(self.status, ("Working", "blue"))
main_text = self.current_text or "..."
elements: List[Dict[str, Any]] = []
if self.reasoning_steps:
elements.append(
_panel(
"Reasoning ({})".format(len(self.reasoning_steps)),
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
expanded=streaming,
)
)
elif streaming:
elements.append(
_panel("Reasoning", [_text_row("Thinking...", muted=True)], expanded=True)
)
if self.tool_steps:
elements.append(
_panel(
"Tools ({})".format(len(self.tool_steps)),
[
_text_row("{} · {}".format(step["summary"], step["status"]))
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
],
expanded=streaming,
)
)
elements.append(
{
"tag": "markdown",
"element_id": "stream_md",
"content": main_text,
}
)
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
turn_label = "turn" if self.turns == 1 else "turns"
elements.extend(
[
{"tag": "hr"},
{
"tag": "markdown",
"content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label),
"text_size": "notation",
},
]
)
config: Dict[str, Any] = {
"streaming_mode": streaming,
"update_multi": True,
"enable_forward_interaction": True,
"summary": {"content": _summary(main_text, title)},
}
if streaming:
config["streaming_config"] = {
"print_frequency_ms": {"default": 40},
"print_step": {"default": 4},
"print_strategy": "fast",
}
return {
"schema": "2.0",
"config": config,
"header": {
"template": template,
"title": {"tag": "plain_text", "content": title},
},
"body": {"elements": elements},
}
def _commit_reasoning(self) -> None:
reasoning = self._reasoning_buffer.strip()
if reasoning:
self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:])
self._reasoning_buffer = ""
def _mark_running_tools_done(self) -> None:
for step in self.tool_steps:
if step["status"] == "running":
step["status"] = "done"
def _format_tool_call(tool_call: Dict[str, Any]) -> str:
# Tool arguments can contain user data or credentials. The progress card
# only needs an activity label, so never echo raw arguments into chat.
return str(tool_call.get("name") or "tool")
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
return {
"tag": "collapsible_panel",
"expanded": expanded,
"background_color": "grey",
"header": {"title": {"tag": "plain_text", "content": title}},
"border": {"color": "grey"},
"vertical_spacing": "8px",
"padding": "4px 8px",
"elements": elements,
}
def _text_row(content: str, muted: bool = False) -> Dict[str, Any]:
text = {
"tag": "plain_text",
"content": content,
"text_size": "notation",
}
if muted:
text["text_color"] = "grey"
return {"tag": "div", "text": text}
def _summary(text: str, fallback: str) -> str:
preview = " ".join(text.strip().split())
return preview[:60] or fallback

View File

@@ -0,0 +1,185 @@
"""Feishu scheduler card rendering and callback handling."""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Set
_MAX_TASKS = 20
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
visible = []
for task in tasks:
action = task.get("action") or {}
if action.get("channel_type") != "feishu":
continue
if action.get("receiver") in receivers:
visible.append(task)
return visible
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
"""Build a Card 2.0 task list with explicit, idempotent actions."""
task_list = list(tasks)
elements: List[Dict[str, Any]] = []
if not task_list:
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
else:
for index, task in enumerate(task_list[:_MAX_TASKS]):
if index:
elements.append({"tag": "hr"})
task_id = str(task.get("id") or "")
receiver = str((task.get("action") or {}).get("receiver") or "")
enabled = task.get("enabled", True)
status = "Enabled" if enabled else "Disabled"
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
elements.append(
{
"tag": "markdown",
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
task.get("name") or "Unnamed task",
status,
task_id,
_format_schedule(task.get("schedule") or {}),
next_run,
),
}
)
toggle_action = "disable" if enabled else "enable"
toggle_text = "Disable" if enabled else "Enable"
toggle_type = "default" if enabled else "primary"
elements.append(
{
"tag": "column_set",
"columns": [
{
"tag": "column",
"elements": [
_button(
toggle_text,
toggle_type,
toggle_action,
task_id,
receiver,
)
],
},
{
"tag": "column",
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
},
],
}
)
hidden = len(task_list) - _MAX_TASKS
if hidden > 0:
elements.extend(
[
{"tag": "hr"},
{
"tag": "markdown",
"content": "{} more tasks are hidden.".format(hidden),
"text_size": "notation",
},
]
)
return {
"schema": "2.0",
"config": {"update_multi": True, "enable_forward_interaction": False},
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
},
"body": {"elements": elements},
}
def handle_scheduler_action(
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
) -> Dict[str, Any]:
"""Apply an owned scheduler action and return a Feishu callback response."""
if value.get("cowagent") != "scheduler":
return {}
task_id = str(value.get("task_id") or "")
action = value.get("action")
if not task_id or action not in {"enable", "disable", "delete"}:
return _toast("error", "Invalid scheduler action")
task = task_store.get_task(task_id)
if not task:
return _response(
"info",
"Task no longer exists",
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
)
task_receiver = (task.get("action") or {}).get("receiver")
task_channel = (task.get("action") or {}).get("channel_type")
value_receiver = value.get("receiver")
if (
task_channel != "feishu"
or task_receiver not in allowed_receivers
or (value_receiver and value_receiver != task_receiver)
):
return _toast("error", "Task is not available in this chat")
try:
if action == "delete":
task_store.delete_task(task_id)
message = "Task deleted"
else:
enabled = action == "enable"
task_store.enable_task(task_id, enabled)
message = "Task enabled" if enabled else "Task disabled"
except (OSError, ValueError) as exc:
return _toast("error", "Task update failed: {}".format(exc))
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
return _response("success", message, build_scheduler_card(visible))
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
response = _toast(toast_type, content)
response["card"] = {"type": "raw", "data": card}
return response
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
return {"toast": {"type": toast_type, "content": content}}
def _button(
text: str,
button_type: str,
action: str,
task_id: str,
receiver: str,
) -> Dict[str, Any]:
return {
"tag": "button",
"text": {"tag": "plain_text", "content": text},
"type": button_type,
"value": {
"cowagent": "scheduler",
"action": action,
"task_id": task_id,
"receiver": receiver,
},
}
def _format_schedule(schedule: Dict[str, Any]) -> str:
schedule_type = schedule.get("type")
if schedule_type == "cron":
return "cron {}".format(schedule.get("expression") or "?")
if schedule_type == "interval":
return "every {}s".format(schedule.get("seconds") or "?")
if schedule_type == "once":
return "once at {}".format(schedule.get("run_at") or "?")
return str(schedule_type or "unknown schedule")

View File

@@ -0,0 +1,46 @@
"""Helpers for choosing the native Feishu delivery format for text replies."""
import json
import re
from typing import Tuple
_BLOCK_MARKDOWN = re.compile(
r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)"
)
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
def contains_markdown(text: str) -> bool:
"""Return whether *text* contains syntax that benefits from card Markdown."""
if not text:
return False
return bool(
_BLOCK_MARKDOWN.search(text)
or _INLINE_MARKDOWN.search(text)
or _TABLE_SEPARATOR.search(text)
)
def build_markdown_card(text: str) -> dict:
"""Build an inline Card 2.0 payload with one Markdown element."""
return {
"schema": "2.0",
"config": {},
"body": {
"elements": [
{
"tag": "markdown",
"content": text,
}
]
},
}
def build_text_delivery(text: str, enabled: bool = True) -> Tuple[str, str]:
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
if enabled and contains_markdown(text):
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
return "text", json.dumps({"text": text}, ensure_ascii=False)

View File

@@ -47,12 +47,15 @@
This runs synchronously in <head> so the correct class is on <html> This runs synchronously in <head> so the correct class is on <html>
before any CSS or body rendering occurs. --> before any CSS or body rendering occurs. -->
<script> <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. // or '' when unrecognized so callers can fall through to the next source.
window.__cowNormalizeLang__ = function(raw) { window.__cowNormalizeLang__ = function(raw) {
if (!raw) return ''; if (!raw) return '';
var v = String(raw).trim().toLowerCase(); var v = String(raw).trim().toLowerCase().replace('_', '-');
if (v === 'auto') return ''; 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('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en'; if (v.indexOf('en') === 0) return 'en';
return ''; return '';
@@ -267,14 +270,29 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<!-- Language Toggle --> <!-- Language Selector (dropdown) -->
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium <div id="lang-selector" class="relative">
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 <button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
cursor-pointer transition-colors duration-150" text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
onclick="toggleLanguage()"> cursor-pointer transition-colors duration-150"
<i class="fas fa-globe text-xs"></i> onclick="toggleLangMenu(event)">
<span id="lang-label">EN</span> <i class="fas fa-globe text-xs"></i>
</button> <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 --> <!-- Theme Toggle -->
<button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400 <button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
@@ -304,6 +322,14 @@
cursor-pointer transition-colors duration-150" title="GitHub"> cursor-pointer transition-colors duration-150" title="GitHub">
<i class="fab fa-github text-lg"></i> <i class="fab fa-github text-lg"></i>
</a> </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> </header>
<!-- Content Area --> <!-- Content Area -->
@@ -829,11 +855,11 @@
<!-- VIEW: Knowledge --> <!-- VIEW: Knowledge -->
<!-- ====================================================== --> <!-- ====================================================== -->
<div id="view-knowledge" class="view"> <div id="view-knowledge" class="view">
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10"> <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"> <div class="w-full max-w-[1600px] mx-auto md:flex-1 md:min-h-0 md:flex md:flex-col">
<!-- Header --> <!-- 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> <div>
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2> <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> <p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">浏览和探索你的知识库</p>
@@ -841,10 +867,6 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span> <span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span> <span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
<button onclick="createKnowledgeCategory()"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
</button>
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5"> <div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')" <button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active"> class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
@@ -855,6 +877,28 @@
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span> <i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
</button> </button>
</div> </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>
</div> </div>
@@ -877,12 +921,12 @@
</div> </div>
<!-- Documents panel --> <!-- Documents panel -->
<div id="knowledge-panel-docs" class="hidden"> <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" style="min-height: calc(100vh - 220px)"> <div class="flex flex-col md:flex-row gap-4 md:gap-6 md:h-full">
<!-- File tree --> <!-- File tree -->
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0"> <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"> <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"> <div class="px-4 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<div class="relative"> <div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i> <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..." <input id="knowledge-search" type="text" placeholder="Search..."
@@ -890,19 +934,19 @@
oninput="filterKnowledgeTree(this.value)"> oninput="filterKnowledgeTree(this.value)">
</div> </div>
</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>
</div> </div>
<!-- Content viewer --> <!-- Content viewer -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 md:h-full">
<div id="knowledge-content-placeholder" <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> <i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p> <p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
</div> </div>
<div id="knowledge-content-viewer" class="hidden"> <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"> <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"> <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"> <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> <i class="fas fa-arrow-left text-xs"></i>
</button> </button>
@@ -911,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> <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>
<div id="knowledge-viewer-body" <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" 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>
style="max-height: calc(100vh - 280px)"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1083,7 +1126,7 @@
<!-- Knowledge Action Dialog --> <!-- 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-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden"> <div 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="p-6">
<div class="flex items-center gap-3 mb-5"> <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"> <div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
@@ -1097,8 +1140,36 @@
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label> <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" <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"> class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
<select id="knowledge-dialog-select" <div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select> <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-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> <p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
</div> </div>

View File

@@ -1293,6 +1293,18 @@
background: rgba(74, 190, 110, 0.1); background: rgba(74, 190, 110, 0.1);
color: #4ABE6E; 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 */ /* Graph legend */
.knowledge-graph-legend { .knowledge-graph-legend {

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ from common import const
from common import i18n from common import i18n
from common.log import logger from common.log import logger
from common.singleton import singleton from common.singleton import singleton
from config import conf from config import conf, get_data_root, get_weixin_credentials_path
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"} VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -79,11 +79,42 @@ def _verify_auth_token(token):
return hmac.compare_digest(sig, expected) return hmac.compare_digest(sig, expected)
def _get_bearer_token():
"""Extract the token from an `Authorization: Bearer <token>` header.
The desktop client renders from a file:// origin, so cross-origin cookies
to http://127.0.0.1 are unreliable (SameSite=Lax cookies aren't sent). It
therefore authenticates via this header instead; browsers keep using the
cookie set by /auth/login.
"""
auth = web.ctx.env.get("HTTP_AUTHORIZATION", "") or ""
if auth.startswith("Bearer "):
return auth[7:].strip()
return ""
def _get_query_token():
"""Extract a token from the `token` query param.
Needed for SSE endpoints: EventSource can't set an Authorization header,
and file:// cookies are unreliable, so the desktop client passes the token
in the query string for /stream and /api/logs.
"""
try:
return web.input(token="").token or ""
except Exception:
return ""
def _check_auth(): def _check_auth():
"""Return True if request is authenticated or password not enabled.""" """Return True if request is authenticated or password not enabled."""
if not _is_password_enabled(): if not _is_password_enabled():
return True return True
return _verify_auth_token(web.cookies().get("cow_auth_token", "")) if _verify_auth_token(web.cookies().get("cow_auth_token", "")):
return True
if _verify_auth_token(_get_bearer_token()):
return True
return _verify_auth_token(_get_query_token())
def _require_auth(): def _require_auth():
@@ -181,6 +212,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}") raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
def _read_uploaded_file_bytes_limited(file_obj, max_bytes: int) -> bytes:
"""Read uploaded content and fail once it exceeds max_bytes."""
if isinstance(file_obj, bytes):
content = file_obj
elif isinstance(file_obj, str):
content = file_obj.encode("utf-8")
elif hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
content = file_obj.file.read(max_bytes + 1)
elif hasattr(file_obj, "read"):
content = file_obj.read(max_bytes + 1)
elif hasattr(file_obj, "value"):
content = file_obj.value
else:
raise ValueError("Unable to read uploaded file content")
if isinstance(content, str):
content = content.encode("utf-8")
if not isinstance(content, bytes):
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
if len(content) > max_bytes:
raise ValueError("file too large")
return content
def _raw_web_input(): def _raw_web_input():
"""Return unprocessed multipart form data when web.py exposes rawinput.""" """Return unprocessed multipart form data when web.py exposes rawinput."""
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None) rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
@@ -240,7 +294,13 @@ class WebChannel(ChatChannel):
self.session_queues = {} # session_id -> Queue (fallback polling) self.session_queues = {} # session_id -> Queue (fallback polling)
self.request_to_session = {} # request_id -> session_id self.request_to_session = {} # request_id -> session_id
self.sse_queues = {} # request_id -> Queue (SSE streaming) self.sse_queues = {} # request_id -> Queue (SSE streaming)
# request_id -> last-active timestamp. Refreshed while the SSE
# generator is being consumed (client still connected). The janitor
# only reclaims queues whose generator stopped refreshing this, so a
# long-running but still-streaming reply is never wrongly killed.
self.sse_last_active = {}
self._http_server = None self._http_server = None
self._sse_janitor_started = False
def _generate_msg_id(self): def _generate_msg_id(self):
"""生成唯一的消息ID""" """生成唯一的消息ID"""
@@ -527,14 +587,29 @@ class WebChannel(ChatChannel):
file_path = data.get("path", "") file_path = data.get("path", "")
file_name = data.get("file_name", os.path.basename(file_path)) file_name = data.get("file_name", os.path.basename(file_path))
file_type = data.get("file_type", "file") file_type = data.get("file_type", "file")
from urllib.parse import quote # Remote URLs are passed through as-is; local files are served
web_url = f"/api/file?path={quote(file_path)}" # via the backend /api/file endpoint.
remote_url = data.get("url", "")
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
if is_remote:
web_url = remote_url
else:
from urllib.parse import quote
web_url = f"/api/file?path={quote(file_path)}"
is_image = file_type == "image" is_image = file_type == "image"
q.put({ payload = {
"type": "image" if is_image else "file", "type": "image" if is_image else "file",
"content": web_url, "content": web_url,
"file_name": file_name, "file_name": file_name,
}) # Preserve the concrete media kind (image/video/audio/...)
# so richer clients can render an inline player.
"file_type": file_type,
}
# Expose the local absolute path so the desktop client can open
# the file directly (Finder / default app) instead of the browser.
if not is_remote and file_path:
payload["abs_path"] = file_path
q.put(payload)
return on_event return on_event
@@ -865,6 +940,7 @@ class WebChannel(ChatChannel):
if use_sse: if use_sse:
self.sse_queues[request_id] = Queue() self.sse_queues[request_id] = Queue()
self.sse_last_active[request_id] = time.time()
trigger_prefixs = conf().get("single_chat_prefix", [""]) trigger_prefixs = conf().get("single_chat_prefix", [""])
if check_prefix(prompt, trigger_prefixs) is None: if check_prefix(prompt, trigger_prefixs) is None:
@@ -879,8 +955,7 @@ class WebChannel(ChatChannel):
if context is None: if context is None:
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered") logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
if request_id in self.sse_queues: self._drop_sse_request(request_id)
del self.sse_queues[request_id]
return json.dumps({"status": "error", "message": "Message was filtered"}) return json.dumps({"status": "error", "message": "Message was filtered"})
context["session_id"] = session_id context["session_id"] = session_id
@@ -903,6 +978,60 @@ class WebChannel(ChatChannel):
logger.error(f"Error processing message: {e}") logger.error(f"Error processing message: {e}")
return json.dumps({"status": "error", "message": str(e)}) return json.dumps({"status": "error", "message": str(e)})
def _drop_sse_request(self, request_id: str):
"""Reclaim all state tied to an SSE request to prevent fd/memory leaks.
Removing the queue lets the WSGI generator and its socket be released,
and dropping request_to_session avoids unbounded map growth.
"""
self.sse_queues.pop(request_id, None)
self.sse_last_active.pop(request_id, None)
self.request_to_session.pop(request_id, None)
def _start_sse_janitor(self):
"""Start a background thread that reclaims orphaned SSE queues.
When a client disconnects before the "done" event arrives (browser
closed, session switched, network drop), the generator may keep the
queue around to allow reconnection. Without a sweep these orphans
accumulate, leaking file descriptors until cheroot raises
"[Errno 24] Too many open files".
Reclamation is based on idle time, not total age: an active stream
refreshes ``sse_last_active`` every second while its generator is being
consumed, so a long-running reply (even hours long) is never killed
while the client stays connected. Only queues that stopped refreshing
(client gone) past SSE_IDLE_TIMEOUT are reclaimed.
"""
if self._sse_janitor_started:
return
self._sse_janitor_started = True
SSE_IDLE_TIMEOUT = 1800 # 30 minutes with no client consumption
SWEEP_INTERVAL = 60
def _sweep():
while True:
time.sleep(SWEEP_INTERVAL)
try:
now = time.time()
stale = [
rid for rid, ts in list(self.sse_last_active.items())
if now - ts > SSE_IDLE_TIMEOUT
]
for rid in stale:
self._drop_sse_request(rid)
if stale:
logger.info(
f"[WebChannel] SSE janitor reclaimed {len(stale)} "
f"idle stream(s)"
)
except Exception as e:
logger.warning(f"[WebChannel] SSE janitor error: {e}")
t = threading.Thread(target=_sweep, name="sse-janitor", daemon=True)
t.start()
def stream_response(self, request_id: str): def stream_response(self, request_id: str):
""" """
SSE generator for a given request_id. SSE generator for a given request_id.
@@ -927,6 +1056,10 @@ class WebChannel(ChatChannel):
try: try:
while time.time() < deadline: while time.time() < deadline:
# Mark the stream alive on every loop. While the client keeps
# consuming, the generator runs and refreshes this, so the
# janitor won't reclaim a long-running but active stream.
self.sse_last_active[request_id] = time.time()
try: try:
item = q.get(timeout=1) item = q.get(timeout=1)
except Empty: except Empty:
@@ -955,13 +1088,21 @@ class WebChannel(ChatChannel):
# voice_attach payload through to the browser. # voice_attach payload through to the browser.
post_done = True post_done = True
post_deadline = time.time() + 2 # 2s post-attach tail post_deadline = time.time() + 2 # 2s post-attach tail
except GeneratorExit:
# Client disconnected (WSGI closed the generator). If the reply is
# already complete there is nothing to resume, so reclaim now to
# release the socket fd. Otherwise keep the queue briefly so a
# reconnect with the same request_id can resume; the janitor will
# reclaim it if no reconnect happens.
if post_done:
self._drop_sse_request(request_id)
raise
finally: finally:
# Only drop the queue once the reply is actually complete. If the # Drop the queue once the reply is actually complete or the idle
# client disconnected early (e.g. switched sessions and will # deadline has passed. Early client disconnects are handled by the
# re-attach with the same request_id), keep the queue so the new # GeneratorExit branch above and the background janitor.
# connection can resume reading the remaining events.
if post_done or time.time() >= deadline: if post_done or time.time() >= deadline:
self.sse_queues.pop(request_id, None) self._drop_sse_request(request_id)
def cancel_request(self): def cancel_request(self):
""" """
@@ -1062,7 +1203,10 @@ class WebChannel(ChatChannel):
def startup(self): def startup(self):
configured_host = conf().get("web_host", "") configured_host = conf().get("web_host", "")
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1") host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
port = conf().get("web_port", 9899) # The desktop app passes its chosen port via COW_WEB_PORT so its backend
# never collides with a source-run web console (default 9899). This makes
# the port a single source of truth owned by the Electron shell.
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
is_public_bind = host in ("0.0.0.0", "::") is_public_bind = host in ("0.0.0.0", "::")
self._cleanup_stale_voice_recordings() self._cleanup_stale_voice_recordings()
@@ -1114,21 +1258,29 @@ class WebChannel(ChatChannel):
else: else:
logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password") logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password")
try: # In desktop mode the Electron shell renders the UI, so don't pop a
import webbrowser # browser window (also avoids issues when running detached/headless).
webbrowser.open(f"http://localhost:{port}") if os.environ.get("COW_DESKTOP") != "1":
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}") try:
except Exception as e: import webbrowser
logger.debug(f"[WebChannel] Could not open browser: {e}") webbrowser.open(f"http://localhost:{port}")
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
except Exception as e:
logger.debug(f"[WebChannel] Could not open browser: {e}")
# 确保静态文件目录存在 # Ensure the static dir exists. In a packaged build it ships read-only
# inside the bundle, so swallow errors instead of failing startup.
static_dir = os.path.join(os.path.dirname(__file__), 'static') static_dir = os.path.join(os.path.dirname(__file__), 'static')
if not os.path.exists(static_dir): if not os.path.exists(static_dir):
os.makedirs(static_dir) try:
logger.debug(f"[WebChannel] Created static directory: {static_dir}") os.makedirs(static_dir)
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
except OSError as e:
logger.debug(f"[WebChannel] Skipped creating static dir (read-only bundle?): {e}")
urls = ( urls = (
'/', 'RootHandler', '/', 'RootHandler',
'/api/health', 'HealthHandler',
'/auth/login', 'AuthLoginHandler', '/auth/login', 'AuthLoginHandler',
'/auth/check', 'AuthCheckHandler', '/auth/check', 'AuthCheckHandler',
'/auth/logout', 'AuthLogoutHandler', '/auth/logout', 'AuthLogoutHandler',
@@ -1155,6 +1307,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/read', 'KnowledgeReadHandler', '/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler', '/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler', '/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler', '/api/scheduler', 'SchedulerHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler', '/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler', '/api/scheduler/update', 'SchedulerUpdateHandler',
@@ -1167,6 +1320,7 @@ class WebChannel(ChatChannel):
'/api/messages/delete', 'MessageDeleteHandler', '/api/messages/delete', 'MessageDeleteHandler',
'/api/logs', 'LogsHandler', '/api/logs', 'LogsHandler',
'/api/version', 'VersionHandler', '/api/version', 'VersionHandler',
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
'/assets/(.*)', 'AssetsHandler', '/assets/(.*)', 'AssetsHandler',
) )
app = web.application(urls, globals(), autoreload=False) app = web.application(urls, globals(), autoreload=False)
@@ -1191,6 +1345,8 @@ class WebChannel(ChatChannel):
server.requests.min = 20 server.requests.min = 20
server.requests.max = 80 server.requests.max = 80
self._http_server = server self._http_server = server
# Reclaim orphaned SSE queues so disconnected clients don't leak fds.
self._start_sse_janitor()
try: try:
server.start() server.start()
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
@@ -1218,6 +1374,74 @@ class RootHandler:
raise web.seeother('/chat') raise web.seeother('/chat')
class HealthHandler:
# Unauthenticated liveness probe. The desktop shell polls this to know the
# backend is up; it must never require auth (a set web_password would
# otherwise make startup hang). Returns no sensitive data.
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')
web.header('Cache-Control', 'no-store')
return json.dumps({"status": "ok"})
class McpOAuthCallbackHandler:
"""OAuth redirect target for MCP servers requiring authorization.
The browser lands here after the user authorizes a remote MCP server.
We exchange the authorization code for tokens and bring the server
online. Unauthenticated by design: the OAuth `state` param is the
single-use secret that binds this request to a pending authorization.
"""
def GET(self):
web.header('Content-Type', 'text/html; charset=utf-8')
params = web.input(code="", state="", error="", error_description="")
def _page(title: str, message: str) -> str:
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>{title}</title></head>"
"<body style='font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
"max-width:520px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2328'>"
f"<h2>{title}</h2><p style='color:#57606a'>{message}</p></body></html>"
)
if params.error:
logger.warning(f"[MCP-OAuth] callback error: {params.error} {params.error_description}")
return _page("授权失败", f"{params.error}: {params.error_description or ''}")
if not params.code or not params.state:
return _page("参数缺失", "回调缺少 code 或 state 参数。")
try:
from agent.tools.mcp.mcp_oauth import pop_pending
from agent.tools.mcp.mcp_client import notify_server_authorized
except Exception as e:
logger.warning(f"[MCP-OAuth] callback import failed: {e}")
return _page("内部错误", "OAuth 模块不可用。")
handler = pop_pending(params.state)
if handler is None:
return _page("会话已过期", "授权请求不存在或已过期,请重新触发授权。")
try:
ok = handler.finish_authorization(params.code)
except Exception as e:
logger.warning(f"[MCP-OAuth] token exchange crashed: {e}")
ok = False
if not ok:
return _page("授权失败", "换取令牌失败,请重试。")
notify_server_authorized(handler.server_name)
logger.info(f"[MCP-OAuth] Server '{handler.server_name}' authorized via web callback")
return _page(
"授权成功",
f"MCP 服务 “{handler.server_name}” 已授权,可以返回聊天继续使用了。",
)
class AuthCheckHandler: class AuthCheckHandler:
def GET(self): def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')
@@ -1245,7 +1469,9 @@ class AuthLoginHandler:
token = _create_auth_token() token = _create_auth_token()
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(), web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
path="/", httponly=True, samesite="Lax") path="/", httponly=True, samesite="Lax")
return json.dumps({"status": "success"}) # Also return the token in the body: the desktop client (file:// origin)
# can't rely on the cookie and sends it back via an Authorization header.
return json.dumps({"status": "success", "token": token})
class AuthLogoutHandler: class AuthLogoutHandler:
@@ -1474,15 +1700,14 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [ _RECOMMENDED_MODELS = [
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude # claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
# default) since it is often unavailable due to policy restrictions. const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o, const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7, const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS, const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE, const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2, const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K, const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
const.MIMO_V2_5_PRO, const.MIMO_V2_5, const.MIMO_V2_5_PRO, const.MIMO_V2_5,
] ]
@@ -1521,7 +1746,7 @@ class ConfigHandler:
"api_base_key": "claude_api_base", "api_base_key": "claude_api_base",
"api_base_default": "https://api.anthropic.com/v1", "api_base_default": "https://api.anthropic.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1, "api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], "models": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
}), }),
("gemini", { ("gemini", {
"label": "Gemini", "label": "Gemini",
@@ -1537,7 +1762,7 @@ class ConfigHandler:
"api_base_key": "open_ai_api_base", "api_base_key": "open_ai_api_base",
"api_base_default": "https://api.openai.com/v1", "api_base_default": "https://api.openai.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1, "api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o], "models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
}), }),
("zhipu", { ("zhipu", {
"label": {"zh": "智谱AI", "en": "GLM"}, "label": {"zh": "智谱AI", "en": "GLM"},
@@ -1561,7 +1786,7 @@ class ConfigHandler:
"api_base_key": "ark_base_url", "api_base_key": "ark_base_url",
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3", "api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
"api_base_placeholder": _PLACEHOLDER_DOUBAO, "api_base_placeholder": _PLACEHOLDER_DOUBAO,
"models": [const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE], "models": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
}), }),
("moonshot", { ("moonshot", {
"label": "Kimi", "label": "Kimi",
@@ -1569,7 +1794,7 @@ class ConfigHandler:
"api_base_key": "moonshot_base_url", "api_base_key": "moonshot_base_url",
"api_base_default": "https://api.moonshot.cn/v1", "api_base_default": "https://api.moonshot.cn/v1",
"api_base_placeholder": _PLACEHOLDER_V1, "api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2], "models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
}), }),
("qianfan", { ("qianfan", {
"label": {"zh": "百度千帆", "en": "ERNIE"}, "label": {"zh": "百度千帆", "en": "ERNIE"},
@@ -1684,7 +1909,7 @@ class ConfigHandler:
raw_pwd = str(local_config.get("web_password", "") or "") raw_pwd = str(local_config.get("web_password", "") or "")
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else "" masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
return json.dumps({ result = {
"status": "success", "status": "success",
"use_agent": use_agent, "use_agent": use_agent,
"title": title, "title": title,
@@ -1701,7 +1926,13 @@ class ConfigHandler:
"api_keys": api_keys_masked, "api_keys": api_keys_masked,
"providers": providers, "providers": providers,
"web_password_masked": masked_pwd, "web_password_masked": masked_pwd,
}, ensure_ascii=False) }
# The desktop app runs on the local trusted machine, so it can edit
# the real password in place (cursor at the end, delete to clear).
# Browser access only ever sees the masked value.
if os.environ.get("COW_DESKTOP") == "1":
result["web_password"] = raw_pwd
return json.dumps(result, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error(f"Error getting config: {e}") logger.error(f"Error getting config: {e}")
return json.dumps({"status": "error", "message": str(e)}) return json.dumps({"status": "error", "message": str(e)})
@@ -1730,11 +1961,14 @@ class ConfigHandler:
if not applied: if not applied:
return json.dumps({"status": "error", "message": "no valid keys to update"}) return json.dumps({"status": "error", "message": "no valid keys to update"})
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json") old_password = "" # Store old password before update
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
# Capture old password before updating
if "web_password" in applied:
old_password = file_cfg.get("web_password", "")
else: else:
file_cfg = {} file_cfg = {}
file_cfg.update(applied) file_cfg.update(applied)
@@ -1752,6 +1986,26 @@ class ConfigHandler:
except Exception as lang_err: except Exception as lang_err:
logger.warning(f"[WebChannel] Failed to apply language: {lang_err}") logger.warning(f"[WebChannel] Failed to apply language: {lang_err}")
# Check if password was cleared: if there was a password before clearing,
# the service is likely bound to 0.0.0.0 (public), so warn the user.
password_warning = None
if "web_password" in applied:
new_password = applied["web_password"]
configured_host = file_cfg.get("web_host", "")
# If password was cleared and there was a password before
if not new_password and old_password:
# If web_host is not explicitly set, the service auto-binds based on password
# With password → 0.0.0.0 (public), without password → 127.0.0.1 (local)
# So clearing password when it was previously set means going from public to local
if not configured_host or configured_host == "0.0.0.0":
password_warning = "password_cleared_with_public_host"
logger.warning(
"[WebChannel] Password cleared while service is likely bound to 0.0.0.0. "
"Consider restarting the service to rebind to 127.0.0.1 "
"or explicitly set web_host in config to prevent unauthorized access."
)
# Reset Bridge so that bot routing reflects the new config. # Reset Bridge so that bot routing reflects the new config.
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot) # Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
# even after the user switches bot_type / use_linkai / model in UI. # even after the user switches bot_type / use_linkai / model in UI.
@@ -1764,7 +2018,7 @@ class ConfigHandler:
except Exception as reset_err: except Exception as reset_err:
logger.warning(f"[WebChannel] Failed to reset bridge: {reset_err}") logger.warning(f"[WebChannel] Failed to reset bridge: {reset_err}")
return json.dumps({"status": "success", "applied": applied}, ensure_ascii=False) return json.dumps({"status": "success", "applied": applied, "warning": password_warning}, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error(f"Error updating config: {e}") logger.error(f"Error updating config: {e}")
return json.dumps({"status": "error", "message": str(e)}) return json.dumps({"status": "error", "message": str(e)})
@@ -2062,7 +2316,20 @@ class ModelsHandler:
], ],
}, },
} }
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai"] _EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai", "custom"]
# Embedding model catalog per provider. Mirrors the default_model in
# agent/memory/embedding/provider.py::EMBEDDING_VENDORS.
# Custom providers have no preset list — model names vary per vendor,
# so the user always types the model id manually.
_EMBEDDING_PROVIDER_MODELS = {
"openai": ["text-embedding-3-small", "text-embedding-3-large"],
"dashscope": ["text-embedding-v4"],
"doubao": ["doubao-embedding-vision-251215"],
"zhipu": ["embedding-3"],
"linkai": ["text-embedding-3-small"],
"custom": [],
}
# Capability-scoped model catalogs. The chat dropdown can reuse the # Capability-scoped model catalogs. The chat dropdown can reuse the
# provider's generic model list, but vision and image generation are # provider's generic model list, but vision and image generation are
@@ -2071,9 +2338,12 @@ class ModelsHandler:
# Anything not listed here intentionally hides the model dropdown so # Anything not listed here intentionally hides the model dropdown so
# users cannot pin a chat-only model and silently get a 4xx at runtime. # users cannot pin a chat-only model and silently get a 4xx at runtime.
_VISION_PROVIDER_MODELS = { _VISION_PROVIDER_MODELS = {
# OpenAI ordering matches the recommended GPT-5.4 family first, then # OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
# GPT-5 and the GPT-4.1/4o backstops. # GPT-5 and the GPT-4.1/4o backstops.
"openai": [ "openai": [
const.GPT_56_LUNA,
const.GPT_56_TERRA,
const.GPT_56_SOL,
const.GPT_55, const.GPT_55,
const.GPT_54, const.GPT_54,
const.GPT_54_MINI, const.GPT_54_MINI,
@@ -2083,10 +2353,10 @@ class ModelsHandler:
const.GPT_41_MINI, const.GPT_41_MINI,
const.GPT_4o, const.GPT_4o,
], ],
"doubao": [const.DOUBAO_SEED_2_PRO], "doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
"moonshot": [const.KIMI_K2_6], "moonshot": [const.KIMI_K2_6],
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS], "dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
"claudeAPI": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS], "claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE], "gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"qianfan": [const.ERNIE_45_TURBO_VL], "qianfan": [const.ERNIE_45_TURBO_VL],
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what # Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
@@ -2107,11 +2377,15 @@ class ModelsHandler:
const.GPT_41_MINI, const.GPT_41_MINI,
const.GPT_54_MINI, const.GPT_54_MINI,
const.QWEN37_PLUS, const.QWEN37_PLUS,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_1_PRO,
const.KIMI_K2_6, const.KIMI_K2_6,
const.CLAUDE_4_6_SONNET, const.CLAUDE_SONNET_5,
const.CLAUDE_FABLE_5,
const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_FLASH_LITE_PRE,
], ],
# Custom OpenAI-compatible providers have no preset list — model
# names vary per vendor, so the user types the model id manually.
"custom": [],
} }
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md. # Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
@@ -2148,10 +2422,7 @@ class ModelsHandler:
@staticmethod @staticmethod
def _config_path() -> str: def _config_path() -> str:
return os.path.join( return os.path.join(get_data_root(), "config.json")
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"config.json",
)
@classmethod @classmethod
def _read_file_config(cls) -> dict: def _read_file_config(cls) -> dict:
@@ -2338,7 +2609,7 @@ class ModelsHandler:
("moonshot", "moonshot_api_key", const.KIMI_K2_6), ("moonshot", "moonshot_api_key", const.KIMI_K2_6),
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO), ("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
("dashscope", "dashscope_api_key", const.QWEN37_PLUS), ("dashscope", "dashscope_api_key", const.QWEN37_PLUS),
("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET), ("claudeAPI", "claude_api_key", const.CLAUDE_SONNET_5),
("gemini", "gemini_api_key", const.GEMINI_35_FLASH), ("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL), ("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO), ("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
@@ -2425,18 +2696,31 @@ class ModelsHandler:
user_specified = (vision_conf.get("model") or "").strip() user_specified = (vision_conf.get("model") or "").strip()
explicit_provider = (vision_conf.get("provider") or "").strip() explicit_provider = (vision_conf.get("provider") or "").strip()
# Build provider list: built-in providers + expanded custom:<id> entries.
# Same pattern as _embedding_capability — each user-created custom
# provider gets its own dropdown entry showing the user-chosen name.
providers = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in cls._VISION_PROVIDER_MODELS:
if pid == "custom":
if custom_cards:
providers.extend(c["id"] for c in custom_cards)
else:
providers.append(pid)
# Provider resolution priority: # Provider resolution priority:
# 1. Explicit `tools.vision.provider` (persisted via UI; supports # 1. Explicit `tools.vision.provider` (persisted via UI; supports
# custom model names that prefix-inference can't recognize). # custom model names that prefix-inference can't recognize).
# 2. Scan per-provider model lists by model name. # 2. Scan per-provider model lists by model name.
# Empty provider keeps the dropdown on "auto" when we can't tell. # Empty provider keeps the dropdown on "auto" when we can't tell.
inferred_provider = "" inferred_provider = ""
if explicit_provider and explicit_provider in cls._VISION_PROVIDER_MODELS: if explicit_provider and explicit_provider in providers:
inferred_provider = explicit_provider inferred_provider = explicit_provider
elif user_specified: elif user_specified:
for pid, models in cls._VISION_PROVIDER_MODELS.items(): for pid, models in cls._VISION_PROVIDER_MODELS.items():
if user_specified in models: if user_specified in models:
inferred_provider = pid # For "custom" key, map to the first custom card
inferred_provider = custom_cards[0]["id"] if pid == "custom" and custom_cards else pid
break break
# In auto mode the hint should reflect what vision.py will actually # In auto mode the hint should reflect what vision.py will actually
@@ -2452,7 +2736,7 @@ class ModelsHandler:
"current_model": user_specified, "current_model": user_specified,
"fallback_provider": predicted["provider"], "fallback_provider": predicted["provider"],
"fallback_model": predicted["model"], "fallback_model": predicted["model"],
"providers": list(cls._VISION_PROVIDER_MODELS.keys()), "providers": providers,
"provider_models": cls._VISION_PROVIDER_MODELS, "provider_models": cls._VISION_PROVIDER_MODELS,
} }
@@ -2525,18 +2809,40 @@ class ModelsHandler:
suggested = "" suggested = ""
if not explicit: if not explicit:
for pid in cls._EMBEDDING_PROVIDERS: for pid in cls._EMBEDDING_PROVIDERS:
if pid == "custom":
continue
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {} meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
key_field = meta.get("api_key_field") key_field = meta.get("api_key_field")
if key_field and cls._is_real_key(local_config.get(key_field, "")): if key_field and cls._is_real_key(local_config.get(key_field, "")):
suggested = pid suggested = pid
break break
if not suggested:
custom_cards = cls._custom_provider_cards(local_config)
if custom_cards:
suggested = custom_cards[0]["id"]
# Build provider list: built-in providers + expanded custom:<id> entries
# Same pattern as _chat_capability — each user-created custom provider
# gets its own dropdown entry showing the user-chosen name.
providers = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in cls._EMBEDDING_PROVIDERS:
if pid == "custom":
if custom_cards:
providers.extend(c["id"] for c in custom_cards)
# No custom providers configured — skip the bare "custom" entry
# since the runtime cannot resolve its credentials.
else:
providers.append(pid)
return { return {
"editable": True, "editable": True,
"current_provider": explicit, "current_provider": explicit,
"suggested_provider": suggested, "suggested_provider": suggested,
"current_model": local_config.get("embedding_model", "") or "", "current_model": local_config.get("embedding_model", "") or "",
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None, "current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
"providers": cls._EMBEDDING_PROVIDERS, "providers": providers,
"provider_models": cls._EMBEDDING_PROVIDER_MODELS,
} }
# Auto-fallback order for image generation. Mirrors the global priority # Auto-fallback order for image generation. Mirrors the global priority
@@ -2898,10 +3204,10 @@ class ModelsHandler:
{ {
"action": "set_custom_provider", "action": "set_custom_provider",
"id": "3f2a9c1b", # required for edit; omit for create "id": "3f2a9c1b", # required for edit; omit for create
"name": "siliconflow", # required, display label "name": "my-provider", # required, display label
"api_base": "https://...", # required when creating "api_base": "https://...", # required when creating
"api_key": "sk-...", # optional on edit (keep existing) "api_key": "sk-...", # optional on edit (keep existing)
"model": "deepseek-ai/...", # optional default model "model": "model-name", # optional default model
"make_active": true # optional, also activate it "make_active": true # optional, also activate it
} }
""" """
@@ -3122,6 +3428,25 @@ class ModelsHandler:
# is persisted so users picking a custom model under a specific vendor # is persisted so users picking a custom model under a specific vendor
# still get routed there — runtime falls back to model-name prefix # still get routed there — runtime falls back to model-name prefix
# inference only when provider is empty. # inference only when provider is empty.
# Validate provider_id — mirrors _set_chat / _set_embedding pattern.
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
if not model:
model = custom_provider.get("model") or ""
elif provider_id and provider_id not in {k for k in ModelsHandler._VISION_PROVIDER_MODELS if k != "custom"}:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
if provider_id and not model:
return json.dumps({
"status": "error",
"message": "vision model is required when a provider is selected",
})
local_config = conf() local_config = conf()
file_cfg = self._read_file_config() file_cfg = self._read_file_config()
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model) self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
@@ -3247,7 +3572,20 @@ class ModelsHandler:
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}") logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
def _set_embedding(self, provider_id: str, model: str) -> str: def _set_embedding(self, provider_id: str, model: str) -> str:
# Two valid states: both empty (reset to pick-or-empty) OR both set. # Validate provider_id — mirrors _set_chat's validation pattern.
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
# Fall back to the custom provider's default model when none is given.
if not model:
model = custom_provider.get("model") or ""
elif provider_id and provider_id not in {p for p in ModelsHandler._EMBEDDING_PROVIDERS if p != "custom"}:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
# A provider without a model leaves the runtime in a broken half-state, # A provider without a model leaves the runtime in a broken half-state,
# so reject that explicitly instead of silently writing it through. # so reject that explicitly instead of silently writing it through.
if provider_id and not model: if provider_id and not model:
@@ -3465,10 +3803,16 @@ class ChannelsHandler:
_require_auth() _require_auth()
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')
try: try:
from common import i18n
local_config = conf() local_config = conf()
active_channels = self._active_channel_set() active_channels = self._active_channel_set()
# Desktop build ships without lark-oapi, so hide Feishu from the list.
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
channels = [] channels = []
is_hant = i18n.get_language() == i18n.ZH_HANT
for ch_name, ch_def in self.CHANNEL_DEFS.items(): for ch_name, ch_def in self.CHANNEL_DEFS.items():
if desktop_mode and ch_name == "feishu":
continue
fields_out = [] fields_out = []
for f in ch_def["fields"]: for f in ch_def["fields"]:
raw_val = local_config.get(f["key"], f.get("default", "")) raw_val = local_config.get(f["key"], f.get("default", ""))
@@ -3476,16 +3820,32 @@ class ChannelsHandler:
display_val = self._mask_secret(str(raw_val)) display_val = self._mask_secret(str(raw_val))
else: else:
display_val = raw_val display_val = raw_val
label_val = f["label"]
if is_hant and isinstance(label_val, str):
label_val = i18n.to_traditional(label_val)
elif is_hant and isinstance(label_val, dict):
label_val = label_val.copy()
label_val["zh-Hant"] = i18n.to_traditional(label_val.get("zh", ""))
fields_out.append({ fields_out.append({
"key": f["key"], "key": f["key"],
"label": f["label"], "label": label_val,
"type": f["type"], "type": f["type"],
"value": display_val, "value": display_val,
"default": f.get("default", ""), "default": f.get("default", ""),
}) })
label_val = ch_def["label"]
if is_hant and isinstance(label_val, str):
label_val = i18n.to_traditional(label_val)
elif is_hant and isinstance(label_val, dict):
label_val = label_val.copy()
label_val["zh-Hant"] = i18n.to_traditional(label_val.get("zh", ""))
ch_info = { ch_info = {
"name": ch_name, "name": ch_name,
"label": ch_def["label"], "label": label_val,
"icon": ch_def["icon"], "icon": ch_def["icon"],
"color": ch_def["color"], "color": ch_def["color"],
"active": ch_name in active_channels, "active": ch_name in active_channels,
@@ -3550,8 +3910,7 @@ class ChannelsHandler:
if not applied: if not applied:
return json.dumps({"status": "error", "message": "no valid fields to update"}) return json.dumps({"status": "error", "message": "no valid fields to update"})
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -3621,8 +3980,7 @@ class ChannelsHandler:
new_channel_type = ",".join(existing) new_channel_type = ",".join(existing)
local_config["channel_type"] = new_channel_type local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -3677,8 +4035,7 @@ class ChannelsHandler:
local_config = conf() local_config = conf()
local_config["channel_type"] = new_channel_type local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( config_path = os.path.join(get_data_root(), "config.json")
os.path.abspath(__file__)))), "config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f) file_cfg = json.load(f)
@@ -3828,9 +4185,7 @@ class WeixinQrHandler:
if not bot_token or not bot_id: if not bot_token or not bot_id:
return json.dumps({"status": "error", "message": "Login confirmed but missing token"}) return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
cred_path = os.path.expanduser( cred_path = get_weixin_credentials_path()
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
from channel.weixin.weixin_channel import _save_credentials from channel.weixin.weixin_channel import _save_credentials
_save_credentials(cred_path, { _save_credentials(cred_path, {
"token": bot_token, "token": bot_token,
@@ -4047,16 +4402,26 @@ class ToolsHandler:
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')
try: try:
from agent.tools.tool_manager import ToolManager from agent.tools.tool_manager import ToolManager
from common import i18n
tm = ToolManager() tm = ToolManager()
if not tm.tool_classes: if not tm.tool_classes:
tm.load_tools() tm.load_tools()
tools = [] tools = []
lang = i18n.get_language()
for name, cls in tm.tool_classes.items(): for name, cls in tm.tool_classes.items():
try: try:
instance = cls() instance = cls()
desc = instance.description
if lang == i18n.ZH_HANT and desc:
desc = i18n.to_traditional(desc)
elif lang == "en" and name == "scheduler":
desc = (
"Create, query and manage scheduled tasks (reminders, periodic tasks, etc.).\n\n"
"⚠️ IMPORTANT: Only use this tool when delayed or periodic execution is needed."
)
tools.append({ tools.append({
"name": name, "name": name,
"description": instance.description, "description": desc,
}) })
except Exception: except Exception:
tools.append({"name": name, "description": ""}) tools.append({"name": name, "description": ""})
@@ -4073,10 +4438,17 @@ class SkillsHandler:
try: try:
from agent.skills.service import SkillService from agent.skills.service import SkillService
from agent.skills.manager import SkillManager from agent.skills.manager import SkillManager
from common import i18n
workspace_root = _get_workspace_root() workspace_root = _get_workspace_root()
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills")) manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
service = SkillService(manager) service = SkillService(manager)
skills = service.query() skills = service.query()
if i18n.get_language() == i18n.ZH_HANT:
for skill in skills:
if isinstance(skill, dict):
for k, v in list(skill.items()):
if k in ("name", "description", "display_name") and isinstance(v, str):
skill[k] = i18n.to_traditional(v)
return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False) return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False)
except Exception as e: except Exception as e:
logger.error(f"[WebChannel] Skills API error: {e}") logger.error(f"[WebChannel] Skills API error: {e}")
@@ -4238,11 +4610,33 @@ class SchedulerUpdateHandler:
# Update action # Update action
if "action" in body: if "action" in body:
action = body["action"]
channel_type = action.get("channel_type", "web")
# Get the task's original channel_type # Get the task's original channel_type
old_channel = original_task.get("action", {}).get("channel_type", "web") original_action = original_task.get("action", {})
if not isinstance(original_action, dict):
original_action = {}
action_patch = body["action"]
if not isinstance(action_patch, dict):
return json.dumps({
"status": "error",
"message": "Action must be an object."
}, ensure_ascii=False)
# The Web editor only exposes a subset of action fields. Merge
# that patch into the stored action so scheduler metadata such
# as notify_session_id, silent, and channel-specific delivery
# fields survive unrelated edits.
action = dict(original_action)
action.update(action_patch)
action_type = action.get("type")
if action_type == "send_message":
action.pop("task_description", None)
action.pop("silent", None)
elif action_type == "agent_task":
action.pop("content", None)
old_channel = original_action.get("channel_type", "web")
channel_type = action.get("channel_type") or old_channel
action["channel_type"] = channel_type
# If channel type changed or no receiver, reject the update. # If channel type changed or no receiver, reject the update.
# Note: the web UI disables the channel selector, so this branch # Note: the web UI disables the channel selector, so this branch
@@ -4494,8 +4888,7 @@ class LogsHandler:
web.header('Cache-Control', 'no-cache') web.header('Cache-Control', 'no-cache')
web.header('X-Accel-Buffering', 'no') web.header('X-Accel-Buffering', 'no')
from config import get_root log_path = os.path.join(get_data_root(), "run.log")
log_path = os.path.join(get_root(), "run.log")
def generate(): def generate():
if not os.path.isfile(log_path): if not os.path.isfile(log_path):
@@ -4647,6 +5040,71 @@ class KnowledgeActionHandler:
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None}) return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
class KnowledgeImportHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.knowledge.service import KnowledgeService
content_length = int(getattr(web.ctx, "env", {}).get("CONTENT_LENGTH") or 0)
if content_length > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
return json.dumps({
"status": "error",
"code": 413,
"message": "import batch too large",
"payload": None,
})
params = _raw_web_input()
target_category = params.get("target_category", "")
conflict_strategy = params.get("conflict_strategy", "skip")
uploaded = _ensure_list(params.get("files"))
single = params.get("file")
if single is not None:
uploaded.append(single)
if not uploaded:
return json.dumps({"status": "error", "code": 400, "message": "No files uploaded", "payload": None})
if len(uploaded) > KnowledgeService.MAX_IMPORT_FILES:
return json.dumps({
"status": "error",
"code": 400,
"message": f"too many files: max {KnowledgeService.MAX_IMPORT_FILES}",
"payload": None,
})
files = []
total_size = 0
for file_obj in uploaded:
if file_obj is None:
continue
filename = getattr(file_obj, "filename", "") or getattr(file_obj, "name", "")
content = _read_uploaded_file_bytes_limited(file_obj, KnowledgeService.MAX_IMPORT_FILE_SIZE)
total_size += len(content)
if total_size > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
return json.dumps({
"status": "error",
"code": 413,
"message": "import batch too large",
"payload": None,
})
files.append({
"filename": filename,
"content": content,
})
result = KnowledgeService(_get_workspace_root()).dispatch("import_documents", {
"target_category": target_category,
"conflict_strategy": conflict_strategy,
"files": files,
})
return json.dumps({
"status": "success" if result["code"] < 300 else "error",
**result,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Knowledge import error: {e}", exc_info=True)
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
class VersionHandler: class VersionHandler:
def GET(self): def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8') web.header('Content-Type', 'application/json; charset=utf-8')

View File

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

View File

@@ -1 +1 @@
2.1.1 2.1.3

View File

@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
return ret return ret
def _is_frozen() -> bool:
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
playwright is already bundled and we only need to download the browser
binary in-process rather than pip-installing anything.
"""
return bool(getattr(sys, "frozen", False))
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
"""Invoke the Playwright CLI, working in both source and frozen builds.
Source builds shell out to ``python -m playwright <args>``. Frozen builds
can't use ``-m`` (the exe isn't a Python interpreter), so we call
Playwright's driver entrypoint in-process instead. ``env`` overrides are
applied to os.environ for the duration of the call (frozen path) or passed
through to the subprocess (source path).
"""
if not _is_frozen():
cmd = [sys.executable, "-m", "playwright"] + args
return subprocess.call(cmd, env=env)
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
# returns the Node driver shipped inside the bundle; we spawn it directly.
prev_env = {}
if env:
for k, v in env.items():
prev_env[k] = os.environ.get(k)
os.environ[k] = v
try:
from playwright._impl._driver import compute_driver_executable, get_driver_env
driver = compute_driver_executable()
# compute_driver_executable may return a tuple (node, cli.js) on newer
# Playwright, or a single path on older ones.
if isinstance(driver, (list, tuple)):
cmd = list(driver) + args
else:
cmd = [str(driver)] + args
# get_driver_env() snapshots os.environ, which we've already patched with
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
# so mirror + pinned browsers dir are honored here too.
return subprocess.call(cmd, env=get_driver_env())
except Exception as e:
# Last resort: try the module main via runpy (works if the frozen build
# kept playwright.__main__ importable).
try:
import runpy
sys.argv = ["playwright"] + args
runpy.run_module("playwright", run_name="__main__")
return 0
except SystemExit as se:
return int(se.code or 0)
except Exception:
return 1
finally:
for k, v in prev_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def _default_stream(msg: str, fg: Optional[str] = None) -> None: def _default_stream(msg: str, fg: Optional[str] = None) -> None:
"""CLI: colored click output.""" """CLI: colored click output."""
if fg == "yellow": if fg == "yellow":
@@ -129,6 +192,7 @@ def run_install_browser(
stream = stream or _default_stream stream = stream or _default_stream
python = sys.executable python = sys.executable
legacy_mode = False legacy_mode = False
frozen = _is_frozen()
_phase(on_phase, _t( _phase(on_phase, _t(
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…", "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
@@ -159,7 +223,7 @@ def run_install_browser(
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it # 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 # 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. # py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
if sys.platform == "win32": if sys.platform == "win32" and not frozen:
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow") stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
ret = subprocess.call( ret = subprocess.call(
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"] [python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
@@ -172,22 +236,52 @@ def run_install_browser(
"yellow", "yellow",
) )
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) if frozen:
stream("[1/3] Installing playwright Python package...", "yellow") # Desktop bundle: playwright is already shipped inside the app; there is
ret = _pip_install(f"playwright=={target_version}", stream) # no pip and nothing to install. Skip straight to downloading Chromium.
if ret != 0: installed = _get_installed_version()
stream("Failed to install playwright package.", "red") stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package.")) _phase(on_phase, _t(
return 1 "✅ [1/3] Playwright 已内置于客户端,跳过安装。",
"✅ [1/3] Playwright is bundled in the app; skipping install.",
))
else:
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream)
if ret != 0:
stream("Failed to install playwright package.", "red")
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
return 1
installed = _get_installed_version() installed = _get_installed_version()
if installed: if installed:
stream(f" playwright {installed} installed.", "green") stream(f" playwright {installed} installed.", "green")
stream("") stream("")
_phase(on_phase, _t( _phase(on_phase, _t(
f"✅ [1/3] Playwright 包已安装({installed or target_version})。", f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
f"✅ [1/3] Playwright package installed ({installed or target_version}).", f"✅ [1/3] Playwright package installed ({installed or target_version}).",
)) ))
# With playwright available, prefer the user's system Chrome/Edge: the browser
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
# source) — only headless Linux servers, which usually lack a system browser,
# fall through to the download below. Honors prefer_system_browser via
# resolve_engine, so users who force downloaded Chromium still get it.
try:
from agent.tools.browser import browser_env
summary = browser_env.capability_summary()
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
sc = summary.get("system_chrome") or {}
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
_phase(on_phase, _t(
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium浏览器工具已就绪。",
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
))
return 0
except Exception as e:
stream(f" (system browser probe skipped: {e})", None)
if sys.platform == "linux": if sys.platform == "linux":
_phase(on_phase, _t( _phase(on_phase, _t(
@@ -195,7 +289,7 @@ def run_install_browser(
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…", "🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
)) ))
stream("[2/3] Installing system dependencies (Linux)...", "yellow") stream("[2/3] Installing system dependencies (Linux)...", "yellow")
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"]) ret = _playwright_cli(["install-deps", "chromium"])
if ret != 0: if ret != 0:
stream( stream(
" Could not auto-install system deps (may need sudo).\n" " Could not auto-install system deps (may need sudo).\n"
@@ -238,12 +332,12 @@ def run_install_browser(
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…", "🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
)) ))
stream("[3/3] Installing Chromium browser...", "yellow") stream("[3/3] Installing Chromium browser...", "yellow")
cmd = [python, "-m", "playwright", "install", "chromium"] pw_args = ["install", "chromium"]
if _is_headless_linux() and not legacy_mode: if _is_headless_linux() and not legacy_mode:
ver = _version_tuple(installed or "") ver = _version_tuple(installed or "")
if ver >= (1, 57, 0): if ver >= (1, 57, 0):
cmd.append("--only-shell") pw_args.append("--only-shell")
stream(" (headless shell for Linux server)", None) stream(" (headless shell for Linux server)", None)
else: else:
stream(" (full Chromium)", None) stream(" (full Chromium)", None)
@@ -251,6 +345,15 @@ def run_install_browser(
stream(" (full browser for Linux desktop)", None) stream(" (full browser for Linux desktop)", None)
env = os.environ.copy() env = os.environ.copy()
# Pin the download location so it survives desktop app updates and matches
# what the runtime looks up (see browser_env.browsers_download_dir()).
try:
from agent.tools.browser.browser_env import browsers_download_dir
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
except Exception:
pass
use_mirror = _is_china_network() use_mirror = _is_china_network()
if use_mirror: if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
@@ -260,7 +363,7 @@ def run_install_browser(
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.", "📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
)) ))
ret = subprocess.call(cmd, env=env) ret = _playwright_cli(pw_args, env=env)
if ret != 0 and use_mirror: if ret != 0 and use_mirror:
stream(" Mirror download failed, retrying with official CDN...", "yellow") stream(" Mirror download failed, retrying with official CDN...", "yellow")
@@ -268,9 +371,9 @@ def run_install_browser(
"⚠️ 镜像下载失败,正在改用官方源重试…", "⚠️ 镜像下载失败,正在改用官方源重试…",
"⚠️ Mirror download failed; retrying with the official CDN…", "⚠️ Mirror download failed; retrying with the official CDN…",
)) ))
env_no_mirror = os.environ.copy() env_no_mirror = dict(env)
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None) env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
ret = subprocess.call(cmd, env=env_no_mirror) ret = _playwright_cli(pw_args, env=env_no_mirror)
if ret != 0: if ret != 0:
stream("Failed to install Chromium.", "red") stream("Failed to install Chromium.", "red")
@@ -282,10 +385,18 @@ def run_install_browser(
stream("Verifying browser installation...", None) stream("Verifying browser installation...", None)
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…")) _phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
ret = subprocess.call( if frozen:
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"], # Frozen: no child interpreter to spawn; import in-process instead.
stderr=subprocess.DEVNULL, try:
) from playwright.sync_api import sync_playwright # noqa: F401
ret = 0
except Exception:
ret = 1
else:
ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL,
)
if ret != 0: if ret != 0:
stream( stream(
" Warning: playwright import failed. Browser tool may not work on this system.\n" " Warning: playwright import failed. Browser tool may not work on this system.\n"

View File

@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType from bridge.reply import Reply, ReplyType
from common.log import logger from common.log import logger
from linkai import LinkAIClient, PushMsg 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 from plugins import PluginManager
import threading import threading
import time import time
@@ -336,9 +336,7 @@ class CloudClient(LinkAIClient):
@staticmethod @staticmethod
def _remove_weixin_credentials(): def _remove_weixin_credentials():
"""Remove the weixin token credentials file so next connect triggers QR login.""" """Remove the weixin token credentials file so next connect triggers QR login."""
cred_path = os.path.expanduser( cred_path = get_weixin_credentials_path()
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
try: try:
if os.path.exists(cred_path): if os.path.exists(cred_path):
os.remove(cred_path) os.remove(cred_path)

View File

@@ -30,13 +30,14 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific 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_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
CLAUDE_4_OPUS = "claude-opus-4-0" CLAUDE_4_OPUS = "claude-opus-4-0"
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy) CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7 CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6 CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0 CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model 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_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 (Google)
GEMINI_PRO = "gemini-1.0-pro" GEMINI_PRO = "gemini-1.0-pro"
@@ -79,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
GPT_54_MINI = "gpt-5.4-mini" GPT_54_MINI = "gpt-5.4-mini"
GPT_54_NANO = "gpt-5.4-nano" GPT_54_NANO = "gpt-5.4-nano"
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
O1 = "o1-preview" O1 = "o1-preview"
O1_MINI = "o1-mini" O1_MINI = "o1-mini"
WHISPER_1 = "whisper-1" WHISPER_1 = "whisper-1"
@@ -138,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
# Kimi (Moonshot) # Kimi (Moonshot)
MOONSHOT = "moonshot" MOONSHOT = "moonshot"
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default) KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
KIMI_K2 = "kimi-k2" KIMI_K2 = "kimi-k2"
KIMI_K2_5 = "kimi-k2.5" KIMI_K2_5 = "kimi-k2.5"
@@ -153,6 +158,8 @@ MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
# Doubao (Volcengine Ark) # Doubao (Volcengine Ark)
DOUBAO = "doubao" 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_CODE = "doubao-seed-2-0-code-preview-260215"
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215" DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215" DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
@@ -197,7 +204,7 @@ MODEL_LIST = [
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH, MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
# Claude # Claude
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_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_SONNET_5, CLAUDE_FABLE_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU, CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet", "claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
@@ -211,6 +218,7 @@ MODEL_LIST = [
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09, GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
GPT_4o, GPT_4O_0806, GPT_4o_MINI, GPT_4o, GPT_4O_0806, GPT_4o_MINI,
GPT_41, GPT_41_MINI, GPT_41_NANO, GPT_41, GPT_41_MINI, GPT_41_NANO,
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
GPT_5, GPT_5_MINI, GPT_5_NANO, GPT_5, GPT_5_MINI, GPT_5_NANO,
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO, GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
O1, O1_MINI, O1, O1_MINI,
@@ -223,11 +231,12 @@ MODEL_LIST = [
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
# Doubao # Doubao
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI, DOUBAO, DOUBAO_SEED_2_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) # Kimi (Moonshot)
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k", MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2, KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
# ModelScope # ModelScope
MODELSCOPE, MODELSCOPE,

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 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. 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): Resolution priority (highest first):
1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any 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), 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 5. Default -> English
A value of "auto" (the default) triggers detection (steps 2-5). Explicitly 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 import os
@@ -28,10 +36,85 @@ import sys
# Supported language codes # Supported language codes
ZH = "zh" ZH = "zh"
ZH_HANT = "zh-Hant"
EN = "en" EN = "en"
SUPPORTED = (ZH, EN) SUPPORTED = (ZH, ZH_HANT, EN)
DEFAULT_LANG = 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 language cache; None until first resolution.
_resolved_lang = None _resolved_lang = None
@@ -48,7 +131,10 @@ def _normalize(raw):
value = str(raw).strip().lower().replace("_", "-") value = str(raw).strip().lower().replace("_", "-")
if value in ("auto", ""): if value in ("auto", ""):
return None 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"): if value.startswith("zh") or value.startswith("chinese"):
return ZH return ZH
if value.startswith("en") or value.startswith("english"): if value.startswith("en") or value.startswith("english"):
@@ -167,7 +253,7 @@ def get_language():
def is_zh(): def is_zh():
return get_language() == ZH return get_language() in (ZH, ZH_HANT)
def t(zh_text, en_text): def t(zh_text, en_text):
@@ -176,4 +262,7 @@ def t(zh_text, en_text):
Intended for one-off strings where a full message catalog is overkill: Intended for one-off strings where a full message catalog is overkill:
t("已中止", "Cancelled") 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 logging
import os
import sys import sys
import io 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): def _reset_logger(log):
for handler in log.handlers: for handler in log.handlers:
handler.close() handler.close()
@@ -20,15 +33,28 @@ def _reset_logger(log):
datefmt="%Y-%m-%d %H:%M:%S", 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) 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(): def _get_logger():

View File

@@ -1,18 +1,21 @@
import os import os
import pathlib
from common.utils import expand_path
from config import conf from config import conf
class TmpDir(object): 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): def __init__(self):
pathExists = os.path.exists(self.tmpFilePath) ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
if not pathExists: self.tmpFilePath = os.path.join(ws_root, "tmp")
os.makedirs(self.tmpFilePath) os.makedirs(self.tmpFilePath, exist_ok=True)
def path(self): def path(self):
return str(self.tmpFilePath) + "/" return str(self.tmpFilePath) + "/"

View File

@@ -28,6 +28,8 @@
"feishu_app_id": "", "feishu_app_id": "",
"feishu_app_secret": "", "feishu_app_secret": "",
"feishu_stream_reply": true, "feishu_stream_reply": true,
"feishu_markdown_card": true,
"feishu_progress_card": false,
"dingtalk_client_id": "", "dingtalk_client_id": "",
"dingtalk_client_secret": "", "dingtalk_client_secret": "",
"wecom_bot_id": "", "wecom_bot_id": "",
@@ -41,5 +43,8 @@
"enable_thinking": false, "enable_thinking": false,
"reasoning_effort": "high", "reasoning_effort": "high",
"knowledge": true, "knowledge": true,
"self_evolution_enabled": true "self_evolution_enabled": true,
"mcp_tool_retrieval_enabled": false,
"mcp_tool_retrieval_threshold": 20,
"mcp_tool_retrieval_top_k": 10
} }

View File

@@ -1,10 +1,12 @@
# encoding:utf-8 # encoding:utf-8
import ast
import copy import copy
import json import json
import logging import logging
import os import os
import pickle import pickle
import sys
from common.log import logger from common.log import logger
from common import i18n from common import i18n
@@ -27,7 +29,7 @@ available_setting = {
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field "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 "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>". # Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"} # Each item: {"id": "3f2a9c1b", "name": "my-provider", "api_key": "sk-...", "api_base": "https://api.example.com/v1", "model": "model-name"}
"custom_providers": [], "custom_providers": [],
"proxy": "", # proxy used by openai "proxy": "", # proxy used by openai
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name # chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
@@ -176,6 +178,8 @@ available_setting = {
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection) "feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+) # Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients "feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
"feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure
"feishu_progress_card": False, # render normal chat streaming as a rich progress card (status header, reasoning/tool panels, elapsed time); off by default keeps the plain typewriter card
# DingTalk config # DingTalk config
"dingtalk_client_id": "", # DingTalk bot Client ID "dingtalk_client_id": "", # DingTalk bot Client ID
"dingtalk_client_secret": "", # DingTalk bot Client Secret "dingtalk_client_secret": "", # DingTalk bot Client Secret
@@ -251,6 +255,7 @@ available_setting = {
"web_password": "", # Web console password; empty means no authentication required "web_password": "", # Web console password; empty means no authentication required
"web_session_expire_days": 30, # Auth session expiry in days "web_session_expire_days": 30, # Auth session expiry in days
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem "web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
"mcp_oauth_redirect_base": "", # Base URL for MCP OAuth callback (e.g. http://your-ip:9899); empty uses local web console
"agent": True, # whether to enable Agent mode "agent": True, # whether to enable Agent mode
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc. "agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
"agent_max_context_tokens": 50000, # max context tokens in Agent mode "agent_max_context_tokens": 50000, # max context tokens in Agent mode
@@ -263,8 +268,17 @@ available_setting = {
"self_evolution_enabled": False, # switch to enable/disable self-evolution "self_evolution_enabled": False, # switch to enable/disable self-evolution
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed "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 "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 "skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL) "mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
# 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
} }
@@ -306,6 +320,12 @@ class Config(dict):
self.user_datas[user] = {} self.user_datas[user] = {}
return 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): def load_user_datas(self):
try: try:
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f: with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
@@ -319,6 +339,8 @@ class Config(dict):
def save_user_datas(self): def save_user_datas(self):
try: 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: with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
pickle.dump(self.user_datas, f) pickle.dump(self.user_datas, f)
logger.info("[Config] User datas saved.") logger.info("[Config] User datas saved.")
@@ -377,10 +399,16 @@ def load_config():
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|") logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
logger.info(" |___/ ") 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): if not os.path.exists(config_path):
logger.info("config file not found, falling back to config-template.json") 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) config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str))) logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
@@ -408,11 +436,19 @@ def load_config():
if name in available_setting: if name in available_setting:
logger.info("[INIT] override config by environ args: {}={}".format(name, value)) logger.info("[INIT] override config by environ args: {}={}".format(name, value))
try: 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: 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 config[name] = False
elif value == "true": elif value.lower() == "true":
config[name] = True config[name] = True
else: else:
config[name] = value config[name] = value
@@ -620,6 +656,34 @@ def get_root():
return os.path.dirname(os.path.abspath(__file__)) 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): def read_file(path):
with open(path, mode="r", encoding="utf-8-sig") as f: with open(path, mode="r", encoding="utf-8-sig") as f:
return f.read() return f.read()
@@ -630,13 +694,29 @@ def conf():
def get_appdata_dir(): 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): if not os.path.exists(data_path):
logger.info("[INIT] data path not exists, create it: {}".format(data_path)) logger.info("[INIT] data path not exists, create it: {}".format(data_path))
os.makedirs(data_path) os.makedirs(data_path)
return 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(): def subscribe_msg():
trigger_prefix = conf().get("single_chat_prefix", [""])[0] trigger_prefix = conf().get("single_chat_prefix", [""])[0]
msg = conf().get("subscribe_msg", "") 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,211 @@
# -*- 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')
# Playwright powers the browser tool. Only the pure-Python package + its bundled
# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled
# and is either satisfied by the user's system Chrome/Edge (preferred, zero
# download) or downloaded on demand into ~/.cow/ms-playwright at first use.
# Playwright imports its transport/driver lazily, so list submodules explicitly.
hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl']
hiddenimports += collect_submodules('playwright')
# --- 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')
# Playwright ships its Node.js driver + package.json under playwright/driver/.
# These are NOT Python modules, so hiddenimports won't pull them in — collect
# them as data or `playwright install` / launching fails in the frozen build.
# include_py_files=True is required: the driver dir contains .py entrypoints.
datas += collect_data_files('playwright', include_py_files=True)
# --- 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',
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
# so the browser tool works out of the box on desktop. The heavy Chromium
# binary is still NOT bundled: it comes from the user's system Chrome/Edge or
# is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py.
]
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,58 @@
# 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
# ---- browser tool ----
# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB).
# The Chromium binary is NOT bundled: the browser tool drives the user's system
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
playwright==1.52.0
# ---- 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

View File

@@ -0,0 +1,122 @@
/**
* Dynamic electron-builder config for WINDOWS code signing.
*
* Mirrors electron-builder.js (which handles mac.binaries) but for Windows.
* It wires a signing CLI into electron-builder so that every .exe is signed,
* with the private key kept in hardware per the post-2023 code-signing rules.
*
* A SINGLE sign hook (win.signtoolOptions.sign) covers everything: electron-
* builder calls it for EVERY .exe it processes, which includes the app
* launcher, the packaged PyInstaller backend (extraResources/backend/
* cowagent-backend.exe) and the NSIS installer. We deliberately do NOT add an
* afterPack pass — that would sign the backend a second time and waste a paid
* signing call on every release.
*
* PRIVACY: the CLI path and all credentials come from env vars only. Nothing in
* this file (or the public workflow) is hardcoded, so a public repo never leaks
* any signing configuration.
*
* DRY-RUN / SKIP: when SIGNTOOL_CERT_CODE is absent we skip signing entirely
* (unsigned dev/dry builds keep working). When COW_SIGN_DRY_RUN=1 we pass
* --dry-run so the WHOLE pipeline can be validated in CI with a self-signed
* cert, WITHOUT a real certificate and WITHOUT consuming any signing quota.
*/
const { execFileSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const config = require('./package.json').build
// Absolute path to the signing CLI on the runner. Injected by CI so this file
// never hardcodes a download URL. e.g. C:\signtool\signtool.exe
const SIGNTOOL = process.env.SIGNTOOL_PATH || ''
// Dry-run validates the pipeline with a self-signed cert (no quota, no real
// cert needed). Any truthy value enables it.
const DRY_RUN = !!process.env.COW_SIGN_DRY_RUN
// In dry-run the CLI still requires these flags to be NON-EMPTY (it validates
// presence, not the value, and signs with a self-signed cert). So when no real
// credentials are provided during a dry-run, fall back to harmless placeholders
// to satisfy the CLI's arg check. Real runs pass the actual secrets through.
const PLACEHOLDER = DRY_RUN ? 'dry-run' : ''
const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || PLACEHOLDER
const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || PLACEHOLDER
const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || PLACEHOLDER
// RFC3161 timestamp server for SHA256. Microsoft's is reliable from CI runners
// worldwide; overridable via env if needed.
const TIMESTAMP = process.env.SIGNTOOL_TIMESTAMP || 'http://timestamp.acs.microsoft.com'
// Signing is possible when we have the CLI plus either a real cert code or
// explicit dry-run mode (dry-run accepts placeholder credentials).
function canSign() {
if (!SIGNTOOL || !fs.existsSync(SIGNTOOL)) return false
if (DRY_RUN) return true
return !!(ACCESS_KEY && ACCESS_SECRET && CERT_CODE)
}
/**
* Sign a single file in place using the signing CLI. The CLI writes to a
* separate --out path (it refuses to overwrite an existing file), so we sign to
* a temp file and atomically move it back over the original.
*/
function signFile(filePath) {
const tmpOut = `${filePath}.signed`
// Remove a stale temp from a previous failed run (CLI errors if --out exists).
try {
if (fs.existsSync(tmpOut)) fs.rmSync(tmpOut)
} catch {
/* ignore */
}
const args = [
'sign',
...(DRY_RUN ? ['--dry-run'] : []),
`--access-key=${ACCESS_KEY}`,
`--access-secret=${ACCESS_SECRET}`,
`--cert-code=${CERT_CODE}`,
`--file=${filePath}`,
`--out=${tmpOut}`,
'--sha1=false',
'--sha2=true',
'--timestamp-rfc3161',
TIMESTAMP,
]
// Never print credentials: log only the file being signed.
console.log(`[win-sign] signing ${path.basename(filePath)}${DRY_RUN ? ' (dry-run)' : ''}`)
execFileSync(SIGNTOOL, args, { stdio: ['ignore', 'inherit', 'inherit'] })
if (!fs.existsSync(tmpOut)) {
throw new Error(`[win-sign] signed output not produced for ${filePath}`)
}
// Replace the original with the signed copy.
fs.rmSync(filePath)
fs.renameSync(tmpOut, filePath)
}
// electron-builder calls this for each artifact it generates (app exe, NSIS
// installer, uninstaller). Signature: (configuration) => void, where
// configuration.path is the file to sign.
async function customSign(configuration) {
if (!canSign()) {
console.warn('[win-sign] signing skipped (no signtool/credentials)')
return
}
signFile(configuration.path)
}
// Extend the base config: attach the sign hook. Only meaningful on Windows
// builds (this config is only passed via --config on the win matrix leg).
//
// electron-builder invokes customSign for EVERY .exe it touches — that already
// includes the packaged backend (extraResources/backend/cowagent-backend.exe)
// and the NSIS installer, not just the app launcher. So there's no separate
// afterPack pass: adding one would sign the backend twice (wasting a paid
// signing call per release). Nested PyInstaller .dll/.pyd files are left
// unsigned, which Windows Authenticode tolerates (unlike macOS, it doesn't
// require deep-signing every nested lib — a signed top-level exe is enough for
// SmartScreen/Defender to attribute the publisher).
config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } }
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,456 @@
import { ChildProcess, spawn, execFileSync } 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
}
// Cache the resolved PATH so we only spawn a login shell once per process.
private resolvedPath: string | null = null
/**
* Build the PATH the backend should run with.
*
* When launched from Finder/Dock, a GUI app inherits launchd's minimal PATH
* (/usr/bin:/bin:...) and never loads ~/.zshrc, so user-installed CLIs like
* `linkai`, `node`, or Homebrew tools are invisible to the agent's bash tool.
* We recover the real login-shell PATH (macOS/Linux) and merge in common bin
* dirs, so the agent can find these commands regardless of how the app started.
*/
private resolveEnvPath(): string {
if (this.resolvedPath !== null) {
return this.resolvedPath
}
const sep = path.delimiter
const existing = process.env.PATH || ''
const parts: string[] = existing ? existing.split(sep) : []
// Windows GUI apps already inherit the full system PATH; nothing to fix.
if (process.platform !== 'win32') {
// Ask the user's login shell for its PATH. `-ilc` runs an interactive
// login shell so it sources ~/.zshrc / ~/.zprofile etc.
try {
const shell = process.env.SHELL || '/bin/zsh'
const out = execFileSync(shell, ['-ilc', 'echo -n "__PATH__$PATH"'], {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
})
const marker = out.lastIndexOf('__PATH__')
if (marker !== -1) {
const shellPath = out.slice(marker + '__PATH__'.length).trim()
if (shellPath) {
parts.push(...shellPath.split(sep))
}
}
} catch {
// Shell probe failed (unusual shell, timeout). Fall back to the
// common dirs below so at least the typical install paths work.
}
const home = os.homedir()
parts.push(
path.join(home, '.local/bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin',
)
}
// De-duplicate while preserving order (first occurrence wins).
const seen = new Set<string>()
const merged: string[] = []
for (const p of parts) {
const dir = p.trim()
if (dir && !seen.has(dir)) {
seen.add(dir)
merged.push(dir)
}
}
this.resolvedPath = merged.join(sep)
return this.resolvedPath
}
/**
* 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,
// Recover the user's real PATH (login shell + common bin dirs) so the
// agent's bash tool can find CLIs like `linkai`/`node` even when the
// app is launched from Finder/Dock with launchd's minimal PATH.
PATH: this.resolveEnvPath(),
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 = () => {
// Probe the unauthenticated health endpoint, NOT /config: /config
// requires auth once a web_password is set, which would make this poll
// 401 forever and hang startup.
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (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
}

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

@@ -0,0 +1,260 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs'
import os from 'os'
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
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
// update to OTHER legacy builds — never the standard build (Electron 33 won't
// launch on Win7). The update Function serves that build under /update/legacy/.
// We detect the old OS at runtime (os.release() reports the Windows NT version:
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
// flag, so the same source serves the right feed on whatever it runs on.
function isLegacyWindows(): boolean {
if (process.platform !== 'win32') return false
const major = Number((os.release() || '').split('.')[0])
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
return Number.isFinite(major) && major < 10
}
// 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. Legacy Windows appends a
// /legacy/ segment so it gets the win-legacy release instead of the standard.
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
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,182 @@
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 LoginGate from './components/LoginGate'
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)
// Auth gate for web_password-protected backends. 'checking' until we know
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
// the main UI render.
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl])
// Once the backend is ready, check whether a web_password is set. If so and
// this session isn't authenticated, show the login gate before the app.
useEffect(() => {
if (backend.status !== 'ready') {
setAuthState('checking')
return
}
let cancelled = false
apiClient
.authCheck()
.then((res) => {
if (cancelled) return
const needLogin = res.auth_required && !res.authenticated
setAuthState(needLogin ? 'need_login' : 'ok')
})
.catch(() => {
// If the check itself fails, don't hard-block the user — assume no auth
// is required (backends without web_password never return errors here).
if (!cancelled) setAuthState('ok')
})
return () => {
cancelled = true
}
}, [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' || authState !== 'ok') 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, authState, 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} />
}
// Backend is up but we're still resolving auth — keep the loading screen.
if (authState === 'checking') {
return <StatusScreen status="connecting" onRetry={backend.restart} />
}
if (authState === 'need_login') {
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
}
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,457 @@
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
}
const AUTH_TOKEN_KEY = 'cow_auth_token'
class ApiClient {
private baseUrl = 'http://127.0.0.1:9876'
// Bearer token for web_password-protected backends. The desktop renderer
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
// aren't sent reliably, so we authenticate via an Authorization header
// instead. Persisted in localStorage so it survives reloads.
private authToken: string | null =
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
setBaseUrl(url: string) {
this.baseUrl = url
}
getBaseUrl() {
return this.baseUrl
}
setAuthToken(token: string | null) {
this.authToken = token
try {
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
else localStorage.removeItem(AUTH_TOKEN_KEY)
} catch {
// localStorage may be unavailable; in-memory token still works this session
}
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
// Cookies still work for browser access; the desktop app relies on the
// Authorization header below.
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
...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 }),
})
}
// EventSource can't set an Authorization header, so append the auth token as
// a query param for SSE endpoints (the backend accepts it there).
private withToken(url: string): string {
if (!this.authToken) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
}
createSSEStream(requestId: string): EventSource {
return new EventSource(this.withToken(`${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
// Served via <img src>, which can't set headers — carry the token in the
// query so protected file endpoints load under web_password.
return this.withToken(`${this.baseUrl}${previewUrl}`)
}
getServeFileUrl(absPath: string): string {
return this.withToken(`${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.withToken(`${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 & { token?: string }> {
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
if (res.status === 'success' && res.token) {
this.setAuthToken(res.token)
}
return res
}
async authLogout(): Promise<ApiResult> {
this.setAuthToken(null)
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,399 @@
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: '/install-browser', desc: t('slash_install_browser') },
{ 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,72 @@
import React, { useState } from 'react'
import apiClient from '../api/client'
import { t } from '../i18n'
interface LoginGateProps {
// Called once the password is accepted (auth cookie set), so the app can
// proceed to the main UI.
onAuthenticated: () => void
}
/**
* Shown when the backend has a web_password set and the current session isn't
* authenticated yet. Submitting the correct password sets an auth cookie
* (handled by the backend), after which the app reloads its data.
*/
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password || submitting) return
setSubmitting(true)
setError('')
try {
const res = await apiClient.authLogin(password)
if (res.status === 'success') {
onAuthenticated()
} else {
setError(t('login_error'))
}
} catch {
setError(t('login_error'))
} finally {
setSubmitting(false)
}
}
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
</div>
<input
type="password"
autoFocus
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (error) setError('')
}}
placeholder={t('login_placeholder')}
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={submitting || !password}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
>
{submitting ? t('login_checking') : t('login_submit')}
</button>
</form>
</div>
)
}
export default LoginGate

View File

@@ -0,0 +1,161 @@
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 ''
}
},
})
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`)
// and CJK full-width punctuation glued to a URL (common in LLM output like
// `**https://x**,中文`), turning the whole tail into one broken link. Cut the
// URL at the first such char and spill the remainder back as plain text.
const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/
md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => {
for (const blk of state.tokens) {
if (blk.type !== 'inline' || !blk.children) continue
const ch = blk.children
for (let i = 0; i < ch.length; i++) {
const open = ch[i]
if (open.type !== 'link_open' || open.markup !== 'linkify') continue
const textTok = ch[i + 1]
const close = ch[i + 2]
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue
const idx = textTok.content.search(_GREEDY_LINK_CUT)
if (idx < 0) continue
const keep = textTok.content.slice(0, idx)
const spill = textTok.content.slice(idx)
textTok.content = keep
open.attrSet('href', keep)
const spillTok = new state.Token('text', '', 0)
spillTok.content = spill
ch.splice(i + 3, 0, spillTok)
}
}
})
// 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
/**
* Intercept clicks on internal document links (relative `.md` hrefs). When
* provided, such links open in-app instead of being handed to the OS. Used by
* the knowledge viewer so index links open the target doc rather than firing
* an "application cannot be opened (-120)" error in Electron.
*/
onInternalLink?: (href: string) => void
}
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
const rootRef = useRef<HTMLDivElement>(null)
const html = useMemo(() => md.render(content || ''), [content])
// Delegate clicks: copy buttons on code blocks, and internal doc links.
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
// Internal knowledge links (relative *.md), when a handler is provided.
if (onInternalLink) {
const a = target.closest('a') as HTMLAnchorElement | null
if (a) {
const href = a.getAttribute('href') || ''
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
e.preventDefault()
onInternalLink(href)
return
}
}
}
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)
},
[onInternalLink]
)
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,210 @@
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">
{hasSteps && <MessageSteps steps={message.steps!} />}
{/* Live reasoning is the current, not-yet-committed thinking, so it
must render after all committed steps (tools/thinking), not at
the very top of the bubble. */}
{hasLiveReasoning && (
<div className={hasSteps ? 'mt-1' : ''}>
<ThinkingStep content={message.reasoning!} streaming />
</div>
)}
</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

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react'
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
import type { MessageStep } from '../types'
import { t } from '../i18n'
import Markdown from './Markdown'
/**
* Assistant reasoning / tool steps, styled to match the web console: small,
* muted, collapsible rows with an indented detail panel.
*/
const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ content, streaming }) => {
const [expanded, setExpanded] = useState(false)
return (
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
<div
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
</div>
{expanded && (
<pre className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle whitespace-pre-wrap leading-relaxed max-h-[260px] overflow-y-auto font-sans text-content-tertiary">
{content}
</pre>
)}
</div>
)
}
const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
const [expanded, setExpanded] = useState(false)
const running = step.status === 'running'
const isError = step.is_error || (!!step.status && step.status !== 'success' && !running)
const icon = running ? (
<Loader2 size={12} className="text-accent animate-spin" />
) : isError ? (
<X size={12} className="text-danger" />
) : (
<Check size={12} className="text-accent" />
)
return (
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
<div
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<span className="flex-shrink-0">{icon}</span>
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
{step.execution_time !== undefined && (
<span className="opacity-60">{step.execution_time}s</span>
)}
<ChevronRight size={11} className={`ml-auto transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
</div>
{expanded && (
<div className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle space-y-2">
{step.arguments && Object.keys(step.arguments).length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">Input</div>
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all max-h-[200px] overflow-y-auto leading-relaxed">
{JSON.stringify(step.arguments, null, 2)}
</pre>
</div>
)}
{step.result && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">
{isError ? 'Error' : 'Output'}
</div>
<pre
className={`font-mono text-[11px] whitespace-pre-wrap break-all max-h-[240px] overflow-y-auto leading-relaxed ${
isError ? 'text-danger' : ''
}`}
>
{step.result.length > 4000 ? step.result.slice(0, 4000) + '\n… (truncated)' : step.result}
</pre>
</div>
)}
</div>
)}
</div>
)
}
/** Renders an ordered list of assistant steps (thinking / content / tool). */
const MessageSteps: React.FC<{ steps: MessageStep[] }> = ({ steps }) => {
if (!steps.length) return null
return (
<div>
{steps.map((step, i) => {
if (step.type === 'thinking') return <ThinkingStep key={i} content={step.content || ''} />
if (step.type === 'tool') return <ToolStep key={i} step={step} />
if (step.type === 'content' && step.content)
return (
<div key={i} className="mb-2 pb-2 border-b border-dashed border-default last:border-0 last:mb-0 last:pb-0">
<Markdown content={step.content} />
</div>
)
return null
})}
</div>
)
}
export { ThinkingStep, ToolStep }
export default MessageSteps

View File

@@ -0,0 +1,293 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Sparkles, KeyRound, Loader2, ArrowRight, ArrowLeft, ExternalLink } from 'lucide-react'
import { t, getLang, setLang, type Lang } from '../i18n'
import apiClient from '../api/client'
import type { ModelsData } from '../types'
import { Field, Dropdown, TextInput, type DropdownOption } from '../pages/settings/primitives'
import { resolveModels, providerLabel } from '../pages/settings/modelsHelpers'
import { useOnboardingStore } from '../store/onboardingStore'
interface OnboardingWizardProps {
// Called after the wizard finishes so the host can refresh language/state.
onDone: () => void
}
const TOTAL_STEPS = 2
// Optional "where to get an API key" console link, per provider.
const PROVIDER_KEY_CONSOLE: Record<string, string> = {
linkai: 'https://link-ai.tech/console/interface',
}
// First-run guided setup: language -> chat model (provider + key + model).
// After saving the model the user goes straight into the chat (no extra
// confirmation step). Rendered as a full-screen overlay above the main UI;
// reuses the same models API and primitives as the settings page.
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onDone }) => {
const finish = useOnboardingStore((s) => s.finish)
const [step, setStep] = useState(1)
const [lang, setLangState] = useState<Lang>(getLang())
const [models, setModels] = useState<ModelsData | null>(null)
// Step 2 form state.
const [provider, setProvider] = useState('')
const [apiKey, setApiKey] = useState('')
const [apiBase, setApiBase] = useState('')
const [model, setModel] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
// Load the models console data once for the provider/model dropdowns.
useEffect(() => {
apiClient
.getModels()
.then(setModels)
.catch(() => setError(t('onboarding_save_failed')))
}, [])
// Persist the auto-detected default language on first show so the pre-selected
// option (driven by OS locale) also reaches the backend, even if the user
// doesn't tap the language buttons.
useEffect(() => {
if (!localStorage.getItem('cow_lang')) switchLang(lang)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const providerOptions: DropdownOption[] = useMemo(() => {
const chat = models?.capabilities?.chat
const ids = chat?.providers || []
return ids.map((id) => ({ value: id, label: providerLabel(models, id) }))
}, [models])
const modelOptions: DropdownOption[] = useMemo(() => {
return resolveModels(models, provider, models?.capabilities?.chat?.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
}, [models, provider])
// The currently selected provider's api_base placeholder/default, if any.
const providerMeta = models?.providers?.find((p) => p.id === provider)
const apiBasePlaceholder = providerMeta?.api_base_placeholder || providerMeta?.api_base_default
const handleProvider = (id: string) => {
setProvider(id)
setApiBase('')
const first = resolveModels(models, id, models?.capabilities?.chat?.provider_models)[0]
setModel(first?.value || '')
}
const switchLang = (next: Lang) => {
setLang(next)
setLangState(next)
// Mirror the choice to the backend so the agent/logs use the same language
// (matches BasicSettings). Non-blocking: the UI already switched locally.
apiClient.updateConfig({ cow_lang: next }).catch(() => {})
}
// Step 1 (language) can always advance; step 2 needs a provider, key, model.
const canNext = step === 1 || (!!provider && !!apiKey.trim() && !!model)
const goNext = async () => {
setError('')
// Step 1 (language) just advances to the model step.
if (step === 1) {
setStep(2)
return
}
// Step 2 is the last step: persist the provider credentials, point the chat
// capability at it, then finish straight into the chat (no extra step).
setSaving(true)
try {
await apiClient.modelsAction({
action: 'set_provider',
provider_id: provider,
api_key: apiKey.trim(),
...(apiBase.trim() ? { api_base: apiBase.trim() } : {}),
})
await apiClient.modelsAction({
action: 'set_capability',
capability: 'chat',
provider_id: provider,
model,
})
} catch {
setSaving(false)
setError(t('onboarding_save_failed'))
return
}
setSaving(false)
complete()
}
const goBack = () => {
setError('')
setStep((s) => Math.max(1, s - 1))
}
const complete = () => {
finish()
onDone()
}
const stepLabel = t('onboarding_step').replace('{n}', String(step)).replace('{total}', String(TOTAL_STEPS))
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-base">
<div className="w-full max-w-lg px-8">
{/* Progress dots */}
<div className="flex items-center justify-center gap-2 mb-8">
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i + 1 === step ? 'w-8 bg-accent' : i + 1 < step ? 'w-4 bg-accent/50' : 'w-4 bg-surface-2'
}`}
/>
))}
</div>
{step === 1 && (
<div className="text-center space-y-6">
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
<Sparkles size={30} />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-content">{t('onboarding_welcome_title')}</h1>
<p className="text-sm text-content-secondary">{t('onboarding_welcome_desc')}</p>
</div>
<div className="max-w-xs mx-auto text-left">
<Field label={t('onboarding_lang_label')}>
<div className="grid grid-cols-2 gap-2">
{(['zh', 'en'] as Lang[]).map((l) => (
<button
key={l}
onClick={() => switchLang(l)}
className={`px-4 py-2.5 rounded-btn border text-sm font-medium cursor-pointer transition-colors ${
lang === l
? 'border-accent bg-accent-soft text-accent'
: 'border-strong text-content-secondary hover:bg-surface-2'
}`}
>
{l === 'zh' ? '简体中文' : 'English'}
</button>
))}
</div>
</Field>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
<KeyRound size={28} />
</div>
<h1 className="text-2xl font-bold text-content">{t('onboarding_model_title')}</h1>
<p className="text-sm text-content-secondary">{t('onboarding_model_desc')}</p>
</div>
<div className="space-y-4">
<Field label={t('onboarding_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('onboarding_select_provider')}
onChange={handleProvider}
/>
</Field>
{provider && (
<>
<Field label={t('onboarding_apikey')}>
<TextInput
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('onboarding_apikey_placeholder')}
className="font-mono"
/>
{PROVIDER_KEY_CONSOLE[provider] && (
<a
href={PROVIDER_KEY_CONSOLE[provider]}
target="_blank"
rel="noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:underline"
>
{t('onboarding_key_guide')}
<ExternalLink size={11} />
</a>
)}
</Field>
{providerMeta?.api_base_field && (
<Field label={t('onboarding_apibase')}>
<TextInput
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={apiBasePlaceholder || ''}
className="font-mono"
/>
</Field>
)}
<Field label={t('onboarding_model')}>
<Dropdown
value={model}
options={modelOptions}
placeholder={t('onboarding_select_model')}
onChange={setModel}
/>
</Field>
</>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
</div>
)}
{/* Footer controls */}
<div className="mt-10 flex items-center justify-between">
<div className="text-xs text-content-tertiary">{stepLabel}</div>
<div className="flex items-center gap-2">
{/* Step 2: back to language. */}
{step === 2 && (
<button
onClick={goBack}
disabled={saving}
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
<ArrowLeft size={15} />
{t('onboarding_back')}
</button>
)}
{/* Skip is available on every step: dismiss and go straight to chat. */}
<button
onClick={complete}
disabled={saving}
className="px-4 py-2 rounded-btn text-sm font-medium text-content-tertiary hover:text-content cursor-pointer transition-colors disabled:opacity-50"
>
{t('onboarding_skip')}
</button>
{/* Primary action: advance on step 1, save + finish on the last step. */}
<button
onClick={goNext}
disabled={!canNext || saving}
className="px-5 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{saving && <Loader2 size={15} className="animate-spin" />}
{saving
? t('onboarding_saving')
: step === TOTAL_STEPS
? t('onboarding_finish')
: t('onboarding_next')}
{!saving && <ArrowRight size={15} />}
</button>
</div>
</div>
</div>
</div>
)
}
export default OnboardingWizard

View File

@@ -0,0 +1,222 @@
import React, { useEffect, useRef, useState } from 'react'
import { Loader2, Check, RotateCcw } from 'lucide-react'
import { t } from '../i18n'
import apiClient from '../api/client'
import { Modal } from '../pages/settings/primitives'
type Provider = 'weixin' | 'feishu'
type Phase = 'loading' | 'waiting' | 'scanned' | 'success' | 'error'
interface QrLoginModalProps {
provider: Provider
onClose: () => void
// Fired once the channel is connected so the page can refresh.
onConnected: () => void
}
const POLL_INTERVAL = 2000
// Shared QR-login / QR-register modal for WeChat and Feishu. Mirrors the web
// console flow: fetch a QR, poll status, then connect the channel on success.
const QrLoginModal: React.FC<QrLoginModalProps> = ({ provider, onClose, onConnected }) => {
const [phase, setPhase] = useState<Phase>('loading')
const [qr, setQr] = useState('')
const [openLink, setOpenLink] = useState('')
const [errMsg, setErrMsg] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const aliveRef = useRef(true)
const stopPoll = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
const fail = (msg: string) => {
if (!aliveRef.current) return
setPhase('error')
setErrMsg(msg)
}
// ---- WeChat: GET qr, POST poll {scaned|confirmed|expired} -----------------
const pollWeixin = () => {
timerRef.current = setTimeout(async () => {
if (!aliveRef.current) return
try {
const data = await apiClient.weixinQrAction('poll')
if (!aliveRef.current) return
if (data.status !== 'success') return pollWeixin()
const s = data.qr_status as string
if (s === 'confirmed') {
setPhase('success')
await apiClient.channelAction('connect', 'weixin', {})
if (aliveRef.current) onConnected()
} else if (s === 'expired' && (data.qr_image || data.qrcode_url)) {
setQr((data.qr_image as string) || (data.qrcode_url as string))
setPhase('waiting')
pollWeixin()
} else if (s === 'scaned') {
setPhase('scanned')
pollWeixin()
} else {
pollWeixin()
}
} catch {
pollWeixin()
}
}, POLL_INTERVAL)
}
const startWeixin = async () => {
setPhase('loading')
try {
const data = await apiClient.getWeixinQr()
if (!aliveRef.current) return
if (data.status !== 'success') return fail(data.message || t('weixin_scan_fail'))
setQr(data.qr_image || data.qrcode_url || '')
setPhase('waiting')
pollWeixin()
} catch {
fail(t('weixin_scan_fail'))
}
}
// ---- Feishu: GET qr, POST poll {done|expired|denied|error} ----------------
const pollFeishu = () => {
timerRef.current = setTimeout(async () => {
if (!aliveRef.current) return
try {
const data = await apiClient.feishuRegisterPoll()
if (!aliveRef.current) return
if (data.status !== 'success') return fail((data.message as string) || t('feishu_scan_fail'))
const rs = data.register_status as string
if (rs === 'done') {
setPhase('success')
await apiClient.channelAction('connect', 'feishu', {
feishu_app_id: data.app_id,
feishu_app_secret: data.app_secret,
})
if (aliveRef.current) onConnected()
} else if (rs === 'expired') {
fail(t('feishu_scan_expired'))
} else if (rs === 'denied') {
fail(t('feishu_scan_denied'))
} else if (rs === 'error') {
fail((data.message as string) || t('feishu_scan_fail'))
} else {
pollFeishu()
}
} catch {
pollFeishu()
}
}, POLL_INTERVAL)
}
const startFeishu = async () => {
setPhase('loading')
try {
const data = await apiClient.getFeishuRegister()
if (!aliveRef.current) return
if (data.status !== 'success') return fail(data.message || t('feishu_scan_fail'))
setQr(data.qr_image || data.qrcode_url || '')
setOpenLink(data.qrcode_url || '')
setPhase('waiting')
pollFeishu()
} catch {
fail(t('feishu_scan_fail'))
}
}
const start = () => {
stopPoll()
if (provider === 'weixin') void startWeixin()
else void startFeishu()
}
useEffect(() => {
aliveRef.current = true
start()
return () => {
aliveRef.current = false
stopPoll()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const title = provider === 'weixin' ? t('weixin_scan_title') : t('feishu_scan_title')
const desc = provider === 'weixin' ? t('weixin_scan_desc') : t('feishu_scan_desc')
const tip = provider === 'weixin' ? t('weixin_qr_tip') : t('feishu_scan_tip')
const statusText = (): string => {
if (provider === 'weixin') {
if (phase === 'scanned') return t('weixin_scan_scanned')
return t('weixin_scan_waiting')
}
return t('feishu_scan_waiting')
}
return (
<Modal open title={title} onClose={onClose}>
<div className="flex flex-col items-center py-2">
{phase === 'loading' && (
<div className="flex items-center text-content-tertiary py-10">
<Loader2 size={18} className="animate-spin mr-2" />
{provider === 'weixin' ? t('weixin_scan_loading') : t('feishu_scan_loading')}
</div>
)}
{(phase === 'waiting' || phase === 'scanned') && (
<>
<p className="text-sm text-content-secondary mb-4 text-center">{desc}</p>
<div className="bg-white p-3 rounded-card border border-subtle mb-3">
{qr ? (
<img src={qr} alt="QR" className="w-48 h-48" style={{ imageRendering: 'pixelated' }} />
) : (
<div className="w-48 h-48 flex items-center justify-center text-content-tertiary text-xs">QR</div>
)}
</div>
<p className={`text-xs mb-1 ${phase === 'scanned' ? 'text-accent' : 'text-warning'}`}>{statusText()}</p>
<p className="text-xs text-content-tertiary">{tip}</p>
{openLink && (
<a
href={openLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-info hover:underline mt-2"
>
{t('feishu_scan_open_link')}
</a>
)}
</>
)}
{phase === 'success' && (
<div className="flex flex-col items-center py-8">
<div className="w-12 h-12 rounded-full bg-accent-soft flex items-center justify-center mb-3">
<Check size={22} className="text-accent" />
</div>
<p className="text-sm font-medium text-accent">
{provider === 'weixin' ? t('weixin_scan_success') : t('feishu_scan_success')}
</p>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center py-8">
<p className="text-sm text-danger text-center mb-3">{errMsg}</p>
<button
onClick={start}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn border border-strong text-sm text-content-secondary hover:bg-inset cursor-pointer transition-colors"
>
<RotateCcw size={13} />
{t('feishu_scan_retry')}
</button>
</div>
)}
</div>
</Modal>
)
}
export default QrLoginModal

View File

@@ -0,0 +1,58 @@
import React from 'react'
import { t } from '../i18n'
interface StatusScreenProps {
status: 'connecting' | 'error'
error?: string
onRetry: () => void
}
const StatusScreen: React.FC<StatusScreenProps> = ({ status, error, onRetry }) => {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
<div className="text-center space-y-6 max-w-md px-8">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
{status === 'connecting' && (
<>
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t('status_starting')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('status_starting_desc')}
</p>
</div>
<div className="flex justify-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0s' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.2s' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.4s' }} />
</div>
</>
)}
{status === 'error' && (
<>
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t('status_error')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{error || t('status_error_desc')}
</p>
</div>
<button
onClick={onRetry}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
>
<i className="fas fa-rotate-right text-xs" />
{t('status_retry')}
</button>
</>
)}
</div>
</div>
)
}
export default StatusScreen

View File

@@ -0,0 +1,133 @@
import React from 'react'
import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react'
import { t } from '../i18n'
import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
// Compact update panel anchored to the NavRail footer. Shown whenever an update
// is available AND the panel is open (auto-opened on detection, re-openable via
// "check for update"). Dismissing (×) just closes it; the menu can re-open it.
const UpdateBanner: React.FC = () => {
const state = useUpdateStore()
const open = state.panelOpen
const available = hasAvailableUpdate(state)
const status = state.status
const errored = status?.state === 'error'
// Full-screen "installing…" overlay: bridges the otherwise blank window
// between clicking "restart to install" and the app actually quitting to
// swap the bundle. (The gap AFTER quit, before relaunch, is OS-level and
// can't be covered.)
if (state.installing) {
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-3 bg-base/90 backdrop-blur-sm">
<Loader2 size={28} className="animate-spin text-accent" />
<p className="text-sm text-content-secondary">{t('update_installing')}</p>
</div>
)
}
// Show the panel when it's open AND we either know of an update or hit an
// error. Keeping it up on error is important: a failed download must surface
// a visible message instead of silently doing nothing.
if (!open || (!available && !errored)) return null
const version = state.version
const preparing = state.preparing
const downloading = status?.state === 'downloading'
const downloaded = status?.state === 'downloaded'
// macOS emits a second progress pass (verify) after hitting 100%; show it as
// an indeterminate "verifying" state rather than a bar restarting from 0.
const verifying = downloading && state.progressPeaked
const busy = preparing || downloading
return (
<div className="absolute bottom-2 left-2 right-2 z-40">
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-content">
{errored ? t('update_failed') : t('update_available')}
</p>
{!errored && version && (
<p className="text-xs text-content-tertiary mt-0.5">v{version}</p>
)}
</div>
<button
onClick={() => state.dismiss()}
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
title={t('update_later')}
>
<X size={15} />
</button>
</div>
{errored && (
<div className="space-y-2.5">
<div className="flex items-start gap-2 text-xs text-content-secondary">
<AlertTriangle size={13} className="text-amber-500 flex-shrink-0 mt-0.5" />
<span className="break-words">
{status?.state === 'error' ? status.message : ''}
</span>
</div>
<button
onClick={() => state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<RefreshCw size={15} />
{t('update_retry')}
</button>
</div>
)}
{!errored && preparing && (
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_preparing')}</span>
</div>
)}
{!errored && downloading && verifying && (
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_verifying')}</span>
</div>
)}
{!errored && downloading && !verifying && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-content-secondary">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_downloading')} {state.percent}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-surface-2 overflow-hidden">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${state.percent}%` }} />
</div>
</div>
)}
{!errored && !busy && !downloaded && (
<button
onClick={() => state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<Download size={15} />
{t('update_download')}
</button>
)}
{!errored && downloaded && (
<button
onClick={() => state.install()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<RefreshCw size={15} />
{t('update_restart')}
</button>
)}
</div>
</div>
)
}
export default UpdateBanner

View File

@@ -0,0 +1,16 @@
import React from 'react'
// Solid paper-plane icon (Font Awesome's fa-paper-plane path) so the send
// button and the feishu/telegram channels match the web console exactly.
export const PaperPlaneIcon: React.FC<{ size?: number; className?: string }> = ({ size = 16, className }) => (
<svg
width={size}
height={size}
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480l0-83.6c0-4 1.5-7.8 4.2-10.8L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z" />
</svg>
)

View File

@@ -0,0 +1,189 @@
/* highlight.js github themes, scoped for light/dark. Generated; do not edit. */
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em
}
code.hljs {
padding: 3px 5px
}
/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/
.hljs {
color: #24292e;
background: #ffffff
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #d73a49
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
/* prettylights-syntax-entity */
color: #6f42c1
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
/* prettylights-syntax-constant */
color: #005cc5
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #032f62
}
.hljs-built_in,
.hljs-symbol {
/* prettylights-syntax-variable */
color: #e36209
}
.hljs-comment,
.hljs-code,
.hljs-formula {
/* prettylights-syntax-comment */
color: #6a737d
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #22863a
}
.hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #24292e
}
.hljs-section {
/* prettylights-syntax-markup-heading */
color: #005cc5;
font-weight: bold
}
.hljs-bullet {
/* prettylights-syntax-markup-list */
color: #735c0f
}
.hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #24292e;
font-style: italic
}
.hljs-strong {
/* prettylights-syntax-markup-bold */
color: #24292e;
font-weight: bold
}
.hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #22863a;
background-color: #f0fff4
}
.hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #b31d28;
background-color: #ffeef0
}
.hljs-char.escape_,
.hljs-link,
.hljs-params,
.hljs-property,
.hljs-punctuation,
.hljs-tag {
/* purposely ignored */
}
.dark pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em
}.dark code.hljs {
padding: 3px 5px
}.dark /*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/
.hljs {
color: #c9d1d9;
background: #0d1117
}.dark .hljs-doctag, .dark .hljs-keyword, .dark .hljs-meta .hljs-keyword, .dark .hljs-template-tag, .dark .hljs-template-variable, .dark .hljs-type, .dark .hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #ff7b72
}.dark .hljs-title, .dark .hljs-title.class_, .dark .hljs-title.class_.inherited__, .dark .hljs-title.function_ {
/* prettylights-syntax-entity */
color: #d2a8ff
}.dark .hljs-attr, .dark .hljs-attribute, .dark .hljs-literal, .dark .hljs-meta, .dark .hljs-number, .dark .hljs-operator, .dark .hljs-variable, .dark .hljs-selector-attr, .dark .hljs-selector-class, .dark .hljs-selector-id {
/* prettylights-syntax-constant */
color: #79c0ff
}.dark .hljs-regexp, .dark .hljs-string, .dark .hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #a5d6ff
}.dark .hljs-built_in, .dark .hljs-symbol {
/* prettylights-syntax-variable */
color: #ffa657
}.dark .hljs-comment, .dark .hljs-code, .dark .hljs-formula {
/* prettylights-syntax-comment */
color: #8b949e
}.dark .hljs-name, .dark .hljs-quote, .dark .hljs-selector-tag, .dark .hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #7ee787
}.dark .hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #c9d1d9
}.dark .hljs-section {
/* prettylights-syntax-markup-heading */
color: #1f6feb;
font-weight: bold
}.dark .hljs-bullet {
/* prettylights-syntax-markup-list */
color: #f2cc60
}.dark .hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #c9d1d9;
font-style: italic
}.dark .hljs-strong {
/* prettylights-syntax-markup-bold */
color: #c9d1d9;
font-weight: bold
}.dark .hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #aff5b4;
background-color: #033a16
}.dark .hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #ffdcd7;
background-color: #67060c
}.dark .hljs-char.escape_, .dark .hljs-link, .dark .hljs-params, .dark .hljs-property, .dark .hljs-punctuation, .dark .hljs-tag {
/* purposely ignored */
}

View File

@@ -0,0 +1,156 @@
import { useState, useEffect, useCallback, useRef } from 'react'
// Fixed default port — MUST match DESKTOP_BACKEND_PORT in main/python-manager.ts.
// The backend is launched on exactly this port (the main process frees it first
// and passes it via COW_WEB_PORT), so probing it works even before the
// getBackendPort IPC resolves. Keeping both sides on one constant means the
// renderer can never end up talking to the wrong port.
const BACKEND_PORT = 9876
interface BackendState {
status: 'connecting' | 'ready' | 'error'
port: number
error?: string
}
export function useBackend() {
const [state, setState] = useState<BackendState>({
status: 'connecting',
port: BACKEND_PORT,
})
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
try {
// Probe the unauthenticated health endpoint, NOT /config: once a
// web_password is set, /config returns 401 and we'd wrongly treat the
// (healthy) backend as unreachable, hanging on "connecting".
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}, [])
// True once the backend has answered at least once. After this we never flip
// back to "error" from polling — a hidden/backgrounded window throttles JS
// timers, so attempt counters are unreliable and would otherwise produce a
// false "failed to start" even though the backend is alive.
const readyRef = useRef(false)
// Holds the latest resolved port so the visibility handler (registered once)
// always probes the correct port without re-running the effect.
const portRef = useRef(BACKEND_PORT)
useEffect(() => {
let cancelled = false
let offStatus: (() => void) | undefined
const api = window.electronAPI
// Use a wall-clock deadline instead of an attempt counter so timer
// throttling (when the window is in the background) can't fast-forward us
// into a false failure. Only give up if we genuinely can't reach the
// backend for this long.
const startPolling = async (port: number) => {
portRef.current = port
const deadline = Date.now() + 90_000
const poll = async () => {
if (cancelled) return
const ready = await probeBackend(port)
if (cancelled) return
if (ready) {
readyRef.current = true
setState({ status: 'ready', port })
return
}
// Backend already answered before but is briefly unreachable (e.g.
// window was asleep): keep retrying, never surface an error.
if (!readyRef.current && Date.now() >= deadline) {
// Leave error undefined so StatusScreen shows the localized,
// user-friendly message instead of a raw technical string.
setState({ status: 'error', port })
return
}
pollingRef.current = setTimeout(poll, 1000)
}
await poll()
}
if (api) {
// Always start polling, even if getBackendPort rejects or the ready event
// was already emitted before we subscribed: polling /config is the
// self-sufficient path to "ready" and must never depend on the IPC round
// trip succeeding (otherwise the app can hang forever on "connecting").
api
.getBackendPort()
.then((port) => {
const p = port || BACKEND_PORT
portRef.current = p
setState((prev) => ({ ...prev, port: p }))
startPolling(p)
})
.catch(() => {
startPolling(BACKEND_PORT)
})
offStatus = api.onBackendStatus((data) => {
if (data.status === 'ready' && data.port) {
readyRef.current = true
portRef.current = data.port
setState({ status: 'ready', port: data.port })
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
} else if (data.status === 'error' && !readyRef.current) {
// Ignore late "error" from the main process once we've been ready —
// it usually means the window was backgrounded, not a real failure.
// Drop the raw technical message; StatusScreen shows a localized one.
setState((prev) => ({ ...prev, status: 'error' }))
}
})
} else {
startPolling(BACKEND_PORT)
}
// When the window comes back to the foreground, re-probe immediately so a
// user returning after a while sees the real (ready) state right away
// instead of waiting for the throttled timer to catch up.
const onVisible = () => {
if (cancelled || document.visibilityState !== 'visible') return
probeBackend(portRef.current).then((ready) => {
if (cancelled || !ready) return
readyRef.current = true
setState((prev) => ({ ...prev, status: 'ready' }))
})
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
offStatus?.()
document.removeEventListener('visibilitychange', onVisible)
}
}, [probeBackend])
const restart = useCallback(async () => {
setState((prev) => ({ ...prev, status: 'connecting', error: undefined }))
if (window.electronAPI) {
await window.electronAPI.restartBackend()
}
}, [])
const baseUrl = `http://127.0.0.1:${state.port}`
return { ...state, baseUrl, restart }
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
export type Platform = 'mac' | 'win' | 'linux'
function detectPlatform(): Platform {
const p = window.electronAPI?.platform
if (p === 'darwin') return 'mac'
if (p === 'win32') return 'win'
if (p === 'linux') return 'linux'
// Fallback for browser dev without electron
if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'mac'
return 'win'
}
/**
* Resolves the host platform and applies a `.platform-*` class on <html>
* so CSS can branch on platform (titlebar layout, scrollbars, etc.).
*/
export function usePlatform(): { platform: Platform; isMac: boolean; isWin: boolean } {
const [platform] = useState<Platform>(detectPlatform)
useEffect(() => {
const root = document.documentElement
root.classList.remove('platform-mac', 'platform-win', 'platform-linux')
root.classList.add(`platform-${platform}`)
}, [platform])
return { platform, isMac: platform === 'mac', isWin: platform === 'win' }
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect, useCallback } from 'react'
export type ThemePref = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
const STORAGE_KEY = 'cow_theme'
function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function readStored(): ThemePref {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
// First run: follow the OS appearance rather than forcing a fixed theme.
return 'system'
}
function applyTheme(resolved: ResolvedTheme) {
const root = document.documentElement
root.classList.toggle('dark', resolved === 'dark')
}
export function useTheme() {
const [pref, setPref] = useState<ThemePref>(readStored)
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme)
)
useEffect(() => {
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
setResolved(next)
applyTheme(next)
localStorage.setItem(STORAGE_KEY, pref)
}, [pref])
// Follow system changes only when preference is "system"
useEffect(() => {
if (pref !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
const next = getSystemTheme()
setResolved(next)
applyTheme(next)
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [pref])
const toggleTheme = useCallback(() => {
setPref(resolved === 'dark' ? 'light' : 'dark')
}, [resolved])
const setTheme = useCallback((next: ThemePref) => setPref(next), [])
return { theme: resolved, pref, toggleTheme, setTheme }
}

View File

@@ -0,0 +1,811 @@
const translations: Record<string, Record<string, string>> = {
zh: {
console: '控制台',
nav_chat: '对话',
nav_manage: '管理',
nav_monitor: '监控',
menu_chat: '对话',
menu_config: '配置',
menu_skills: '技能',
menu_memory: '记忆',
menu_channels: '通道',
menu_tasks: '定时',
menu_logs: '日志',
menu_models: '模型',
menu_knowledge: '知识',
menu_settings: '设置',
// knowledge
knowledge_title: '知识库',
knowledge_desc: '浏览和探索你的知识库',
knowledge_tab_docs: '文档',
knowledge_tab_graph: '图谱',
knowledge_search: '搜索文档...',
knowledge_stats: '{pages} 篇 · {size}',
knowledge_select_hint: '从左侧选择一个文档查看',
knowledge_empty: '知识库还是空的',
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中',
knowledge_go_chat: '开始对话',
knowledge_loading: '加载知识库中...',
knowledge_graph_empty: '暂无关联图谱',
knowledge_disabled: '知识库未启用',
knowledge_doc_load_error: '文档加载失败',
// knowledge management
knowledge_new: '新建',
knowledge_new_category: '新建分类',
knowledge_new_document: '新建文档',
knowledge_import_documents: '导入文档',
knowledge_working: '处理中...',
knowledge_importing: '正在导入...',
knowledge_request_failed: '请求失败,请稍后重试',
knowledge_import_failed: '导入失败',
knowledge_category_created: '分类已创建',
knowledge_document_created: '文档已创建',
knowledge_dialog_confirm: '确定',
knowledge_dialog_cancel: '取消',
knowledge_field_required: '此项不能为空',
knowledge_category_label: '分类路径',
knowledge_category_hint: '支持嵌套路径,例如 research/ai',
knowledge_category_subtitle: '分类会创建为 knowledge/ 下的目录',
knowledge_need_category: '请先创建分类',
knowledge_destination: '目标分类',
knowledge_doc_choose_category: '先选择分类,然后输入文件名',
knowledge_doc_save_to: '保存到 {category}',
knowledge_doc_filename: '文件名',
knowledge_doc_filename_required: '文件名不能为空',
knowledge_doc_must_md: '新建文档仅支持 .md 文件名',
knowledge_doc_content: 'Markdown 内容',
knowledge_doc_content_required: '内容不能为空',
knowledge_doc_content_too_large: '内容不能超过 10MB',
knowledge_doc_insert_template: '插入模板',
knowledge_import_selected: '已选择 {count} 个文件',
knowledge_import_hint: '支持 Markdown 和 TXTTXT 会转成 Markdown 文档',
knowledge_import_need_category: '请先创建一个分类',
knowledge_import_choose_files: '请选择 .md 或 .txt 文件',
knowledge_import_too_many: '一次最多导入 {max} 个文件',
knowledge_import_file_too_large: '{name} 超过 10MB',
knowledge_import_total_too_large: '单次导入总大小不能超过 200MB',
knowledge_import_result: '导入 {imported} 个,跳过 {skipped} 个,失败 {failed} 个',
knowledge_drop_hint: '拖放 .md / .txt 文件到此导入',
nav_expand: '展开侧栏',
nav_collapse: '收起侧栏',
session_history: '历史会话',
update_available: '发现新版本',
update_download: '下载更新',
update_downloading: '正在下载',
update_preparing: '正在准备…',
update_verifying: '正在校验,即将完成…',
update_installing: '正在安装并重启,请稍候…',
update_restart: '重启以更新',
update_later: '稍后',
update_latest: '已是最新版本',
update_check: '检查更新',
update_checking: '正在检查…',
update_failed: '更新失败',
update_retry: '重试',
menu_more: '更多',
menu_theme_light: '浅色模式',
menu_theme_dark: '深色模式',
menu_language: '语言',
menu_website: '官网',
menu_docs: '文档中心',
menu_skill_hub: '技能广场',
menu_feedback: '反馈',
// onboarding
onboarding_welcome_title: '欢迎使用 CowAgent',
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
onboarding_lang_label: '界面语言',
onboarding_model_title: '配置对话模型',
onboarding_model_desc: '选择模型厂商并填入 API Key即可开始使用。',
onboarding_provider: '模型厂商',
onboarding_select_provider: '选择厂商',
onboarding_apikey: 'API Key',
onboarding_apikey_placeholder: '输入 API 密钥',
onboarding_key_guide: '没有秘钥?前往创建',
onboarding_apibase: 'API 地址(可选)',
onboarding_model: '模型',
onboarding_select_model: '选择模型',
onboarding_done_title: '一切就绪',
onboarding_done_desc: '配置完成,开始你的第一次对话吧。',
onboarding_next: '下一步',
onboarding_back: '上一步',
onboarding_skip: '跳过',
onboarding_finish: '开始对话',
onboarding_step: '第 {n} / {total} 步',
onboarding_saving: '保存中...',
onboarding_save_failed: '保存失败,请检查后重试',
sessions_title: '会话',
session_new: '新对话',
session_rename: '重命名',
session_delete: '删除',
session_empty: '暂无会话',
session_today: '今天',
session_yesterday: '昨天',
session_earlier: '更早',
msg_copy: '复制',
msg_copied: '已复制',
msg_regenerate: '重新生成',
msg_edit: '编辑',
msg_delete: '删除',
msg_cancelled: '已中止',
msg_self_learned: '自主学习',
msg_stop: '停止',
thinking_in_progress: '思考中…',
thinking_done: '已深度思考',
chat_clear_context: '清除上下文',
context_cleared: '— 以上内容已从上下文中移除 —',
chat_load_earlier: '加载更早的消息',
chat_send: '发送',
chat_attach: '添加附件',
slash_hint: '输入 / 查看命令',
chat_welcome: '有什么可以帮你的?',
chat_empty_hint: '发送一条消息开始对话',
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理',
example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '定时任务',
example_task_text: '1分钟后提醒我检查服务器',
example_code_title: '编程助手',
example_code_text: '搜索AI资讯生成可视化网页报告',
example_knowledge_title: '知识库',
example_knowledge_text: '查看知识库当前文档情况',
example_skill_title: '技能系统',
example_skill_text: '查看所有支持的工具和技能',
example_web_title: '指令中心',
example_web_text: '查看全部命令',
input_placeholder: '输入消息...',
config_title: '配置管理',
config_desc: '管理模型和 Agent 配置',
config_model: '模型配置',
config_agent: 'Agent 配置',
config_provider: '模型厂商',
config_model_name: '模型',
config_custom_model_hint: '输入自定义模型名称',
config_save: '保存',
config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义',
config_max_tokens: '最大上下文 Token',
config_max_turns: '最大记忆轮次',
config_max_steps: '最大执行步数',
config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理',
config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理',
config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数',
config_thinking: '深度思考',
config_thinking_hint: '是否启用深度思考模式',
config_evolution: '自主进化',
config_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项',
config_security: '安全设置',
config_password: '访问密码',
config_password_hint: '留空则不启用密码保护',
config_password_placeholder: '留空表示不设密码',
config_password_saved: '密码已更新,请重新登录',
config_password_cleared: '密码已清除',
config_language: '语言',
config_language_hint: '界面与回复语言',
config_credentials_link: 'API Key 与接口地址请在「模型配置」中设置',
config_goto_models: '前往配置',
config_provider_unconfigured: '未配置',
config_provider_unconfigured_hint: '该厂商尚未配置 API Key请先前往配置',
config_cancel: '取消',
// settings tabs
settings_tab_basic: '基础配置',
settings_tab_models: '模型配置',
// models tab
models_vendors: '厂商凭据',
models_vendors_sub: '一处配置,多个模型能力共享',
models_configured: '已配置',
models_no_vendor: '尚未配置任何厂商',
models_add_vendor: '添加厂商',
models_custom_vendor: '自定义',
models_add_custom: '添加自定义厂商',
models_add_custom_hint: '接口需遵循 OpenAI API 协议',
models_edit_custom: '编辑自定义厂商',
models_custom_name: '名称',
models_custom_base_hint: '接口需遵循 OpenAI API 协议',
models_clear: '清除凭据',
models_delete: '删除',
models_clear_confirm: '确认清除该厂商的 API Key 与 Base URL 吗?相关能力将不再可用。',
models_delete_confirm: '确定删除该自定义厂商吗?此操作无法撤销。',
models_provider: '厂商',
models_model: '模型',
models_voice: '音色',
models_select_provider: '待选择',
models_select_model: '请选择模型',
models_select_voice: '请选择音色',
models_no_options: '暂无可选项',
models_auto: '自动',
models_asr_auto: '自动(跟随主模型)',
models_disabled: '不启用',
models_fallback: '兜底',
models_cap_chat: '主模型',
models_cap_chat_sub: '用于基础对话和 Agent 推理',
models_cap_vision: '图像理解',
models_cap_vision_sub: '识别图片内容,用于图像识别工具',
models_cap_image: '图像生成',
models_cap_image_sub: '生成图片,用于图像生成技能',
models_cap_asr: '语音识别',
models_cap_asr_sub: '语音转文字',
models_cap_tts: '语音合成',
models_cap_tts_sub: '文字转语音',
models_cap_embedding: '向量',
models_cap_embedding_sub: '用于记忆与知识的向量化检索',
models_cap_search: '联网搜索',
models_cap_search_sub: '实时网页检索能力,用于搜索工具',
models_tts_reply_mode: '语音回复模式',
models_tts_reply_mode_hint: '决定何时以语音回复用户',
models_tts_mode_off: '关闭',
models_tts_mode_if_voice: '仅当用户发语音时',
models_tts_mode_always: '始终语音回复',
models_embedding_dim: '维度',
models_embedding_rebuild_hint: '切换向量模型后,已有索引将失效,需要重建',
models_search_strategy: '策略',
models_search_auto: '自动',
models_search_fixed: '指定',
models_search_provider: '搜索厂商',
models_search_bocha_key: '配置博查 API Key',
models_search_bocha_hint: '前往博查开放平台创建 API Key',
skills_title: '技能管理',
skills_desc: '查看、启用或禁用 Agent 工具和技能',
skills_hub_btn: '探索技能广场',
tools_section_title: '内置工具',
skills_section_title: '技能',
tools_loading: '加载工具中...',
skills_loading: '加载技能中...',
skills_loading_desc: '技能将在加载后显示',
skill_enabled: '已启用',
skill_disabled: '已禁用',
tools_empty: '暂无内置工具',
skills_empty: '暂无技能',
memory_title: '记忆管理',
memory_desc: '查看 Agent 记忆文件和内容',
memory_tab_files: '记忆文件',
memory_tab_dreams: '自主进化',
memory_loading: '加载记忆文件中...',
memory_loading_desc: '记忆文件将在加载后显示',
memory_col_name: '文件名',
memory_col_type: '类型',
memory_col_size: '大小',
memory_col_updated: '更新时间',
memory_back: '返回列表',
memory_empty_files: '暂无记忆文件',
memory_empty_evolution: '暂无进化记录',
memory_type_global: '全局',
memory_type_daily: '每日',
memory_type_evolution: '进化',
memory_type_dream: '梦境',
memory_doc_load_error: '内容加载失败',
memory_prev: '上一页',
memory_next: '下一页',
channels_title: '通道管理',
channels_desc: '查看和管理消息通道',
channels_add: '接入通道',
channels_select_label: '选择要接入的通道',
channels_select_placeholder: '请选择通道...',
channels_add_close: '关闭',
channels_connected: '已连接',
channels_disconnected: '未连接',
channels_connect: '连接',
channels_disconnect: '断开',
channels_save: '保存',
channels_loading: '加载通道中...',
channels_connected_section: '已连接',
channels_available_section: '可添加',
channels_empty_connected: '暂无已连接的通道',
channels_empty: '暂未接入任何通道',
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
channels_save_ok: '已保存',
channels_save_error: '保存失败',
channels_connect_error: '连接失败',
channels_scan_login: '扫码登录',
channels_scan_register: '扫码注册',
weixin_scan_title: '微信扫码登录',
weixin_scan_desc: '使用微信扫描下方二维码登录',
weixin_scan_loading: '正在获取二维码...',
weixin_scan_waiting: '等待扫码',
weixin_scan_scanned: '已扫描,请在手机上确认',
weixin_scan_success: '登录成功',
weixin_scan_expired: '二维码已过期,正在刷新...',
weixin_scan_fail: '获取二维码失败',
weixin_qr_tip: '请使用登录的微信扫码',
feishu_scan_title: '飞书扫码注册',
feishu_scan_desc: '扫码后将自动创建并连接飞书机器人',
feishu_scan_loading: '正在生成二维码...',
feishu_scan_waiting: '请使用飞书扫码授权',
feishu_scan_tip: '扫码后在飞书中确认授权',
feishu_scan_open_link: '打开授权链接',
feishu_scan_success: '注册成功',
feishu_scan_expired: '二维码已过期',
feishu_scan_denied: '授权被拒绝',
feishu_scan_fail: '注册失败',
feishu_scan_retry: '重试',
tasks_title: '定时任务',
tasks_desc: '查看和管理定时任务',
tasks_active: '运行中',
tasks_paused: '已暂停',
tasks_empty: '暂无定时任务',
tasks_empty_guide: '在对话中告诉 Agent「每天 9 点提醒我…」即可创建定时任务',
tasks_go_chat: '去对话创建',
tasks_next_run: '下次执行',
tasks_loading: '加载定时任务中...',
task_edit_title: '编辑任务',
task_name: '名称',
task_enabled: '启用',
task_schedule_type: '调度类型',
task_type_cron: 'Cron 表达式',
task_type_interval: '固定间隔',
task_type_once: '单次执行',
task_cron_expr: 'Cron 表达式',
task_cron_hint: '如 0 9 * * * 表示每天 9 点',
task_interval_seconds: '间隔(秒)',
task_once_time: '执行时间',
task_action_type: '动作类型',
task_action_send: '发送消息',
task_action_agent: 'Agent 任务',
task_message_content: '消息内容',
task_task_description: '任务描述',
task_channel: '通道',
task_receiver: '接收者',
task_channel_locked: '通道与接收者创建后不可修改',
task_save: '保存',
task_cancel: '取消',
task_delete: '删除',
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
task_save_error: '保存失败',
logs_title: '日志',
logs_desc: '实时日志输出 (run.log)',
logs_live: '实时',
logs_connecting: '日志流将在连接后显示...',
status_starting: '正在启动 CowAgent...',
status_starting_desc: '正在初始化客户端,请稍候',
status_error: '初始化失败',
status_error_desc: '客户端初始化失败,请重试',
status_retry: '重试',
// login (web_password)
login_title: '请输入访问密码',
login_desc: '此客户端已设置访问密码,请输入以继续',
login_placeholder: '访问密码',
login_submit: '进入',
login_error: '密码错误,请重试',
login_checking: '验证中...',
// slash command descriptions
slash_menu_title: '命令',
slash_new: '新建对话',
slash_clear: '清除对话上下文',
slash_help: '显示命令帮助',
slash_status: '查看运行状态',
slash_context: '查看对话上下文',
slash_skill_list: '查看已安装技能',
slash_skill_search: '搜索技能',
slash_skill_install: '安装技能 (名称或 GitHub URL)',
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
slash_knowledge: '查看知识库统计',
slash_knowledge_list: '查看知识库文件树',
slash_install_browser: '安装浏览器工具',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
},
en: {
console: 'Console',
nav_chat: 'Chat',
nav_manage: 'Management',
nav_monitor: 'Monitor',
menu_chat: 'Chat',
menu_config: 'Config',
menu_skills: 'Skills',
menu_memory: 'Memory',
menu_channels: 'Channels',
menu_tasks: 'Tasks',
menu_logs: 'Logs',
menu_models: 'Models',
menu_knowledge: 'Knowledge',
// knowledge
knowledge_title: 'Knowledge Base',
knowledge_desc: 'Browse and explore your knowledge base',
knowledge_tab_docs: 'Documents',
knowledge_tab_graph: 'Graph',
knowledge_search: 'Search documents...',
knowledge_stats: '{pages} pages · {size}',
knowledge_select_hint: 'Select a document to view',
knowledge_empty: 'Your knowledge base is empty',
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat — it will organize them into your knowledge base',
knowledge_go_chat: 'Start chatting',
knowledge_loading: 'Loading knowledge base...',
knowledge_graph_empty: 'No graph available',
knowledge_disabled: 'Knowledge base is disabled',
knowledge_doc_load_error: 'Failed to load document',
// knowledge management
knowledge_new: 'New',
knowledge_new_category: 'New category',
knowledge_new_document: 'New document',
knowledge_import_documents: 'Import documents',
knowledge_working: 'Working...',
knowledge_importing: 'Importing...',
knowledge_request_failed: 'Request failed, please try again',
knowledge_import_failed: 'Import failed',
knowledge_category_created: 'Category created',
knowledge_document_created: 'Document created',
knowledge_dialog_confirm: 'Confirm',
knowledge_dialog_cancel: 'Cancel',
knowledge_field_required: 'This field is required',
knowledge_category_label: 'Category path',
knowledge_category_hint: 'Nested paths are supported, e.g. research/ai',
knowledge_category_subtitle: 'Creates a directory under knowledge/',
knowledge_need_category: 'Create a category first',
knowledge_destination: 'Destination category',
knowledge_doc_choose_category: 'Choose a category, then enter a filename',
knowledge_doc_save_to: 'Save to {category}',
knowledge_doc_filename: 'Filename',
knowledge_doc_filename_required: 'Filename is required',
knowledge_doc_must_md: 'New documents must be .md files',
knowledge_doc_content: 'Markdown content',
knowledge_doc_content_required: 'Content is required',
knowledge_doc_content_too_large: 'Content cannot exceed 10MB',
knowledge_doc_insert_template: 'Insert template',
knowledge_import_selected: '{count} file(s) selected',
knowledge_import_hint: 'Markdown and TXT are supported. TXT is converted to Markdown.',
knowledge_import_need_category: 'Create a category first',
knowledge_import_choose_files: 'Choose .md or .txt files',
knowledge_import_too_many: 'Import at most {max} files at a time',
knowledge_import_file_too_large: '{name} exceeds 10MB',
knowledge_import_total_too_large: 'Total import size cannot exceed 200MB',
knowledge_import_result: '{imported} imported · {skipped} skipped · {failed} failed',
knowledge_drop_hint: 'Drop .md / .txt files here to import',
menu_settings: 'Settings',
nav_expand: 'Expand sidebar',
nav_collapse: 'Collapse sidebar',
session_history: 'Chat history',
update_available: 'New version available',
update_download: 'Download update',
update_downloading: 'Downloading',
update_preparing: 'Preparing…',
update_verifying: 'Verifying, almost done…',
update_installing: 'Installing and restarting, please wait…',
update_restart: 'Restart to update',
update_later: 'Later',
update_latest: 'You are up to date',
update_check: 'Check for updates',
update_checking: 'Checking…',
update_failed: 'Update failed',
update_retry: 'Retry',
menu_more: 'More',
menu_theme_light: 'Light mode',
menu_theme_dark: 'Dark mode',
menu_language: 'Language',
menu_website: 'Website',
menu_docs: 'Documentation',
menu_skill_hub: 'Skill Hub',
menu_feedback: 'Feedback',
// onboarding
onboarding_welcome_title: 'Welcome to CowAgent',
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
onboarding_lang_label: 'Language',
onboarding_model_title: 'Set up your chat model',
onboarding_model_desc: 'Pick a provider and paste its API key to get started.',
onboarding_provider: 'Provider',
onboarding_select_provider: 'Select a provider',
onboarding_apikey: 'API Key',
onboarding_apikey_placeholder: 'Enter your API key',
onboarding_key_guide: 'No key yet? Create one',
onboarding_apibase: 'API base (optional)',
onboarding_model: 'Model',
onboarding_select_model: 'Select a model',
onboarding_done_title: 'All set',
onboarding_done_desc: 'Setup complete. Start your first conversation.',
onboarding_next: 'Next',
onboarding_back: 'Back',
onboarding_skip: 'Skip',
onboarding_finish: 'Start chatting',
onboarding_step: 'Step {n} of {total}',
onboarding_saving: 'Saving...',
onboarding_save_failed: 'Save failed, please check and retry',
sessions_title: 'Chats',
session_new: 'New chat',
session_rename: 'Rename',
session_delete: 'Delete',
session_empty: 'No conversations yet',
session_today: 'Today',
session_yesterday: 'Yesterday',
session_earlier: 'Earlier',
msg_copy: 'Copy',
msg_copied: 'Copied',
msg_regenerate: 'Regenerate',
msg_edit: 'Edit',
msg_delete: 'Delete',
msg_cancelled: 'Cancelled',
msg_self_learned: 'Self-learned',
msg_stop: 'Stop',
thinking_in_progress: 'Thinking…',
thinking_done: 'Thought',
chat_clear_context: 'Clear context',
context_cleared: '— Context above has been cleared —',
chat_load_earlier: 'Load earlier messages',
chat_send: 'Send',
chat_attach: 'Attach file',
slash_hint: 'Type / for commands',
chat_welcome: 'How can I help you?',
chat_empty_hint: 'Send a message to start the conversation',
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
example_sys_title: 'System',
example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Scheduled Task',
example_task_text: 'Remind me to check the server in 1 minute',
example_code_title: 'Coding',
example_code_text: 'Search AI news and build a visual web report',
example_knowledge_title: 'Knowledge Base',
example_knowledge_text: 'Show the current documents in the knowledge base',
example_skill_title: 'Skills',
example_skill_text: 'Show all supported tools and skills',
example_web_title: 'Commands',
example_web_text: 'Show all commands',
input_placeholder: 'Type a message...',
config_title: 'Configuration',
config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration',
config_agent: 'Agent Configuration',
config_provider: 'Provider',
config_model_name: 'Model',
config_custom_model_hint: 'Enter custom model name',
config_save: 'Save',
config_saved: 'Saved',
config_save_error: 'Save failed',
config_custom_option: 'Custom',
config_max_tokens_hint: 'Max token length the Agent can take in; longer context is compressed automatically',
config_max_turns_hint: 'One question and answer is a turn; older turns are compressed automatically',
config_max_steps_hint: 'Max tool calls the Agent can make in one turn',
config_thinking: 'Deep Thinking',
config_thinking_hint: 'Whether to enable deep thinking mode',
config_evolution: 'Self-Evolution',
config_evolution_hint: 'Review automatically when idle: consolidate memory, refine skills, finish pending tasks',
config_security: 'Security',
config_password: 'Access Password',
config_password_hint: 'Leave empty to disable password protection',
config_password_placeholder: 'Leave empty for no password',
config_password_saved: 'Password updated, please log in again',
config_password_cleared: 'Password cleared',
config_language: 'Language',
config_language_hint: 'Interface and reply language',
config_credentials_link: 'Set API key and endpoint in "Models"',
config_goto_models: 'Configure',
config_provider_unconfigured: 'Not configured',
config_provider_unconfigured_hint: 'This provider has no API key yet — configure it first',
config_cancel: 'Cancel',
// settings tabs
settings_tab_basic: 'Basic',
settings_tab_models: 'Models',
// models tab
models_vendors: 'Provider Credentials',
models_vendors_sub: 'Configured once, shared by multiple model capabilities',
models_configured: 'configured',
models_no_vendor: 'No provider configured yet',
models_add_vendor: 'Add Provider',
models_custom_vendor: 'Custom',
models_add_custom: 'Add custom provider',
models_add_custom_hint: 'Endpoint must follow the OpenAI API protocol',
models_edit_custom: 'Edit custom provider',
models_custom_name: 'Name',
models_custom_base_hint: 'Endpoint must follow the OpenAI API protocol',
models_clear: 'Clear credentials',
models_delete: 'Delete',
models_clear_confirm: 'Clear the API key and base URL for this provider? Related capabilities will stop working.',
models_delete_confirm: 'Delete this custom provider? This cannot be undone.',
models_provider: 'Provider',
models_model: 'Model',
models_voice: 'Voice',
models_select_provider: 'Select',
models_select_model: 'Select a model',
models_select_voice: 'Select a voice',
models_no_options: 'No options',
models_auto: 'Auto',
models_asr_auto: 'Auto (follow main model)',
models_disabled: 'Disabled',
models_fallback: 'Fallback',
models_cap_chat: 'Main Model',
models_cap_chat_sub: 'Used for basic chat and agent reasoning',
models_cap_vision: 'Image Understanding',
models_cap_vision_sub: 'Recognizes image content, used by image recognition tools',
models_cap_image: 'Image Generation',
models_cap_image_sub: 'Generates images, used by image generation skills',
models_cap_asr: 'Speech Recognition',
models_cap_asr_sub: 'Voice to text',
models_cap_tts: 'Speech Synthesis',
models_cap_tts_sub: 'Text to voice',
models_cap_embedding: 'Embedding',
models_cap_embedding_sub: 'Used for vectorized retrieval of memory and knowledge',
models_cap_search: 'Web Search',
models_cap_search_sub: 'Real-time web retrieval, used by search tools',
models_tts_reply_mode: 'Voice Reply Mode',
models_tts_reply_mode_hint: 'When to reply with voice',
models_tts_mode_off: 'Off',
models_tts_mode_if_voice: 'Only when user sends voice',
models_tts_mode_always: 'Always reply with voice',
models_embedding_dim: 'Dimension',
models_embedding_rebuild_hint: 'Existing index becomes invalid and must be rebuilt after changing the model',
models_search_strategy: 'Strategy',
models_search_auto: 'Auto',
models_search_fixed: 'Pinned',
models_search_provider: 'Search provider',
models_search_bocha_key: 'Configure Bocha API Key',
models_search_bocha_hint: 'Create a key at the Bocha open platform',
config_max_tokens: 'Max Context Tokens',
config_max_turns: 'Max Memory Turns',
config_max_steps: 'Max Steps',
skills_title: 'Skills',
skills_desc: 'View, enable, or disable agent tools and skills',
skills_hub_btn: 'Skill Hub',
tools_section_title: 'Built-in Tools',
skills_section_title: 'Skills',
tools_loading: 'Loading tools...',
skills_loading: 'Loading skills...',
skills_loading_desc: 'Skills will be displayed here after loading',
skill_enabled: 'Enabled',
skill_disabled: 'Disabled',
tools_empty: 'No built-in tools',
skills_empty: 'No skills found',
memory_title: 'Memory',
memory_desc: 'View agent memory files and contents',
memory_tab_files: 'Memory Files',
memory_tab_dreams: 'Self-Evolution',
memory_loading: 'Loading memory files...',
memory_loading_desc: 'Memory files will be displayed here',
memory_col_name: 'Filename',
memory_col_type: 'Type',
memory_col_size: 'Size',
memory_col_updated: 'Updated',
memory_back: 'Back to list',
memory_empty_files: 'No memory files',
memory_empty_evolution: 'No evolution records yet',
memory_type_global: 'Global',
memory_type_daily: 'Daily',
memory_type_evolution: 'Evolution',
memory_type_dream: 'Dream',
memory_doc_load_error: 'Failed to load content',
memory_prev: 'Prev',
memory_next: 'Next',
channels_title: 'Channels',
channels_desc: 'View and manage messaging channels',
channels_add: 'Add channel',
channels_select_label: 'Select a channel to add',
channels_select_placeholder: 'Select a channel...',
channels_add_close: 'Close',
channels_connected: 'Connected',
channels_disconnected: 'Disconnected',
channels_connect: 'Connect',
channels_disconnect: 'Disconnect',
channels_save: 'Save',
channels_loading: 'Loading channels...',
channels_connected_section: 'Connected',
channels_available_section: 'Available',
channels_empty_connected: 'No connected channels yet',
channels_empty: 'No channels connected',
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
channels_save_ok: 'Saved',
channels_save_error: 'Failed to save',
channels_connect_error: 'Failed to connect',
channels_scan_login: 'QR login',
channels_scan_register: 'QR register',
weixin_scan_title: 'WeChat QR Login',
weixin_scan_desc: 'Scan the QR code below with WeChat to log in',
weixin_scan_loading: 'Fetching QR code...',
weixin_scan_waiting: 'Waiting for scan',
weixin_scan_scanned: 'Scanned, please confirm on your phone',
weixin_scan_success: 'Logged in',
weixin_scan_expired: 'QR code expired, refreshing...',
weixin_scan_fail: 'Failed to fetch QR code',
weixin_qr_tip: 'Scan with the WeChat account to log in',
feishu_scan_title: 'Feishu QR Register',
feishu_scan_desc: 'Scanning will create and connect a Feishu bot automatically',
feishu_scan_loading: 'Generating QR code...',
feishu_scan_waiting: 'Scan with Feishu to authorize',
feishu_scan_tip: 'Confirm the authorization in Feishu after scanning',
feishu_scan_open_link: 'Open authorization link',
feishu_scan_success: 'Registered',
feishu_scan_expired: 'QR code expired',
feishu_scan_denied: 'Authorization denied',
feishu_scan_fail: 'Registration failed',
feishu_scan_retry: 'Retry',
tasks_title: 'Scheduled Tasks',
tasks_desc: 'View and manage scheduled tasks',
tasks_active: 'Active',
tasks_paused: 'Paused',
tasks_empty: 'No scheduled tasks',
tasks_empty_guide: 'Tell the agent "remind me every day at 9am…" in chat to create a scheduled task',
tasks_go_chat: 'Create in chat',
tasks_next_run: 'Next run',
tasks_loading: 'Loading scheduled tasks...',
task_edit_title: 'Edit Task',
task_name: 'Name',
task_enabled: 'Enabled',
task_schedule_type: 'Schedule type',
task_type_cron: 'Cron',
task_type_interval: 'Interval',
task_type_once: 'Once',
task_cron_expr: 'Cron expression',
task_cron_hint: 'e.g. 0 9 * * * runs daily at 9am',
task_interval_seconds: 'Interval (seconds)',
task_once_time: 'Run at',
task_action_type: 'Action type',
task_action_send: 'Send message',
task_action_agent: 'Agent task',
task_message_content: 'Message content',
task_task_description: 'Task description',
task_channel: 'Channel',
task_receiver: 'Receiver',
task_channel_locked: 'Channel and receiver cannot be changed after creation',
task_save: 'Save',
task_cancel: 'Cancel',
task_delete: 'Delete',
task_delete_confirm: 'Delete this task? This cannot be undone.',
task_save_error: 'Failed to save',
logs_title: 'Logs',
logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live',
logs_connecting: 'Log streaming will connect shortly...',
status_starting: 'Starting CowAgent...',
status_starting_desc: 'Initializing the client, please wait',
status_error: 'Initialization Failed',
status_error_desc: 'Failed to initialize the client, please retry',
status_retry: 'Retry',
// login (web_password)
login_title: 'Enter access password',
login_desc: 'This client is password-protected. Enter the password to continue.',
login_placeholder: 'Access password',
login_submit: 'Enter',
login_error: 'Wrong password, please try again',
login_checking: 'Verifying...',
// slash command descriptions
slash_menu_title: 'Commands',
slash_new: 'New chat',
slash_clear: 'Clear conversation context',
slash_help: 'Show command help',
slash_status: 'Show running status',
slash_context: 'Show conversation context',
slash_skill_list: 'List installed skills',
slash_skill_search: 'Search skills',
slash_skill_install: 'Install a skill (name or GitHub URL)',
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
slash_knowledge: 'Show knowledge base stats',
slash_knowledge_list: 'Show knowledge base file tree',
slash_install_browser: 'Install browser tool',
slash_config: 'Show current config',
slash_cancel: 'Abort the running agent task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
},
}
export type Lang = 'zh' | 'en'
// First-run default: follow the OS language so zh-* systems start in Chinese
// and everyone else in English. Once the user picks a language it's persisted
// in cow_lang and always wins. Falls back to 'en' when the locale is unknown.
function detectDefaultLang(): Lang {
const locale = (window.electronAPI?.systemLocale || navigator.language || '').toLowerCase()
return locale.startsWith('zh') ? 'zh' : 'en'
}
const savedLang = localStorage.getItem('cow_lang') as Lang | null
let currentLang: Lang = savedLang === 'zh' || savedLang === 'en' ? savedLang : detectDefaultLang()
export function t(key: string): string {
return translations[currentLang]?.[key] || translations['en']?.[key] || key
}
export function getLang(): Lang {
return currentLang
}
export function setLang(lang: Lang) {
currentLang = lang
localStorage.setItem('cow_lang', lang)
}
/** Resolve a possibly-localized label ({zh,en} or plain string) for the current language. */
export function localizedLabel(label: string | { zh: string; en: string } | undefined): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[currentLang] || label.en || label.zh || ''
}

View File

@@ -0,0 +1,335 @@
@import './highlight.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================================
Design tokens — semantic CSS variables driving both themes.
Components reference these via Tailwind semantic classes
(bg-surface, text-primary, border-default, etc.), so theme
switching only swaps variable values, never component code.
============================================================ */
:root {
/* Brand accent (CowAgent green), used sparingly */
--accent: #4abe6e;
--accent-hover: #35a85b;
--accent-active: #228547;
--accent-soft: rgba(74, 190, 110, 0.12);
--accent-contrast: #ffffff;
/* Status colors */
--success: #4abe6e;
--warning: #f59e0b;
--danger: #ef4444;
--danger-soft: rgba(239, 68, 68, 0.1);
--danger-border: rgba(239, 68, 68, 0.3);
--info: #3b82f6;
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
--bg-base: #f9fafb; /* app background (gray-50) */
--bg-surface: #ffffff; /* panels, cards */
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
--bg-elevated: #ffffff; /* popovers, menus, modals */
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
--text-primary: #1e293b; /* headings, primary text (slate-800) */
--text-secondary: #475569; /* body, labels (slate-600) */
--text-tertiary: #64748b; /* hints, captions (slate-500) */
--text-disabled: #94a3b8; /* slate-400 */
--border-default: #e5e7eb; /* gray-200 (web console border) */
--border-strong: #d1d5db; /* gray-300 */
--border-subtle: #f3f4f6; /* gray-100 */
--overlay: rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
/* Chat-specific tokens (AI-Native UI) */
--ai-bubble-bg: transparent;
--message-gap: 16px;
color-scheme: light;
}
.dark {
/* Dark theme — layered greys instead of harsh pure black */
--bg-base: #0e0e10;
--bg-surface: #161618;
--bg-surface-2: #1c1c1f;
--bg-elevated: #1f1f23;
--bg-inset: #161618;
--text-primary: #f4f4f5; /* contrast > 7:1 on bg-base */
--text-secondary: #c4c4cc;
--text-tertiary: #8e8e96;
--text-disabled: #5a5a62;
--border-default: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--border-subtle: rgba(255, 255, 255, 0.04);
--accent-contrast: #0e0e10;
--overlay: rgba(0, 0, 0, 0.6);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
color-scheme: dark;
}
/* ============================================================
Base
============================================================ */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.6;
overflow: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Smooth theme transition (respect reduced motion) */
body,
body * {
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 200ms;
transition-timing-function: ease;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
/* ============================================================
Scrollbar — thin, low-key, theme-aware
============================================================ */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
background-clip: padding-box;
}
/* Windows scrollbars are slightly wider/more visible */
.platform-win ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/* ============================================================
Titlebar drag regions (frameless window)
============================================================ */
.titlebar-drag {
-webkit-app-region: drag;
}
.titlebar-no-drag {
-webkit-app-region: no-drag;
}
/* ============================================================
Animations
============================================================ */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.animate-blink {
animation: blink 1s step-end infinite;
}
/* AI typing indicator — 3-dot pulse */
@keyframes typingPulse {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.typing-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--text-tertiary);
animation: typingPulse 1.4s infinite ease-in-out both;
}
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
.typing-dot:nth-child(3) { animation-delay: 0.32s; }
/* Smooth content reveal */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-reveal {
animation: fadeInUp 0.25s ease both;
}
/* Skeleton shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--bg-surface-2) 25%,
var(--border-subtle) 50%,
var(--bg-surface-2) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
}
/* ============================================================
Markdown content (assistant messages, memory/knowledge viewers)
============================================================ */
.msg-content > *:first-child { margin-top: 0; }
.msg-content > *:last-child { margin-bottom: 0; }
.msg-content p { margin: 0.5em 0; line-height: 1.7; }
.msg-content ul, .msg-content ol { padding-left: 1.4em; margin: 0.5em 0; }
.msg-content li { margin: 0.25em 0; }
.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 {
font-weight: 600;
margin: 0.8em 0 0.4em;
color: var(--text-primary);
}
.msg-content h1 { font-size: 1.4em; }
.msg-content h2 { font-size: 1.25em; }
.msg-content h3 { font-size: 1.1em; }
.msg-content a { color: var(--accent); text-decoration: none; }
.msg-content a:hover { text-decoration: underline; }
.msg-content blockquote {
padding-left: 0.9em;
margin: 0.6em 0;
border-left: 3px solid var(--accent);
color: var(--text-secondary);
}
.msg-content table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
font-size: 0.9em;
}
.msg-content th, .msg-content td {
border: 1px solid var(--border-default);
padding: 0.4em 0.7em;
text-align: left;
}
.msg-content th { background: var(--bg-surface-2); font-weight: 600; }
.msg-content hr { border: none; border-top: 1px solid var(--border-default); margin: 1em 0; }
/* Inline code */
.msg-content :not(pre) > code {
padding: 0.12em 0.4em;
border-radius: 5px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 0.85em;
}
/* Fenced code blocks */
.msg-content .code-block-wrapper {
margin: 0.7em 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border-default);
background: var(--bg-inset);
}
.msg-content .code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px 5px 12px;
background: var(--bg-surface-2);
border-bottom: 1px solid var(--border-subtle);
}
.msg-content .code-block-lang {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.msg-content .code-copy-btn {
font-size: 11px;
color: var(--text-tertiary);
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 5px;
transition: color 0.15s, background 0.15s;
}
.msg-content .code-copy-btn:hover { color: var(--text-secondary); background: var(--bg-inset); }
.msg-content .code-copy-btn.copied { color: var(--accent); }
.msg-content .code-block-wrapper pre {
margin: 0;
padding: 12px 14px;
overflow-x: auto;
background: transparent;
}
.msg-content .code-block-wrapper pre code,
.msg-content pre code.hljs {
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
background: transparent;
padding: 0;
}

View File

@@ -0,0 +1,386 @@
import React, { useState, useRef, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
MessageSquare,
BookOpen,
Brain,
Zap,
Radio,
Clock,
Settings,
PanelLeftClose,
PanelLeftOpen,
Sun,
Moon,
ScrollText,
MoreHorizontal,
Languages,
Download,
Loader2,
Globe,
FileText,
Store,
MessageSquareWarning,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
import brandLogo from '../assets/logo.png'
import { t, getLang, setLang, Lang } from '../i18n'
import { useUIStore } from '../store/uiStore'
import { useTheme } from '../hooks/useTheme'
import { usePlatform } from '../hooks/usePlatform'
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
import UpdateBanner from '../components/UpdateBanner'
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
// in sync with desktop/package.json "version"; the packaged app overrides this
// with the real value via IPC, so it only matters outside a packaged build.
const FALLBACK_VERSION = '2.1.3'
// External links opened in the user's default browser. The window-open handler
// in the main process routes window.open() through shell.openExternal.
// English is the default (no suffix); Chinese gets a /zh suffix. Skill hub is
// language-agnostic.
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
// GitHub issues — where users report bugs / request features.
const FEEDBACK_URL = 'https://github.com/zhayujie/CowAgent/issues'
const websiteUrl = () => (getLang() === 'zh' ? 'https://cowagent.ai/zh' : 'https://cowagent.ai')
const docsUrl = () => (getLang() === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai')
const openExternal = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
interface NavItem {
path: string
labelKey: string
icon: LucideIcon
}
const NAV_ITEMS: NavItem[] = [
{ path: '/', labelKey: 'menu_chat', icon: MessageSquare },
{ path: '/knowledge', labelKey: 'menu_knowledge', icon: BookOpen },
{ path: '/memory', labelKey: 'menu_memory', icon: Brain },
{ path: '/skills', labelKey: 'menu_skills', icon: Zap },
{ path: '/channels', labelKey: 'menu_channels', icon: Radio },
{ path: '/tasks', labelKey: 'menu_tasks', icon: Clock },
{ path: '/settings', labelKey: 'menu_settings', icon: Settings },
]
interface NavRailProps {
onLangChange: () => void
}
const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
const location = useLocation()
const navigate = useNavigate()
const { navCollapsed, toggleNav } = useUIStore()
const { theme, toggleTheme } = useTheme()
// On macOS the top-left is occupied by the native traffic lights, so the
// brand mark is only shown on Windows/Linux where that corner is otherwise
// empty (mirrors the web console's sidebar logo).
const { isMac } = usePlatform()
const collapsed = navCollapsed
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
const updateState = useUpdateStore()
// Footer dot: hidden once dismissed for this version (user asked for this).
const pendingUpdate = hasPendingUpdate(updateState)
// Menu "check for update" dot: stays as long as an update actually exists,
// even after dismissing the footer badge.
const availableUpdate = hasAvailableUpdate(updateState)
const checking = updateState.status?.state === 'checking'
const [menuOpen, setMenuOpen] = useState(false)
// Local fallback so a version always shows even if the main-process IPC is
// unavailable (e.g. dev/web preview). The real value comes from
// app.getVersion() (packaged package.json), never from a remote service.
const [version, setVersion] = useState(FALLBACK_VERSION)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
window.electronAPI
?.getAppVersion?.()
.then((v) => v && setVersion(v))
.catch(() => {})
}, [])
// Close the popover on any outside click / Escape.
useEffect(() => {
if (!menuOpen) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [menuOpen])
const toggleLanguage = () => {
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
setLang(next)
onLangChange()
}
// Track a user-initiated check so we can show "up to date" feedback in the
// menu when the result comes back as not-available (the auto poll stays
// silent). Cleared shortly after, and whenever the menu closes.
const [checkedManually, setCheckedManually] = useState(false)
const updateStatusState = updateState.status?.state
useEffect(() => {
if (!checkedManually) return
if (updateStatusState === 'not-available') {
const id = setTimeout(() => setCheckedManually(false), 4000)
return () => clearTimeout(id)
}
// A pending update opens its own panel; no need for the inline hint.
if (updateStatusState === 'available' || updateStatusState === 'downloaded') {
setCheckedManually(false)
}
return
}, [checkedManually, updateStatusState])
useEffect(() => {
if (!menuOpen) setCheckedManually(false)
}, [menuOpen])
const checkUpdate = () => {
setCheckedManually(true)
// If an update is already known, recheck() re-opens its panel, so close the
// menu to reveal it. Otherwise keep the menu OPEN: the "up to date" result
// shows inline as the menu label — closing it (which resets checkedManually)
// is exactly what made the box flash and never show "up to date".
if (availableUpdate) setMenuOpen(false)
updateState.recheck()
}
return (
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
{/* Top: full-width drag strip; bottom border continues the header divider
across the whole window. No right border so it doesn't cut the lights.
On Windows/Linux the top-left corner is empty (no traffic lights), so
we surface the brand mark here like the web console's sidebar. */}
<div
className={`titlebar-drag h-[44px] flex-shrink-0 border-b border-default flex items-center ${
collapsed ? 'justify-center px-0' : 'px-3'
}`}
>
{!isMac && (
<div className="flex items-center gap-2 min-w-0 select-none">
<BrandLogo />
{!collapsed && (
<span className="text-[14px] font-semibold text-content truncate">CowAgent</span>
)}
</div>
)}
</div>
{/* Content area carries the right divider, starting below the titlebar */}
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.path
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
title={collapsed ? t(item.labelKey) : undefined}
className={`group w-full flex items-center gap-3 rounded-btn cursor-pointer transition-colors h-9 ${
collapsed ? 'justify-center px-0' : 'px-3'
} ${
isActive
? 'bg-accent-soft text-accent'
: 'text-content-secondary hover:bg-surface-2 hover:text-content'
}`}
>
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.8} className="flex-shrink-0" />
{!collapsed && <span className="text-[13px] truncate">{t(item.labelKey)}</span>}
</button>
)
})}
</nav>
{/* Update banner floats above the footer when a new version is pending */}
<div className="relative">
{!collapsed && <UpdateBanner />}
</div>
{/* Footer actions: a single "more" entry (with version + update dot) that
opens an upward popover, plus the always-visible collapse toggle. */}
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
{menuOpen && (
<FooterMenu
theme={theme}
checking={checking}
pendingUpdate={availableUpdate}
upToDate={checkedManually && updateStatusState === 'not-available' && !availableUpdate}
onLogs={() => {
setMenuOpen(false)
navigate('/logs')
}}
onTheme={toggleTheme}
onLanguage={toggleLanguage}
onCheckUpdate={checkUpdate}
onOpenLink={(url) => {
setMenuOpen(false)
openExternal(url)
}}
/>
)}
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
{/* Single clickable entry: version label (left) + the three dots
(right) form one button; the whole block opens the popover. The
version is the packaged app version, also what auto-update
compares against. Collapsed: dots only, version hidden. */}
<button
onClick={() => setMenuOpen((o) => !o)}
title={t('menu_more')}
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
>
{!collapsed && version && (
<span className="text-[12px] truncate">{`v${version}`}</span>
)}
<MoreHorizontal size={17} className="flex-shrink-0" />
{pendingUpdate && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
)}
</button>
{!collapsed && <div className="flex-1" />}
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</FooterBtn>
</div>
</div>
</div>
</aside>
)
}
// Brand mark for the top-left corner (Windows/Linux). Uses the desktop app's
// own icon (transparent PNG with its own rounded shape), so it sits cleanly on
// both light and dark backgrounds without extra styling.
const BrandLogo: React.FC = () => (
<img
src={brandLogo}
alt="CowAgent"
draggable={false}
className="flex-shrink-0 w-7 h-7 object-contain"
/>
)
const FooterBtn: React.FC<{
collapsed: boolean
onClick: () => void
title: string
active?: boolean
children: React.ReactNode
}> = ({ collapsed, onClick, title, active, children }) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center gap-1.5 rounded-btn cursor-pointer transition-colors ${
active
? 'bg-accent-soft text-accent'
: 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2'}`}
>
{children}
</button>
)
// Upward popover holding the secondary actions previously crammed into the
// footer (theme, language, logs, update check). Keeps the footer to a single
// entry so new items can be added here without cluttering the rail.
const FooterMenu: React.FC<{
theme: string
checking: boolean
pendingUpdate: boolean
upToDate: boolean
onLogs: () => void
onTheme: () => void
onLanguage: () => void
onCheckUpdate: () => void
onOpenLink: (url: string) => void
}> = ({ theme, checking, pendingUpdate, upToDate, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => {
const updateLabel = checking
? t('update_checking')
: upToDate
? t('update_latest')
: t('update_check')
return (
<div className="absolute bottom-full left-2 right-2 mb-2 z-50 rounded-lg border border-default bg-elevated shadow-lg py-1">
{/* External destinations first (skill hub, docs, website) */}
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
<MenuItem
icon={<MessageSquareWarning size={16} />}
label={t('menu_feedback')}
onClick={() => onOpenLink(FEEDBACK_URL)}
/>
<div className="my-1 border-t border-subtle" />
{/* App actions below: update, theme, language, logs */}
<MenuItem
icon={checking ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
label={updateLabel}
onClick={onCheckUpdate}
dot={pendingUpdate}
disabled={checking || upToDate}
/>
<MenuItem
icon={theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
onClick={onTheme}
/>
<MenuItem
icon={<Languages size={16} />}
label={t('menu_language')}
trailing={getLang() === 'zh' ? 'EN' : '中'}
onClick={onLanguage}
/>
<MenuItem icon={<ScrollText size={16} />} label={t('menu_logs')} onClick={onLogs} />
</div>
)
}
const MenuItem: React.FC<{
icon: React.ReactNode
label: string
trailing?: string
dot?: boolean
disabled?: boolean
onClick: () => void
}> = ({ icon, label, trailing, dot, disabled, onClick }) => (
<button
disabled={disabled}
onClick={onClick}
className="w-full flex items-center gap-2.5 px-3 h-9 text-[13px] text-content-secondary hover:bg-surface-2 hover:text-content cursor-pointer transition-colors disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-content-secondary"
>
<span className="flex-shrink-0 text-content-tertiary relative">
{icon}
{dot && <span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-danger" />}
</span>
<span className="flex-1 text-left truncate">{label}</span>
{trailing && <span className="text-[11px] font-medium text-content-tertiary">{trailing}</span>}
</button>
)
export default NavRail

View File

@@ -0,0 +1,189 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Plus, MessageSquare, Pencil, Trash2, Check, X, History } from 'lucide-react'
import { t } from '../i18n'
import { useSessionStore } from '../store/sessionStore'
import { useUIStore } from '../store/uiStore'
import { usePlatform } from '../hooks/usePlatform'
import type { SessionItem } from '../types'
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const startOfYesterday = startOfToday - 86400
const today: SessionItem[] = []
const yesterday: SessionItem[] = []
const earlier: SessionItem[] = []
for (const s of sessions) {
const ts = s.last_active || s.created_at
if (ts >= startOfToday) today.push(s)
else if (ts >= startOfYesterday) yesterday.push(s)
else earlier.push(s)
}
return [
{ label: t('session_today'), items: today },
{ label: t('session_yesterday'), items: yesterday },
{ label: t('session_earlier'), items: earlier },
].filter((g) => g.items.length > 0)
}
const SessionList: React.FC = () => {
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
useSessionStore()
const toggleSessions = useUIStore((s) => s.toggleSessions)
const navCollapsed = useUIStore((s) => s.navCollapsed)
const { isMac } = usePlatform()
// When the nav rail is collapsed on macOS, the native traffic lights spill
// past it, so nudge the history button right to keep it (and its sibling in
// the main header) clear of the lights and aligned across states.
const trafficOffset = isMac && navCollapsed ? 'ml-2' : ''
// Nudge header buttons down a touch to sit level with the macOS traffic lights.
const trafficDrop = isMac ? 'mt-1' : ''
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
useEffect(() => {
loadSessions(1)
}, [loadSessions])
const groups = useMemo(() => groupByTime(sessions), [sessions])
const startEdit = (s: SessionItem) => {
setEditingId(s.session_id)
setEditValue(s.title || '')
}
const commitEdit = async () => {
if (editingId && editValue.trim()) {
await rename(editingId, editValue.trim())
}
setEditingId(null)
}
return (
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
{/* Header */}
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag border-b border-default">
<button
onClick={toggleSessions}
title={t('session_history')}
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 ${trafficDrop} ${trafficOffset}`}
>
<History size={16} />
</button>
<button
onClick={() => newSession()}
title={t('session_new')}
className={`titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors ${trafficDrop}`}
>
<Plus size={15} />
{t('session_new')}
</button>
</div>
{/* List */}
<div
className="flex-1 overflow-y-auto px-2 pb-2"
onScroll={(e) => {
const el = e.currentTarget
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80 && hasMore && !loading) loadMore()
}}
>
{sessions.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<MessageSquare size={22} className="text-content-disabled mb-2" />
<p className="text-xs text-content-tertiary">{t('session_empty')}</p>
</div>
)}
{groups.map((group) => (
<div key={group.label} className="mb-2">
<div className="px-2 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-content-disabled">
{group.label}
</div>
{group.items.map((s) => {
const isActive = s.session_id === activeId
const isEditing = editingId === s.session_id
return (
<div
key={s.session_id}
onClick={() => !isEditing && setActive(s.session_id)}
className={`group flex items-center gap-2 px-2 h-9 rounded-btn cursor-pointer transition-colors ${
isActive ? 'bg-accent-soft' : 'hover:bg-surface-2'
}`}
>
{isEditing ? (
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitEdit()
if (e.key === 'Escape') setEditingId(null)
}}
onClick={(e) => e.stopPropagation()}
className="flex-1 min-w-0 bg-inset border border-strong rounded px-1.5 py-0.5 text-[13px] text-content focus:outline-none focus:border-accent"
/>
) : (
<span
className={`flex-1 min-w-0 truncate text-[13px] ${
isActive ? 'text-accent font-medium' : 'text-content-secondary'
}`}
>
{s.title || s.session_id}
</span>
)}
{isEditing ? (
<div className="flex items-center gap-0.5">
<IconBtn onClick={(e) => { e.stopPropagation(); commitEdit() }}><Check size={13} /></IconBtn>
<IconBtn onClick={(e) => { e.stopPropagation(); setEditingId(null) }}><X size={13} /></IconBtn>
</div>
) : (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<IconBtn onClick={(e) => { e.stopPropagation(); startEdit(s) }} title={t('session_rename')}>
<Pencil size={13} />
</IconBtn>
<IconBtn onClick={(e) => { e.stopPropagation(); remove(s.session_id) }} title={t('session_delete')} danger>
<Trash2 size={13} />
</IconBtn>
</div>
)}
</div>
)
})}
</div>
))}
{loading && (
<div className="px-2 py-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="skeleton h-7 w-full" />
))}
</div>
)}
</div>
</div>
)
}
const IconBtn: React.FC<{
onClick: (e: React.MouseEvent) => 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-6 h-6 rounded cursor-pointer transition-colors text-content-tertiary ${
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface'
}`}
>
{children}
</button>
)
export default SessionList

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react'
import { Minus, Square, Copy, X } from 'lucide-react'
/**
* Custom window controls for the frameless Windows titlebar.
* On macOS the system renders traffic lights, so this returns null there.
*/
const WindowControls: React.FC = () => {
const [maximized, setMaximized] = useState(false)
const api = window.electronAPI
useEffect(() => {
api?.windowIsMaximized().then(setMaximized)
const off = api?.onMaximizeChange(setMaximized)
return off
}, [api])
if (api?.platform === 'darwin') return null
const btn =
'titlebar-no-drag inline-flex items-center justify-center w-11 h-full text-content-tertiary hover:text-content cursor-pointer transition-colors'
return (
<div className="flex items-stretch h-full">
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMinimize()} aria-label="Minimize">
<Minus size={15} strokeWidth={2} />
</button>
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMaximize()} aria-label="Maximize">
{maximized ? <Copy size={12} strokeWidth={2} /> : <Square size={12} strokeWidth={2} />}
</button>
<button
className={`${btn} hover:bg-danger hover:text-white`}
onClick={() => api?.windowClose()}
aria-label="Close"
>
<X size={16} strokeWidth={2} />
</button>
</div>
)
}
export default WindowControls

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