mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
95 Commits
feat-cow-d
...
feat-deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d55ec0f8c | ||
|
|
eeb4b7981e | ||
|
|
5f1c98881d | ||
|
|
94d0f56689 | ||
|
|
4d690341a7 | ||
|
|
d8c419227c | ||
|
|
8c7cda89dc | ||
|
|
42a5cf9538 | ||
|
|
996406eb2a | ||
|
|
b98fbae6f6 | ||
|
|
4d87703e31 | ||
|
|
9ef64b7858 | ||
|
|
bf0c26d3c4 | ||
|
|
65970c564c | ||
|
|
ed36ca99c0 | ||
|
|
ce09efe640 | ||
|
|
adaf9a7813 | ||
|
|
06f9492518 | ||
|
|
530042675e | ||
|
|
ca404aeb24 | ||
|
|
a6c975f92c | ||
|
|
dd3cadbd81 | ||
|
|
efbabfcace | ||
|
|
09c71ef1d9 | ||
|
|
0bb8208f36 | ||
|
|
56571c77ca | ||
|
|
6c353d389b | ||
|
|
583217d396 | ||
|
|
93162d2f10 | ||
|
|
2e74295807 | ||
|
|
93bf8844de | ||
|
|
fcc520df47 | ||
|
|
a94f4e3c18 | ||
|
|
de9e7f0e84 | ||
|
|
ed5b2d6ce6 | ||
|
|
2cd3fa7981 | ||
|
|
f01cc3a0b4 | ||
|
|
51bf09208d | ||
|
|
bf0831a664 | ||
|
|
96b1fccf76 | ||
|
|
4b57971d33 | ||
|
|
d531e14fbf | ||
|
|
8df38a23d2 | ||
|
|
38105e6539 | ||
|
|
14c6577d51 | ||
|
|
f051a58db5 | ||
|
|
825d990312 | ||
|
|
cb31013584 | ||
|
|
dd74d1dabe | ||
|
|
75f3952ac6 | ||
|
|
37423fbb31 | ||
|
|
00c3436d48 | ||
|
|
377b4e5cb8 | ||
|
|
a427586b89 | ||
|
|
a951494489 | ||
|
|
a871c0437d | ||
|
|
013960cd5a | ||
|
|
60aebf41a8 | ||
|
|
2cf521e57e | ||
|
|
dad3a84efb | ||
|
|
ae864c7ff9 | ||
|
|
3b33114a40 | ||
|
|
e0f49ac619 | ||
|
|
01ec49afd2 | ||
|
|
b44154fe02 | ||
|
|
b8dad38622 | ||
|
|
80fea77c86 | ||
|
|
e5f3eb48d4 | ||
|
|
ca876b0c65 | ||
|
|
0a762b8c08 | ||
|
|
fd90a89b45 | ||
|
|
f82eb39d23 | ||
|
|
2786148153 | ||
|
|
2959cfea32 | ||
|
|
e536232963 | ||
|
|
778d78cebe | ||
|
|
538281da51 | ||
|
|
12cd626949 | ||
|
|
ff64a7930e | ||
|
|
5d726fe340 | ||
|
|
e1834124d4 | ||
|
|
f49e965736 | ||
|
|
936eaf5939 | ||
|
|
7047b30e27 | ||
|
|
5c67e970d1 | ||
|
|
8023c4e8b7 | ||
|
|
641b84519c | ||
|
|
0c8cb974e2 | ||
|
|
915edbe145 | ||
|
|
0c20c5c159 | ||
|
|
9ea0017778 | ||
|
|
f1cdc2d2cc | ||
|
|
5db2998e3d | ||
|
|
0bc0f2b930 | ||
|
|
84d6848e67 |
116
.github/scripts/register-releases.mjs
vendored
Normal file
116
.github/scripts/register-releases.mjs
vendored
Normal 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
154
.github/workflows/publish-desktop.yml
vendored
Normal 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
|
||||
200
.github/workflows/release.yml
vendored
200
.github/workflows/release.yml
vendored
@@ -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,39 @@ 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
|
||||
|
||||
# Download the Windows signing CLI. The URL comes from a repo variable, so
|
||||
# nothing about the signing setup is hardcoded in a public workflow. Only
|
||||
# runs on the Windows leg and only when a URL is set; otherwise the build
|
||||
# stays unsigned. SIGNTOOL_PATH is exported for the next step's
|
||||
# electron-builder.win.js to invoke.
|
||||
- name: Download Windows signing CLI
|
||||
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
|
||||
shell: bash
|
||||
env:
|
||||
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/signtool"
|
||||
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||
# Unzip and locate the signtool executable regardless of nesting.
|
||||
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||
if [ -z "$exe" ]; then
|
||||
echo "signtool.exe not found in downloaded archive" >&2
|
||||
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
# Normalize to a Windows-style path for execFileSync in Node.
|
||||
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||
echo "resolved signtool: $exe"
|
||||
|
||||
- name: Build & publish (electron-builder)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
@@ -108,41 +149,77 @@ 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 }}
|
||||
# Windows code signing via the signing CLI. Credentials are
|
||||
# secrets; SIGNTOOL_PATH was exported by the download step above.
|
||||
# COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
|
||||
# with a self-signed cert before buying a real one — no quota used.
|
||||
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||
run: |
|
||||
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).
|
||||
# macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
|
||||
# a local .pfx (EV private keys can't be exported since 2023); it signs
|
||||
# via the CLI wired into electron-builder.win.js instead, using the
|
||||
# SIGNTOOL_* env already set above — nothing to export here.
|
||||
case "${{ matrix.platform }}" in
|
||||
mac)
|
||||
if [ -n "$MAC_CSC_LINK" ]; then
|
||||
export CSC_LINK="$MAC_CSC_LINK"
|
||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# 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: each platform loads its OWN dynamic config.
|
||||
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||
# win -> electron-builder.win.js (wires the sign hook; electron-builder
|
||||
# signs the app, backend and installer)
|
||||
# HISTORY: passing --config on Windows previously broke the build (no
|
||||
# installer, job still green). That happened because the MAC config
|
||||
# (electron-builder.js) was a no-op on Windows yet still disturbed the
|
||||
# run. The fix is a DEDICATED win config that correctly extends
|
||||
# config.win — not sharing the mac one. If a build ever runs WITHOUT
|
||||
# signing configured, electron-builder.win.js still returns the base
|
||||
# config unchanged (sign hook just skips), so the installer is still
|
||||
# produced.
|
||||
#
|
||||
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||
# make bash return before the wrapped process finishes. node skips it.
|
||||
case "${{ matrix.platform }}" in
|
||||
mac) config_arg="--config electron-builder.js" ;;
|
||||
win) config_arg="--config electron-builder.win.js" ;;
|
||||
*) config_arg="" ;;
|
||||
esac
|
||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||
|
||||
# Upload artifacts regardless of outcome, so a failed run still surfaces
|
||||
# the built installers (and, on success, the notarized+stapled dmg).
|
||||
- name: Upload artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# One bundle per platform/arch so the publish job can collect them all.
|
||||
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
desktop/release/*.dmg
|
||||
desktop/release/*.zip
|
||||
desktop/release/*.exe
|
||||
desktop/release/*.yml
|
||||
desktop/release/*.blockmap
|
||||
@@ -201,10 +278,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 +319,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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
13
README.md
13
README.md
@@ -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> ·
|
||||
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 Quick Start</a> ·
|
||||
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> ·
|
||||
<a href="https://cowagent.ai/download/">💻 Download</a> ·
|
||||
<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 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
209
agent/memory/embedding/factory.py
Normal file
209
agent/memory/embedding/factory.py
Normal 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
|
||||
@@ -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. "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
290
agent/tools/browser/browser_env.py
Normal file
290
agent/tools/browser/browser_env.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Browser environment detection and capability resolution.
|
||||
|
||||
Centralizes everything about *where* a usable browser engine comes from, so
|
||||
both the runtime (browser_service) and the installer (cli/commands/install)
|
||||
agree on the same decisions:
|
||||
|
||||
- Whether the `playwright` Python package is importable.
|
||||
- Whether a system Chrome / Edge is installed (Playwright can drive it via
|
||||
the `channel="chrome"/"msedge"` launcher, no download needed).
|
||||
- Where Playwright's own Chromium download lives (redirected to the writable
|
||||
data dir so it survives frozen/desktop app updates).
|
||||
|
||||
Resolution priority (see resolve_engine):
|
||||
1. system-chrome -> drive the user's installed Chrome / Edge (zero download)
|
||||
2. playwright-chromium -> Playwright's own Chromium, if already downloaded
|
||||
3. none -> nothing usable yet; caller should trigger onboarding
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# Playwright browser channels we accept for the "system-chrome" mode, in
|
||||
# preference order. "chrome" covers stable Google Chrome; "msedge" is the
|
||||
# Chromium-based Edge shipped on every Windows 10/11.
|
||||
_PREFERRED_CHANNELS = ("chrome", "msedge", "chrome-beta", "msedge-beta")
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
"""Writable data root (~/.cow on desktop, else CWD-based).
|
||||
|
||||
Mirrors the logic in common/log.py without importing config, to avoid a
|
||||
circular import. The desktop build sets COW_DATA_DIR; source deployments
|
||||
fall back to the current working directory.
|
||||
"""
|
||||
data_dir = os.environ.get("COW_DATA_DIR")
|
||||
if data_dir:
|
||||
return os.path.expanduser(data_dir)
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def browsers_download_dir() -> str:
|
||||
"""Directory Playwright downloads its Chromium into.
|
||||
|
||||
We pin it under the writable data root (~/.cow/ms-playwright) rather than
|
||||
Playwright's default (~/.cache/ms-playwright or %USERPROFILE%). This keeps
|
||||
the frozen desktop build self-contained and makes the download survive app
|
||||
updates. Set as PLAYWRIGHT_BROWSERS_PATH for both install and runtime.
|
||||
"""
|
||||
return os.path.join(get_data_root(), "ms-playwright")
|
||||
|
||||
|
||||
def apply_browsers_path_env() -> None:
|
||||
"""Point Playwright at our pinned download dir via env var (idempotent).
|
||||
|
||||
Only set it when not already provided by the user, so power users can
|
||||
override the location. Must run before importing playwright's launcher.
|
||||
"""
|
||||
if not os.environ.get("PLAYWRIGHT_BROWSERS_PATH"):
|
||||
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||
|
||||
In this mode sys.executable is the frozen exe (no pip), so the installer
|
||||
must skip `pip install` and only download the browser binary.
|
||||
"""
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_desktop() -> bool:
|
||||
"""True when running as the Electron desktop client (dev or packaged).
|
||||
|
||||
The desktop shell always sets COW_DESKTOP=1 (see python-manager.ts), both in
|
||||
`npm run dev` (runs app.py with the user's Python) and in the packaged build
|
||||
(frozen exe). Desktop users have no `cow` CLI, so onboarding must point them
|
||||
at the in-chat `/install-browser` command rather than a terminal command.
|
||||
"""
|
||||
return os.environ.get("COW_DESKTOP") == "1"
|
||||
|
||||
|
||||
def has_playwright_package() -> bool:
|
||||
"""True if the `playwright` Python package can be imported."""
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _windows_program_dirs() -> list:
|
||||
dirs = []
|
||||
for var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
||||
val = os.environ.get(var)
|
||||
if val:
|
||||
dirs.append(val)
|
||||
return dirs
|
||||
|
||||
|
||||
def detect_system_chrome() -> Optional[Dict[str, str]]:
|
||||
"""Locate an installed Chromium-based browser Playwright can drive.
|
||||
|
||||
Returns a dict {"channel": <playwright channel>, "path": <exe path>} for
|
||||
the first match, or None. The `channel` is what we hand to Playwright's
|
||||
launcher; `path` is only informational (Playwright resolves the channel on
|
||||
its own, but we keep the path for logging / onboarding messages).
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
if sys.platform == "darwin":
|
||||
candidates = [
|
||||
("chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
("msedge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
||||
("chrome-beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"),
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
prog_dirs = _windows_program_dirs()
|
||||
for base in prog_dirs:
|
||||
candidates.append(("chrome", os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")))
|
||||
candidates.append(("msedge", os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe")))
|
||||
else:
|
||||
# Linux: rely on PATH lookups for the common binaries.
|
||||
path_lookups = [
|
||||
("chrome", "google-chrome"),
|
||||
("chrome", "google-chrome-stable"),
|
||||
("chrome", "chromium"),
|
||||
("chrome", "chromium-browser"),
|
||||
("msedge", "microsoft-edge"),
|
||||
]
|
||||
for channel, binary in path_lookups:
|
||||
found = shutil.which(binary)
|
||||
if found:
|
||||
return {"channel": channel, "path": found}
|
||||
|
||||
for channel, path in candidates:
|
||||
if path and os.path.exists(path):
|
||||
return {"channel": channel, "path": path}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_downloaded_chromium() -> bool:
|
||||
"""True if Playwright already has a Chromium download available.
|
||||
|
||||
We check our pinned download dir for a chromium-* folder. This is a
|
||||
lightweight heuristic (avoids importing/launching Playwright just to probe)
|
||||
and matches how Playwright lays browsers out on disk.
|
||||
"""
|
||||
download_dir = browsers_download_dir()
|
||||
if not os.path.isdir(download_dir):
|
||||
return False
|
||||
try:
|
||||
for name in os.listdir(download_dir):
|
||||
# Playwright names its browser dirs like "chromium-1140",
|
||||
# "chromium_headless_shell-1140".
|
||||
if name.startswith("chromium"):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def resolve_engine(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Decide which browser engine to use, given config and environment.
|
||||
|
||||
Returns a dict describing the launch strategy:
|
||||
{
|
||||
"mode": "system-chrome" | "playwright-chromium" | "none",
|
||||
"channel": Optional[str], # for system-chrome
|
||||
"path": Optional[str], # for system-chrome (informational)
|
||||
"has_playwright": bool,
|
||||
"reason": str, # human-readable, for logging / onboarding
|
||||
}
|
||||
|
||||
Config keys under tools.browser that influence this:
|
||||
- engine: "auto" (default) | "system-chrome" | "chromium"
|
||||
Force a specific engine. "auto" prefers system Chrome, then falls
|
||||
back to a downloaded Chromium.
|
||||
- prefer_system_browser: bool (default True). When False under "auto",
|
||||
skip system Chrome and go straight to Playwright's Chromium.
|
||||
"""
|
||||
config = config or {}
|
||||
apply_browsers_path_env()
|
||||
|
||||
has_pw = has_playwright_package()
|
||||
engine_pref = str(config.get("engine", "auto")).strip().lower()
|
||||
prefer_system = config.get("prefer_system_browser", True)
|
||||
|
||||
if not has_pw:
|
||||
return {
|
||||
"mode": "none",
|
||||
"channel": None,
|
||||
"path": None,
|
||||
"has_playwright": False,
|
||||
"reason": "playwright package not available",
|
||||
}
|
||||
|
||||
system = None
|
||||
if engine_pref in ("auto", "system-chrome") and prefer_system:
|
||||
system = detect_system_chrome()
|
||||
|
||||
if engine_pref == "system-chrome":
|
||||
# Explicitly requested: use system Chrome if found, else report none.
|
||||
if system:
|
||||
return {
|
||||
"mode": "system-chrome",
|
||||
"channel": system["channel"],
|
||||
"path": system["path"],
|
||||
"has_playwright": True,
|
||||
"reason": f"using system browser ({system['channel']})",
|
||||
}
|
||||
return {
|
||||
"mode": "none",
|
||||
"channel": None,
|
||||
"path": None,
|
||||
"has_playwright": True,
|
||||
"reason": "engine=system-chrome but no Chrome/Edge found",
|
||||
}
|
||||
|
||||
if engine_pref == "chromium":
|
||||
# Explicitly requested Playwright's own Chromium.
|
||||
if has_downloaded_chromium():
|
||||
return {
|
||||
"mode": "playwright-chromium",
|
||||
"channel": None,
|
||||
"path": None,
|
||||
"has_playwright": True,
|
||||
"reason": "using downloaded Playwright Chromium",
|
||||
}
|
||||
return {
|
||||
"mode": "none",
|
||||
"channel": None,
|
||||
"path": None,
|
||||
"has_playwright": True,
|
||||
"reason": "engine=chromium but Chromium not downloaded yet",
|
||||
}
|
||||
|
||||
# auto: system Chrome first, then downloaded Chromium.
|
||||
if system:
|
||||
return {
|
||||
"mode": "system-chrome",
|
||||
"channel": system["channel"],
|
||||
"path": system["path"],
|
||||
"has_playwright": True,
|
||||
"reason": f"auto: using system browser ({system['channel']})",
|
||||
}
|
||||
if has_downloaded_chromium():
|
||||
return {
|
||||
"mode": "playwright-chromium",
|
||||
"channel": None,
|
||||
"path": None,
|
||||
"has_playwright": True,
|
||||
"reason": "auto: using downloaded Playwright Chromium",
|
||||
}
|
||||
|
||||
return {
|
||||
"mode": "none",
|
||||
"channel": None,
|
||||
"path": None,
|
||||
"has_playwright": True,
|
||||
"reason": "no system Chrome/Edge and no downloaded Chromium",
|
||||
}
|
||||
|
||||
|
||||
def capability_summary(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""High-level browser capability status, for onboarding / diagnostics.
|
||||
|
||||
Combines resolve_engine with raw detection flags so the UI / tool layer can
|
||||
craft a helpful message (e.g. "Chrome detected, click to enable" vs
|
||||
"no browser, will download ~150MB").
|
||||
"""
|
||||
engine = resolve_engine(config)
|
||||
system = detect_system_chrome()
|
||||
return {
|
||||
"ready": engine["mode"] != "none",
|
||||
"engine": engine,
|
||||
"has_playwright": engine["has_playwright"],
|
||||
"has_system_chrome": system is not None,
|
||||
"system_chrome": system,
|
||||
"has_downloaded_chromium": has_downloaded_chromium(),
|
||||
"is_frozen": is_frozen(),
|
||||
"is_desktop": is_desktop(),
|
||||
"browsers_dir": browsers_download_dir(),
|
||||
}
|
||||
@@ -326,12 +326,19 @@ class BrowserService:
|
||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||
# so cookies / login state survive across runs (default).
|
||||
# - fresh: classic launch + new_context, clean state every run.
|
||||
#
|
||||
# Within persistent/fresh, the actual Chromium binary is resolved by
|
||||
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
|
||||
# download) is preferred, falling back to Playwright's own downloaded
|
||||
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
|
||||
# when driving a system browser, else None (bundled Chromium).
|
||||
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
||||
persistent_flag = self._config.get("persistent", True)
|
||||
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||
if user_data_dir_cfg is None:
|
||||
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
||||
|
||||
self._channel: Optional[str] = None
|
||||
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
||||
if self._cdp_endpoint:
|
||||
self._launch_mode = "cdp"
|
||||
@@ -343,6 +350,22 @@ class BrowserService:
|
||||
self._launch_mode = "fresh"
|
||||
self._user_data_dir = ""
|
||||
|
||||
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||
if self._launch_mode != "cdp":
|
||||
try:
|
||||
from agent.tools.browser.browser_env import resolve_engine
|
||||
engine = resolve_engine(self._config)
|
||||
if engine["mode"] == "system-chrome":
|
||||
self._channel = engine["channel"]
|
||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||
elif engine["mode"] == "playwright-chromium":
|
||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||
else:
|
||||
logger.info(f"[Browser] No ready engine yet: {engine['reason']}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] Engine resolution skipped: {e}")
|
||||
|
||||
# Idle auto-release
|
||||
idle_cfg = self._config.get("idle_timeout")
|
||||
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||
@@ -428,6 +451,14 @@ class BrowserService:
|
||||
|
||||
def _launch_browser(self):
|
||||
"""Launch / connect Chromium on the background thread."""
|
||||
# Point Playwright at our pinned download dir before any launch so a
|
||||
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
|
||||
try:
|
||||
from agent.tools.browser.browser_env import apply_browsers_path_env
|
||||
apply_browsers_path_env()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
|
||||
|
||||
if self._headless is None:
|
||||
headless_cfg = self._config.get("headless")
|
||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||
@@ -475,12 +506,20 @@ class BrowserService:
|
||||
logger.info("[Browser] Browser ready")
|
||||
|
||||
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||
"""Classic launch: brand new Chromium with an empty context."""
|
||||
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
|
||||
self._browser = self._playwright.chromium.launch(
|
||||
headless=self._headless,
|
||||
args=launch_args,
|
||||
)
|
||||
"""Classic launch: brand new Chromium with an empty context.
|
||||
|
||||
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
|
||||
the user's installed system browser instead of its own Chromium.
|
||||
"""
|
||||
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
|
||||
launch_kwargs: Dict[str, Any] = {
|
||||
"headless": self._headless,
|
||||
"args": launch_args,
|
||||
}
|
||||
if self._channel:
|
||||
launch_kwargs["channel"] = self._channel
|
||||
self._browser = self._playwright.chromium.launch(**launch_kwargs)
|
||||
self._context = self._browser.new_context(
|
||||
viewport=viewport,
|
||||
user_agent=user_agent,
|
||||
@@ -491,18 +530,25 @@ class BrowserService:
|
||||
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||
logger.info(
|
||||
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
|
||||
f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
|
||||
f"profile={self._user_data_dir})"
|
||||
)
|
||||
persistent_kwargs: Dict[str, Any] = {
|
||||
"user_data_dir": self._user_data_dir,
|
||||
"headless": self._headless,
|
||||
"args": launch_args,
|
||||
"viewport": viewport,
|
||||
"user_agent": user_agent,
|
||||
}
|
||||
# When driving a system browser, let it use its real UA instead of the
|
||||
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
|
||||
if self._channel:
|
||||
persistent_kwargs["channel"] = self._channel
|
||||
persistent_kwargs.pop("user_agent", None)
|
||||
try:
|
||||
self._context = self._playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=self._user_data_dir,
|
||||
headless=self._headless,
|
||||
args=launch_args,
|
||||
viewport=viewport,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
|
||||
except Exception as e:
|
||||
# Profile is locked when another Chromium instance already holds it.
|
||||
msg = str(e).lower()
|
||||
|
||||
@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
|
||||
f"({ip_str}), request blocked for security"
|
||||
)
|
||||
|
||||
def _check_engine_ready(self) -> Optional[ToolResult]:
|
||||
"""Return an actionable onboarding message if no browser engine is ready.
|
||||
|
||||
Returns None when a system Chrome/Edge or a downloaded Chromium is
|
||||
available (so the tool can proceed). Otherwise returns a ToolResult with
|
||||
clear guidance so the agent asks the user to enable the browser instead
|
||||
of surfacing a raw Playwright launch error. CDP mode is exempt (the
|
||||
endpoint is external and validated at connect time).
|
||||
"""
|
||||
if self.config.get("cdp_endpoint"):
|
||||
return None
|
||||
try:
|
||||
from agent.tools.browser.browser_env import capability_summary
|
||||
summary = capability_summary(self.config)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] capability probe failed: {e}")
|
||||
return None
|
||||
|
||||
if summary.get("ready"):
|
||||
return None
|
||||
|
||||
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
|
||||
# in-chat `/install-browser` command. Source / web / server installs use
|
||||
# the `cow install-browser` terminal command.
|
||||
install_hint = (
|
||||
"reply `/install-browser`" if summary.get("is_desktop")
|
||||
else "run `cow install-browser` in a terminal"
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
|
||||
"skipped automatically if Google Chrome is already installed). "
|
||||
"Do not retry until the user confirms."
|
||||
)
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
action = args.get("action", "").strip().lower()
|
||||
if not action:
|
||||
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
|
||||
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||
|
||||
# Preflight: on desktop the playwright package is bundled but the browser
|
||||
# binary may be missing; return actionable onboarding instead of a cryptic
|
||||
# launch failure.
|
||||
not_ready = self._check_engine_ready()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
|
||||
try:
|
||||
return handler(self, args)
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,7 +13,7 @@ from agent.tools.utils.diff import (
|
||||
detect_line_ending,
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
count_matches,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string
|
||||
)
|
||||
@@ -110,10 +110,10 @@ class Edit(BaseTool):
|
||||
"The old text must match exactly including all whitespace and newlines."
|
||||
)
|
||||
|
||||
# Calculate occurrence count (use fuzzy normalized content for consistency)
|
||||
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
||||
occurrences = fuzzy_content.count(fuzzy_old_text)
|
||||
# Count occurrences with the same matcher used to locate and
|
||||
# replace (fuzzy_find_text), so the uniqueness guard cannot
|
||||
# disagree with what actually gets replaced.
|
||||
occurrences = count_matches(normalized_content, normalized_old_text)
|
||||
|
||||
if occurrences > 1:
|
||||
return ToolResult.fail(
|
||||
|
||||
@@ -21,6 +21,48 @@ from common.log import logger
|
||||
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
|
||||
|
||||
|
||||
# Optional callback invoked after an OAuth authorization completes, so the
|
||||
# tool manager can bring the newly-authorized server online. Signature:
|
||||
# reload_fn(server_name: str) -> None. Installed by the tool manager.
|
||||
_reload_callback = None
|
||||
|
||||
|
||||
def set_reload_callback(fn) -> None:
|
||||
"""Register a callback fired after a server's OAuth flow succeeds."""
|
||||
global _reload_callback
|
||||
_reload_callback = fn
|
||||
|
||||
|
||||
def notify_server_authorized(server_name: str) -> None:
|
||||
"""Called by the web callback once tokens are stored for a server."""
|
||||
fn = _reload_callback
|
||||
if fn is None:
|
||||
logger.debug(f"[MCP:{server_name}] Authorized but no reload callback registered")
|
||||
return
|
||||
try:
|
||||
fn(server_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP:{server_name}] reload callback failed: {e}")
|
||||
|
||||
|
||||
def _oauth_redirect_uri() -> str:
|
||||
"""Build the OAuth redirect URI served by the web console callback.
|
||||
|
||||
Priority: explicit mcp_oauth_redirect_base config, otherwise the local
|
||||
web console address (127.0.0.1:<web_port>). Both point at the shared
|
||||
/mcp/oauth/callback route.
|
||||
"""
|
||||
try:
|
||||
from config import conf
|
||||
base = (conf().get("mcp_oauth_redirect_base") or "").strip().rstrip("/")
|
||||
if not base:
|
||||
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
except Exception:
|
||||
base = "http://127.0.0.1:9899"
|
||||
return f"{base}/mcp/oauth/callback"
|
||||
|
||||
|
||||
class McpClient:
|
||||
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
|
||||
|
||||
@@ -56,6 +98,13 @@ class McpClient:
|
||||
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
|
||||
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
|
||||
|
||||
# OAuth state (streamable-http only). Lazily created when the server
|
||||
# responds with 401 and the user has not supplied a static token.
|
||||
self._oauth = None # OAuthHandler instance
|
||||
# Set to True once a 401 could not be satisfied and the user must
|
||||
# complete the browser authorization. Callers can surface this state.
|
||||
self.needs_auth: bool = False
|
||||
|
||||
# Shared state
|
||||
self._next_id = 1
|
||||
self._id_lock = threading.Lock()
|
||||
@@ -325,13 +374,118 @@ class McpClient:
|
||||
if isinstance(extra_headers, dict):
|
||||
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
|
||||
|
||||
# Restore any previously stored OAuth credentials for this server so a
|
||||
# restart reuses the token instead of forcing re-authorization.
|
||||
self._maybe_load_oauth()
|
||||
|
||||
return self._handshake()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuth helpers (streamable-http only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _has_static_auth(self) -> bool:
|
||||
"""True when the user supplied their own Authorization header."""
|
||||
return any(k.lower() == "authorization" for k in self._http_headers)
|
||||
|
||||
def _maybe_load_oauth(self) -> None:
|
||||
"""Attach an OAuthHandler when stored credentials exist for this server."""
|
||||
if self._has_static_auth():
|
||||
return
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import OAuthHandler, load_server_record
|
||||
except Exception:
|
||||
return
|
||||
rec = load_server_record(self.name)
|
||||
# Only create a handler when we have something to reuse; otherwise it
|
||||
# is created lazily on the first 401.
|
||||
if rec.get("access_token") or rec.get("client_id"):
|
||||
self._oauth = OAuthHandler(
|
||||
server_name=self.name,
|
||||
resource_url=self._http_url,
|
||||
redirect_uri=_oauth_redirect_uri(),
|
||||
scope=self.config.get("scope", ""),
|
||||
)
|
||||
|
||||
def _current_bearer(self) -> Optional[str]:
|
||||
"""Return a valid access token, refreshing if needed."""
|
||||
if self._oauth is None:
|
||||
return None
|
||||
return self._oauth.get_valid_access_token()
|
||||
|
||||
def _begin_oauth(self, www_authenticate: str = "") -> None:
|
||||
"""Kick off the OAuth flow after a 401: discover, register, prompt user."""
|
||||
if self._has_static_auth():
|
||||
return
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import OAuthHandler
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP:{self.name}] OAuth module unavailable: {e}")
|
||||
return
|
||||
|
||||
if self._oauth is None:
|
||||
self._oauth = OAuthHandler(
|
||||
server_name=self.name,
|
||||
resource_url=self._http_url,
|
||||
redirect_uri=_oauth_redirect_uri(),
|
||||
scope=self.config.get("scope", ""),
|
||||
)
|
||||
|
||||
if not self._oauth.ensure_registered(www_authenticate):
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] OAuth discovery/registration failed; "
|
||||
f"cannot authorize automatically"
|
||||
)
|
||||
return
|
||||
|
||||
auth_url = self._oauth.build_authorization_url()
|
||||
if not auth_url:
|
||||
logger.warning(f"[MCP:{self.name}] Failed to build authorization URL")
|
||||
return
|
||||
|
||||
self.needs_auth = True
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] ⚠️ Authorization required. Open this URL in a "
|
||||
f"browser to authorize, then this server will come online automatically:\n"
|
||||
f" {auth_url}"
|
||||
)
|
||||
# On a machine with a local browser (desktop/dev), open it directly.
|
||||
if os.environ.get("COW_DESKTOP") == "1" or not os.environ.get("COW_HEADLESS"):
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(auth_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _streamable_http_send(self, message: dict) -> dict:
|
||||
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
|
||||
return self._streamable_http_post(message, expect_response=True)
|
||||
|
||||
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict:
|
||||
def _handle_401(self, err, message: dict, expect_response: bool, retried: bool) -> dict:
|
||||
"""Handle a 401: refresh the token and retry once, else begin OAuth."""
|
||||
www_auth = ""
|
||||
try:
|
||||
www_auth = err.headers.get("WWW-Authenticate", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
err.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First try a silent refresh with the stored refresh token.
|
||||
if not retried and self._oauth is not None and self._oauth.refresh():
|
||||
logger.info(f"[MCP:{self.name}] Token refreshed after 401, retrying")
|
||||
return self._streamable_http_post(message, expect_response, _retried=True)
|
||||
|
||||
# No usable token — start (or restart) the interactive OAuth flow.
|
||||
self._begin_oauth(www_auth)
|
||||
raise IOError(
|
||||
f"[MCP:{self.name}] streamable-http HTTP 401: authorization required "
|
||||
f"(complete the OAuth flow to enable this server)"
|
||||
)
|
||||
|
||||
def _streamable_http_post(self, message: dict, expect_response: bool, _retried: bool = False) -> dict:
|
||||
"""
|
||||
POST a JSON-RPC message over Streamable HTTP.
|
||||
|
||||
@@ -351,6 +505,12 @@ class McpClient:
|
||||
if sid:
|
||||
headers["Mcp-Session-Id"] = sid
|
||||
headers.update(self._http_headers)
|
||||
# Inject OAuth bearer token when we have one (unless the user set a
|
||||
# static Authorization header, which takes precedence).
|
||||
if not self._has_static_auth():
|
||||
token = self._current_bearer()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
self._http_url,
|
||||
@@ -362,6 +522,9 @@ class McpClient:
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
except urllib.error.HTTPError as e:
|
||||
# 401 is the spec-compliant "needs authorization" signal.
|
||||
if e.code == 401 and not self._has_static_auth():
|
||||
return self._handle_401(e, message, expect_response, _retried)
|
||||
# Surface the server-provided error body for easier debugging
|
||||
detail = ""
|
||||
try:
|
||||
|
||||
466
agent/tools/mcp/mcp_oauth.py
Normal file
466
agent/tools/mcp/mcp_oauth.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
MCP OAuth 2.1 client (authorization code + PKCE) with zero external deps.
|
||||
|
||||
Implements the subset of the MCP authorization spec needed to connect to
|
||||
remote MCP servers that guard their endpoint behind OAuth (e.g. Xmind):
|
||||
|
||||
1. Metadata discovery via RFC 9728 (protected-resource) + RFC 8414
|
||||
(authorization-server) .well-known documents.
|
||||
2. Dynamic Client Registration (RFC 7591) to obtain a client_id.
|
||||
3. PKCE (RFC 7636, S256) authorization-code flow.
|
||||
4. Token exchange + refresh, persisted to ~/.cow/mcp_oauth.json.
|
||||
|
||||
The actual browser round-trip is completed out-of-band: McpClient generates
|
||||
an authorization URL, the user opens it, and the web console callback
|
||||
(/mcp/oauth/callback) feeds the returned code back into finish_authorization().
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token store: ~/.cow/mcp_oauth.json {server_name: {...credentials...}}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _store_path() -> str:
|
||||
base = os.path.expanduser("~/.cow")
|
||||
try:
|
||||
os.makedirs(base, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return os.path.join(base, "mcp_oauth.json")
|
||||
|
||||
|
||||
def _load_store() -> dict:
|
||||
path = _store_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] Failed to read token store: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_store(store: dict) -> None:
|
||||
path = _store_path()
|
||||
tmp = f"{path}.tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(store, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
# Credentials file: restrict to owner read/write when possible.
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] Failed to persist token store: {e}")
|
||||
|
||||
|
||||
def load_server_record(server_name: str) -> dict:
|
||||
with _STORE_LOCK:
|
||||
return dict(_load_store().get(server_name, {}))
|
||||
|
||||
|
||||
def save_server_record(server_name: str, record: dict) -> None:
|
||||
with _STORE_LOCK:
|
||||
store = _load_store()
|
||||
store[server_name] = record
|
||||
_save_store(store)
|
||||
|
||||
|
||||
def clear_server_record(server_name: str) -> None:
|
||||
with _STORE_LOCK:
|
||||
store = _load_store()
|
||||
if server_name in store:
|
||||
store.pop(server_name, None)
|
||||
_save_store(store)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pending authorizations, keyed by the OAuth `state` param.
|
||||
# Populated when an authorization URL is generated; consumed by the
|
||||
# web callback when the browser redirects back with ?code&state.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_PENDING_LOCK = threading.Lock()
|
||||
_PENDING: dict = {} # state -> {"handler": OAuthHandler, "created": ts}
|
||||
_PENDING_TTL = 600 # seconds
|
||||
|
||||
|
||||
def _register_pending(state: str, handler: "OAuthHandler") -> None:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
_PENDING[state] = {"handler": handler, "created": time.time()}
|
||||
|
||||
|
||||
def _prune_pending_locked() -> None:
|
||||
now = time.time()
|
||||
stale = [s for s, v in _PENDING.items() if now - v["created"] > _PENDING_TTL]
|
||||
for s in stale:
|
||||
_PENDING.pop(s, None)
|
||||
|
||||
|
||||
def pop_pending(state: str) -> Optional["OAuthHandler"]:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
entry = _PENDING.pop(state, None)
|
||||
return entry["handler"] if entry else None
|
||||
|
||||
|
||||
def has_pending() -> bool:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
return bool(_PENDING)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers (stdlib only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_UA = "CowAgent-MCP-OAuth/1.0"
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: int = 15) -> Optional[dict]:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": _UA})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.debug(f"[MCP-OAuth] GET {url} -> HTTP {e.code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[MCP-OAuth] GET {url} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _http_post_form(url: str, fields: dict, timeout: int = 20) -> dict:
|
||||
body = urllib.parse.urlencode(fields).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _UA,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict, timeout: int = 20) -> dict:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _UA,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery (RFC 9728 + RFC 8414)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _origin(url: str) -> str:
|
||||
p = urllib.parse.urlparse(url)
|
||||
return f"{p.scheme}://{p.netloc}"
|
||||
|
||||
|
||||
def discover_metadata(resource_url: str, www_authenticate: str = "") -> Optional[dict]:
|
||||
"""
|
||||
Resolve the authorization server metadata for a protected MCP resource.
|
||||
|
||||
Returns a dict with at least authorization_endpoint + token_endpoint,
|
||||
plus registration_endpoint when the server supports DCR. Returns None
|
||||
when discovery fails.
|
||||
"""
|
||||
as_metadata_url = _parse_resource_metadata_url(www_authenticate)
|
||||
|
||||
# 1) Protected-resource metadata (RFC 9728) to locate the auth server.
|
||||
auth_server = None
|
||||
prm = None
|
||||
if as_metadata_url:
|
||||
prm = _http_get_json(as_metadata_url)
|
||||
if prm is None:
|
||||
origin = _origin(resource_url)
|
||||
prm = _http_get_json(f"{origin}/.well-known/oauth-protected-resource")
|
||||
if prm and isinstance(prm.get("authorization_servers"), list) and prm["authorization_servers"]:
|
||||
auth_server = prm["authorization_servers"][0]
|
||||
|
||||
# 2) Authorization-server metadata (RFC 8414). Fall back to the resource
|
||||
# origin when the resource did not advertise a separate auth server.
|
||||
base = auth_server or _origin(resource_url)
|
||||
asm = _fetch_as_metadata(base)
|
||||
if not asm:
|
||||
return None
|
||||
|
||||
if not asm.get("authorization_endpoint") or not asm.get("token_endpoint"):
|
||||
logger.warning("[MCP-OAuth] Authorization server metadata missing required endpoints")
|
||||
return None
|
||||
|
||||
# Derive the scope to request. Prefer the resource's required_scopes
|
||||
# (RFC 9728), then its scopes_supported, then the auth server's
|
||||
# scopes_supported. Stored so callers don't have to configure it.
|
||||
discovered_scope = ""
|
||||
if prm:
|
||||
scopes = prm.get("required_scopes") or prm.get("scopes_supported")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
discovered_scope = " ".join(str(s) for s in scopes)
|
||||
if not discovered_scope and isinstance(asm.get("scopes_supported"), list) and asm["scopes_supported"]:
|
||||
discovered_scope = " ".join(str(s) for s in asm["scopes_supported"])
|
||||
if discovered_scope:
|
||||
asm["_discovered_scope"] = discovered_scope
|
||||
return asm
|
||||
|
||||
|
||||
def _parse_resource_metadata_url(www_authenticate: str) -> Optional[str]:
|
||||
"""Extract resource_metadata="..." from a WWW-Authenticate: Bearer header."""
|
||||
if not www_authenticate:
|
||||
return None
|
||||
# naive but sufficient parse for `resource_metadata="URL"`
|
||||
marker = "resource_metadata="
|
||||
idx = www_authenticate.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
rest = www_authenticate[idx + len(marker):].strip()
|
||||
if rest.startswith('"'):
|
||||
end = rest.find('"', 1)
|
||||
return rest[1:end] if end > 0 else None
|
||||
# unquoted, up to comma/space
|
||||
for sep in (",", " "):
|
||||
if sep in rest:
|
||||
rest = rest.split(sep, 1)[0]
|
||||
return rest or None
|
||||
|
||||
|
||||
def _fetch_as_metadata(base: str) -> Optional[dict]:
|
||||
"""Try both RFC 8414 and OIDC well-known locations."""
|
||||
base = base.rstrip("/")
|
||||
candidates = [
|
||||
f"{base}/.well-known/oauth-authorization-server",
|
||||
f"{base}/.well-known/openid-configuration",
|
||||
]
|
||||
for url in candidates:
|
||||
data = _http_get_json(url)
|
||||
if data and data.get("authorization_endpoint"):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PKCE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _make_pkce() -> tuple:
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuthHandler: per-server OAuth state machine
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class OAuthHandler:
|
||||
"""Drives the OAuth flow and token lifecycle for a single MCP server."""
|
||||
|
||||
def __init__(self, server_name: str, resource_url: str, redirect_uri: str,
|
||||
scope: str = "", client_name: str = "CowAgent"):
|
||||
self.server_name = server_name
|
||||
self.resource_url = resource_url
|
||||
self.redirect_uri = redirect_uri
|
||||
self.scope = scope
|
||||
self.client_name = client_name
|
||||
|
||||
rec = load_server_record(server_name)
|
||||
self.metadata: dict = rec.get("metadata", {})
|
||||
self.client_id: Optional[str] = rec.get("client_id")
|
||||
self.client_secret: Optional[str] = rec.get("client_secret")
|
||||
self.access_token: Optional[str] = rec.get("access_token")
|
||||
self.refresh_token: Optional[str] = rec.get("refresh_token")
|
||||
self.expires_at: float = float(rec.get("expires_at", 0) or 0)
|
||||
self._verifier: Optional[str] = None
|
||||
|
||||
# --- persistence -------------------------------------------------
|
||||
|
||||
def _persist(self) -> None:
|
||||
save_server_record(self.server_name, {
|
||||
"resource_url": self.resource_url,
|
||||
"metadata": self.metadata,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"access_token": self.access_token,
|
||||
"refresh_token": self.refresh_token,
|
||||
"expires_at": self.expires_at,
|
||||
})
|
||||
|
||||
# --- token access ------------------------------------------------
|
||||
|
||||
def get_valid_access_token(self, leeway: int = 60) -> Optional[str]:
|
||||
"""Return a usable access token, refreshing proactively when near expiry."""
|
||||
if not self.access_token:
|
||||
return None
|
||||
if self.expires_at and time.time() >= self.expires_at - leeway:
|
||||
if not self.refresh():
|
||||
return None
|
||||
return self.access_token
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Refresh the access token using the stored refresh token."""
|
||||
if not self.refresh_token or not self.metadata.get("token_endpoint"):
|
||||
return False
|
||||
fields = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"client_id": self.client_id or "",
|
||||
}
|
||||
if self.client_secret:
|
||||
fields["client_secret"] = self.client_secret
|
||||
try:
|
||||
resp = _http_post_form(self.metadata["token_endpoint"], fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] refresh failed: {e}")
|
||||
return False
|
||||
return self._absorb_token_response(resp)
|
||||
|
||||
# --- authorization-code flow ------------------------------------
|
||||
|
||||
def ensure_registered(self, www_authenticate: str = "") -> bool:
|
||||
"""Discover metadata + register a client if not already done."""
|
||||
if not self.metadata.get("authorization_endpoint"):
|
||||
meta = discover_metadata(self.resource_url, www_authenticate)
|
||||
if not meta:
|
||||
return False
|
||||
self.metadata = meta
|
||||
# Adopt the scope discovered from metadata when the user didn't set one.
|
||||
if not self.scope and self.metadata.get("_discovered_scope"):
|
||||
self.scope = self.metadata["_discovered_scope"]
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Using discovered scope: {self.scope}")
|
||||
if not self.client_id:
|
||||
if not self._register_client():
|
||||
return False
|
||||
self._persist()
|
||||
return True
|
||||
|
||||
def _register_client(self) -> bool:
|
||||
reg_endpoint = self.metadata.get("registration_endpoint")
|
||||
if not reg_endpoint:
|
||||
logger.warning(
|
||||
f"[MCP-OAuth:{self.server_name}] No registration_endpoint; "
|
||||
f"DCR unavailable. Provide client_id manually."
|
||||
)
|
||||
return False
|
||||
payload = {
|
||||
"client_name": self.client_name,
|
||||
"redirect_uris": [self.redirect_uri],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
}
|
||||
if self.scope:
|
||||
payload["scope"] = self.scope
|
||||
try:
|
||||
resp = _http_post_json(reg_endpoint, payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR failed: {e}")
|
||||
return False
|
||||
client_id = resp.get("client_id")
|
||||
if not client_id:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR returned no client_id")
|
||||
return False
|
||||
self.client_id = client_id
|
||||
self.client_secret = resp.get("client_secret")
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Registered client_id={client_id}")
|
||||
return True
|
||||
|
||||
def build_authorization_url(self) -> Optional[str]:
|
||||
"""Create an authorization URL and register this handler as pending."""
|
||||
if not self.metadata.get("authorization_endpoint") or not self.client_id:
|
||||
return None
|
||||
self._verifier, challenge = _make_pkce()
|
||||
state = secrets.token_urlsafe(24)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
if self.scope:
|
||||
params["scope"] = self.scope
|
||||
# Advertise the resource we intend to access (RFC 8707).
|
||||
params["resource"] = self.resource_url
|
||||
_register_pending(state, self)
|
||||
return f"{self.metadata['authorization_endpoint']}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def finish_authorization(self, code: str) -> bool:
|
||||
"""Exchange an authorization code for tokens."""
|
||||
if not self.metadata.get("token_endpoint") or not self._verifier:
|
||||
return False
|
||||
fields = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.client_id or "",
|
||||
"code_verifier": self._verifier,
|
||||
"resource": self.resource_url,
|
||||
}
|
||||
if self.client_secret:
|
||||
fields["client_secret"] = self.client_secret
|
||||
try:
|
||||
resp = _http_post_form(self.metadata["token_endpoint"], fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] token exchange failed: {e}")
|
||||
return False
|
||||
ok = self._absorb_token_response(resp)
|
||||
self._verifier = None
|
||||
return ok
|
||||
|
||||
def _absorb_token_response(self, resp: dict) -> bool:
|
||||
access = resp.get("access_token")
|
||||
if not access:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] token response missing access_token: {resp}")
|
||||
return False
|
||||
self.access_token = access
|
||||
if resp.get("refresh_token"):
|
||||
self.refresh_token = resp["refresh_token"]
|
||||
expires_in = resp.get("expires_in")
|
||||
self.expires_at = time.time() + int(expires_in) if expires_in else 0
|
||||
self._persist()
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Access token stored")
|
||||
return True
|
||||
159
agent/tools/mcp/tool_retrieval.py
Normal file
159
agent/tools/mcp/tool_retrieval.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -450,21 +466,30 @@ class ToolManager:
|
||||
the others, and never raises out of the worker thread.
|
||||
"""
|
||||
try:
|
||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
|
||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry, set_reload_callback
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
|
||||
registry = McpClientRegistry()
|
||||
self._mcp_registry = registry
|
||||
# Let the OAuth web callback bring a server online once authorized.
|
||||
set_reload_callback(self.reload_mcp_server)
|
||||
|
||||
for cfg in mcp_servers_config:
|
||||
server_name = cfg.get("name", "<unnamed>")
|
||||
try:
|
||||
client = McpClient(cfg)
|
||||
if not client.initialize():
|
||||
self._mcp_status[server_name] = "failed"
|
||||
logger.warning(
|
||||
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||
)
|
||||
if getattr(client, "needs_auth", False):
|
||||
self._mcp_status[server_name] = "needs_auth"
|
||||
logger.info(
|
||||
f"[MCP] Server '{server_name}' needs authorization — "
|
||||
f"waiting for the user to complete the OAuth flow"
|
||||
)
|
||||
else:
|
||||
self._mcp_status[server_name] = "failed"
|
||||
logger.warning(
|
||||
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
tool_schemas = client.list_tools()
|
||||
@@ -502,6 +527,28 @@ class ToolManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
|
||||
|
||||
def reload_mcp_server(self, server_name: str) -> None:
|
||||
"""Re-initialize a single MCP server (e.g. after OAuth authorization).
|
||||
|
||||
Tears down any existing client for the server and starts it again in
|
||||
the background, so a freshly-stored access token is picked up and the
|
||||
server's tools become available on the next message.
|
||||
"""
|
||||
with self._mcp_lock:
|
||||
cfg = self._mcp_active_configs.get(server_name)
|
||||
if not cfg:
|
||||
logger.warning(f"[MCP] reload requested for unknown server '{server_name}'")
|
||||
return
|
||||
logger.info(f"[MCP] Reloading server '{server_name}' after authorization")
|
||||
self._teardown_mcp_server(server_name)
|
||||
self._mcp_status[server_name] = "pending"
|
||||
threading.Thread(
|
||||
target=self._load_mcp_tools_async,
|
||||
args=([cfg],),
|
||||
daemon=True,
|
||||
name=f"mcp-reload-{server_name}",
|
||||
).start()
|
||||
|
||||
def list_mcp_status(self) -> dict:
|
||||
"""Return {server_name: status} snapshot for UI / debugging."""
|
||||
return dict(self._mcp_status)
|
||||
@@ -523,6 +570,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 +621,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.
|
||||
|
||||
@@ -15,6 +15,7 @@ from .diff import (
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
count_matches,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string,
|
||||
FuzzyMatchResult
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
'normalize_to_lf',
|
||||
'restore_line_endings',
|
||||
'normalize_for_fuzzy_match',
|
||||
'count_matches',
|
||||
'fuzzy_find_text',
|
||||
'generate_diff_string',
|
||||
'FuzzyMatchResult',
|
||||
|
||||
@@ -93,6 +93,40 @@ class FuzzyMatchResult:
|
||||
self.content_for_replacement = content_for_replacement
|
||||
|
||||
|
||||
def _build_fuzzy_pattern(old_text: str) -> Optional[str]:
|
||||
"""
|
||||
Build the whitespace-flexible regex used to locate ``old_text`` fuzzily.
|
||||
|
||||
Returns ``None`` when ``old_text`` has no non-whitespace content to match.
|
||||
This is the single source of truth for fuzzy matching, so that *finding* a
|
||||
match (:func:`fuzzy_find_text`) and *counting* occurrences
|
||||
(:func:`count_matches`) always use the exact same rules.
|
||||
"""
|
||||
stripped = old_text.strip('\n')
|
||||
if not stripped.strip():
|
||||
return None
|
||||
|
||||
source_lines = stripped.split('\n')
|
||||
line_patterns = []
|
||||
for i, line in enumerate(source_lines):
|
||||
tokens = line.split()
|
||||
if not tokens:
|
||||
line_patterns.append(r'[ \t]*')
|
||||
continue
|
||||
# Tolerate any run of blanks between tokens.
|
||||
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||
# First-line leading whitespace is folded into the match only when
|
||||
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||
# the file's existing indentation -- mirroring an exact substring
|
||||
# match. Inner lines always tolerate indentation: it sits inside the
|
||||
# matched region and is re-supplied by new_text.
|
||||
if i > 0 or line[:1] in (' ', '\t'):
|
||||
core = r'[ \t]*' + core
|
||||
line_patterns.append(core + r'[ \t]*')
|
||||
return '\n'.join(line_patterns)
|
||||
|
||||
|
||||
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
"""
|
||||
Find text in content, try exact match first, then fuzzy match
|
||||
@@ -110,25 +144,54 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
match_length=len(old_text),
|
||||
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).
|
||||
pattern = _build_fuzzy_pattern(old_text)
|
||||
if pattern is not None:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
return FuzzyMatchResult(
|
||||
found=True,
|
||||
index=match.start(),
|
||||
match_length=match.end() - match.start(),
|
||||
content_for_replacement=content
|
||||
)
|
||||
|
||||
# Not found
|
||||
return FuzzyMatchResult(found=False)
|
||||
|
||||
|
||||
def count_matches(content: str, old_text: str) -> int:
|
||||
"""
|
||||
Count occurrences of ``old_text`` using the SAME strategy as
|
||||
:func:`fuzzy_find_text`: an exact substring when one is present, otherwise
|
||||
the whitespace-flexible fuzzy regex.
|
||||
|
||||
The edit tool's uniqueness guard must agree with the matcher that actually
|
||||
performs the replacement. Counting through a separate normalization pass
|
||||
(the previous approach) could disagree with the regex used to locate and
|
||||
replace, so both paths now share :func:`_build_fuzzy_pattern`.
|
||||
"""
|
||||
if not old_text:
|
||||
return 0
|
||||
# Mirror fuzzy_find_text: prefer exact matching when it applies.
|
||||
if content.find(old_text) != -1:
|
||||
return content.count(old_text)
|
||||
pattern = _build_fuzzy_pattern(old_text)
|
||||
if pattern is None:
|
||||
return 0
|
||||
return len(re.findall(pattern, content))
|
||||
|
||||
|
||||
def generate_diff_string(old_content: str, new_content: str) -> dict:
|
||||
"""
|
||||
Generate unified diff string
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
18
app.py
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,11 +79,42 @@ def _verify_auth_token(token):
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
def _get_bearer_token():
|
||||
"""Extract the token from an `Authorization: Bearer <token>` header.
|
||||
|
||||
The desktop client renders from a file:// origin, so cross-origin cookies
|
||||
to http://127.0.0.1 are unreliable (SameSite=Lax cookies aren't sent). It
|
||||
therefore authenticates via this header instead; browsers keep using the
|
||||
cookie set by /auth/login.
|
||||
"""
|
||||
auth = web.ctx.env.get("HTTP_AUTHORIZATION", "") or ""
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _get_query_token():
|
||||
"""Extract a token from the `token` query param.
|
||||
|
||||
Needed for SSE endpoints: EventSource can't set an Authorization header,
|
||||
and file:// cookies are unreliable, so the desktop client passes the token
|
||||
in the query string for /stream and /api/logs.
|
||||
"""
|
||||
try:
|
||||
return web.input(token="").token or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _check_auth():
|
||||
"""Return True if request is authenticated or password not enabled."""
|
||||
if not _is_password_enabled():
|
||||
return True
|
||||
return _verify_auth_token(web.cookies().get("cow_auth_token", ""))
|
||||
if _verify_auth_token(web.cookies().get("cow_auth_token", "")):
|
||||
return True
|
||||
if _verify_auth_token(_get_bearer_token()):
|
||||
return True
|
||||
return _verify_auth_token(_get_query_token())
|
||||
|
||||
|
||||
def _require_auth():
|
||||
@@ -181,6 +212,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 +294,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 +587,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 +940,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 +955,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 +978,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 +1056,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 +1088,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 +1203,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()
|
||||
@@ -1136,6 +1280,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
urls = (
|
||||
'/', 'RootHandler',
|
||||
'/api/health', 'HealthHandler',
|
||||
'/auth/login', 'AuthLoginHandler',
|
||||
'/auth/check', 'AuthCheckHandler',
|
||||
'/auth/logout', 'AuthLogoutHandler',
|
||||
@@ -1162,6 +1307,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',
|
||||
@@ -1174,6 +1320,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/messages/delete', 'MessageDeleteHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
'/api/version', 'VersionHandler',
|
||||
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
|
||||
'/assets/(.*)', 'AssetsHandler',
|
||||
)
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
@@ -1198,6 +1345,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):
|
||||
@@ -1225,6 +1374,74 @@ class RootHandler:
|
||||
raise web.seeother('/chat')
|
||||
|
||||
|
||||
class HealthHandler:
|
||||
# Unauthenticated liveness probe. The desktop shell polls this to know the
|
||||
# backend is up; it must never require auth (a set web_password would
|
||||
# otherwise make startup hang). Returns no sensitive data.
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
web.header('Cache-Control', 'no-store')
|
||||
return json.dumps({"status": "ok"})
|
||||
|
||||
|
||||
class McpOAuthCallbackHandler:
|
||||
"""OAuth redirect target for MCP servers requiring authorization.
|
||||
|
||||
The browser lands here after the user authorizes a remote MCP server.
|
||||
We exchange the authorization code for tokens and bring the server
|
||||
online. Unauthenticated by design: the OAuth `state` param is the
|
||||
single-use secret that binds this request to a pending authorization.
|
||||
"""
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'text/html; charset=utf-8')
|
||||
params = web.input(code="", state="", error="", error_description="")
|
||||
|
||||
def _page(title: str, message: str) -> str:
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
f"<title>{title}</title></head>"
|
||||
"<body style='font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
|
||||
"max-width:520px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2328'>"
|
||||
f"<h2>{title}</h2><p style='color:#57606a'>{message}</p></body></html>"
|
||||
)
|
||||
|
||||
if params.error:
|
||||
logger.warning(f"[MCP-OAuth] callback error: {params.error} {params.error_description}")
|
||||
return _page("授权失败", f"{params.error}: {params.error_description or ''}")
|
||||
|
||||
if not params.code or not params.state:
|
||||
return _page("参数缺失", "回调缺少 code 或 state 参数。")
|
||||
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import pop_pending
|
||||
from agent.tools.mcp.mcp_client import notify_server_authorized
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] callback import failed: {e}")
|
||||
return _page("内部错误", "OAuth 模块不可用。")
|
||||
|
||||
handler = pop_pending(params.state)
|
||||
if handler is None:
|
||||
return _page("会话已过期", "授权请求不存在或已过期,请重新触发授权。")
|
||||
|
||||
try:
|
||||
ok = handler.finish_authorization(params.code)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] token exchange crashed: {e}")
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
return _page("授权失败", "换取令牌失败,请重试。")
|
||||
|
||||
notify_server_authorized(handler.server_name)
|
||||
logger.info(f"[MCP-OAuth] Server '{handler.server_name}' authorized via web callback")
|
||||
return _page(
|
||||
"授权成功",
|
||||
f"MCP 服务 “{handler.server_name}” 已授权,可以返回聊天继续使用了。",
|
||||
)
|
||||
|
||||
|
||||
class AuthCheckHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
@@ -1252,7 +1469,9 @@ class AuthLoginHandler:
|
||||
token = _create_auth_token()
|
||||
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
||||
path="/", httponly=True, samesite="Lax")
|
||||
return json.dumps({"status": "success"})
|
||||
# Also return the token in the body: the desktop client (file:// origin)
|
||||
# can't rely on the cookie and sends it back via an Authorization header.
|
||||
return json.dumps({"status": "success", "token": token})
|
||||
|
||||
|
||||
class AuthLogoutHandler:
|
||||
@@ -1481,14 +1700,13 @@ 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 follows right after it.
|
||||
const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
const.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 +1746,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_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
}),
|
||||
("gemini", {
|
||||
"label": "Gemini",
|
||||
@@ -1544,7 +1762,7 @@ class ConfigHandler:
|
||||
"api_base_key": "open_ai_api_base",
|
||||
"api_base_default": "https://api.openai.com/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
}),
|
||||
("zhipu", {
|
||||
"label": {"zh": "智谱AI", "en": "GLM"},
|
||||
@@ -1568,7 +1786,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",
|
||||
@@ -1691,7 +1909,7 @@ class ConfigHandler:
|
||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||
|
||||
return json.dumps({
|
||||
result = {
|
||||
"status": "success",
|
||||
"use_agent": use_agent,
|
||||
"title": title,
|
||||
@@ -1708,7 +1926,13 @@ class ConfigHandler:
|
||||
"api_keys": api_keys_masked,
|
||||
"providers": providers,
|
||||
"web_password_masked": masked_pwd,
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
# The desktop app runs on the local trusted machine, so it can edit
|
||||
# the real password in place (cursor at the end, delete to clear).
|
||||
# Browser access only ever sees the masked value.
|
||||
if os.environ.get("COW_DESKTOP") == "1":
|
||||
result["web_password"] = raw_pwd
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
@@ -1738,9 +1962,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 +1986,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 +2018,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)})
|
||||
@@ -2090,9 +2338,12 @@ class ModelsHandler:
|
||||
# Anything not listed here intentionally hides the model dropdown so
|
||||
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||
_VISION_PROVIDER_MODELS = {
|
||||
# OpenAI ordering matches the recommended GPT-5.4 family first, then
|
||||
# OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
|
||||
# GPT-5 and the GPT-4.1/4o backstops.
|
||||
"openai": [
|
||||
const.GPT_56_LUNA,
|
||||
const.GPT_56_TERRA,
|
||||
const.GPT_56_SOL,
|
||||
const.GPT_55,
|
||||
const.GPT_54,
|
||||
const.GPT_54_MINI,
|
||||
@@ -2102,10 +2353,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_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||
@@ -2126,9 +2377,10 @@ 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.CLAUDE_FABLE_5,
|
||||
const.GEMINI_31_FLASH_LITE_PRE,
|
||||
],
|
||||
# Custom OpenAI-compatible providers have no preset list — model
|
||||
@@ -2357,7 +2609,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 +3803,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 +3820,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 +4402,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 +4438,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 +5018,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')
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.1
|
||||
2.1.3
|
||||
|
||||
@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||
return ret
|
||||
|
||||
|
||||
def _is_frozen() -> bool:
|
||||
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||
|
||||
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
|
||||
playwright is already bundled and we only need to download the browser
|
||||
binary in-process rather than pip-installing anything.
|
||||
"""
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
|
||||
"""Invoke the Playwright CLI, working in both source and frozen builds.
|
||||
|
||||
Source builds shell out to ``python -m playwright <args>``. Frozen builds
|
||||
can't use ``-m`` (the exe isn't a Python interpreter), so we call
|
||||
Playwright's driver entrypoint in-process instead. ``env`` overrides are
|
||||
applied to os.environ for the duration of the call (frozen path) or passed
|
||||
through to the subprocess (source path).
|
||||
"""
|
||||
if not _is_frozen():
|
||||
cmd = [sys.executable, "-m", "playwright"] + args
|
||||
return subprocess.call(cmd, env=env)
|
||||
|
||||
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
|
||||
# returns the Node driver shipped inside the bundle; we spawn it directly.
|
||||
prev_env = {}
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
prev_env[k] = os.environ.get(k)
|
||||
os.environ[k] = v
|
||||
try:
|
||||
from playwright._impl._driver import compute_driver_executable, get_driver_env
|
||||
driver = compute_driver_executable()
|
||||
# compute_driver_executable may return a tuple (node, cli.js) on newer
|
||||
# Playwright, or a single path on older ones.
|
||||
if isinstance(driver, (list, tuple)):
|
||||
cmd = list(driver) + args
|
||||
else:
|
||||
cmd = [str(driver)] + args
|
||||
# get_driver_env() snapshots os.environ, which we've already patched with
|
||||
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
|
||||
# so mirror + pinned browsers dir are honored here too.
|
||||
return subprocess.call(cmd, env=get_driver_env())
|
||||
except Exception as e:
|
||||
# Last resort: try the module main via runpy (works if the frozen build
|
||||
# kept playwright.__main__ importable).
|
||||
try:
|
||||
import runpy
|
||||
sys.argv = ["playwright"] + args
|
||||
runpy.run_module("playwright", run_name="__main__")
|
||||
return 0
|
||||
except SystemExit as se:
|
||||
return int(se.code or 0)
|
||||
except Exception:
|
||||
return 1
|
||||
finally:
|
||||
for k, v in prev_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
||||
"""CLI: colored click output."""
|
||||
if fg == "yellow":
|
||||
@@ -129,6 +192,7 @@ def run_install_browser(
|
||||
stream = stream or _default_stream
|
||||
python = sys.executable
|
||||
legacy_mode = False
|
||||
frozen = _is_frozen()
|
||||
|
||||
_phase(on_phase, _t(
|
||||
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
||||
@@ -159,7 +223,7 @@ def run_install_browser(
|
||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||
if sys.platform == "win32":
|
||||
if sys.platform == "win32" and not frozen:
|
||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||
ret = subprocess.call(
|
||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||
@@ -172,22 +236,52 @@ def run_install_browser(
|
||||
"yellow",
|
||||
)
|
||||
|
||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||
if ret != 0:
|
||||
stream("Failed to install playwright package.", "red")
|
||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||
return 1
|
||||
if frozen:
|
||||
# Desktop bundle: playwright is already shipped inside the app; there is
|
||||
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||||
installed = _get_installed_version()
|
||||
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||||
_phase(on_phase, _t(
|
||||
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
|
||||
"✅ [1/3] Playwright is bundled in the app; skipping install.",
|
||||
))
|
||||
else:
|
||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||
if ret != 0:
|
||||
stream("Failed to install playwright package.", "red")
|
||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||
return 1
|
||||
|
||||
installed = _get_installed_version()
|
||||
if installed:
|
||||
stream(f" playwright {installed} installed.", "green")
|
||||
stream("")
|
||||
_phase(on_phase, _t(
|
||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||
))
|
||||
installed = _get_installed_version()
|
||||
if installed:
|
||||
stream(f" playwright {installed} installed.", "green")
|
||||
stream("")
|
||||
_phase(on_phase, _t(
|
||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||
))
|
||||
|
||||
# With playwright available, prefer the user's system Chrome/Edge: the browser
|
||||
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
|
||||
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
|
||||
# source) — only headless Linux servers, which usually lack a system browser,
|
||||
# fall through to the download below. Honors prefer_system_browser via
|
||||
# resolve_engine, so users who force downloaded Chromium still get it.
|
||||
try:
|
||||
from agent.tools.browser import browser_env
|
||||
summary = browser_env.capability_summary()
|
||||
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
|
||||
sc = summary.get("system_chrome") or {}
|
||||
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
|
||||
_phase(on_phase, _t(
|
||||
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。",
|
||||
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
|
||||
))
|
||||
return 0
|
||||
except Exception as e:
|
||||
stream(f" (system browser probe skipped: {e})", None)
|
||||
|
||||
if sys.platform == "linux":
|
||||
_phase(on_phase, _t(
|
||||
@@ -195,7 +289,7 @@ def run_install_browser(
|
||||
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
||||
))
|
||||
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
||||
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
||||
ret = _playwright_cli(["install-deps", "chromium"])
|
||||
if ret != 0:
|
||||
stream(
|
||||
" Could not auto-install system deps (may need sudo).\n"
|
||||
@@ -238,12 +332,12 @@ def run_install_browser(
|
||||
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
||||
))
|
||||
stream("[3/3] Installing Chromium browser...", "yellow")
|
||||
cmd = [python, "-m", "playwright", "install", "chromium"]
|
||||
pw_args = ["install", "chromium"]
|
||||
|
||||
if _is_headless_linux() and not legacy_mode:
|
||||
ver = _version_tuple(installed or "")
|
||||
if ver >= (1, 57, 0):
|
||||
cmd.append("--only-shell")
|
||||
pw_args.append("--only-shell")
|
||||
stream(" (headless shell for Linux server)", None)
|
||||
else:
|
||||
stream(" (full Chromium)", None)
|
||||
@@ -251,6 +345,15 @@ def run_install_browser(
|
||||
stream(" (full browser for Linux desktop)", None)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Pin the download location so it survives desktop app updates and matches
|
||||
# what the runtime looks up (see browser_env.browsers_download_dir()).
|
||||
try:
|
||||
from agent.tools.browser.browser_env import browsers_download_dir
|
||||
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
use_mirror = _is_china_network()
|
||||
if use_mirror:
|
||||
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
||||
@@ -260,7 +363,7 @@ def run_install_browser(
|
||||
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
||||
))
|
||||
|
||||
ret = subprocess.call(cmd, env=env)
|
||||
ret = _playwright_cli(pw_args, env=env)
|
||||
|
||||
if ret != 0 and use_mirror:
|
||||
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
||||
@@ -268,9 +371,9 @@ def run_install_browser(
|
||||
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
||||
"⚠️ Mirror download failed; retrying with the official CDN…",
|
||||
))
|
||||
env_no_mirror = os.environ.copy()
|
||||
env_no_mirror = dict(env)
|
||||
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
||||
ret = subprocess.call(cmd, env=env_no_mirror)
|
||||
ret = _playwright_cli(pw_args, env=env_no_mirror)
|
||||
|
||||
if ret != 0:
|
||||
stream("Failed to install Chromium.", "red")
|
||||
@@ -282,10 +385,18 @@ def run_install_browser(
|
||||
|
||||
stream("Verifying browser installation...", None)
|
||||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||||
ret = subprocess.call(
|
||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if frozen:
|
||||
# Frozen: no child interpreter to spawn; import in-process instead.
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright # noqa: F401
|
||||
ret = 0
|
||||
except Exception:
|
||||
ret = 1
|
||||
else:
|
||||
ret = subprocess.call(
|
||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if ret != 0:
|
||||
stream(
|
||||
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
||||
|
||||
@@ -30,13 +30,14 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
|
||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
|
||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent 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"
|
||||
@@ -79,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
||||
GPT_54_MINI = "gpt-5.4-mini"
|
||||
GPT_54_NANO = "gpt-5.4-nano"
|
||||
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
||||
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
|
||||
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
|
||||
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
|
||||
O1 = "o1-preview"
|
||||
O1_MINI = "o1-mini"
|
||||
WHISPER_1 = "whisper-1"
|
||||
@@ -153,6 +157,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 +203,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, CLAUDE_FABLE_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||
|
||||
@@ -211,6 +217,7 @@ MODEL_LIST = [
|
||||
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
||||
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
||||
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
||||
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
|
||||
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||
O1, O1_MINI,
|
||||
@@ -223,7 +230,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
config.py
33
config.py
@@ -1,5 +1,6 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -252,6 +253,7 @@ available_setting = {
|
||||
"web_password": "", # Web console password; empty means no authentication required
|
||||
"web_session_expire_days": 30, # Auth session expiry in days
|
||||
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
|
||||
"mcp_oauth_redirect_base": "", # Base URL for MCP OAuth callback (e.g. http://your-ip:9899); empty uses local web console
|
||||
"agent": True, # whether to enable Agent mode
|
||||
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
|
||||
"agent_max_context_tokens": 50000, # max context tokens in Agent mode
|
||||
@@ -264,8 +266,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 +318,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 +337,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 +434,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
|
||||
|
||||
@@ -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,42 @@ 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')
|
||||
|
||||
# Playwright powers the browser tool. Only the pure-Python package + its bundled
|
||||
# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled
|
||||
# and is either satisfied by the user's system Chrome/Edge (preferred, zero
|
||||
# download) or downloaded on demand into ~/.cow/ms-playwright at first use.
|
||||
# Playwright imports its transport/driver lazily, so list submodules explicitly.
|
||||
hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl']
|
||||
hiddenimports += collect_submodules('playwright')
|
||||
|
||||
# --- Data files -----------------------------------------------------------
|
||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||
# (source, dest_dir_in_bundle).
|
||||
datas = [
|
||||
(rp('config-template.json'), '.'),
|
||||
(rp('skills'), 'skills'),
|
||||
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
|
||||
# (it doesn't rely solely on imports), so ship the package directory too.
|
||||
(rp('plugins'), 'plugins'),
|
||||
# Ship the `cli` package as loose files too: onedir adds _internal to
|
||||
# sys.path, so `import cli.commands.*` resolves even if PyInstaller's
|
||||
# submodule collection misses the lazily-imported command modules.
|
||||
(rp('cli'), 'cli'),
|
||||
# Web console served on the backend port: ship chat.html plus its static
|
||||
# assets (~1.9MB) so the browser-based console works as a debug/fallback
|
||||
# entry alongside the Electron UI.
|
||||
@@ -85,6 +136,18 @@ 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')
|
||||
|
||||
# Playwright ships its Node.js driver + package.json under playwright/driver/.
|
||||
# These are NOT Python modules, so hiddenimports won't pull them in — collect
|
||||
# them as data or `playwright install` / launching fails in the frozen build.
|
||||
# include_py_files=True is required: the driver dir contains .py entrypoints.
|
||||
datas += collect_data_files('playwright', include_py_files=True)
|
||||
|
||||
# --- Excludes -------------------------------------------------------------
|
||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||
# mode), tests/docs, and dev-only packages.
|
||||
@@ -94,7 +157,10 @@ excludes = [
|
||||
'pip',
|
||||
'wheel',
|
||||
'pytest',
|
||||
'playwright', # browser tool is opt-in, not bundled
|
||||
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
|
||||
# so the browser tool works out of the box on desktop. The heavy Chromium
|
||||
# binary is still NOT bundled: it comes from the user's system Chrome/Edge or
|
||||
# is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py.
|
||||
]
|
||||
|
||||
block_cipher = None
|
||||
|
||||
19
desktop/build/entitlements.mac.plist
Normal file
19
desktop/build/entitlements.mac.plist
Normal 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
147
desktop/build/notarize-dmg.sh
Executable 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."
|
||||
@@ -42,6 +42,12 @@ python-docx
|
||||
openpyxl
|
||||
python-pptx
|
||||
|
||||
# ---- browser tool ----
|
||||
# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB).
|
||||
# The Chromium binary is NOT bundled: the browser tool drives the user's system
|
||||
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
||||
playwright==1.52.0
|
||||
|
||||
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
||||
wechatpy
|
||||
pycryptodome
|
||||
|
||||
83
desktop/electron-builder.js
Normal file
83
desktop/electron-builder.js
Normal 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
|
||||
122
desktop/electron-builder.win.js
Normal file
122
desktop/electron-builder.win.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Dynamic electron-builder config for WINDOWS code signing.
|
||||
*
|
||||
* Mirrors electron-builder.js (which handles mac.binaries) but for Windows.
|
||||
* It wires a signing CLI into electron-builder so that every .exe is signed,
|
||||
* with the private key kept in hardware per the post-2023 code-signing rules.
|
||||
*
|
||||
* A SINGLE sign hook (win.signtoolOptions.sign) covers everything: electron-
|
||||
* builder calls it for EVERY .exe it processes, which includes the app
|
||||
* launcher, the packaged PyInstaller backend (extraResources/backend/
|
||||
* cowagent-backend.exe) and the NSIS installer. We deliberately do NOT add an
|
||||
* afterPack pass — that would sign the backend a second time and waste a paid
|
||||
* signing call on every release.
|
||||
*
|
||||
* PRIVACY: the CLI path and all credentials come from env vars only. Nothing in
|
||||
* this file (or the public workflow) is hardcoded, so a public repo never leaks
|
||||
* any signing configuration.
|
||||
*
|
||||
* DRY-RUN / SKIP: when SIGNTOOL_CERT_CODE is absent we skip signing entirely
|
||||
* (unsigned dev/dry builds keep working). When COW_SIGN_DRY_RUN=1 we pass
|
||||
* --dry-run so the WHOLE pipeline can be validated in CI with a self-signed
|
||||
* cert, WITHOUT a real certificate and WITHOUT consuming any signing quota.
|
||||
*/
|
||||
const { execFileSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const config = require('./package.json').build
|
||||
|
||||
// Absolute path to the signing CLI on the runner. Injected by CI so this file
|
||||
// never hardcodes a download URL. e.g. C:\signtool\signtool.exe
|
||||
const SIGNTOOL = process.env.SIGNTOOL_PATH || ''
|
||||
// Dry-run validates the pipeline with a self-signed cert (no quota, no real
|
||||
// cert needed). Any truthy value enables it.
|
||||
const DRY_RUN = !!process.env.COW_SIGN_DRY_RUN
|
||||
|
||||
// In dry-run the CLI still requires these flags to be NON-EMPTY (it validates
|
||||
// presence, not the value, and signs with a self-signed cert). So when no real
|
||||
// credentials are provided during a dry-run, fall back to harmless placeholders
|
||||
// to satisfy the CLI's arg check. Real runs pass the actual secrets through.
|
||||
const PLACEHOLDER = DRY_RUN ? 'dry-run' : ''
|
||||
const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || PLACEHOLDER
|
||||
const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || PLACEHOLDER
|
||||
const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || PLACEHOLDER
|
||||
|
||||
// RFC3161 timestamp server for SHA256. Microsoft's is reliable from CI runners
|
||||
// worldwide; overridable via env if needed.
|
||||
const TIMESTAMP = process.env.SIGNTOOL_TIMESTAMP || 'http://timestamp.acs.microsoft.com'
|
||||
|
||||
// Signing is possible when we have the CLI plus either a real cert code or
|
||||
// explicit dry-run mode (dry-run accepts placeholder credentials).
|
||||
function canSign() {
|
||||
if (!SIGNTOOL || !fs.existsSync(SIGNTOOL)) return false
|
||||
if (DRY_RUN) return true
|
||||
return !!(ACCESS_KEY && ACCESS_SECRET && CERT_CODE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a single file in place using the signing CLI. The CLI writes to a
|
||||
* separate --out path (it refuses to overwrite an existing file), so we sign to
|
||||
* a temp file and atomically move it back over the original.
|
||||
*/
|
||||
function signFile(filePath) {
|
||||
const tmpOut = `${filePath}.signed`
|
||||
// Remove a stale temp from a previous failed run (CLI errors if --out exists).
|
||||
try {
|
||||
if (fs.existsSync(tmpOut)) fs.rmSync(tmpOut)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const args = [
|
||||
'sign',
|
||||
...(DRY_RUN ? ['--dry-run'] : []),
|
||||
`--access-key=${ACCESS_KEY}`,
|
||||
`--access-secret=${ACCESS_SECRET}`,
|
||||
`--cert-code=${CERT_CODE}`,
|
||||
`--file=${filePath}`,
|
||||
`--out=${tmpOut}`,
|
||||
'--sha1=false',
|
||||
'--sha2=true',
|
||||
'--timestamp-rfc3161',
|
||||
TIMESTAMP,
|
||||
]
|
||||
|
||||
// Never print credentials: log only the file being signed.
|
||||
console.log(`[win-sign] signing ${path.basename(filePath)}${DRY_RUN ? ' (dry-run)' : ''}`)
|
||||
execFileSync(SIGNTOOL, args, { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||
|
||||
if (!fs.existsSync(tmpOut)) {
|
||||
throw new Error(`[win-sign] signed output not produced for ${filePath}`)
|
||||
}
|
||||
// Replace the original with the signed copy.
|
||||
fs.rmSync(filePath)
|
||||
fs.renameSync(tmpOut, filePath)
|
||||
}
|
||||
|
||||
// electron-builder calls this for each artifact it generates (app exe, NSIS
|
||||
// installer, uninstaller). Signature: (configuration) => void, where
|
||||
// configuration.path is the file to sign.
|
||||
async function customSign(configuration) {
|
||||
if (!canSign()) {
|
||||
console.warn('[win-sign] signing skipped (no signtool/credentials)')
|
||||
return
|
||||
}
|
||||
signFile(configuration.path)
|
||||
}
|
||||
|
||||
// Extend the base config: attach the sign hook. Only meaningful on Windows
|
||||
// builds (this config is only passed via --config on the win matrix leg).
|
||||
//
|
||||
// electron-builder invokes customSign for EVERY .exe it touches — that already
|
||||
// includes the packaged backend (extraResources/backend/cowagent-backend.exe)
|
||||
// and the NSIS installer, not just the app launcher. So there's no separate
|
||||
// afterPack pass: adding one would sign the backend twice (wasting a paid
|
||||
// signing call per release). Nested PyInstaller .dll/.pyd files are left
|
||||
// unsigned, which Windows Authenticode tolerates (unlike macOS, it doesn't
|
||||
// require deep-signing every nested lib — a signed top-level exe is enough for
|
||||
// SmartScreen/Defender to attribute the publisher).
|
||||
config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } }
|
||||
|
||||
module.exports = config
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { ChildProcess, spawn, execFileSync } from 'child_process'
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
import net from 'net'
|
||||
|
||||
// Writable data dir for the packaged app (config.json, run.log, user data).
|
||||
// Lives in the user's home so it survives app updates and avoids writing into
|
||||
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
||||
|
||||
// Fixed port for the desktop backend. Deliberately not 9899 (the web console's
|
||||
// default) so a source-run `python app.py` never collides with the packaged
|
||||
// app. This is a SINGLE SOURCE OF TRUTH shared with the renderer (see
|
||||
// useBackend.ts BACKEND_PORT): the backend is always told to bind exactly here
|
||||
// via COW_WEB_PORT, and the renderer always talks to exactly here. We do NOT
|
||||
// fall back to an OS-random port, because the renderer could never guess it —
|
||||
// instead we proactively free this port before launch (see freePort()).
|
||||
export const DESKTOP_BACKEND_PORT = 9876
|
||||
|
||||
export class PythonBackend extends EventEmitter {
|
||||
private process: ChildProcess | null = null
|
||||
private backendPath: string
|
||||
private port: number = 9899
|
||||
private port: number = DESKTOP_BACKEND_PORT
|
||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||
|
||||
constructor(backendPath: string) {
|
||||
@@ -29,6 +39,77 @@ export class PythonBackend extends EventEmitter {
|
||||
return this.status
|
||||
}
|
||||
|
||||
// Cache the resolved PATH so we only spawn a login shell once per process.
|
||||
private resolvedPath: string | null = null
|
||||
|
||||
/**
|
||||
* Build the PATH the backend should run with.
|
||||
*
|
||||
* When launched from Finder/Dock, a GUI app inherits launchd's minimal PATH
|
||||
* (/usr/bin:/bin:...) and never loads ~/.zshrc, so user-installed CLIs like
|
||||
* `linkai`, `node`, or Homebrew tools are invisible to the agent's bash tool.
|
||||
* We recover the real login-shell PATH (macOS/Linux) and merge in common bin
|
||||
* dirs, so the agent can find these commands regardless of how the app started.
|
||||
*/
|
||||
private resolveEnvPath(): string {
|
||||
if (this.resolvedPath !== null) {
|
||||
return this.resolvedPath
|
||||
}
|
||||
|
||||
const sep = path.delimiter
|
||||
const existing = process.env.PATH || ''
|
||||
const parts: string[] = existing ? existing.split(sep) : []
|
||||
|
||||
// Windows GUI apps already inherit the full system PATH; nothing to fix.
|
||||
if (process.platform !== 'win32') {
|
||||
// Ask the user's login shell for its PATH. `-ilc` runs an interactive
|
||||
// login shell so it sources ~/.zshrc / ~/.zprofile etc.
|
||||
try {
|
||||
const shell = process.env.SHELL || '/bin/zsh'
|
||||
const out = execFileSync(shell, ['-ilc', 'echo -n "__PATH__$PATH"'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
const marker = out.lastIndexOf('__PATH__')
|
||||
if (marker !== -1) {
|
||||
const shellPath = out.slice(marker + '__PATH__'.length).trim()
|
||||
if (shellPath) {
|
||||
parts.push(...shellPath.split(sep))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Shell probe failed (unusual shell, timeout). Fall back to the
|
||||
// common dirs below so at least the typical install paths work.
|
||||
}
|
||||
|
||||
const home = os.homedir()
|
||||
parts.push(
|
||||
path.join(home, '.local/bin'),
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/usr/sbin',
|
||||
'/sbin',
|
||||
)
|
||||
}
|
||||
|
||||
// De-duplicate while preserving order (first occurrence wins).
|
||||
const seen = new Set<string>()
|
||||
const merged: string[] = []
|
||||
for (const p of parts) {
|
||||
const dir = p.trim()
|
||||
if (dir && !seen.has(dir)) {
|
||||
seen.add(dir)
|
||||
merged.push(dir)
|
||||
}
|
||||
}
|
||||
|
||||
this.resolvedPath = merged.join(sep)
|
||||
return this.resolvedPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the packaged onedir backend executable shipped with the app.
|
||||
* Returns null when not present (e.g. during local development), so we can
|
||||
@@ -66,23 +147,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 +280,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 +298,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')
|
||||
@@ -138,8 +332,15 @@ export class PythonBackend extends EventEmitter {
|
||||
// app bundle stays read-only; dev runs omit it and keep using the repo.
|
||||
env: {
|
||||
...process.env,
|
||||
// Recover the user's real PATH (login shell + common bin dirs) so the
|
||||
// agent's bash tool can find CLIs like `linkai`/`node` even when the
|
||||
// app is launched from Finder/Dock with launchd's minimal PATH.
|
||||
PATH: this.resolveEnvPath(),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
COW_DESKTOP: '1',
|
||||
// The shell owns the port: tell the backend to bind exactly here so the
|
||||
// two sides can never disagree (and we avoid the 9899 web-console clash).
|
||||
COW_WEB_PORT: String(this.port),
|
||||
...(bundled ? { COW_DATA_DIR } : {}),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -160,10 +361,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 +381,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
|
||||
@@ -194,7 +390,10 @@ export class PythonBackend extends EventEmitter {
|
||||
const startedAt = Date.now()
|
||||
|
||||
const check = () => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: /config
|
||||
// requires auth once a web_password is set, which would make this poll
|
||||
// 401 forever and hang startup.
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend ready on port ${this.port}`)
|
||||
@@ -213,7 +412,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
|
||||
}
|
||||
|
||||
@@ -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
21
desktop/src/renderer/assets.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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'
|
||||
import StatusScreen from './components/StatusScreen'
|
||||
import LoginGate from './components/LoginGate'
|
||||
import { useBackend } from './hooks/useBackend'
|
||||
import { usePlatform } from './hooks/usePlatform'
|
||||
import { useUIStore } from './store/uiStore'
|
||||
@@ -27,21 +28,50 @@ 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)
|
||||
// Auth gate for web_password-protected backends. 'checking' until we know
|
||||
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
||||
// the main UI render.
|
||||
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// Once the backend is ready, check whether a web_password is set. If so and
|
||||
// this session isn't authenticated, show the login gate before the app.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') {
|
||||
setAuthState('checking')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.authCheck()
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const needLogin = res.auth_required && !res.authenticated
|
||||
setAuthState(needLogin ? 'need_login' : 'ok')
|
||||
})
|
||||
.catch(() => {
|
||||
// If the check itself fails, don't hard-block the user — assume no auth
|
||||
// is required (backends without web_password never return errors here).
|
||||
if (!cancelled) setAuthState('ok')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// First-run check: once the backend is ready, decide whether to show the
|
||||
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||
// configured (and not dismissed earlier this session); no persisted flag.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') return
|
||||
if (backend.status !== 'ready' || authState !== 'ok') return
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.getModels()
|
||||
@@ -65,7 +95,7 @@ const App: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, maybeOpenOnboarding])
|
||||
}, [backend.status, authState, maybeOpenOnboarding])
|
||||
|
||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||
useEffect(() => initUpdateListener(), [])
|
||||
@@ -91,6 +121,15 @@ const App: React.FC = () => {
|
||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
// Backend is up but we're still resolving auth — keep the loading screen.
|
||||
if (authState === 'checking') {
|
||||
return <StatusScreen status="connecting" onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
if (authState === 'need_login') {
|
||||
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
|
||||
}
|
||||
|
||||
const isChat = location.pathname === '/'
|
||||
const showSessions = isChat && !sessionsCollapsed
|
||||
|
||||
@@ -107,10 +146,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" />
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
KnowledgeList,
|
||||
KnowledgeGraph,
|
||||
KnowledgeAction,
|
||||
KnowledgeImportPayload,
|
||||
} from '../types'
|
||||
|
||||
interface ApiResult {
|
||||
@@ -23,8 +24,16 @@ interface ApiResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_KEY = 'cow_auth_token'
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9899'
|
||||
private baseUrl = 'http://127.0.0.1:9876'
|
||||
// Bearer token for web_password-protected backends. The desktop renderer
|
||||
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
|
||||
// aren't sent reliably, so we authenticate via an Authorization header
|
||||
// instead. Persisted in localStorage so it survives reloads.
|
||||
private authToken: string | null =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url
|
||||
@@ -34,13 +43,25 @@ class ApiClient {
|
||||
return this.baseUrl
|
||||
}
|
||||
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||
else localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
} catch {
|
||||
// localStorage may be unavailable; in-memory token still works this session
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
// Send cookies for future web_password auth support
|
||||
// Cookies still work for browser access; the desktop app relies on the
|
||||
// Authorization header below.
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
@@ -92,8 +113,16 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// EventSource can't set an Authorization header, so append the auth token as
|
||||
// a query param for SSE endpoints (the backend accepts it there).
|
||||
private withToken(url: string): string {
|
||||
if (!this.authToken) return url
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
|
||||
}
|
||||
|
||||
createSSEStream(requestId: string): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
|
||||
}
|
||||
|
||||
async deleteMessage(opts: {
|
||||
@@ -137,11 +166,13 @@ class ApiClient {
|
||||
|
||||
getFileUrl(previewUrl: string): string {
|
||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
||||
return `${this.baseUrl}${previewUrl}`
|
||||
// Served via <img src>, which can't set headers — carry the token in the
|
||||
// query so protected file endpoints load under web_password.
|
||||
return this.withToken(`${this.baseUrl}${previewUrl}`)
|
||||
}
|
||||
|
||||
getServeFileUrl(absPath: string): string {
|
||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
||||
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
@@ -315,6 +346,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
|
||||
// ---------------------------------------------------------
|
||||
@@ -372,7 +420,7 @@ class ApiClient {
|
||||
// ---------------------------------------------------------
|
||||
|
||||
createLogStream(): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
@@ -388,14 +436,19 @@ class ApiClient {
|
||||
return this.request('/auth/check')
|
||||
}
|
||||
|
||||
async authLogin(password: string): Promise<ApiResult> {
|
||||
return this.request('/auth/login', {
|
||||
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
|
||||
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (res.status === 'success' && res.token) {
|
||||
this.setAuthToken(res.token)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async authLogout(): Promise<ApiResult> {
|
||||
this.setAuthToken(null)
|
||||
return this.request('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
BIN
desktop/src/renderer/src/assets/logo.png
Normal file
BIN
desktop/src/renderer/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -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,54 @@ 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: '/install-browser', desc: t('slash_install_browser') },
|
||||
{ cmd: '/config', desc: t('slash_config') },
|
||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||
{ cmd: '/logs', desc: t('slash_logs') },
|
||||
{ cmd: '/version', desc: t('slash_version') },
|
||||
]
|
||||
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||
|
||||
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 +94,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 +170,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 +262,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 +341,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 +369,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 +384,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>
|
||||
|
||||
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface LoginGateProps {
|
||||
// Called once the password is accepted (auth cookie set), so the app can
|
||||
// proceed to the main UI.
|
||||
onAuthenticated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the backend has a web_password set and the current session isn't
|
||||
* authenticated yet. Submitting the correct password sets an auth cookie
|
||||
* (handled by the backend), after which the app reloads its data.
|
||||
*/
|
||||
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await apiClient.authLogin(password)
|
||||
if (res.status === 'success') {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
setError(t('login_error'))
|
||||
}
|
||||
} catch {
|
||||
setError(t('login_error'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
|
||||
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder={t('login_placeholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !password}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{submitting ? t('login_checking') : t('login_submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginGate
|
||||
@@ -30,6 +30,34 @@ const md: MarkdownIt = new MarkdownIt({
|
||||
},
|
||||
})
|
||||
|
||||
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`)
|
||||
// and CJK full-width punctuation glued to a URL (common in LLM output like
|
||||
// `**https://x**,中文`), turning the whole tail into one broken link. Cut the
|
||||
// URL at the first such char and spill the remainder back as plain text.
|
||||
const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/
|
||||
md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => {
|
||||
for (const blk of state.tokens) {
|
||||
if (blk.type !== 'inline' || !blk.children) continue
|
||||
const ch = blk.children
|
||||
for (let i = 0; i < ch.length; i++) {
|
||||
const open = ch[i]
|
||||
if (open.type !== 'link_open' || open.markup !== 'linkify') continue
|
||||
const textTok = ch[i + 1]
|
||||
const close = ch[i + 2]
|
||||
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue
|
||||
const idx = textTok.content.search(_GREEDY_LINK_CUT)
|
||||
if (idx < 0) continue
|
||||
const keep = textTok.content.slice(0, idx)
|
||||
const spill = textTok.content.slice(idx)
|
||||
textTok.content = keep
|
||||
open.attrSet('href', keep)
|
||||
const spillTok = new state.Token('text', '', 0)
|
||||
spillTok.content = spill
|
||||
ch.splice(i + 3, 0, spillTok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Open links in a new tab safely.
|
||||
const defaultLinkOpen =
|
||||
md.renderer.rules.link_open ||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
16
desktop/src/renderer/src/components/icons.tsx
Normal file
16
desktop/src/renderer/src/components/icons.tsx
Normal 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>
|
||||
)
|
||||
@@ -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,13 +16,16 @@ 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)
|
||||
|
||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/config`, {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: once a
|
||||
// web_password is set, /config returns 401 and we'd wrongly treat the
|
||||
// (healthy) backend as unreachable, hanging on "connecting".
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
@@ -31,7 +41,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 +84,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 +117,7 @@ export function useBackend() {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
startPolling(9899)
|
||||
startPolling(BACKEND_PORT)
|
||||
}
|
||||
|
||||
// When the window comes back to the foreground, re-probe immediately so a
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 和 TXT,TXT 会转成 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,31 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: '初始化失败',
|
||||
status_error_desc: '客户端初始化失败,请重试',
|
||||
status_retry: '重试',
|
||||
// login (web_password)
|
||||
login_title: '请输入访问密码',
|
||||
login_desc: '此客户端已设置访问密码,请输入以继续',
|
||||
login_placeholder: '访问密码',
|
||||
login_submit: '进入',
|
||||
login_error: '密码错误,请重试',
|
||||
login_checking: '验证中...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: '命令',
|
||||
slash_new: '新建对话',
|
||||
slash_clear: '清除对话上下文',
|
||||
slash_help: '显示命令帮助',
|
||||
slash_status: '查看运行状态',
|
||||
slash_context: '查看对话上下文',
|
||||
slash_skill_list: '查看已安装技能',
|
||||
slash_skill_search: '搜索技能',
|
||||
slash_skill_install: '安装技能 (名称或 GitHub URL)',
|
||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||
slash_knowledge: '查看知识库统计',
|
||||
slash_knowledge_list: '查看知识库文件树',
|
||||
slash_install_browser: '安装浏览器工具',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_logs: '查看最近日志',
|
||||
slash_version: '查看版本',
|
||||
},
|
||||
en: {
|
||||
console: 'Console',
|
||||
@@ -324,15 +411,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 +522,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 +659,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 +739,31 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: 'Initialization Failed',
|
||||
status_error_desc: 'Failed to initialize the client, please retry',
|
||||
status_retry: 'Retry',
|
||||
// login (web_password)
|
||||
login_title: 'Enter access password',
|
||||
login_desc: 'This client is password-protected. Enter the password to continue.',
|
||||
login_placeholder: 'Access password',
|
||||
login_submit: 'Enter',
|
||||
login_error: 'Wrong password, please try again',
|
||||
login_checking: 'Verifying...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: 'Commands',
|
||||
slash_new: 'New chat',
|
||||
slash_clear: 'Clear conversation context',
|
||||
slash_help: 'Show command help',
|
||||
slash_status: 'Show running status',
|
||||
slash_context: 'Show conversation context',
|
||||
slash_skill_list: 'List installed skills',
|
||||
slash_skill_search: 'Search skills',
|
||||
slash_skill_install: 'Install a skill (name or GitHub URL)',
|
||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||
slash_knowledge: 'Show knowledge base stats',
|
||||
slash_knowledge_list: 'Show knowledge base file tree',
|
||||
slash_install_browser: 'Install browser tool',
|
||||
slash_config: 'Show current config',
|
||||
slash_cancel: 'Abort the running agent task',
|
||||
slash_logs: 'Show recent logs',
|
||||
slash_version: 'Show version',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setMaxSteps(data.agent_max_steps ?? 20)
|
||||
setThinking(!!data.enable_thinking)
|
||||
setEvolution(!!data.self_evolution_enabled)
|
||||
setPassword(data.web_password_masked || '')
|
||||
// Prefer the real password (desktop only) so it can be edited in place;
|
||||
// fall back to the masked value for browser access.
|
||||
setPassword(data.web_password ?? data.web_password_masked ?? '')
|
||||
setPwDirty(false)
|
||||
|
||||
const ids = data.providers ? Object.keys(data.providers) : []
|
||||
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setTimeout(() => setAgentStatus(''), 2000)
|
||||
}
|
||||
|
||||
// Desktop returns the real password, so the field holds plaintext and can be
|
||||
// saved (including cleared) directly. Browser access only has the masked
|
||||
// value, where a masked string must never be saved as the real password.
|
||||
const hasRealPassword = config?.web_password !== undefined
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!pwDirty || MASK_RE.test(password)) return
|
||||
if (!pwDirty) return
|
||||
if (!hasRealPassword && MASK_RE.test(password)) return
|
||||
try {
|
||||
await apiClient.updateConfig({ web_password: password })
|
||||
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||
@@ -170,12 +178,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 = [
|
||||
@@ -288,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
value={password}
|
||||
placeholder={t('config_password_placeholder')}
|
||||
onFocus={() => {
|
||||
if (!pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
// Browser access shows a mask; clear it on focus so the user
|
||||
// types a fresh password. Desktop holds the real password and
|
||||
// must stay editable in place (cursor at the end).
|
||||
if (!hasRealPassword && !pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
||||
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -222,6 +230,9 @@ export interface ConfigData {
|
||||
api_keys: Record<string, string>
|
||||
providers: Record<string, ProviderMeta>
|
||||
web_password_masked?: string
|
||||
// Real password, only returned to the desktop app (trusted local machine) so
|
||||
// it can be edited in place. Undefined for browser access.
|
||||
web_password?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -422,11 +433,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
|
||||
// ============================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
51
docs/guide/desktop.mdx
Normal file
51
docs/guide/desktop.mdx
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent Desktop client" />
|
||||
|
||||
## 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).
|
||||
|
||||
## Access from a Browser
|
||||
|
||||
Once the desktop client is running, it listens on port `9876` locally, and its backend is exactly the same Web console. So while the app is open you can also just point your browser at `http://localhost:9876` for the same full experience as the client UI.
|
||||
|
||||
## Local Data Storage
|
||||
|
||||
All data of the desktop client is stored on your machine:
|
||||
|
||||
- **Config directory**: `~/.cow` in your home folder, holding `config.json` (model keys, channels, etc.) along with logs, cache and other runtime data.
|
||||
- **Workspace**: `~/cow` by default, holding chat history, knowledge base, memory, skills, scheduled tasks and other files produced by the Agent.
|
||||
|
||||
Uninstalling the client does not delete these two directories, so your data is preserved across reinstalls. To fully clean up or migrate to another device, just back up or remove the corresponding folders manually.
|
||||
@@ -59,3 +59,9 @@ sudo docker compose up -d
|
||||
<Tip>
|
||||
Back up `config.json` before upgrading. For Docker deployments, mount the workspace directory as a volume to persist data across upgrades.
|
||||
</Tip>
|
||||
|
||||
## Desktop client upgrade
|
||||
|
||||
The [desktop client](/guide/desktop) has built-in auto-update: it checks for new versions automatically and prompts you, so you can download and restart to upgrade in one click.
|
||||
|
||||
You can also grab the latest version anytime from the [download page](https://cowagent.ai/download/).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,6 +24,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
|
||||
<a href="https://docs.cowagent.ai/ja/intro/index">📖 ドキュメント</a> ·
|
||||
<a href="https://docs.cowagent.ai/ja/guide/quick-start">🚀 クイックスタート</a> ·
|
||||
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> ·
|
||||
<a href="https://cowagent.ai/download/">💻 ダウンロード</a> ·
|
||||
<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 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [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 対応。
|
||||
@@ -225,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
||||
|
||||
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
||||
|
||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
51
docs/ja/guide/desktop.mdx
Normal file
51
docs/ja/guide/desktop.mdx
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: デスクトップクライアント
|
||||
description: CowAgent デスクトップクライアント(macOS / Windows)のダウンロードと使い方
|
||||
---
|
||||
|
||||
CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスクトップクライアントを提供しています。**Python や依存関係を手動でインストールする必要はありません**。ダウンロードしてインストールするだけで、ローカルでスーパー AI アシスタントを実行できます。
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent デスクトップクライアント" />
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
<Card title="ダウンロードページへ" icon="download" href="https://cowagent.ai/download/">
|
||||
macOS / Windows 用インストーラーをダウンロード
|
||||
</Card>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="macOS">
|
||||
1. [ダウンロードページ](https://cowagent.ai/download/) を開き、チップに合わせて選択します:
|
||||
- Apple Silicon(M1/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) を参照。
|
||||
|
||||
## ブラウザからのアクセス
|
||||
|
||||
デスクトップクライアントは起動するとローカルでポート `9876` をリッスンし、そのバックエンドは Web コンソールとまったく同じです。そのため、アプリを開いている間はブラウザで `http://localhost:9876` にアクセスすれば、クライアント UI と同じ完全な体験が得られます。
|
||||
|
||||
## ローカルデータの保存
|
||||
|
||||
デスクトップクライアントのすべてのデータはお使いのマシンに保存されます:
|
||||
|
||||
- **設定ディレクトリ**:ホームフォルダの `~/.cow`。`config.json`(モデルキーやチャネルなどの設定)のほか、ログ・キャッシュなどの実行データが含まれます。
|
||||
- **ワークスペース**:デフォルトは `~/cow`。会話履歴・ナレッジベース・記憶・スキル・定期タスクなど、Agent が生成したファイルが保存されます。
|
||||
|
||||
クライアントをアンインストールしてもこの 2 つのディレクトリは自動削除されないため、再インストール後もデータは保持されます。完全に削除したい場合や別のデバイスへ移行する場合は、該当するフォルダを手動でバックアップまたは削除してください。
|
||||
@@ -59,3 +59,9 @@ sudo docker compose up -d
|
||||
<Tip>
|
||||
アップグレード前に `config.json` 設定ファイルのバックアップを推奨します。Docker 環境でデータを保持する場合は、volume マウントでワークスペースディレクトリを永続化できます。
|
||||
</Tip>
|
||||
|
||||
## デスクトップクライアントのアップグレード
|
||||
|
||||
[デスクトップクライアント](/ja/guide/desktop)は自動アップデート機能を内蔵しており、新しいバージョンを自動的に確認して通知します。ワンクリックでダウンロードして再起動し、アップグレードを完了できます。
|
||||
|
||||
最新バージョンは [ダウンロードページ](https://cowagent.ai/download/) からいつでも手動で入手することもできます。
|
||||
|
||||
@@ -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` もこのスイッチの影響を受けず、いつでもトリガー可能です。
|
||||
|
||||
## 安全メカニズム
|
||||
|
||||
| メカニズム | 説明 |
|
||||
|
||||
@@ -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-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
||||
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||
|
||||
@@ -28,8 +28,9 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
||||
|
||||
| モデル | 用途 |
|
||||
| --- | --- |
|
||||
| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め |
|
||||
| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い |
|
||||
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
||||
| `claude-fable-5` | Claude 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 +45,7 @@ Vision モデルを手動で指定したい場合は、設定ファイルで明
|
||||
{
|
||||
"tools": {
|
||||
"vision": {
|
||||
"model": "claude-sonnet-4-6"
|
||||
"model": "claude-sonnet-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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、claude-fable-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 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [OpenAI](/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [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+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -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`、`claude-fable-5`、`gemini-3.1-flash-lite-preview` など。
|
||||
|
||||
## 画像生成
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"model": "gpt-5.6-luna",
|
||||
"open_ai_api_key": "YOUR_API_KEY",
|
||||
"open_ai_api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -22,7 +22,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1`、o シリーズなどをサポート。Agent モードのデフォルトは `gpt-5.5`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1` などをサポート。Agent モードのデフォルトは `gpt-5.6-luna`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
||||
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
||||
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
||||
|
||||
@@ -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、新モデル追加 |
|
||||
|
||||
73
docs/ja/releases/v2.1.3.mdx
Normal file
73
docs/ja/releases/v2.1.3.mdx
Normal 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 @Jiangrong-W
|
||||
- **設定解析の安全化**:設定内容をより安全な方法で解析し、潜在的なコード実行リスクを回避します。Thanks @shunfeng8421
|
||||
|
||||
## 🛠 改善と修正
|
||||
|
||||
- **カスタムプロバイダー対応**:埋め込みモデルとビジョンモデルでカスタムプロバイダーを利用可能に。あわせて Windows でのメモリ取得の問題を修正しました。Thanks @HnBigVolibear
|
||||
- **ファイル編集の安定性向上**:元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @weijun-xia (#2942)
|
||||
- **コマンド出力の文字化け修正**:コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @weijun-xia (#2941)
|
||||
- **Azure OpenAI の修正**:Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Tunnello
|
||||
- **企業向け 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)
|
||||
@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
|
||||
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
**デスクトップクライアント利用者**:playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時:
|
||||
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
|
||||
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
|
||||
</Note>
|
||||
|
||||
## ワークフロー
|
||||
|
||||
Agentがブラウザを使う典型的な流れ:
|
||||
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ:
|
||||
}
|
||||
```
|
||||
|
||||
## ブラウザエンジン
|
||||
|
||||
ブラウザエンジンは自動選択され、設定は不要です:
|
||||
|
||||
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
|
||||
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
|
||||
|
||||
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
|
||||
|
||||
## ログイン状態の永続化
|
||||
|
||||
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
||||
|
||||
@@ -37,6 +37,7 @@ MCP コミュニティ標準に完全準拠しており、Claude Desktop / Curso
|
||||
| `url` | SSE / Streamable HTTP | リモートエンドポイントの URL(`command` と二者択一) |
|
||||
| `type` | リモート | リモートトランスポート種別:`sse` または `streamable-http`(既定は `sse`) |
|
||||
| `headers` | 任意 | リモートリクエストの追加 HTTP ヘッダ(`Authorization` など)。Streamable HTTP のみ |
|
||||
| `scope` | 任意 | OAuth スコープ。OAuth 認可が必要なリモート server のみ使用(任意) |
|
||||
| `disabled` | 任意 | `true` のとき該当サーバーをスキップ。一時的に無効化したいときに便利 |
|
||||
|
||||
### 完全な例
|
||||
@@ -79,6 +80,27 @@ Agent は次のように動作します:
|
||||
1. 既存の MCP 設定ファイルを読み込み、新しい server エントリをマージ(既存の項目は保持)
|
||||
2. 増分の MCP Server を自動でリロードし、次のメッセージから対応する Tool が利用可能に
|
||||
|
||||
## Web 認可(OAuth)
|
||||
|
||||
一部のリモート MCP は OAuth の Web 認可が必要で、そのまま設定すると `401` が返ります。CowAgent は標準的な OAuth フローを内蔵しているため、**token を手動で入力する必要はなく**、通常どおり設定するだけです。例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"xmind": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://app.xmind.com/api/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
server の初回ロードで `401` が返ると、認可が自動的に開始されます。ローカル実行では**自動的にブラウザが開き**、サーバー環境では**認可リンクがログに出力**されるので、ブラウザで開いてください。承認すると server はすぐにオンラインになり、token は期限切れ時に自動更新されるため、再認可は不要です。
|
||||
|
||||
- **Web サービスが必要**:認可コールバックは Web コンソール(既定ポート `9899`)で受け取るため、Web channel が起動している必要があります。
|
||||
- **認証情報の保存**:token は `~/.cow/mcp_oauth.json` に永続化され、再起動後も再利用されます。
|
||||
- **コールバック URL**:既定は `http://127.0.0.1:9899/mcp/oauth/callback`。サーバーに配置し認可用ブラウザが別の端末にある場合は、`config.json` に `mcp_oauth_redirect_base`(例:`http://あなたのIP:9899`)を設定してください。
|
||||
|
||||
## 動作の仕組み
|
||||
|
||||
- **起動時の非同期ロード**:`mcp.json` に設定された全 server はバックグラウンドで非同期に読み込まれ、メインループをブロックしません。会話はすぐに開始できます
|
||||
|
||||
@@ -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 | 常にビジョン専用モデルを使用 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
||||
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||
|
||||
@@ -28,8 +28,9 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
||||
|
||||
| Model | Use Case |
|
||||
| --- | --- |
|
||||
| `claude-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-fable-5` | Alternative flagship in the Claude 5 family |
|
||||
| `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 +45,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user