mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded3d2c929 | ||
|
|
b55c592714 | ||
|
|
1d226979d1 | ||
|
|
6fad628551 | ||
|
|
8b426ed71d | ||
|
|
c7060c147d | ||
|
|
db49532211 | ||
|
|
1a52de241d | ||
|
|
b35501e6ad | ||
|
|
7f8f690497 | ||
|
|
356b02ac79 | ||
|
|
5d55ec0f8c | ||
|
|
d5fdd644cf | ||
|
|
eeb4b7981e | ||
|
|
5f1c98881d | ||
|
|
94d0f56689 | ||
|
|
4d690341a7 | ||
|
|
d8c419227c | ||
|
|
8c7cda89dc | ||
|
|
42a5cf9538 | ||
|
|
996406eb2a | ||
|
|
b98fbae6f6 | ||
|
|
4d87703e31 | ||
|
|
9ef64b7858 | ||
|
|
ed36ca99c0 |
6
.github/scripts/register-releases.mjs
vendored
6
.github/scripts/register-releases.mjs
vendored
@@ -74,6 +74,12 @@ for (const base of fs.readdirSync(dir)) {
|
|||||||
} else if (/x64\.zip$/.test(base)) {
|
} else if (/x64\.zip$/.test(base)) {
|
||||||
platform = 'mac-x64'
|
platform = 'mac-x64'
|
||||||
slot = 'upd'
|
slot = 'upd'
|
||||||
|
} else if (/win7.*\.exe$/i.test(base)) {
|
||||||
|
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||||
|
// segment so it never collides with the standard win exe in the same
|
||||||
|
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||||
|
platform = 'win-legacy'
|
||||||
|
slot = 'main'
|
||||||
} else if (/\.exe$/.test(base)) {
|
} else if (/\.exe$/.test(base)) {
|
||||||
platform = 'win'
|
platform = 'win'
|
||||||
slot = 'main'
|
slot = 'main'
|
||||||
|
|||||||
154
.github/workflows/publish-desktop.yml
vendored
154
.github/workflows/publish-desktop.yml
vendored
@@ -1,154 +0,0 @@
|
|||||||
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
|
|
||||||
230
.github/workflows/release-win7.yml
vendored
Normal file
230
.github/workflows/release-win7.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: Release Desktop (Win7 legacy)
|
||||||
|
|
||||||
|
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
|
||||||
|
#
|
||||||
|
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
|
||||||
|
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
|
||||||
|
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
|
||||||
|
#
|
||||||
|
# To support Win7 we must pin BOTH halves to the last versions that still
|
||||||
|
# target it:
|
||||||
|
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
|
||||||
|
# - Python 3.8 (last CPython to support Win7)
|
||||||
|
#
|
||||||
|
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
|
||||||
|
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
|
||||||
|
# then — exactly like the main pipeline — uploads it to R2 and registers a
|
||||||
|
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
|
||||||
|
# stays invisible until promoted, so it can't accidentally get served to Win10
|
||||||
|
# users). Because it's stamped with the SAME version as the standard release,
|
||||||
|
# the download page shows both Windows builds under one version row, and the
|
||||||
|
# /update feed serves each build to its own clients. Delete this file whenever
|
||||||
|
# legacy Windows support is no longer worth maintaining — the main pipeline is
|
||||||
|
# unaffected.
|
||||||
|
#
|
||||||
|
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
|
||||||
|
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Windows x64 (Win7 legacy)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Python 3.8 is the last CPython that supports Windows 7. A backend built
|
||||||
|
# with it (via PyInstaller) still runs on Win7 even though the CI host is
|
||||||
|
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
|
||||||
|
# OS, not the build machine's.
|
||||||
|
- name: Set up Python 3.8
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Build Python backend (PyInstaller, Python 3.8)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
|
||||||
|
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
|
||||||
|
# for this legacy build. We rewrite them into a throwaway requirements
|
||||||
|
# file so the repo's source stays untouched (main pipeline keeps its
|
||||||
|
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
|
||||||
|
sed 's/^playwright==.*/playwright==1.48.0/' \
|
||||||
|
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
|
||||||
|
pip install -r /tmp/requirements-win7.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
# Run from repo root so the spec's relative datas resolve correctly.
|
||||||
|
pyinstaller desktop/build/cowagent-backend.spec \
|
||||||
|
--noconfirm \
|
||||||
|
--distpath desktop/build/dist \
|
||||||
|
--workpath desktop/build/build-work
|
||||||
|
|
||||||
|
- name: Install desktop deps
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Write version into package.json
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
|
||||||
|
# this out of package.json so the repo's committed deps stay on Electron 33
|
||||||
|
# for the main pipeline. electron-builder reads the installed Electron
|
||||||
|
# version from node_modules, so this is all that's needed to package v22.
|
||||||
|
- name: Pin Electron to 22 (last Win7-capable)
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm install --no-save electron@22.3.27
|
||||||
|
|
||||||
|
- name: Compile (vite + tsc)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Same signing setup as the main pipeline: download the signtool CLI (URL
|
||||||
|
# from a repo variable so nothing is hardcoded in a public workflow). Only
|
||||||
|
# runs when a URL is configured; otherwise the build stays unsigned but
|
||||||
|
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
|
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
|
||||||
|
# hook the main pipeline uses (signs app + backend + installer via the
|
||||||
|
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
|
||||||
|
# installer is still produced (unsigned). --publish never emits the exe
|
||||||
|
# without touching any feed. Invoke via node (not npx) to avoid the
|
||||||
|
# Windows npx.cmd wrapper returning early (see release.yml).
|
||||||
|
#
|
||||||
|
# -c.win.artifactName injects a "win7" segment into the file name
|
||||||
|
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
|
||||||
|
# builds differ by ${arch}: it keeps the legacy exe from colliding with
|
||||||
|
# the standard win exe in the same v<version>/ folder, and lets
|
||||||
|
# register-releases.mjs map it to the win-legacy platform by name.
|
||||||
|
- name: Build installer (electron-builder, Electron 22)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
|
run: |
|
||||||
|
node node_modules/electron-builder/cli.js --win --x64 \
|
||||||
|
--config electron-builder.win.js \
|
||||||
|
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
|
||||||
|
--publish never
|
||||||
|
|
||||||
|
# Collect the installer + its blockmap (differential updates). The .yml
|
||||||
|
# feed is NOT uploaded: the /update Function generates it dynamically from
|
||||||
|
# D1 (same as the main pipeline), so it isn't needed here.
|
||||||
|
- name: Upload installer artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: |
|
||||||
|
desktop/release/*.exe
|
||||||
|
desktop/release/*.blockmap
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
|
||||||
|
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
|
||||||
|
# wrangler@latest works (the build job pins Node 20 for Electron 22).
|
||||||
|
#
|
||||||
|
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
|
||||||
|
# build — its "win7" name segment keeps them distinct — and register-releases
|
||||||
|
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
|
||||||
|
# publish workflow). Because the version matches the standard release, the
|
||||||
|
# download page merges both Windows builds into one version row.
|
||||||
|
publish:
|
||||||
|
name: Publish to R2 + D1
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$CF_TOKEN" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Upload installer to R2
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in dist/*.exe dist/*.blockmap; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
|
||||||
|
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||||
|
--file "$f" --remote
|
||||||
|
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
|
||||||
|
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
|
||||||
|
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
|
||||||
|
- name: Register release row in D1
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
@@ -113,6 +113,31 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: npm run build
|
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)
|
- name: Build & publish (electron-builder)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -124,8 +149,14 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
# Windows code signing via the signing CLI. Credentials are
|
||||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
# 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: |
|
run: |
|
||||||
# Pick the signing cert for THIS platform only. The mac and win secrets
|
# 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
|
# are both present in the job env, but a mac cert must never leak into a
|
||||||
@@ -137,6 +168,10 @@ jobs:
|
|||||||
#
|
#
|
||||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||||
# (an `unset` can return non-zero and abort under errexit).
|
# (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
|
case "${{ matrix.platform }}" in
|
||||||
mac)
|
mac)
|
||||||
if [ -n "$MAC_CSC_LINK" ]; then
|
if [ -n "$MAC_CSC_LINK" ]; then
|
||||||
@@ -144,12 +179,6 @@ jobs:
|
|||||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
win)
|
|
||||||
if [ -n "$WIN_CSC_LINK" ]; then
|
|
||||||
export CSC_LINK="$WIN_CSC_LINK"
|
|
||||||
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Never let electron-builder publish: our publish target is a generic
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
@@ -157,19 +186,25 @@ jobs:
|
|||||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# `--publish never` still emits the latest*.yml files.
|
# `--publish never` still emits the latest*.yml files.
|
||||||
#
|
#
|
||||||
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
|
||||||
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||||
# notarization) — it's a pure no-op on Windows. Passing --config on
|
# win -> electron-builder.win.js (wires the sign hook; electron-builder
|
||||||
# Windows was what silently broke the Windows build (it produced no
|
# signs the app, backend and installer)
|
||||||
# installer while the job still reported success; Windows worked fine
|
# HISTORY: passing --config on Windows previously broke the build (no
|
||||||
# before --config was introduced). So Windows uses the plain
|
# installer, job still green). That happened because the MAC config
|
||||||
# package.json build config and only mac uses the dynamic one.
|
# (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
|
# 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
|
# 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.
|
# make bash return before the wrapped process finishes. node skips it.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac) config_arg="--config electron-builder.js" ;;
|
mac) config_arg="--config electron-builder.js" ;;
|
||||||
|
win) config_arg="--config electron-builder.win.js" ;;
|
||||||
*) config_arg="" ;;
|
*) config_arg="" ;;
|
||||||
esac
|
esac
|
||||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
|
|
||||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
|||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# BrowserTool (requires playwright)
|
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||||
|
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||||
|
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError as e:
|
|
||||||
logger.info(
|
|
||||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
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
|
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||||
# so cookies / login state survive across runs (default).
|
# so cookies / login state survive across runs (default).
|
||||||
# - fresh: classic launch + new_context, clean state every run.
|
# - fresh: classic launch + new_context, clean state every run.
|
||||||
|
#
|
||||||
|
# Within persistent/fresh, the actual Chromium binary is resolved by
|
||||||
|
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
|
||||||
|
# download) is preferred, falling back to Playwright's own downloaded
|
||||||
|
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
|
||||||
|
# when driving a system browser, else None (bundled Chromium).
|
||||||
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
||||||
persistent_flag = self._config.get("persistent", True)
|
persistent_flag = self._config.get("persistent", True)
|
||||||
user_data_dir_cfg = self._config.get("user_data_dir")
|
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||||
if user_data_dir_cfg is None:
|
if user_data_dir_cfg is None:
|
||||||
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
||||||
|
|
||||||
|
self._channel: Optional[str] = None
|
||||||
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
||||||
if self._cdp_endpoint:
|
if self._cdp_endpoint:
|
||||||
self._launch_mode = "cdp"
|
self._launch_mode = "cdp"
|
||||||
@@ -343,6 +350,22 @@ class BrowserService:
|
|||||||
self._launch_mode = "fresh"
|
self._launch_mode = "fresh"
|
||||||
self._user_data_dir = ""
|
self._user_data_dir = ""
|
||||||
|
|
||||||
|
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||||
|
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||||
|
if self._launch_mode != "cdp":
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import resolve_engine
|
||||||
|
engine = resolve_engine(self._config)
|
||||||
|
if engine["mode"] == "system-chrome":
|
||||||
|
self._channel = engine["channel"]
|
||||||
|
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||||
|
elif engine["mode"] == "playwright-chromium":
|
||||||
|
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||||
|
else:
|
||||||
|
logger.info(f"[Browser] No ready engine yet: {engine['reason']}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] Engine resolution skipped: {e}")
|
||||||
|
|
||||||
# Idle auto-release
|
# Idle auto-release
|
||||||
idle_cfg = self._config.get("idle_timeout")
|
idle_cfg = self._config.get("idle_timeout")
|
||||||
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||||
@@ -428,11 +451,30 @@ class BrowserService:
|
|||||||
|
|
||||||
def _launch_browser(self):
|
def _launch_browser(self):
|
||||||
"""Launch / connect Chromium on the background thread."""
|
"""Launch / connect Chromium on the background thread."""
|
||||||
|
# Point Playwright at our pinned download dir before any launch so a
|
||||||
|
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import apply_browsers_path_env
|
||||||
|
apply_browsers_path_env()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
|
||||||
|
|
||||||
if self._headless is None:
|
if self._headless is None:
|
||||||
headless_cfg = self._config.get("headless")
|
headless_cfg = self._config.get("headless")
|
||||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
launch_args = ["--disable-dev-shm-usage"]
|
launch_args = [
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||||
|
# browser prompt, and Chrome's background/component network chatter.
|
||||||
|
# These have no effect on page interaction but noticeably speed up
|
||||||
|
# cold starts and each navigation.
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-features=Translate,OptimizationHints",
|
||||||
|
]
|
||||||
if self._headless:
|
if self._headless:
|
||||||
launch_args.append("--no-sandbox")
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
@@ -475,12 +517,20 @@ class BrowserService:
|
|||||||
logger.info("[Browser] Browser ready")
|
logger.info("[Browser] Browser ready")
|
||||||
|
|
||||||
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
"""Classic launch: brand new Chromium with an empty context."""
|
"""Classic launch: brand new Chromium with an empty context.
|
||||||
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
|
|
||||||
self._browser = self._playwright.chromium.launch(
|
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
|
||||||
headless=self._headless,
|
the user's installed system browser instead of its own Chromium.
|
||||||
args=launch_args,
|
"""
|
||||||
)
|
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||||
|
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
|
||||||
|
launch_kwargs: Dict[str, Any] = {
|
||||||
|
"headless": self._headless,
|
||||||
|
"args": launch_args,
|
||||||
|
}
|
||||||
|
if self._channel:
|
||||||
|
launch_kwargs["channel"] = self._channel
|
||||||
|
self._browser = self._playwright.chromium.launch(**launch_kwargs)
|
||||||
self._context = self._browser.new_context(
|
self._context = self._browser.new_context(
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
@@ -491,18 +541,25 @@ class BrowserService:
|
|||||||
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
||||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
|
f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
|
||||||
f"profile={self._user_data_dir})"
|
f"profile={self._user_data_dir})"
|
||||||
)
|
)
|
||||||
|
persistent_kwargs: Dict[str, Any] = {
|
||||||
|
"user_data_dir": self._user_data_dir,
|
||||||
|
"headless": self._headless,
|
||||||
|
"args": launch_args,
|
||||||
|
"viewport": viewport,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
}
|
||||||
|
# When driving a system browser, let it use its real UA instead of the
|
||||||
|
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
|
||||||
|
if self._channel:
|
||||||
|
persistent_kwargs["channel"] = self._channel
|
||||||
|
persistent_kwargs.pop("user_agent", None)
|
||||||
try:
|
try:
|
||||||
self._context = self._playwright.chromium.launch_persistent_context(
|
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
|
||||||
user_data_dir=self._user_data_dir,
|
|
||||||
headless=self._headless,
|
|
||||||
args=launch_args,
|
|
||||||
viewport=viewport,
|
|
||||||
user_agent=user_agent,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Profile is locked when another Chromium instance already holds it.
|
# Profile is locked when another Chromium instance already holds it.
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
@@ -687,11 +744,15 @@ class BrowserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Navigation failed: {e}"}
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||||
|
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||||
|
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||||
|
# short grace period for initial render/XHR, then proceed.
|
||||||
try:
|
try:
|
||||||
page.wait_for_load_state("networkidle", timeout=8000)
|
page.wait_for_load_state("networkidle", timeout=1500)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
title = page.title()
|
title = page.title()
|
||||||
|
|||||||
@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
|
|||||||
f"({ip_str}), request blocked for security"
|
f"({ip_str}), request blocked for security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_engine_ready(self) -> Optional[ToolResult]:
|
||||||
|
"""Return an actionable onboarding message if no browser engine is ready.
|
||||||
|
|
||||||
|
Returns None when a system Chrome/Edge or a downloaded Chromium is
|
||||||
|
available (so the tool can proceed). Otherwise returns a ToolResult with
|
||||||
|
clear guidance so the agent asks the user to enable the browser instead
|
||||||
|
of surfacing a raw Playwright launch error. CDP mode is exempt (the
|
||||||
|
endpoint is external and validated at connect time).
|
||||||
|
"""
|
||||||
|
if self.config.get("cdp_endpoint"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import capability_summary
|
||||||
|
summary = capability_summary(self.config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] capability probe failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if summary.get("ready"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
|
||||||
|
# in-chat `/install-browser` command. Source / web / server installs use
|
||||||
|
# the `cow install-browser` terminal command.
|
||||||
|
install_hint = (
|
||||||
|
"reply `/install-browser`" if summary.get("is_desktop")
|
||||||
|
else "run `cow install-browser` in a terminal"
|
||||||
|
)
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
|
||||||
|
"skipped automatically if Google Chrome is already installed). "
|
||||||
|
"Do not retry until the user confirms."
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
action = args.get("action", "").strip().lower()
|
action = args.get("action", "").strip().lower()
|
||||||
if not action:
|
if not action:
|
||||||
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
|
|||||||
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||||
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||||
|
|
||||||
|
# Preflight: on desktop the playwright package is bundled but the browser
|
||||||
|
# binary may be missing; return actionable onboarding instead of a cryptic
|
||||||
|
# launch failure.
|
||||||
|
not_ready = self._check_engine_ready()
|
||||||
|
if not_ready is not None:
|
||||||
|
return not_ready
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return handler(self, args)
|
return handler(self, args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from agent.tools.utils.diff import (
|
|||||||
detect_line_ending,
|
detect_line_ending,
|
||||||
normalize_to_lf,
|
normalize_to_lf,
|
||||||
restore_line_endings,
|
restore_line_endings,
|
||||||
normalize_for_fuzzy_match,
|
count_matches,
|
||||||
fuzzy_find_text,
|
fuzzy_find_text,
|
||||||
generate_diff_string
|
generate_diff_string
|
||||||
)
|
)
|
||||||
@@ -110,10 +110,10 @@ class Edit(BaseTool):
|
|||||||
"The old text must match exactly including all whitespace and newlines."
|
"The old text must match exactly including all whitespace and newlines."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate occurrence count (use fuzzy normalized content for consistency)
|
# Count occurrences with the same matcher used to locate and
|
||||||
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
# replace (fuzzy_find_text), so the uniqueness guard cannot
|
||||||
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
# disagree with what actually gets replaced.
|
||||||
occurrences = fuzzy_content.count(fuzzy_old_text)
|
occurrences = count_matches(normalized_content, normalized_old_text)
|
||||||
|
|
||||||
if occurrences > 1:
|
if occurrences > 1:
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
|
|||||||
@@ -21,6 +21,48 @@ from common.log import logger
|
|||||||
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
|
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
|
||||||
|
|
||||||
|
|
||||||
|
# Optional callback invoked after an OAuth authorization completes, so the
|
||||||
|
# tool manager can bring the newly-authorized server online. Signature:
|
||||||
|
# reload_fn(server_name: str) -> None. Installed by the tool manager.
|
||||||
|
_reload_callback = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_reload_callback(fn) -> None:
|
||||||
|
"""Register a callback fired after a server's OAuth flow succeeds."""
|
||||||
|
global _reload_callback
|
||||||
|
_reload_callback = fn
|
||||||
|
|
||||||
|
|
||||||
|
def notify_server_authorized(server_name: str) -> None:
|
||||||
|
"""Called by the web callback once tokens are stored for a server."""
|
||||||
|
fn = _reload_callback
|
||||||
|
if fn is None:
|
||||||
|
logger.debug(f"[MCP:{server_name}] Authorized but no reload callback registered")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fn(server_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP:{server_name}] reload callback failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_redirect_uri() -> str:
|
||||||
|
"""Build the OAuth redirect URI served by the web console callback.
|
||||||
|
|
||||||
|
Priority: explicit mcp_oauth_redirect_base config, otherwise the local
|
||||||
|
web console address (127.0.0.1:<web_port>). Both point at the shared
|
||||||
|
/mcp/oauth/callback route.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
base = (conf().get("mcp_oauth_redirect_base") or "").strip().rstrip("/")
|
||||||
|
if not base:
|
||||||
|
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
except Exception:
|
||||||
|
base = "http://127.0.0.1:9899"
|
||||||
|
return f"{base}/mcp/oauth/callback"
|
||||||
|
|
||||||
|
|
||||||
class McpClient:
|
class McpClient:
|
||||||
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
|
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
|
||||||
|
|
||||||
@@ -56,6 +98,13 @@ class McpClient:
|
|||||||
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
|
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
|
||||||
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
|
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
|
||||||
|
|
||||||
|
# OAuth state (streamable-http only). Lazily created when the server
|
||||||
|
# responds with 401 and the user has not supplied a static token.
|
||||||
|
self._oauth = None # OAuthHandler instance
|
||||||
|
# Set to True once a 401 could not be satisfied and the user must
|
||||||
|
# complete the browser authorization. Callers can surface this state.
|
||||||
|
self.needs_auth: bool = False
|
||||||
|
|
||||||
# Shared state
|
# Shared state
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
self._id_lock = threading.Lock()
|
self._id_lock = threading.Lock()
|
||||||
@@ -325,13 +374,118 @@ class McpClient:
|
|||||||
if isinstance(extra_headers, dict):
|
if isinstance(extra_headers, dict):
|
||||||
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
|
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
|
||||||
|
|
||||||
|
# Restore any previously stored OAuth credentials for this server so a
|
||||||
|
# restart reuses the token instead of forcing re-authorization.
|
||||||
|
self._maybe_load_oauth()
|
||||||
|
|
||||||
return self._handshake()
|
return self._handshake()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# OAuth helpers (streamable-http only)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _has_static_auth(self) -> bool:
|
||||||
|
"""True when the user supplied their own Authorization header."""
|
||||||
|
return any(k.lower() == "authorization" for k in self._http_headers)
|
||||||
|
|
||||||
|
def _maybe_load_oauth(self) -> None:
|
||||||
|
"""Attach an OAuthHandler when stored credentials exist for this server."""
|
||||||
|
if self._has_static_auth():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from agent.tools.mcp.mcp_oauth import OAuthHandler, load_server_record
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
rec = load_server_record(self.name)
|
||||||
|
# Only create a handler when we have something to reuse; otherwise it
|
||||||
|
# is created lazily on the first 401.
|
||||||
|
if rec.get("access_token") or rec.get("client_id"):
|
||||||
|
self._oauth = OAuthHandler(
|
||||||
|
server_name=self.name,
|
||||||
|
resource_url=self._http_url,
|
||||||
|
redirect_uri=_oauth_redirect_uri(),
|
||||||
|
scope=self.config.get("scope", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _current_bearer(self) -> Optional[str]:
|
||||||
|
"""Return a valid access token, refreshing if needed."""
|
||||||
|
if self._oauth is None:
|
||||||
|
return None
|
||||||
|
return self._oauth.get_valid_access_token()
|
||||||
|
|
||||||
|
def _begin_oauth(self, www_authenticate: str = "") -> None:
|
||||||
|
"""Kick off the OAuth flow after a 401: discover, register, prompt user."""
|
||||||
|
if self._has_static_auth():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from agent.tools.mcp.mcp_oauth import OAuthHandler
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP:{self.name}] OAuth module unavailable: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._oauth is None:
|
||||||
|
self._oauth = OAuthHandler(
|
||||||
|
server_name=self.name,
|
||||||
|
resource_url=self._http_url,
|
||||||
|
redirect_uri=_oauth_redirect_uri(),
|
||||||
|
scope=self.config.get("scope", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._oauth.ensure_registered(www_authenticate):
|
||||||
|
logger.warning(
|
||||||
|
f"[MCP:{self.name}] OAuth discovery/registration failed; "
|
||||||
|
f"cannot authorize automatically"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
auth_url = self._oauth.build_authorization_url()
|
||||||
|
if not auth_url:
|
||||||
|
logger.warning(f"[MCP:{self.name}] Failed to build authorization URL")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.needs_auth = True
|
||||||
|
logger.warning(
|
||||||
|
f"[MCP:{self.name}] ⚠️ Authorization required. Open this URL in a "
|
||||||
|
f"browser to authorize, then this server will come online automatically:\n"
|
||||||
|
f" {auth_url}"
|
||||||
|
)
|
||||||
|
# On a machine with a local browser (desktop/dev), open it directly.
|
||||||
|
if os.environ.get("COW_DESKTOP") == "1" or not os.environ.get("COW_HEADLESS"):
|
||||||
|
try:
|
||||||
|
import webbrowser
|
||||||
|
webbrowser.open(auth_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _streamable_http_send(self, message: dict) -> dict:
|
def _streamable_http_send(self, message: dict) -> dict:
|
||||||
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
|
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
|
||||||
return self._streamable_http_post(message, expect_response=True)
|
return self._streamable_http_post(message, expect_response=True)
|
||||||
|
|
||||||
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict:
|
def _handle_401(self, err, message: dict, expect_response: bool, retried: bool) -> dict:
|
||||||
|
"""Handle a 401: refresh the token and retry once, else begin OAuth."""
|
||||||
|
www_auth = ""
|
||||||
|
try:
|
||||||
|
www_auth = err.headers.get("WWW-Authenticate", "") or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
err.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# First try a silent refresh with the stored refresh token.
|
||||||
|
if not retried and self._oauth is not None and self._oauth.refresh():
|
||||||
|
logger.info(f"[MCP:{self.name}] Token refreshed after 401, retrying")
|
||||||
|
return self._streamable_http_post(message, expect_response, _retried=True)
|
||||||
|
|
||||||
|
# No usable token — start (or restart) the interactive OAuth flow.
|
||||||
|
self._begin_oauth(www_auth)
|
||||||
|
raise IOError(
|
||||||
|
f"[MCP:{self.name}] streamable-http HTTP 401: authorization required "
|
||||||
|
f"(complete the OAuth flow to enable this server)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _streamable_http_post(self, message: dict, expect_response: bool, _retried: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
POST a JSON-RPC message over Streamable HTTP.
|
POST a JSON-RPC message over Streamable HTTP.
|
||||||
|
|
||||||
@@ -351,6 +505,12 @@ class McpClient:
|
|||||||
if sid:
|
if sid:
|
||||||
headers["Mcp-Session-Id"] = sid
|
headers["Mcp-Session-Id"] = sid
|
||||||
headers.update(self._http_headers)
|
headers.update(self._http_headers)
|
||||||
|
# Inject OAuth bearer token when we have one (unless the user set a
|
||||||
|
# static Authorization header, which takes precedence).
|
||||||
|
if not self._has_static_auth():
|
||||||
|
token = self._current_bearer()
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
self._http_url,
|
self._http_url,
|
||||||
@@ -362,6 +522,9 @@ class McpClient:
|
|||||||
try:
|
try:
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
resp = urllib.request.urlopen(req, timeout=30)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
|
# 401 is the spec-compliant "needs authorization" signal.
|
||||||
|
if e.code == 401 and not self._has_static_auth():
|
||||||
|
return self._handle_401(e, message, expect_response, _retried)
|
||||||
# Surface the server-provided error body for easier debugging
|
# Surface the server-provided error body for easier debugging
|
||||||
detail = ""
|
detail = ""
|
||||||
try:
|
try:
|
||||||
|
|||||||
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
|
||||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
|||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
return True # agent ran but produced nothing; don't loop
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
if action.get("silent", False):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
channel = create_channel(channel_type)
|
channel = create_channel(channel_type)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
|||||||
"schedule_value": {
|
"schedule_value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
|||||||
"channel_type": self.config.get("channel_type", "unknown"),
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
"notify_session_id": notify_session_id,
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
# silent only applies to ai_task; fixed messages always deliver
|
||||||
|
if kwargs.get("silent", False):
|
||||||
|
action["silent"] = True
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
msg = context.kwargs.get("msg")
|
msg = context.kwargs.get("msg")
|
||||||
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
|
|||||||
else:
|
else:
|
||||||
content_desc = f"🤖 AI任务: {ai_task}"
|
content_desc = f"🤖 AI任务: {ai_task}"
|
||||||
|
|
||||||
|
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||||
|
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"✅ 定时任务创建成功\n\n"
|
f"✅ 定时任务创建成功\n\n"
|
||||||
f"📋 任务ID: {task_id}\n"
|
f"📋 任务ID: {task_id}\n"
|
||||||
f"📝 名称: {name}\n"
|
f"📝 名称: {name}\n"
|
||||||
f"⏰ 调度: {schedule_desc}\n"
|
f"⏰ 调度: {schedule_desc}\n"
|
||||||
f"👤 接收者: {receiver_desc}\n"
|
f"👤 接收者: {receiver_desc}\n"
|
||||||
f"{content_desc}\n"
|
f"{content_desc}{silent_desc}\n"
|
||||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -222,14 +215,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -261,14 +247,7 @@ class ToolManager:
|
|||||||
# If there are missing tools, record warnings
|
# If there are missing tools, record warnings
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
for tool_name in missing_tools:
|
for tool_name in missing_tools:
|
||||||
if tool_name == "browser":
|
if tool_name == "google_search":
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif tool_name == "google_search":
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||||
f" Get API key from: https://serper.dev\n"
|
f" Get API key from: https://serper.dev\n"
|
||||||
@@ -466,21 +445,30 @@ class ToolManager:
|
|||||||
the others, and never raises out of the worker thread.
|
the others, and never raises out of the worker thread.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
|
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry, set_reload_callback
|
||||||
from agent.tools.mcp.mcp_tool import McpTool
|
from agent.tools.mcp.mcp_tool import McpTool
|
||||||
|
|
||||||
registry = McpClientRegistry()
|
registry = McpClientRegistry()
|
||||||
self._mcp_registry = registry
|
self._mcp_registry = registry
|
||||||
|
# Let the OAuth web callback bring a server online once authorized.
|
||||||
|
set_reload_callback(self.reload_mcp_server)
|
||||||
|
|
||||||
for cfg in mcp_servers_config:
|
for cfg in mcp_servers_config:
|
||||||
server_name = cfg.get("name", "<unnamed>")
|
server_name = cfg.get("name", "<unnamed>")
|
||||||
try:
|
try:
|
||||||
client = McpClient(cfg)
|
client = McpClient(cfg)
|
||||||
if not client.initialize():
|
if not client.initialize():
|
||||||
self._mcp_status[server_name] = "failed"
|
if getattr(client, "needs_auth", False):
|
||||||
logger.warning(
|
self._mcp_status[server_name] = "needs_auth"
|
||||||
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
logger.info(
|
||||||
)
|
f"[MCP] Server '{server_name}' needs authorization — "
|
||||||
|
f"waiting for the user to complete the OAuth flow"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._mcp_status[server_name] = "failed"
|
||||||
|
logger.warning(
|
||||||
|
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tool_schemas = client.list_tools()
|
tool_schemas = client.list_tools()
|
||||||
@@ -518,6 +506,28 @@ class ToolManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
|
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
|
||||||
|
|
||||||
|
def reload_mcp_server(self, server_name: str) -> None:
|
||||||
|
"""Re-initialize a single MCP server (e.g. after OAuth authorization).
|
||||||
|
|
||||||
|
Tears down any existing client for the server and starts it again in
|
||||||
|
the background, so a freshly-stored access token is picked up and the
|
||||||
|
server's tools become available on the next message.
|
||||||
|
"""
|
||||||
|
with self._mcp_lock:
|
||||||
|
cfg = self._mcp_active_configs.get(server_name)
|
||||||
|
if not cfg:
|
||||||
|
logger.warning(f"[MCP] reload requested for unknown server '{server_name}'")
|
||||||
|
return
|
||||||
|
logger.info(f"[MCP] Reloading server '{server_name}' after authorization")
|
||||||
|
self._teardown_mcp_server(server_name)
|
||||||
|
self._mcp_status[server_name] = "pending"
|
||||||
|
threading.Thread(
|
||||||
|
target=self._load_mcp_tools_async,
|
||||||
|
args=([cfg],),
|
||||||
|
daemon=True,
|
||||||
|
name=f"mcp-reload-{server_name}",
|
||||||
|
).start()
|
||||||
|
|
||||||
def list_mcp_status(self) -> dict:
|
def list_mcp_status(self) -> dict:
|
||||||
"""Return {server_name: status} snapshot for UI / debugging."""
|
"""Return {server_name: status} snapshot for UI / debugging."""
|
||||||
return dict(self._mcp_status)
|
return dict(self._mcp_status)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .diff import (
|
|||||||
normalize_to_lf,
|
normalize_to_lf,
|
||||||
restore_line_endings,
|
restore_line_endings,
|
||||||
normalize_for_fuzzy_match,
|
normalize_for_fuzzy_match,
|
||||||
|
count_matches,
|
||||||
fuzzy_find_text,
|
fuzzy_find_text,
|
||||||
generate_diff_string,
|
generate_diff_string,
|
||||||
FuzzyMatchResult
|
FuzzyMatchResult
|
||||||
@@ -39,6 +40,7 @@ __all__ = [
|
|||||||
'normalize_to_lf',
|
'normalize_to_lf',
|
||||||
'restore_line_endings',
|
'restore_line_endings',
|
||||||
'normalize_for_fuzzy_match',
|
'normalize_for_fuzzy_match',
|
||||||
|
'count_matches',
|
||||||
'fuzzy_find_text',
|
'fuzzy_find_text',
|
||||||
'generate_diff_string',
|
'generate_diff_string',
|
||||||
'FuzzyMatchResult',
|
'FuzzyMatchResult',
|
||||||
|
|||||||
@@ -93,6 +93,40 @@ class FuzzyMatchResult:
|
|||||||
self.content_for_replacement = content_for_replacement
|
self.content_for_replacement = content_for_replacement
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fuzzy_pattern(old_text: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Build the whitespace-flexible regex used to locate ``old_text`` fuzzily.
|
||||||
|
|
||||||
|
Returns ``None`` when ``old_text`` has no non-whitespace content to match.
|
||||||
|
This is the single source of truth for fuzzy matching, so that *finding* a
|
||||||
|
match (:func:`fuzzy_find_text`) and *counting* occurrences
|
||||||
|
(:func:`count_matches`) always use the exact same rules.
|
||||||
|
"""
|
||||||
|
stripped = old_text.strip('\n')
|
||||||
|
if not stripped.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
source_lines = stripped.split('\n')
|
||||||
|
line_patterns = []
|
||||||
|
for i, line in enumerate(source_lines):
|
||||||
|
tokens = line.split()
|
||||||
|
if not tokens:
|
||||||
|
line_patterns.append(r'[ \t]*')
|
||||||
|
continue
|
||||||
|
# Tolerate any run of blanks between tokens.
|
||||||
|
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||||
|
# First-line leading whitespace is folded into the match only when
|
||||||
|
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||||
|
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||||
|
# the file's existing indentation -- mirroring an exact substring
|
||||||
|
# match. Inner lines always tolerate indentation: it sits inside the
|
||||||
|
# matched region and is re-supplied by new_text.
|
||||||
|
if i > 0 or line[:1] in (' ', '\t'):
|
||||||
|
core = r'[ \t]*' + core
|
||||||
|
line_patterns.append(core + r'[ \t]*')
|
||||||
|
return '\n'.join(line_patterns)
|
||||||
|
|
||||||
|
|
||||||
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||||
"""
|
"""
|
||||||
Find text in content, try exact match first, then fuzzy match
|
Find text in content, try exact match first, then fuzzy match
|
||||||
@@ -121,27 +155,8 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
|||||||
# doing so previously returned the normalized copy as
|
# doing so previously returned the normalized copy as
|
||||||
# content_for_replacement, which caused the whole file to be rewritten
|
# content_for_replacement, which caused the whole file to be rewritten
|
||||||
# with collapsed indentation (every untouched line got reformatted).
|
# with collapsed indentation (every untouched line got reformatted).
|
||||||
stripped = old_text.strip('\n')
|
pattern = _build_fuzzy_pattern(old_text)
|
||||||
if stripped.strip():
|
if pattern is not 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]*')
|
|
||||||
pattern = '\n'.join(line_patterns)
|
|
||||||
match = re.search(pattern, content)
|
match = re.search(pattern, content)
|
||||||
if match:
|
if match:
|
||||||
return FuzzyMatchResult(
|
return FuzzyMatchResult(
|
||||||
@@ -155,6 +170,28 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
|||||||
return FuzzyMatchResult(found=False)
|
return FuzzyMatchResult(found=False)
|
||||||
|
|
||||||
|
|
||||||
|
def count_matches(content: str, old_text: str) -> int:
|
||||||
|
"""
|
||||||
|
Count occurrences of ``old_text`` using the SAME strategy as
|
||||||
|
:func:`fuzzy_find_text`: an exact substring when one is present, otherwise
|
||||||
|
the whitespace-flexible fuzzy regex.
|
||||||
|
|
||||||
|
The edit tool's uniqueness guard must agree with the matcher that actually
|
||||||
|
performs the replacement. Counting through a separate normalization pass
|
||||||
|
(the previous approach) could disagree with the regex used to locate and
|
||||||
|
replace, so both paths now share :func:`_build_fuzzy_pattern`.
|
||||||
|
"""
|
||||||
|
if not old_text:
|
||||||
|
return 0
|
||||||
|
# Mirror fuzzy_find_text: prefer exact matching when it applies.
|
||||||
|
if content.find(old_text) != -1:
|
||||||
|
return content.count(old_text)
|
||||||
|
pattern = _build_fuzzy_pattern(old_text)
|
||||||
|
if pattern is None:
|
||||||
|
return 0
|
||||||
|
return len(re.findall(pattern, content))
|
||||||
|
|
||||||
|
|
||||||
def generate_diff_string(old_content: str, new_content: str) -> dict:
|
def generate_diff_string(old_content: str, new_content: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Generate unified diff string
|
Generate unified diff string
|
||||||
|
|||||||
@@ -1139,6 +1139,33 @@ function createMd() {
|
|||||||
return hljsLib.highlightAuto(str).value;
|
return hljsLib.highlightAuto(str).value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 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 text.
|
||||||
|
var GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/;
|
||||||
|
md.core.ruler.after('linkify', 'fix_greedy_linkify', function(state) {
|
||||||
|
for (var b = 0; b < state.tokens.length; b++) {
|
||||||
|
var blk = state.tokens[b];
|
||||||
|
if (blk.type !== 'inline' || !blk.children) continue;
|
||||||
|
var ch = blk.children;
|
||||||
|
for (var i = 0; i < ch.length; i++) {
|
||||||
|
var open = ch[i];
|
||||||
|
if (open.type !== 'link_open' || open.markup !== 'linkify') continue;
|
||||||
|
var textTok = ch[i + 1], close = ch[i + 2];
|
||||||
|
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue;
|
||||||
|
var idx = textTok.content.search(GREEDY_LINK_CUT);
|
||||||
|
if (idx < 0) continue;
|
||||||
|
var keep = textTok.content.slice(0, idx);
|
||||||
|
var spill = textTok.content.slice(idx);
|
||||||
|
textTok.content = keep;
|
||||||
|
open.attrSet('href', keep);
|
||||||
|
var spillTok = new state.Token('text', '', 0);
|
||||||
|
spillTok.content = spill;
|
||||||
|
ch.splice(i + 3, 0, spillTok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||||||
return self.renderToken(tokens, idx, options);
|
return self.renderToken(tokens, idx, options);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,11 +79,42 @@ def _verify_auth_token(token):
|
|||||||
return hmac.compare_digest(sig, expected)
|
return hmac.compare_digest(sig, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bearer_token():
|
||||||
|
"""Extract the token from an `Authorization: Bearer <token>` header.
|
||||||
|
|
||||||
|
The desktop client renders from a file:// origin, so cross-origin cookies
|
||||||
|
to http://127.0.0.1 are unreliable (SameSite=Lax cookies aren't sent). It
|
||||||
|
therefore authenticates via this header instead; browsers keep using the
|
||||||
|
cookie set by /auth/login.
|
||||||
|
"""
|
||||||
|
auth = web.ctx.env.get("HTTP_AUTHORIZATION", "") or ""
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
return auth[7:].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_query_token():
|
||||||
|
"""Extract a token from the `token` query param.
|
||||||
|
|
||||||
|
Needed for SSE endpoints: EventSource can't set an Authorization header,
|
||||||
|
and file:// cookies are unreliable, so the desktop client passes the token
|
||||||
|
in the query string for /stream and /api/logs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return web.input(token="").token or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _check_auth():
|
def _check_auth():
|
||||||
"""Return True if request is authenticated or password not enabled."""
|
"""Return True if request is authenticated or password not enabled."""
|
||||||
if not _is_password_enabled():
|
if not _is_password_enabled():
|
||||||
return True
|
return True
|
||||||
return _verify_auth_token(web.cookies().get("cow_auth_token", ""))
|
if _verify_auth_token(web.cookies().get("cow_auth_token", "")):
|
||||||
|
return True
|
||||||
|
if _verify_auth_token(_get_bearer_token()):
|
||||||
|
return True
|
||||||
|
return _verify_auth_token(_get_query_token())
|
||||||
|
|
||||||
|
|
||||||
def _require_auth():
|
def _require_auth():
|
||||||
@@ -1249,6 +1280,7 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
urls = (
|
urls = (
|
||||||
'/', 'RootHandler',
|
'/', 'RootHandler',
|
||||||
|
'/api/health', 'HealthHandler',
|
||||||
'/auth/login', 'AuthLoginHandler',
|
'/auth/login', 'AuthLoginHandler',
|
||||||
'/auth/check', 'AuthCheckHandler',
|
'/auth/check', 'AuthCheckHandler',
|
||||||
'/auth/logout', 'AuthLogoutHandler',
|
'/auth/logout', 'AuthLogoutHandler',
|
||||||
@@ -1288,6 +1320,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/messages/delete', 'MessageDeleteHandler',
|
'/api/messages/delete', 'MessageDeleteHandler',
|
||||||
'/api/logs', 'LogsHandler',
|
'/api/logs', 'LogsHandler',
|
||||||
'/api/version', 'VersionHandler',
|
'/api/version', 'VersionHandler',
|
||||||
|
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
|
||||||
'/assets/(.*)', 'AssetsHandler',
|
'/assets/(.*)', 'AssetsHandler',
|
||||||
)
|
)
|
||||||
app = web.application(urls, globals(), autoreload=False)
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
@@ -1341,6 +1374,74 @@ class RootHandler:
|
|||||||
raise web.seeother('/chat')
|
raise web.seeother('/chat')
|
||||||
|
|
||||||
|
|
||||||
|
class HealthHandler:
|
||||||
|
# Unauthenticated liveness probe. The desktop shell polls this to know the
|
||||||
|
# backend is up; it must never require auth (a set web_password would
|
||||||
|
# otherwise make startup hang). Returns no sensitive data.
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
web.header('Cache-Control', 'no-store')
|
||||||
|
return json.dumps({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
class McpOAuthCallbackHandler:
|
||||||
|
"""OAuth redirect target for MCP servers requiring authorization.
|
||||||
|
|
||||||
|
The browser lands here after the user authorizes a remote MCP server.
|
||||||
|
We exchange the authorization code for tokens and bring the server
|
||||||
|
online. Unauthenticated by design: the OAuth `state` param is the
|
||||||
|
single-use secret that binds this request to a pending authorization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
params = web.input(code="", state="", error="", error_description="")
|
||||||
|
|
||||||
|
def _page(title: str, message: str) -> str:
|
||||||
|
return (
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
|
f"<title>{title}</title></head>"
|
||||||
|
"<body style='font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
|
||||||
|
"max-width:520px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2328'>"
|
||||||
|
f"<h2>{title}</h2><p style='color:#57606a'>{message}</p></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
if params.error:
|
||||||
|
logger.warning(f"[MCP-OAuth] callback error: {params.error} {params.error_description}")
|
||||||
|
return _page("授权失败", f"{params.error}: {params.error_description or ''}")
|
||||||
|
|
||||||
|
if not params.code or not params.state:
|
||||||
|
return _page("参数缺失", "回调缺少 code 或 state 参数。")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from agent.tools.mcp.mcp_oauth import pop_pending
|
||||||
|
from agent.tools.mcp.mcp_client import notify_server_authorized
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP-OAuth] callback import failed: {e}")
|
||||||
|
return _page("内部错误", "OAuth 模块不可用。")
|
||||||
|
|
||||||
|
handler = pop_pending(params.state)
|
||||||
|
if handler is None:
|
||||||
|
return _page("会话已过期", "授权请求不存在或已过期,请重新触发授权。")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = handler.finish_authorization(params.code)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP-OAuth] token exchange crashed: {e}")
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
return _page("授权失败", "换取令牌失败,请重试。")
|
||||||
|
|
||||||
|
notify_server_authorized(handler.server_name)
|
||||||
|
logger.info(f"[MCP-OAuth] Server '{handler.server_name}' authorized via web callback")
|
||||||
|
return _page(
|
||||||
|
"授权成功",
|
||||||
|
f"MCP 服务 “{handler.server_name}” 已授权,可以返回聊天继续使用了。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthCheckHandler:
|
class AuthCheckHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
@@ -1368,7 +1469,9 @@ class AuthLoginHandler:
|
|||||||
token = _create_auth_token()
|
token = _create_auth_token()
|
||||||
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
||||||
path="/", httponly=True, samesite="Lax")
|
path="/", httponly=True, samesite="Lax")
|
||||||
return json.dumps({"status": "success"})
|
# Also return the token in the body: the desktop client (file:// origin)
|
||||||
|
# can't rely on the cookie and sends it back via an Authorization header.
|
||||||
|
return json.dumps({"status": "success", "token": token})
|
||||||
|
|
||||||
|
|
||||||
class AuthLogoutHandler:
|
class AuthLogoutHandler:
|
||||||
@@ -1597,11 +1700,10 @@ class ConfigHandler:
|
|||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||||
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
|
# claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
|
||||||
# from this web console list for now.
|
const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||||
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
|
||||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||||
@@ -1644,7 +1746,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
}),
|
}),
|
||||||
("gemini", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1660,7 +1762,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "open_ai_api_base",
|
"api_base_key": "open_ai_api_base",
|
||||||
"api_base_default": "https://api.openai.com/v1",
|
"api_base_default": "https://api.openai.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||||
}),
|
}),
|
||||||
("zhipu", {
|
("zhipu", {
|
||||||
"label": {"zh": "智谱AI", "en": "GLM"},
|
"label": {"zh": "智谱AI", "en": "GLM"},
|
||||||
@@ -1807,7 +1909,7 @@ class ConfigHandler:
|
|||||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||||
|
|
||||||
return json.dumps({
|
result = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"use_agent": use_agent,
|
"use_agent": use_agent,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -1824,7 +1926,13 @@ class ConfigHandler:
|
|||||||
"api_keys": api_keys_masked,
|
"api_keys": api_keys_masked,
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
"web_password_masked": masked_pwd,
|
"web_password_masked": masked_pwd,
|
||||||
}, ensure_ascii=False)
|
}
|
||||||
|
# The desktop app runs on the local trusted machine, so it can edit
|
||||||
|
# the real password in place (cursor at the end, delete to clear).
|
||||||
|
# Browser access only ever sees the masked value.
|
||||||
|
if os.environ.get("COW_DESKTOP") == "1":
|
||||||
|
result["web_password"] = raw_pwd
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting config: {e}")
|
logger.error(f"Error getting config: {e}")
|
||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
@@ -2230,9 +2338,12 @@ class ModelsHandler:
|
|||||||
# Anything not listed here intentionally hides the model dropdown so
|
# Anything not listed here intentionally hides the model dropdown so
|
||||||
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||||
_VISION_PROVIDER_MODELS = {
|
_VISION_PROVIDER_MODELS = {
|
||||||
# OpenAI ordering matches the recommended GPT-5.4 family first, then
|
# OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
|
||||||
# GPT-5 and the GPT-4.1/4o backstops.
|
# GPT-5 and the GPT-4.1/4o backstops.
|
||||||
"openai": [
|
"openai": [
|
||||||
|
const.GPT_56_LUNA,
|
||||||
|
const.GPT_56_TERRA,
|
||||||
|
const.GPT_56_SOL,
|
||||||
const.GPT_55,
|
const.GPT_55,
|
||||||
const.GPT_54,
|
const.GPT_54,
|
||||||
const.GPT_54_MINI,
|
const.GPT_54_MINI,
|
||||||
@@ -2245,7 +2356,7 @@ class ModelsHandler:
|
|||||||
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
||||||
"moonshot": [const.KIMI_K2_6],
|
"moonshot": [const.KIMI_K2_6],
|
||||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||||
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||||
@@ -2269,6 +2380,7 @@ class ModelsHandler:
|
|||||||
const.DOUBAO_SEED_2_1_PRO,
|
const.DOUBAO_SEED_2_1_PRO,
|
||||||
const.KIMI_K2_6,
|
const.KIMI_K2_6,
|
||||||
const.CLAUDE_SONNET_5,
|
const.CLAUDE_SONNET_5,
|
||||||
|
const.CLAUDE_FABLE_5,
|
||||||
const.GEMINI_31_FLASH_LITE_PRE,
|
const.GEMINI_31_FLASH_LITE_PRE,
|
||||||
],
|
],
|
||||||
# Custom OpenAI-compatible providers have no preset list — model
|
# Custom OpenAI-compatible providers have no preset list — model
|
||||||
|
|||||||
@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def _is_frozen() -> bool:
|
||||||
|
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||||
|
|
||||||
|
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
|
||||||
|
playwright is already bundled and we only need to download the browser
|
||||||
|
binary in-process rather than pip-installing anything.
|
||||||
|
"""
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
|
||||||
|
"""Invoke the Playwright CLI, working in both source and frozen builds.
|
||||||
|
|
||||||
|
Source builds shell out to ``python -m playwright <args>``. Frozen builds
|
||||||
|
can't use ``-m`` (the exe isn't a Python interpreter), so we call
|
||||||
|
Playwright's driver entrypoint in-process instead. ``env`` overrides are
|
||||||
|
applied to os.environ for the duration of the call (frozen path) or passed
|
||||||
|
through to the subprocess (source path).
|
||||||
|
"""
|
||||||
|
if not _is_frozen():
|
||||||
|
cmd = [sys.executable, "-m", "playwright"] + args
|
||||||
|
return subprocess.call(cmd, env=env)
|
||||||
|
|
||||||
|
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
|
||||||
|
# returns the Node driver shipped inside the bundle; we spawn it directly.
|
||||||
|
prev_env = {}
|
||||||
|
if env:
|
||||||
|
for k, v in env.items():
|
||||||
|
prev_env[k] = os.environ.get(k)
|
||||||
|
os.environ[k] = v
|
||||||
|
try:
|
||||||
|
from playwright._impl._driver import compute_driver_executable, get_driver_env
|
||||||
|
driver = compute_driver_executable()
|
||||||
|
# compute_driver_executable may return a tuple (node, cli.js) on newer
|
||||||
|
# Playwright, or a single path on older ones.
|
||||||
|
if isinstance(driver, (list, tuple)):
|
||||||
|
cmd = list(driver) + args
|
||||||
|
else:
|
||||||
|
cmd = [str(driver)] + args
|
||||||
|
# get_driver_env() snapshots os.environ, which we've already patched with
|
||||||
|
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
|
||||||
|
# so mirror + pinned browsers dir are honored here too.
|
||||||
|
return subprocess.call(cmd, env=get_driver_env())
|
||||||
|
except Exception as e:
|
||||||
|
# Last resort: try the module main via runpy (works if the frozen build
|
||||||
|
# kept playwright.__main__ importable).
|
||||||
|
try:
|
||||||
|
import runpy
|
||||||
|
sys.argv = ["playwright"] + args
|
||||||
|
runpy.run_module("playwright", run_name="__main__")
|
||||||
|
return 0
|
||||||
|
except SystemExit as se:
|
||||||
|
return int(se.code or 0)
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
for k, v in prev_env.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
||||||
"""CLI: colored click output."""
|
"""CLI: colored click output."""
|
||||||
if fg == "yellow":
|
if fg == "yellow":
|
||||||
@@ -129,6 +192,7 @@ def run_install_browser(
|
|||||||
stream = stream or _default_stream
|
stream = stream or _default_stream
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
legacy_mode = False
|
legacy_mode = False
|
||||||
|
frozen = _is_frozen()
|
||||||
|
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
||||||
@@ -159,7 +223,7 @@ def run_install_browser(
|
|||||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32" and not frozen:
|
||||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||||
ret = subprocess.call(
|
ret = subprocess.call(
|
||||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||||
@@ -172,22 +236,52 @@ def run_install_browser(
|
|||||||
"yellow",
|
"yellow",
|
||||||
)
|
)
|
||||||
|
|
||||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
if frozen:
|
||||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
# Desktop bundle: playwright is already shipped inside the app; there is
|
||||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||||||
if ret != 0:
|
installed = _get_installed_version()
|
||||||
stream("Failed to install playwright package.", "red")
|
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
_phase(on_phase, _t(
|
||||||
return 1
|
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
|
||||||
|
"✅ [1/3] Playwright is bundled in the app; skipping install.",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||||
|
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||||
|
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||||
|
if ret != 0:
|
||||||
|
stream("Failed to install playwright package.", "red")
|
||||||
|
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||||
|
return 1
|
||||||
|
|
||||||
installed = _get_installed_version()
|
installed = _get_installed_version()
|
||||||
if installed:
|
if installed:
|
||||||
stream(f" playwright {installed} installed.", "green")
|
stream(f" playwright {installed} installed.", "green")
|
||||||
stream("")
|
stream("")
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# With playwright available, prefer the user's system Chrome/Edge: the browser
|
||||||
|
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
|
||||||
|
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
|
||||||
|
# source) — only headless Linux servers, which usually lack a system browser,
|
||||||
|
# fall through to the download below. Honors prefer_system_browser via
|
||||||
|
# resolve_engine, so users who force downloaded Chromium still get it.
|
||||||
|
try:
|
||||||
|
from agent.tools.browser import browser_env
|
||||||
|
summary = browser_env.capability_summary()
|
||||||
|
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
|
||||||
|
sc = summary.get("system_chrome") or {}
|
||||||
|
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
|
||||||
|
_phase(on_phase, _t(
|
||||||
|
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。",
|
||||||
|
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
|
||||||
|
))
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
stream(f" (system browser probe skipped: {e})", None)
|
||||||
|
|
||||||
if sys.platform == "linux":
|
if sys.platform == "linux":
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
@@ -195,7 +289,7 @@ def run_install_browser(
|
|||||||
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
||||||
))
|
))
|
||||||
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
||||||
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
ret = _playwright_cli(["install-deps", "chromium"])
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(
|
stream(
|
||||||
" Could not auto-install system deps (may need sudo).\n"
|
" Could not auto-install system deps (may need sudo).\n"
|
||||||
@@ -238,12 +332,12 @@ def run_install_browser(
|
|||||||
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
||||||
))
|
))
|
||||||
stream("[3/3] Installing Chromium browser...", "yellow")
|
stream("[3/3] Installing Chromium browser...", "yellow")
|
||||||
cmd = [python, "-m", "playwright", "install", "chromium"]
|
pw_args = ["install", "chromium"]
|
||||||
|
|
||||||
if _is_headless_linux() and not legacy_mode:
|
if _is_headless_linux() and not legacy_mode:
|
||||||
ver = _version_tuple(installed or "")
|
ver = _version_tuple(installed or "")
|
||||||
if ver >= (1, 57, 0):
|
if ver >= (1, 57, 0):
|
||||||
cmd.append("--only-shell")
|
pw_args.append("--only-shell")
|
||||||
stream(" (headless shell for Linux server)", None)
|
stream(" (headless shell for Linux server)", None)
|
||||||
else:
|
else:
|
||||||
stream(" (full Chromium)", None)
|
stream(" (full Chromium)", None)
|
||||||
@@ -251,6 +345,15 @@ def run_install_browser(
|
|||||||
stream(" (full browser for Linux desktop)", None)
|
stream(" (full browser for Linux desktop)", None)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
# Pin the download location so it survives desktop app updates and matches
|
||||||
|
# what the runtime looks up (see browser_env.browsers_download_dir()).
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import browsers_download_dir
|
||||||
|
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||||
|
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
use_mirror = _is_china_network()
|
use_mirror = _is_china_network()
|
||||||
if use_mirror:
|
if use_mirror:
|
||||||
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
||||||
@@ -260,7 +363,7 @@ def run_install_browser(
|
|||||||
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
||||||
))
|
))
|
||||||
|
|
||||||
ret = subprocess.call(cmd, env=env)
|
ret = _playwright_cli(pw_args, env=env)
|
||||||
|
|
||||||
if ret != 0 and use_mirror:
|
if ret != 0 and use_mirror:
|
||||||
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
||||||
@@ -268,9 +371,9 @@ def run_install_browser(
|
|||||||
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
||||||
"⚠️ Mirror download failed; retrying with the official CDN…",
|
"⚠️ Mirror download failed; retrying with the official CDN…",
|
||||||
))
|
))
|
||||||
env_no_mirror = os.environ.copy()
|
env_no_mirror = dict(env)
|
||||||
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
||||||
ret = subprocess.call(cmd, env=env_no_mirror)
|
ret = _playwright_cli(pw_args, env=env_no_mirror)
|
||||||
|
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream("Failed to install Chromium.", "red")
|
stream("Failed to install Chromium.", "red")
|
||||||
@@ -282,10 +385,18 @@ def run_install_browser(
|
|||||||
|
|
||||||
stream("Verifying browser installation...", None)
|
stream("Verifying browser installation...", None)
|
||||||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||||||
ret = subprocess.call(
|
if frozen:
|
||||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
# Frozen: no child interpreter to spawn; import in-process instead.
|
||||||
stderr=subprocess.DEVNULL,
|
try:
|
||||||
)
|
from playwright.sync_api import sync_playwright # noqa: F401
|
||||||
|
ret = 0
|
||||||
|
except Exception:
|
||||||
|
ret = 1
|
||||||
|
else:
|
||||||
|
ret = subprocess.call(
|
||||||
|
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(
|
stream(
|
||||||
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
|
|||||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
|
||||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||||
@@ -80,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
|||||||
GPT_54_MINI = "gpt-5.4-mini"
|
GPT_54_MINI = "gpt-5.4-mini"
|
||||||
GPT_54_NANO = "gpt-5.4-nano"
|
GPT_54_NANO = "gpt-5.4-nano"
|
||||||
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
||||||
|
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
|
||||||
|
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
|
||||||
|
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
|
||||||
O1 = "o1-preview"
|
O1 = "o1-preview"
|
||||||
O1_MINI = "o1-mini"
|
O1_MINI = "o1-mini"
|
||||||
WHISPER_1 = "whisper-1"
|
WHISPER_1 = "whisper-1"
|
||||||
@@ -200,7 +203,7 @@ MODEL_LIST = [
|
|||||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE_SONNET_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
CLAUDE_SONNET_5, CLAUDE_FABLE_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -214,6 +217,7 @@ MODEL_LIST = [
|
|||||||
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
||||||
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
||||||
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
||||||
|
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
|
||||||
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
||||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||||
O1, O1_MINI,
|
O1, O1_MINI,
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ available_setting = {
|
|||||||
"web_password": "", # Web console password; empty means no authentication required
|
"web_password": "", # Web console password; empty means no authentication required
|
||||||
"web_session_expire_days": 30, # Auth session expiry in days
|
"web_session_expire_days": 30, # Auth session expiry in days
|
||||||
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
|
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
|
||||||
|
"mcp_oauth_redirect_base": "", # Base URL for MCP OAuth callback (e.g. http://your-ip:9899); empty uses local web console
|
||||||
"agent": True, # whether to enable Agent mode
|
"agent": True, # whether to enable Agent mode
|
||||||
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
|
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
|
||||||
"agent_max_context_tokens": 50000, # max context tokens in Agent mode
|
"agent_max_context_tokens": 50000, # max context tokens in Agent mode
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ hiddenimports += collect_submodules('docx')
|
|||||||
hiddenimports += collect_submodules('pptx')
|
hiddenimports += collect_submodules('pptx')
|
||||||
hiddenimports += collect_submodules('openpyxl')
|
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 -----------------------------------------------------------
|
# --- Data files -----------------------------------------------------------
|
||||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||||
# (source, dest_dir_in_bundle).
|
# (source, dest_dir_in_bundle).
|
||||||
@@ -134,6 +142,12 @@ datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
|||||||
datas += collect_data_files('docx')
|
datas += collect_data_files('docx')
|
||||||
datas += collect_data_files('pptx')
|
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 -------------------------------------------------------------
|
# --- Excludes -------------------------------------------------------------
|
||||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||||
# mode), tests/docs, and dev-only packages.
|
# mode), tests/docs, and dev-only packages.
|
||||||
@@ -143,7 +157,10 @@ excludes = [
|
|||||||
'pip',
|
'pip',
|
||||||
'wheel',
|
'wheel',
|
||||||
'pytest',
|
'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
|
block_cipher = None
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ python-docx
|
|||||||
openpyxl
|
openpyxl
|
||||||
python-pptx
|
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. ----
|
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
||||||
wechatpy
|
wechatpy
|
||||||
pycryptodome
|
pycryptodome
|
||||||
|
|||||||
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,4 +1,4 @@
|
|||||||
import { ChildProcess, spawn } from 'child_process'
|
import { ChildProcess, spawn, execFileSync } from 'child_process'
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
@@ -39,6 +39,77 @@ export class PythonBackend extends EventEmitter {
|
|||||||
return this.status
|
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.
|
* Locate the packaged onedir backend executable shipped with the app.
|
||||||
* Returns null when not present (e.g. during local development), so we can
|
* Returns null when not present (e.g. during local development), so we can
|
||||||
@@ -261,6 +332,10 @@ export class PythonBackend extends EventEmitter {
|
|||||||
// app bundle stays read-only; dev runs omit it and keep using the repo.
|
// app bundle stays read-only; dev runs omit it and keep using the repo.
|
||||||
env: {
|
env: {
|
||||||
...process.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',
|
PYTHONUNBUFFERED: '1',
|
||||||
COW_DESKTOP: '1',
|
COW_DESKTOP: '1',
|
||||||
// The shell owns the port: tell the backend to bind exactly here so the
|
// The shell owns the port: tell the backend to bind exactly here so the
|
||||||
@@ -315,7 +390,10 @@ export class PythonBackend extends EventEmitter {
|
|||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
const check = () => {
|
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) {
|
if (res.statusCode === 200) {
|
||||||
this.status = 'ready'
|
this.status = 'ready'
|
||||||
this.emit('log', `Backend ready on port ${this.port}`)
|
this.emit('log', `Backend ready on port ${this.port}`)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
let getWindow: () => BrowserWindow | null = () => null
|
||||||
|
|
||||||
|
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||||
|
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||||
|
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||||
|
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||||
|
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||||
|
// flag, so the same source serves the right feed on whatever it runs on.
|
||||||
|
function isLegacyWindows(): boolean {
|
||||||
|
if (process.platform !== 'win32') return false
|
||||||
|
const major = Number((os.release() || '').split('.')[0])
|
||||||
|
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||||
|
return Number.isFinite(major) && major < 10
|
||||||
|
}
|
||||||
|
|
||||||
// The update feed. Both entries hit the same Pages Function
|
// The update feed. Both entries hit the same Pages Function
|
||||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
// (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
|
// 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
|
// identical either way, so we can freely switch the feed URL between attempts
|
||||||
// to fall back from one download origin to the other.
|
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||||
|
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||||
|
|
||||||
// Which origin the current session prefers, derived from the app UI language
|
// Which origin the current session prefers, derived from the app UI language
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import NavRail from './layout/NavRail'
|
|||||||
import SessionList from './layout/SessionList'
|
import SessionList from './layout/SessionList'
|
||||||
import WindowControls from './layout/WindowControls'
|
import WindowControls from './layout/WindowControls'
|
||||||
import StatusScreen from './components/StatusScreen'
|
import StatusScreen from './components/StatusScreen'
|
||||||
|
import LoginGate from './components/LoginGate'
|
||||||
import { useBackend } from './hooks/useBackend'
|
import { useBackend } from './hooks/useBackend'
|
||||||
import { usePlatform } from './hooks/usePlatform'
|
import { usePlatform } from './hooks/usePlatform'
|
||||||
import { useUIStore } from './store/uiStore'
|
import { useUIStore } from './store/uiStore'
|
||||||
@@ -32,16 +33,45 @@ const App: React.FC = () => {
|
|||||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||||
const [, forceUpdate] = useState(0)
|
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(() => {
|
useEffect(() => {
|
||||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||||
}, [backend.status, 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
|
// 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
|
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||||
// configured (and not dismissed earlier this session); no persisted flag.
|
// configured (and not dismissed earlier this session); no persisted flag.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backend.status !== 'ready') return
|
if (backend.status !== 'ready' || authState !== 'ok') return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
apiClient
|
apiClient
|
||||||
.getModels()
|
.getModels()
|
||||||
@@ -65,7 +95,7 @@ const App: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [backend.status, maybeOpenOnboarding])
|
}, [backend.status, authState, maybeOpenOnboarding])
|
||||||
|
|
||||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||||
useEffect(() => initUpdateListener(), [])
|
useEffect(() => initUpdateListener(), [])
|
||||||
@@ -91,6 +121,15 @@ const App: React.FC = () => {
|
|||||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
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 isChat = location.pathname === '/'
|
||||||
const showSessions = isChat && !sessionsCollapsed
|
const showSessions = isChat && !sessionsCollapsed
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ interface ApiResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTH_TOKEN_KEY = 'cow_auth_token'
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl = 'http://127.0.0.1:9876'
|
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) {
|
setBaseUrl(url: string) {
|
||||||
this.baseUrl = url
|
this.baseUrl = url
|
||||||
@@ -35,13 +43,25 @@ class ApiClient {
|
|||||||
return this.baseUrl
|
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> {
|
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
...options,
|
...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',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -93,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 {
|
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: {
|
async deleteMessage(opts: {
|
||||||
@@ -138,11 +166,13 @@ class ApiClient {
|
|||||||
|
|
||||||
getFileUrl(previewUrl: string): string {
|
getFileUrl(previewUrl: string): string {
|
||||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
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 {
|
getServeFileUrl(absPath: string): string {
|
||||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -390,7 +420,7 @@ class ApiClient {
|
|||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
createLogStream(): EventSource {
|
createLogStream(): EventSource {
|
||||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVersion(): Promise<string> {
|
async getVersion(): Promise<string> {
|
||||||
@@ -406,14 +436,19 @@ class ApiClient {
|
|||||||
return this.request('/auth/check')
|
return this.request('/auth/check')
|
||||||
}
|
}
|
||||||
|
|
||||||
async authLogin(password: string): Promise<ApiResult> {
|
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
|
||||||
return this.request('/auth/login', {
|
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 'success' && res.token) {
|
||||||
|
this.setAuthToken(res.token)
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async authLogout(): Promise<ApiResult> {
|
async authLogout(): Promise<ApiResult> {
|
||||||
|
this.setAuthToken(null)
|
||||||
return this.request('/auth/logout', { method: 'POST' })
|
return this.request('/auth/logout', { method: 'POST' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||||
|
{ cmd: '/install-browser', desc: t('slash_install_browser') },
|
||||||
{ cmd: '/config', desc: t('slash_config') },
|
{ cmd: '/config', desc: t('slash_config') },
|
||||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||||
{ cmd: '/logs', desc: t('slash_logs') },
|
{ cmd: '/logs', desc: t('slash_logs') },
|
||||||
|
|||||||
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.
|
// Open links in a new tab safely.
|
||||||
const defaultLinkOpen =
|
const defaultLinkOpen =
|
||||||
md.renderer.rules.link_open ||
|
md.renderer.rules.link_open ||
|
||||||
@@ -72,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
|||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string
|
content: string
|
||||||
|
/**
|
||||||
|
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||||
|
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||||
|
* the knowledge viewer so index links open the target doc rather than firing
|
||||||
|
* an "application cannot be opened (-120)" error in Electron.
|
||||||
|
*/
|
||||||
|
onInternalLink?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const html = useMemo(() => md.render(content || ''), [content])
|
const html = useMemo(() => md.render(content || ''), [content])
|
||||||
|
|
||||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = useCallback(
|
||||||
const target = e.target as HTMLElement
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
const target = e.target as HTMLElement
|
||||||
if (!btn) return
|
|
||||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||||
if (!pre) return
|
if (onInternalLink) {
|
||||||
navigator.clipboard.writeText(pre.textContent || '')
|
const a = target.closest('a') as HTMLAnchorElement | null
|
||||||
const original = btn.textContent
|
if (a) {
|
||||||
btn.textContent = t('msg_copied')
|
const href = a.getAttribute('href') || ''
|
||||||
btn.classList.add('copied')
|
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||||
setTimeout(() => {
|
e.preventDefault()
|
||||||
btn.textContent = original
|
onInternalLink(href)
|
||||||
btn.classList.remove('copied')
|
return
|
||||||
}, 1600)
|
}
|
||||||
}, [])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard.writeText(pre.textContent || '')
|
||||||
|
const original = btn.textContent
|
||||||
|
btn.textContent = t('msg_copied')
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 1600)
|
||||||
|
},
|
||||||
|
[onInternalLink]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
muted, separated from the final answer by a dashed divider. */}
|
muted, separated from the final answer by a dashed divider. */}
|
||||||
{(hasSteps || hasLiveReasoning) && (
|
{(hasSteps || hasLiveReasoning) && (
|
||||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
|
||||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||||
|
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||||
|
must render after all committed steps (tools/thinking), not at
|
||||||
|
the very top of the bubble. */}
|
||||||
|
{hasLiveReasoning && (
|
||||||
|
<div className={hasSteps ? 'mt-1' : ''}>
|
||||||
|
<ThinkingStep content={message.reasoning!} streaming />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||||
import type { MessageStep } from '../types'
|
import type { MessageStep } from '../types'
|
||||||
|
import { t } from '../i18n'
|
||||||
import Markdown from './Markdown'
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
|||||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<Brain size={12} className="flex-shrink-0" />
|
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export function useBackend() {
|
|||||||
|
|
||||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||||
try {
|
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),
|
signal: AbortSignal.timeout(3000),
|
||||||
})
|
})
|
||||||
return res.ok
|
return res.ok
|
||||||
|
|||||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: '已中止',
|
msg_cancelled: '已中止',
|
||||||
msg_self_learned: '自主学习',
|
msg_self_learned: '自主学习',
|
||||||
msg_stop: '停止',
|
msg_stop: '停止',
|
||||||
|
thinking_in_progress: '思考中…',
|
||||||
|
thinking_done: '已深度思考',
|
||||||
chat_clear_context: '清除上下文',
|
chat_clear_context: '清除上下文',
|
||||||
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
chat_load_earlier: '加载更早的消息',
|
chat_load_earlier: '加载更早的消息',
|
||||||
chat_send: '发送',
|
chat_send: '发送',
|
||||||
chat_attach: '添加附件',
|
chat_attach: '添加附件',
|
||||||
slash_hint: '输入 / 查看命令',
|
slash_hint: '输入 / 查看命令',
|
||||||
chat_welcome: '有什么可以帮你的?',
|
chat_welcome: '有什么可以帮你的?',
|
||||||
chat_empty_hint: '发送一条消息开始对话',
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务',
|
example_task_title: '定时任务',
|
||||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: '已连接',
|
channels_connected_section: '已连接',
|
||||||
channels_available_section: '可添加',
|
channels_available_section: '可添加',
|
||||||
channels_empty_connected: '暂无已连接的通道',
|
channels_empty_connected: '暂无已连接的通道',
|
||||||
|
channels_empty: '暂未接入任何通道',
|
||||||
|
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||||
channels_save_ok: '已保存',
|
channels_save_ok: '已保存',
|
||||||
channels_save_error: '保存失败',
|
channels_save_error: '保存失败',
|
||||||
@@ -356,6 +361,13 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: '初始化失败',
|
status_error: '初始化失败',
|
||||||
status_error_desc: '客户端初始化失败,请重试',
|
status_error_desc: '客户端初始化失败,请重试',
|
||||||
status_retry: '重试',
|
status_retry: '重试',
|
||||||
|
// login (web_password)
|
||||||
|
login_title: '请输入访问密码',
|
||||||
|
login_desc: '此客户端已设置访问密码,请输入以继续',
|
||||||
|
login_placeholder: '访问密码',
|
||||||
|
login_submit: '进入',
|
||||||
|
login_error: '密码错误,请重试',
|
||||||
|
login_checking: '验证中...',
|
||||||
// slash command descriptions
|
// slash command descriptions
|
||||||
slash_menu_title: '命令',
|
slash_menu_title: '命令',
|
||||||
slash_new: '新建对话',
|
slash_new: '新建对话',
|
||||||
@@ -369,6 +381,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||||
slash_knowledge: '查看知识库统计',
|
slash_knowledge: '查看知识库统计',
|
||||||
slash_knowledge_list: '查看知识库文件树',
|
slash_knowledge_list: '查看知识库文件树',
|
||||||
|
slash_install_browser: '安装浏览器工具',
|
||||||
slash_config: '查看当前配置',
|
slash_config: '查看当前配置',
|
||||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||||
slash_logs: '查看最近日志',
|
slash_logs: '查看最近日志',
|
||||||
@@ -504,7 +517,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: 'Cancelled',
|
msg_cancelled: 'Cancelled',
|
||||||
msg_self_learned: 'Self-learned',
|
msg_self_learned: 'Self-learned',
|
||||||
msg_stop: 'Stop',
|
msg_stop: 'Stop',
|
||||||
|
thinking_in_progress: 'Thinking…',
|
||||||
|
thinking_done: 'Thought',
|
||||||
chat_clear_context: 'Clear context',
|
chat_clear_context: 'Clear context',
|
||||||
|
context_cleared: '— Context above has been cleared —',
|
||||||
chat_load_earlier: 'Load earlier messages',
|
chat_load_earlier: 'Load earlier messages',
|
||||||
chat_send: 'Send',
|
chat_send: 'Send',
|
||||||
chat_attach: 'Attach file',
|
chat_attach: 'Attach file',
|
||||||
@@ -663,6 +679,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: 'Connected',
|
channels_connected_section: 'Connected',
|
||||||
channels_available_section: 'Available',
|
channels_available_section: 'Available',
|
||||||
channels_empty_connected: 'No connected channels yet',
|
channels_empty_connected: 'No connected channels yet',
|
||||||
|
channels_empty: 'No channels connected',
|
||||||
|
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||||
channels_save_ok: 'Saved',
|
channels_save_ok: 'Saved',
|
||||||
channels_save_error: 'Failed to save',
|
channels_save_error: 'Failed to save',
|
||||||
@@ -731,6 +749,13 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: 'Initialization Failed',
|
status_error: 'Initialization Failed',
|
||||||
status_error_desc: 'Failed to initialize the client, please retry',
|
status_error_desc: 'Failed to initialize the client, please retry',
|
||||||
status_retry: '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 command descriptions
|
||||||
slash_menu_title: 'Commands',
|
slash_menu_title: 'Commands',
|
||||||
slash_new: 'New chat',
|
slash_new: 'New chat',
|
||||||
@@ -744,6 +769,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||||
slash_knowledge: 'Show knowledge base stats',
|
slash_knowledge: 'Show knowledge base stats',
|
||||||
slash_knowledge_list: 'Show knowledge base file tree',
|
slash_knowledge_list: 'Show knowledge base file tree',
|
||||||
|
slash_install_browser: 'Install browser tool',
|
||||||
slash_config: 'Show current config',
|
slash_config: 'Show current config',
|
||||||
slash_cancel: 'Abort the running agent task',
|
slash_cancel: 'Abort the running agent task',
|
||||||
slash_logs: 'Show recent logs',
|
slash_logs: 'Show recent logs',
|
||||||
|
|||||||
@@ -27,21 +27,21 @@
|
|||||||
--danger-border: rgba(239, 68, 68, 0.3);
|
--danger-border: rgba(239, 68, 68, 0.3);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Light theme — layered neutral surfaces */
|
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||||
--bg-base: #fafafa; /* app background */
|
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||||
--bg-surface: #ffffff; /* panels, cards */
|
--bg-surface: #ffffff; /* panels, cards */
|
||||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||||
|
|
||||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||||
--text-secondary: #52525b; /* body, labels */
|
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||||
--text-tertiary: #71717a; /* hints, captions */
|
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||||
--text-disabled: #a1a1aa;
|
--text-disabled: #94a3b8; /* slate-400 */
|
||||||
|
|
||||||
--border-default: #e4e4e7;
|
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||||
--border-strong: #d4d4d8;
|
--border-strong: #d1d5db; /* gray-300 */
|
||||||
--border-subtle: #f0f0f1;
|
--border-subtle: #f3f4f6; /* gray-100 */
|
||||||
|
|
||||||
--overlay: rgba(0, 0, 0, 0.4);
|
--overlay: rgba(0, 0, 0, 0.4);
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Headset,
|
Headset,
|
||||||
Hash,
|
Hash,
|
||||||
AtSign,
|
AtSign,
|
||||||
|
RadioTower,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { t, localizedLabel } from '../i18n'
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{connected.length === 0 && !addOpen ? (
|
{connected.length === 0 && !addOpen ? (
|
||||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||||
|
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||||
|
<RadioTower size={26} className="text-info" />
|
||||||
|
</span>
|
||||||
|
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||||
|
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||||
|
{t('channels_empty_desc')}
|
||||||
|
</p>
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Btn variant="primary" onClick={openAdd}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Plus size={15} />
|
||||||
|
{t('channels_add')}
|
||||||
|
</span>
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
|||||||
import type { Attachment, ChatMessage } from '../types'
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
import { useChatStore } from '../store/chatStore'
|
import { useChatStore } from '../store/chatStore'
|
||||||
import { useSessionStore } from '../store/sessionStore'
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
import { useUIStore } from '../store/uiStore'
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
const clearContext = useChatStore((s) => s.clearContext)
|
||||||
|
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||||
|
|
||||||
const messages = session?.messages ?? []
|
const messages = session?.messages ?? []
|
||||||
const isStreaming = session?.isStreaming ?? false
|
const isStreaming = session?.isStreaming ?? false
|
||||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const id = newSession()
|
const id = newSession()
|
||||||
ensureSession(id)
|
ensureSession(id)
|
||||||
loadHistory(id, 1)
|
loadHistory(id, 1)
|
||||||
}, [newSession, ensureSession, loadHistory])
|
// Auto-expand the session list so the user sees the new/switched session.
|
||||||
|
setSessionsCollapsed(false)
|
||||||
|
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||||
|
|
||||||
const handleClearContext = useCallback(async () => {
|
const handleClearContext = useCallback(async () => {
|
||||||
try {
|
await clearContext(activeId)
|
||||||
await apiClient.clearContext(activeId)
|
scrollToBottom(true)
|
||||||
} catch {
|
}, [clearContext, activeId, scrollToBottom])
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [activeId])
|
|
||||||
|
|
||||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<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>
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||||
|
{t('welcome_subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
<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 }) => (
|
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-3 max-w-3xl mx-auto">
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) =>
|
||||||
<MessageBubble
|
msg.kind === 'divider' ? (
|
||||||
key={msg.id}
|
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||||
message={msg}
|
<span
|
||||||
onRegenerate={handleRegenerate}
|
className="flex-1 h-px"
|
||||||
onEdit={handleEdit}
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
onDelete={handleDelete}
|
/>
|
||||||
onMediaLoad={handleMediaLoad}
|
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||||
/>
|
<span
|
||||||
))}
|
className="flex-1 h-px"
|
||||||
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onMediaLoad={handleMediaLoad}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||||
|
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||||
|
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||||
|
function stripDuplicateH1(content: string, title: string): string {
|
||||||
|
if (!content) return content
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||||
|
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||||
|
if (!m) return content
|
||||||
|
if (norm(m[1]) !== norm(title)) return content
|
||||||
|
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten the tree into category paths (for destination selectors).
|
// Flatten the tree into category paths (for destination selectors).
|
||||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||||
|
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||||
|
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||||
|
for (const f of list.root_files || []) {
|
||||||
|
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
for (const f of dir.files) {
|
||||||
|
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
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 null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||||
|
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||||
|
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||||
|
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||||
|
const clean = href.split('#')[0].split('?')[0]
|
||||||
|
if (!clean.endsWith('.md')) return null
|
||||||
|
if (clean.startsWith('knowledge/')) {
|
||||||
|
const path = clean.replace(/^knowledge\//, '')
|
||||||
|
return { path, title: findTitle(list, path) }
|
||||||
|
}
|
||||||
|
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||||
|
return { path: clean, title: findTitle(list, clean) }
|
||||||
|
}
|
||||||
|
// Relative/other path: fall back to matching by filename.
|
||||||
|
const filename = clean.split('/').pop() || clean
|
||||||
|
return findFileByName(list, filename)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a document's display title from its path, falling back to the stem.
|
// Resolve a document's display title from its path, falling back to the stem.
|
||||||
function findTitle(list: KnowledgeList, path: string): string {
|
function findTitle(list: KnowledgeList, path: string): string {
|
||||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
setContent('')
|
setContent('')
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.readKnowledge(path)
|
const res = await apiClient.readKnowledge(path)
|
||||||
setContent(res.content || '')
|
setContent(stripDuplicateH1(res.content || '', title))
|
||||||
} catch {
|
} catch {
|
||||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||||
|
// Falls back silently when the target can't be resolved in the current tree.
|
||||||
|
const openInternalLink = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
if (!data) return
|
||||||
|
const hit = resolveKnowledgeLink(data, href)
|
||||||
|
if (hit) void openDoc(hit.path, hit.title)
|
||||||
|
},
|
||||||
|
[data, openDoc]
|
||||||
|
)
|
||||||
|
|
||||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||||
// currently open doc (or open the first one on the initial load).
|
// currently open doc (or open the first one on the initial load).
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
<Loader2 size={16} className="animate-spin mr-2" />
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Markdown content={content} />
|
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
setMaxSteps(data.agent_max_steps ?? 20)
|
setMaxSteps(data.agent_max_steps ?? 20)
|
||||||
setThinking(!!data.enable_thinking)
|
setThinking(!!data.enable_thinking)
|
||||||
setEvolution(!!data.self_evolution_enabled)
|
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)
|
setPwDirty(false)
|
||||||
|
|
||||||
const ids = data.providers ? Object.keys(data.providers) : []
|
const ids = data.providers ? Object.keys(data.providers) : []
|
||||||
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
setTimeout(() => setAgentStatus(''), 2000)
|
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 () => {
|
const savePassword = async () => {
|
||||||
if (!pwDirty || MASK_RE.test(password)) return
|
if (!pwDirty) return
|
||||||
|
if (!hasRealPassword && MASK_RE.test(password)) return
|
||||||
try {
|
try {
|
||||||
await apiClient.updateConfig({ web_password: password })
|
await apiClient.updateConfig({ web_password: password })
|
||||||
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||||
@@ -292,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
value={password}
|
value={password}
|
||||||
placeholder={t('config_password_placeholder')}
|
placeholder={t('config_password_placeholder')}
|
||||||
onFocus={() => {
|
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={() => {
|
onBlur={() => {
|
||||||
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPassword(e.target.value)
|
setPassword(e.target.value)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ChatState {
|
|||||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||||
|
|
||||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||||
|
clearContext: (sid: string) => Promise<boolean>
|
||||||
clearLocal: (sid: string) => void
|
clearLocal: (sid: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
cancel: async (sid) => {
|
cancel: async (sid) => {
|
||||||
const s = get().sessions[sid]
|
const s = get().sessions[sid]
|
||||||
if (!s?.requestId) return
|
if (!s?.requestId) return
|
||||||
|
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||||
|
// cancelled, free the input, and tear down the local SSE stream so no
|
||||||
|
// further deltas render after the user hit stop. The backend still gets
|
||||||
|
// the cancel request to abort the running agent task.
|
||||||
|
patchMessages(sid, (msgs) => {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') {
|
||||||
|
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...msgs]
|
||||||
|
})
|
||||||
|
patchSession(sid, { isStreaming: false, requestId: null })
|
||||||
|
const es = streams[sid]
|
||||||
|
if (es) {
|
||||||
|
es.close()
|
||||||
|
delete streams[sid]
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearContext: async (sid) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.clearContext(sid)
|
||||||
|
if (res.status !== 'success') return false
|
||||||
|
// Append a visual divider so the user sees the context was cleared
|
||||||
|
// (mirrors the web console's context-divider).
|
||||||
|
patchMessages(sid, (msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
id: uid('divider'),
|
||||||
|
role: 'system',
|
||||||
|
kind: 'divider',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearLocal: (sid) => {
|
clearLocal: (sid) => {
|
||||||
const es = streams[sid]
|
const es = streams[sid]
|
||||||
if (es) {
|
if (es) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UIState {
|
|||||||
/** Session list panel collapsed (hidden) vs expanded. */
|
/** Session list panel collapsed (hidden) vs expanded. */
|
||||||
sessionsCollapsed: boolean
|
sessionsCollapsed: boolean
|
||||||
toggleSessions: () => void
|
toggleSessions: () => void
|
||||||
|
setSessionsCollapsed: (v: boolean) => void
|
||||||
|
|
||||||
/** Currently active session id (Chat page). */
|
/** Currently active session id (Chat page). */
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||||
return { sessionsCollapsed: next }
|
return { sessionsCollapsed: next }
|
||||||
}),
|
}),
|
||||||
|
setSessionsCollapsed: (v) => {
|
||||||
|
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||||
|
set({ sessionsCollapsed: v })
|
||||||
|
},
|
||||||
|
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
|||||||
// Chat / messages / streaming
|
// Chat / messages / streaming
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export type Role = 'user' | 'assistant'
|
export type Role = 'user' | 'assistant' | 'system'
|
||||||
|
|
||||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||||
export interface MessageStep {
|
export interface MessageStep {
|
||||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
|||||||
/** Sequence numbers from backend (for delete/regenerate). */
|
/** Sequence numbers from backend (for delete/regenerate). */
|
||||||
userSeq?: number
|
userSeq?: number
|
||||||
botSeq?: number
|
botSeq?: number
|
||||||
/** Self-evolution bubble flag. */
|
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||||
kind?: 'evolution'
|
kind?: 'evolution' | 'divider'
|
||||||
extras?: Record<string, unknown>
|
extras?: Record<string, unknown>
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
isCancelled?: boolean
|
isCancelled?: boolean
|
||||||
@@ -230,6 +230,9 @@ export interface ConfigData {
|
|||||||
api_keys: Record<string, string>
|
api_keys: Record<string, string>
|
||||||
providers: Record<string, ProviderMeta>
|
providers: Record<string, ProviderMeta>
|
||||||
web_password_masked?: string
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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.
|
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
|
## Download & Install
|
||||||
|
|
||||||
<Card title="Go to the download page" icon="download" href="https://cowagent.ai/download/">
|
<Card title="Go to the download page" icon="download" href="https://cowagent.ai/download/">
|
||||||
@@ -34,3 +36,16 @@ The desktop client has built-in auto-update. When a new version is available it
|
|||||||
|
|
||||||
- **Desktop client**: best for personal use on your own computer — works out of the box, GUI-based, auto-updating.
|
- **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).
|
- **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>
|
<Tip>
|
||||||
Back up `config.json` before upgrading. For Docker deployments, mount the workspace directory as a volume to persist data across upgrades.
|
Back up `config.json` before upgrading. For Docker deployments, mount the workspace directory as a volume to persist data across upgrades.
|
||||||
</Tip>
|
</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"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/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://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://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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -106,8 +106,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -230,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
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/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: CowAgent デスクトップクライアント(macOS / Windows)
|
|||||||
|
|
||||||
CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスクトップクライアントを提供しています。**Python や依存関係を手動でインストールする必要はありません**。ダウンロードしてインストールするだけで、ローカルでスーパー AI アシスタントを実行できます。
|
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/">
|
<Card title="ダウンロードページへ" icon="download" href="https://cowagent.ai/download/">
|
||||||
@@ -34,3 +36,16 @@ CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスク
|
|||||||
|
|
||||||
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
|
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
|
||||||
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/ja/guide/quick-start) を参照。
|
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/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>
|
<Tip>
|
||||||
アップグレード前に `config.json` 設定ファイルのバックアップを推奨します。Docker 環境でデータを保持する場合は、volume マウントでワークスペースディレクトリを永続化できます。
|
アップグレード前に `config.json` 設定ファイルのバックアップを推奨します。Docker 環境でデータを保持する場合は、volume マウントでワークスペースディレクトリを永続化できます。
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
|
## デスクトップクライアントのアップグレード
|
||||||
|
|
||||||
|
[デスクトップクライアント](/ja/guide/desktop)は自動アップデート機能を内蔵しており、新しいバージョンを自動的に確認して通知します。ワンクリックでダウンロードして再起動し、アップグレードを完了できます。
|
||||||
|
|
||||||
|
最新バージョンは [ダウンロードページ](https://cowagent.ai/download/) からいつでも手動で入手することもできます。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
| `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_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
| モデル | 用途 |
|
| モデル | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
||||||
|
| `claude-fable-5` | Claude 5 シリーズのもう一つのフラッグシップモデル |
|
||||||
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
||||||
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
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 で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -20,9 +20,9 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ description: LinkAI プラットフォーム経由でテキスト、ビジョン
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` など。
|
選択可能なモデル:`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
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"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_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
||||||
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
||||||
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
||||||
|
|||||||
@@ -52,15 +52,15 @@ Web コンソール、ログ、ドキュメントが **繁体字中国語(zh-H
|
|||||||
## 🔒 セキュリティ強化
|
## 🔒 セキュリティ強化
|
||||||
|
|
||||||
- **機密ファイル読み取り防護**:認証情報などの機密ファイルへのアクセスを強化し、迂回による読み取りを防止します。Thanks @fengyl07 (#2913)
|
- **機密ファイル読み取り防護**:認証情報などの機密ファイルへのアクセスを強化し、迂回による読み取りを防止します。Thanks @fengyl07 (#2913)
|
||||||
- **ブラウザアクセス防護**:ブラウザによる内部ネットワークやクラウドサーバーの内部エンドポイントへのリクエストをブロックし、内部サービスへ誘導されるリスクを低減します。Thanks @christop
|
- **ブラウザアクセス防護**:ブラウザによる内部ネットワークやクラウドサーバーの内部エンドポイントへのリクエストをブロックし、内部サービスへ誘導されるリスクを低減します。Thanks @Jiangrong-W
|
||||||
- **設定解析の安全化**:設定内容をより安全な方法で解析し、潜在的なコード実行リスクを回避します。Thanks @shunfeng8421
|
- **設定解析の安全化**:設定内容をより安全な方法で解析し、潜在的なコード実行リスクを回避します。Thanks @shunfeng8421
|
||||||
|
|
||||||
## 🛠 改善と修正
|
## 🛠 改善と修正
|
||||||
|
|
||||||
- **カスタムプロバイダー対応**:埋め込みモデルとビジョンモデルでカスタムプロバイダーを利用可能に。あわせて Windows でのメモリ取得の問題を修正しました。Thanks @HnBigVolibear
|
- **カスタムプロバイダー対応**:埋め込みモデルとビジョンモデルでカスタムプロバイダーを利用可能に。あわせて Windows でのメモリ取得の問題を修正しました。Thanks @HnBigVolibear
|
||||||
- **ファイル編集の安定性向上**:元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @xiaweiwei67-stack (#2942)
|
- **ファイル編集の安定性向上**:元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @weijun-xia (#2942)
|
||||||
- **コマンド出力の文字化け修正**:コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @xiaweiwei67-stack (#2941)
|
- **コマンド出力の文字化け修正**:コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @weijun-xia (#2941)
|
||||||
- **Azure OpenAI の修正**:Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Eric L
|
- **Azure OpenAI の修正**:Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Tunnello
|
||||||
- **企業向け WeChat スマートボット**:webhook(コールバック)モードの接続ドキュメントを追加しました。Thanks @6vision
|
- **企業向け WeChat スマートボット**:webhook(コールバック)モードの接続ドキュメントを追加しました。Thanks @6vision
|
||||||
- **ディープドリームの切り替え**:`deep_dream_enabled` の専用スイッチを追加し、ディープドリームの蒸留を個別に有効・無効化できます。
|
- **ディープドリームの切り替え**:`deep_dream_enabled` の専用スイッチを追加し、ディープドリームの蒸留を個別に有効・無効化できます。
|
||||||
- **安定性**:Web サービスの接続回収を改善し、自己進化に関するいくつかの問題を修正しました (#2924, #2904)
|
- **安定性**:Web サービスの接続回収を改善し、自己進化に関するいくつかの問題を修正しました (#2924, #2904)
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
|
|||||||
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**デスクトップクライアント利用者**:playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時:
|
||||||
|
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
|
||||||
|
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
|
||||||
|
</Note>
|
||||||
|
|
||||||
## ワークフロー
|
## ワークフロー
|
||||||
|
|
||||||
Agentがブラウザを使う典型的な流れ:
|
Agentがブラウザを使う典型的な流れ:
|
||||||
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ブラウザエンジン
|
||||||
|
|
||||||
|
ブラウザエンジンは自動選択され、設定は不要です:
|
||||||
|
|
||||||
|
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
|
||||||
|
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
|
||||||
|
|
||||||
|
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
|
||||||
|
|
||||||
## ログイン状態の永続化
|
## ログイン状態の永続化
|
||||||
|
|
||||||
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ MCP コミュニティ標準に完全準拠しており、Claude Desktop / Curso
|
|||||||
| `url` | SSE / Streamable HTTP | リモートエンドポイントの URL(`command` と二者択一) |
|
| `url` | SSE / Streamable HTTP | リモートエンドポイントの URL(`command` と二者択一) |
|
||||||
| `type` | リモート | リモートトランスポート種別:`sse` または `streamable-http`(既定は `sse`) |
|
| `type` | リモート | リモートトランスポート種別:`sse` または `streamable-http`(既定は `sse`) |
|
||||||
| `headers` | 任意 | リモートリクエストの追加 HTTP ヘッダ(`Authorization` など)。Streamable HTTP のみ |
|
| `headers` | 任意 | リモートリクエストの追加 HTTP ヘッダ(`Authorization` など)。Streamable HTTP のみ |
|
||||||
|
| `scope` | 任意 | OAuth スコープ。OAuth 認可が必要なリモート server のみ使用(任意) |
|
||||||
| `disabled` | 任意 | `true` のとき該当サーバーをスキップ。一時的に無効化したいときに便利 |
|
| `disabled` | 任意 | `true` のとき該当サーバーをスキップ。一時的に無効化したいときに便利 |
|
||||||
|
|
||||||
### 完全な例
|
### 完全な例
|
||||||
@@ -79,6 +80,27 @@ Agent は次のように動作します:
|
|||||||
1. 既存の MCP 設定ファイルを読み込み、新しい server エントリをマージ(既存の項目は保持)
|
1. 既存の MCP 設定ファイルを読み込み、新しい server エントリをマージ(既存の項目は保持)
|
||||||
2. 増分の MCP Server を自動でリロードし、次のメッセージから対応する Tool が利用可能に
|
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 はバックグラウンドで非同期に読み込まれ、メインループをブロックしません。会話はすぐに開始できます
|
- **起動時の非同期ロード**:`mcp.json` に設定された全 server はバックグラウンドで非同期に読み込まれ、メインループをブロックしません。会話はすぐに開始できます
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Supports `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `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_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 |
|
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
| Model | Use Case |
|
| Model | Use Case |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning 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-8` | Previous flagship with the strongest reasoning, at a higher price |
|
||||||
| `claude-opus-4-7` | Earlier Opus flagship |
|
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-1-pro-260628`, `kimi-k2.6`, `claude-sonnet-5`, `gemini-3.1-flash-lite-preview`, etc.
|
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-1-pro-260628`, `kimi-k2.6`, `claude-sonnet-5`, `claude-fable-5`, `gemini-3.1-flash-lite-preview`, etc.
|
||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `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`, the `gpt-5` series, `gpt-4.1`, etc. Agent mode defaults to `gpt-5.6-luna`; use `gpt-5.4` for better cost-efficiency |
|
||||||
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||||
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
||||||
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Highlights:
|
|||||||
- **Auto update**: automatic version checks and one-click updates, with download speed optimized across regions
|
- **Auto update**: automatic version checks and one-click updates, with download speed optimized across regions
|
||||||
- **Native experience**: first-run onboarding, follows the system language, and platform-adaptive window interactions
|
- **Native experience**: first-run onboarding, follows the system language, and platform-adaptive window interactions
|
||||||
|
|
||||||
|
Docs: [Desktop Client](https://docs.cowagent.ai/guide/desktop)
|
||||||
|
|
||||||
## 📚 Knowledge Base
|
## 📚 Knowledge Base
|
||||||
|
|
||||||
@@ -53,15 +54,15 @@ Docs: [Models](https://docs.cowagent.ai/models)
|
|||||||
## 🔒 Security Hardening
|
## 🔒 Security Hardening
|
||||||
|
|
||||||
- **Sensitive file read protection**: hardened access to credential and other sensitive files to prevent bypass reads. Thanks @fengyl07 (#2913)
|
- **Sensitive file read protection**: hardened access to credential and other sensitive files to prevent bypass reads. Thanks @fengyl07 (#2913)
|
||||||
- **Browser access protection**: blocks browser requests targeting internal network and cloud server internal endpoints, reducing the risk of being tricked into reaching internal services. Thanks @christop
|
- **Browser access protection**: blocks browser requests targeting internal network and cloud server internal endpoints, reducing the risk of being tricked into reaching internal services. Thanks @Jiangrong-W
|
||||||
- **Safer config parsing**: config content is parsed in a safer way to avoid potential code execution risks. Thanks @shunfeng8421
|
- **Safer config parsing**: config content is parsed in a safer way to avoid potential code execution risks. Thanks @shunfeng8421
|
||||||
|
|
||||||
## 🛠 Improvements & Fixes
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
- **Custom provider support**: embedding and vision models can now use custom providers; also fixed a memory query issue on Windows. Thanks @HnBigVolibear
|
- **Custom provider support**: embedding and vision models can now use custom providers; also fixed a memory query issue on Windows. Thanks @HnBigVolibear
|
||||||
- **More reliable file editing**: better preserves original indentation, and fuzzy matching no longer touches unrelated content. Thanks @xiaweiwei67-stack (#2942)
|
- **More reliable file editing**: better preserves original indentation, and fuzzy matching no longer touches unrelated content. Thanks @weijun-xia (#2942)
|
||||||
- **Command output encoding fix**: fixed garbled Chinese characters when a command produces large output. Thanks @xiaweiwei67-stack (#2941)
|
- **Command output encoding fix**: fixed garbled Chinese characters when a command produces large output. Thanks @weijun-xia (#2941)
|
||||||
- **Azure OpenAI fixes**: fixed streaming output and related configuration issues for Azure OpenAI. Thanks @Eric L
|
- **Azure OpenAI fixes**: fixed streaming output and related configuration issues for Azure OpenAI. Thanks @Tunnello
|
||||||
- **WeCom Smart Bot**: added channel docs for the webhook (callback) mode. Thanks @6vision
|
- **WeCom Smart Bot**: added channel docs for the webhook (callback) mode. Thanks @6vision
|
||||||
- **Deep Dream toggle**: added a dedicated `deep_dream_enabled` switch to enable or disable Deep Dream distillation independently.
|
- **Deep Dream toggle**: added a dedicated `deep_dream_enabled` switch to enable or disable Deep Dream distillation independently.
|
||||||
- **Stability**: improved connection recycling in the Web service and fixed several Self-Evolution issues (#2924, #2904)
|
- **Stability**: improved connection recycling in the Web service and fixed several Self-Evolution issues (#2924, #2904)
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ Control a Chromium browser for web navigation, element interaction and content e
|
|||||||
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
|
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**Desktop client users**: playwright is bundled in the installer, no separate install needed. On first use of the browser tool:
|
||||||
|
- If **Google Chrome / Edge** is installed, it drives the system browser directly with **no download** (recommended);
|
||||||
|
- Otherwise, send `/install-browser` in chat to download a lightweight browser engine into `~/.cow`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
A typical browser workflow for the Agent:
|
A typical browser workflow for the Agent:
|
||||||
@@ -105,6 +111,15 @@ You can override it in `config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Browser Engine
|
||||||
|
|
||||||
|
The browser engine is selected automatically, no configuration needed:
|
||||||
|
|
||||||
|
1. If **Google Chrome / Edge** is detected on the machine, it drives the system browser directly, with **no Chromium download**, using real browser fingerprints;
|
||||||
|
2. Otherwise it falls back to the Chromium engine downloaded into `~/.cow` via `install-browser`.
|
||||||
|
|
||||||
|
Both use the persistent login below and behave identically.
|
||||||
|
|
||||||
## Persistent Login
|
## Persistent Login
|
||||||
|
|
||||||
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
|
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Fully compatible with the MCP community standard, identical to Claude Desktop /
|
|||||||
| `url` | SSE / Streamable HTTP | Remote endpoint URL (alternative to `command`) |
|
| `url` | SSE / Streamable HTTP | Remote endpoint URL (alternative to `command`) |
|
||||||
| `type` | Remote | Remote transport type: `sse` or `streamable-http` (defaults to `sse`) |
|
| `type` | Remote | Remote transport type: `sse` or `streamable-http` (defaults to `sse`) |
|
||||||
| `headers` | No | Extra HTTP headers for remote requests (e.g. `Authorization`); Streamable HTTP only |
|
| `headers` | No | Extra HTTP headers for remote requests (e.g. `Authorization`); Streamable HTTP only |
|
||||||
|
| `scope` | No | OAuth scope, only for remote servers that require OAuth authorization (optional) |
|
||||||
| `disabled` | No | When `true`, this server is skipped — handy for temporary disabling |
|
| `disabled` | No | When `true`, this server is skipped — handy for temporary disabling |
|
||||||
|
|
||||||
### Full Example
|
### Full Example
|
||||||
@@ -79,6 +80,27 @@ The Agent will:
|
|||||||
1. Read the existing MCP config and merge the new server entry, preserving existing ones
|
1. Read the existing MCP config and merge the new server entry, preserving existing ones
|
||||||
2. Hot-reload the new MCP server, so the corresponding tools become available on the next message
|
2. Hot-reload the new MCP server, so the corresponding tools become available on the next message
|
||||||
|
|
||||||
|
## Web Authorization (OAuth)
|
||||||
|
|
||||||
|
Some remote MCP servers require OAuth web authorization, and connecting to them directly returns `401`. CowAgent has a built-in standard OAuth flow, so **no manual token is needed** — just configure the server normally, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xmind": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "https://app.xmind.com/api/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When a server returns `401` on its first load, authorization starts automatically: running locally **opens the browser automatically**, while server deployments **print the authorization link to the log** for you to open in a browser. Once you approve, the server comes online immediately; tokens are refreshed automatically on expiry, so you never have to re-authorize.
|
||||||
|
|
||||||
|
- **Requires the web service**: The authorization callback is received by the web console (default port `9899`), so the Web channel must be running.
|
||||||
|
- **Credential storage**: Tokens are persisted in `~/.cow/mcp_oauth.json` and reused across restarts.
|
||||||
|
- **Callback URL**: Defaults to `http://127.0.0.1:9899/mcp/oauth/callback`. If deployed on a server with the authorizing browser on another device, set `mcp_oauth_redirect_base` in `config.json` (e.g. `http://YOUR_IP:9899`).
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
- **Async loading at startup**: All servers configured in `mcp.json` are loaded asynchronously in the background, never blocking the main loop — chat is usable immediately.
|
- **Async loading at startup**: All servers configured in `mcp.json` are loaded asynchronously in the background, never blocking the main loop — chat is usable immediately.
|
||||||
|
|||||||
@@ -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"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/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://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -108,9 +108,9 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
@@ -233,7 +233,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
掃碼加入微信開源交流群:
|
掃碼加入微信開源交流群:
|
||||||
|
|
||||||
<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" />
|
||||||
|
|
||||||
也可透過以下方式獲取支援:
|
也可透過以下方式獲取支援:
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
## 🏢 企業服務
|
## 🏢 企業服務
|
||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
|
||||||
|
|
||||||
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
||||||
>
|
>
|
||||||
@@ -262,7 +262,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
**產品諮詢和企業服務** 可聯絡產品客服:
|
**產品諮詢和企業服務** 可聯絡產品客服:
|
||||||
|
|
||||||
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -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"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/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://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档"></a>
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -108,9 +108,9 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
@@ -233,7 +233,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
扫码加入微信开源交流群:
|
扫码加入微信开源交流群:
|
||||||
|
|
||||||
<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" />
|
||||||
|
|
||||||
也可通过以下方式获取支持:
|
也可通过以下方式获取支持:
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏢 企业服务
|
## 🏢 企业服务
|
||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
|
||||||
|
|
||||||
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
|
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
|
||||||
>
|
>
|
||||||
@@ -262,7 +262,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
**产品咨询和企业服务** 可联系产品客服:
|
**产品咨询和企业服务** 可联系产品客服:
|
||||||
|
|
||||||
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: 下载并使用 CowAgent 桌面客户端(macOS / Windows)
|
|||||||
|
|
||||||
CowAgent 提供开箱即用的桌面客户端,内置 Agent 运行环境,**无需手动安装 Python 或依赖**,下载安装后即可在本地运行你的超级 AI 助理。
|
CowAgent 提供开箱即用的桌面客户端,内置 Agent 运行环境,**无需手动安装 Python 或依赖**,下载安装后即可在本地运行你的超级 AI 助理。
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/desktop-chat-demo-zh.png" alt="CowAgent 桌面客户端" />
|
||||||
|
|
||||||
## 下载安装
|
## 下载安装
|
||||||
|
|
||||||
<Card title="前往下载页" icon="download" href="https://cowagent.ai/zh/download/">
|
<Card title="前往下载页" icon="download" href="https://cowagent.ai/zh/download/">
|
||||||
@@ -25,11 +27,25 @@ CowAgent 提供开箱即用的桌面客户端,内置 Agent 运行环境,**
|
|||||||
3. 从桌面或开始菜单打开 CowAgent 即可。
|
3. 从桌面或开始菜单打开 CowAgent 即可。
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
## 自动更新
|
## 自动更新
|
||||||
|
|
||||||
桌面客户端内置自动更新能力。有新版本时会自动检测并提示,你也可以在应用左下角菜单中手动「检查更新」,一键下载并重启完成升级。
|
桌面客户端内置自动更新能力。有新版本时会自动检测并提示,你也可以在应用左下角菜单中手动「检查更新」,一键下载并重启完成升级。
|
||||||
|
|
||||||
## 与命令行部署的区别
|
## 与命令行部署的区别
|
||||||
|
|
||||||
- **桌面客户端**:适合个人在本地电脑使用,开箱即用,图形界面操作,自动更新。
|
- **桌面客户端**:适合个人用户开箱即用,无需安装任何依赖,图形界面操作,支持自动更新。
|
||||||
- **命令行部署**:适合开发者或服务器长期运行,可定制性更强,详见 [一键安装](/zh/guide/quick-start)。
|
- **命令行部署**:源码运行方式,适合开发者在本地或服务器部署,方便扩展功能,可定制性更强,详见 [一键安装](/zh/guide/quick-start)。
|
||||||
|
|
||||||
|
## 通过浏览器访问
|
||||||
|
|
||||||
|
桌面客户端启动后会在本机监听 `9876` 端口,其后端与 Web 控制台完全一致。因此在打开客户端的同时,也可以直接用浏览器访问 `http://localhost:9876`,获得与客户端界面一致的完整体验。
|
||||||
|
|
||||||
|
## 本地数据存储
|
||||||
|
|
||||||
|
桌面客户端的所有数据都保存在本机:
|
||||||
|
|
||||||
|
- **配置文件**:位于用户目录下的 `~/.cow`,包含 `config.json`(模型密钥、通道等配置)以及日志、缓存等运行数据。
|
||||||
|
- **工作空间**:默认位于 `~/cow`,用于存放对话记录、知识库、记忆、技能、定时任务等 Agent 产生的文件。
|
||||||
|
|
||||||
|
卸载客户端不会自动删除这两个目录,重装后数据依然保留。如需彻底清理或迁移到其他设备,手动备份或删除对应目录即可。
|
||||||
|
|||||||
@@ -59,3 +59,9 @@ sudo docker compose up -d
|
|||||||
<Tip>
|
<Tip>
|
||||||
升级前建议备份 `config.json` 配置文件。Docker 环境下如需保留数据,可通过 volume 挂载持久化工作空间目录。
|
升级前建议备份 `config.json` 配置文件。Docker 环境下如需保留数据,可通过 volume 挂载持久化工作空间目录。
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
|
## 客户端升级
|
||||||
|
|
||||||
|
[桌面客户端](/zh/guide/desktop)内置自动更新能力,有新版本时会自动检查并提示,一键即可下载并重启完成升级。
|
||||||
|
|
||||||
|
你也可以随时前往[下载页](https://cowagent.ai/zh/download/)手动下载最新版本安装。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 支持 `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `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_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
||||||
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
| 模型 | 适用场景 |
|
| 模型 | 适用场景 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
||||||
|
| `claude-fable-5` | Claude 5 系列的另一款旗舰模型 |
|
||||||
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
||||||
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
||||||
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` 等。
|
可选模型:`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
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
|
|||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 与 OpenAI 接口的 [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 接口的 [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_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
||||||
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
||||||
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ description: CowAgent 2.1.3:正式推出桌面客户端(macOS / Windows)
|
|||||||
- **自动更新**:支持版本自动检查与一键更新,优化不同地区的下载速度
|
- **自动更新**:支持版本自动检查与一键更新,优化不同地区的下载速度
|
||||||
- **原生体验**:首次启动引导、跟随系统语言、平台自适应的窗口交互
|
- **原生体验**:首次启动引导、跟随系统语言、平台自适应的窗口交互
|
||||||
|
|
||||||
|
相关文档:[桌面客户端](https://docs.cowagent.ai/zh/guide/desktop)
|
||||||
|
|
||||||
## 📚 知识库
|
## 📚 知识库
|
||||||
|
|
||||||
@@ -54,15 +55,15 @@ Web 控制台、日志与文档新增 **繁体中文(zh-Hant)** 支持,界
|
|||||||
## 🔒 安全加固
|
## 🔒 安全加固
|
||||||
|
|
||||||
- **敏感文件读取防护**:加固对凭证等敏感文件的访问,防止绕过读取。Thanks @fengyl07 (#2913)
|
- **敏感文件读取防护**:加固对凭证等敏感文件的访问,防止绕过读取。Thanks @fengyl07 (#2913)
|
||||||
- **浏览器访问防护**:浏览器访问网页时拦截指向内网及云服务器内部地址的请求,降低被诱导访问内部服务的风险。Thanks @christop
|
- **浏览器访问防护**:浏览器访问网页时拦截指向内网及云服务器内部地址的请求,降低被诱导访问内部服务的风险。Thanks @Jiangrong-W
|
||||||
- **配置解析加固**:使用更安全的方式解析配置内容,避免潜在的代码执行风险。Thanks @shunfeng8421
|
- **配置解析加固**:使用更安全的方式解析配置内容,避免潜在的代码执行风险。Thanks @shunfeng8421
|
||||||
|
|
||||||
## 🛠 体验优化与修复
|
## 🛠 体验优化与修复
|
||||||
|
|
||||||
- **自定义供应商扩展**:嵌入与视觉模型支持配置自定义供应商;同时修复记忆查询在 Windows 下的问题。Thanks @HnBigVolibear
|
- **自定义供应商扩展**:嵌入与视觉模型支持配置自定义供应商;同时修复记忆查询在 Windows 下的问题。Thanks @HnBigVolibear
|
||||||
- **文件编辑更稳**:编辑文件时更好地保留原有缩进,且模糊匹配时不改动未涉及的内容。Thanks @xiaweiwei67-stack (#2942)
|
- **文件编辑更稳**:编辑文件时更好地保留原有缩进,且模糊匹配时不改动未涉及的内容。Thanks @weijun-xia (#2942)
|
||||||
- **命令输出乱码修复**:修复执行命令产生大量输出时可能出现的中文乱码。Thanks @xiaweiwei67-stack (#2941)
|
- **命令输出乱码修复**:修复执行命令产生大量输出时可能出现的中文乱码。Thanks @weijun-xia (#2941)
|
||||||
- **Azure OpenAI 修复**:修复 Azure OpenAI 的流式输出与相关配置问题。Thanks @Eric L
|
- **Azure OpenAI 修复**:修复 Azure OpenAI 的流式输出与相关配置问题。Thanks @Tunnello
|
||||||
- **企业微信智能机器人**:补充 webhook(回调)模式的接入文档。Thanks @6vision
|
- **企业微信智能机器人**:补充 webhook(回调)模式的接入文档。Thanks @6vision
|
||||||
- **深度梦境开关**:新增 `deep_dream_enabled` 配置开关,可按需开启或关闭深度梦境。
|
- **深度梦境开关**:新增 `deep_dream_enabled` 配置开关,可按需开启或关闭深度梦境。
|
||||||
- **稳定性提升**:优化 Web 服务的连接回收,并修复自主进化过程中的若干问题 (#2924, #2904)
|
- **稳定性提升**:优化 Web 服务的连接回收,并修复自主进化过程中的若干问题 (#2924, #2904)
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ description: 控制浏览器访问和操作网页
|
|||||||
2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
|
2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**桌面客户端用户**:playwright 已内置于安装包,无需单独安装。首次使用浏览器工具时:
|
||||||
|
- 若系统已安装 **Google Chrome / Edge**,会直接驱动系统浏览器,**无需任何下载**(推荐);
|
||||||
|
- 若未安装,可在对话中发送 `/install-browser`,自动下载一个精简浏览器内核到 `~/.cow`。
|
||||||
|
</Note>
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
Agent 使用浏览器的典型流程:
|
Agent 使用浏览器的典型流程:
|
||||||
@@ -105,6 +111,15 @@ Agent 使用浏览器的典型流程:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 浏览器内核
|
||||||
|
|
||||||
|
浏览器内核会自动选择,无需配置:
|
||||||
|
|
||||||
|
1. 若检测到本机已安装 **Google Chrome / Edge**,直接驱动系统浏览器,**无需下载 Chromium**,并使用真实浏览器指纹;
|
||||||
|
2. 否则使用 `install-browser` 下载到 `~/.cow` 的 Chromium 内核作为兜底。
|
||||||
|
|
||||||
|
两种方式都使用下面的登录态持久化,行为一致。
|
||||||
|
|
||||||
## 登录态持久化
|
## 登录态持久化
|
||||||
|
|
||||||
**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式:
|
**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Docker 部署时,官方 `docker-compose.yml` 已经把宿主机 `./cow` 挂载
|
|||||||
| `url` | SSE / Streamable HTTP | 远程端点 URL(与 `command` 二选一) |
|
| `url` | SSE / Streamable HTTP | 远程端点 URL(与 `command` 二选一) |
|
||||||
| `type` | 远程 | 远程传输类型,可选 `sse` 或 `streamable-http`,默认 `sse` |
|
| `type` | 远程 | 远程传输类型,可选 `sse` 或 `streamable-http`,默认 `sse` |
|
||||||
| `headers` | 否 | 远程请求附加 HTTP 头(如 `Authorization`),仅 Streamable HTTP 使用 |
|
| `headers` | 否 | 远程请求附加 HTTP 头(如 `Authorization`),仅 Streamable HTTP 使用 |
|
||||||
|
| `scope` | 否 | OAuth 授权范围,仅需要 OAuth 授权的远程 server 使用(可选) |
|
||||||
| `disabled` | 否 | `true` 时跳过该 server,便于临时关闭 |
|
| `disabled` | 否 | `true` 时跳过该 server,便于临时关闭 |
|
||||||
|
|
||||||
### 完整示例
|
### 完整示例
|
||||||
@@ -79,6 +80,27 @@ Agent 会:
|
|||||||
1. 访问 MCP 配置文件,合并新 server 配置,保留已有项
|
1. 访问 MCP 配置文件,合并新 server 配置,保留已有项
|
||||||
2. 自动重载增量的 MCP Server,下一次对话即可使用相应 Tools
|
2. 自动重载增量的 MCP Server,下一次对话即可使用相应 Tools
|
||||||
|
|
||||||
|
## 网页授权(OAuth)
|
||||||
|
|
||||||
|
部分远程 MCP需要 OAuth 网页授权,直接配置会返回 `401`。CowAgent 内置标准 OAuth 流程,**无需手填 token**,正常配置即可,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xmind": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "https://app.xmind.com/api/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
server 首次加载遇到 `401` 时会自动发起授权:本机运行会**自动打开浏览器**,服务器部署则把**授权链接打印到日志**,复制到浏览器打开。授权同意后即完成,该 server 随即上线,令牌过期自动刷新,无需重复授权。
|
||||||
|
|
||||||
|
- **依赖 Web 服务**:授权回调由 Web 控制台(默认端口 `9899`)接收,需保证 Web channel 正在运行。
|
||||||
|
- **凭证存储**:令牌持久化在 `~/.cow/mcp_oauth.json`,重启后复用。
|
||||||
|
- **回调地址**:默认 `http://127.0.0.1:9899/mcp/oauth/callback`;若部署在服务器、授权浏览器在另一台设备,在 `config.json` 设置 `mcp_oauth_redirect_base`(如 `http://你的IP:9899`)即可。
|
||||||
|
|
||||||
## 工作方式
|
## 工作方式
|
||||||
|
|
||||||
- 启动时**异步加载**:`mcp.json` 中配置的所有 server 会在后台异步加载,不阻塞主流程,对话可以立刻使用
|
- 启动时**异步加载**:`mcp.json` 中配置的所有 server 会在后台异步加载,不阻塞主流程,对话可以立刻使用
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ class OpenAICompatibleBot:
|
|||||||
Subclasses only need to override get_api_config() to provide their specific API settings.
|
Subclasses only need to override get_api_config() to provide their specific API settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_gpt5_reasoning_model(model_name: str) -> bool:
|
||||||
|
"""Whether the model is a GPT-5.x / o-series reasoning model.
|
||||||
|
|
||||||
|
Covers gpt-5, gpt-5.4/5.5/5.6 (including suffixed variants like
|
||||||
|
gpt-5.6-sol / gpt-5.6-luna) and the o1/o3/o4 families. These models
|
||||||
|
only accept default sampling params and, on /v1/chat/completions,
|
||||||
|
reject reasoning_effort together with function tools.
|
||||||
|
"""
|
||||||
|
if not model_name or not isinstance(model_name, str):
|
||||||
|
return False
|
||||||
|
name = model_name.lower()
|
||||||
|
if name.startswith("gpt-5"):
|
||||||
|
return True
|
||||||
|
if name.startswith(("o1", "o3", "o4")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_api_config(self):
|
def get_api_config(self):
|
||||||
"""
|
"""
|
||||||
Get API configuration for this bot.
|
Get API configuration for this bot.
|
||||||
@@ -99,8 +117,10 @@ class OpenAICompatibleBot:
|
|||||||
"presence_penalty": kwargs.get("presence_penalty", api_config.get('default_presence_penalty', 0.0)),
|
"presence_penalty": kwargs.get("presence_penalty", api_config.get('default_presence_penalty', 0.0)),
|
||||||
"stream": stream
|
"stream": stream
|
||||||
}
|
}
|
||||||
# GPT-5 / GPT-5.5 / o1 series only accept default temperature/top_p and reject penalty params
|
# GPT-5.x / o-series reasoning models only accept default
|
||||||
if model_name in ("gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5.5", "o1", "o1-mini"):
|
# temperature/top_p and reject penalty params.
|
||||||
|
is_gpt5_reasoning = self._is_gpt5_reasoning_model(model_name)
|
||||||
|
if is_gpt5_reasoning:
|
||||||
for key in ("temperature", "top_p", "frequency_penalty", "presence_penalty"):
|
for key in ("temperature", "top_p", "frequency_penalty", "presence_penalty"):
|
||||||
request_params.pop(key, None)
|
request_params.pop(key, None)
|
||||||
|
|
||||||
@@ -112,6 +132,12 @@ class OpenAICompatibleBot:
|
|||||||
if tools:
|
if tools:
|
||||||
request_params["tools"] = tools
|
request_params["tools"] = tools
|
||||||
request_params["tool_choice"] = kwargs.get("tool_choice", "auto")
|
request_params["tool_choice"] = kwargs.get("tool_choice", "auto")
|
||||||
|
# GPT-5.x reasoning models reject function tools combined with
|
||||||
|
# reasoning_effort on /v1/chat/completions unless it is "none".
|
||||||
|
# Force "none" so agent tool calling works without migrating to
|
||||||
|
# the Responses API.
|
||||||
|
if is_gpt5_reasoning:
|
||||||
|
request_params["reasoning_effort"] = "none"
|
||||||
|
|
||||||
# Make API call with proper configuration
|
# Make API call with proper configuration
|
||||||
api_key = api_config.get('api_key')
|
api_key = api_config.get('api_key')
|
||||||
|
|||||||
@@ -813,8 +813,8 @@ class CowCliPlugin(Plugin):
|
|||||||
"you can also run `cow install-browser` in a terminal.",
|
"you can also run `cow install-browser` in a terminal.",
|
||||||
)
|
)
|
||||||
return _t(
|
return _t(
|
||||||
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。",
|
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具。",
|
||||||
"✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).",
|
"✅ Installation finished. Restart CowAgent to use the browser tool.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
4
run.sh
4
run.sh
@@ -599,7 +599,7 @@ select_model() {
|
|||||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
||||||
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
|
||||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
||||||
"OpenAI (gpt-5.5, etc.)" \
|
"OpenAI (gpt-5.6-luna, etc.)" \
|
||||||
"MiniMax (MiniMax-M3, etc.)" \
|
"MiniMax (MiniMax-M3, etc.)" \
|
||||||
"GLM (glm-5.2, etc.)" \
|
"GLM (glm-5.2, etc.)" \
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
||||||
@@ -631,7 +631,7 @@ configure_model() {
|
|||||||
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
|
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
|
||||||
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
|
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
|
||||||
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
||||||
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
|
4) read_model_config "OpenAI" "gpt-5.6-luna" "OPENAI_KEY" ;;
|
||||||
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
|
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
|
||||||
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
||||||
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ $ModelChoices = @{
|
|||||||
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
||||||
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
||||||
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
||||||
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
4 = @{ Provider = "OpenAI"; Default = "gpt-5.6-luna"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
||||||
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
||||||
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
||||||
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
||||||
@@ -473,7 +473,7 @@ function Select-Model {
|
|||||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
||||||
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
||||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
||||||
"OpenAI (gpt-5.5, etc.)",
|
"OpenAI (gpt-5.6-luna, etc.)",
|
||||||
"MiniMax (MiniMax-M3, etc.)",
|
"MiniMax (MiniMax-M3, etc.)",
|
||||||
"GLM (glm-5.2, etc.)",
|
"GLM (glm-5.2, etc.)",
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
||||||
|
|||||||
@@ -98,6 +98,41 @@ class TestEditFuzzyPreservesWhitespace(unittest.TestCase):
|
|||||||
"def foo():\n x = 100\n y = 2\n return x + y\n",
|
"def foo():\n x = 100\n y = 2\n return x + y\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_exact_match_rejects_multiple_occurrences(self):
|
||||||
|
# Two byte-identical statements; the exact-match path applies and the
|
||||||
|
# uniqueness guard counts exact occurrences, so the ambiguous edit is
|
||||||
|
# rejected instead of silently editing only the first.
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("a = 1\nb = 2\na = 1\n")
|
||||||
|
result = self.tool.execute({
|
||||||
|
"path": self.path,
|
||||||
|
"oldText": "a = 1",
|
||||||
|
"newText": "a = 9",
|
||||||
|
})
|
||||||
|
self.assertEqual(result.status, "error", result.result)
|
||||||
|
self.assertIn("occurrences", result.result)
|
||||||
|
# An ambiguous match must leave the file untouched.
|
||||||
|
self.assertEqual(self._read(), "a = 1\nb = 2\na = 1\n")
|
||||||
|
|
||||||
|
def test_fuzzy_match_rejects_multiple_occurrences(self):
|
||||||
|
# oldText uses loose spacing, so the exact match fails and the fuzzy
|
||||||
|
# path runs. The uniqueness guard now counts with the SAME regex used
|
||||||
|
# to match/replace, so an ambiguous fuzzy match (two hits) is rejected
|
||||||
|
# rather than silently editing the first one.
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("def foo():\n x = 1\n y = 2\n x = 1\n")
|
||||||
|
result = self.tool.execute({
|
||||||
|
"path": self.path,
|
||||||
|
"oldText": "x = 1",
|
||||||
|
"newText": "x = 99",
|
||||||
|
})
|
||||||
|
self.assertEqual(result.status, "error", result.result)
|
||||||
|
self.assertIn("occurrences", result.result)
|
||||||
|
self.assertEqual(
|
||||||
|
self._read(),
|
||||||
|
"def foo():\n x = 1\n y = 2\n x = 1\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
92
tests/test_scheduler_silent.py
Normal file
92
tests/test_scheduler_silent.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Regression tests for silent scheduled agent tasks."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from agent.tools.scheduler.integration import _execute_agent_task
|
||||||
|
from agent.tools.scheduler.scheduler_tool import SchedulerTool
|
||||||
|
|
||||||
|
|
||||||
|
class _Context(dict):
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
def add_task(self, task):
|
||||||
|
self.added.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
class _AgentBridge:
|
||||||
|
def __init__(self, content="maintenance complete"):
|
||||||
|
self.content = content
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def agent_reply(self, task_description, **kwargs):
|
||||||
|
self.calls.append((task_description, kwargs))
|
||||||
|
return SimpleNamespace(content=self.content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerSilentMode(unittest.TestCase):
|
||||||
|
def test_schema_exposes_silent_for_agent_tasks(self):
|
||||||
|
silent = SchedulerTool.params["properties"]["silent"]
|
||||||
|
|
||||||
|
self.assertEqual(silent["type"], "boolean")
|
||||||
|
self.assertFalse(silent["default"])
|
||||||
|
|
||||||
|
def test_create_persists_silent_on_agent_task(self):
|
||||||
|
tool = SchedulerTool({"channel_type": "web"})
|
||||||
|
store = _TaskStore()
|
||||||
|
tool.task_store = store
|
||||||
|
tool.current_context = _Context(
|
||||||
|
receiver="user-1",
|
||||||
|
session_id="session-1",
|
||||||
|
isgroup=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.execute(
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"name": "refresh token",
|
||||||
|
"ai_task": "refresh the token",
|
||||||
|
"schedule_type": "interval",
|
||||||
|
"schedule_value": "3000",
|
||||||
|
"silent": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "success")
|
||||||
|
self.assertEqual(len(store.added), 1)
|
||||||
|
self.assertIs(store.added[0]["action"]["silent"], True)
|
||||||
|
|
||||||
|
def test_silent_agent_task_executes_without_delivery(self):
|
||||||
|
bridge = _AgentBridge()
|
||||||
|
task = {
|
||||||
|
"id": "task-1",
|
||||||
|
"action": {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "rotate logs",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"is_group": False,
|
||||||
|
"channel_type": "web",
|
||||||
|
"silent": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("channel.channel_factory.create_channel") as create_channel:
|
||||||
|
result = _execute_agent_task(task, bridge)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(len(bridge.calls), 1)
|
||||||
|
create_channel.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user