Compare commits

...

82 Commits

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

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

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

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

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

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

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

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

Fixes: potential RCE via controlled environment variables
2026-06-29 07:33:33 +08:00
zhayujie
538281da51 feat(desktop): support plugin commands 2026-06-27 12:19:12 +08:00
zhayujie
12cd626949 fix(web): reclaim orphaned SSE streams to prevent fd leak #2924 2026-06-27 11:32:19 +08:00
zhayujie
ff64a7930e Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2026-06-27 11:14:00 +08:00
zhayujie
5d726fe340 fix(desktop): run bundled backend from writable data dir 2026-06-27 11:13:39 +08:00
zhayujie
e1834124d4 Merge pull request #2918 from Tunnello/fix-azure-openai-stream
fix: azure openai stream
2026-06-27 10:59:40 +08:00
Eric L
f49e965736 fix: azure openai stream config 2026-06-26 20:54:25 +08:00
Eric L
936eaf5939 fix: azure openai stream 2026-06-25 21:13:50 +08:00
zhayujie
7047b30e27 feat(models): support doubao-seed-2.1 series 2026-06-25 11:53:24 +08:00
zhayujie
5c67e970d1 feat: sync knowledge management to desktop 2026-06-25 11:43:19 +08:00
zhayujie
8023c4e8b7 feat(knowledge): consolidate actions into a New menu and refine docs layout 2026-06-25 11:19:33 +08:00
zhayujie
641b84519c Merge branch 'pr-2915' 2026-06-25 11:02:55 +08:00
zhayujie
0c8cb974e2 feat(knowledge): auto-maintain index.md, improve import UX, fix embedding provider
- Auto-rebuild knowledge/index.md from the real directory tree on
  create/import so it never drifts or loses documents (no longer relies
  on the agent hand-writing it).
- Auto-open the created/imported document in the tree after success.
- Add create_document status message, shorten EN action buttons, and
  localize the "insert template" content.
- Show filename for protected system files (index.md/log.md) in the tree
  instead of their H1 heading.
- Reuse a shared embedding-provider factory so knowledge index sync also
  gets vectors instead of degrading to keyword-only search.
2026-06-25 11:02:18 +08:00
zhayujie
915edbe145 fix(tools): make web SSRF protection opt-in, disabled by default 2026-06-24 19:40:55 +08:00
zhayujie
0c20c5c159 Merge pull request #2917 from zhayujie/feat-cow-desktop
feat: CowAgent desktop client
2026-06-24 19:26:06 +08:00
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
5db2998e3d fix: update web console version 2026-06-22 13:04:15 +08:00
zhayujie
0bc0f2b930 fix(evolution): prevent MCP tool re-injection into restricted review agent #2904 2026-06-20 15:17:11 +08:00
yangziyu-hhh
84d6848e67 feat(knowledge): add document creation and import 2026-06-18 16:06:41 +08:00
120 changed files with 6932 additions and 857 deletions

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

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

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

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

View File

@@ -1,14 +1,22 @@
name: Release Desktop
# Tag-driven release: push a tag like `v1.2.0` to build and publish the
# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the
# single source of truth for the version — it's written into package.json at
# build time, so the maintainer never edits the version by hand.
# 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:
push:
tags:
- "v*"
# Manual trigger for testing the full pipeline without cutting a real tag.
workflow_dispatch:
inputs:
version:
@@ -97,6 +105,14 @@ jobs:
shell: bash
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
# Compile renderer + main in its OWN step, alone, so the npm.cmd batch
# wrapper (see the note on the build step below) can't take out anything
# after it.
- name: Compile (vite + tsc)
working-directory: desktop
shell: bash
run: npm run build
- name: Build & publish (electron-builder)
working-directory: desktop
shell: bash
@@ -108,41 +124,67 @@ jobs:
# is the correct state for unsigned builds.
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
npm run build
# Pick the signing cert for THIS platform only. The mac and win secrets
# are both present in the job env, but a mac cert must never leak into a
# Windows build (electron-builder would try to load it and fail), and
# vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD
# pair, so we set it per-platform. An empty CSC_LINK is treated by
# electron-builder as a broken cert path, so we leave it entirely unset
# for an unsigned build.
#
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
# (an `unset` can return non-zero and abort under errexit).
case "${{ matrix.platform }}" in
mac)
if [ -n "$MAC_CSC_LINK" ]; then
export CSC_LINK="$MAC_CSC_LINK"
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
fi
;;
win)
if [ -n "$WIN_CSC_LINK" ]; then
export CSC_LINK="$WIN_CSC_LINK"
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
fi
;;
esac
# Only export signing vars when provided. Empty strings are NOT the
# same as unset to electron-builder: an empty CSC_LINK is treated as
# a (broken) certificate path and aborts the build. Leaving them
# unset makes electron-builder fall back to an unsigned build.
if [ -n "$MAC_CSC_LINK" ]; then
export CSC_LINK="$MAC_CSC_LINK"
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
fi
if [ -z "$WIN_CSC_LINK" ]; then
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
fi
# Publish to the GitHub Release on tag pushes; otherwise build only.
if [ "${{ github.event_name }}" = "push" ]; then
PUBLISH=always
else
PUBLISH=never
fi
npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH"
# Never let electron-builder publish: our publish target is a generic
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
# installers to R2 and register them in D1 ourselves (publish-r2 job).
# `--publish never` still emits the latest*.yml files.
#
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
# inject mac.binaries (the backend Mach-O files to hardened-sign for
# notarization) — it's a pure no-op on Windows. Passing --config on
# Windows was what silently broke the Windows build (it produced no
# installer while the job still reported success; Windows worked fine
# before --config was introduced). So Windows uses the plain
# package.json build config and only mac uses the dynamic one.
#
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
# make bash return before the wrapped process finishes. node skips it.
case "${{ matrix.platform }}" in
mac) config_arg="--config electron-builder.js" ;;
*) config_arg="" ;;
esac
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
# Upload artifacts regardless of outcome, so a failed run still surfaces
# the built installers (and, on success, the notarized+stapled dmg).
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
# One bundle per platform/arch so the publish job can collect them all.
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.exe
desktop/release/*.yml
desktop/release/*.blockmap
@@ -201,10 +243,13 @@ jobs:
id: stage
run: |
mkdir -p dist
# Flatten installers from every per-platform artifact dir; only the
# user-facing installers go to R2 (updater .yml/.blockmap stay on the
# GitHub Release, which electron-updater reads directly).
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} 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.
@@ -239,36 +284,18 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ steps.ver.outputs.version }}
run: |
# Map each installer filename to a platform id. dmg arch is in the
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
sql_file="$(mktemp)"
# 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)."
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
# recorded but NEVER marked latest, so /download/<p>/latest keeps
# serving the last stable build. They also must not clear an existing
# stable's latest flag. Only a final version (no pre-release suffix)
# becomes the new latest and clears the previous one per platform.
case "$VER" in
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
esac
for f in dist/*; do
base="$(basename "$f")"
size="$(stat -c%s "$f")"
case "$base" in
*arm64.dmg) platform="mac-arm64" ;;
*x64.dmg) platform="mac-x64" ;;
*.exe) platform="win" ;;
*) echo "Skipping unrecognized artifact: $base"; continue ;;
esac
key="v${VER}/${base}"
# Stable only: clear the previous latest for THIS platform first, so
# a partial backfill never wipes other platforms' latest flag.
if [ "$is_latest" = "1" ]; then
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
fi
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, ${is_latest});" >> "$sql_file"
done
echo "==> D1 statements:"; cat "$sql_file"
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
# 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

4
.gitignore vendored
View File

@@ -54,7 +54,11 @@ 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 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>
**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/guide/quick-start">🚀 Quick Start</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> &nbsp;·&nbsp;
<a href="https://cowagent.ai/download/">💻 Download</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
</p>
@@ -95,6 +96,8 @@ cow skill install <name> # install a skill
cow install-browser # install browser automation
```
> 💻 Desktop client: download the **[CowAgent Desktop client](https://cowagent.ai/download/)** (macOS / Windows) — the backend is bundled, ready to use out of the box.
<br/>
## 🤖 Models
@@ -103,13 +106,13 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [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 | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
@@ -199,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
## 🏷 Changelog
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.

View File

@@ -424,6 +424,11 @@ def run_evolution_for_session(
enable_skills=True,
runtime_info=getattr(agent, "runtime_info", None),
)
# Mark this as a restricted review agent so runtime MCP reconciliation
# (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools
# that _select_tools()/_guard_tools() intentionally withheld. Without this
# flag the review boundary would be re-opened on the first LLM turn.
review_agent._evolution_restricted = True
# Reuse the live model so it follows the user's configured model.
review_agent.model = agent.model
# Inject the evolution task brief AFTER the full system prompt: the agent

View File

@@ -17,6 +17,7 @@ import shutil
import threading
from pathlib import Path
from typing import Optional, Iterable
from urllib.parse import quote
from common.log import logger
from config import conf
@@ -32,6 +33,10 @@ class KnowledgeService:
PROTECTED_FILES = {"index.md", "log.md"}
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
IMPORT_EXTENSIONS = {".md", ".txt"}
MAX_IMPORT_FILES = 100
MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024
MAX_IMPORT_TOTAL_SIZE = 200 * 1024 * 1024
def __init__(self, workspace_root: str, memory_manager=None):
self.workspace_root = os.path.abspath(workspace_root)
@@ -75,7 +80,14 @@ class KnowledgeService:
def _manager(self):
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
@staticmethod
@@ -100,9 +112,9 @@ class KnowledgeService:
raise error[0]
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))
if not old_paths:
if not old_paths and not force:
return
manager = self._manager()
for rel_path in old_paths:
@@ -110,6 +122,195 @@ class KnowledgeService:
manager.mark_dirty()
self._run_sync(manager.sync())
@staticmethod
def _extract_title(md_path: Path, fallback: str) -> str:
"""Read a markdown file's H1 title, falling back to the file stem."""
try:
with open(md_path, "r", encoding="utf-8") as f:
for _ in range(20):
line = f.readline()
if not line:
break
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip() or fallback
except Exception:
pass
return fallback
def rebuild_index_md(self) -> bool:
"""Regenerate knowledge/index.md from the actual directory tree.
Keeps the index in sync with real files so it never drifts or loses
documents. Returns True when the file was (re)written.
"""
root = Path(self.knowledge_dir)
if not root.is_dir():
return False
def collect(dir_path: Path) -> list:
# Return sorted (rel_path, title) tuples for *.md under dir_path,
# excluding protected files at the knowledge root and dot files.
entries = []
for md in sorted(dir_path.rglob("*.md")):
rel = md.relative_to(root).as_posix()
if any(part.startswith(".") for part in md.relative_to(root).parts):
continue
if rel in self.PROTECTED_FILES:
continue
entries.append((rel, self._extract_title(md, md.stem)))
return entries
all_entries = collect(root)
def link(rel: str) -> str:
# Encode each path segment so spaces / special chars stay valid in
# markdown links, while keeping the slashes between segments.
encoded = "/".join(quote(part) for part in rel.split("/"))
return f"./{encoded}"
lines = ["# 知识库目录", ""]
# Root-level documents first (no category dir).
root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel]
for rel, title in root_docs:
lines.append(f"- [{title}]({link(rel)})")
if root_docs:
lines.append("")
# Group remaining documents by their top-level category.
categories = {}
for rel, title in all_entries:
if "/" not in rel:
continue
category = rel.split("/", 1)[0]
categories.setdefault(category, []).append((rel, title))
for category in sorted(categories.keys()):
lines.append(f"## {category}")
for rel, title in categories[category]:
lines.append(f"- [{title}]({link(rel)})")
lines.append("")
content = "\n".join(lines).rstrip() + "\n"
index_path = root / "index.md"
try:
index_path.write_text(content, encoding="utf-8")
return True
except Exception as exc:
logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}")
return False
def _sanitize_document_name(self, filename: str) -> str:
name = os.path.basename((filename or "").replace("\\", "/")).strip()
if not name:
raise ValueError("filename is required")
stem, ext = os.path.splitext(name)
if ext.lower() not in self.IMPORT_EXTENSIONS:
raise ValueError(f"unsupported file type: {ext or name}")
if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem):
raise ValueError("invalid filename")
safe_name = f"{stem}.md"
self._ensure_not_protected(safe_name)
return safe_name
@staticmethod
def _decode_document_content(content) -> str:
if isinstance(content, str):
return content
if not isinstance(content, (bytes, bytearray)):
raise ValueError("document content is required")
return bytes(content).decode("utf-8-sig", errors="replace")
def _resolve_import_destination(self, target_category: str, filename: str,
conflict_strategy: str) -> tuple:
target_rel, target_full = self._resolve_path(target_category, kind="category")
if not target_full.is_dir():
raise FileNotFoundError(f"category not found: {target_rel}")
safe_name = self._sanitize_document_name(filename)
destination = target_full / safe_name
rel_path = f"{target_rel}/{safe_name}"
if destination.exists():
if conflict_strategy == "skip":
return rel_path, destination, "skip"
if conflict_strategy == "rename":
stem = destination.stem
suffix = destination.suffix
for index in range(1, 1000):
candidate = target_full / f"{stem}-{index}{suffix}"
if not candidate.exists():
candidate_rel = f"{target_rel}/{candidate.name}"
return candidate_rel, candidate, "write"
raise FileExistsError(f"target already exists: {rel_path}")
if conflict_strategy != "overwrite":
raise ValueError("invalid conflict strategy")
return rel_path, destination, "write"
def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE:
raise ValueError("file too large")
if full_path.exists() and not overwrite:
raise FileExistsError(f"target already exists: {rel_path}")
old_paths = [rel_path] if full_path.exists() else []
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content or "", encoding="utf-8")
# Keep index.md in sync before reindexing so it is indexed too.
self.rebuild_index_md()
self._sync_index(old_paths, force=True)
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
def import_documents(self, target_category: str, files: Iterable[dict],
conflict_strategy: str = "skip") -> dict:
if not isinstance(files, list):
raise ValueError("files must be a list")
if len(files) > self.MAX_IMPORT_FILES:
raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}")
results = []
old_paths = []
imported = skipped = failed = 0
total_size = 0
for item in files:
filename = item.get("filename") if isinstance(item, dict) else None
try:
content_bytes = item.get("content") if isinstance(item, dict) else None
size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"")
total_size += size
if total_size > self.MAX_IMPORT_TOTAL_SIZE:
raise ValueError("import batch too large")
if size > self.MAX_IMPORT_FILE_SIZE:
raise ValueError("file too large")
rel_path, destination, mode = self._resolve_import_destination(
target_category, filename, conflict_strategy
)
if mode == "skip":
skipped += 1
results.append({"filename": filename, "path": rel_path, "status": "skipped",
"reason": "target_exists"})
continue
old_exists = destination.exists()
content = self._decode_document_content(content_bytes)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")
if old_exists:
old_paths.append(rel_path)
imported += 1
results.append({"filename": filename, "path": rel_path, "status": "imported",
"overwritten": old_exists})
except Exception as exc:
failed += 1
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
if imported:
# Keep index.md in sync before reindexing so it is indexed too.
self.rebuild_index_md()
self._sync_index(old_paths, force=True)
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
def create_category(self, path: str) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category")
if full_path.exists():
@@ -283,14 +484,18 @@ class KnowledgeService:
if not is_root:
stats["pages"] += 1
stats["size"] += size
title = name.replace(".md", "")
try:
with open(full, "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line.startswith("# "):
title = first_line[2:].strip()
except Exception:
pass
# Prefer the H1 heading as a readable title for normal docs.
# System files (index.md / log.md) keep their filename so the
# tree never hides what they actually are.
title = name[:-3]
if name not in self.PROTECTED_FILES:
try:
with open(full, "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line.startswith("# "):
title = first_line[2:].strip() or title
except Exception:
pass
files.append({"name": name, "title": title, "size": size})
return files, children
@@ -416,6 +621,15 @@ class KnowledgeService:
result = self.delete_documents(payload.get("paths") or [])
elif action == "move_documents":
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
elif action == "create_document":
result = self.create_document(payload.get("path"), payload.get("content", ""),
payload.get("overwrite", False))
elif action == "import_documents":
result = self.import_documents(
payload.get("target_category"),
payload.get("files") or [],
payload.get("conflict_strategy", "skip"),
)
else:
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
return {"action": action, "code": 200, "message": "success", "payload": result}

View File

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

View File

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

View File

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

View File

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

View File

@@ -419,6 +419,17 @@ class MemoryFlushManager:
lookback_days: How many days of daily files to read (default 1 for scheduled, 3 for manual)
force: Skip input-hash dedup check (used by manual /memory dream trigger)
"""
# Config guard for scheduled runs. Manual trigger (force=True) always
# runs since it is an explicit user action.
if not force:
try:
from config import conf
if not conf().get("deep_dream_enabled", True):
logger.info("[DeepDream] deep_dream_enabled=false, skipping")
return False
except Exception:
pass
if not self.llm_model:
logger.warning("[DeepDream] No LLM model available, skipping")
return False

View File

@@ -379,6 +379,12 @@ class AgentStreamExecutor:
self._emit_event("agent_start")
# Reset the run-scoped MCP tool-retrieval accumulator. On-demand tool
# retrieval only grows this set within a run, so a tool that already
# produced a tool_use never disappears from the schema mid-run (which
# would make Claude/MiniMax raise a message-format error).
self._retrieved_mcp_names = set()
final_response = ""
turn = 0
@@ -702,6 +708,70 @@ class AgentStreamExecutor:
return final_response
def _select_tools_for_injection(self) -> list:
"""Decide which tools to inject into the current LLM turn.
Built-in tools are ALWAYS injected in full (skills and core flows hard
depend on them). MCP tools are also injected in full UNLESS on-demand
retrieval is enabled AND the MCP tool count exceeds the configured
threshold — then only the most relevant MCP tools are injected, unioned
with those already selected earlier in this run (only-grows, so a tool
that already produced a tool_use never vanishes from the schema).
Degrades safely: disabled feature, no embedding provider, embedding
failure, count below threshold, or any error → inject all tools. Tools
are never silently dropped.
"""
all_tools = list(self.tools.values())
try:
from config import conf
if not conf().get("mcp_tool_retrieval_enabled", False):
return all_tools
from agent.tools.mcp.mcp_tool import McpTool
mcp_tools = [t for t in all_tools if isinstance(t, McpTool)]
builtin_tools = [t for t in all_tools if not isinstance(t, McpTool)]
threshold = int(conf().get("mcp_tool_retrieval_threshold", 20) or 20)
if len(mcp_tools) <= threshold:
return all_tools
top_k = int(conf().get("mcp_tool_retrieval_top_k", 10) or 10)
from agent.tools import ToolManager
from agent.tools.mcp.tool_retrieval import (
build_retrieval_query,
select_mcp_tools,
)
tm = ToolManager()
tool_vectors = tm.get_mcp_tool_vectors()
query = build_retrieval_query(self.messages)
query_vector = tm.embed_query(query)
selected = select_mcp_tools(
query_vector,
tool_vectors,
top_k,
getattr(self, "_retrieved_mcp_names", set()),
)
if selected is None:
# No provider / empty index / error → full injection.
return all_tools
# Persist the accumulated selection for subsequent turns.
self._retrieved_mcp_names = selected
selected_mcp = [t for t in mcp_tools if t.name in selected]
logger.info(
f"[ToolRetrieval] Injecting {len(builtin_tools)} built-in + "
f"{len(selected_mcp)}/{len(mcp_tools)} MCP tool(s) (top_k={top_k})"
)
return builtin_tools + selected_mcp
except Exception as e:
logger.debug(f"[ToolRetrieval] full injection (retrieval skipped): {e}")
return all_tools
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
"""
@@ -742,7 +812,7 @@ class AgentStreamExecutor:
tools_schema = None
if self.tools:
tools_schema = []
for tool in self.tools.values():
for tool in self._select_tools_for_injection():
input_schema = tool.params
try:
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}

View File

@@ -202,8 +202,12 @@ SAFETY:
total_bytes = len(output.encode('utf-8'))
if total_bytes > DEFAULT_MAX_BYTES:
# Save full output to temp file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
# Save full output to temp file. encoding='utf-8' is required:
# the default text-mode encoding is the platform locale (e.g.
# cp936/GBK on Chinese Windows), which raises UnicodeEncodeError
# for output containing emoji or other non-locale characters and
# would discard an otherwise successful command result.
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f:
f.write(output)
temp_file_path = f.name

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ _DISCOVERABLE_MODELS = [
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
("dashscope_api_key", const.QWEN_DASHSCOPE, const.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"),
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
("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"
"The main model does not support vision and no other API keys are configured.\n"
"Options:\n"
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.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"
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
)

18
app.py
View File

@@ -15,9 +15,9 @@ import threading
_channel_mgr = None
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
# framework is still bundled (it's tiny and on the web channel's import path),
# but we skip loading actual plugins and MCP tools to keep startup fast.
# 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"
@@ -80,8 +80,16 @@ class ChannelManager:
if self._primary_channel is None and channels:
self._primary_channel = channels[0][1]
if first_start and not DESKTOP_MODE:
PluginManager().load_plugins()
if first_start:
if DESKTOP_MODE:
# Load plugins in the background so command plugins
# (cow_cli / godcmd, e.g. /status, #help) work in the
# desktop client, without blocking web-service readiness.
threading.Thread(
target=PluginManager().load_plugins, daemon=True
).start()
else:
PluginManager().load_plugins()
# Cloud client is optional. It is only started when
# use_linkai=True AND cloud_deployment_id is set.

View File

@@ -684,11 +684,21 @@ class AgentBridge:
"""
file_type = file_info.get("file_type", "file")
file_path = file_info.get("path")
# Remote URLs are passed through as-is; local paths get a file:// prefix
# so the channel can read them from disk.
remote_url = file_info.get("url", "")
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
def _to_channel_url(p: str) -> str:
if is_remote:
return remote_url
if p and p.lower().startswith(("http://", "https://")):
return p
return f"file://{p}"
# For images, use IMAGE_URL type (channel will handle upload)
if file_type == "image":
# Convert local path to file:// URL for channel processing
file_url = f"file://{file_path}"
file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending image: {file_url}")
reply = Reply(ReplyType.IMAGE_URL, file_url)
# Attach text message if present (for channels that support text+image)
@@ -698,7 +708,7 @@ class AgentBridge:
# For all file types (document, video, audio), use FILE type
if file_type in ["document", "video", "audio"]:
file_url = f"file://{file_path}"
file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
@@ -708,7 +718,7 @@ class AgentBridge:
return reply
# For all other file types (tar.gz, zip, etc.), also use FILE type
file_url = f"file://{file_path}"
file_url = _to_channel_url(file_path)
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
reply = Reply(ReplyType.FILE, file_url)
reply.file_name = file_info.get("file_name", os.path.basename(file_path))

View File

@@ -17,10 +17,6 @@ from common.utils import expand_path
# Module-level lock to serialize scheduler init across concurrent sessions
_scheduler_init_lock = threading.Lock()
# Track whether the embedding model log has been printed in this process,
# so we avoid spamming it once per session.
_embedding_logged: bool = False
class AgentInitializer:
"""
@@ -306,224 +302,16 @@ class AgentInitializer:
"""
Initialize the embedding provider for memory.
Two paths:
Delegates to the shared factory so agent init, knowledge sync and
index rebuild all select the same provider:
A. Default (no `embedding_provider` in config.json):
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
keep working.
Auto-init OpenAI -> LinkAI fallback.
B. Explicit (`embedding_provider` is set):
Initialize the requested vendor with unified dim (default 1024).
If the index was built with a different dim, vector search will
quietly return no results (cosine returns 0) and keyword search
takes over until the user runs /memory rebuild-index.
Initialize the requested vendor.
"""
from agent.memory import create_embedding_provider
from config import conf
from agent.memory import create_default_embedding_provider
return create_default_embedding_provider()
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
if not explicit_provider:
return self._init_embedding_provider_legacy(session_id=session_id)
return self._init_embedding_provider_explicit(
memory_config, explicit_provider, session_id=session_id,
)
def _init_embedding_provider_legacy(self, session_id: Optional[str] = None):
"""Legacy auto-init path: OpenAI -> LinkAI. Preserved verbatim for compat."""
from agent.memory import create_embedding_provider
from config import conf
embedding_provider = None
embedding_model = None
openai_api_key = conf().get("open_ai_api_key", "")
openai_api_base = conf().get("open_ai_api_base", "")
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="openai",
model=model,
api_key=openai_api_key,
api_base=openai_api_base or "https://api.openai.com/v1"
)
embedding_model = f"openai/{model}"
except Exception as e:
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
if embedding_provider is None:
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
try:
model = "text-embedding-3-small"
embedding_provider = create_embedding_provider(
provider="linkai",
model=model,
api_key=linkai_api_key,
api_base=f"{linkai_api_base}/v1"
)
embedding_model = f"linkai/{model}"
except Exception as e:
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
if embedding_provider is not None and embedding_model:
global _embedding_logged
if not _embedding_logged:
logger.info(
f"[AgentInitializer] Embedding model in use: {embedding_model} "
f"(dim={embedding_provider.dimensions})"
)
_embedding_logged = True
return embedding_provider
def _init_embedding_provider_explicit(
self,
memory_config,
provider_key: str,
session_id: Optional[str] = None,
):
"""Explicit-provider path: build the configured vendor.
If the index was built with a different dim, vector search will
silently return no results (cosine returns 0 for mismatched dims)
and keyword search takes over. Users switch vendors by running
/memory rebuild-index — see docs.
"""
from agent.memory import create_embedding_provider
from agent.memory.embedding import EMBEDDING_VENDORS
from config import conf
# Custom providers ("custom:<id>") resolve credentials
# from the custom_providers list.
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"[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()
# 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"[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
# Custom providers ("custom:<id>") resolve from the custom_providers list.
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:
providers = get_custom_providers()
entry = _find_provider_by_id(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
@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
# Custom providers ("custom:<id>") resolve from the custom_providers list.
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:
providers = get_custom_providers()
entry = _find_provider_by_id(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 _sync_memory(self, memory_manager, session_id: Optional[str] = None):
"""Sync memory database"""
try:

View File

@@ -47,12 +47,15 @@
This runs synchronously in <head> so the correct class is on <html>
before any CSS or body rendering occurs. -->
<script>
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'en',
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'zh-Hant' / 'en',
// or '' when unrecognized so callers can fall through to the next source.
window.__cowNormalizeLang__ = function(raw) {
if (!raw) return '';
var v = String(raw).trim().toLowerCase();
var v = String(raw).trim().toLowerCase().replace('_', '-');
if (v === 'auto') return '';
// Handle Traditional Chinese variants first (more specific)
if (v === 'zh-hant' || v.indexOf('zh-hant-') === 0 || v === 'zh-tw' || v === 'zh-hk') return 'zh-Hant';
// Then Simplified Chinese
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
@@ -267,14 +270,29 @@
<div class="flex-1"></div>
<!-- Language Toggle -->
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
cursor-pointer transition-colors duration-150"
onclick="toggleLanguage()">
<i class="fas fa-globe text-xs"></i>
<span id="lang-label">EN</span>
</button>
<!-- Language Selector (dropdown) -->
<div id="lang-selector" class="relative">
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
cursor-pointer transition-colors duration-150"
onclick="toggleLangMenu(event)">
<i class="fas fa-globe text-xs"></i>
<span id="lang-label">EN</span>
<i class="fas fa-chevron-down text-[10px] opacity-60"></i>
</button>
<div id="lang-menu" class="hidden absolute right-0 mt-1 min-w-[120px] py-1 rounded-lg z-50
bg-white dark:bg-slate-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10">
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh"
onclick="selectLanguage('zh')">简体中文</button>
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh-Hant"
onclick="selectLanguage('zh-Hant')">繁體中文</button>
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="en"
onclick="selectLanguage('en')">English</button>
</div>
</div>
<!-- Theme Toggle -->
<button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
@@ -304,6 +322,14 @@
cursor-pointer transition-colors duration-150" title="GitHub">
<i class="fab fa-github text-lg"></i>
</a>
<!-- Logout Button (hidden by default) -->
<button id="logout-btn-header" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 dark:hover:text-red-400
cursor-pointer transition-colors duration-150 hidden"
onclick="handleLogout()" title="Logout" data-i18n-title="logout">
<i class="fas fa-sign-out-alt text-base"></i>
</button>
</header>
<!-- Content Area -->
@@ -829,11 +855,11 @@
<!-- VIEW: Knowledge -->
<!-- ====================================================== -->
<div id="view-knowledge" class="view">
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10">
<div class="w-full max-w-[1600px] mx-auto">
<div class="flex-1 min-h-0 overflow-y-auto md:overflow-hidden p-4 md:p-8 lg:p-10 md:flex md:flex-col">
<div class="w-full max-w-[1600px] mx-auto md:flex-1 md:min-h-0 md:flex md:flex-col">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6 md:flex-shrink-0">
<div>
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">浏览和探索你的知识库</p>
@@ -841,10 +867,6 @@
<div class="flex items-center gap-2">
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
<button onclick="createKnowledgeCategory()"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
</button>
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
@@ -855,6 +877,28 @@
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
</button>
</div>
<div id="knowledge-new-menu" class="relative">
<button onclick="toggleKnowledgeNewMenu(event)"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-plus"></i><span data-i18n="knowledge_new">新建</span><i class="fas fa-chevron-down text-[9px] ml-0.5"></i>
</button>
<div id="knowledge-new-menu-list"
class="hidden absolute right-0 mt-1.5 w-44 z-50 bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-lg shadow-lg py-1">
<button onclick="createKnowledgeCategory(); closeKnowledgeNewMenu()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<i class="fas fa-folder-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_category">新建分类</span>
</button>
<button onclick="createKnowledgeDocument(); closeKnowledgeNewMenu()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<i class="fas fa-file-circle-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_document">新建文档</span>
</button>
<button onclick="selectKnowledgeImportFiles(); closeKnowledgeNewMenu()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
<i class="fas fa-file-arrow-up w-3.5 text-slate-400"></i><span data-i18n="knowledge_import_documents">导入文档</span>
</button>
</div>
</div>
<input id="knowledge-import-input" type="file" class="hidden" multiple accept=".md,.txt,text/markdown,text/plain">
</div>
</div>
@@ -877,12 +921,12 @@
</div>
<!-- Documents panel -->
<div id="knowledge-panel-docs" class="hidden">
<div class="flex flex-col md:flex-row gap-4 md:gap-6" style="min-height: calc(100vh - 220px)">
<div id="knowledge-panel-docs" class="hidden md:flex-1 md:min-h-0">
<div class="flex flex-col md:flex-row gap-4 md:gap-6 md:h-full">
<!-- File tree -->
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10">
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0 md:h-full">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
<input id="knowledge-search" type="text" placeholder="Search..."
@@ -890,19 +934,19 @@
oninput="filterKnowledgeTree(this.value)">
</div>
</div>
<div id="knowledge-tree" class="p-2 overflow-y-auto max-h-[50vh] md:max-h-[calc(100vh-300px)]"></div>
<div id="knowledge-tree" class="p-2 overflow-y-auto flex-1 max-h-[50vh] md:max-h-none"></div>
</div>
</div>
<!-- Content viewer -->
<div class="flex-1 min-w-0">
<div class="flex-1 min-w-0 md:h-full">
<div id="knowledge-content-placeholder"
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500">
class="flex flex-col items-center justify-center py-20 md:h-full text-slate-400 dark:text-slate-500 bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10">
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
</div>
<div id="knowledge-content-viewer" class="hidden">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10">
<div id="knowledge-content-viewer" class="hidden md:h-full">
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<button onclick="knowledgeMobileBack()" class="md:hidden p-1 -ml-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
<i class="fas fa-arrow-left text-xs"></i>
</button>
@@ -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>
</div>
<div id="knowledge-viewer-body"
class="p-4 md:p-5 overflow-y-auto text-sm msg-content text-slate-700 dark:text-slate-200"
style="max-height: calc(100vh - 280px)"></div>
class="p-4 md:p-5 overflow-y-auto flex-1 max-h-[60vh] md:max-h-none text-sm msg-content text-slate-700 dark:text-slate-200"></div>
</div>
</div>
</div>
@@ -1083,7 +1126,7 @@
<!-- Knowledge Action Dialog -->
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div id="knowledge-dialog-card" class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
@@ -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>
<input id="knowledge-dialog-input" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
<select id="knowledge-dialog-select"
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
<div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
<textarea id="knowledge-dialog-textarea" rows="8"
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"></textarea>
<div id="knowledge-document-form" class="hidden space-y-3">
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/15 border border-emerald-100 dark:border-emerald-800/40 px-3 py-2">
<div id="knowledge-document-category-label" class="text-[11px] text-emerald-600 dark:text-emerald-400 mb-0.5"></div>
<div id="knowledge-document-path-preview" class="text-xs font-mono text-emerald-700 dark:text-emerald-300 break-all"></div>
</div>
<div>
<label id="knowledge-document-filename-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
<input id="knowledge-document-filename" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"
placeholder="note.md">
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label id="knowledge-document-content-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300"></label>
<button id="knowledge-document-template" type="button" class="text-xs text-primary-500 hover:text-primary-600"></button>
</div>
<textarea id="knowledge-document-content" rows="14"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"
placeholder="# Title&#10;&#10;Write your notes here..."></textarea>
</div>
</div>
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
</div>

View File

@@ -1293,6 +1293,18 @@
background: rgba(74, 190, 110, 0.1);
color: #4ABE6E;
}
.knowledge-import-drag-over {
outline: 2px dashed rgba(74, 190, 110, 0.55);
outline-offset: 4px;
border-radius: 14px;
}
#knowledge-dialog-card.knowledge-document-dialog {
max-width: 760px;
}
#knowledge-document-content {
min-height: 320px;
line-height: 1.55;
}
/* Graph legend */
.knowledge-graph-legend {

View File

@@ -93,7 +93,10 @@ const I18N = {
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中。',
knowledge_go_chat: '开始对话',
knowledge_new: '新建',
knowledge_new_category: '新建分类',
knowledge_new_document: '新建文档',
knowledge_import_documents: '导入文档',
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器',
@@ -144,8 +147,9 @@ const I18N = {
config_custom_tip: '接口需遵循 OpenAI API 协议',
config_security: '安全设置', config_password: '访问密码',
config_password_hint: '留空则不启用密码保护',
config_password_changed: '密码已更新,请重新登录',
config_password_changed: '密码已更新',
config_password_cleared: '密码已清除',
config_password_security_warning: '⚠️ 警告:目前密码为空且对外连接埠开放,建议重启服务,或检查是否调整监听位址绑定。',
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 工具和技能', skills_hub_btn: '探索技能广场',
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
tools_section_title: '内置工具', tools_loading: '加载工具中...',
@@ -251,7 +255,255 @@ const I18N = {
regenerate_response: '重新生成',
edit_save: '保存并发送',
edit_cancel: '取消',
logout: '退出',
},
'zh-Hant': {
console: '控制台',
nav_chat: '對話', nav_manage: '管理', nav_monitor: '監控',
menu_chat: '對話', menu_config: '設定', menu_models: '模型', menu_skills: '技能',
menu_memory: '記憶', menu_knowledge: '知識', menu_channels: '管道', menu_tasks: '定時',
menu_logs: '日誌',
models_title: '模型管理',
models_desc: '統一管理對話、影像、語音、向量、搜尋能力',
models_section_vendors: '廠商憑據',
models_section_vendors_desc: '一處設定,多個模型能力共享',
models_section_capabilities: '模型能力',
models_add_vendor: '新增廠商',
models_provider: '廠商',
models_model: '模型',
models_voice: '音色',
models_configured: '已設定',
models_not_configured: '未設定',
models_pick_to_configure: '選擇以設定',
models_clear_credential: '清除憑據',
models_base_default_hint: '留空將使用官方預設地址',
models_base_default: '預設',
models_custom_vendor_label: '自定義',
models_custom_name: '名稱',
models_custom_delete: '刪除',
models_custom_delete_confirm_title: '刪除自定義廠商',
models_custom_delete_confirm_msg: '確定刪除該自定義廠商嗎?此操作無法撤銷。',
models_custom_name_required: '請填寫名稱',
models_custom_base_required: '請填寫 API Base',
models_custom_edit_title: '編輯自定義廠商',
models_custom_add_title: '新增自定義廠商',
models_capability_chat: '主模型',
models_capability_chat_desc: '用於基礎對話和 Agent 推理',
models_capability_vision: '影像理解',
models_capability_vision_desc: '識別圖片內容,用於影像識別工具',
models_capability_image: '影像生成',
models_capability_image_desc: '生成圖片,用於影像生成技能',
models_auto_using: '當前優先使用',
models_capability_asr: '語音識別',
models_capability_asr_desc: '語音轉文字',
models_capability_tts: '語音合成',
models_capability_tts_desc: '文字轉語音',
models_capability_embedding: '向量',
models_capability_embedding_desc: '用於記憶與知識的向量化檢索',
models_capability_search: '聯網搜尋',
models_capability_search_desc: '實時網頁檢索能力,用於搜尋工具',
models_strategy_auto: '自動',
models_search_strategy_label: '策略',
models_search_strategy_fixed: '指定',
models_search_strategy_auto_hint: '從已設定廠商中自動選擇',
models_search_strategy_fixed_hint: '指定使用搜尋廠商',
models_pending_config: '待設定',
models_search_available_label: '可用搜尋廠商:',
models_search_none_configured: '暫未啟用任何搜尋廠商,點選新增',
models_search_add_provider: '新增廠商',
models_search_add_desc: '選擇一個搜尋廠商進行設定',
models_search_bocha_title: '設定博查 API Key',
models_search_bocha_desc: '前往博查開放平臺建立 API Key',
models_search_edit_hint: '點選修改設定',
models_unavailable: '不可用',
models_set_via_env: '透過環境變數啟用',
models_dim_label: '維度',
models_save_success: '已儲存',
models_save_failed: '儲存失敗',
models_cleared: '已清除',
models_clear_failed: '清除失敗',
models_embedding_change_title: '更改向量模型',
models_embedding_change_msg: '切換向量模型後,已有索引將失效,需要重建。是否繼續?',
models_embedding_saved_title: '向量模型已更新',
models_embedding_saved_msg: '請在聊天框輸入 /memory rebuild-index 重建索引。',
models_embedding_saved_ok: '去執行',
models_pick_provider: '待選擇',
models_clear_confirm_title: '清除廠商憑據',
models_clear_confirm_msg: '確認清除該廠商的 API Key 與 Base URL 嗎?相關能力將不再可用。',
cancel: '取消',
save: '儲存',
ok: '確定',
knowledge_title: '知識庫', knowledge_desc: '瀏覽和探索你的知識庫',
knowledge_tab_docs: '檔案', knowledge_tab_graph: '圖譜',
knowledge_loading: '載入知識庫中...', knowledge_loading_desc: '知識頁面將顯示在這裡',
knowledge_select_hint: '選擇一個檔案檢視', knowledge_empty_hint: '暫無知識頁面',
knowledge_empty_guide: '在對話中傳送檔案、連結或主題給 Agent它會自動整理到你的知識庫中。',
knowledge_go_chat: '開始對話',
knowledge_new: '新建',
knowledge_new_category: '新建分類',
knowledge_new_document: '新建檔案',
knowledge_import_documents: '匯入檔案',
welcome_subtitle: '我可以幫你解答問題、管理電腦、創造和執行技能,並透過<br>長期記憶和知識庫不斷成長',
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: '檢視全部命令',
slash_help: '顯示命令幫助',
slash_status: '檢視執行狀態',
slash_context: '檢視對話上下文',
slash_context_clear: '清除對話上下文',
slash_skill_list: '檢視已安裝技能',
slash_skill_list_remote: '瀏覽技能廣場',
slash_skill_search: '搜尋技能',
slash_skill_install: '安裝技能 (名稱或 GitHub URL)',
slash_skill_uninstall: '解除安裝技能',
slash_skill_info: '檢視技能詳情',
slash_skill_enable: '啟用技能',
slash_skill_disable: '禁用技能',
slash_memory_dream: '手動觸發記憶蒸餾 (可指定天數, 預設3)',
slash_knowledge: '檢視知識庫統計',
slash_knowledge_list: '檢視知識庫檔案樹',
slash_knowledge_on: '開啟知識庫',
slash_knowledge_off: '關閉知識庫',
slash_config: '檢視當前設定',
slash_cancel: '中止當前正在執行的 Agent 任務',
slash_logs: '檢視最近日誌',
slash_version: '檢視版本',
input_placeholder: '輸入訊息,或輸入 / 使用指令',
config_title: '設定管理', config_desc: '管理模型和 Agent 設定',
config_model: '模型設定', config_agent: 'Agent 設定',
config_language: '語言', config_language_hint: '介面展示、命令文案、系統提示詞等使用的語言(與右上角切換同步)',
config_model_advanced: '高階設定',
config_channel: '管道設定',
config_agent_enabled: 'Agent 模式',
config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '對話中 Agent 能輸入的最大 Token 長度,超過後會智慧壓縮處理',
config_max_turns: '最大記憶輪次', config_max_turns_hint: '一問一答為一輪,超過後會智慧壓縮處理',
config_max_steps: '最大執行步數', config_max_steps_hint: '單次對話中 Agent 最多呼叫工具的次數',
config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否啟用深度思考模式',
config_self_evolution: '自主進化', config_self_evolution_hint: '會話空閒後自動覆盤,沉澱記憶、最佳化技能、處理未完成事項',
evolution_badge: '自主學習',
config_channel_type: '管道型別',
config_provider: '模型廠商', config_model_name: '模型',
config_custom_model_hint: '輸入自定義模型名稱',
config_save: '儲存', config_saved: '已儲存',
config_save_error: '儲存失敗',
config_custom_option: '自定義',
config_custom_tip: '介面需遵循 OpenAI API 協議',
config_security: '安全設定', config_password: '訪問密碼',
config_password_hint: '留空則不啟用密碼保護',
config_password_changed: '密碼已更新',
config_password_cleared: '密碼已清除',
config_password_security_warning: '⚠️ 警告:目前密碼為空且對外連接埠開放,建議重啟服務,或檢查是否調整監聽位址綁定。',
skills_title: '技能管理', skills_desc: '檢視、啟用或禁用 Agent 工具和技能', skills_hub_btn: '探索技能廣場',
skills_loading: '載入技能中...', skills_loading_desc: '技能載入後將顯示在此處',
tools_section_title: '內建工具', tools_loading: '載入工具中...',
skills_section_title: '技能', skill_enable: '啟用', skill_disable: '禁用',
skill_toggle_error: '操作失敗,請稍後再試',
memory_title: '記憶管理', memory_desc: '檢視 Agent 記憶檔案和內容',
memory_tab_files: '記憶檔案', memory_tab_dreams: '自主進化',
memory_loading: '載入記憶檔案中...', memory_loading_desc: '記憶檔案將顯示在此處',
memory_back: '返回列表',
memory_col_name: '檔名', memory_col_type: '型別', memory_col_size: '大小', memory_col_updated: '更新時間',
channels_title: '管道管理', channels_desc: '管理已接入的訊息管道',
channels_add: '接入管道', channels_disconnect: '斷開',
channels_save: '儲存設定', channels_saved: '已儲存', channels_save_error: '儲存失敗',
channels_restarted: '已儲存並重啟',
channels_connect_btn: '接入', channels_cancel: '取消',
channels_select_placeholder: '選擇要接入的管道...',
channels_empty: '暫未接入任何管道', channels_empty_desc: '點選右上角「接入管道」按鈕開始設定',
channels_disconnect_confirm: '確認斷開該管道?設定將保留但管道會停止執行。',
channels_connected: '已接入', channels_connecting: '接入中...',
weixin_scan_title: '微信掃碼登入', weixin_scan_desc: '請使用微信掃描下方二維碼',
weixin_scan_loading: '正在獲取二維碼...', weixin_scan_waiting: '等待掃碼...',
weixin_scan_scanned: '已掃碼,請在手機上確認', weixin_scan_expired: '二維碼已過期,正在重新整理...',
weixin_scan_success: '登入成功,正在啟動管道...', weixin_scan_fail: '獲取二維碼失敗',
weixin_qr_tip: '二維碼約2分鐘後過期',
wecom_scan_btn: '掃碼建立企微機器人', wecom_scan_desc: '使用企業微信掃碼,一鍵建立智慧機器人',
wecom_scan_success: '建立成功,正在啟動管道...',
wecom_scan_fail: '建立失敗',
wecom_mode_scan: '掃碼接入', wecom_mode_manual: '手動填寫',
feishu_scan_btn: '一鍵建立飛書應用',
feishu_scan_desc: '使用飛書 App 掃碼,自動建立應用並預置全部許可權與事件訂閱',
feishu_scan_replace_desc: '使用飛書 App 掃碼建立新機器人,將覆蓋當前的 App ID / Secret',
feishu_scan_loading: '正在向飛書申請二維碼...',
feishu_scan_waiting: '等待掃碼...',
feishu_scan_tip: '二維碼 10 分鐘內有效,僅供一次掃描',
feishu_scan_open_link: '或點選此處在瀏覽器中開啟',
feishu_scan_success: '應用建立成功,正在啟動管道...',
feishu_scan_expired: '二維碼已過期,請重試',
feishu_scan_denied: '已取消授權',
feishu_scan_fail: '建立失敗',
feishu_scan_retry: '重試',
feishu_mode_scan: '掃碼建立', feishu_mode_manual: '手動填寫',
tasks_title: '定時任務', tasks_desc: '檢視和管理定時任務',
tasks_coming: '即將推出', tasks_coming_desc: '定時任務管理功能即將在此提供',
task_add_btn: '新增任務',
task_edit_title: '編輯定時任務',
task_add_title: '新增定時任務',
task_name: '任務名稱',
task_enabled: '啟用任務',
task_schedule_type: '排程型別',
task_schedule_cron: 'Cron 表示式',
task_schedule_interval: '固定間隔',
task_schedule_once: '一次性任務',
task_cron_expression: 'Cron 表示式',
task_cron_hint: '格式: 分 時 日 月 周,例如 "0 9 * * *" 表示每天 9:00',
task_interval_seconds: '間隔秒數',
task_interval_hint: '最小 60 秒,例如 3600 表示每小時執行一次',
task_once_time: '執行時間',
task_action_type: '動作型別',
task_action_send_message: '傳送訊息',
task_action_agent_task: 'AI 任務',
task_channel_type: '管道型別',
task_channel_hint: '選擇定時訊息傳送的管道',
task_message_content: '訊息內容',
task_task_description: '任務描述',
task_delete_btn: '刪除任務',
task_delete_confirm_title: '刪除定時任務',
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
new_chat: '新對話',
session_history: '歷史會話',
today: '今天', yesterday: '昨天', earlier: '更早',
delete_session_confirm: '確認刪除該會話?所有訊息將被清除。',
delete_session_title: '刪除會話',
rename_session: '重新命名',
delete_message_confirm: '確認刪除這條訊息?',
delete_message_title: '刪除訊息',
edit_disabled_reply_active: '正在生成回覆,暫時無法編輯。',
delete_disabled_reply_active: '正在生成回覆,暫時無法刪除。',
untitled_session: '新對話',
context_cleared: '— 以上內容已從上下文中移除 —',
tip_new_chat: '新建對話',
tip_clear_context: '清除上下文',
tip_attach: '新增附件',
attach_menu_file: '上傳檔案',
mic_idle_title: '點選錄音 / 再按一次結束',
mic_recording_title: '錄音中,再次點選結束',
mic_busy_title: '識別中…',
mic_permission_denied: '無法訪問麥克風,請檢查瀏覽器許可權',
mic_too_short: '錄音太短,請重試',
mic_error: '語音識別失敗',
speak_msg: '朗讀這段回覆',
voice_reply_mode_label: '語音回覆策略',
voice_reply_off: '關閉',
voice_reply_if_voice: '僅語音問/語音答',
voice_reply_always: '總是語音回覆',
attach_menu_folder: '上傳資料夾',
confirm_yes: '確認',
confirm_cancel: '取消',
error_send: '傳送失敗,請稍後再試。', error_timeout: '請求超時,請再試一次。',
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗時',
edit_message: '編輯訊息',
regenerate_response: '重新生成',
edit_save: '儲存併傳送',
edit_cancel: '取消',
logout: '登出',
},
en: {
console: 'Console',
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
@@ -334,7 +586,10 @@ const I18N = {
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.',
knowledge_go_chat: 'Start a conversation',
knowledge_new: 'New',
knowledge_new_category: 'New category',
knowledge_new_document: 'New document',
knowledge_import_documents: 'Import documents',
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory and a personal knowledge base.',
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
@@ -385,8 +640,9 @@ const I18N = {
config_custom_tip: 'API must follow OpenAI protocol.',
config_security: 'Security', config_password: 'Password',
config_password_hint: 'Leave empty to disable password protection',
config_password_changed: 'Password updated, please re-login',
config_password_changed: 'Password updated',
config_password_cleared: 'Password cleared',
config_password_security_warning: '⚠️ Warning: Password is now empty and the port is exposed. Consider restarting the service or adjusting the listening address binding.',
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent tools and skills', skills_hub_btn: 'Skill Hub',
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...',
@@ -492,6 +748,7 @@ const I18N = {
regenerate_response: 'Regenerate',
edit_save: 'Save and send',
edit_cancel: 'Cancel',
logout: 'Logout',
}
};
@@ -505,6 +762,9 @@ let currentLang = (typeof window.__cowResolveLang__ === 'function')
if (!raw) return '';
const v = String(raw).trim().toLowerCase();
if (v === 'auto') return '';
// Handle Traditional Chinese variants first (more specific)
if (v === 'zh-hant' || v.startsWith('zh-hant-') || v === 'zh-tw' || v === 'zh-hk') return 'zh-Hant';
// Then Simplified Chinese
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
@@ -538,12 +798,32 @@ function applyI18n() {
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.dataset['i18nPlaceholder']);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset['i18nTitle']);
});
document.querySelectorAll('[data-tip-key]').forEach(el => {
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
});
installCfgTipPortal();
// Clear any status messages when language changes
document.querySelectorAll('[id$="-status"]').forEach(el => {
el.classList.add('opacity-0');
});
const langLabel = document.getElementById('lang-label');
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
if (langLabel) {
if (currentLang === 'zh-Hant') langLabel.textContent = '繁体';
else if (currentLang === 'zh') langLabel.textContent = '简体';
else langLabel.textContent = 'EN';
}
// Highlight the active option in the header language dropdown menu.
document.querySelectorAll('#lang-menu .lang-menu-item').forEach(item => {
const active = item.dataset.lang === currentLang;
item.classList.toggle('text-blue-600', active);
item.classList.toggle('dark:text-blue-400', active);
item.classList.toggle('font-medium', active);
});
// Point the docs link to the locale-specific documentation site.
const docsLink = document.getElementById('docs-link');
if (docsLink) docsLink.href = currentLang === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai';
@@ -553,7 +833,7 @@ function applyI18n() {
// persists the user choice locally, re-renders the UI, and binds the choice to
// the backend `cow_lang` config so logs / agent replies / CLI follow suit.
function setLanguage(lang) {
const next = (lang === 'en') ? 'en' : 'zh';
const next = (lang === 'en' || lang === 'zh' || lang === 'zh-Hant') ? lang : 'zh';
if (next === currentLang) {
// Still persist + sync in case storage/backend drifted from the UI.
syncLanguageToBackend(next);
@@ -563,48 +843,90 @@ function setLanguage(lang) {
localStorage.setItem('cow_lang', currentLang);
applyI18n();
_applyInputTooltips();
// Re-render views whose DOM is built in JS (data-i18n alone does not
// cover strings interpolated via t() into innerHTML).
try { rerenderDynamicViews(); } catch (e) {}
// Keep the language switch button and config selector visually in sync.
try { updateLangControls(); } catch (e) {}
syncLanguageToBackend(currentLang);
// Sync language choice to backend first, then trigger dynamic views reload
// to avoid race conditions on API endpoints.
syncLanguageToBackend(currentLang, () => {
try { rerenderDynamicViews(); } catch (e) {}
});
}
// Persist the language to the backend `cow_lang` config (best-effort; the UI
// has already switched locally, so a network failure is non-blocking).
function syncLanguageToBackend(lang) {
function syncLanguageToBackend(lang, callback) {
try {
fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { cow_lang: lang } })
}).catch(() => {});
} catch (e) {}
})
.then(() => { if (callback) callback(); })
.catch(() => { if (callback) callback(); });
} catch (e) {
if (callback) callback();
}
}
// Reflect the current language on both the top-right toggle and the config
// selector (if present), so the two entry points stay synchronized.
function updateLangControls() {
const langLabel = document.getElementById('lang-label');
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
if (langLabel) {
if (currentLang === 'zh-Hant') langLabel.textContent = '繁体';
else if (currentLang === 'zh') langLabel.textContent = '简体';
else langLabel.textContent = 'EN';
}
// Highlight the active option in the header language dropdown menu.
document.querySelectorAll('#lang-menu .lang-menu-item').forEach(item => {
const active = item.dataset.lang === currentLang;
item.classList.toggle('text-blue-600', active);
item.classList.toggle('dark:text-blue-400', active);
item.classList.toggle('font-medium', active);
});
// The config language picker is the custom .cfg-dropdown component. Only
// sync it once it has been initialized (i.e. the config panel was opened).
const sel = document.getElementById('cfg-lang-select');
if (sel && sel._ddValue !== undefined && sel._ddValue !== currentLang) {
sel._ddValue = currentLang;
const textEl = sel.querySelector('.cfg-dropdown-text');
if (textEl) textEl.textContent = currentLang === 'zh' ? '中文' : 'English';
if (textEl) {
if (currentLang === 'zh-Hant') textEl.textContent = '繁體中文';
else if (currentLang === 'zh') textEl.textContent = '简体中文';
else textEl.textContent = 'English';
}
sel.querySelectorAll('.cfg-dropdown-item').forEach(i => {
i.classList.toggle('active', i.dataset.value === currentLang);
});
}
}
function toggleLanguage() {
setLanguage(currentLang === 'zh' ? 'en' : 'zh');
// Toggle the header language dropdown menu open/closed.
function toggleLangMenu(event) {
if (event) event.stopPropagation();
const menu = document.getElementById('lang-menu');
if (menu) menu.classList.toggle('hidden');
}
// Pick a language from the dropdown, then close the menu.
function selectLanguage(lang) {
const menu = document.getElementById('lang-menu');
if (menu) menu.classList.add('hidden');
setLanguage(lang);
}
window.toggleLangMenu = toggleLangMenu;
window.selectLanguage = selectLanguage;
// Close the language menu when clicking outside of it.
document.addEventListener('click', (e) => {
const selector = document.getElementById('lang-selector');
const menu = document.getElementById('lang-menu');
if (menu && !menu.classList.contains('hidden') && selector && !selector.contains(e.target)) {
menu.classList.add('hidden');
}
});
// Refresh JS-rendered views after a language switch. Each branch uses the
// lightweight in-memory re-render path (no extra network round-trips).
function rerenderDynamicViews() {
@@ -617,6 +939,19 @@ function rerenderDynamicViews() {
tasksLoaded = false;
loadTasksView();
}
// Reload skills and tools after language switch
if (currentView === 'skills') {
toolsLoaded = false;
loadSkillsView();
}
// Reload channels after language switch
if (currentView === 'channels') {
loadChannelsView();
}
// Reload config after language switch
if (currentView === 'config') {
loadConfigView();
}
}
// Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are
@@ -724,6 +1059,12 @@ function navigateTo(viewId) {
document.getElementById('breadcrumb-page').textContent = t(meta.page);
document.getElementById('breadcrumb-page').dataset.i18n = meta.page;
currentView = viewId;
// Clear status messages when navigating away
document.querySelectorAll('[id$="-status"]').forEach(el => {
el.classList.add('opacity-0');
});
if (window.innerWidth < 1024) closeSidebar();
}
@@ -4260,7 +4601,7 @@ function initConfigView(data) {
if (langSel) {
initDropdown(
langSel,
[{ value: 'zh', label: '中文' }, { value: 'en', label: 'English' }],
[{ value: 'zh', label: '简体中文' }, { value: 'zh-Hant', label: '繁體中文' }, { value: 'en', label: 'English' }],
currentLang,
(val) => setLanguage(val)
);
@@ -4433,7 +4774,10 @@ function showStatus(elId, msgKey, isError) {
el.classList.toggle('text-red-500', !!isError);
el.classList.toggle('text-primary-500', !isError);
el.classList.remove('opacity-0');
setTimeout(() => el.classList.add('opacity-0'), 2500);
// Warning messages (errors) should stay visible, success messages auto-hide
if (!isError) {
setTimeout(() => el.classList.add('opacity-0'), 2500);
}
}
function saveModelConfig() {
@@ -4546,15 +4890,33 @@ function savePasswordConfig() {
})
.then(r => r.json())
.then(data => {
console.log('[Password Config] Response:', data); // Debug
if (data.status === 'success') {
if (newPwd) {
showStatus('cfg-password-status', 'config_password_changed', false);
setTimeout(() => { window.location.reload(); }, 1500);
// Mark as masked so user needs to re-enter to change again
input.dataset.masked = '1';
input.dataset.maskedVal = newPwd;
input.value = '••••••••';
input.classList.add('cfg-key-masked');
// Show logout button since password is now enabled
const logoutBtn = document.getElementById('logout-btn-header');
if (logoutBtn) logoutBtn.classList.remove('hidden');
} else {
input.dataset.masked = '';
input.dataset.maskedVal = '';
input.classList.remove('cfg-key-masked');
showStatus('cfg-password-status', 'config_password_cleared', false);
// Show security warning if password was cleared with public host
if (data.warning === 'password_cleared_with_public_host') {
showStatus('cfg-password-status', 'config_password_security_warning', true);
} else {
showStatus('cfg-password-status', 'config_password_cleared', false);
}
const logoutBtn = document.getElementById('logout-btn-header');
if (logoutBtn) logoutBtn.classList.add('hidden');
}
} else {
showStatus('cfg-password-status', 'config_save_error', true);
@@ -7763,8 +8125,11 @@ let _knowledgeTreeData = [];
let _knowledgeRootFiles = [];
let _knowledgeCurrentFile = null;
let _knowledgeGraphLoaded = false;
const KNOWLEDGE_IMPORT_MAX_FILES = 100;
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024;
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024;
function loadKnowledgeView() {
function loadKnowledgeView(targetPath) {
// Reset to docs tab
switchKnowledgeTab('docs');
_knowledgeGraphLoaded = false;
@@ -7772,6 +8137,7 @@ function loadKnowledgeView() {
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
if (data.status !== 'success') return;
initKnowledgeImportDropZone();
const emptyEl = document.getElementById('knowledge-empty');
const docsPanel = document.getElementById('knowledge-panel-docs');
@@ -7800,6 +8166,15 @@ function loadKnowledgeView() {
renderKnowledgeTree(tree, rootFiles);
// Prefer opening the just created/imported file; ensure its group is
// expanded so the active item is visible in the tree.
const targetTitle = targetPath ? _findKnowledgeFileTitle(targetPath) : null;
if (targetTitle !== null) {
_expandKnowledgeGroupFor(targetPath);
openKnowledgeFile(targetPath, targetTitle);
return;
}
// Auto-select the first file (desktop only)
if (window.innerWidth >= 768) {
const firstFile = rootFiles.length > 0 ? rootFiles[0] : null;
@@ -7817,6 +8192,36 @@ function loadKnowledgeView() {
}).catch(() => {});
}
// Find a file's display title by its relative path within the knowledge tree.
// Returns the title, or null when the path is not present.
function _findKnowledgeFileTitle(path) {
if (!path) return null;
const rootHit = (_knowledgeRootFiles || []).find(f => f.name === path);
if (rootHit) return rootHit.title || rootHit.name;
const walk = (groups, parentPath) => {
for (const group of groups || []) {
const groupPath = parentPath ? `${parentPath}/${group.dir}` : group.dir;
const hit = (group.files || []).find(f => `${groupPath}/${f.name}` === path);
if (hit) return hit.title || hit.name;
const childHit = walk(group.children, groupPath);
if (childHit !== null) return childHit;
}
return null;
};
return walk(_knowledgeTreeData, '');
}
// Open every ancestor group of the given file path so it is visible.
function _expandKnowledgeGroupFor(path) {
if (!path || !path.includes('/')) return;
const target = document.querySelector(`.knowledge-tree-file[data-path="${CSS.escape(path)}"]`);
let node = target ? target.closest('.knowledge-tree-group') : null;
while (node) {
node.classList.add('open');
node = node.parentElement ? node.parentElement.closest('.knowledge-tree-group') : null;
}
}
function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
const container = document.getElementById('knowledge-tree');
container.innerHTML = '';
@@ -7898,7 +8303,7 @@ function _knowledgeCategoryActions(path) {
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}</span>`;
}
async function dispatchKnowledgeAction(action, payload) {
async function dispatchKnowledgeAction(action, payload, openPathResolver) {
_setKnowledgeStatus(currentLang === 'zh' ? '处理中...' : 'Working...', false, true);
try {
const response = await fetch('/api/knowledge/action', {
@@ -7913,7 +8318,9 @@ async function dispatchKnowledgeAction(action, payload) {
return null;
}
_setKnowledgeStatus(_knowledgeResultMessage(action, result.payload), false);
loadKnowledgeView();
// Optionally auto-open the affected file after the tree refreshes.
const openPath = openPathResolver ? openPathResolver(result.payload) : null;
loadKnowledgeView(openPath || undefined);
return result.payload;
} catch (error) {
_setKnowledgeStatus(currentLang === 'zh' ? '请求失败,请稍后重试' : 'Request failed, please try again', true);
@@ -7933,14 +8340,18 @@ function _setKnowledgeStatus(message, isError, persistent) {
function _knowledgeResultMessage(action, payload) {
if (currentLang !== 'zh') {
return action === 'create_category' ? 'Category created' :
action === 'create_document' ? 'Document created' :
action === 'rename_category' ? 'Category renamed' :
action === 'delete_category' ? 'Category deleted' :
action === 'import_documents' ? `${payload?.imported || 0} imported · ${payload?.skipped || 0} skipped · ${payload?.failed || 0} failed` :
action === 'move_documents' ? `${payload?.moved || 0} document moved` :
`${payload?.deleted || 0} document deleted`;
}
return action === 'create_category' ? '分类已创建' :
action === 'create_document' ? '文档已创建' :
action === 'rename_category' ? '分类已重命名' :
action === 'delete_category' ? '分类已删除' :
action === 'import_documents' ? `导入 ${payload?.imported || 0} 个,跳过 ${payload?.skipped || 0} 个,失败 ${payload?.failed || 0}` :
action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` :
`已删除 ${payload?.deleted || 0} 个文档`;
}
@@ -7956,8 +8367,15 @@ function _knowledgeCategoryPaths(groups, parent = '') {
function openKnowledgeDialog(options) {
const overlay = document.getElementById('knowledge-dialog-overlay');
const card = document.getElementById('knowledge-dialog-card');
const input = document.getElementById('knowledge-dialog-input');
const select = document.getElementById('knowledge-dialog-select');
const textarea = document.getElementById('knowledge-dialog-textarea');
const documentForm = document.getElementById('knowledge-document-form');
const documentFilename = document.getElementById('knowledge-document-filename');
const documentContent = document.getElementById('knowledge-document-content');
const templateBtn = document.getElementById('knowledge-document-template');
const documentPathPreview = document.getElementById('knowledge-document-path-preview');
const submit = document.getElementById('knowledge-dialog-submit');
const cancel = document.getElementById('knowledge-dialog-cancel');
document.getElementById('knowledge-dialog-title').textContent = options.title;
@@ -7966,11 +8384,36 @@ function openKnowledgeDialog(options) {
document.getElementById('knowledge-dialog-hint').textContent = options.hint || '';
document.getElementById('knowledge-dialog-error').classList.add('hidden');
document.getElementById('knowledge-dialog-icon').className = `fas ${options.icon || 'fa-folder'} text-emerald-500`;
input.classList.toggle('hidden', options.type === 'select');
card.classList.toggle('knowledge-document-dialog', options.type === 'document');
input.classList.toggle('hidden', options.type === 'select' || options.type === 'textarea' || options.type === 'document');
select.classList.toggle('hidden', options.type !== 'select');
textarea.classList.toggle('hidden', options.type !== 'textarea');
documentForm.classList.toggle('hidden', options.type !== 'document');
input.value = options.value || '';
textarea.value = options.value || '';
documentFilename.value = options.filename || '';
documentContent.value = options.content || '';
document.getElementById('knowledge-document-category-label').textContent = currentLang === 'zh' ? '目标分类' : 'Destination category';
documentPathPreview.textContent = options.category
? `knowledge/${options.category}/`
: 'knowledge/';
documentFilename.oninput = null;
document.getElementById('knowledge-document-filename-label').textContent = currentLang === 'zh' ? '文件名' : 'Filename';
document.getElementById('knowledge-document-content-label').textContent = currentLang === 'zh' ? 'Markdown 内容' : 'Markdown content';
templateBtn.textContent = currentLang === 'zh' ? '插入模板' : 'Insert template';
templateBtn.onclick = () => {
if (documentContent.value.trim()) return;
const title = (documentFilename.value || 'untitled').replace(/\.md$/i, '');
documentContent.value = currentLang === 'zh'
? `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`
: `# ${title}\n\n## Summary\n\n\n## Key points\n\n- \n\n## References\n\n`;
documentContent.focus();
};
if (options.type === 'select') {
select.innerHTML = (options.choices || []).map(value => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
// Use the shared custom dropdown component instead of a native
// <select> so the arrow / menu match the rest of the console.
const ddOptions = (options.choices || []).map(value => ({ value, label: value }));
initDropdown(select, ddOptions, (options.choices || [])[0] || '', null);
}
submit.textContent = currentLang === 'zh' ? '确定' : 'Confirm';
cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel';
@@ -7978,7 +8421,13 @@ function openKnowledgeDialog(options) {
const close = () => overlay.classList.add('hidden');
const submitAction = async () => {
const value = (options.type === 'select' ? select.value : input.value).trim();
const rawValue = options.type === 'select' ? getDropdownValue(select) :
(options.type === 'textarea' ? textarea.value :
(options.type === 'document' ? {
filename: documentFilename.value.trim(),
content: documentContent.value,
} : input.value));
const value = options.type === 'textarea' || options.type === 'document' ? rawValue : rawValue.trim();
const error = options.validate ? options.validate(value) : (!value ? (currentLang === 'zh' ? '此项不能为空' : 'This field is required') : '');
if (error) {
const errorEl = document.getElementById('knowledge-dialog-error');
@@ -7996,7 +8445,31 @@ function openKnowledgeDialog(options) {
overlay.onclick = event => { if (event.target === overlay) close(); };
input.onkeydown = event => { if (event.key === 'Enter') submitAction(); };
overlay.classList.remove('hidden');
setTimeout(() => (options.type === 'select' ? select : input).focus(), 0);
setTimeout(() => (options.type === 'select' ? select : (options.type === 'textarea' ? textarea : (options.type === 'document' ? documentFilename : input))).focus(), 0);
}
function closeKnowledgeNewMenu() {
const list = document.getElementById('knowledge-new-menu-list');
if (list) list.classList.add('hidden');
document.removeEventListener('click', _knowledgeNewMenuOutside, true);
}
function _knowledgeNewMenuOutside(event) {
const menu = document.getElementById('knowledge-new-menu');
if (menu && !menu.contains(event.target)) closeKnowledgeNewMenu();
}
function toggleKnowledgeNewMenu(event) {
if (event) event.stopPropagation();
const list = document.getElementById('knowledge-new-menu-list');
if (!list) return;
const willOpen = list.classList.contains('hidden');
list.classList.toggle('hidden');
if (willOpen) {
document.addEventListener('click', _knowledgeNewMenuOutside, true);
} else {
document.removeEventListener('click', _knowledgeNewMenuOutside, true);
}
}
function createKnowledgeCategory() {
@@ -8010,6 +8483,166 @@ function createKnowledgeCategory() {
});
}
function createKnowledgeDocument() {
const categories = _knowledgeCategoryPaths(_knowledgeTreeData);
if (!categories.length) {
_setKnowledgeStatus(currentLang === 'zh' ? '请先创建分类' : 'Create a category first', true);
return;
}
openKnowledgeDialog({
title: currentLang === 'zh' ? '新建文档' : 'New document',
subtitle: currentLang === 'zh' ? '先选择分类,然后输入文件名' : 'Choose a category, then enter a filename',
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
type: 'select',
choices: categories,
icon: 'fa-file-circle-plus',
onSubmit: category => {
openKnowledgeDocumentEditor(category);
return null;
},
});
}
function openKnowledgeDocumentEditor(category) {
openKnowledgeDialog({
title: currentLang === 'zh' ? '新建文档' : 'New document',
subtitle: currentLang === 'zh' ? `保存到 ${category}` : `Save to ${category}`,
label: '',
hint: currentLang === 'zh' ? '文件名可省略 .md 后缀;保存后会自动同步索引。' : 'The .md suffix is optional. Index sync runs after saving.',
type: 'document',
category,
filename: '',
content: '',
icon: 'fa-file-circle-plus',
validate: value => {
if (!value.filename) return currentLang === 'zh' ? '文件名不能为空' : 'Filename is required';
if (/\.[^.]+$/i.test(value.filename) && !/\.md$/i.test(value.filename)) {
return currentLang === 'zh' ? '新建文档仅支持 .md 文件名' : 'New documents must be .md files';
}
if (!value.content.trim()) return currentLang === 'zh' ? '内容不能为空' : 'Content is required';
if (new Blob([value.content]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
return currentLang === 'zh' ? '内容不能超过 10MB' : 'Content cannot exceed 10MB';
}
return '';
},
onSubmit: value => {
const safeName = value.filename.endsWith('.md') ? value.filename : `${value.filename}.md`;
return dispatchKnowledgeAction('create_document', {
path: `${category}/${safeName}`,
content: value.content,
overwrite: false,
}, payload => payload?.path || `${category}/${safeName}`);
},
});
}
function selectKnowledgeImportFiles() {
const input = document.getElementById('knowledge-import-input');
input.value = '';
input.onchange = () => {
if (input.files && input.files.length) openKnowledgeImportDialog(Array.from(input.files));
};
input.click();
}
function openKnowledgeImportDialog(files) {
const validationError = validateKnowledgeImportFiles(files);
if (validationError) {
_setKnowledgeStatus(validationError, true);
return;
}
const choices = _knowledgeCategoryPaths(_knowledgeTreeData);
openKnowledgeDialog({
title: currentLang === 'zh' ? '导入文档' : 'Import documents',
subtitle: currentLang === 'zh' ? `已选择 ${files.length} 个文件` : `${files.length} file(s) selected`,
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
hint: choices.length ? (currentLang === 'zh' ? '支持 Markdown 和 TXTTXT 会转成 Markdown 文档' : 'Markdown and TXT are supported. TXT is converted to Markdown.') :
(currentLang === 'zh' ? '请先创建一个分类' : 'Create a category first'),
type: 'select',
choices,
icon: 'fa-file-arrow-up',
onSubmit: target => importKnowledgeDocuments(files, target),
});
}
async function importKnowledgeDocuments(files, targetCategory) {
const validationError = validateKnowledgeImportFiles(files);
if (validationError) {
_setKnowledgeStatus(validationError, true);
return null;
}
const supported = files.filter(file => /\.(md|txt)$/i.test(file.name || ''));
if (!supported.length) {
_setKnowledgeStatus(currentLang === 'zh' ? '请选择 .md 或 .txt 文件' : 'Choose .md or .txt files', true);
return null;
}
const formData = new FormData();
formData.append('target_category', targetCategory);
formData.append('conflict_strategy', 'rename');
supported.forEach(file => formData.append('files', file, file.name));
_setKnowledgeStatus(currentLang === 'zh' ? '正在导入...' : 'Importing...', false, true);
try {
const response = await fetch('/api/knowledge/import', { method: 'POST', body: formData });
const result = await response.json();
if (result.status !== 'success') {
_setKnowledgeStatus(result.message || (currentLang === 'zh' ? '导入失败' : 'Import failed'), true);
loadKnowledgeView();
return null;
}
_setKnowledgeStatus(_knowledgeResultMessage('import_documents', result.payload), false);
// Auto-open the first successfully imported document.
const firstImported = (result.payload?.results || []).find(item => item.status === 'imported');
loadKnowledgeView(firstImported ? firstImported.path : undefined);
return result.payload;
} catch (error) {
_setKnowledgeStatus(currentLang === 'zh' ? '导入请求失败' : 'Import request failed', true);
return null;
}
}
function validateKnowledgeImportFiles(files) {
if (!files || !files.length) return currentLang === 'zh' ? '请选择文件' : 'Choose files';
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
return currentLang === 'zh' ? `一次最多导入 ${KNOWLEDGE_IMPORT_MAX_FILES} 个文件` : `Import at most ${KNOWLEDGE_IMPORT_MAX_FILES} files at a time`;
}
let total = 0;
for (const file of files) {
total += file.size || 0;
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
return currentLang === 'zh' ? `${file.name} 超过 10MB` : `${file.name} exceeds 10MB`;
}
}
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) {
return currentLang === 'zh' ? '单次导入总大小不能超过 200MB' : 'Total import size cannot exceed 200MB';
}
return '';
}
let _knowledgeImportDropReady = false;
function initKnowledgeImportDropZone() {
if (_knowledgeImportDropReady) return;
const panel = document.getElementById('knowledge-panel-docs');
if (!panel) return;
_knowledgeImportDropReady = true;
['dragenter', 'dragover'].forEach(name => {
panel.addEventListener(name, event => {
if (!event.dataTransfer || !event.dataTransfer.types.includes('Files')) return;
event.preventDefault();
panel.classList.add('knowledge-import-drag-over');
});
});
['dragleave', 'drop'].forEach(name => {
panel.addEventListener(name, event => {
if (event.type === 'drop') {
event.preventDefault();
const files = Array.from(event.dataTransfer?.files || []);
if (files.length) openKnowledgeImportDialog(files);
}
panel.classList.remove('knowledge-import-drag-over');
});
});
}
function renameKnowledgeCategory(path) {
openKnowledgeDialog({
title: currentLang === 'zh' ? '重命名分类' : 'Rename category',
@@ -8451,6 +9084,9 @@ function showLoginScreen() {
if (currentLang === 'en') {
subtitle.textContent = 'Enter password to access the console';
loginBtn.textContent = 'Login';
} else if (currentLang === 'zh-Hant') {
subtitle.textContent = '請輸入密碼以存取控制台';
loginBtn.textContent = '登入';
} else {
subtitle.textContent = '请输入密码以访问控制台';
loginBtn.textContent = '登录';
@@ -8477,16 +9113,30 @@ function showLoginScreen() {
if (data.status === 'success') {
overlay.classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
const logoutBtn = document.getElementById('logout-btn-header');
if (logoutBtn) logoutBtn.classList.remove('hidden');
initApp();
} else {
errEl.textContent = currentLang === 'zh' ? '密码错误' : 'Wrong password';
if (currentLang === 'zh-Hant') {
errEl.textContent = '密碼錯誤';
} else if (currentLang === 'zh') {
errEl.textContent = '密码错误';
} else {
errEl.textContent = 'Wrong password';
}
errEl.classList.remove('hidden');
pwdInput.value = '';
pwdInput.focus();
}
btn.disabled = false;
}).catch(() => {
errEl.textContent = currentLang === 'zh' ? '网络错误,请重试' : 'Network error, please retry';
if (currentLang === 'zh-Hant') {
errEl.textContent = '網路錯誤,請重試';
} else if (currentLang === 'zh') {
errEl.textContent = '网络错误,请重试';
} else {
errEl.textContent = 'Network error, please retry';
}
errEl.classList.remove('hidden');
btn.disabled = false;
});
@@ -8494,6 +9144,19 @@ function showLoginScreen() {
};
}
function handleLogout() {
fetch('/auth/logout', {
method: 'POST'
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
window.location.reload();
}
}).catch(() => {
window.location.reload();
});
}
window.handleLogout = handleLogout;
// Intercept 401 responses globally to show login screen on session expiry
const _originalFetch = window.fetch;
window.fetch = function(...args) {
@@ -8539,6 +9202,10 @@ fetch('/auth/check').then(r => r.json()).then(data => {
if (data.auth_required && !data.authenticated) {
showLoginScreen();
} else {
if (data.auth_required) {
const logoutBtn = document.getElementById('logout-btn-header');
if (logoutBtn) logoutBtn.classList.remove('hidden');
}
initApp();
}
}).catch(() => {

View File

@@ -181,6 +181,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
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():
"""Return unprocessed multipart form data when web.py exposes rawinput."""
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
@@ -240,7 +263,13 @@ class WebChannel(ChatChannel):
self.session_queues = {} # session_id -> Queue (fallback polling)
self.request_to_session = {} # request_id -> session_id
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._sse_janitor_started = False
def _generate_msg_id(self):
"""生成唯一的消息ID"""
@@ -527,14 +556,29 @@ class WebChannel(ChatChannel):
file_path = data.get("path", "")
file_name = data.get("file_name", os.path.basename(file_path))
file_type = data.get("file_type", "file")
from urllib.parse import quote
web_url = f"/api/file?path={quote(file_path)}"
# Remote URLs are passed through as-is; local files are served
# 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"
q.put({
payload = {
"type": "image" if is_image else "file",
"content": web_url,
"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
@@ -865,6 +909,7 @@ class WebChannel(ChatChannel):
if use_sse:
self.sse_queues[request_id] = Queue()
self.sse_last_active[request_id] = time.time()
trigger_prefixs = conf().get("single_chat_prefix", [""])
if check_prefix(prompt, trigger_prefixs) is None:
@@ -879,8 +924,7 @@ class WebChannel(ChatChannel):
if context is None:
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
if request_id in self.sse_queues:
del self.sse_queues[request_id]
self._drop_sse_request(request_id)
return json.dumps({"status": "error", "message": "Message was filtered"})
context["session_id"] = session_id
@@ -903,6 +947,60 @@ class WebChannel(ChatChannel):
logger.error(f"Error processing message: {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):
"""
SSE generator for a given request_id.
@@ -927,6 +1025,10 @@ class WebChannel(ChatChannel):
try:
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:
item = q.get(timeout=1)
except Empty:
@@ -955,13 +1057,21 @@ class WebChannel(ChatChannel):
# voice_attach payload through to the browser.
post_done = True
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:
# Only drop the queue once the reply is actually complete. If the
# client disconnected early (e.g. switched sessions and will
# re-attach with the same request_id), keep the queue so the new
# connection can resume reading the remaining events.
# Drop the queue once the reply is actually complete or the idle
# deadline has passed. Early client disconnects are handled by the
# GeneratorExit branch above and the background janitor.
if post_done or time.time() >= deadline:
self.sse_queues.pop(request_id, None)
self._drop_sse_request(request_id)
def cancel_request(self):
"""
@@ -1062,7 +1172,10 @@ class WebChannel(ChatChannel):
def startup(self):
configured_host = conf().get("web_host", "")
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", "::")
self._cleanup_stale_voice_recordings()
@@ -1162,6 +1275,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
@@ -1198,6 +1312,8 @@ class WebChannel(ChatChannel):
server.requests.min = 20
server.requests.max = 80
self._http_server = server
# Reclaim orphaned SSE queues so disconnected clients don't leak fds.
self._start_sse_janitor()
try:
server.start()
except (KeyboardInterrupt, SystemExit):
@@ -1481,14 +1597,14 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
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
# default) since it is often unavailable due to policy restrictions.
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
# from this web console list for now.
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
const.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.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,
@@ -1528,7 +1644,7 @@ class ConfigHandler:
"api_base_key": "claude_api_base",
"api_base_default": "https://api.anthropic.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
}),
("gemini", {
"label": "Gemini",
@@ -1568,7 +1684,7 @@ class ConfigHandler:
"api_base_key": "ark_base_url",
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
"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", {
"label": "Kimi",
@@ -1738,9 +1854,13 @@ class ConfigHandler:
return json.dumps({"status": "error", "message": "no valid keys to update"})
config_path = os.path.join(get_data_root(), "config.json")
old_password = "" # Store old password before update
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
# Capture old password before updating
if "web_password" in applied:
old_password = file_cfg.get("web_password", "")
else:
file_cfg = {}
file_cfg.update(applied)
@@ -1758,6 +1878,26 @@ class ConfigHandler:
except Exception as 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.
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
# even after the user switches bot_type / use_linkai / model in UI.
@@ -1770,7 +1910,7 @@ class ConfigHandler:
except Exception as 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:
logger.error(f"Error updating config: {e}")
return json.dumps({"status": "error", "message": str(e)})
@@ -2102,10 +2242,10 @@ class ModelsHandler:
const.GPT_41_MINI,
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],
"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_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"qianfan": [const.ERNIE_45_TURBO_VL],
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
@@ -2126,9 +2266,9 @@ class ModelsHandler:
const.GPT_41_MINI,
const.GPT_54_MINI,
const.QWEN37_PLUS,
const.DOUBAO_SEED_2_PRO,
const.DOUBAO_SEED_2_1_PRO,
const.KIMI_K2_6,
const.CLAUDE_4_6_SONNET,
const.CLAUDE_SONNET_5,
const.GEMINI_31_FLASH_LITE_PRE,
],
# Custom OpenAI-compatible providers have no preset list — model
@@ -2357,7 +2497,7 @@ class ModelsHandler:
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
("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),
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
@@ -3551,11 +3691,13 @@ class ChannelsHandler:
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from common import i18n
local_config = conf()
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 = []
is_hant = i18n.get_language() == i18n.ZH_HANT
for ch_name, ch_def in self.CHANNEL_DEFS.items():
if desktop_mode and ch_name == "feishu":
continue
@@ -3566,16 +3708,32 @@ class ChannelsHandler:
display_val = self._mask_secret(str(raw_val))
else:
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({
"key": f["key"],
"label": f["label"],
"label": label_val,
"type": f["type"],
"value": display_val,
"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 = {
"name": ch_name,
"label": ch_def["label"],
"label": label_val,
"icon": ch_def["icon"],
"color": ch_def["color"],
"active": ch_name in active_channels,
@@ -4132,16 +4290,26 @@ class ToolsHandler:
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.tools.tool_manager import ToolManager
from common import i18n
tm = ToolManager()
if not tm.tool_classes:
tm.load_tools()
tools = []
lang = i18n.get_language()
for name, cls in tm.tool_classes.items():
try:
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({
"name": name,
"description": instance.description,
"description": desc,
})
except Exception:
tools.append({"name": name, "description": ""})
@@ -4158,10 +4326,17 @@ class SkillsHandler:
try:
from agent.skills.service import SkillService
from agent.skills.manager import SkillManager
from common import i18n
workspace_root = _get_workspace_root()
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
service = SkillService(manager)
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)
except Exception as e:
logger.error(f"[WebChannel] Skills API error: {e}")
@@ -4731,6 +4906,71 @@ class KnowledgeActionHandler:
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:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')

View File

@@ -1 +1 @@
2.1.1
2.1.3

View File

@@ -37,6 +37,7 @@ CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
CLAUDE_SONNET_5 = "claude-sonnet-5" # Claude Sonnet 5 - default flagship model for Claude
# Gemini (Google)
GEMINI_PRO = "gemini-1.0-pro"
@@ -153,6 +154,8 @@ MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
# Doubao (Volcengine Ark)
DOUBAO = "doubao"
DOUBAO_SEED_2_1_PRO = "doubao-seed-2-1-pro-260628"
DOUBAO_SEED_2_1_TURBO = "doubao-seed-2-1-turbo-260628"
DOUBAO_SEED_2_CODE = "doubao-seed-2-0-code-preview-260215"
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
@@ -197,7 +200,7 @@ MODEL_LIST = [
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
# Claude
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_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, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
@@ -223,7 +226,8 @@ MODEL_LIST = [
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
# Doubao
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
DOUBAO, DOUBAO_SEED_2_1_PRO, DOUBAO_SEED_2_1_TURBO,
DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
# Kimi (Moonshot)
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",

View File

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

View File

@@ -33,15 +33,28 @@ def _reset_logger(log):
datefmt="%Y-%m-%d %H:%M:%S",
)
)
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)
log.addHandler(console_handle)
# File logging is best-effort: if the log path isn't writable (e.g. a
# packaged app installed under Program Files run by a non-admin user, with
# an unwritable CWD), fall back to console-only instead of crashing the
# whole process at import time.
try:
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
file_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
log.addHandler(file_handle)
except OSError:
console_handle.handle(
logging.LogRecord(
"log", logging.WARNING, __file__, 0,
"[log] file logging disabled (log path not writable): %s",
(_log_path(),), None,
)
)
def _get_logger():

View File

@@ -41,5 +41,8 @@
"enable_thinking": false,
"reasoning_effort": "high",
"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,5 +1,6 @@
# encoding:utf-8
import ast
import copy
import json
import logging
@@ -264,8 +265,17 @@ available_setting = {
"self_evolution_enabled": False, # switch to enable/disable self-evolution
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
# Deep Dream: nightly memory distillation into MEMORY.md + dream diary.
"deep_dream_enabled": True, # scheduled deep dream switch; manual /memory dream is unaffected
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
# On-demand MCP tool retrieval: when many MCP tools are connected, inject
# only the most query-relevant ones instead of all of them. Built-in tools
# are always injected in full; degrades to full injection when disabled,
# below threshold, or when no embedding provider is available.
"mcp_tool_retrieval_enabled": False, # switch for on-demand MCP tool retrieval
"mcp_tool_retrieval_threshold": 20, # only retrieve when MCP tool count exceeds this
"mcp_tool_retrieval_top_k": 10, # max relevant MCP tools injected per turn
}
@@ -307,6 +317,12 @@ class Config(dict):
self.user_datas[user] = {}
return self.user_datas[user]
# SECURITY NOTE: pickle.load() can execute arbitrary code during
# deserialization. This is safe as long as user_datas.pkl is trusted
# (local app data directory, written only by this process). For a future
# hardening pass, consider migrating to JSON (json.load/json.dump) if the
# data structures are JSON-serializable, or adding an HMAC signature to
# detect tampering of the pickle file.
def load_user_datas(self):
try:
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
@@ -320,6 +336,8 @@ class Config(dict):
def save_user_datas(self):
try:
# SECURITY: pickle.dump output should only be loaded by this same
# process. See note on load_user_datas() above.
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
pickle.dump(self.user_datas, f)
logger.info("[Config] User datas saved.")
@@ -415,11 +433,19 @@ def load_config():
if name in available_setting:
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
try:
config[name] = eval(value)
# SECURITY: Use ast.literal_eval instead of eval().
# ast.literal_eval only parses Python literals (strings, numbers,
# tuples, lists, dicts, booleans, None) and CANNOT execute
# arbitrary code, preventing environment-variable injection.
config[name] = ast.literal_eval(value)
except Exception:
if value == "false":
# literal_eval can raise ValueError/SyntaxError for non-literal
# strings, but also TypeError/RecursionError on malformed input
# (e.g. unhashable dict keys); catch broadly to avoid crashing
# startup, and fall back to treating the value as a plain string.
if value.lower() == "false":
config[name] = False
elif value == "true":
elif value.lower() == "true":
config[name] = True
else:
config[name] = value

View File

@@ -52,15 +52,36 @@ hiddenimports += collect_submodules('models')
hiddenimports += collect_submodules('voice')
hiddenimports += collect_submodules('bridge')
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
# so the framework package must be present even though desktop mode never loads
# actual plugins (it's only ~tens of KB of code).
# 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')
@@ -69,12 +90,34 @@ hiddenimports += [
'tiktoken_ext.openai_public',
]
# Document parsing libs. The read / web_fetch tools import these lazily inside
# functions (e.g. `from pypdf import PdfReader`), so PyInstaller's static
# analysis misses them and they'd be dropped from the bundle — leaving the
# desktop client unable to read PDF/Word/Excel/PPT. List them explicitly.
hiddenimports += [
'pypdf',
'docx', # python-docx
'pptx', # python-pptx
'openpyxl',
]
hiddenimports += collect_submodules('pypdf')
hiddenimports += collect_submodules('docx')
hiddenimports += collect_submodules('pptx')
hiddenimports += collect_submodules('openpyxl')
# --- Data files -----------------------------------------------------------
# Runtime-read files/dirs that must travel with the executable. Paths are
# (source, dest_dir_in_bundle).
datas = [
(rp('config-template.json'), '.'),
(rp('skills'), 'skills'),
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
# (it doesn't rely solely on imports), so ship the package directory too.
(rp('plugins'), 'plugins'),
# Ship the `cli` package as loose files too: onedir adds _internal to
# sys.path, so `import cli.commands.*` resolves even if PyInstaller's
# submodule collection misses the lazily-imported command modules.
(rp('cli'), 'cli'),
# Web console served on the backend port: ship chat.html plus its static
# assets (~1.9MB) so the browser-based console works as a debug/fallback
# entry alongside the Electron UI.
@@ -85,6 +128,12 @@ datas = [
# Some libraries (tiktoken encodings, etc.) ship data files.
datas += collect_data_files('tiktoken_ext', include_py_files=False)
# python-docx / python-pptx bundle template files (default.docx / default.pptx,
# content-type XML) inside their packages; they're loaded at import/parse time,
# so ship them or document parsing fails in the frozen build.
datas += collect_data_files('docx')
datas += collect_data_files('pptx')
# --- Excludes -------------------------------------------------------------
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
# mode), tests/docs, and dev-only packages.

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,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

@@ -1,6 +1,6 @@
{
"name": "cowagent-desktop",
"version": "1.0.0",
"version": "2.1.3",
"description": "CowAgent Desktop Client - AI Agent on your desktop",
"main": "dist/main/index.js",
"author": "CowAgent",
@@ -67,6 +67,12 @@
"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",
@@ -74,11 +80,19 @@
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": [
"arm64",
"x64"
]
}
]
},
"win": {
"icon": "resources/icon.ico",
"artifactName": "${productName}-Setup-${version}-${arch}.${ext}",
"target": [
{
"target": "nsis",
@@ -90,14 +104,14 @@
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"publish": {
"provider": "github",
"owner": "zhayujie",
"repo": "chatgpt-on-wechat"
"provider": "generic",
"url": "https://cowagent.ai/update/"
}
}
}

View File

@@ -5,7 +5,7 @@ import http from 'http'
import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
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".
@@ -125,6 +125,16 @@ function createWindow() {
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()
})
@@ -160,14 +170,20 @@ async function startBackend() {
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)
})
@@ -203,6 +219,15 @@ function setupIPC() {
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', () => {
@@ -214,10 +239,28 @@ function setupIPC() {
ipcMain.handle('window-close', () => mainWindow?.close())
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
// Auto-update controls (renderer-driven: check, then opt-in download/install)
ipcMain.handle('update-check', () => checkForUpdates())
ipcMain.handle('update-download', () => startDownload())
ipcMain.handle('update-install', () => quitAndInstall())
// 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.
@@ -279,10 +322,14 @@ app.whenReady().then(async () => {
}
await startBackend()
// Wire auto-update and do a first silent check a few seconds after launch so
// it doesn't compete with backend startup for resources.
// 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) {

View File

@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
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.
@@ -39,9 +40,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('menu-action', handler)
},
// Auto-update: trigger checks/download/install and subscribe to status.
checkForUpdate: () => ipcRenderer.invoke('update-check'),
downloadUpdate: () => ipcRenderer.invoke('update-download'),
// 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)

View File

@@ -4,16 +4,26 @@ 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 = 9899
private port: number = DESKTOP_BACKEND_PORT
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
constructor(backendPath: string) {
@@ -66,23 +76,125 @@ export class PythonBackend extends EventEmitter {
}
/**
* Resolve config.json from the given data dir to read the web port. The
* 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 the default port when no config (or web_port) is found.
* repo path. Returns null when unset, so the caller can auto-pick a free port
* instead of fighting over a fixed one.
*/
private readPort(dataDir: string): number {
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'))
if (config.web_port) {
return config.web_port
const p = Number(config.web_port)
if (Number.isInteger(p) && p > 0 && p < 65536) {
return p
}
}
} catch {
// ignore
// ignore — fall through to auto-selection
}
return 9899
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> {
@@ -97,15 +209,16 @@ export class PythonBackend extends EventEmitter {
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
this.port = this.readPort(dataDir)
const alreadyRunning = await this.probeHealth()
if (alreadyRunning) {
this.status = 'ready'
this.emit('log', `Backend already running on port ${this.port}`)
this.emit('ready', this.port)
return
}
// 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[]
@@ -114,9 +227,19 @@ export class PythonBackend extends EventEmitter {
if (bundled) {
command = bundled
args = []
// The onedir bundle reads data files relative to the executable's dir.
cwd = path.dirname(bundled)
this.emit('log', `Starting bundled backend: ${bundled}`)
// 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')
@@ -140,6 +263,9 @@ export class PythonBackend extends EventEmitter {
...process.env,
PYTHONUNBUFFERED: '1',
COW_DESKTOP: '1',
// The shell owns the port: tell the backend to bind exactly here so the
// two sides can never disagree (and we avoid the 9899 web-console clash).
COW_WEB_PORT: String(this.port),
...(bundled ? { COW_DATA_DIR } : {}),
},
stdio: ['pipe', 'pipe', 'pipe'],
@@ -160,10 +286,15 @@ export class PythonBackend extends EventEmitter {
})
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 (code !== 0 && code !== null) {
this.emit('error', `Python process exited with code ${code}`)
if (!wasReady && code !== 0 && code !== null) {
this.status = 'error'
this.emit('error', `Backend exited during startup (code ${code})`)
}
})
@@ -175,16 +306,6 @@ export class PythonBackend extends EventEmitter {
await this.waitForReady()
}
private probeHealth(): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
resolve(res.statusCode === 200)
})
req.on('error', () => resolve(false))
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
})
}
private waitForReady(): Promise<void> {
return new Promise((resolve) => {
// Wall-clock deadline rather than an attempt counter: if the machine
@@ -213,7 +334,9 @@ export class PythonBackend extends EventEmitter {
}
const retry = () => {
if (this.status === 'stopped' || this.status === 'ready') {
// 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
}

View File

@@ -1,4 +1,6 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs'
import path from 'path'
// electron-updater is CommonJS: its members live on module.exports, with no
// meaningful default export. Under module=commonjs + esModuleInterop, a named
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
@@ -17,16 +19,93 @@ export type UpdateStatus =
let getWindow: () => BrowserWindow | null = () => null
// The update feed. Both entries hit the same Pages Function
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
// downloads to the China CDN mirror instead of R2. The feed metadata is
// identical either way, so we can freely switch the feed URL between attempts
// to fall back from one download origin to the other.
const FEED_BASE = 'https://cowagent.ai/update/'
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
// Which origin the current session prefers, derived from the app UI language
// (zh -> China mirror). Downloads that fail on the preferred origin retry once
// on the other one before surfacing an error.
let preferChina = false
// Guard so a single download only ever falls back once (avoids ping-pong).
let downloadFellBack = false
function applyFeedUrl(): void {
const url = feedUrlFor(preferChina)
try {
autoUpdater.setFeedURL({ provider: 'generic', url })
log(`feed url set: ${url} (preferChina=${preferChina})`)
} catch (err) {
log(`feed url set failed: ${(err as Error)?.message || String(err)}`)
}
}
// Called from the check/download IPC with the renderer's current UI language.
export function setUpdateLanguage(lang: string | undefined): void {
const china = (lang || '').toLowerCase().startsWith('zh')
if (china !== preferChina) {
preferChina = china
if (app.isPackaged) applyFeedUrl()
}
}
// Persist update logs to a file so a user hitting a silent "spinner never
// resolves" can just send us userData/logs/updater.log. We can't rely on a
// logging dep, so this is a tiny append-only writer, plus console for the
// in-app log view / terminal.
let logFile: string | null = null
function initLogFile() {
try {
const dir = path.join(app.getPath('userData'), 'logs')
fs.mkdirSync(dir, { recursive: true })
logFile = path.join(dir, 'updater.log')
} catch {
logFile = null
}
}
function log(...parts: unknown[]) {
const line = `[${new Date().toISOString()}] [updater] ${parts
.map((p) => (typeof p === 'string' ? p : safeStringify(p)))
.join(' ')}`
// Console: shows up in the terminal (dev) and the packaged app's stdout.
console.log(line)
if (logFile) {
try {
fs.appendFileSync(logFile, line + '\n')
} catch {
// ignore disk errors — logging must never break the updater
}
}
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
function send(status: UpdateStatus) {
getWindow()?.webContents.send('update-status', status)
}
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
getWindow = windowGetter
initLogFile()
log(`init: appVersion=${app.getVersion()} packaged=${app.isPackaged} logFile=${logFile ?? '<none>'}`)
// In dev (not packaged) there's no update feed; skip wiring entirely so
// electron-updater doesn't throw on the missing app-update.yml.
if (!app.isPackaged) {
log('not packaged — updater wiring skipped')
return
}
@@ -34,40 +113,133 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void {
// 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', () => send({ state: 'checking' }))
autoUpdater.on('update-available', (info) =>
send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined })
)
autoUpdater.on('update-not-available', () => send({ state: 'not-available' }))
autoUpdater.on('download-progress', (p) =>
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) =>
})
autoUpdater.on('update-downloaded', (info) => {
log(`update-downloaded: version=${info.version} -> ready to install`)
send({ state: 'downloaded', version: info.version })
)
autoUpdater.on('error', (err) =>
send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) })
)
})
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; safe to call when not packaged (no-op).
// 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) return
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) => {
send({ state: 'error', message: err?.message || String(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) => {
send({ state: 'error', message: err?.message || String(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
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
autoUpdater.quitAndInstall(false, true)
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

@@ -3,7 +3,7 @@
<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: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
<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" />

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback, useEffect } from 'react'
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
import { PanelLeftOpen } from 'lucide-react'
import { History } from 'lucide-react'
import NavRail from './layout/NavRail'
import SessionList from './layout/SessionList'
import WindowControls from './layout/WindowControls'
@@ -27,8 +27,8 @@ const App: React.FC = () => {
const backend = useBackend()
const location = useLocation()
const navigate = useNavigate()
const { isWin } = usePlatform()
const { sessionsCollapsed, toggleSessions } = useUIStore()
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)
@@ -107,10 +107,13 @@ const App: React.FC = () => {
{isChat && sessionsCollapsed && (
<button
onClick={toggleSessions}
title={t('nav_expand')}
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"
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' : ''}`}
>
<PanelLeftOpen size={16} />
<History size={16} />
</button>
)}
<div className="flex-1 min-w-0" />

View File

@@ -16,6 +16,7 @@ import type {
KnowledgeList,
KnowledgeGraph,
KnowledgeAction,
KnowledgeImportPayload,
} from '../types'
interface ApiResult {
@@ -24,7 +25,7 @@ interface ApiResult {
}
class ApiClient {
private baseUrl = 'http://127.0.0.1:9899'
private baseUrl = 'http://127.0.0.1:9876'
setBaseUrl(url: string) {
this.baseUrl = url
@@ -315,6 +316,23 @@ class ApiClient {
})
}
// 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
// ---------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -1,15 +1,18 @@
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2 } from 'lucide-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
action: 'new' | 'clear'
// '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 {
@@ -35,16 +38,53 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
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('session_new'), action: 'new' },
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
{ cmd: '/clear', desc: t('slash_clear'), action: 'clear' },
{ cmd: '/help', desc: t('slash_help') },
{ cmd: '/status', desc: t('slash_status') },
{ cmd: '/context', desc: t('slash_context') },
{ cmd: '/skill list', desc: t('slash_skill_list') },
{ cmd: '/skill search ', desc: t('slash_skill_search') },
{ cmd: '/skill install ', desc: t('slash_skill_install') },
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
{ cmd: '/knowledge', desc: t('slash_knowledge') },
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
{ cmd: '/config', desc: t('slash_config') },
{ cmd: '/cancel', desc: t('slash_cancel') },
{ cmd: '/logs', desc: t('slash_logs') },
{ cmd: '/version', desc: t('slash_version') },
]
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
const resetHeight = () => {
if (textareaRef.current) textareaRef.current.style.height = '42px'
// 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)
@@ -53,18 +93,36 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
const el = textareaRef.current
if (el) {
el.focus()
el.style.height = '42px'
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
autoSize(el)
}
})
})
const runSlash = (c: SlashCommand) => {
setText('')
setSlashOpen(false)
resetHeight()
if (c.action === 'new') onNewChat()
else if (c.action === 'clear') onClearContext()
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(() => {
@@ -111,9 +169,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value
setText(v)
const el = e.target
el.style.height = '42px'
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
autoSize(e.target)
// open slash menu when the input starts with "/" and has no space
setSlashOpen(v.startsWith('/') && !v.includes(' '))
setSlashIndex(0)
@@ -205,18 +261,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
{/* Slash command menu */}
{slashOpen && filtered.length > 0 && (
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
<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 gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
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-sm font-medium text-accent">{c.cmd}</span>
<span className="text-xs text-content-tertiary">{c.desc}</span>
<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>
@@ -275,6 +340,13 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
>
{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}
@@ -296,7 +368,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
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"
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 ? (
@@ -311,10 +383,10 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
<button
onClick={handleSubmit}
disabled={!canSend}
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
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')}
>
<Send size={17} />
<PaperPlaneIcon size={15} />
</button>
)}
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-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'
@@ -11,6 +11,9 @@ interface MessageBubbleProps {
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 {
@@ -36,7 +39,7 @@ const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boole
</button>
)
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete, onMediaLoad }) => {
const isUser = message.role === 'user'
const [copied, setCopied] = useState(false)
@@ -46,6 +49,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
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">
@@ -68,16 +81,14 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
)}
</div>
)}
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content">
<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>
{onEdit && message.userSeq != null && (
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
<Pencil size={13} />
</HoverAction>
)}
{/* 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} />
@@ -118,6 +129,42 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
{/* 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" />

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react'
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
import type { MessageStep } from '../types'
import Markdown from './Markdown'
@@ -49,7 +49,6 @@ const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
onClick={() => setExpanded((v) => !v)}
>
<span className="flex-shrink-0">{icon}</span>
<Wrench size={11} className="flex-shrink-0 opacity-70" />
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
{step.execution_time !== undefined && (
<span className="opacity-60">{step.execution_time}s</span>

View File

@@ -1,63 +1,100 @@
import React, { useEffect, useState } from 'react'
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
import React from 'react'
import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react'
import { t } from '../i18n'
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
// Compact update panel anchored to the NavRail footer. Only mounts content
// when there's a pending update; otherwise renders nothing so it stays out of
// the way until electron-updater reports a new version.
// 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, setOpen] = useState(false)
const open = state.panelOpen
const pending = hasPendingUpdate(state)
const available = hasAvailableUpdate(state)
const status = state.status
const errored = status?.state === 'error'
// Auto-open the panel the moment a new version is first detected.
useEffect(() => {
if (status?.state === 'available') setOpen(true)
}, [status?.state])
// 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>
)
}
if (!pending) return null
// 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-14 left-2 right-2 z-40">
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
{!open && (
<button
onClick={() => setOpen(true)}
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
>
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
<Download size={15} />
<span className="truncate">{t('update_available')}</span>
</button>
)}
{open && (
<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">{t('update_available')}</p>
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
</div>
<button
onClick={() => {
setOpen(false)
state.dismiss()
}}
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
title={t('update_later')}
>
<X size={15} />
</button>
<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>
{downloading && (
{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" />
@@ -69,7 +106,7 @@ const UpdateBanner: React.FC = () => {
</div>
)}
{!downloading && !downloaded && (
{!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"
@@ -79,7 +116,7 @@ const UpdateBanner: React.FC = () => {
</button>
)}
{downloaded && (
{!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"
@@ -89,7 +126,6 @@ const UpdateBanner: React.FC = () => {
</button>
)}
</div>
)}
</div>
)
}

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

@@ -1,5 +1,12 @@
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
@@ -9,7 +16,7 @@ interface BackendState {
export function useBackend() {
const [state, setState] = useState<BackendState>({
status: 'connecting',
port: 9899,
port: BACKEND_PORT,
})
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -31,7 +38,7 @@ export function useBackend() {
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(9899)
const portRef = useRef(BACKEND_PORT)
useEffect(() => {
let cancelled = false
@@ -74,12 +81,21 @@ export function useBackend() {
}
if (api) {
api.getBackendPort().then((port) => {
const p = port || 9899
portRef.current = p
setState((prev) => ({ ...prev, port: p }))
startPolling(p)
})
// 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) {
@@ -98,7 +114,7 @@ export function useBackend() {
}
})
} else {
startPolling(9899)
startPolling(BACKEND_PORT)
}
// When the window comes back to the foreground, re-probe immediately so a

View File

@@ -12,8 +12,8 @@ function getSystemTheme(): ResolvedTheme {
function readStored(): ThemePref {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
// Default to dark to match the app's flagship look
return 'dark'
// First run: follow the OS appearance rather than forcing a fixed theme.
return 'system'
}
function applyTheme(resolved: ResolvedTheme) {

View File

@@ -29,14 +29,67 @@ const translations: Record<string, Record<string, string>> = {
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 助手。几步设置,即可开始对话。',
@@ -85,11 +138,17 @@ const translations: Record<string, Record<string, string>> = {
chat_empty_hint: '发送一条消息开始对话',
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理',
example_sys_text: '帮我查看工作空间里有哪些文件',
example_task_title: '技能系统',
example_task_text: '查看所有支持的工具和技能',
example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '定时任务',
example_task_text: '1分钟后提醒我检查服务器',
example_code_title: '编程助手',
example_code_text: '帮我编写一个Python爬虫脚本',
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 配置',
@@ -216,7 +275,10 @@ const translations: Record<string, Record<string, string>> = {
memory_next: '下一页',
channels_title: '通道管理',
channels_desc: '查看和管理消息通道',
channels_add: '添加通道',
channels_add: '接入通道',
channels_select_label: '选择要接入的通道',
channels_select_placeholder: '请选择通道...',
channels_add_close: '关闭',
channels_connected: '已连接',
channels_disconnected: '未连接',
channels_connect: '连接',
@@ -294,6 +356,23 @@ const translations: Record<string, Record<string, string>> = {
status_error: '初始化失败',
status_error_desc: '客户端初始化失败,请重试',
status_retry: '重试',
// 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_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
},
en: {
console: 'Console',
@@ -324,15 +403,68 @@ const translations: Record<string, Record<string, string>> = {
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.',
@@ -382,10 +514,16 @@ const translations: Record<string, Record<string, string>> = {
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: 'Skills',
example_task_text: 'Show current tools and skills',
example_task_title: 'Scheduled Task',
example_task_text: 'Remind me to check the server in 1 minute',
example_code_title: 'Coding',
example_code_text: 'Write a Python web scraper script',
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',
@@ -513,6 +651,9 @@ const translations: Record<string, Record<string, string>> = {
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',
@@ -590,6 +731,23 @@ const translations: Record<string, Record<string, string>> = {
status_error: 'Initialization Failed',
status_error_desc: 'Failed to initialize the client, please retry',
status_retry: 'Retry',
// 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_config: 'Show current config',
slash_cancel: 'Abort the running agent task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
},
}

View File

@@ -49,7 +49,6 @@
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
/* Chat-specific tokens (AI-Native UI) */
--user-bubble-bg: var(--accent-soft);
--ai-bubble-bg: transparent;
--message-gap: 16px;
@@ -80,8 +79,6 @@
--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);
--user-bubble-bg: rgba(74, 190, 110, 0.16);
color-scheme: dark;
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useState, useRef, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
MessageSquare,
@@ -13,13 +13,45 @@ import {
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
@@ -45,21 +77,112 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
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; reserve space for macOS traffic lights.
No right border here so the divider doesn't cut across the traffic lights. */}
<div className="titlebar-drag h-[44px] flex-shrink-0" />
{/* 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">
@@ -93,32 +216,74 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
{!collapsed && <UpdateBanner />}
</div>
{/* Footer actions */}
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
<FooterBtn
collapsed={collapsed}
onClick={() => navigate('/logs')}
title={t('menu_logs')}
active={location.pathname === '/logs'}
>
<ScrollText size={17} />
</FooterBtn>
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
</FooterBtn>
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
</FooterBtn>
<div className={collapsed ? '' : 'flex-1'} />
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</FooterBtn>
{/* 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
@@ -139,4 +304,83 @@ const FooterBtn: React.FC<{
</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

@@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-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[] }[] {
@@ -32,6 +33,14 @@ 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('')
@@ -56,18 +65,18 @@ const SessionList: React.FC = () => {
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">
<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('nav_collapse')}
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"
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}`}
>
<PanelLeftClose size={16} />
<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"
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')}

View File

@@ -1,14 +1,52 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Loader2, Plug, QrCode } from 'lucide-react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Loader2,
Plug,
Plus,
X,
ChevronDown,
Check,
MessageCircle,
MessageSquare,
Bot,
Building2,
Headset,
Hash,
AtSign,
} from 'lucide-react'
import { t, localizedLabel } from '../i18n'
import apiClient from '../api/client'
import type { ChannelInfo, ChannelField } from '../types'
import { Toggle, Btn } from './settings/primitives'
import QrLoginModal from '../components/QrLoginModal'
import { PaperPlaneIcon } from '../components/icons'
// Channels that connect via QR scanning rather than credential fields.
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
// An icon component that takes a `size` prop (lucide icons and our PaperPlaneIcon).
type IconComponent = React.FC<{ size?: number }>
// Per-channel icon + accent color, mirroring the web console's FontAwesome
// icon + Tailwind color palette (we use lucide here, with hex colors so the
// tinted icon background isn't purged by Tailwind's JIT). Feishu/Telegram use
// the same paper-plane as the web console.
const CHANNEL_STYLE: Record<string, { Icon: IconComponent; color: string }> = {
weixin: { Icon: MessageCircle, color: '#10b981' },
feishu: { Icon: PaperPlaneIcon, color: '#3b82f6' },
dingtalk: { Icon: MessageSquare, color: '#3b82f6' },
wecom_bot: { Icon: Bot, color: '#10b981' },
qq: { Icon: MessageCircle, color: '#3b82f6' },
wechatcom_app: { Icon: Building2, color: '#10b981' },
wechat_kf: { Icon: Headset, color: '#10b981' },
wechatmp: { Icon: MessageCircle, color: '#10b981' },
telegram: { Icon: PaperPlaneIcon, color: '#0ea5e9' },
slack: { Icon: Hash, color: '#a855f7' },
discord: { Icon: AtSign, color: '#6366f1' },
}
const channelStyle = (name: string) => CHANNEL_STYLE[name] ?? { Icon: Plug, color: '#94a3b8' }
interface ChannelsPageProps {
baseUrl: string
}
@@ -19,6 +57,12 @@ const MASK_RE = /\*{2,}/
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
const [channels, setChannels] = useState<ChannelInfo[]>([])
const [loading, setLoading] = useState(true)
// Whether the "add channel" panel is open, and the channel chosen in it.
// `selected` starts empty so the user must pick a channel themselves.
const [addOpen, setAddOpen] = useState(false)
const [selected, setSelected] = useState<string>('')
const scrollRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const loadChannels = async () => {
try {
@@ -45,14 +89,55 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
return { connected, available }
}, [channels])
// If the selected channel got connected (or vanished), clear the selection.
useEffect(() => {
if (selected && !available.some((c) => c.name === selected)) setSelected('')
}, [available, selected])
const openAdd = () => {
setSelected('')
setAddOpen(true)
// Scroll the new panel into view at the bottom of the list.
requestAnimationFrame(() => {
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
const addingChannel = available.find((c) => c.name === selected)
const onAdded = () => {
setAddOpen(false)
setSelected('')
void loadChannels()
}
// Keep the config form in view as it grows after picking a channel.
useEffect(() => {
if (selected) {
requestAnimationFrame(() => {
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
}, [selected])
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 pt-5 pb-3 flex-shrink-0">
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
<div className="px-6 pt-5 pb-3 flex-shrink-0 flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
</div>
{!loading && available.length > 0 && !addOpen && (
<Btn variant="primary" onClick={openAdd}>
<span className="flex items-center gap-1.5">
<Plus size={15} />
{t('channels_add')}
</span>
</Btn>
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-default">
<div ref={scrollRef} className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-3xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
@@ -60,21 +145,37 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
{t('channels_loading')}
</div>
) : (
<div className="space-y-6">
<Section title={t('channels_connected_section')}>
{connected.length === 0 ? (
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
) : (
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
)}
</Section>
<div className="space-y-3">
{connected.length === 0 && !addOpen ? (
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
) : (
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
)}
{available.length > 0 && (
<Section title={t('channels_available_section')}>
{available.map((ch) => (
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
))}
</Section>
{/* Add-channel panel lives at the bottom of the list: pick a
channel from the dropdown, then configure/connect it inline. */}
{addOpen && available.length > 0 && (
<div ref={panelRef} className="rounded-card border border-accent/40 bg-surface p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-content">{t('channels_select_label')}</label>
<button
onClick={() => setAddOpen(false)}
className="text-content-tertiary hover:text-content cursor-pointer"
title={t('channels_add_close')}
>
<X size={16} />
</button>
</div>
<ChannelDropdown
channels={available}
value={selected}
onChange={setSelected}
placeholder={t('channels_select_placeholder')}
/>
{addingChannel && (
<ChannelCard key={addingChannel.name} channel={addingChannel} onChanged={onAdded} defaultExpanded />
)}
</div>
)}
</div>
)}
@@ -84,20 +185,101 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
)
}
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
<div className="space-y-3">{children}</div>
</div>
)
// Custom dropdown styled like the web console's `.cfg-dropdown` (rounded,
// green focus ring, hover/active states) instead of a native <select>.
const ChannelDropdown: React.FC<{
channels: ChannelInfo[]
value: string
onChange: (name: string) => void
placeholder: string
}> = ({ channels, value, onChange, placeholder }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => {
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const current = channels.find((c) => c.name === value)
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 h-10 px-3 rounded-btn border bg-inset text-sm cursor-pointer transition-colors ${
open ? 'border-accent ring-2 ring-accent/15' : 'border-strong hover:border-content-tertiary'
} ${current ? 'text-content' : 'text-content-tertiary'}`}
>
{current ? (
<span className="flex items-center gap-2 min-w-0">
<ChannelIcon name={current.name} size={26} />
<span className="truncate">{localizedLabel(current.label)}</span>
<span className="text-content-tertiary font-mono text-xs">({current.name})</span>
</span>
) : (
<span>{placeholder}</span>
)}
<ChevronDown size={14} className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute top-[calc(100%+4px)] left-0 right-0 z-50 max-h-60 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg p-1">
{channels.map((ch) => {
const active = ch.name === value
return (
<button
key={ch.name}
type="button"
onClick={() => {
onChange(ch.name)
setOpen(false)
}}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-sm cursor-pointer transition-colors ${
active ? 'bg-accent-soft text-accent font-medium' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<ChannelIcon name={ch.name} size={26} />
<span className="truncate">{localizedLabel(ch.label)}</span>
<span className="text-content-tertiary font-mono text-xs">({ch.name})</span>
{active && <Check size={14} className="ml-auto flex-shrink-0" />}
</button>
)
})}
</div>
)}
</div>
)
}
// A tinted square with the channel's icon (web-console style).
const ChannelIcon: React.FC<{ name: string; size?: number }> = ({ name, size = 36 }) => {
const { Icon, color } = channelStyle(name)
return (
<span
className="rounded-lg flex items-center justify-center flex-shrink-0"
style={{ width: size, height: size, backgroundColor: `${color}1a`, color }}
>
<Icon size={Math.round(size * 0.45)} />
</span>
)
}
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void; defaultExpanded?: boolean }> = ({
channel,
onChanged,
defaultExpanded = false,
}) => {
// Channels with no fields connect purely via QR (e.g. weixin).
const isQrLogin = channel.fields.length === 0
// QR provider supported by the desktop scan modal (weixin / feishu).
const qrProvider = QR_PROVIDERS[channel.name]
const [showQr, setShowQr] = useState(false)
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useState(defaultExpanded)
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
)
@@ -148,15 +330,14 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
}
return (
<div className="rounded-card border border-default bg-surface p-4">
<div className={defaultExpanded ? '' : 'rounded-card border border-default bg-surface p-4'}>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
</div>
<ChannelIcon name={channel.name} size={40} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
<span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
{channel.active && <span className="text-xs text-accent">{t('channels_connected')}</span>}
</div>
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
</div>
@@ -169,7 +350,7 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
<Btn variant="primary" onClick={() => setShowQr(true)}>
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
</Btn>
) : isQrLogin ? null : (
) : isQrLogin || defaultExpanded ? null : (
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
{t('channels_add')}
</Btn>

View File

@@ -1,5 +1,15 @@
import React, { useEffect, useRef, useCallback, useState } from 'react'
import { ChevronUp, Loader2 } from 'lucide-react'
import {
ChevronUp,
Loader2,
FolderOpen,
Clock,
Code2,
BookOpen,
Puzzle,
Terminal,
type LucideIcon,
} from 'lucide-react'
import MessageBubble from '../components/MessageBubble'
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
import { t } from '../i18n'
@@ -12,7 +22,24 @@ interface ChatPageProps {
baseUrl: string
}
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
// Welcome-screen suggestion cards (aligned with the web console: 6 cards).
// `send` overrides the text dropped into the input (e.g. show "查看全部命令"
// but fill "/help"); otherwise the card's *_text is used.
// Icon + accent color per card, aligned with the web console palette.
const SUGGESTIONS: {
key: string
send?: string
icon: LucideIcon
iconClass: string
bgClass: string
}[] = [
{ key: 'example_sys', icon: FolderOpen, iconClass: 'text-blue-500', bgClass: 'bg-blue-500/10' },
{ key: 'example_task', icon: Clock, iconClass: 'text-amber-500', bgClass: 'bg-amber-500/10' },
{ key: 'example_code', icon: Code2, iconClass: 'text-emerald-500', bgClass: 'bg-emerald-500/10' },
{ key: 'example_knowledge', icon: BookOpen, iconClass: 'text-violet-500', bgClass: 'bg-violet-500/10' },
{ key: 'example_skill', icon: Puzzle, iconClass: 'text-rose-500', bgClass: 'bg-rose-500/10' },
{ key: 'example_web', send: '/help', icon: Terminal, iconClass: 'text-content-tertiary', bgClass: 'bg-content-tertiary/10' },
]
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const activeId = useSessionStore((s) => s.activeId)
@@ -51,14 +78,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
}, [activeId, ensureSession, loadHistory])
const scrollToBottom = useCallback((smooth = true) => {
const el = scrollRef.current
if (!el) return
// Jump straight to the bottom; instant for session switches, smooth for streaming.
if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
// Defer to the next frame so we read the height *after* the new content has
// been laid out (markdown/streaming renders a frame later than the effect).
requestAnimationFrame(() => {
const el = scrollRef.current
if (!el) return
// Smooth animations get interrupted by high-frequency streaming updates
// and never catch up, so jump instantly while following the stream.
if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
})
}, [])
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
@@ -66,6 +98,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const lastSessionRef = useRef('')
const lastLenRef = useRef(0)
const pendingSnapRef = useRef(false)
// True while we should keep the view pinned to the bottom (e.g. during
// streaming). Cleared when the user scrolls up to read earlier messages.
const followBottomRef = useRef(true)
// Tracks the previous streaming state so we can do one final snap to the
// bottom right when streaming ends (the last chunk of a long command output
// often lands together with isStreaming flipping to false).
const wasStreamingRef = useRef(false)
useEffect(() => {
const el = scrollRef.current
if (!el) return
@@ -74,12 +113,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
lastSessionRef.current = activeId
lastLenRef.current = messages.length
pendingSnapRef.current = true
followBottomRef.current = true
}
if (pendingSnapRef.current) {
// Instant snap on switch and on the first content that lands afterwards.
lastLenRef.current = messages.length
requestAnimationFrame(() => scrollToBottom(false))
scrollToBottom(false)
if (messages.length > 0) pendingSnapRef.current = false
return
}
@@ -87,8 +127,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const grew = messages.length !== lastLenRef.current
lastLenRef.current = messages.length
if (nearBottom || grew) scrollToBottom(true)
}, [messages, activeId, scrollToBottom])
// Follow the bottom when: a new message arrived, the user is already near
// the bottom, or we're streaming and the user hasn't scrolled up. This
// keeps long command/streaming output (where length is unchanged but the
// content keeps growing) glued to the latest line.
// One final snap right when streaming ends, so the tail of a long command
// output isn't left scrolled off-screen.
const justFinished = wasStreamingRef.current && !isStreaming
wasStreamingRef.current = isStreaming
const following = isStreaming && followBottomRef.current
if (grew || nearBottom || following || (justFinished && followBottomRef.current)) {
// Instant jump while streaming/new content (smooth animations get
// interrupted by rapid updates and never reach the bottom); smooth only
// for a lone increment when the user is already sitting near the bottom.
const smooth = nearBottom && !following && !grew && !justFinished
scrollToBottom(smooth)
}
}, [messages, activeId, isStreaming, scrollToBottom])
const handleSend = useCallback(
async (text: string, attachments: Attachment[]) => {
@@ -143,9 +199,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
[deleteMessage, activeId]
)
// Inline images/videos load asynchronously and grow the bubble after mount,
// so a scroll triggered on message change fires before the final height is
// known. Re-scroll once media loads, but only while following the bottom.
const handleMediaLoad = useCallback(() => {
if (followBottomRef.current) scrollToBottom(false)
}, [scrollToBottom])
const handleScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
// Track whether the user wants to stay pinned to the bottom: scrolling up
// pauses auto-follow; returning near the bottom resumes it.
followBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const s = useChatStore.getState().sessions[activeId]
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
setLoadingMore(true)
@@ -178,15 +244,26 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
{SUGGESTIONS.map((key) => (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
<button
key={key}
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])}
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
onClick={() => {
// Fill the input (don't auto-send) so the user can tweak it first.
const draft = send ?? t(`${key}_text` as Parameters<typeof t>[0])
inputResetRef.current?.(draft, [])
}}
className="group text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
>
<div className="font-medium text-sm text-content mb-1">
{t(`${key}_title` as Parameters<typeof t>[0])}
<div className="flex items-center gap-2 mb-1.5">
<span
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${bgClass}`}
>
<Icon size={15} className={iconClass} />
</span>
<span className="font-medium text-sm text-content">
{t(`${key}_title` as Parameters<typeof t>[0])}
</span>
</div>
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
{t(`${key}_text` as Parameters<typeof t>[0])}
@@ -204,6 +281,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onMediaLoad={handleMediaLoad}
/>
))}
<div ref={bottomRef} />

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Loader2,
Search,
@@ -8,12 +8,21 @@ import {
MessageSquarePlus,
Network,
Files,
Plus,
FolderPlus,
FilePlus2,
Upload,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import { t, getLang } from '../i18n'
import apiClient from '../api/client'
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
import type {
KnowledgeDir,
KnowledgeFile,
KnowledgeList,
KnowledgeGraph as KnowledgeGraphData,
} from '../types'
import Markdown from '../components/Markdown'
import KnowledgeGraph from '../components/KnowledgeGraph'
@@ -23,12 +32,58 @@ interface KnowledgePageProps {
type Tab = 'docs' | 'graph'
const KNOWLEDGE_IMPORT_MAX_FILES = 100
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024
// t() with simple {placeholder} interpolation.
const tf = (key: string, vars: Record<string, string | number>): string => {
let out = t(key)
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v))
return out
}
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// Flatten the tree into category paths (for destination selectors).
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
const paths: string[] = []
for (const dir of dirs || []) {
const path = parent ? `${parent}/${dir.dir}` : dir.dir
paths.push(path, ...categoryPaths(dir.children || [], path))
}
return paths
}
// Validate a batch of files chosen for import. Returns an error message or ''.
function validateImportFiles(files: File[]): string {
if (!files.length) return t('knowledge_import_choose_files')
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
return tf('knowledge_import_too_many', { max: KNOWLEDGE_IMPORT_MAX_FILES })
}
let total = 0
for (const file of files) {
total += file.size || 0
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
return tf('knowledge_import_file_too_large', { name: file.name })
}
}
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) return t('knowledge_import_total_too_large')
return ''
}
// ---- Dialog model ----------------------------------------------------------
interface DialogState {
kind: 'category' | 'doc-pick-category' | 'document' | 'import'
category?: string
files?: File[]
}
// Find the first document (root files first, then a DFS over the tree).
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
const root = list.root_files?.[0]
@@ -50,6 +105,30 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
return null
}
// Resolve a document's display title from its path, falling back to the stem.
function findTitle(list: KnowledgeList, path: string): string {
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
for (const f of list.root_files || []) {
if (f.name === path) return f.title || fallback
}
const walk = (dir: KnowledgeDir, prefix: string): string | null => {
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
for (const f of dir.files) {
if (`${dirPath}/${f.name}` === path) return f.title || fallback
}
for (const c of dir.children) {
const hit = walk(c, dirPath)
if (hit) return hit
}
return null
}
for (const d of list.tree || []) {
const hit = walk(d, '')
if (hit) return hit
}
return fallback
}
const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('docs')
@@ -65,6 +144,22 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
const [graphLoading, setGraphLoading] = useState(false)
// Management UI state.
const [menuOpen, setMenuOpen] = useState(false)
const [dialog, setDialog] = useState<DialogState | null>(null)
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const showStatus = useCallback((text: string, error = false, sticky = false) => {
if (statusTimer.current) clearTimeout(statusTimer.current)
setStatus({ text, error })
if (!sticky) {
statusTimer.current = setTimeout(() => setStatus(null), 4000)
}
}, [])
const openDoc = useCallback(async (path: string, title: string) => {
setActivePath(path)
setDocTitle(title)
@@ -80,16 +175,37 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
}
}, [])
// Reload the tree. When targetPath is given, open it; otherwise keep the
// currently open doc (or open the first one on the initial load).
const refresh = useCallback(
async (targetPath?: string) => {
try {
const fresh = await apiClient.getKnowledgeList()
setData(fresh)
if (targetPath) {
void openDoc(targetPath, findTitle(fresh, targetPath))
} else if (!activePath) {
const first = firstFile(fresh)
if (first) void openDoc(first.path, first.title)
}
return fresh
} catch (e) {
console.error('Failed to load knowledge:', e)
return null
}
},
[openDoc, activePath]
)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
let cancelled = false
;(async () => {
setLoading(true)
try {
setLoading(true)
const fresh = await apiClient.getKnowledgeList()
if (cancelled) return
setData(fresh)
// Auto-open the first document so the viewer isn't empty on entry.
const first = firstFile(fresh)
if (first) void openDoc(first.path, first.title)
} catch (e) {
@@ -101,7 +217,9 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
return () => {
cancelled = true
}
}, [baseUrl, openDoc])
// Only run on baseUrl change (initial mount). refresh() handles later reloads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const loadGraph = useCallback(async () => {
if (graph) return
@@ -130,6 +248,115 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
[openDoc]
)
// ---- Management actions --------------------------------------------------
const categories = useMemo(() => categoryPaths(data?.tree || []), [data])
const createCategory = useCallback(
async (path: string): Promise<string | null> => {
showStatus(t('knowledge_working'), false, true)
try {
const res = await apiClient.knowledgeAction({ action: 'create_category', payload: { path } })
if (res.status !== 'success') {
showStatus((res.message as string) || t('knowledge_request_failed'), true)
return null
}
showStatus(t('knowledge_category_created'))
await refresh()
return path
} catch {
showStatus(t('knowledge_request_failed'), true)
return null
}
},
[refresh, showStatus]
)
const createDocument = useCallback(
async (path: string, content: string): Promise<string | null> => {
showStatus(t('knowledge_working'), false, true)
try {
const res = await apiClient.knowledgeAction({
action: 'create_document',
payload: { path, content, overwrite: false },
})
if (res.status !== 'success') {
showStatus((res.message as string) || t('knowledge_request_failed'), true)
return null
}
const created = ((res.payload as { path?: string })?.path) || path
showStatus(t('knowledge_document_created'))
await refresh(created)
return created
} catch {
showStatus(t('knowledge_request_failed'), true)
return null
}
},
[refresh, showStatus]
)
const importDocuments = useCallback(
async (files: File[], targetCategory: string): Promise<boolean> => {
const err = validateImportFiles(files)
if (err) {
showStatus(err, true)
return false
}
const supported = files.filter((f) => /\.(md|txt)$/i.test(f.name || ''))
if (!supported.length) {
showStatus(t('knowledge_import_choose_files'), true)
return false
}
showStatus(t('knowledge_importing'), false, true)
try {
const res = await apiClient.importKnowledge(supported, targetCategory)
if (res.status !== 'success') {
showStatus(res.message || t('knowledge_import_failed'), true)
await refresh()
return false
}
const p = res.payload
showStatus(
tf('knowledge_import_result', {
imported: p?.imported ?? 0,
skipped: p?.skipped ?? 0,
failed: p?.failed ?? 0,
})
)
const first = (p?.results || []).find((r) => r.status === 'imported')
await refresh(first?.path)
return true
} catch {
showStatus(t('knowledge_import_failed'), true)
return false
}
},
[refresh, showStatus]
)
// Open the import dialog after validating the chosen files.
const startImport = useCallback(
(files: File[]) => {
const err = validateImportFiles(files)
if (err) {
showStatus(err, true)
return
}
setDialog({ kind: 'import', files })
},
[showStatus]
)
const onFilesPicked = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
e.target.value = ''
if (files.length) startImport(files)
},
[startImport]
)
const totalPages = data?.stats?.pages ?? 0
const statsLabel = useMemo(() => {
if (!data) return ''
@@ -178,19 +405,81 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
</div>
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
<TabBtn
icon={Network}
label={t('knowledge_tab_graph')}
active={tab === 'graph'}
onClick={() => switchTab('graph')}
<div className="flex items-center gap-2">
{status && (
<span
className={`text-xs max-w-[260px] truncate ${
status.error ? 'text-danger' : 'text-content-tertiary'
}`}
title={status.text}
>
{status.text}
</span>
)}
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
<TabBtn
icon={Network}
label={t('knowledge_tab_graph')}
active={tab === 'graph'}
onClick={() => switchTab('graph')}
/>
</div>
<NewMenu
open={menuOpen}
setOpen={setMenuOpen}
onCreateCategory={() => setDialog({ kind: 'category' })}
onCreateDocument={() => {
if (!categories.length) {
showStatus(t('knowledge_need_category'), true)
return
}
setDialog({ kind: 'doc-pick-category' })
}}
onImport={() => fileInputRef.current?.click()}
/>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.txt,text/markdown,text/plain"
className="hidden"
onChange={onFilesPicked}
/>
</div>
{tab === 'docs' ? (
<div className="flex-1 flex min-h-0 border-t border-default">
<div
className="flex-1 flex min-h-0 border-t border-default relative"
onDragEnter={(e) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault()
setDragOver(true)
}
}}
onDragOver={(e) => {
if (e.dataTransfer?.types?.includes('Files')) e.preventDefault()
}}
onDragLeave={(e) => {
// Only clear when leaving the panel, not its children.
if (e.currentTarget === e.target) setDragOver(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
const files = Array.from(e.dataTransfer?.files || [])
if (files.length) startImport(files)
}}
>
{dragOver && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-accent-soft/80 border-2 border-dashed border-accent rounded-lg m-2 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-accent">
<Upload size={28} />
<p className="text-sm font-medium">{t('knowledge_drop_hint')}</p>
</div>
</div>
)}
{/* Tree sidebar */}
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
<div className="p-3 flex-shrink-0">
@@ -251,6 +540,313 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
)}
</div>
)}
{dialog && (
<KnowledgeDialog
state={dialog}
categories={categories}
onClose={() => setDialog(null)}
onCreateCategory={createCategory}
onPickDocCategory={(category) => setDialog({ kind: 'document', category })}
onCreateDocument={createDocument}
onImport={importDocuments}
/>
)}
</div>
)
}
// ---- New menu --------------------------------------------------------------
const NewMenu: React.FC<{
open: boolean
setOpen: (v: boolean) => void
onCreateCategory: () => void
onCreateDocument: () => void
onImport: () => void
}> = ({ open, setOpen, onCreateCategory, onCreateDocument, onImport }) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open, setOpen])
const pick = (fn: () => void) => {
setOpen(false)
fn()
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
<Plus size={14} />
{t('knowledge_new')}
<ChevronDown size={12} className="opacity-80" />
</button>
{open && (
<div className="absolute right-0 mt-1.5 w-44 z-50 bg-surface border border-default rounded-lg shadow-lg py-1">
<MenuItem icon={FolderPlus} label={t('knowledge_new_category')} onClick={() => pick(onCreateCategory)} />
<MenuItem icon={FilePlus2} label={t('knowledge_new_document')} onClick={() => pick(onCreateDocument)} />
<MenuItem icon={Upload} label={t('knowledge_import_documents')} onClick={() => pick(onImport)} />
</div>
)}
</div>
)
}
const MenuItem: React.FC<{ icon: LucideIcon; label: string; onClick: () => void }> = ({
icon: Icon,
label,
onClick,
}) => (
<button
onClick={onClick}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors text-left"
>
<Icon size={14} className="opacity-70 flex-shrink-0" />
{label}
</button>
)
// ---- Dialog ----------------------------------------------------------------
function templateFor(filename: string): string {
const title = (filename || 'untitled').replace(/\.md$/i, '')
return getLang() === 'zh'
? `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`
: `# ${title}\n\n## Summary\n\n\n## Key points\n\n- \n\n## References\n\n`
}
const KnowledgeDialog: React.FC<{
state: DialogState
categories: string[]
onClose: () => void
onCreateCategory: (path: string) => Promise<string | null>
onPickDocCategory: (category: string) => void
onCreateDocument: (path: string, content: string) => Promise<string | null>
onImport: (files: File[], target: string) => Promise<boolean>
}> = ({ state, categories, onClose, onCreateCategory, onPickDocCategory, onCreateDocument, onImport }) => {
const [categoryInput, setCategoryInput] = useState('')
const [selected, setSelected] = useState(categories[0] || '')
const [filename, setFilename] = useState('')
const [contentInput, setContentInput] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setError('')
if (state.kind === 'category') {
const path = categoryInput.trim()
if (!path) return setError(t('knowledge_field_required'))
setBusy(true)
const ok = await onCreateCategory(path)
setBusy(false)
if (ok !== null) onClose()
return
}
if (state.kind === 'doc-pick-category') {
if (!selected) return setError(t('knowledge_field_required'))
onPickDocCategory(selected)
return
}
if (state.kind === 'document') {
const name = filename.trim()
if (!name) return setError(t('knowledge_doc_filename_required'))
if (/\.[^.]+$/i.test(name) && !/\.md$/i.test(name)) return setError(t('knowledge_doc_must_md'))
if (!contentInput.trim()) return setError(t('knowledge_doc_content_required'))
if (new Blob([contentInput]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
return setError(t('knowledge_doc_content_too_large'))
}
const safeName = name.endsWith('.md') ? name : `${name}.md`
setBusy(true)
const ok = await onCreateDocument(`${state.category}/${safeName}`, contentInput)
setBusy(false)
if (ok !== null) onClose()
return
}
if (state.kind === 'import') {
if (!selected) return setError(t('knowledge_field_required'))
setBusy(true)
const ok = await onImport(state.files || [], selected)
setBusy(false)
if (ok) onClose()
return
}
}
const titleMap: Record<DialogState['kind'], string> = {
category: t('knowledge_new_category'),
'doc-pick-category': t('knowledge_new_document'),
document: t('knowledge_new_document'),
import: t('knowledge_import_documents'),
}
const noCategory = (state.kind === 'doc-pick-category' || state.kind === 'import') && !categories.length
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
<div
className="w-full max-w-lg bg-surface border border-default rounded-xl shadow-xl p-5"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-content mb-1">{titleMap[state.kind]}</h3>
{state.kind === 'category' && (
<>
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_category_subtitle')}</p>
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_category_label')}</label>
<input
autoFocus
value={categoryInput}
onChange={(e) => setCategoryInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder="research/ai"
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors"
/>
<p className="text-xs text-content-tertiary mt-1.5">{t('knowledge_category_hint')}</p>
</>
)}
{state.kind === 'doc-pick-category' && (
<>
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_doc_choose_category')}</p>
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
<CategorySelect value={selected} options={categories} onChange={setSelected} />
</>
)}
{state.kind === 'document' && (
<>
<p className="text-xs text-content-tertiary mb-4">
{tf('knowledge_doc_save_to', { category: state.category || '' })}
</p>
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_doc_filename')}</label>
<input
autoFocus
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="my-note.md"
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors mb-3"
/>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm text-content-secondary">{t('knowledge_doc_content')}</label>
<button
onClick={() => {
if (!contentInput.trim()) setContentInput(templateFor(filename))
}}
className="text-xs text-accent hover:underline cursor-pointer"
>
{t('knowledge_doc_insert_template')}
</button>
</div>
<textarea
value={contentInput}
onChange={(e) => setContentInput(e.target.value)}
rows={10}
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors font-mono resize-y"
/>
</>
)}
{state.kind === 'import' && (
<>
<p className="text-xs text-content-tertiary mb-4">
{tf('knowledge_import_selected', { count: state.files?.length ?? 0 })}
</p>
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
<CategorySelect value={selected} options={categories} onChange={setSelected} />
<p className="text-xs text-content-tertiary mt-1.5">
{categories.length ? t('knowledge_import_hint') : t('knowledge_import_need_category')}
</p>
</>
)}
{error && <p className="text-xs text-danger mt-3">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button
onClick={onClose}
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"
>
{t('knowledge_dialog_cancel')}
</button>
<button
onClick={submit}
disabled={busy || noCategory}
className="px-4 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"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('knowledge_dialog_confirm')}
</button>
</div>
</div>
</div>
)
}
// Custom dropdown: keeps the arrow / menu styling consistent with the rest of
// the desktop UI (a native <select> renders an OS arrow we can't space out).
const CategorySelect: React.FC<{ value: string; options: string[]; onChange: (v: string) => void }> = ({
value,
options,
onChange,
}) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
const disabled = !options.length
return (
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 rounded-btn border text-sm text-content transition-colors cursor-pointer ${
open ? 'border-accent' : 'border-strong'
} bg-inset hover:border-accent/70 disabled:opacity-50 disabled:cursor-not-allowed`}
>
<span className="truncate">{value || '--'}</span>
<ChevronDown
size={14}
className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-60 overflow-y-auto bg-surface border border-default rounded-lg shadow-lg p-1">
{options.map((opt) => (
<button
key={opt}
type="button"
onClick={() => {
onChange(opt)
setOpen(false)
}}
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm cursor-pointer transition-colors truncate ${
opt === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
{opt}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -170,12 +170,16 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
return !!config?.api_keys?.[f]
}
// Only list configured providers (built-in or custom). Unconfigured vendors
// have no usable credentials, so showing them — flagged "unconfigured" — is
// just noise. Keep the current selection so a saved value never disappears.
const providerIds = config?.providers ? Object.keys(config.providers) : []
const providerOptions = providerIds.map((id) => ({
value: id,
label: localizedLabel(providerMeta(id)?.label) || id,
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
const providerOptions = providerIds
.filter((id) => isConfigured(id) || id === provider)
.map((id) => ({
value: id,
label: localizedLabel(providerMeta(id)?.label) || id,
}))
const currentMeta = providerMeta(provider)
const currentUnconfigured = !!provider && !isConfigured(provider)
const modelOptions = [

View File

@@ -46,24 +46,26 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
// A provider is configured when it has credentials (custom providers always
// carry their own). Unconfigured ones stay selectable but are flagged so the
// user is guided to set up the API key.
// A provider is configured when it has credentials (a custom provider counts
// only once it actually carries a name/key, not as an empty placeholder).
const isConfigured = (id: string): boolean => {
const p = data?.providers?.find((x) => x.id === id)
if (!p) return true
return p.configured || (p.is_custom && !!p.custom_name)
}
// Only surface providers that are actually configured (built-in or custom).
// An unconfigured vendor has no usable credentials, so listing it — and
// flagging it "unconfigured" — only adds noise. The currently-selected
// provider is always kept so a saved value never silently disappears.
const providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state.providers || []).map((id) => ({
value: id,
label: providerLabel(data, id),
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
const opts = (state.providers || [])
.filter((id) => isConfigured(id) || id === provider)
.map((id) => ({ value: id, label: providerLabel(data, id) }))
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
return opts
}, [state.providers, data, allowAuto, autoLabel])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.providers, data, allowAuto, autoLabel, provider])
const currentUnconfigured = !!provider && !isConfigured(provider)

View File

@@ -294,8 +294,11 @@ const VendorModal: React.FC<{
const open = !!provider || addMode
// In add-mode the user first picks a built-in provider; that selection
// becomes the effective provider whose key/base fields we edit.
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers])
// becomes the effective provider whose key/base fields we edit. Exclude ALL
// custom providers (named or empty placeholder): custom vendors are added via
// the single "custom vendor" option below, so an empty custom placeholder must
// not show up here as a second, duplicate custom entry.
const builtins = useMemo(() => data.providers.filter((p) => !p.is_custom), [data.providers])
const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
const [pickId, setPickId] = useState('')

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import { t } from '../../i18n'
@@ -50,13 +51,42 @@ export const Dropdown: React.FC<{
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// The menu is rendered in a portal with fixed positioning so it's never
// clipped by an ancestor's `overflow` (e.g. a modal's scroll container).
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null)
const place = () => {
const el = ref.current
if (!el) return
const r = el.getBoundingClientRect()
setRect({ left: r.left, top: r.bottom + 4, width: r.width })
}
useLayoutEffect(() => {
if (open) place()
}, [open])
useEffect(() => {
const h = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
if (!open) return
const onDown = (e: MouseEvent) => {
const target = e.target as Node
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
// Keep the fixed menu anchored to the trigger on scroll/resize by
// re-measuring — NOT closing. Closing on scroll makes the dropdown vanish
// the moment the user scrolls the settings page.
document.addEventListener('mousedown', onDown)
window.addEventListener('resize', place, true)
window.addEventListener('scroll', place, true)
return () => {
document.removeEventListener('mousedown', onDown)
window.removeEventListener('resize', place, true)
window.removeEventListener('scroll', place, true)
}
}, [open])
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
return (
<div ref={ref} className="relative">
@@ -73,28 +103,35 @@ export const Dropdown: React.FC<{
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1">
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)}
{options.map((o) => (
<div
key={o.value}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<div className="truncate">{o.label}</div>
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div>
))}
</div>
)}
{open &&
rect &&
createPortal(
<div
ref={menuRef}
style={{ position: 'fixed', left: rect.left, top: rect.top, width: rect.width }}
className="z-[100] max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1"
>
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)}
{options.map((o) => (
<div
key={o.value}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<div className="truncate">{o.label}</div>
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div>
))}
</div>,
document.body
)}
</div>
)
}

View File

@@ -62,6 +62,47 @@ function stripCancelMarker(text: string): string {
.trim()
}
/**
* Rebuild attachments from `send`-tool results persisted in the message steps.
* SSE `file_to_send` events aren't stored, so on history reload the only record
* of a sent image/file is the tool result JSON. Mirrors the web console's
* `_renderSentFileFromToolResult` so media survives an app restart.
*/
function attachmentsFromSteps(steps: MessageStep[]): Attachment[] {
const out: Attachment[] = []
for (const s of steps) {
if (s.type !== 'tool' || !s.result) continue
let payload: Record<string, unknown>
try {
payload = typeof s.result === 'string' ? JSON.parse(s.result) : (s.result as unknown as Record<string, unknown>)
} catch {
continue
}
if (!payload || payload.type !== 'file_to_send') continue
const rawPath = (payload.path as string) || ''
const url = (payload.url as string) || ''
if (!rawPath && !url) continue
const isRemote = url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://')
// Local files are served via /api/file; remote URLs are used directly.
const previewUrl = isRemote
? url
: rawPath.toLowerCase().startsWith('http')
? rawPath
: apiClient.getServeFileUrl(rawPath)
const kind = (payload.file_type as string) || 'file'
const fileType: Attachment['file_type'] =
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
out.push({
file_path: previewUrl,
file_name: (payload.file_name as string) || 'file',
file_type: fileType,
preview_url: previewUrl,
abs_path: isRemote ? undefined : rawPath,
})
}
return out
}
/** Convert a backend history message into a UI ChatMessage. */
function historyToMessage(m: HistoryMessage): ChatMessage {
if (m.role === 'user') {
@@ -89,6 +130,7 @@ function historyToMessage(m: HistoryMessage): ChatMessage {
.filter((_, i) => i !== lastContentIdx)
.map((s) => ({ ...s }))
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
const attachments = attachmentsFromSteps(raw)
return {
id: uid('assistant'),
@@ -100,6 +142,7 @@ function historyToMessage(m: HistoryMessage): ChatMessage {
kind: m.kind,
extras: m.extras,
botSeq: m._seq,
attachments: attachments.length > 0 ? attachments : undefined,
}
}
@@ -218,6 +261,31 @@ export const useChatStore = create<ChatState>((set, get) => {
}))
break
case 'image':
case 'file': {
// Media pushed by the `send` tool (file_to_send). `content` is either
// a backend /api/file?path=... URL or a passed-through http(s) URL.
const url = data.content || ''
if (!url) break
// Prefer the concrete media kind from the backend (image/video/...);
// fall back to the coarse SSE event type.
const kind = data.file_type || (data.type === 'image' ? 'image' : 'file')
const attType: Attachment['file_type'] =
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
const att: Attachment = {
file_path: url,
file_name: data.file_name || 'file',
file_type: attType,
preview_url: url,
abs_path: data.abs_path,
}
updateMsg(sid, botId, (m) => ({
...m,
attachments: [...(m.attachments || []), att],
}))
break
}
case 'cancelled':
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
break

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { UpdateStatus } from '../types'
import { getLang } from '../i18n'
interface UpdateState {
status: UpdateStatus | null
@@ -7,11 +8,33 @@ interface UpdateState {
version: string | null
/** Download progress 0-100 while state === 'downloading'. */
percent: number
/** User clicked "download" but no progress event has arrived yet. Gives the
* button an instant busy state so it can't be clicked again during the 1-2s
* lead-up to the first progress event. Cleared on the first 'downloading'. */
preparing: boolean
/** The download progress already reached ~100% once. macOS (Squirrel.Mac)
* emits a SECOND progress pass (verify / block-map) after the first, which
* used to make the bar visibly restart from 0. Once peaked we render an
* indeterminate "verifying" state instead of a confusing second bar. */
progressPeaked: boolean
/** User clicked "restart to install"; show a full-screen "installing…"
* overlay for the brief window before the app quits to swap the bundle. */
installing: boolean
/** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null
/** Whether the update panel is currently shown. Lifted here so the "check
* for update" menu item can re-open it on demand. */
panelOpen: boolean
setStatus: (s: UpdateStatus) => void
/** Dismiss the floating badge/panel for the current version (footer dot goes
* away), but keep the update itself known so the menu can still surface it. */
dismiss: () => void
openPanel: () => void
closePanel: () => void
/** User explicitly clicked "check for update": ask main to re-check, and if
* an update is already known, re-open the panel immediately (undismiss). */
recheck: () => void
// Actions proxied to the main process via the preload bridge.
download: () => void
@@ -22,20 +45,58 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
status: null,
version: null,
percent: 0,
preparing: false,
progressPeaked: false,
installing: false,
dismissedVersion: null,
panelOpen: false,
setStatus: (s) =>
set(() => {
if (s.state === 'available') return { status: s, version: s.version, percent: 0 }
if (s.state === 'downloading') return { status: s, percent: s.percent }
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
set((st) => {
// A newly detected version auto-opens the panel.
if (s.state === 'available')
return { status: s, version: s.version, percent: 0, preparing: false, progressPeaked: false, panelOpen: true }
if (s.state === 'downloading') {
// First real progress event clears the "preparing" busy state. Track
// when we've hit ~100% so the Squirrel.Mac second pass renders as an
// indeterminate "verifying" state instead of a bar restarting from 0.
const peaked = st.progressPeaked || s.percent >= 99
return { status: s, percent: s.percent, preparing: false, progressPeaked: peaked }
}
if (s.state === 'downloaded')
return { status: s, version: s.version, percent: 100, preparing: false }
if (s.state === 'error') return { status: s, preparing: false, installing: false }
return { status: s }
}),
dismiss: () => set((st) => ({ dismissedVersion: st.version })),
dismiss: () => set((st) => ({ dismissedVersion: st.version, panelOpen: false })),
openPanel: () => set({ panelOpen: true }),
closePanel: () => set({ panelOpen: false }),
download: () => window.electronAPI?.downloadUpdate?.(),
install: () => window.electronAPI?.installUpdate?.(),
recheck: () => {
// Clear any dismiss so a known update surfaces again. Only re-open the panel
// when an update actually exists — if we're already up to date, opening the
// panel would just flash it (the banner renders nothing for not-available)
// and the "up to date" feedback belongs in the menu, not a panel. The menu
// (NavRail) decides whether to close itself + show the panel based on this.
const known = hasAvailableUpdate(get())
set({ dismissedVersion: null, panelOpen: known })
// Always kick a fresh check too (picks up newer versions / recovers errors).
// Pass the UI language so downloads route to the China CDN / R2 accordingly.
window.electronAPI?.checkForUpdate?.(getLang())
},
download: () => {
// Enter a busy state immediately so the button can't be clicked twice while
// we wait (1-2s) for the first download-progress event to arrive.
set({ preparing: true, progressPeaked: false })
window.electronAPI?.downloadUpdate?.(getLang())
},
install: () => {
// Show the "installing…" overlay before the app quits to swap the bundle.
set({ installing: true })
window.electronAPI?.installUpdate?.()
},
}))
// Subscribe to main-process update events. Returns an unsubscribe fn.
@@ -45,11 +106,18 @@ export function initUpdateListener(): (() => void) | undefined {
})
}
// Whether a new version should be surfaced (available/downloading/downloaded
// and not dismissed for that version).
export function hasPendingUpdate(state: UpdateState): boolean {
// Whether an update exists at all (available/downloading/downloaded),
// regardless of dismiss. Drives the "check for update" menu item's dot, which
// should persist as long as an update is actually available.
export function hasAvailableUpdate(state: UpdateState): boolean {
const s = state.status
if (!s) return false
const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
return active && state.dismissedVersion !== state.version
return s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
}
// Whether a new version should be surfaced in the floating footer badge:
// available and not dismissed for that version. Dismissing hides only this,
// not the menu dot (hasAvailableUpdate).
export function hasPendingUpdate(state: UpdateState): boolean {
return hasAvailableUpdate(state) && state.dismissedVersion !== state.version
}

View File

@@ -8,6 +8,8 @@ export interface ElectronAPI {
restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
/** Open a local file with the OS default app. Resolves to '' on success. */
openPath: (targetPath: string) => Promise<string>
// Listener registrars return an unsubscribe fn for cleanup.
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
onBackendLog: (callback: (line: string) => void) => () => void
@@ -17,9 +19,11 @@ export interface ElectronAPI {
windowIsMaximized: () => Promise<boolean>
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
onMenuAction?: (callback: (action: string) => void) => () => void
// Auto-update
checkForUpdate?: () => Promise<void>
downloadUpdate?: () => Promise<void>
// Current app version string (e.g. "0.0.5").
getAppVersion?: () => Promise<string>
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
checkForUpdate?: (lang?: string) => Promise<void>
downloadUpdate?: (lang?: string) => Promise<void>
installUpdate?: () => Promise<void>
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
platform: string
@@ -92,6 +96,9 @@ export interface Attachment {
file_name: string
file_type: 'image' | 'video' | 'file' | 'directory'
preview_url?: string
/** Local absolute path (set for files sent via the `send` tool) so the
* desktop client can open them directly with the OS default app. */
abs_path?: string
}
/** Live tool event during SSE streaming. */
@@ -135,6 +142,7 @@ export interface StreamEvent {
execution_time?: number
has_tool_calls?: boolean
path?: string
abs_path?: string
file_name?: string
file_type?: string
web_url?: string
@@ -422,11 +430,27 @@ export interface KnowledgeGraph {
export type KnowledgeAction =
| { action: 'create_category'; payload: { path: string } }
| { action: 'create_document'; payload: { path: string; content: string; overwrite?: boolean } }
| { action: 'rename_category'; payload: { path: string; new_path: string } }
| { action: 'delete_category'; payload: { path: string; confirm?: boolean } }
| { action: 'delete_documents'; payload: { paths: string[] } }
| { action: 'move_documents'; payload: { paths: string[]; target_category: string } }
// Result row from a bulk import (one per uploaded file).
export interface KnowledgeImportResult {
status: 'imported' | 'skipped' | 'failed'
path?: string
name?: string
message?: string
}
export interface KnowledgeImportPayload {
imported: number
skipped: number
failed: number
results: KnowledgeImportResult[]
}
// ============================================================
// Scheduler
// ============================================================

View File

@@ -22,6 +22,10 @@
"label": "官网",
"href": "https://cowagent.ai/"
},
{
"label": "下载",
"href": "https://cowagent.ai/download/"
},
{
"label": "博客",
"href": "https://cowagent.ai/zh/blog/"
@@ -55,6 +59,10 @@
"label": "Website",
"href": "https://cowagent.ai/"
},
{
"label": "Download",
"href": "https://cowagent.ai/download/"
},
{
"label": "Blog",
"href": "https://cowagent.ai/blog/"
@@ -86,6 +94,7 @@
"group": "Installation",
"pages": [
"guide/quick-start",
"guide/desktop",
"guide/manual-install",
"guide/upgrade"
]
@@ -249,6 +258,7 @@
"group": "Release Notes",
"pages": [
"releases/overview",
"releases/v2.1.3",
"releases/v2.1.2",
"releases/v2.1.1",
"releases/v2.1.0",
@@ -276,6 +286,10 @@
"label": "官网",
"href": "https://cowagent.ai/?lang=zh"
},
{
"label": "下载",
"href": "https://cowagent.ai/zh/download/"
},
{
"label": "博客",
"href": "https://cowagent.ai/zh/blog/"
@@ -307,6 +321,7 @@
"group": "安装部署",
"pages": [
"zh/guide/quick-start",
"zh/guide/desktop",
"zh/guide/manual-install",
"zh/guide/upgrade"
]
@@ -470,6 +485,7 @@
"group": "发布记录",
"pages": [
"zh/releases/overview",
"zh/releases/v2.1.3",
"zh/releases/v2.1.2",
"zh/releases/v2.1.1",
"zh/releases/v2.1.0",
@@ -497,6 +513,10 @@
"label": "ウェブサイト",
"href": "https://cowagent.ai/"
},
{
"label": "ダウンロード",
"href": "https://cowagent.ai/download/"
},
{
"label": "ブログ",
"href": "https://cowagent.ai/blog/"
@@ -528,6 +548,7 @@
"group": "インストール",
"pages": [
"ja/guide/quick-start",
"ja/guide/desktop",
"ja/guide/manual-install",
"ja/guide/upgrade"
]
@@ -691,6 +712,7 @@
"group": "リリースノート",
"pages": [
"ja/releases/overview",
"ja/releases/v2.1.3",
"ja/releases/v2.1.2",
"ja/releases/v2.1.1",
"ja/releases/v2.1.0",

36
docs/guide/desktop.mdx Normal file
View File

@@ -0,0 +1,36 @@
---
title: Desktop Client
description: Download and use the CowAgent desktop client (macOS / Windows)
---
CowAgent ships a ready-to-use desktop client with the Agent runtime bundled in — **no need to install Python or dependencies manually**. Just download, install, and run your local super AI assistant.
## Download & Install
<Card title="Go to the download page" icon="download" href="https://cowagent.ai/download/">
Download the macOS / Windows installer
</Card>
<Tabs>
<Tab title="macOS">
1. Open the [download page](https://cowagent.ai/download/) and pick the build for your chip:
- Apple Silicon (M1/M2/M3/M4): download the `arm64` build
- Intel: download the `x64` build
2. Open the downloaded `.dmg` and drag CowAgent into your Applications folder.
3. Launch CowAgent from Launchpad or Applications.
</Tab>
<Tab title="Windows">
1. Open the [download page](https://cowagent.ai/download/) and download the Windows installer (`.exe`).
2. Run the installer and follow the prompts (you can choose the install directory).
3. Launch CowAgent from the desktop or Start menu.
</Tab>
</Tabs>
## Auto Update
The desktop client has built-in auto-update. When a new version is available it is detected automatically; you can also manually "Check for updates" from the menu in the bottom-left corner and upgrade with one click.
## Desktop vs. Command-line Deployment
- **Desktop client**: best for personal use on your own computer — works out of the box, GUI-based, auto-updating.
- **Command-line deployment**: best for developers or long-running servers with more customization. See [Quick Start](/guide/quick-start).

View File

@@ -24,6 +24,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
<a href="https://docs.cowagent.ai/ja/intro/index">📖 ドキュメント</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/ja/guide/quick-start">🚀 クイックスタート</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> &nbsp;·&nbsp;
<a href="https://cowagent.ai/download/">💻 ダウンロード</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ オンラインで試す</a>
</p>
@@ -95,6 +96,8 @@ cow skill install <名前> # Skill のインストール
cow install-browser # ブラウザツールのインストール
```
> 💻 デスクトップクライアント:**[CowAgent デスクトップクライアント](https://cowagent.ai/download/)**macOS / Windowsはバックエンドを内蔵し、ダウンロードしてすぐに使えます。
<br/>
## 🤖 モデル
@@ -103,13 +106,13 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
@@ -199,6 +202,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
## 🏷 更新履歴
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)macOS / Windows、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー、自己進化の改善、新モデルkimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデルMiniMax-M3、qwen3.7-plus、Python 3.13 対応。

36
docs/ja/guide/desktop.mdx Normal file
View File

@@ -0,0 +1,36 @@
---
title: デスクトップクライアント
description: CowAgent デスクトップクライアントmacOS / Windowsのダウンロードと使い方
---
CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスクトップクライアントを提供しています。**Python や依存関係を手動でインストールする必要はありません**。ダウンロードしてインストールするだけで、ローカルでスーパー AI アシスタントを実行できます。
## ダウンロードとインストール
<Card title="ダウンロードページへ" icon="download" href="https://cowagent.ai/download/">
macOS / Windows 用インストーラーをダウンロード
</Card>
<Tabs>
<Tab title="macOS">
1. [ダウンロードページ](https://cowagent.ai/download/) を開き、チップに合わせて選択します:
- Apple SiliconM1/M2/M3/M4`arm64` 版をダウンロード
- Intel`x64` 版をダウンロード
2. ダウンロードした `.dmg` を開き、CowAgent を「アプリケーション」フォルダにドラッグします。
3. Launchpad またはアプリケーションから CowAgent を起動します。
</Tab>
<Tab title="Windows">
1. [ダウンロードページ](https://cowagent.ai/download/) を開き、Windows インストーラー(`.exe`)をダウンロードします。
2. インストーラーを実行し、画面の指示に従います(インストール先を選択できます)。
3. デスクトップまたはスタートメニューから CowAgent を起動します。
</Tab>
</Tabs>
## 自動アップデート
デスクトップクライアントには自動アップデート機能が組み込まれています。新しいバージョンがあると自動的に検出・通知され、左下のメニューから手動で「アップデートを確認」してワンクリックでアップグレードすることもできます。
## デスクトップとコマンドラインの違い
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/ja/guide/quick-start) を参照。

View File

@@ -79,6 +79,22 @@ Deep Dream は以下の整理ルールに従います:
初回デプロイ後は `/memory dream 30` を一度実行して、すべての履歴日次記憶を MEMORY.md に蒸留することをお勧めします。
</Tip>
## 設定スイッチ
`config.json` の `deep_dream_enabled` で毎晩の自動蒸留を制御します:
```json
{
"deep_dream_enabled": true
}
```
| 設定項目 | 説明 | デフォルト |
| --- | --- | --- |
| `deep_dream_enabled` | 毎日の定期蒸留の有効化 | `true` |
デフォルトで有効になっており、既存の動作と互換性があります。無効にすると毎晩の自動蒸留が実行されなくなります。MEMORY.md を手動管理したい場合や LLM 呼び出しを 1 回節約したい場合に適しています(日次記憶の要約には影響しません)。手動コマンド `/memory dream` もこのスイッチの影響を受けず、いつでもトリガー可能です。
## 安全メカニズム
| メカニズム | 説明 |

View File

@@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
```json
{
"model": "claude-fable-5",
"model": "claude-sonnet-5",
"claude_api_key": "YOUR_API_KEY"
}
```
| パラメータ | 説明 |
| --- | --- |
| `model` | `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
| `model` | `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
@@ -28,8 +28,8 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
| モデル | 用途 |
| --- | --- |
| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め |
| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い |
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
@@ -44,7 +44,7 @@ Vision モデルを手動で指定したい場合は、設定ファイルで明
{
"tools": {
"vision": {
"model": "claude-sonnet-4-6"
"model": "claude-sonnet-5"
}
}
}

View File

@@ -13,20 +13,20 @@ Doubao火山方舟はテキスト対話、画像理解、画像生成Se
```json
{
"model": "doubao-seed-2-0-pro-260215",
"model": "doubao-seed-2-1-pro-260628",
"ark_api_key": "YOUR_API_KEY"
}
```
| パラメータ | 説明 |
| --- | --- |
| `model` | `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` などを指定可能 |
| `model` | `doubao-seed-2-1-pro-260628`、`doubao-seed-2-1-turbo-260628`、`doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215` などを指定可能 |
| `ark_api_key` | [火山方舟コンソール](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) で作成 |
| `ark_base_url` | 任意。デフォルトは `https://ark.cn-beijing.volces.com/api/v3` |
## 画像理解
`ark_api_key` を設定すると、Agent の Vision ツールは自動的に `doubao-seed-2-0-pro-260215` を使用して画像を認識します。追加設定は不要です。
`ark_api_key` を設定し、メインモデルが Doubao 系の場合、Agent の Vision ツールは現在のメインモデルを自動的に使用して画像を認識します。追加設定は不要です。
Vision モデルを手動で指定したい場合は:
@@ -34,7 +34,7 @@ Vision モデルを手動で指定したい場合は:
{
"tools": {
"vision": {
"model": "doubao-seed-2-0-pro-260215"
"model": "doubao-seed-2-1-pro-260628"
}
}
}

View File

@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
<Note>
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨しますdeepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨しますdeepseek-v4-flash、MiniMax-M3、claude-sonnet-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
</Note>
@@ -20,12 +20,12 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

@@ -40,7 +40,7 @@ description: LinkAI プラットフォーム経由でテキスト、ビジョン
}
```
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` など。
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` など。
## 画像生成

View File

@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
| バージョン | 日付 | 説明 |
| --- | --- | --- |
| [2.1.3](/ja/releases/v2.1.3) | 2026.07.08 | デスクトップクライアント正式リリースmacOS / Windows、ナレッジベースのドキュメント管理強化、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加claude-sonnet-5、doubao-seed-2.1 など)、セキュリティ強化と改善 |
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー、自己進化の改善、新モデル追加kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加MiniMax-M3、qwen3.7-plus など)、各種改善 |
| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル、MCP Streamable HTTP、新モデル追加 |

View File

@@ -0,0 +1,73 @@
---
title: v2.1.3
description: "CowAgent 2.1.3デスクトップクライアントmacOS / Windowsを正式リリース。ナレッジベースのドキュメント管理を強化し、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデルに加え、セキュリティと使い勝手の各種改善"
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.3) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.3)
## 🖥 デスクトップクライアント
**CowAgent デスクトップクライアント**を正式にリリースしました。**macOS と Windows** に対応し、ローカルのスーパー AI アシスタントをそのまますぐに使えます。
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent デスクトップクライアント" />
主な特長:
- **すぐに使える**Agent の実行環境を内蔵しており、インストール後すぐに起動できます。Python などの依存関係を用意する必要はありません
- **充実したチャット体験**ストリーミング応答、セッション管理、ツール呼び出しのステップ表示、Markdown レンダリングに加え、画像 / 動画 / ファイルの送信とプレビューに対応
- **ビジュアル管理**:設定、モデル、ナレッジベース、定期タスク、スキル、メモリなどの画面が Web コンソールと同等で、ネイティブ UI 上で管理できます
- **チャネル接続**:アプリ内で QR コードをスキャンして各種メッセージチャネルに接続
- **自動アップデート**:バージョンの自動チェックとワンクリック更新に対応し、地域ごとにダウンロード速度を最適化
- **ネイティブ体験**:初回起動ガイド、システム言語への追従、プラットフォームに適応したウィンドウ操作
ダウンロード:[CowAgent デスクトップ](https://cowagent.ai/download/)
## 📚 ナレッジベース
- **ドキュメントの作成とインポート**UI から直接ドキュメントを新規作成したり、外部ドキュメントをインポートできます
- **インデックスの自動メンテナンス**:実際のディレクトリ構成からインデックスを自動的に再構築し、インデックスのずれやドキュメントの欠落を防ぎます
- **ベクトル化の修正**:インデックスが統一された埋め込みプロバイダーを再利用するようになり、キーワード検索へのフォールバックではなく本来のセマンティックベクトルを生成します
Thanks @yangziyu-hhh
ドキュメント:[ナレッジベース](https://docs.cowagent.ai/ja/memory/knowledge)
## 🔌 MCP ツールのオンデマンド検索
多数の MCP ツールを接続した際のコンテキスト肥大化に対応するため、**オンデマンドのツール検索**を追加しました。現在のタスクに関連する MCP ツールを RAG ベクトル検索で必要に応じて読み込み、無関係なツールがコンテキストを占有するのを抑えます。Thanks @fengyl07
ドキュメント:[MCP ツール](https://docs.cowagent.ai/ja/tools/mcp)
## 🌏 繁体字中国語対応
Web コンソール、ログ、ドキュメントが **繁体字中国語zh-Hant** に対応しました。インターフェース言語はシステムに追従するか、手動で切り替えられます。Thanks @anomixer (#2935)
## 🤖 新モデル追加
- **claude-sonnet-5**、**claude-fable-5** に対応
- **doubao-seed-2-1-pro**、**doubao-seed-2-1-turbo** に対応
ドキュメント:[モデル](https://docs.cowagent.ai/ja/models)
## 🔒 セキュリティ強化
- **機密ファイル読み取り防護**認証情報などの機密ファイルへのアクセスを強化し、迂回による読み取りを防止します。Thanks @fengyl07 (#2913)
- **ブラウザアクセス防護**ブラウザによる内部ネットワークやクラウドサーバーの内部エンドポイントへのリクエストをブロックし、内部サービスへ誘導されるリスクを低減します。Thanks @christop
- **設定解析の安全化**設定内容をより安全な方法で解析し、潜在的なコード実行リスクを回避します。Thanks @shunfeng8421
## 🛠 改善と修正
- **カスタムプロバイダー対応**:埋め込みモデルとビジョンモデルでカスタムプロバイダーを利用可能に。あわせて Windows でのメモリ取得の問題を修正しました。Thanks @HnBigVolibear
- **ファイル編集の安定性向上**元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @xiaweiwei67-stack (#2942)
- **コマンド出力の文字化け修正**コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @xiaweiwei67-stack (#2941)
- **Azure OpenAI の修正**Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Eric L
- **企業向け WeChat スマートボット**webhookコールバックモードの接続ドキュメントを追加しました。Thanks @6vision
- **ディープドリームの切り替え**`deep_dream_enabled` の専用スイッチを追加し、ディープドリームの蒸留を個別に有効・無効化できます。
- **安定性**Web サービスの接続回収を改善し、自己進化に関するいくつかの問題を修正しました (#2924, #2904)
## 📦 アップグレード方法
- **デスクトップクライアント**[ダウンロードページ](https://cowagent.ai/download/) から最新バージョンを入手してください。
- **ソースデプロイ**`cow update` でワンクリックアップグレード、または最新コードを取得して再起動します。詳しくは [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。
**リリース日**2026.07.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.2...2.1.3)

View File

@@ -22,7 +22,7 @@ Vision ツールは多段階の自動選択 + 自動フォールバック戦略
| 通義千問 (DashScope) | メインモデルを使用 | 例qwen3.7-plus など |
| Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 |
| Gemini | メインモデルを使用 | inlineData 形式 |
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-0 シリーズがネイティブ対応 |
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-1 シリーズがネイティブ対応 |
| Kimi (Moonshot) | メインモデルを使用 | kimi-k2.6、kimi-k2.5 がネイティブ対応 |
| 百度 Qianfan | メインモデルを使用 | デフォルトでマルチモーダルメインモデル(`ernie-5.1` など)を使用。メインモデルが非対応の場合は `ernie-4.5-turbo-vl` にフォールバック |
| 智谱 AI | glm-5v-turbo | 常にビジョン専用モデルを使用 |

View File

@@ -79,6 +79,22 @@ In addition to the automatic daily run, you can manually trigger distillation in
After first deployment, it's recommended to run `/memory dream 30` once to distill all historical daily memories into MEMORY.md.
</Tip>
## Config Toggle
Control the nightly automatic distillation via `deep_dream_enabled` in `config.json`:
```json
{
"deep_dream_enabled": true
}
```
| Key | Description | Default |
| --- | --- | --- |
| `deep_dream_enabled` | Enable the daily scheduled distillation | `true` |
Enabled by default to keep existing behavior. When disabled, the nightly distillation no longer runs — useful if you prefer to maintain MEMORY.md manually or want to save an LLM call (daily memory summarization is not affected). The manual `/memory dream` command is also unaffected and can still be triggered anytime.
## Safety Mechanisms
| Mechanism | Description |

View File

@@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand
```json
{
"model": "claude-fable-5",
"model": "claude-sonnet-5",
"claude_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Supports `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `model` | Supports `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
@@ -28,8 +28,8 @@ Claude is provided by Anthropic and supports both text chat and image understand
| Model | Use Case |
| --- | --- |
| `claude-fable-5` | Latest flagship; best for complex reasoning and long-running tasks, at a higher price |
| `claude-opus-4-8` | Previous flagship with balanced quality and cost |
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
| `claude-opus-4-7` | Earlier Opus flagship |
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
@@ -44,7 +44,7 @@ To manually specify a Vision model, set it explicitly in the configuration file:
{
"tools": {
"vision": {
"model": "claude-sonnet-4-6"
"model": "claude-sonnet-5"
}
}
}

View File

@@ -13,20 +13,20 @@ Doubao (Volcengine Ark) supports text chat, image understanding, image generatio
```json
{
"model": "doubao-seed-2-0-pro-260215",
"model": "doubao-seed-2-1-pro-260628",
"ark_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Can be `doubao-seed-2-0-pro-260215`, `doubao-seed-2-0-code-preview-260215`, `doubao-seed-2-0-lite-260215`, etc. |
| `model` | Can be `doubao-seed-2-1-pro-260628`, `doubao-seed-2-1-turbo-260628`, `doubao-seed-2-0-pro-260215`, `doubao-seed-2-0-code-preview-260215`, etc. |
| `ark_api_key` | Create one in the [Volcengine Ark Console](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) |
| `ark_base_url` | Optional, defaults to `https://ark.cn-beijing.volces.com/api/v3` |
## Image Understanding
Once `ark_api_key` is configured, the Agent's Vision tool automatically uses `doubao-seed-2-0-pro-260215` to recognize images, with no extra setup required.
Once `ark_api_key` is configured and the main model is a Doubao model, the Agent's Vision tool automatically uses the current main model to recognize images, with no extra setup required.
To manually specify a Vision model:
@@ -34,7 +34,7 @@ To manually specify a Vision model:
{
"tools": {
"vision": {
"model": "doubao-seed-2-0-pro-260215"
"model": "doubao-seed-2-1-pro-260628"
}
}
}

View File

@@ -13,12 +13,12 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |

View File

@@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v
}
```
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc.
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-1-pro-260628`, `kimi-k2.6`, `claude-sonnet-5`, `gemini-3.1-flash-lite-preview`, etc.
## Image Generation

View File

@@ -5,6 +5,7 @@ description: CowAgent version history
| Version | Date | Description |
| --- | --- | --- |
| [2.1.3](/releases/v2.1.3) | 2026.07.08 | Desktop client released (macOS / Windows), knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models (claude-sonnet-5, doubao-seed-2.1, etc.), security hardening and refinements |
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | Web Console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements |
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |
| [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models |

74
docs/releases/v2.1.3.mdx Normal file
View File

@@ -0,0 +1,74 @@
---
title: v2.1.3
description: "CowAgent 2.1.3: Desktop client for macOS / Windows, enhanced knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models, plus security and experience improvements"
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.3) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.3)
## 🖥 Desktop Client
Introducing the **CowAgent Desktop client** for **macOS and Windows** — your local super AI assistant, truly ready to use out of the box.
Download: [CowAgent Desktop](https://cowagent.ai/download/)
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent Desktop client" />
Highlights:
- **Out of the box**: the full Agent runtime is bundled — launch right after install, no need to set up Python or other dependencies
- **Full chat experience**: streaming replies, session management, tool-call step display, Markdown rendering, plus sending and previewing images / videos / files
- **Visual management**: configuration, models, knowledge base, scheduled tasks, skills, and memory pages mirror the Web console, all manageable in the native UI
- **Channel onboarding**: connect messaging channels by scanning a QR code right inside the app
- **Auto update**: automatic version checks and one-click updates, with download speed optimized across regions
- **Native experience**: first-run onboarding, follows the system language, and platform-adaptive window interactions
## 📚 Knowledge Base
- **Create & import documents**: create new documents or import external ones directly from the UI
- **Automatic index maintenance**: the knowledge base index is rebuilt automatically from the actual directory tree, preventing index drift or lost documents
- **Vectorization fix**: the index now reuses the unified embedding provider, ensuring real semantic vectors instead of falling back to keyword search
Thanks @yangziyu-hhh
Docs: [Knowledge Base](https://docs.cowagent.ai/memory/knowledge)
## 🔌 On-demand MCP Tool Retrieval
To address context bloat when many MCP tools are connected, we added **on-demand tool retrieval**: relevant MCP tools are loaded on demand via RAG vector search based on the current task, reducing the context taken up by irrelevant tools. Thanks @fengyl07
Docs: [MCP Tools](https://docs.cowagent.ai/tools/mcp)
## 🌏 Traditional Chinese Support
The Web console, logs, and documentation now support **Traditional Chinese (zh-Hant)**; the interface language can follow the system or be switched manually. Thanks @anomixer (#2935)
## 🤖 New Models
- Added support for **claude-sonnet-5** and **claude-fable-5**
- Added support for **doubao-seed-2-1-pro** and **doubao-seed-2-1-turbo**
Docs: [Models](https://docs.cowagent.ai/models)
## 🔒 Security Hardening
- **Sensitive file read protection**: hardened access to credential and other sensitive files to prevent bypass reads. Thanks @fengyl07 (#2913)
- **Browser access protection**: blocks browser requests targeting internal network and cloud server internal endpoints, reducing the risk of being tricked into reaching internal services. Thanks @christop
- **Safer config parsing**: config content is parsed in a safer way to avoid potential code execution risks. Thanks @shunfeng8421
## 🛠 Improvements & Fixes
- **Custom provider support**: embedding and vision models can now use custom providers; also fixed a memory query issue on Windows. Thanks @HnBigVolibear
- **More reliable file editing**: better preserves original indentation, and fuzzy matching no longer touches unrelated content. Thanks @xiaweiwei67-stack (#2942)
- **Command output encoding fix**: fixed garbled Chinese characters when a command produces large output. Thanks @xiaweiwei67-stack (#2941)
- **Azure OpenAI fixes**: fixed streaming output and related configuration issues for Azure OpenAI. Thanks @Eric L
- **WeCom Smart Bot**: added channel docs for the webhook (callback) mode. Thanks @6vision
- **Deep Dream toggle**: added a dedicated `deep_dream_enabled` switch to enable or disable Deep Dream distillation independently.
- **Stability**: improved connection recycling in the Web service and fixed several Self-Evolution issues (#2924, #2904)
## 📦 How to Upgrade
- **Desktop client**: get the latest version from the [download page](https://cowagent.ai/download/).
- **Source deployment**: run `cow update` for a one-click upgrade, or pull the latest code and restart. See the [upgrade guide](https://docs.cowagent.ai/guide/upgrade).
**Release date**: 2026.07.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.2...2.1.3)

View File

@@ -22,7 +22,7 @@ If the current provider fails, the tool automatically tries the next one until i
| Qwen (DashScope) | Main model | e.g. qwen3.7-plus, etc. |
| Claude | Main model | Anthropic native image format |
| Gemini | Main model | inlineData format |
| Doubao | Main model | doubao-seed-2-0 series natively supported |
| Doubao | Main model | doubao-seed-2-1 series natively supported |
| Kimi (Moonshot) | Main model | kimi-k2.6, kimi-k2.5 natively supported |
| ERNIE | Main model | Defaults to the multimodal main model (e.g. `ernie-5.1`); falls back to `ernie-4.5-turbo-vl` when the main model is not multimodal |
| ZhipuAI | glm-5v-turbo | Always uses the dedicated vision model |

291
docs/zh/README-Hant.md Normal file
View File

@@ -0,0 +1,291 @@
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center">
[<a href="../../README.md">English</a>] | [<a href="README.md">中文</a>] | [繁體中文] | [<a href="../ja/README.md">日本語</a>]
</p>
**CowAgent** 是一個開源的超級 AI 助理,能夠主動思考和規劃任務、操作電腦和外部資源、創造和執行 Skills、構建知識庫與長期記憶、透過自主進化與你一同成長是 Agent Harness 工程的最佳實踐之一。
CowAgent 輕量、易部署、可擴充自由接入主流大模型覆蓋微信、飛書、釘釘、企微、QQ、Telegram、Slack、網頁等多渠道7×24 執行於個人電腦或伺服器中。
<p align="center">
<a href="https://cowagent.ai/?lang=zh">🌐 官網</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/zh/">📖 文件中心</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/zh/guide/quick-start">🚀 快速開始</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 技能廣場</a> &nbsp;·&nbsp;
<a href="https://cowagent.ai/zh/download/">💻 下載客戶端</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ 線上體驗</a>
</p>
<br/>
## 🌟 核心能力
| 能力 | 說明 |
| :--- | :--- |
| [任務規劃](https://docs.cowagent.ai/zh/intro/architecture) | 理解複雜任務並自主分解執行,迴圈呼叫工具直到完成目標 |
| [長期記憶](https://docs.cowagent.ai/zh/memory) | 三層記憶架構(上下文 → 天級 → 核心),夢境蒸餾自動整理,支援關鍵詞與向量混合檢索 |
| [知識庫](https://docs.cowagent.ai/zh/knowledge) | 自動整理結構化知識為 Markdown Wiki構建持續增長的知識圖譜視覺化瀏覽 |
| [自主進化](https://docs.cowagent.ai/zh/memory/self-evolution) | 自動覆盤對話,最佳化技能、處理未完成事項、沉澱記憶與知識,在使用中持續成長 |
| [技能](https://docs.cowagent.ai/zh/skills) | 從 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一鍵安裝;也可透過對話創造自定義技能 |
| [工具](https://docs.cowagent.ai/zh/tools) | 內建檔案讀寫、終端、瀏覽器、定時任務、記憶檢索、聯網搜尋等 10+ 工具,支援 MCP 協議 |
| [通道](https://docs.cowagent.ai/zh/channels) | 一個 Agent 同時接入 Web、微信、飛書、釘釘、企微、QQ、公眾號、Telegram、Slack 等多個渠道 |
| 多模態 | 文字、圖片、語音、檔案全訊息型別支援,覆蓋識別、生成、收發 |
| [模型](https://docs.cowagent.ai/zh/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流廠商,設定一行切換 |
| [部署](https://docs.cowagent.ai/zh/guide/quick-start) | 一鍵指令碼安裝Web 控制台統一管理本地、Docker、伺服器多種部署方式 |
<br/>
## 🏗️ 架構總覽
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/zh/architecture.jpg" alt="CowAgent Architecture" width="750"/>
CowAgent 是一個完整的 **Agent Harness**:訊息從各類**通道**進入,**Agent Core** 結合記憶、知識庫與可用工具/技能進行任務規劃與決策,呼叫**模型**生成結果,再回傳至原通道。各模組解耦清晰,按需擴充。
詳見 [專案架構](https://docs.cowagent.ai/zh/intro/architecture)。
<br/>
## 🚀 快速開始
專案提供一鍵安裝指令碼,自動完成依賴安裝、設定和啟動:
**Linux / macOS**
```bash
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
```
**WindowsPowerShell**
```powershell
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
```
**Docker**
```bash
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
docker compose up -d
```
啟動成功後開啟 `http://localhost:9899` 進入 **Web 控制台**,在控制台內即可完成模型設定、渠道接入、技能安裝等全部操作。
> 伺服器部署且需要從外部網路存取控制台時,請在 `config.json` 中將 `web_host` 設為 `0.0.0.0`(同時強烈建議設定 `web_password` 啟用身分驗證),然後訪問 `http://<server-ip>:9899`,並確保防火牆/安全組放行 `9899` 埠。
> 📖 詳細安裝指南:[快速開始](https://docs.cowagent.ai/zh/guide/quick-start) · [原始碼安裝](https://docs.cowagent.ai/zh/guide/manual-install) · [升級](https://docs.cowagent.ai/zh/guide/upgrade)
安裝後可使用 `cow` [CLI 命令](https://docs.cowagent.ai/zh/cli) 管理服務:
```bash
cow start | stop | restart # 服務管理
cow status | logs # 狀態和日誌
cow update # 拉取最新程式碼並重啟
cow skill install <名稱> # 安裝技能
cow install-browser # 安裝瀏覽器工具
```
> 💻 桌面客戶端:前往 **[下載 CowAgent 桌面客戶端](https://cowagent.ai/zh/download/)**macOS / Windows內建 Agent 執行環境,開箱即用。
<br/>
## 🤖 模型支援
CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像理解、影像生成、語音識別/合成、向量** 等能力均可獨立設定廠商。
| 廠商 | 代表模型 | 文字 | 影像理解 | 影像生成 | 語音識別 | 語音合成 | 向量 |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [自定義](https://docs.cowagent.ai/zh/models/custom) | 本地模型 / 三方代理 | ✅ | | | | | |
> 推薦透過 Web 控制台線上設定,無需手動編輯檔案。手動設定請參考各廠商文件,詳見 [模型概覽](https://docs.cowagent.ai/zh/models)。
<br/>
## 💬 通道接入
一個 Agent 例項可同時接入多個渠道,啟動時透過 `channel_type` 切換或並行執行。
| 通道 | 文字 | 圖片 | 檔案 | 語音 | 群聊 |
| --- | :-: | :-: | :-: | :-: | :-: |
| [Web 控制台](https://docs.cowagent.ai/zh/channels/web)(預設) | ✅ | ✅ | ✅ | ✅ | |
| [微信](https://docs.cowagent.ai/zh/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
| [飛書](https://docs.cowagent.ai/zh/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [釘釘](https://docs.cowagent.ai/zh/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [企微智慧機器人](https://docs.cowagent.ai/zh/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [QQ](https://docs.cowagent.ai/zh/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
| [企業微信應用](https://docs.cowagent.ai/zh/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
| [微信客服](https://docs.cowagent.ai/zh/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
| [微信公眾號](https://docs.cowagent.ai/zh/channels/wechatmp) | ✅ | ✅ | | ✅ | |
| [Telegram](https://docs.cowagent.ai/zh/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Slack](https://docs.cowagent.ai/zh/channels/slack) | ✅ | ✅ | ✅ | | ✅ |
| [Discord](https://docs.cowagent.ai/zh/channels/discord) | ✅ | ✅ | ✅ | | ✅ |
> 飛書、企微智慧機器人支援在 Web 控制台內**掃碼一鍵接入**,無需公有 IP。詳見 [通道概覽](https://docs.cowagent.ai/zh/channels)。
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-chat.png" alt="CowAgent Web 控制台" width="800"/>
*Web 控制台是預設通道,也是統一的 Agent 設定和管理入口*
<br/>
## 🧠 記憶與知識庫
**長期記憶**採用三層架構:對話上下文(短期)→ 天級記憶(中期)→ MEMORY.md長期。每日自動執行**夢境蒸餾Deep Dream**,將分散記憶整合為精煉的長期記憶並生成敘事日記。詳見 [長期記憶](https://docs.cowagent.ai/zh/memory) · [夢境蒸餾](https://docs.cowagent.ai/zh/memory/deep-dream)。
**個人知識庫** 與按時間記錄的記憶不同,以**主題為維度**組織結構化知識。Agent 在對話中自動整理有價值資訊維護交叉引用與索引Web 控制台可視覺化瀏覽知識圖譜。詳見 [個人知識庫](https://docs.cowagent.ai/zh/knowledge)。
<table>
<tr>
<td width="50%">
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-memory.png" alt="長期記憶" />
<p align="center"><em>長期記憶 · 三層記憶 + 夢境蒸餾</em></p>
</td>
<td width="50%">
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-knowledge.png" alt="個人知識庫" />
<p align="center"><em>個人知識庫 · 自動整理的 Markdown Wiki</em></p>
</td>
</tr>
</table>
<br/>
## 🔧 工具與技能
**工具Tools** 是 Agent 作業系統資源的原子能力,**技能Skills** 是基於說明檔案的高階工作流,可組合多個工具完成複雜任務。
### 工具系統
**內建工具** 涵蓋檔案讀寫(`read` / `write` / `edit` / `ls`)、終端(`bash`)、檔案傳送(`send`)、記憶檢索(`memory`)、環境變數(`env_config`)、網頁獲取(`web_fetch`)、定時任務(`scheduler`)、聯網搜尋(`web_search`)、影像識別(`vision`)、瀏覽器自動化(`browser`)等常用能力。
**MCP 協議** 透過 [Model Context Protocol](https://modelcontextprotocol.io) 接入開放生態中的各種 MCP 服務,設定一次 `mcp.json` 即用即得,支援 stdio / SSE 協議、熱更新、零程式碼接入。
詳見 [工具概覽](https://docs.cowagent.ai/zh/tools) · [MCP 整合](https://docs.cowagent.ai/zh/tools/mcp)。
### 技能系統
- **[Skill Hub](https://skills.cowagent.ai/)** — 開源的技能廣場,瀏覽、搜尋、一鍵安裝
- **GitHub / ClawHub / URL 等** — 任意來源一鍵安裝
- **對話創造** — 透過 `skill-creator` 用對話快速生成自定義技能,可將工作流程或第三方介面直接固化為技能
```bash
/skill list # 檢視當前技能
/skill search <關鍵詞> # 在技能廣場搜尋
/skill install <名稱> # 一鍵安裝
```
詳見 [技能概覽](https://docs.cowagent.ai/zh/skills) · [建立技能](https://docs.cowagent.ai/zh/skills/create)。
<br/>
## 🏷 更新日誌
> **2026.07.08** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布macOS / Windows、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
> **2026.06.18** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級定時任務管理、知識庫分類、多模型自定義廠商、自主進化最佳化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化
> **2026.06.09** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自進化能力、Web 控制台升級訊息管理、多會話並行、新模型接入MiniMax-M3、qwen3.7-plus、Python 3.13 支援
> **2026.06.01** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 國際化支援、新增通道Telegram、Discord、Slack、微信客服、命令列互動升級、一鍵安裝指令碼最佳化、MCP Streamable HTTP 支援、新模型接入claude-opus-4-8、MiMo
> **2026.05.22** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — 模型管理、MCP 協議支援、瀏覽器登入態持久化、新模型接入gpt-5.5、gemini-3.5-flash、qwen3.7-max、部署安全加固
> **2026.05.06** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — 飛書渠道全面升級語音、流式輸出、掃碼接入、新模型支援DeepSeek V4、百度千帆、定時任務工具增強
> **2026.04.22** [v2.0.7](https://github.com/zhayujie/CowAgent/releases/tag/2.0.7) — 影像生成內建技能GPT Image 2、Nano Banana、新模型支援Kimi K2.6、Claude Opus 4.7、GLM 5.1)、知識庫和記憶增強
> **2026.04.14** [v2.0.6](https://github.com/zhayujie/CowAgent/releases/tag/2.0.6) — 知識庫系統、夢境記憶模組、上下文智慧壓縮、Web 控制台多會話
> **2026.04.01** [v2.0.5](https://github.com/zhayujie/CowAgent/releases/tag/2.0.5) — Cow CLI 命令系統、Skill Hub 開源、瀏覽器工具、企微掃碼建立
> **2026.03.22** [v2.0.4](https://github.com/zhayujie/CowAgent/releases/tag/2.0.4) — 新增個人微信通道,支援文字/圖片/檔案/語音訊息
> **2026.02.03** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — 正式升級為超級 Agent 助理支援多輪任務決策、長期記憶、Skills 框架
完整更新歷史:[Release Notes](https://docs.cowagent.ai/zh/releases)
<br/>
## 🤝 社群與支援
掃碼加入微信開源交流群:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
也可透過以下方式獲取支援:
- 🐛 [提交 Issue](https://github.com/zhayujie/CowAgent/issues)
- 🤖 線上 AI 助手:[專案小助手](https://link-ai.tech/app/Kv2fXJcH)(基於專案知識庫)
<br/>
## 🔗 相關專案
- **[Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub)** — 開源的 AI Agent 技能廣場,支援 CowAgent、OpenClaw、Claude Code 等多種 Agent
- **[bot-on-anything](https://github.com/zhayujie/bot-on-anything)** — 輕量大模型應用框架,支援 Slack、Telegram、Discord、Gmail 等海外平臺
- **[AgentMesh](https://github.com/MinimalFuture/AgentMesh)** — 開源多智慧體Multi-Agent框架透過團隊協同解決複雜問題
<br/>
## 🏢 企業服務
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
>
> - **🚀 免部署線上執行**:無需伺服器即可建立 [CowAgent 線上助理](https://link-ai.tech/cowagent/create)1 分鐘擁有專屬 Agent
> - **🧠 Agent 基礎設施**:聚合主流大模型、知識庫、資料庫、技能、工作流,提供開箱即用的 Agent 能力擴充
> - **🏢 企業級協作**:提供團隊協作、許可權分級、審計日誌、私有化部署等能力,讓 Agent 安全落地企業場景
**產品諮詢和企業服務** 可聯絡產品客服:
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
<br/>
## 🛠️ 開發與貢獻
歡迎各種形式的貢獻新功能、Bug 修復、效能最佳化、文件完善,或向 [Skill Hub](https://skills.cowagent.ai/submit) 分享你的技能。請先閱讀 [CONTRIBUTING.md](/CONTRIBUTING.md) 瞭解如何開始,然後提交 Issue 討論或直接發起 PR。
歡迎 ⭐ Star 支援專案,並透過 Watch → Custom → Releases 訂閱新版本通知。也歡迎提交 PR、Issue 進行反饋。
## 🌟 貢獻者
![cow contributors](https://contrib.rocks/image?repo=zhayujie/CowAgent&max=1000)
<br/>
## ⚠️ 宣告
1. 本專案遵循 [MIT 開源協議](/LICENSE),主要用於技術研究和學習。使用時請遵守所在地法律法規及相關政策,因使用本專案所產生的一切後果由使用者自行承擔。
2. **成本與安全:** Agent 模式 Token 消耗顯著高於普通對話請根據效果與成本權衡選擇模型Agent 具備訪問本地作業系統的能力,請謹慎選擇部署環境。
3. CowAgent 專案專注於開源技術開發,不會參與、授權或發行任何加密貨幣。
<br/>
## 📌 專案更名說明
本專案原名 `chatgpt-on-wechat`,於 2026.04.13 正式更名為 **CowAgent**。原 GitHub 地址已自動重定向,老使用者可選擇執行 `git remote set-url origin https://github.com/zhayujie/CowAgent.git` 更新本地遠端地址。

View File

@@ -12,7 +12,7 @@
</p>
<p align="center">
[<a href="../../README.md">English</a>] | [中文] | [<a href="../ja/README.md">日本語</a>]
[<a href="../../README.md">English</a>] | [中文] | [<a href="README-Hant.md">繁體中文</a>] | [<a href="../ja/README.md">日本語</a>]
</p>
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆、通过自主进化与你一同成长是 Agent Harness 工程的最佳实践之一。
@@ -24,6 +24,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖
<a href="https://docs.cowagent.ai/zh/">📖 文档中心</a> &nbsp;·&nbsp;
<a href="https://docs.cowagent.ai/zh/guide/quick-start">🚀 快速开始</a> &nbsp;·&nbsp;
<a href="https://skills.cowagent.ai/">🧩 技能广场</a> &nbsp;·&nbsp;
<a href="https://cowagent.ai/zh/download/">💻 下载客户端</a> &nbsp;·&nbsp;
<a href="https://link-ai.tech/cowagent/create">☁️ 在线体验</a>
</p>
@@ -95,6 +96,8 @@ cow skill install <名称> # 安装技能
cow install-browser # 安装浏览器工具
```
> 💻 桌面客户端:前往 **[下载 CowAgent 桌面客户端](https://cowagent.ai/zh/download/)**macOS / Windows内置 Agent 运行环境,开箱即用。
<br/>
## 🤖 模型支持
@@ -105,12 +108,12 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
@@ -200,6 +203,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
## 🏷 更新日志
> **2026.07.08** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布macOS / Windows、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
> **2026.06.18** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化
> **2026.06.09** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自进化能力、Web 控制台升级消息管理、多会话并行、新模型接入MiniMax-M3、qwen3.7-plus、Python 3.13 支持

35
docs/zh/guide/desktop.mdx Normal file
View File

@@ -0,0 +1,35 @@
---
title: 桌面客户端
description: 下载并使用 CowAgent 桌面客户端macOS / Windows
---
CowAgent 提供开箱即用的桌面客户端,内置 Agent 运行环境,**无需手动安装 Python 或依赖**,下载安装后即可在本地运行你的超级 AI 助理。
## 下载安装
<Card title="前往下载页" icon="download" href="https://cowagent.ai/zh/download/">
下载 macOS / Windows 客户端安装包
</Card>
<Tabs>
<Tab title="macOS">
1. 打开 [下载页](https://cowagent.ai/zh/download/),根据芯片选择安装包:
- Apple SiliconM1/M2/M3/M4下载 `arm64` 版本
- Intel 芯片:下载 `x64` 版本
2. 打开下载的 `.dmg`,将 CowAgent 拖入「应用程序」文件夹。
3. 从启动台或应用程序中打开 CowAgent 即可。
</Tab>
<Tab title="Windows">
1. 打开 [下载页](https://cowagent.ai/zh/download/),下载 Windows 安装包(`.exe`)。
2. 双击运行安装程序,按提示完成安装(可自定义安装目录)。
3. 从桌面或开始菜单打开 CowAgent 即可。
</Tab>
</Tabs>
## 自动更新
桌面客户端内置自动更新能力。有新版本时会自动检测并提示,你也可以在应用左下角菜单中手动「检查更新」,一键下载并重启完成升级。
## 与命令行部署的区别
- **桌面客户端**:适合个人在本地电脑使用,开箱即用,图形界面操作,自动更新。
- **命令行部署**:适合开发者或服务器长期运行,可定制性更强,详见 [一键安装](/zh/guide/quick-start)。

View File

@@ -71,6 +71,8 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。
也可以使用桌面客户端macOS / Windows内置 Agent 运行环境,[下载安装](https://cowagent.ai/zh/download/)后开箱即用。
<CardGroup cols={2}>
<Card title="快速开始" icon="rocket" href="/zh/guide/quick-start">
查看完整的安装和运行指南

View File

@@ -83,6 +83,22 @@ Deep Dream 遵循以下整理规则:
首次部署后可以手动执行一次 `/memory dream 30`,将历史天级记忆全量蒸馏到 MEMORY.md。
</Tip>
## 配置开关
通过 `config.json` 中的 `deep_dream_enabled` 控制每晚的自动蒸馏:
```json
{
"deep_dream_enabled": true
}
```
| 配置项 | 说明 | 默认值 |
| --- | --- | --- |
| `deep_dream_enabled` | 是否启用每日定时蒸馏 | `true` |
默认开启,保持与现有行为一致。关闭后每晚不再自动执行蒸馏,适合希望手动维护 MEMORY.md 或减少一次 LLM 调用的场景(每日记忆总结不受影响)。手动命令 `/memory dream` 也不受此开关影响,仍可随时触发。
## 安全机制
| 机制 | 说明 |

View File

@@ -13,14 +13,14 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
```json
{
"model": "claude-fable-5",
"model": "claude-sonnet-5",
"claude_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | 支持 `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `model` | 支持 `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
@@ -28,8 +28,8 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
| 模型 | 适用场景 |
| --- | --- |
| `claude-fable-5` | 最新旗舰,复杂推理与长链路任务效果最佳,价格较高 |
| `claude-opus-4-8` | 上一代 Opus 旗舰,综合表现与成本均衡 |
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 |
@@ -44,7 +44,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
{
"tools": {
"vision": {
"model": "claude-sonnet-4-6"
"model": "claude-sonnet-5"
}
}
}

View File

@@ -13,20 +13,20 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图
```json
{
"model": "doubao-seed-2-0-pro-260215",
"model": "doubao-seed-2-1-pro-260628",
"ark_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | 可填 `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` 等 |
| `model` | 可填 `doubao-seed-2-1-pro-260628`、`doubao-seed-2-1-turbo-260628`、`doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215` 等 |
| `ark_api_key` | 在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 |
| `ark_base_url` | 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` |
## 图像理解
配置 `ark_api_key` 后 Agent 的 Vision 工具会自动使用 `doubao-seed-2-0-pro-260215` 识别图像,无需额外配置。
配置 `ark_api_key` 后,若主模型为豆包系列,Agent 的 Vision 工具会自动使用当前主模型识别图像,无需额外配置。
如需手动指定 Vision 模型:
@@ -34,7 +34,7 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图
{
"tools": {
"vision": {
"model": "doubao-seed-2-0-pro-260215"
"model": "doubao-seed-2-1-pro-260628"
}
}
}

View File

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

View File

@@ -40,7 +40,7 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音
}
```
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` 等。
## 图像生成

View File

@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
| 版本 | 日期 | 说明 |
| --- | --- | --- |
| [2.1.3](/zh/releases/v2.1.3) | 2026.07.08 | 桌面客户端正式发布macOS / Windows、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入claude-sonnet-5、doubao-seed-2.1 等)、安全加固与体验优化 |
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级定时任务管理、知识库分类、多模型自定义厂商、自主进化优化、新模型接入kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |
| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级流式输出、命令模糊匹配、任务取消、MCP Streamable HTTP、新模型接入 |

View File

@@ -0,0 +1,75 @@
---
title: v2.1.3
description: CowAgent 2.1.3正式推出桌面客户端macOS / Windows知识库文档管理增强新增 MCP 工具按需检索,繁体中文支持,模型支持,并带来安全与体验的多项改进
---
🌐 [English](https://docs.cowagent.ai/releases/v2.1.3) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.3)
## 🖥 桌面客户端
正式推出 **CowAgent 桌面客户端**,支持 **macOS 与 Windows**,让本地超级 AI 助理真正做到开箱即用。
下载地址:[CowAgent客户端](https://cowagent.ai/zh/download/)
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/desktop-chat-demo-zh.png" alt="CowAgent 桌面客户端" />
核心特性:
- **开箱即用**:内置完整的 Agent 运行环境,安装后直接启动,无需安装 Python 等依赖
- **完整对话体验**流式对话、会话管理、工具调用步骤展示、Markdown 渲染,并支持发送与展示图片 / 视频 / 文件
- **可视化管理**:配置管理、模型配置、知识库、定时任务、技能系统、记忆等页面与 Web 控制台对齐,在原生界面即可管理
- **通道接入**:支持在客户端内扫码接入各类消息通道
- **自动更新**:支持版本自动检查与一键更新,优化不同地区的下载速度
- **原生体验**:首次启动引导、跟随系统语言、平台自适应的窗口交互
## 📚 知识库
- **文档创建与导入**:知识库支持在界面直接新建文档、导入外部文档。
- **索引自动维护**:根据真实目录树自动重建 `knowledge/index.md` 索引文件,避免索引漂移或丢失文档
- **向量模型修复**:知识库索引同步复用统一的嵌入厂商,确保生成向量而非退化为关键词检索
Thanks @yangziyu-hhh
相关文档:[知识库](https://docs.cowagent.ai/zh/memory/knowledge)
## 🔌 MCP 工具智能检索
针对接入大量 MCP 工具时上下文膨胀的问题,新增 **按需工具检索** 能力,通过 RAG 向量检索按需加载与当前任务相关的 MCP 工具减少无关工具对上下文的占用。Thanks @fengyl07
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
## 🌏 繁体中文支持
Web 控制台、日志与文档新增 **繁体中文zh-Hant** 支持界面语言可跟随系统或手动切换。Thanks @anomixer (#2935)
## 🤖 模型新增
- 新增支持 **claude-sonnet-5**、**claude-fable-5**
- 新增支持 **doubao-seed-2-1-pro**、**doubao-seed-2-1-turbo**
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
## 🔒 安全加固
- **敏感文件读取防护**加固对凭证等敏感文件的访问防止绕过读取。Thanks @fengyl07 (#2913)
- **浏览器访问防护**浏览器访问网页时拦截指向内网及云服务器内部地址的请求降低被诱导访问内部服务的风险。Thanks @christop
- **配置解析加固**使用更安全的方式解析配置内容避免潜在的代码执行风险。Thanks @shunfeng8421
## 🛠 体验优化与修复
- **自定义供应商扩展**:嵌入与视觉模型支持配置自定义供应商;同时修复记忆查询在 Windows 下的问题。Thanks @HnBigVolibear
- **文件编辑更稳**编辑文件时更好地保留原有缩进且模糊匹配时不改动未涉及的内容。Thanks @xiaweiwei67-stack (#2942)
- **命令输出乱码修复**修复执行命令产生大量输出时可能出现的中文乱码。Thanks @xiaweiwei67-stack (#2941)
- **Azure OpenAI 修复**:修复 Azure OpenAI 的流式输出与相关配置问题。Thanks @Eric L
- **企业微信智能机器人**:补充 webhook回调模式的接入文档。Thanks @6vision
- **深度梦境开关**:新增 `deep_dream_enabled` 配置开关,可按需开启或关闭深度梦境。
- **稳定性提升**:优化 Web 服务的连接回收,并修复自主进化过程中的若干问题 (#2924, #2904)
## 📦 升级方式
- **桌面客户端**:前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
**发布日期**2026.07.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.2...2.1.3)

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