mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 13:47:15 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b755d18e63 | ||
|
|
3cdc13d69d | ||
|
|
ea0c90f4ea | ||
|
|
f9e7c07af8 | ||
|
|
c4e5d11da9 | ||
|
|
177f494207 | ||
|
|
0c76d851cb | ||
|
|
0783d64f1f | ||
|
|
064740aaad | ||
|
|
8071ea496a | ||
|
|
83209a8b2c | ||
|
|
3db595124c | ||
|
|
6e10b8e3d2 | ||
|
|
2635c12a7f | ||
|
|
64e8f56a7b | ||
|
|
67be468ff1 | ||
|
|
209f1f0a6f | ||
|
|
f7a0184ea2 | ||
|
|
7d8751f04b | ||
|
|
3ee7b164d4 | ||
|
|
d7abbd3baa | ||
|
|
0db8a24441 | ||
|
|
2f86e9b608 | ||
|
|
9d8c6557ae | ||
|
|
257eb5d598 | ||
|
|
9f63453e67 | ||
|
|
5858bda854 | ||
|
|
c784c89fe3 | ||
|
|
186dae059e | ||
|
|
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)) {
|
||||
platform = 'mac-x64'
|
||||
slot = 'upd'
|
||||
} else if (/win7.*\.exe$/i.test(base)) {
|
||||
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||
// segment so it never collides with the standard win exe in the same
|
||||
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||
platform = 'win-legacy'
|
||||
slot = 'main'
|
||||
} else if (/\.exe$/.test(base)) {
|
||||
platform = 'win'
|
||||
slot = 'main'
|
||||
|
||||
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
|
||||
run: npm run build
|
||||
|
||||
# Download the Windows signing CLI. The URL comes from a repo variable, so
|
||||
# nothing about the signing setup is hardcoded in a public workflow. Only
|
||||
# runs on the Windows leg and only when a URL is set; otherwise the build
|
||||
# stays unsigned. SIGNTOOL_PATH is exported for the next step's
|
||||
# electron-builder.win.js to invoke.
|
||||
- name: Download Windows signing CLI
|
||||
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
|
||||
shell: bash
|
||||
env:
|
||||
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/signtool"
|
||||
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||
# Unzip and locate the signtool executable regardless of nesting.
|
||||
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||
if [ -z "$exe" ]; then
|
||||
echo "signtool.exe not found in downloaded archive" >&2
|
||||
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
# Normalize to a Windows-style path for execFileSync in Node.
|
||||
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||
echo "resolved signtool: $exe"
|
||||
|
||||
- name: Build & publish (electron-builder)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
@@ -124,8 +149,14 @@ jobs:
|
||||
# is the correct state for unsigned builds.
|
||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
# Windows code signing via the signing CLI. Credentials are
|
||||
# secrets; SIGNTOOL_PATH was exported by the download step above.
|
||||
# COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
|
||||
# with a self-signed cert before buying a real one — no quota used.
|
||||
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||
run: |
|
||||
# Pick the signing cert for THIS platform only. The mac and win secrets
|
||||
# are both present in the job env, but a mac cert must never leak into a
|
||||
@@ -137,6 +168,10 @@ jobs:
|
||||
#
|
||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||
# (an `unset` can return non-zero and abort under errexit).
|
||||
# macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
|
||||
# a local .pfx (EV private keys can't be exported since 2023); it signs
|
||||
# via the CLI wired into electron-builder.win.js instead, using the
|
||||
# SIGNTOOL_* env already set above — nothing to export here.
|
||||
case "${{ matrix.platform }}" in
|
||||
mac)
|
||||
if [ -n "$MAC_CSC_LINK" ]; then
|
||||
@@ -144,12 +179,6 @@ jobs:
|
||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
;;
|
||||
win)
|
||||
if [ -n "$WIN_CSC_LINK" ]; then
|
||||
export CSC_LINK="$WIN_CSC_LINK"
|
||||
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Never let electron-builder publish: our publish target is a generic
|
||||
@@ -157,19 +186,25 @@ jobs:
|
||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||
# `--publish never` still emits the latest*.yml files.
|
||||
#
|
||||
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
||||
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
||||
# notarization) — it's a pure no-op on Windows. Passing --config on
|
||||
# Windows was what silently broke the Windows build (it produced no
|
||||
# installer while the job still reported success; Windows worked fine
|
||||
# before --config was introduced). So Windows uses the plain
|
||||
# package.json build config and only mac uses the dynamic one.
|
||||
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
|
||||
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||
# win -> electron-builder.win.js (wires the sign hook; electron-builder
|
||||
# signs the app, backend and installer)
|
||||
# HISTORY: passing --config on Windows previously broke the build (no
|
||||
# installer, job still green). That happened because the MAC config
|
||||
# (electron-builder.js) was a no-op on Windows yet still disturbed the
|
||||
# run. The fix is a DEDICATED win config that correctly extends
|
||||
# config.win — not sharing the mac one. If a build ever runs WITHOUT
|
||||
# signing configured, electron-builder.win.js still returns the base
|
||||
# config unchanged (sign hook just skips), so the installer is still
|
||||
# produced.
|
||||
#
|
||||
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||
# make bash return before the wrapped process finishes. node skips it.
|
||||
case "${{ matrix.platform }}" in
|
||||
mac) config_arg="--config electron-builder.js" ;;
|
||||
win) config_arg="--config electron-builder.win.js" ;;
|
||||
*) config_arg="" ;;
|
||||
esac
|
||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||
|
||||
@@ -106,14 +106,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
||||
|
||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||
|
||||
@@ -183,9 +183,15 @@ class ChatService:
|
||||
|
||||
# Register a cancel token so /cancel can abort this in-flight run.
|
||||
# IM channels key on session_id (no per-turn request_id here).
|
||||
from agent.protocol import get_cancel_registry
|
||||
from agent.protocol import get_cancel_registry, get_steer_registry
|
||||
registry = get_cancel_registry()
|
||||
steer_registry = get_steer_registry()
|
||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||
steer_inbox = (
|
||||
steer_registry.register(session_id)
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
|
||||
executor = AgentStreamExecutor(
|
||||
agent=agent,
|
||||
@@ -197,6 +203,7 @@ class ChatService:
|
||||
messages=messages_copy,
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -217,6 +224,8 @@ class ChatService:
|
||||
registry.unregister(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
steer_registry.unregister(session_id, steer_inbox)
|
||||
|
||||
# Sync executor messages back to agent (thread-safe).
|
||||
# The executor may have trimmed context, making its list shorter than
|
||||
|
||||
@@ -8,6 +8,13 @@ from .cancel import (
|
||||
CancelTokenRegistry,
|
||||
get_cancel_registry,
|
||||
)
|
||||
from .steer import (
|
||||
SteerInbox,
|
||||
SteerRegistry,
|
||||
SteerResult,
|
||||
SteerStatus,
|
||||
get_steer_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
@@ -25,4 +32,9 @@ __all__ = [
|
||||
'AgentCancelledError',
|
||||
'CancelTokenRegistry',
|
||||
'get_cancel_registry',
|
||||
'SteerInbox',
|
||||
'SteerRegistry',
|
||||
'SteerResult',
|
||||
'SteerStatus',
|
||||
'get_steer_registry',
|
||||
]
|
||||
|
||||
@@ -381,7 +381,7 @@ class Agent:
|
||||
return action
|
||||
|
||||
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
||||
skill_filter=None, cancel_event=None) -> str:
|
||||
skill_filter=None, cancel_event=None, steer_inbox=None) -> str:
|
||||
"""
|
||||
Execute single agent task with streaming (based on tool-call)
|
||||
|
||||
@@ -391,6 +391,7 @@ class Agent:
|
||||
- Event callbacks
|
||||
- Persistent conversation history across calls
|
||||
- User-initiated cancellation via ``cancel_event``
|
||||
- Explicit active-turn guidance via ``steer_inbox``
|
||||
|
||||
Args:
|
||||
user_message: User message
|
||||
@@ -403,6 +404,8 @@ class Agent:
|
||||
"[Interrupted by user]" assistant note, and returns the
|
||||
partial response. ``messages`` stays in a valid state
|
||||
(tool_use/tool_result pairs preserved).
|
||||
steer_inbox: Optional SteerInbox drained at safe checkpoints. New
|
||||
instructions guide this run without entering the normal queue.
|
||||
|
||||
Returns:
|
||||
Final response text
|
||||
@@ -448,6 +451,7 @@ class Agent:
|
||||
messages=messages_copy, # Pass copied message history
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
|
||||
# Execute
|
||||
|
||||
@@ -99,6 +99,7 @@ class AgentStreamExecutor:
|
||||
messages: Optional[List[Dict]] = None,
|
||||
max_context_turns: int = 30,
|
||||
cancel_event=None,
|
||||
steer_inbox=None,
|
||||
):
|
||||
"""
|
||||
Initialize stream executor
|
||||
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
|
||||
Checked at every safe point (turn boundary, before tool execution,
|
||||
during LLM streaming). When set, raises AgentCancelledError which
|
||||
run_stream catches to gracefully wind down.
|
||||
steer_inbox: Optional SteerInbox for explicit instructions sent to
|
||||
this active run. Drained only at message-safe checkpoints.
|
||||
"""
|
||||
self.agent = agent
|
||||
self.model = model
|
||||
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
|
||||
self.on_event = on_event
|
||||
self.max_context_turns = max_context_turns
|
||||
self.cancel_event = cancel_event
|
||||
self.steer_inbox = steer_inbox
|
||||
|
||||
# Message history - use provided messages or create new list
|
||||
self.messages = messages if messages is not None else []
|
||||
@@ -145,6 +149,72 @@ class AgentStreamExecutor:
|
||||
if self.cancel_event is not None and self.cancel_event.is_set():
|
||||
raise AgentCancelledError("agent cancelled by user")
|
||||
|
||||
def _drain_steering(self) -> List[str]:
|
||||
if self.steer_inbox is None:
|
||||
return []
|
||||
return self.steer_inbox.drain()
|
||||
|
||||
@staticmethod
|
||||
def _steering_text(updates: List[str]) -> str:
|
||||
if len(updates) == 1:
|
||||
body = updates[0]
|
||||
else:
|
||||
body = "\n".join(f"{idx}. {text}" for idx, text in enumerate(updates, 1))
|
||||
return (
|
||||
"[Steering update for the active task]\n"
|
||||
"Use this new instruction for the current task before continuing.\n\n"
|
||||
f"{body}"
|
||||
)
|
||||
|
||||
def _append_steering(
|
||||
self,
|
||||
updates: List[str],
|
||||
pending_tool_calls: Optional[List[Dict]] = None,
|
||||
content_blocks: Optional[List[Dict]] = None,
|
||||
) -> None:
|
||||
"""Append guidance, closing any tool_use blocks that will be skipped."""
|
||||
if not updates:
|
||||
return
|
||||
blocks = content_blocks if content_blocks is not None else []
|
||||
for tool_call in pending_tool_calls or []:
|
||||
blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call["id"],
|
||||
"content": "Skipped because the user redirected the active task.",
|
||||
"is_error": True,
|
||||
})
|
||||
blocks.append({"type": "text", "text": self._steering_text(updates)})
|
||||
if content_blocks is None:
|
||||
self.messages.append({"role": "user", "content": blocks})
|
||||
self._emit_event("agent_steered", {"count": len(updates)})
|
||||
logger.info(f"[Agent] Applied {len(updates)} steering update(s)")
|
||||
|
||||
def _close_or_apply_final_steering(self) -> bool:
|
||||
"""Return True only when the run can finish without losing a steer."""
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
return False
|
||||
if self.steer_inbox is None:
|
||||
return True
|
||||
if self.steer_inbox.close_if_empty():
|
||||
return True
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
return False
|
||||
|
||||
def _drain_and_close_steering(self) -> None:
|
||||
"""Preserve any final guidance before the max-step summary call."""
|
||||
if self.steer_inbox is None:
|
||||
return
|
||||
while True:
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
if self.steer_inbox.close_if_empty():
|
||||
return
|
||||
|
||||
def _handle_cancelled(self, partial_response: str) -> None:
|
||||
"""Wind down ``self.messages`` after a user-initiated cancel.
|
||||
|
||||
@@ -395,6 +465,10 @@ class AgentStreamExecutor:
|
||||
# between turns short-circuits cleanly.
|
||||
self._check_cancelled()
|
||||
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(steering_updates)
|
||||
|
||||
turn += 1
|
||||
logger.info(f"[Agent] Turn {turn}")
|
||||
self._emit_event("turn_start", {"turn": turn})
|
||||
@@ -403,6 +477,24 @@ class AgentStreamExecutor:
|
||||
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
|
||||
final_response = assistant_msg
|
||||
|
||||
# A steer that arrived while the model was streaming takes
|
||||
# precedence over its proposed continuation. Tool calls have
|
||||
# already been written to history, so close every one with a
|
||||
# synthetic result before asking the model to reconsider.
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(
|
||||
steering_updates,
|
||||
pending_tool_calls=tool_calls,
|
||||
)
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": bool(tool_calls),
|
||||
"tool_count": len(tool_calls),
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
|
||||
# No tool calls, end loop
|
||||
if not tool_calls:
|
||||
# 检查是否返回了空响应
|
||||
@@ -467,6 +559,22 @@ class AgentStreamExecutor:
|
||||
# If the explicit-response retry produced tool_calls, skip the break
|
||||
# and continue down to the tool execution branch in this same iteration.
|
||||
if not tool_calls:
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(steering_updates)
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": False,
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
if not self._close_or_apply_final_steering():
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": False,
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
logger.debug(f"✅ Done (no tool calls)")
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
@@ -499,9 +607,17 @@ class AgentStreamExecutor:
|
||||
tool_result_blocks = []
|
||||
|
||||
try:
|
||||
for tool_call in tool_calls:
|
||||
for tool_index, tool_call in enumerate(tool_calls):
|
||||
# Honour cancel between tool invocations within the same turn
|
||||
self._check_cancelled()
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(
|
||||
steering_updates,
|
||||
pending_tool_calls=tool_calls[tool_index:],
|
||||
content_blocks=tool_result_blocks,
|
||||
)
|
||||
break
|
||||
result = self._execute_tool(tool_call)
|
||||
tool_results.append(result)
|
||||
|
||||
@@ -641,6 +757,7 @@ class AgentStreamExecutor:
|
||||
|
||||
if turn >= self.max_turns:
|
||||
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
|
||||
self._drain_and_close_steering()
|
||||
|
||||
# Force model to summarize without tool calls
|
||||
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
||||
@@ -699,6 +816,8 @@ class AgentStreamExecutor:
|
||||
raise
|
||||
|
||||
finally:
|
||||
if self.steer_inbox is not None:
|
||||
self.steer_inbox.close()
|
||||
final_response = final_response.strip() if final_response else final_response
|
||||
if cancelled:
|
||||
# Emit before agent_end so channels can mark UI as cancelled
|
||||
|
||||
127
agent/protocol/steer.py
Normal file
127
agent/protocol/steer.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Thread-safe active-run steering primitives.
|
||||
|
||||
Steering is deliberately separate from the normal per-session message queue.
|
||||
An instruction is accepted only while exactly one run for the scoped session
|
||||
is active; idle sessions never start a new run as a side effect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Deque, Dict, List, Optional, Set
|
||||
|
||||
|
||||
class SteerStatus(str, Enum):
|
||||
ACCEPTED = "accepted"
|
||||
INACTIVE = "inactive"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
INVALID = "invalid"
|
||||
FULL = "full"
|
||||
CLOSING = "closing"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SteerResult:
|
||||
status: SteerStatus
|
||||
|
||||
@property
|
||||
def accepted(self) -> bool:
|
||||
return self.status == SteerStatus.ACCEPTED
|
||||
|
||||
|
||||
class SteerInbox:
|
||||
"""Bounded inbox owned by one active agent run."""
|
||||
|
||||
def __init__(self, max_pending: int = 16, max_chars: int = 8000):
|
||||
self.max_pending = max(1, int(max_pending))
|
||||
self.max_chars = max(1, int(max_chars))
|
||||
self._lock = threading.Lock()
|
||||
self._pending: Deque[str] = deque()
|
||||
self._accepting = True
|
||||
|
||||
def submit(self, instruction: str) -> SteerResult:
|
||||
text = (instruction or "").strip()
|
||||
if not text or len(text) > self.max_chars:
|
||||
return SteerResult(SteerStatus.INVALID)
|
||||
with self._lock:
|
||||
if not self._accepting:
|
||||
return SteerResult(SteerStatus.CLOSING)
|
||||
if len(self._pending) >= self.max_pending:
|
||||
return SteerResult(SteerStatus.FULL)
|
||||
self._pending.append(text)
|
||||
return SteerResult(SteerStatus.ACCEPTED)
|
||||
|
||||
def drain(self) -> List[str]:
|
||||
with self._lock:
|
||||
items = list(self._pending)
|
||||
self._pending.clear()
|
||||
return items
|
||||
|
||||
def close_if_empty(self) -> bool:
|
||||
"""Atomically stop accepting when no instruction is pending.
|
||||
|
||||
This closes the race between a final empty drain and an agent run
|
||||
returning: after this method succeeds, submitters receive CLOSING.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._pending:
|
||||
return False
|
||||
self._accepting = False
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._accepting = False
|
||||
|
||||
|
||||
class SteerRegistry:
|
||||
"""Map a scoped agent/session key to its active run inboxes."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._by_session: Dict[str, Set[SteerInbox]] = {}
|
||||
|
||||
def register(self, session_id: str, inbox: Optional[SteerInbox] = None) -> SteerInbox:
|
||||
inbox = inbox or SteerInbox()
|
||||
if not session_id:
|
||||
return inbox
|
||||
with self._lock:
|
||||
self._by_session.setdefault(session_id, set()).add(inbox)
|
||||
return inbox
|
||||
|
||||
def unregister(self, session_id: str, inbox: Optional[SteerInbox]) -> None:
|
||||
if not session_id or inbox is None:
|
||||
return
|
||||
inbox.close()
|
||||
with self._lock:
|
||||
bucket = self._by_session.get(session_id)
|
||||
if bucket is None:
|
||||
return
|
||||
bucket.discard(inbox)
|
||||
if not bucket:
|
||||
self._by_session.pop(session_id, None)
|
||||
|
||||
def submit(self, session_id: str, instruction: str) -> SteerResult:
|
||||
if not (instruction or "").strip():
|
||||
return SteerResult(SteerStatus.INVALID)
|
||||
with self._lock:
|
||||
inboxes = list(self._by_session.get(session_id, ()))
|
||||
if not inboxes:
|
||||
return SteerResult(SteerStatus.INACTIVE)
|
||||
if len(inboxes) != 1:
|
||||
return SteerResult(SteerStatus.AMBIGUOUS)
|
||||
return inboxes[0].submit(instruction)
|
||||
|
||||
def active_count(self, session_id: str) -> int:
|
||||
with self._lock:
|
||||
return len(self._by_session.get(session_id, ()))
|
||||
|
||||
|
||||
_registry = SteerRegistry()
|
||||
|
||||
|
||||
def get_steer_registry() -> SteerRegistry:
|
||||
return _registry
|
||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
||||
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():
|
||||
from common.log import logger
|
||||
try:
|
||||
from agent.tools.browser.browser_tool import 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:
|
||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||
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(),
|
||||
}
|
||||
@@ -321,17 +321,32 @@ class BrowserService:
|
||||
self._context = None
|
||||
self._page = None
|
||||
|
||||
# When we drive a system Chrome/Edge, we spawn it ourselves with a
|
||||
# debugging port and attach over CDP (see chrome_launcher). This avoids
|
||||
# the macOS Automation prompt + multi-second stall that
|
||||
# chromium.launch(channel=...) incurs. Holds the child process owner.
|
||||
self._chrome_launcher = None
|
||||
# Path to the system browser executable when using system-chrome mode.
|
||||
self._system_exe: Optional[str] = None
|
||||
|
||||
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
||||
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||
# so cookies / login state survive across runs (default).
|
||||
# - fresh: classic launch + new_context, clean state every run.
|
||||
#
|
||||
# Within persistent/fresh, the actual Chromium binary is resolved by
|
||||
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
|
||||
# download) is preferred, falling back to Playwright's own downloaded
|
||||
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
|
||||
# when driving a system browser, else None (bundled Chromium).
|
||||
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
||||
persistent_flag = self._config.get("persistent", True)
|
||||
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||
if user_data_dir_cfg is None:
|
||||
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
||||
|
||||
self._channel: Optional[str] = None
|
||||
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
||||
if self._cdp_endpoint:
|
||||
self._launch_mode = "cdp"
|
||||
@@ -343,6 +358,38 @@ class BrowserService:
|
||||
self._launch_mode = "fresh"
|
||||
self._user_data_dir = ""
|
||||
|
||||
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||
#
|
||||
# For a system Chrome/Edge we DON'T use chromium.launch(channel=...):
|
||||
# that "takes over" another app and triggers the macOS Automation
|
||||
# prompt + a long stall. Instead we spawn the browser ourselves with a
|
||||
# debugging port and attach over CDP (self._launch_mode = "system-cdp").
|
||||
# `self._system_exe` is the browser executable; the persistent
|
||||
# user_data_dir keeps login state across sessions.
|
||||
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"]
|
||||
self._system_exe = engine.get("path")
|
||||
# Only switch to spawn+CDP when we actually know the exe
|
||||
# path (macOS/Windows/Linux detection returns it). Persist
|
||||
# login state in a dedicated profile dir.
|
||||
if self._system_exe:
|
||||
self._launch_mode = "system-cdp"
|
||||
if not self._user_data_dir:
|
||||
self._user_data_dir = expand_path(_DEFAULT_USER_DATA_DIR)
|
||||
logger.info(f"[Browser] Engine resolved: {engine['reason']} "
|
||||
f"(spawn+CDP={bool(self._system_exe)})")
|
||||
elif engine["mode"] == "playwright-chromium":
|
||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||
else:
|
||||
logger.info(f"[Browser] No ready engine yet: {engine['reason']}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] Engine resolution skipped: {e}")
|
||||
|
||||
# Idle auto-release
|
||||
idle_cfg = self._config.get("idle_timeout")
|
||||
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||
@@ -428,11 +475,30 @@ class BrowserService:
|
||||
|
||||
def _launch_browser(self):
|
||||
"""Launch / connect Chromium on the background thread."""
|
||||
# Point Playwright at our pinned download dir before any launch so a
|
||||
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
|
||||
try:
|
||||
from agent.tools.browser.browser_env import apply_browsers_path_env
|
||||
apply_browsers_path_env()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
|
||||
|
||||
if self._headless is None:
|
||||
headless_cfg = self._config.get("headless")
|
||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||
|
||||
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:
|
||||
launch_args.append("--no-sandbox")
|
||||
|
||||
@@ -467,6 +533,8 @@ class BrowserService:
|
||||
|
||||
if self._launch_mode == "cdp":
|
||||
self._connect_cdp(viewport)
|
||||
elif self._launch_mode == "system-cdp":
|
||||
self._launch_system_cdp(launch_args, viewport)
|
||||
elif self._launch_mode == "persistent":
|
||||
self._launch_persistent(launch_args, viewport, user_agent)
|
||||
else:
|
||||
@@ -475,12 +543,20 @@ class BrowserService:
|
||||
logger.info("[Browser] Browser ready")
|
||||
|
||||
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||
"""Classic launch: brand new Chromium with an empty context."""
|
||||
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
|
||||
self._browser = self._playwright.chromium.launch(
|
||||
headless=self._headless,
|
||||
args=launch_args,
|
||||
)
|
||||
"""Classic launch: brand new Chromium with an empty context.
|
||||
|
||||
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
|
||||
the user's installed system browser instead of its own Chromium.
|
||||
"""
|
||||
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
|
||||
launch_kwargs: Dict[str, Any] = {
|
||||
"headless": self._headless,
|
||||
"args": launch_args,
|
||||
}
|
||||
if self._channel:
|
||||
launch_kwargs["channel"] = self._channel
|
||||
self._browser = self._playwright.chromium.launch(**launch_kwargs)
|
||||
self._context = self._browser.new_context(
|
||||
viewport=viewport,
|
||||
user_agent=user_agent,
|
||||
@@ -491,18 +567,25 @@ class BrowserService:
|
||||
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||
logger.info(
|
||||
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
|
||||
f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
|
||||
f"profile={self._user_data_dir})"
|
||||
)
|
||||
persistent_kwargs: Dict[str, Any] = {
|
||||
"user_data_dir": self._user_data_dir,
|
||||
"headless": self._headless,
|
||||
"args": launch_args,
|
||||
"viewport": viewport,
|
||||
"user_agent": user_agent,
|
||||
}
|
||||
# When driving a system browser, let it use its real UA instead of the
|
||||
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
|
||||
if self._channel:
|
||||
persistent_kwargs["channel"] = self._channel
|
||||
persistent_kwargs.pop("user_agent", None)
|
||||
try:
|
||||
self._context = self._playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=self._user_data_dir,
|
||||
headless=self._headless,
|
||||
args=launch_args,
|
||||
viewport=viewport,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
|
||||
except Exception as e:
|
||||
# Profile is locked when another Chromium instance already holds it.
|
||||
msg = str(e).lower()
|
||||
@@ -520,6 +603,41 @@ class BrowserService:
|
||||
self._page = pages[0] if pages else self._context.new_page()
|
||||
self._wire_close_listeners()
|
||||
|
||||
def _launch_system_cdp(self, launch_args: List[str], viewport: Dict[str, int]):
|
||||
"""Spawn the user's system Chrome/Edge with a debugging port, attach via CDP.
|
||||
|
||||
This is the default for system browsers. Unlike launch(channel=...), it
|
||||
does not "take over" the browser app, so it avoids the macOS Automation
|
||||
prompt / long stall. Login state persists in the isolated user_data_dir.
|
||||
"""
|
||||
from agent.tools.browser.chrome_launcher import ChromeLauncher
|
||||
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
logger.info(
|
||||
f"[Browser] Launching system:{self._channel} via spawn+CDP "
|
||||
f"(headless={self._headless}, profile={self._user_data_dir})"
|
||||
)
|
||||
self._chrome_launcher = ChromeLauncher(
|
||||
executable=self._system_exe,
|
||||
user_data_dir=self._user_data_dir,
|
||||
extra_args=launch_args,
|
||||
headless=self._headless,
|
||||
)
|
||||
endpoint = self._chrome_launcher.launch()
|
||||
|
||||
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
|
||||
# The spawned Chrome opens its own default context (backed by
|
||||
# user_data_dir); reuse it so cookies / logins persist.
|
||||
contexts = self._browser.contexts
|
||||
self._context = contexts[0] if contexts else self._browser.new_context(viewport=viewport)
|
||||
pages = self._context.pages
|
||||
self._page = pages[0] if pages else self._context.new_page()
|
||||
try:
|
||||
self._page.set_viewport_size(viewport)
|
||||
except Exception:
|
||||
pass
|
||||
self._wire_close_listeners()
|
||||
|
||||
def _connect_cdp(self, viewport: Dict[str, int]):
|
||||
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
||||
endpoint = self._cdp_endpoint
|
||||
@@ -574,13 +692,27 @@ class BrowserService:
|
||||
self._cancel_idle_timer()
|
||||
|
||||
if self._launch_mode == "cdp":
|
||||
# For CDP, browser.close() only detaches the Playwright client;
|
||||
# the user's Chrome process and its tabs stay alive.
|
||||
# For external CDP, browser.close() only detaches the Playwright
|
||||
# client; the user's Chrome process and its tabs stay alive.
|
||||
try:
|
||||
if self._browser:
|
||||
self._browser.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
||||
elif self._launch_mode == "system-cdp":
|
||||
# We own the spawned Chrome: detach the CDP client, then kill the
|
||||
# process we started so it doesn't linger.
|
||||
try:
|
||||
if self._browser:
|
||||
self._browser.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] system-cdp disconnect error: {e}")
|
||||
try:
|
||||
if self._chrome_launcher:
|
||||
self._chrome_launcher.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] chrome launcher close error: {e}")
|
||||
self._chrome_launcher = None
|
||||
else:
|
||||
for obj, label in [
|
||||
(self._context, "context"),
|
||||
@@ -687,11 +819,15 @@ class BrowserService:
|
||||
except Exception as 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:
|
||||
page.wait_for_load_state("networkidle", timeout=8000)
|
||||
page.wait_for_load_state("networkidle", timeout=1500)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
try:
|
||||
title = page.title()
|
||||
|
||||
@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
|
||||
f"({ip_str}), request blocked for security"
|
||||
)
|
||||
|
||||
def _check_engine_ready(self) -> Optional[ToolResult]:
|
||||
"""Return an actionable onboarding message if no browser engine is ready.
|
||||
|
||||
Returns None when a system Chrome/Edge or a downloaded Chromium is
|
||||
available (so the tool can proceed). Otherwise returns a ToolResult with
|
||||
clear guidance so the agent asks the user to enable the browser instead
|
||||
of surfacing a raw Playwright launch error. CDP mode is exempt (the
|
||||
endpoint is external and validated at connect time).
|
||||
"""
|
||||
if self.config.get("cdp_endpoint"):
|
||||
return None
|
||||
try:
|
||||
from agent.tools.browser.browser_env import capability_summary
|
||||
summary = capability_summary(self.config)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] capability probe failed: {e}")
|
||||
return None
|
||||
|
||||
if summary.get("ready"):
|
||||
return None
|
||||
|
||||
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
|
||||
# in-chat `/install-browser` command. Source / web / server installs use
|
||||
# the `cow install-browser` terminal command.
|
||||
install_hint = (
|
||||
"reply `/install-browser`" if summary.get("is_desktop")
|
||||
else "run `cow install-browser` in a terminal"
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
|
||||
"skipped automatically if Google Chrome is already installed). "
|
||||
"Do not retry until the user confirms."
|
||||
)
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
action = args.get("action", "").strip().lower()
|
||||
if not action:
|
||||
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
|
||||
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||
|
||||
# Preflight: on desktop the playwright package is bundled but the browser
|
||||
# binary may be missing; return actionable onboarding instead of a cryptic
|
||||
# launch failure.
|
||||
not_ready = self._check_engine_ready()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
|
||||
try:
|
||||
return handler(self, args)
|
||||
except Exception as e:
|
||||
|
||||
149
agent/tools/browser/chrome_launcher.py
Normal file
149
agent/tools/browser/chrome_launcher.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Spawn a system Chrome/Edge with a DevTools debugging port for CDP control.
|
||||
|
||||
Why this exists: driving a system browser via Playwright's
|
||||
``chromium.launch(channel="chrome")`` makes the app *take over* another app's
|
||||
process, which on macOS triggers a TCC "Automation" permission prompt and a
|
||||
multi-second (sometimes 100s+) stall on first use. Launching Chrome ourselves
|
||||
with ``--remote-debugging-port`` and attaching via ``connect_over_cdp`` avoids
|
||||
that entirely — from the OS's view it's just a process listening on a local
|
||||
port — and matches how Codex / Claude Code drive the user's real browser.
|
||||
|
||||
The launched process uses an isolated ``--user-data-dir`` so it never fights
|
||||
the user's day-to-day browser profile, while still persisting login state
|
||||
across sessions inside that dir.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from typing import Optional, List
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class ChromeLauncher:
|
||||
"""Own the lifecycle of a debugging-enabled Chrome/Edge child process."""
|
||||
|
||||
def __init__(self, executable: str, user_data_dir: str,
|
||||
extra_args: Optional[List[str]] = None,
|
||||
headless: bool = False):
|
||||
self._executable = executable
|
||||
self._user_data_dir = user_data_dir
|
||||
self._extra_args = extra_args or []
|
||||
self._headless = headless
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._port: Optional[int] = None
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""CDP HTTP endpoint (only valid after a successful launch())."""
|
||||
return f"http://127.0.0.1:{self._port}" if self._port else ""
|
||||
|
||||
@staticmethod
|
||||
def _free_port() -> int:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def launch(self, ready_timeout: float = 25.0) -> str:
|
||||
"""Spawn Chrome and block until its CDP endpoint answers.
|
||||
|
||||
Returns the CDP endpoint URL. Raises RuntimeError if the endpoint never
|
||||
comes up (the child process is killed in that case).
|
||||
"""
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
self._port = self._free_port()
|
||||
|
||||
args = [
|
||||
self._executable,
|
||||
f"--remote-debugging-port={self._port}",
|
||||
f"--user-data-dir={self._user_data_dir}",
|
||||
# Trim first-run overhead and background chatter for faster starts.
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,OptimizationHints",
|
||||
# A blank first tab keeps startup cheap and predictable.
|
||||
"about:blank",
|
||||
]
|
||||
if self._headless:
|
||||
args.insert(1, "--headless=new")
|
||||
args[1:1] = self._extra_args
|
||||
|
||||
popen_kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
# Detach from any console and never flash a window on Windows.
|
||||
popen_kwargs["creationflags"] = (
|
||||
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
)
|
||||
else:
|
||||
# New session so the child isn't tied to the parent's controlling
|
||||
# terminal / process group (clean teardown, no signal bleed).
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
logger.info(f"[Browser] Spawning {os.path.basename(self._executable)} "
|
||||
f"on CDP port {self._port} (profile={self._user_data_dir})")
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**popen_kwargs,
|
||||
)
|
||||
|
||||
if not self._wait_ready(ready_timeout):
|
||||
self.close()
|
||||
raise RuntimeError(
|
||||
f"Chrome did not expose a CDP endpoint on port {self._port} "
|
||||
f"within {ready_timeout:.0f}s"
|
||||
)
|
||||
return self.endpoint
|
||||
|
||||
def _wait_ready(self, timeout: float) -> bool:
|
||||
"""Poll DevTools /json/version until Chrome is listening (or times out)."""
|
||||
deadline = time.time() + timeout
|
||||
url = f"http://127.0.0.1:{self._port}/json/version"
|
||||
while time.time() < deadline:
|
||||
# Bail out early if the process died on startup.
|
||||
if self._proc and self._proc.poll() is not None:
|
||||
logger.error(
|
||||
f"[Browser] Chrome exited early (code={self._proc.returncode}) "
|
||||
"before opening the CDP port"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def close(self):
|
||||
"""Terminate the spawned Chrome process (idempotent)."""
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
self._port = None
|
||||
if proc is None:
|
||||
return
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] error terminating Chrome process: {e}")
|
||||
@@ -13,7 +13,7 @@ from agent.tools.utils.diff import (
|
||||
detect_line_ending,
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
count_matches,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string
|
||||
)
|
||||
@@ -110,10 +110,10 @@ class Edit(BaseTool):
|
||||
"The old text must match exactly including all whitespace and newlines."
|
||||
)
|
||||
|
||||
# Calculate occurrence count (use fuzzy normalized content for consistency)
|
||||
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
||||
occurrences = fuzzy_content.count(fuzzy_old_text)
|
||||
# Count occurrences with the same matcher used to locate and
|
||||
# replace (fuzzy_find_text), so the uniqueness guard cannot
|
||||
# disagree with what actually gets replaced.
|
||||
occurrences = count_matches(normalized_content, normalized_old_text)
|
||||
|
||||
if occurrences > 1:
|
||||
return ToolResult.fail(
|
||||
|
||||
@@ -21,6 +21,48 @@ from common.log import logger
|
||||
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
|
||||
|
||||
|
||||
# Optional callback invoked after an OAuth authorization completes, so the
|
||||
# tool manager can bring the newly-authorized server online. Signature:
|
||||
# reload_fn(server_name: str) -> None. Installed by the tool manager.
|
||||
_reload_callback = None
|
||||
|
||||
|
||||
def set_reload_callback(fn) -> None:
|
||||
"""Register a callback fired after a server's OAuth flow succeeds."""
|
||||
global _reload_callback
|
||||
_reload_callback = fn
|
||||
|
||||
|
||||
def notify_server_authorized(server_name: str) -> None:
|
||||
"""Called by the web callback once tokens are stored for a server."""
|
||||
fn = _reload_callback
|
||||
if fn is None:
|
||||
logger.debug(f"[MCP:{server_name}] Authorized but no reload callback registered")
|
||||
return
|
||||
try:
|
||||
fn(server_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP:{server_name}] reload callback failed: {e}")
|
||||
|
||||
|
||||
def _oauth_redirect_uri() -> str:
|
||||
"""Build the OAuth redirect URI served by the web console callback.
|
||||
|
||||
Priority: explicit mcp_oauth_redirect_base config, otherwise the local
|
||||
web console address (127.0.0.1:<web_port>). Both point at the shared
|
||||
/mcp/oauth/callback route.
|
||||
"""
|
||||
try:
|
||||
from config import conf
|
||||
base = (conf().get("mcp_oauth_redirect_base") or "").strip().rstrip("/")
|
||||
if not base:
|
||||
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
except Exception:
|
||||
base = "http://127.0.0.1:9899"
|
||||
return f"{base}/mcp/oauth/callback"
|
||||
|
||||
|
||||
class McpClient:
|
||||
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
|
||||
|
||||
@@ -56,6 +98,13 @@ class McpClient:
|
||||
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
|
||||
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
|
||||
|
||||
# OAuth state (streamable-http only). Lazily created when the server
|
||||
# responds with 401 and the user has not supplied a static token.
|
||||
self._oauth = None # OAuthHandler instance
|
||||
# Set to True once a 401 could not be satisfied and the user must
|
||||
# complete the browser authorization. Callers can surface this state.
|
||||
self.needs_auth: bool = False
|
||||
|
||||
# Shared state
|
||||
self._next_id = 1
|
||||
self._id_lock = threading.Lock()
|
||||
@@ -325,13 +374,118 @@ class McpClient:
|
||||
if isinstance(extra_headers, dict):
|
||||
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
|
||||
|
||||
# Restore any previously stored OAuth credentials for this server so a
|
||||
# restart reuses the token instead of forcing re-authorization.
|
||||
self._maybe_load_oauth()
|
||||
|
||||
return self._handshake()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuth helpers (streamable-http only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _has_static_auth(self) -> bool:
|
||||
"""True when the user supplied their own Authorization header."""
|
||||
return any(k.lower() == "authorization" for k in self._http_headers)
|
||||
|
||||
def _maybe_load_oauth(self) -> None:
|
||||
"""Attach an OAuthHandler when stored credentials exist for this server."""
|
||||
if self._has_static_auth():
|
||||
return
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import OAuthHandler, load_server_record
|
||||
except Exception:
|
||||
return
|
||||
rec = load_server_record(self.name)
|
||||
# Only create a handler when we have something to reuse; otherwise it
|
||||
# is created lazily on the first 401.
|
||||
if rec.get("access_token") or rec.get("client_id"):
|
||||
self._oauth = OAuthHandler(
|
||||
server_name=self.name,
|
||||
resource_url=self._http_url,
|
||||
redirect_uri=_oauth_redirect_uri(),
|
||||
scope=self.config.get("scope", ""),
|
||||
)
|
||||
|
||||
def _current_bearer(self) -> Optional[str]:
|
||||
"""Return a valid access token, refreshing if needed."""
|
||||
if self._oauth is None:
|
||||
return None
|
||||
return self._oauth.get_valid_access_token()
|
||||
|
||||
def _begin_oauth(self, www_authenticate: str = "") -> None:
|
||||
"""Kick off the OAuth flow after a 401: discover, register, prompt user."""
|
||||
if self._has_static_auth():
|
||||
return
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import OAuthHandler
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP:{self.name}] OAuth module unavailable: {e}")
|
||||
return
|
||||
|
||||
if self._oauth is None:
|
||||
self._oauth = OAuthHandler(
|
||||
server_name=self.name,
|
||||
resource_url=self._http_url,
|
||||
redirect_uri=_oauth_redirect_uri(),
|
||||
scope=self.config.get("scope", ""),
|
||||
)
|
||||
|
||||
if not self._oauth.ensure_registered(www_authenticate):
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] OAuth discovery/registration failed; "
|
||||
f"cannot authorize automatically"
|
||||
)
|
||||
return
|
||||
|
||||
auth_url = self._oauth.build_authorization_url()
|
||||
if not auth_url:
|
||||
logger.warning(f"[MCP:{self.name}] Failed to build authorization URL")
|
||||
return
|
||||
|
||||
self.needs_auth = True
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] ⚠️ Authorization required. Open this URL in a "
|
||||
f"browser to authorize, then this server will come online automatically:\n"
|
||||
f" {auth_url}"
|
||||
)
|
||||
# On a machine with a local browser (desktop/dev), open it directly.
|
||||
if os.environ.get("COW_DESKTOP") == "1" or not os.environ.get("COW_HEADLESS"):
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(auth_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _streamable_http_send(self, message: dict) -> dict:
|
||||
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
|
||||
return self._streamable_http_post(message, expect_response=True)
|
||||
|
||||
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict:
|
||||
def _handle_401(self, err, message: dict, expect_response: bool, retried: bool) -> dict:
|
||||
"""Handle a 401: refresh the token and retry once, else begin OAuth."""
|
||||
www_auth = ""
|
||||
try:
|
||||
www_auth = err.headers.get("WWW-Authenticate", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
err.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First try a silent refresh with the stored refresh token.
|
||||
if not retried and self._oauth is not None and self._oauth.refresh():
|
||||
logger.info(f"[MCP:{self.name}] Token refreshed after 401, retrying")
|
||||
return self._streamable_http_post(message, expect_response, _retried=True)
|
||||
|
||||
# No usable token — start (or restart) the interactive OAuth flow.
|
||||
self._begin_oauth(www_auth)
|
||||
raise IOError(
|
||||
f"[MCP:{self.name}] streamable-http HTTP 401: authorization required "
|
||||
f"(complete the OAuth flow to enable this server)"
|
||||
)
|
||||
|
||||
def _streamable_http_post(self, message: dict, expect_response: bool, _retried: bool = False) -> dict:
|
||||
"""
|
||||
POST a JSON-RPC message over Streamable HTTP.
|
||||
|
||||
@@ -351,6 +505,12 @@ class McpClient:
|
||||
if sid:
|
||||
headers["Mcp-Session-Id"] = sid
|
||||
headers.update(self._http_headers)
|
||||
# Inject OAuth bearer token when we have one (unless the user set a
|
||||
# static Authorization header, which takes precedence).
|
||||
if not self._has_static_auth():
|
||||
token = self._current_bearer()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
self._http_url,
|
||||
@@ -362,6 +522,9 @@ class McpClient:
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
except urllib.error.HTTPError as e:
|
||||
# 401 is the spec-compliant "needs authorization" signal.
|
||||
if e.code == 401 and not self._has_static_auth():
|
||||
return self._handle_401(e, message, expect_response, _retried)
|
||||
# Surface the server-provided error body for easier debugging
|
||||
detail = ""
|
||||
try:
|
||||
|
||||
466
agent/tools/mcp/mcp_oauth.py
Normal file
466
agent/tools/mcp/mcp_oauth.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
MCP OAuth 2.1 client (authorization code + PKCE) with zero external deps.
|
||||
|
||||
Implements the subset of the MCP authorization spec needed to connect to
|
||||
remote MCP servers that guard their endpoint behind OAuth (e.g. Xmind):
|
||||
|
||||
1. Metadata discovery via RFC 9728 (protected-resource) + RFC 8414
|
||||
(authorization-server) .well-known documents.
|
||||
2. Dynamic Client Registration (RFC 7591) to obtain a client_id.
|
||||
3. PKCE (RFC 7636, S256) authorization-code flow.
|
||||
4. Token exchange + refresh, persisted to ~/.cow/mcp_oauth.json.
|
||||
|
||||
The actual browser round-trip is completed out-of-band: McpClient generates
|
||||
an authorization URL, the user opens it, and the web console callback
|
||||
(/mcp/oauth/callback) feeds the returned code back into finish_authorization().
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token store: ~/.cow/mcp_oauth.json {server_name: {...credentials...}}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _store_path() -> str:
|
||||
base = os.path.expanduser("~/.cow")
|
||||
try:
|
||||
os.makedirs(base, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return os.path.join(base, "mcp_oauth.json")
|
||||
|
||||
|
||||
def _load_store() -> dict:
|
||||
path = _store_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] Failed to read token store: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_store(store: dict) -> None:
|
||||
path = _store_path()
|
||||
tmp = f"{path}.tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(store, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
# Credentials file: restrict to owner read/write when possible.
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] Failed to persist token store: {e}")
|
||||
|
||||
|
||||
def load_server_record(server_name: str) -> dict:
|
||||
with _STORE_LOCK:
|
||||
return dict(_load_store().get(server_name, {}))
|
||||
|
||||
|
||||
def save_server_record(server_name: str, record: dict) -> None:
|
||||
with _STORE_LOCK:
|
||||
store = _load_store()
|
||||
store[server_name] = record
|
||||
_save_store(store)
|
||||
|
||||
|
||||
def clear_server_record(server_name: str) -> None:
|
||||
with _STORE_LOCK:
|
||||
store = _load_store()
|
||||
if server_name in store:
|
||||
store.pop(server_name, None)
|
||||
_save_store(store)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pending authorizations, keyed by the OAuth `state` param.
|
||||
# Populated when an authorization URL is generated; consumed by the
|
||||
# web callback when the browser redirects back with ?code&state.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_PENDING_LOCK = threading.Lock()
|
||||
_PENDING: dict = {} # state -> {"handler": OAuthHandler, "created": ts}
|
||||
_PENDING_TTL = 600 # seconds
|
||||
|
||||
|
||||
def _register_pending(state: str, handler: "OAuthHandler") -> None:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
_PENDING[state] = {"handler": handler, "created": time.time()}
|
||||
|
||||
|
||||
def _prune_pending_locked() -> None:
|
||||
now = time.time()
|
||||
stale = [s for s, v in _PENDING.items() if now - v["created"] > _PENDING_TTL]
|
||||
for s in stale:
|
||||
_PENDING.pop(s, None)
|
||||
|
||||
|
||||
def pop_pending(state: str) -> Optional["OAuthHandler"]:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
entry = _PENDING.pop(state, None)
|
||||
return entry["handler"] if entry else None
|
||||
|
||||
|
||||
def has_pending() -> bool:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
return bool(_PENDING)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers (stdlib only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_UA = "CowAgent-MCP-OAuth/1.0"
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: int = 15) -> Optional[dict]:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": _UA})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.debug(f"[MCP-OAuth] GET {url} -> HTTP {e.code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[MCP-OAuth] GET {url} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _http_post_form(url: str, fields: dict, timeout: int = 20) -> dict:
|
||||
body = urllib.parse.urlencode(fields).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _UA,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict, timeout: int = 20) -> dict:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _UA,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery (RFC 9728 + RFC 8414)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _origin(url: str) -> str:
|
||||
p = urllib.parse.urlparse(url)
|
||||
return f"{p.scheme}://{p.netloc}"
|
||||
|
||||
|
||||
def discover_metadata(resource_url: str, www_authenticate: str = "") -> Optional[dict]:
|
||||
"""
|
||||
Resolve the authorization server metadata for a protected MCP resource.
|
||||
|
||||
Returns a dict with at least authorization_endpoint + token_endpoint,
|
||||
plus registration_endpoint when the server supports DCR. Returns None
|
||||
when discovery fails.
|
||||
"""
|
||||
as_metadata_url = _parse_resource_metadata_url(www_authenticate)
|
||||
|
||||
# 1) Protected-resource metadata (RFC 9728) to locate the auth server.
|
||||
auth_server = None
|
||||
prm = None
|
||||
if as_metadata_url:
|
||||
prm = _http_get_json(as_metadata_url)
|
||||
if prm is None:
|
||||
origin = _origin(resource_url)
|
||||
prm = _http_get_json(f"{origin}/.well-known/oauth-protected-resource")
|
||||
if prm and isinstance(prm.get("authorization_servers"), list) and prm["authorization_servers"]:
|
||||
auth_server = prm["authorization_servers"][0]
|
||||
|
||||
# 2) Authorization-server metadata (RFC 8414). Fall back to the resource
|
||||
# origin when the resource did not advertise a separate auth server.
|
||||
base = auth_server or _origin(resource_url)
|
||||
asm = _fetch_as_metadata(base)
|
||||
if not asm:
|
||||
return None
|
||||
|
||||
if not asm.get("authorization_endpoint") or not asm.get("token_endpoint"):
|
||||
logger.warning("[MCP-OAuth] Authorization server metadata missing required endpoints")
|
||||
return None
|
||||
|
||||
# Derive the scope to request. Prefer the resource's required_scopes
|
||||
# (RFC 9728), then its scopes_supported, then the auth server's
|
||||
# scopes_supported. Stored so callers don't have to configure it.
|
||||
discovered_scope = ""
|
||||
if prm:
|
||||
scopes = prm.get("required_scopes") or prm.get("scopes_supported")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
discovered_scope = " ".join(str(s) for s in scopes)
|
||||
if not discovered_scope and isinstance(asm.get("scopes_supported"), list) and asm["scopes_supported"]:
|
||||
discovered_scope = " ".join(str(s) for s in asm["scopes_supported"])
|
||||
if discovered_scope:
|
||||
asm["_discovered_scope"] = discovered_scope
|
||||
return asm
|
||||
|
||||
|
||||
def _parse_resource_metadata_url(www_authenticate: str) -> Optional[str]:
|
||||
"""Extract resource_metadata="..." from a WWW-Authenticate: Bearer header."""
|
||||
if not www_authenticate:
|
||||
return None
|
||||
# naive but sufficient parse for `resource_metadata="URL"`
|
||||
marker = "resource_metadata="
|
||||
idx = www_authenticate.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
rest = www_authenticate[idx + len(marker):].strip()
|
||||
if rest.startswith('"'):
|
||||
end = rest.find('"', 1)
|
||||
return rest[1:end] if end > 0 else None
|
||||
# unquoted, up to comma/space
|
||||
for sep in (",", " "):
|
||||
if sep in rest:
|
||||
rest = rest.split(sep, 1)[0]
|
||||
return rest or None
|
||||
|
||||
|
||||
def _fetch_as_metadata(base: str) -> Optional[dict]:
|
||||
"""Try both RFC 8414 and OIDC well-known locations."""
|
||||
base = base.rstrip("/")
|
||||
candidates = [
|
||||
f"{base}/.well-known/oauth-authorization-server",
|
||||
f"{base}/.well-known/openid-configuration",
|
||||
]
|
||||
for url in candidates:
|
||||
data = _http_get_json(url)
|
||||
if data and data.get("authorization_endpoint"):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PKCE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _make_pkce() -> tuple:
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuthHandler: per-server OAuth state machine
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class OAuthHandler:
|
||||
"""Drives the OAuth flow and token lifecycle for a single MCP server."""
|
||||
|
||||
def __init__(self, server_name: str, resource_url: str, redirect_uri: str,
|
||||
scope: str = "", client_name: str = "CowAgent"):
|
||||
self.server_name = server_name
|
||||
self.resource_url = resource_url
|
||||
self.redirect_uri = redirect_uri
|
||||
self.scope = scope
|
||||
self.client_name = client_name
|
||||
|
||||
rec = load_server_record(server_name)
|
||||
self.metadata: dict = rec.get("metadata", {})
|
||||
self.client_id: Optional[str] = rec.get("client_id")
|
||||
self.client_secret: Optional[str] = rec.get("client_secret")
|
||||
self.access_token: Optional[str] = rec.get("access_token")
|
||||
self.refresh_token: Optional[str] = rec.get("refresh_token")
|
||||
self.expires_at: float = float(rec.get("expires_at", 0) or 0)
|
||||
self._verifier: Optional[str] = None
|
||||
|
||||
# --- persistence -------------------------------------------------
|
||||
|
||||
def _persist(self) -> None:
|
||||
save_server_record(self.server_name, {
|
||||
"resource_url": self.resource_url,
|
||||
"metadata": self.metadata,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"access_token": self.access_token,
|
||||
"refresh_token": self.refresh_token,
|
||||
"expires_at": self.expires_at,
|
||||
})
|
||||
|
||||
# --- token access ------------------------------------------------
|
||||
|
||||
def get_valid_access_token(self, leeway: int = 60) -> Optional[str]:
|
||||
"""Return a usable access token, refreshing proactively when near expiry."""
|
||||
if not self.access_token:
|
||||
return None
|
||||
if self.expires_at and time.time() >= self.expires_at - leeway:
|
||||
if not self.refresh():
|
||||
return None
|
||||
return self.access_token
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Refresh the access token using the stored refresh token."""
|
||||
if not self.refresh_token or not self.metadata.get("token_endpoint"):
|
||||
return False
|
||||
fields = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"client_id": self.client_id or "",
|
||||
}
|
||||
if self.client_secret:
|
||||
fields["client_secret"] = self.client_secret
|
||||
try:
|
||||
resp = _http_post_form(self.metadata["token_endpoint"], fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] refresh failed: {e}")
|
||||
return False
|
||||
return self._absorb_token_response(resp)
|
||||
|
||||
# --- authorization-code flow ------------------------------------
|
||||
|
||||
def ensure_registered(self, www_authenticate: str = "") -> bool:
|
||||
"""Discover metadata + register a client if not already done."""
|
||||
if not self.metadata.get("authorization_endpoint"):
|
||||
meta = discover_metadata(self.resource_url, www_authenticate)
|
||||
if not meta:
|
||||
return False
|
||||
self.metadata = meta
|
||||
# Adopt the scope discovered from metadata when the user didn't set one.
|
||||
if not self.scope and self.metadata.get("_discovered_scope"):
|
||||
self.scope = self.metadata["_discovered_scope"]
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Using discovered scope: {self.scope}")
|
||||
if not self.client_id:
|
||||
if not self._register_client():
|
||||
return False
|
||||
self._persist()
|
||||
return True
|
||||
|
||||
def _register_client(self) -> bool:
|
||||
reg_endpoint = self.metadata.get("registration_endpoint")
|
||||
if not reg_endpoint:
|
||||
logger.warning(
|
||||
f"[MCP-OAuth:{self.server_name}] No registration_endpoint; "
|
||||
f"DCR unavailable. Provide client_id manually."
|
||||
)
|
||||
return False
|
||||
payload = {
|
||||
"client_name": self.client_name,
|
||||
"redirect_uris": [self.redirect_uri],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
}
|
||||
if self.scope:
|
||||
payload["scope"] = self.scope
|
||||
try:
|
||||
resp = _http_post_json(reg_endpoint, payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR failed: {e}")
|
||||
return False
|
||||
client_id = resp.get("client_id")
|
||||
if not client_id:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR returned no client_id")
|
||||
return False
|
||||
self.client_id = client_id
|
||||
self.client_secret = resp.get("client_secret")
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Registered client_id={client_id}")
|
||||
return True
|
||||
|
||||
def build_authorization_url(self) -> Optional[str]:
|
||||
"""Create an authorization URL and register this handler as pending."""
|
||||
if not self.metadata.get("authorization_endpoint") or not self.client_id:
|
||||
return None
|
||||
self._verifier, challenge = _make_pkce()
|
||||
state = secrets.token_urlsafe(24)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
if self.scope:
|
||||
params["scope"] = self.scope
|
||||
# Advertise the resource we intend to access (RFC 8707).
|
||||
params["resource"] = self.resource_url
|
||||
_register_pending(state, self)
|
||||
return f"{self.metadata['authorization_endpoint']}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def finish_authorization(self, code: str) -> bool:
|
||||
"""Exchange an authorization code for tokens."""
|
||||
if not self.metadata.get("token_endpoint") or not self._verifier:
|
||||
return False
|
||||
fields = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.client_id or "",
|
||||
"code_verifier": self._verifier,
|
||||
"resource": self.resource_url,
|
||||
}
|
||||
if self.client_secret:
|
||||
fields["client_secret"] = self.client_secret
|
||||
try:
|
||||
resp = _http_post_form(self.metadata["token_endpoint"], fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] token exchange failed: {e}")
|
||||
return False
|
||||
ok = self._absorb_token_response(resp)
|
||||
self._verifier = None
|
||||
return ok
|
||||
|
||||
def _absorb_token_response(self, resp: dict) -> bool:
|
||||
access = resp.get("access_token")
|
||||
if not access:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] token response missing access_token: {resp}")
|
||||
return False
|
||||
self.access_token = access
|
||||
if resp.get("refresh_token"):
|
||||
self.refresh_token = resp["refresh_token"]
|
||||
expires_in = resp.get("expires_in")
|
||||
self.expires_at = time.time() + int(expires_in) if expires_in else 0
|
||||
self._persist()
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Access token stored")
|
||||
return True
|
||||
@@ -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")
|
||||
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
|
||||
channel = create_channel(channel_type)
|
||||
if not channel:
|
||||
|
||||
@@ -41,6 +41,8 @@ class SchedulerService:
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self._lock = threading.Lock()
|
||||
self._execution_lock = threading.Lock()
|
||||
self._active_task_ids = set()
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler service"""
|
||||
@@ -85,7 +87,15 @@ class SchedulerService:
|
||||
try:
|
||||
if self._is_task_due(task, now):
|
||||
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
||||
ok = self._execute_task(task)
|
||||
if not self._claim_task(task['id']):
|
||||
logger.info(
|
||||
f"[Scheduler] Task {task['id']} is already running; skipping this tick"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
ok = self._execute_task(task)
|
||||
finally:
|
||||
self._release_task(task['id'])
|
||||
if not ok:
|
||||
# Leave next_run_at as-is so the next loop retries.
|
||||
# Cron tasks within the catch-up window will keep
|
||||
@@ -107,6 +117,57 @@ class SchedulerService:
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
||||
|
||||
def run_task_now(self, task_id: str) -> None:
|
||||
"""Queue one immediate execution without changing the task schedule.
|
||||
|
||||
Disabled and one-time tasks may be run manually for testing. The
|
||||
stored ``next_run_at`` remains unchanged, so a manual run never
|
||||
consumes or delays the next scheduled occurrence.
|
||||
|
||||
Raises:
|
||||
ValueError: if the task does not exist.
|
||||
RuntimeError: if the same task is already executing.
|
||||
"""
|
||||
task = self.task_store.get_task(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task '{task_id}' not found")
|
||||
if not self._claim_task(task_id):
|
||||
raise RuntimeError(f"Task '{task_id}' is already running")
|
||||
|
||||
def _run():
|
||||
now = datetime.now()
|
||||
try:
|
||||
logger.info(f"[Scheduler] Manually executing task: {task_id} - {task.get('name', '')}")
|
||||
ok = self._execute_task(task)
|
||||
if ok:
|
||||
self.task_store.update_task(task_id, {
|
||||
"last_run_at": now.isoformat(),
|
||||
"last_manual_run_at": now.isoformat(),
|
||||
})
|
||||
logger.info(f"[Scheduler] Manual execution completed: {task_id}")
|
||||
else:
|
||||
logger.warning(f"[Scheduler] Manual execution failed: {task_id}")
|
||||
finally:
|
||||
self._release_task(task_id)
|
||||
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name=f"scheduler-manual-{task_id}",
|
||||
).start()
|
||||
|
||||
def _claim_task(self, task_id: str) -> bool:
|
||||
"""Prevent scheduled and manual runs of the same task from overlapping."""
|
||||
with self._execution_lock:
|
||||
if task_id in self._active_task_ids:
|
||||
return False
|
||||
self._active_task_ids.add(task_id)
|
||||
return True
|
||||
|
||||
def _release_task(self, task_id: str) -> None:
|
||||
with self._execution_lock:
|
||||
self._active_task_ids.discard(task_id)
|
||||
|
||||
def _is_task_due(self, task: dict, now: datetime) -> bool:
|
||||
"""
|
||||
Check if a task is due to run
|
||||
|
||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
||||
"schedule_value": {
|
||||
"type": "string",
|
||||
"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"]
|
||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
||||
"channel_type": self.config.get("channel_type", "unknown"),
|
||||
"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
|
||||
msg = context.kwargs.get("msg")
|
||||
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
|
||||
else:
|
||||
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 (
|
||||
f"✅ 定时任务创建成功\n\n"
|
||||
f"📋 任务ID: {task_id}\n"
|
||||
f"📝 名称: {name}\n"
|
||||
f"⏰ 调度: {schedule_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 '未知'}"
|
||||
)
|
||||
|
||||
|
||||
@@ -152,14 +152,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" 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:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -222,14 +215,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" 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:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -261,14 +247,7 @@ class ToolManager:
|
||||
# If there are missing tools, record warnings
|
||||
if missing_tools:
|
||||
for tool_name in missing_tools:
|
||||
if tool_name == "browser":
|
||||
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":
|
||||
if tool_name == "google_search":
|
||||
logger.warning(
|
||||
f"[ToolManager] Google Search tool is configured but may need API key.\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.
|
||||
"""
|
||||
try:
|
||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
|
||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry, set_reload_callback
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
|
||||
registry = McpClientRegistry()
|
||||
self._mcp_registry = registry
|
||||
# Let the OAuth web callback bring a server online once authorized.
|
||||
set_reload_callback(self.reload_mcp_server)
|
||||
|
||||
for cfg in mcp_servers_config:
|
||||
server_name = cfg.get("name", "<unnamed>")
|
||||
try:
|
||||
client = McpClient(cfg)
|
||||
if not client.initialize():
|
||||
self._mcp_status[server_name] = "failed"
|
||||
logger.warning(
|
||||
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||
)
|
||||
if getattr(client, "needs_auth", False):
|
||||
self._mcp_status[server_name] = "needs_auth"
|
||||
logger.info(
|
||||
f"[MCP] Server '{server_name}' needs authorization — "
|
||||
f"waiting for the user to complete the OAuth flow"
|
||||
)
|
||||
else:
|
||||
self._mcp_status[server_name] = "failed"
|
||||
logger.warning(
|
||||
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
tool_schemas = client.list_tools()
|
||||
@@ -518,6 +506,28 @@ class ToolManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
|
||||
|
||||
def reload_mcp_server(self, server_name: str) -> None:
|
||||
"""Re-initialize a single MCP server (e.g. after OAuth authorization).
|
||||
|
||||
Tears down any existing client for the server and starts it again in
|
||||
the background, so a freshly-stored access token is picked up and the
|
||||
server's tools become available on the next message.
|
||||
"""
|
||||
with self._mcp_lock:
|
||||
cfg = self._mcp_active_configs.get(server_name)
|
||||
if not cfg:
|
||||
logger.warning(f"[MCP] reload requested for unknown server '{server_name}'")
|
||||
return
|
||||
logger.info(f"[MCP] Reloading server '{server_name}' after authorization")
|
||||
self._teardown_mcp_server(server_name)
|
||||
self._mcp_status[server_name] = "pending"
|
||||
threading.Thread(
|
||||
target=self._load_mcp_tools_async,
|
||||
args=([cfg],),
|
||||
daemon=True,
|
||||
name=f"mcp-reload-{server_name}",
|
||||
).start()
|
||||
|
||||
def list_mcp_status(self) -> dict:
|
||||
"""Return {server_name: status} snapshot for UI / debugging."""
|
||||
return dict(self._mcp_status)
|
||||
|
||||
@@ -15,6 +15,7 @@ from .diff import (
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
count_matches,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string,
|
||||
FuzzyMatchResult
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
'normalize_to_lf',
|
||||
'restore_line_endings',
|
||||
'normalize_for_fuzzy_match',
|
||||
'count_matches',
|
||||
'fuzzy_find_text',
|
||||
'generate_diff_string',
|
||||
'FuzzyMatchResult',
|
||||
|
||||
@@ -93,6 +93,40 @@ class FuzzyMatchResult:
|
||||
self.content_for_replacement = content_for_replacement
|
||||
|
||||
|
||||
def _build_fuzzy_pattern(old_text: str) -> Optional[str]:
|
||||
"""
|
||||
Build the whitespace-flexible regex used to locate ``old_text`` fuzzily.
|
||||
|
||||
Returns ``None`` when ``old_text`` has no non-whitespace content to match.
|
||||
This is the single source of truth for fuzzy matching, so that *finding* a
|
||||
match (:func:`fuzzy_find_text`) and *counting* occurrences
|
||||
(:func:`count_matches`) always use the exact same rules.
|
||||
"""
|
||||
stripped = old_text.strip('\n')
|
||||
if not stripped.strip():
|
||||
return None
|
||||
|
||||
source_lines = stripped.split('\n')
|
||||
line_patterns = []
|
||||
for i, line in enumerate(source_lines):
|
||||
tokens = line.split()
|
||||
if not tokens:
|
||||
line_patterns.append(r'[ \t]*')
|
||||
continue
|
||||
# Tolerate any run of blanks between tokens.
|
||||
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||
# First-line leading whitespace is folded into the match only when
|
||||
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||
# the file's existing indentation -- mirroring an exact substring
|
||||
# match. Inner lines always tolerate indentation: it sits inside the
|
||||
# matched region and is re-supplied by new_text.
|
||||
if i > 0 or line[:1] in (' ', '\t'):
|
||||
core = r'[ \t]*' + core
|
||||
line_patterns.append(core + r'[ \t]*')
|
||||
return '\n'.join(line_patterns)
|
||||
|
||||
|
||||
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
"""
|
||||
Find text in content, try exact match first, then fuzzy match
|
||||
@@ -121,27 +155,8 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
# doing so previously returned the normalized copy as
|
||||
# content_for_replacement, which caused the whole file to be rewritten
|
||||
# with collapsed indentation (every untouched line got reformatted).
|
||||
stripped = old_text.strip('\n')
|
||||
if stripped.strip():
|
||||
source_lines = stripped.split('\n')
|
||||
line_patterns = []
|
||||
for i, line in enumerate(source_lines):
|
||||
tokens = line.split()
|
||||
if not tokens:
|
||||
line_patterns.append(r'[ \t]*')
|
||||
continue
|
||||
# Tolerate any run of blanks between tokens.
|
||||
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||
# First-line leading whitespace is folded into the match only when
|
||||
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||
# the file's existing indentation -- mirroring an exact substring
|
||||
# match. Inner lines always tolerate indentation: it sits inside the
|
||||
# matched region and is re-supplied by new_text.
|
||||
if i > 0 or line[:1] in (' ', '\t'):
|
||||
core = r'[ \t]*' + core
|
||||
line_patterns.append(core + r'[ \t]*')
|
||||
pattern = '\n'.join(line_patterns)
|
||||
pattern = _build_fuzzy_pattern(old_text)
|
||||
if pattern is not None:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
return FuzzyMatchResult(
|
||||
@@ -155,6 +170,28 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
return FuzzyMatchResult(found=False)
|
||||
|
||||
|
||||
def count_matches(content: str, old_text: str) -> int:
|
||||
"""
|
||||
Count occurrences of ``old_text`` using the SAME strategy as
|
||||
:func:`fuzzy_find_text`: an exact substring when one is present, otherwise
|
||||
the whitespace-flexible fuzzy regex.
|
||||
|
||||
The edit tool's uniqueness guard must agree with the matcher that actually
|
||||
performs the replacement. Counting through a separate normalization pass
|
||||
(the previous approach) could disagree with the regex used to locate and
|
||||
replace, so both paths now share :func:`_build_fuzzy_pattern`.
|
||||
"""
|
||||
if not old_text:
|
||||
return 0
|
||||
# Mirror fuzzy_find_text: prefer exact matching when it applies.
|
||||
if content.find(old_text) != -1:
|
||||
return content.count(old_text)
|
||||
pattern = _build_fuzzy_pattern(old_text)
|
||||
if pattern is None:
|
||||
return 0
|
||||
return len(re.findall(pattern, content))
|
||||
|
||||
|
||||
def generate_diff_string(old_content: str, new_content: str) -> dict:
|
||||
"""
|
||||
Generate unified diff string
|
||||
|
||||
@@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
|
||||
from agent.protocol import (
|
||||
Agent,
|
||||
LLMModel,
|
||||
LLMRequest,
|
||||
get_cancel_registry,
|
||||
get_steer_registry,
|
||||
)
|
||||
from bridge.agent_event_handler import AgentEventHandler
|
||||
from bridge.agent_initializer import AgentInitializer
|
||||
from bridge.bridge import Bridge
|
||||
@@ -360,6 +366,11 @@ class AgentBridge:
|
||||
|
||||
return agent
|
||||
|
||||
def steer_session(self, session_id: str, instruction: str):
|
||||
"""Inject an explicit instruction into one active session."""
|
||||
logger.info(f"[AgentBridge] steer new instruction: session={session_id}, content={instruction}")
|
||||
return get_steer_registry().submit(session_id, instruction)
|
||||
|
||||
def get_agent(self, session_id: str = None) -> Optional[Agent]:
|
||||
"""
|
||||
Get agent instance for the given session
|
||||
@@ -452,6 +463,8 @@ class AgentBridge:
|
||||
agent = None
|
||||
request_id = None
|
||||
cancel_event = None
|
||||
token_key = None
|
||||
steer_inbox = None
|
||||
try:
|
||||
# Extract session_id from context for user isolation
|
||||
if context:
|
||||
@@ -534,12 +547,15 @@ class AgentBridge:
|
||||
pass
|
||||
|
||||
try:
|
||||
if session_id:
|
||||
steer_inbox = get_steer_registry().register(session_id)
|
||||
# Use agent's run_stream method with event handler
|
||||
response = agent.run_stream(
|
||||
user_message=query,
|
||||
on_event=event_handler.handle_event,
|
||||
clear_history=clear_history,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
finally:
|
||||
# Clear the mid-run flag so idle scans can review this session.
|
||||
@@ -562,6 +578,8 @@ class AgentBridge:
|
||||
registry.unregister(token_key)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
get_steer_registry().unregister(session_id, steer_inbox)
|
||||
|
||||
# Persist new messages generated during this run
|
||||
if session_id:
|
||||
@@ -643,6 +661,11 @@ class AgentBridge:
|
||||
get_cancel_registry().unregister(request_id or session_id)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
try:
|
||||
get_steer_registry().unregister(session_id, steer_inbox)
|
||||
except Exception:
|
||||
pass
|
||||
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||||
|
||||
def _schedule_mcp_hot_reload(self, agent):
|
||||
|
||||
@@ -453,6 +453,10 @@ class ChatChannel(Channel):
|
||||
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||
self._handle_cancel_command(context, session_id)
|
||||
return
|
||||
if re.match(r"^/steer(?:\s|$)", stripped):
|
||||
instruction = context.content.strip()[len("/steer"):].strip()
|
||||
self._handle_steer_command(context, session_id, instruction)
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
|
||||
except Exception as e:
|
||||
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
||||
|
||||
def _handle_steer_command(
|
||||
self,
|
||||
context: Context,
|
||||
session_id: str,
|
||||
instruction: str,
|
||||
) -> None:
|
||||
"""Send explicit guidance to the active run without queueing it."""
|
||||
try:
|
||||
from agent.protocol import SteerStatus
|
||||
from bridge.bridge import Bridge
|
||||
|
||||
result = Bridge().get_agent_bridge().steer_session(session_id, instruction)
|
||||
messages = {
|
||||
SteerStatus.ACCEPTED: _t(
|
||||
"↪️ 已引导当前任务。", "↪️ Active task redirected."
|
||||
),
|
||||
SteerStatus.INACTIVE: _t(
|
||||
"当前没有可引导的任务。", "No active task to steer."
|
||||
),
|
||||
SteerStatus.CLOSING: _t(
|
||||
"当前任务已结束,无法再引导。", "The active task is already finishing."
|
||||
),
|
||||
SteerStatus.AMBIGUOUS: _t(
|
||||
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||
),
|
||||
SteerStatus.FULL: _t(
|
||||
"引导指令过多,请等待当前任务处理后再试。",
|
||||
"Too many steering updates are pending; try again after the agent processes them.",
|
||||
),
|
||||
SteerStatus.INVALID: _t(
|
||||
"用法:/steer <引导指令>", "Usage: /steer <instruction>"
|
||||
),
|
||||
}
|
||||
text = messages[result.status]
|
||||
logger.info(
|
||||
f"[chat_channel] /steer fast-path: session={session_id}, "
|
||||
f"status={result.status.value}"
|
||||
)
|
||||
self._send_reply(context, Reply(ReplyType.TEXT, text))
|
||||
except Exception as e:
|
||||
logger.warning(f"[chat_channel] /steer fast-path failed: {e}")
|
||||
|
||||
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
||||
def consume(self):
|
||||
while True:
|
||||
@@ -515,6 +562,42 @@ class ChatChannel(Channel):
|
||||
semaphore.release()
|
||||
time.sleep(0.2)
|
||||
|
||||
def cancel_message(self, session_id: str, message_id: str):
|
||||
"""Cancel one channel message without disturbing later queued work.
|
||||
|
||||
Queued contexts are matched by their original channel message ID. An
|
||||
in-flight agent run is cancelled through the per-request token that the
|
||||
channel placed on the context before dispatch.
|
||||
"""
|
||||
removed = 0
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
if session is not None:
|
||||
context_queue = session[0]
|
||||
kept = []
|
||||
for _ in range(context_queue.qsize()):
|
||||
context = context_queue.get_nowait()
|
||||
context_queue.task_done()
|
||||
message = context.get("msg") if context is not None else None
|
||||
if getattr(message, "msg_id", None) == message_id:
|
||||
removed += 1
|
||||
else:
|
||||
kept.append(context)
|
||||
for context in kept:
|
||||
context_queue.put(context)
|
||||
|
||||
from agent.protocol import get_cancel_registry
|
||||
|
||||
active = get_cancel_registry().cancel_request(message_id)
|
||||
logger.info(
|
||||
"[chat_channel] message recall: session=%s, message=%s, queued=%s, active=%s",
|
||||
session_id,
|
||||
message_id,
|
||||
removed,
|
||||
active,
|
||||
)
|
||||
return removed, active
|
||||
|
||||
# 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
||||
def cancel_session(self, session_id):
|
||||
with self.lock:
|
||||
|
||||
@@ -63,7 +63,7 @@ python3 app.py
|
||||
2. 进入应用详情 -> 事件订阅
|
||||
3. 选择 **将事件发送至开发者服务器**
|
||||
4. 填写请求地址: `http://your-domain:9891/`
|
||||
5. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
||||
5. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||
6. 保存配置
|
||||
|
||||
### 4. 注意事项
|
||||
@@ -101,7 +101,7 @@ python3 app.py
|
||||
1. 登录[飞书开放平台](https://open.feishu.cn/)
|
||||
2. 进入应用详情 -> 事件订阅
|
||||
3. 选择 **使用长连接接收事件**
|
||||
4. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
||||
4. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||
5. 保存配置
|
||||
|
||||
### 5. 注意事项
|
||||
@@ -168,7 +168,7 @@ Address already in use
|
||||
### 收不到消息
|
||||
|
||||
1. 检查飞书应用的事件订阅配置
|
||||
2. 确认已添加 `im.message.receive_v1` 事件
|
||||
2. 确认已添加 `im.message.receive_v1` 和 `im.message.recalled_v1` 事件
|
||||
3. 检查应用权限: 需要 `im:message` 权限
|
||||
4. 查看日志中的错误信息
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.feishu.feishu_message import FeishuMessage
|
||||
from channel.feishu.feishu_static_card import (
|
||||
build_text_delivery,
|
||||
resolve_markdown_images,
|
||||
upload_public_image_to_feishu,
|
||||
)
|
||||
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||
from channel.feishu.feishu_scheduler_card import (
|
||||
build_scheduler_card,
|
||||
handle_scheduler_action,
|
||||
tasks_for_receivers,
|
||||
)
|
||||
from common import utils
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
@@ -240,6 +251,8 @@ class FeiShuChanel(ChatChannel):
|
||||
super().__init__()
|
||||
# 历史消息id暂存,用于幂等控制
|
||||
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
||||
# Route recall events back to the session that accepted the message.
|
||||
self._message_sessions = ExpiredDict(60 * 60 * 7.1)
|
||||
self._http_server = None
|
||||
self._ws_client = None
|
||||
self._ws_thread = None
|
||||
@@ -350,6 +363,10 @@ class FeiShuChanel(ChatChannel):
|
||||
def _startup_websocket(self):
|
||||
"""启动长连接接收事件(websocket模式)"""
|
||||
_ensure_lark_imported()
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||
|
||||
# 创建事件处理器
|
||||
@@ -372,10 +389,40 @@ class FeiShuChanel(ChatChannel):
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
||||
|
||||
def handle_message_recalled_event(
|
||||
data: lark.im.v1.P2ImMessageRecalledV1,
|
||||
) -> None:
|
||||
"""Cancel only the task created by the recalled Feishu message."""
|
||||
try:
|
||||
logger.info("[FeiShu] websocket received message recall event")
|
||||
event_dict = json.loads(lark.JSON.marshal(data))
|
||||
self._handle_message_recalled_event(event_dict.get("event", {}))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[FeiShu] websocket handle message recall error: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def handle_card_action(data):
|
||||
"""Handle Card 2.0 button callbacks and update the card in place."""
|
||||
try:
|
||||
event_dict = json.loads(lark.JSON.marshal(data))
|
||||
response = self._handle_card_action_event(event_dict.get("event", {}))
|
||||
return P2CardActionTriggerResponse(response)
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
|
||||
return P2CardActionTriggerResponse(
|
||||
{"toast": {"type": "error", "content": "Task update failed"}}
|
||||
)
|
||||
|
||||
# 构建事件分发器
|
||||
event_handler = lark.EventDispatcherHandler.builder("", "") \
|
||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
||||
event_handler = (
|
||||
lark.EventDispatcherHandler.builder("", "")
|
||||
.register_p2_im_message_receive_v1(handle_message_event)
|
||||
.register_p2_im_message_recalled_v1(handle_message_recalled_event)
|
||||
.register_p2_card_action_trigger(handle_card_action)
|
||||
.build()
|
||||
)
|
||||
|
||||
def start_client_with_retry():
|
||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||
@@ -470,6 +517,120 @@ class FeiShuChanel(ChatChannel):
|
||||
# so reaching here means the bot was indeed mentioned.
|
||||
return True
|
||||
|
||||
def _get_scheduler_task_store(self):
|
||||
"""Reuse the live scheduler store, with a path-compatible fallback."""
|
||||
from agent.tools.scheduler.integration import get_task_store
|
||||
|
||||
task_store = get_task_store()
|
||||
if task_store is not None:
|
||||
return task_store
|
||||
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
|
||||
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
|
||||
|
||||
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
|
||||
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
|
||||
task_store = self._get_scheduler_task_store()
|
||||
receivers = {feishu_msg.other_user_id}
|
||||
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
|
||||
card = build_scheduler_card(tasks)
|
||||
headers = {
|
||||
"Authorization": "Bearer " + feishu_msg.access_token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
content = json.dumps(card, ensure_ascii=False)
|
||||
|
||||
if is_group and feishu_msg.msg_id:
|
||||
url = (
|
||||
"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||
f"{feishu_msg.msg_id}/reply"
|
||||
)
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json={"msg_type": "interactive", "content": content},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
else:
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
params={"receive_id_type": receive_id_type},
|
||||
json={
|
||||
"receive_id": feishu_msg.other_user_id,
|
||||
"msg_type": "interactive",
|
||||
"content": content,
|
||||
},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
logger.info("[FeiShu] scheduler card sent")
|
||||
return True
|
||||
logger.error(
|
||||
"[FeiShu] scheduler card failed, "
|
||||
f"code={result.get('code')}, msg={result.get('msg')}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _handle_card_action_event(self, event: dict) -> dict:
|
||||
"""Apply a scheduler card action within its chat/operator ownership scope."""
|
||||
action = event.get("action") or {}
|
||||
value = action.get("value") or {}
|
||||
if value.get("cowagent") != "scheduler":
|
||||
return {}
|
||||
|
||||
context = event.get("context") or {}
|
||||
operator = event.get("operator") or {}
|
||||
callback_receivers = {
|
||||
receiver
|
||||
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
|
||||
if receiver
|
||||
}
|
||||
target_receiver = value.get("receiver")
|
||||
allowed_receivers = (
|
||||
{target_receiver}
|
||||
if target_receiver and target_receiver in callback_receivers
|
||||
else set()
|
||||
)
|
||||
response = handle_scheduler_action(
|
||||
value,
|
||||
self._get_scheduler_task_store(),
|
||||
allowed_receivers,
|
||||
)
|
||||
logger.info(
|
||||
"[FeiShu] scheduler card action handled, "
|
||||
f"action={value.get('action')}, task_id={value.get('task_id')}"
|
||||
)
|
||||
return response
|
||||
|
||||
def _handle_message_recalled_event(self, event: dict):
|
||||
"""Cancel one recalled message while preserving later queued messages."""
|
||||
message_id = event.get("message_id")
|
||||
if not message_id:
|
||||
logger.warning(f"[FeiShu] invalid message recall event: {event}")
|
||||
return 0, False
|
||||
|
||||
session_id = self._message_sessions.get(message_id)
|
||||
if not session_id:
|
||||
logger.info(
|
||||
f"[FeiShu] ignored recall for unknown message, message_id={message_id}"
|
||||
)
|
||||
return 0, False
|
||||
|
||||
result = self.cancel_message(session_id, message_id)
|
||||
self._message_sessions.pop(message_id, None)
|
||||
logger.info(
|
||||
"[FeiShu] recalled message cancelled, "
|
||||
f"message_id={message_id}, session_id={session_id}, "
|
||||
f"queued={result[0]}, active={result[1]}"
|
||||
)
|
||||
return result
|
||||
|
||||
def _handle_message_event(self, event: dict):
|
||||
"""
|
||||
处理消息事件的核心逻辑
|
||||
@@ -521,6 +682,11 @@ class FeiShuChanel(ChatChannel):
|
||||
if not feishu_msg:
|
||||
return
|
||||
|
||||
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
|
||||
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
|
||||
logger.warning("[FeiShu] /tasks card delivery failed")
|
||||
return
|
||||
|
||||
# 处理文件缓存逻辑
|
||||
from channel.file_cache import get_file_cache
|
||||
file_cache = get_file_cache()
|
||||
@@ -591,13 +757,17 @@ class FeiShuChanel(ChatChannel):
|
||||
|
||||
context = self._compose_context(
|
||||
feishu_msg.ctype,
|
||||
feishu_msg.content,
|
||||
feishu_msg.content_with_quote(),
|
||||
isgroup=is_group,
|
||||
msg=feishu_msg,
|
||||
receive_id_type=receive_id_type,
|
||||
no_need_at=True
|
||||
)
|
||||
if context:
|
||||
# Feishu recall events only include message_id/chat_id. Keep the
|
||||
# accepted route and use message_id as the agent cancellation key.
|
||||
context["request_id"] = msg_id
|
||||
self._message_sessions[msg_id] = context["session_id"]
|
||||
# 流式回复模式:向 context 注入 on_event 回调,agent 每产出一段文字时会调用它。
|
||||
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
||||
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
||||
@@ -628,7 +798,18 @@ class FeiShuChanel(ChatChannel):
|
||||
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||
reply_content = reply.content
|
||||
content_key = "text"
|
||||
if reply.type == ReplyType.IMAGE_URL:
|
||||
prepared_content_json = None
|
||||
if reply.type == ReplyType.TEXT:
|
||||
# Render Markdown text replies as Feishu cards; falls back to plain text automatically.
|
||||
delivery_text = resolve_markdown_images(
|
||||
reply.content,
|
||||
lambda url: upload_public_image_to_feishu(
|
||||
url,
|
||||
access_token,
|
||||
),
|
||||
)
|
||||
msg_type, prepared_content_json = build_text_delivery(delivery_text)
|
||||
elif reply.type == ReplyType.IMAGE_URL:
|
||||
# 图片上传
|
||||
reply_content = self._upload_image_url(reply.content, access_token)
|
||||
if not reply_content:
|
||||
@@ -690,7 +871,11 @@ class FeiShuChanel(ChatChannel):
|
||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||
|
||||
# Build content JSON
|
||||
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||
content_json = prepared_content_json or (
|
||||
json.dumps(reply_content, ensure_ascii=False)
|
||||
if content_key is None
|
||||
else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||
)
|
||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||
|
||||
if can_reply:
|
||||
@@ -714,10 +899,393 @@ class FeiShuChanel(ChatChannel):
|
||||
res = res.json()
|
||||
if res.get("code") == 0:
|
||||
logger.info(f"[FeiShu] send message success")
|
||||
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
|
||||
logger.warning(
|
||||
"[FeiShu] Markdown card failed, falling back to text, "
|
||||
f"code={res.get('code')}, msg={res.get('msg')}"
|
||||
)
|
||||
fallback_data = {
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
|
||||
}
|
||||
if can_reply:
|
||||
fallback_res = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=fallback_data,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
else:
|
||||
fallback_data["receive_id"] = context.get("receiver")
|
||||
fallback_res = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=fallback_data,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
fallback_body = fallback_res.json()
|
||||
if fallback_body.get("code") == 0:
|
||||
logger.info("[FeiShu] text fallback sent successfully")
|
||||
else:
|
||||
logger.error(
|
||||
"[FeiShu] text fallback failed, "
|
||||
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||
|
||||
def _make_feishu_stream_callback(self, context, access_token):
|
||||
"""Route to detailed or plain streaming callback based on config.
|
||||
|
||||
feishu_detailed_card 默认开启:普通对话使用带状态头、
|
||||
思考/工具面板与耗时的详细卡片。关闭后回退到原有的打字机文本卡片
|
||||
(_make_feishu_stream_callback_plain)。
|
||||
"""
|
||||
if conf().get("feishu_detailed_card", True):
|
||||
return self._make_feishu_stream_callback_progress(context, access_token)
|
||||
return self._make_feishu_stream_callback_plain(context, access_token)
|
||||
|
||||
def _make_feishu_stream_callback_progress(self, context, access_token):
|
||||
"""
|
||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||
|
||||
流程:
|
||||
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
|
||||
随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去
|
||||
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
|
||||
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
|
||||
(流式模式下不受 10 QPS 限制)
|
||||
3. message_end(本轮触发工具调用)→ 原地刷新 Reasoning / Tools 面板,
|
||||
后续 turn 继续复用同一张卡片
|
||||
4. agent_end → 用 final_response、最终状态和耗时整卡更新,关闭 streaming_mode,
|
||||
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
|
||||
|
||||
前提条件:
|
||||
- 机器人已开通 cardkit:card:write 权限
|
||||
- 飞书客户端 7.20+
|
||||
|
||||
失败降级:
|
||||
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
|
||||
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
|
||||
"""
|
||||
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
|
||||
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
|
||||
progress_state = FeishuProgressState()
|
||||
card_id = [None]
|
||||
message_id = [None]
|
||||
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
|
||||
# 事件各自触发一次创建+发送,导致发出多张卡片。
|
||||
init_in_flight = [False]
|
||||
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||
disabled = [False]
|
||||
lock = threading.Lock()
|
||||
|
||||
# ---- 异步推送队列 ----------------------------------------------------
|
||||
# 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。
|
||||
# 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。
|
||||
# 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个
|
||||
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
|
||||
import queue as _queue
|
||||
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
|
||||
|
||||
def _push_worker():
|
||||
while True:
|
||||
snapshot = push_queue.get()
|
||||
if snapshot is None:
|
||||
push_queue.task_done()
|
||||
return
|
||||
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
|
||||
merged_count = 1
|
||||
stop = False
|
||||
while True:
|
||||
try:
|
||||
nxt = push_queue.get_nowait()
|
||||
except _queue.Empty:
|
||||
break
|
||||
merged_count += 1
|
||||
if nxt is None:
|
||||
stop = True
|
||||
break
|
||||
snapshot = nxt
|
||||
try:
|
||||
_stream_update_text(snapshot)
|
||||
finally:
|
||||
for _ in range(merged_count):
|
||||
push_queue.task_done()
|
||||
if stop:
|
||||
return
|
||||
|
||||
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
|
||||
push_thread.start()
|
||||
|
||||
def _drain_push_queue():
|
||||
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain,
|
||||
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
|
||||
try:
|
||||
push_queue.join()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg = context.get("msg")
|
||||
is_group = context.get("isgroup", False)
|
||||
receiver = context.get("receiver")
|
||||
receive_id_type = context.get("receive_id_type", "open_id")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
# 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件
|
||||
ELEMENT_ID = "stream_md"
|
||||
# 操作序号,每次 PUT 必须严格递增(飞书要求)
|
||||
sequence = [0]
|
||||
|
||||
def _next_sequence():
|
||||
with lock:
|
||||
sequence[0] += 1
|
||||
return sequence[0]
|
||||
|
||||
def _build_card_json():
|
||||
"""Build the initial streaming Card 2.0 payload."""
|
||||
with lock:
|
||||
card = progress_state.build_card(streaming=True)
|
||||
return json.dumps(card, ensure_ascii=False)
|
||||
|
||||
def _create_and_send_card():
|
||||
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
|
||||
try:
|
||||
# 步骤 1: 创建卡片实体
|
||||
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
|
||||
create_body = {"type": "card_json", "data": _build_card_json()}
|
||||
res = requests.post(
|
||||
create_url, headers=headers, json=create_body, timeout=(5, 10)
|
||||
)
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: create card failed "
|
||||
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
|
||||
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
|
||||
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
|
||||
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
|
||||
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
return
|
||||
cid = res_json["data"]["card_id"]
|
||||
with lock:
|
||||
card_id[0] = cid
|
||||
|
||||
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send)
|
||||
content_payload = json.dumps(
|
||||
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
|
||||
)
|
||||
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
|
||||
if can_reply:
|
||||
send_url = (
|
||||
f"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||
f"{msg.msg_id}/reply"
|
||||
)
|
||||
send_body = {"msg_type": "interactive", "content": content_payload}
|
||||
send_res = requests.post(
|
||||
send_url, headers=headers, json=send_body, timeout=(5, 10)
|
||||
)
|
||||
else:
|
||||
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
params = {"receive_id_type": receive_id_type}
|
||||
send_body = {
|
||||
"receive_id": receiver,
|
||||
"msg_type": "interactive",
|
||||
"content": content_payload,
|
||||
}
|
||||
send_res = requests.post(
|
||||
send_url, headers=headers, params=params, json=send_body,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
send_json = send_res.json()
|
||||
if send_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
return
|
||||
mid = send_json["data"]["message_id"]
|
||||
with lock:
|
||||
message_id[0] = mid
|
||||
logger.info(
|
||||
f"[FeiShu] Stream: card created and sent, "
|
||||
f"card_id={cid}, message_id={mid}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
finally:
|
||||
with lock:
|
||||
init_in_flight[0] = False
|
||||
|
||||
def _stream_update_text(full_text):
|
||||
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
|
||||
with lock:
|
||||
cid = card_id[0]
|
||||
if not cid:
|
||||
return
|
||||
url = (
|
||||
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
|
||||
f"{cid}/elements/{ELEMENT_ID}/content"
|
||||
)
|
||||
body = {
|
||||
"content": full_text,
|
||||
"sequence": _next_sequence(),
|
||||
}
|
||||
try:
|
||||
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: update text failed: {res_json}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
|
||||
|
||||
def _update_full_card(streaming: bool):
|
||||
"""Refresh panels, header, footer and streaming state in one update."""
|
||||
with lock:
|
||||
cid = card_id[0]
|
||||
full_card = progress_state.build_card(streaming=streaming)
|
||||
if not cid:
|
||||
return
|
||||
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
|
||||
put_body = {
|
||||
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
|
||||
"sequence": _next_sequence(),
|
||||
}
|
||||
try:
|
||||
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: full card update failed: {res_json}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: full card update exception: {e}"
|
||||
)
|
||||
|
||||
def on_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
data = event.get("data", {})
|
||||
|
||||
# 一旦降级,本次回复不再做任何流式操作
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
if event_type == "agent_start":
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if card_id[0] is None and not init_in_flight[0]:
|
||||
init_in_flight[0] = True
|
||||
_create_and_send_card()
|
||||
return
|
||||
|
||||
if event_type in ("turn_start", "reasoning_update"):
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
return
|
||||
|
||||
if event_type == "message_update":
|
||||
delta = data.get("delta", "")
|
||||
if not delta:
|
||||
return
|
||||
|
||||
# 第一段:判断是否需要初始化(创建卡片 + 发送)
|
||||
need_init = False
|
||||
with lock:
|
||||
if card_id[0] is None and not init_in_flight[0]:
|
||||
init_in_flight[0] = True
|
||||
need_init = True
|
||||
|
||||
if need_init:
|
||||
_create_and_send_card()
|
||||
# 初始化失败已标记 disabled,下次循环直接 return
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
# 第二段:累加文本,把快照丢给 push worker 异步推送。
|
||||
# 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk
|
||||
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。
|
||||
snapshot = ""
|
||||
should_push = False
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if card_id[0]:
|
||||
snapshot = progress_state.current_text
|
||||
should_push = True
|
||||
|
||||
if should_push:
|
||||
push_queue.put(snapshot)
|
||||
|
||||
elif event_type in ("tool_execution_start", "tool_execution_end"):
|
||||
# Refresh the Tools panel as each tool starts/finishes so users
|
||||
# see live status and per-tool elapsed time.
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
has_card = card_id[0] is not None
|
||||
if has_card:
|
||||
_drain_push_queue()
|
||||
_update_full_card(streaming=True)
|
||||
|
||||
elif event_type == "message_end":
|
||||
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
||||
tool_calls = data.get("tool_calls", []) or []
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if not tool_calls:
|
||||
return
|
||||
_drain_push_queue()
|
||||
_update_full_card(streaming=True)
|
||||
|
||||
elif event_type == "agent_cancelled":
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
|
||||
elif event_type == "agent_end":
|
||||
# Finalize the same card with a status header and elapsed footer.
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
final_text = progress_state.current_text
|
||||
has_card = card_id[0] is not None
|
||||
init_busy = init_in_flight[0]
|
||||
final_text = resolve_markdown_images(
|
||||
final_text,
|
||||
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||
)
|
||||
with lock:
|
||||
progress_state.current_text = final_text
|
||||
context["feishu_streamed"] = True
|
||||
|
||||
if not has_card and not init_busy:
|
||||
with lock:
|
||||
init_in_flight[0] = True
|
||||
_create_and_send_card()
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
_drain_push_queue()
|
||||
_stream_update_text(final_text)
|
||||
_update_full_card(streaming=False)
|
||||
push_queue.put(None)
|
||||
|
||||
return on_event
|
||||
|
||||
def _make_feishu_stream_callback_plain(self, context, access_token):
|
||||
"""
|
||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||
|
||||
@@ -966,9 +1534,15 @@ class FeiShuChanel(ChatChannel):
|
||||
if not cid:
|
||||
return
|
||||
|
||||
preview_text = final_text
|
||||
final_text = resolve_markdown_images(
|
||||
final_text,
|
||||
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||
)
|
||||
|
||||
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
||||
preview_src = (final_text or "").strip().replace("\n", " ")
|
||||
preview_src = (preview_text or "").strip().replace("\n", " ")
|
||||
preview = preview_src[:30] if preview_src else ""
|
||||
full_card = {
|
||||
"schema": "2.0",
|
||||
@@ -1565,6 +2139,8 @@ class FeishuController:
|
||||
FAILED_MSG = '{"success": false}'
|
||||
SUCCESS_MSG = '{"success": true}'
|
||||
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
||||
MESSAGE_RECALLED_TYPE = "im.message.recalled_v1"
|
||||
CARD_ACTION_TYPE = "card.action.trigger"
|
||||
|
||||
def GET(self):
|
||||
return "Feishu service start success!"
|
||||
@@ -1581,16 +2157,30 @@ class FeishuController:
|
||||
varify_res = {"challenge": request.get("challenge")}
|
||||
return json.dumps(varify_res)
|
||||
|
||||
# 2.消息接收处理
|
||||
# token 校验
|
||||
header = request.get("header")
|
||||
if not header or header.get("token") != channel.feishu_token:
|
||||
# 2. Verify callbacks. Card callbacks may carry the verification
|
||||
# token in event.token while message events carry it in the header.
|
||||
header = request.get("header") or {}
|
||||
event = request.get("event") or {}
|
||||
event_type = header.get("event_type") or request.get("type")
|
||||
callback_token = (
|
||||
header.get("token")
|
||||
or event.get("token")
|
||||
or request.get("token")
|
||||
)
|
||||
if callback_token != channel.feishu_token:
|
||||
return self.FAILED_MSG
|
||||
|
||||
# 处理消息事件
|
||||
event = request.get("event")
|
||||
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
|
||||
if event_type == self.CARD_ACTION_TYPE and event:
|
||||
return json.dumps(
|
||||
channel._handle_card_action_event(event),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 3. Handle message events.
|
||||
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
|
||||
channel._handle_message_event(event)
|
||||
elif event_type == self.MESSAGE_RECALLED_TYPE and event:
|
||||
channel._handle_message_recalled_event(event)
|
||||
|
||||
return self.SUCCESS_MSG
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
||||
self.msg_id = msg.get("message_id")
|
||||
self.create_time = msg.get("create_time")
|
||||
self.is_group = is_group
|
||||
self.quoted_content = ""
|
||||
msg_type = msg.get("message_type")
|
||||
|
||||
if msg_type == "text":
|
||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
||||
|
||||
if self.ctype == ContextType.TEXT:
|
||||
self.quoted_content = self._fetch_quoted_content(msg.get("parent_id"))
|
||||
|
||||
self.from_user_id = sender.get("sender_id").get("open_id")
|
||||
self.to_user_id = event.get("app_id")
|
||||
if is_group:
|
||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
||||
# 私聊
|
||||
self.other_user_id = self.from_user_id
|
||||
self.actual_user_id = self.from_user_id
|
||||
|
||||
def content_with_quote(self) -> str:
|
||||
"""Return user text with optional quoted-message context for the agent."""
|
||||
if not self.quoted_content:
|
||||
return self.content
|
||||
return (
|
||||
"[Quoted message]\n{}\n[/Quoted message]\n\n{}".format(
|
||||
self.quoted_content,
|
||||
self.content,
|
||||
)
|
||||
)
|
||||
|
||||
def _fetch_quoted_content(self, parent_id: str) -> str:
|
||||
"""Fetch one parent message, degrading to an empty quote on failure."""
|
||||
if not parent_id or not self.access_token:
|
||||
return ""
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages/{}".format(parent_id)
|
||||
headers = {"Authorization": "Bearer " + self.access_token}
|
||||
try:
|
||||
response = requests.get(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params={"card_msg_content_type": "raw_card_content"},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch failed, parent_id=%s, status=%s",
|
||||
parent_id,
|
||||
response.status_code,
|
||||
)
|
||||
return ""
|
||||
body = response.json()
|
||||
items = (body.get("data") or {}).get("items") or []
|
||||
if body.get("code") != 0 or not items:
|
||||
return ""
|
||||
return self._extract_quoted_text(items[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch error, parent_id=%s: %s",
|
||||
parent_id,
|
||||
exc,
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_quoted_text(item: dict) -> str:
|
||||
msg_type = item.get("msg_type")
|
||||
raw_content = (item.get("body") or {}).get("content") or ""
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
if msg_type == "text":
|
||||
return str(content.get("text") or "").strip()
|
||||
if msg_type == "post":
|
||||
# Some message-history payloads wrap post content in a locale key.
|
||||
if "content" not in content:
|
||||
localized = next(
|
||||
(value for value in content.values() if isinstance(value, dict)),
|
||||
None,
|
||||
)
|
||||
if localized:
|
||||
content = localized
|
||||
|
||||
parts = []
|
||||
title = str(content.get("title") or "").strip()
|
||||
if title:
|
||||
parts.append(title)
|
||||
for block in content.get("content") or []:
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
for element in block:
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
tag = element.get("tag")
|
||||
text = str(element.get("text") or "").strip()
|
||||
if tag == "text" and text:
|
||||
parts.append(text)
|
||||
elif tag == "a" and text:
|
||||
href = str(element.get("href") or "").strip()
|
||||
parts.append("{} ({})".format(text, href) if href else text)
|
||||
elif tag == "img":
|
||||
parts.append("[Image]")
|
||||
return "\n".join(parts).strip()
|
||||
if msg_type == "image":
|
||||
return "[Image]"
|
||||
if msg_type == "file":
|
||||
return "[File: {}]".format(content.get("file_name") or "file")
|
||||
if msg_type == "audio":
|
||||
return "[Audio]"
|
||||
if msg_type == "media":
|
||||
return "[Video: {}]".format(content.get("file_name") or "video")
|
||||
return ""
|
||||
|
||||
247
channel/feishu/feishu_progress_card.py
Normal file
247
channel/feishu/feishu_progress_card.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""State and Card 2.0 rendering for a Feishu agent run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from common import i18n
|
||||
|
||||
|
||||
_MAX_PANEL_STEPS = 10
|
||||
_MAX_STEP_CHARS = 800
|
||||
|
||||
|
||||
class FeishuProgressState:
|
||||
"""Reduce CowAgent stream events into one renderable Feishu card state."""
|
||||
|
||||
def __init__(self, started_at: Optional[float] = None):
|
||||
self.started_at = time.monotonic() if started_at is None else started_at
|
||||
self.status = "running"
|
||||
self.turns = 0
|
||||
self.current_text = ""
|
||||
self._reasoning_buffer = ""
|
||||
self.reasoning_steps: List[str] = []
|
||||
self.tool_steps: List[Dict[str, Any]] = []
|
||||
self._tool_index: Dict[str, Dict[str, Any]] = {}
|
||||
self.cancelled = False
|
||||
|
||||
def consume(self, event: Dict[str, Any]) -> None:
|
||||
"""Consume one event emitted by ``AgentStreamHandler``."""
|
||||
event_type = event.get("type")
|
||||
data = event.get("data") or {}
|
||||
|
||||
if event_type == "turn_start":
|
||||
self._mark_running_tools_done()
|
||||
turn = data.get("turn")
|
||||
if isinstance(turn, int):
|
||||
self.turns = max(self.turns, turn)
|
||||
else:
|
||||
self.turns += 1
|
||||
if self.turns > 1:
|
||||
self.current_text = ""
|
||||
return
|
||||
|
||||
if event_type == "reasoning_update":
|
||||
self._reasoning_buffer += str(data.get("delta") or "")
|
||||
return
|
||||
|
||||
if event_type == "message_update":
|
||||
self.current_text += str(data.get("delta") or "")
|
||||
return
|
||||
|
||||
if event_type == "message_end":
|
||||
self._commit_reasoning()
|
||||
return
|
||||
|
||||
if event_type == "tool_execution_start":
|
||||
tool_id = data.get("tool_call_id")
|
||||
step = {
|
||||
"summary": str(data.get("tool_name") or "tool"),
|
||||
"status": "running",
|
||||
"started_at": time.monotonic(),
|
||||
"elapsed": None,
|
||||
}
|
||||
self.tool_steps.append(step)
|
||||
if tool_id:
|
||||
self._tool_index[tool_id] = step
|
||||
return
|
||||
|
||||
if event_type == "tool_execution_end":
|
||||
tool_id = data.get("tool_call_id")
|
||||
step = self._tool_index.get(tool_id) if tool_id else None
|
||||
if step is None:
|
||||
# Fall back to the most recent running step when no id match.
|
||||
step = next((s for s in reversed(self.tool_steps) if s["status"] == "running"), None)
|
||||
if step is not None:
|
||||
step["status"] = "error" if data.get("status") not in (None, "success") else "done"
|
||||
elapsed = data.get("execution_time")
|
||||
if elapsed is None and step.get("started_at") is not None:
|
||||
elapsed = time.monotonic() - step["started_at"]
|
||||
step["elapsed"] = elapsed
|
||||
return
|
||||
|
||||
if event_type == "agent_cancelled":
|
||||
self.cancelled = True
|
||||
self.status = "stopped"
|
||||
return
|
||||
|
||||
if event_type == "agent_end":
|
||||
self._commit_reasoning()
|
||||
self._mark_running_tools_done()
|
||||
cancelled = self.cancelled or bool(data.get("cancelled"))
|
||||
if cancelled:
|
||||
self.status = "stopped"
|
||||
self.current_text = self.current_text.rstrip() or "_(stopped)_"
|
||||
else:
|
||||
self.status = "done"
|
||||
final_response = data.get("final_response")
|
||||
if final_response:
|
||||
self.current_text = str(final_response)
|
||||
|
||||
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""Render the current state as a Feishu Card 2.0 object."""
|
||||
# Localized status header text; en/zh/zh-Hant via i18n.t.
|
||||
title, template = {
|
||||
"running": (i18n.t("处理中", "Working"), "blue"),
|
||||
"done": (i18n.t("完成", "Done"), "green"),
|
||||
"stopped": (i18n.t("已停止", "Stopped"), "grey"),
|
||||
"error": (i18n.t("出错", "Error"), "red"),
|
||||
}.get(self.status, (i18n.t("处理中", "Working"), "blue"))
|
||||
|
||||
main_text = self.current_text or "..."
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
# Only render the Reasoning panel when there is real reasoning content.
|
||||
# Upstream emits reasoning_update only when deep thinking is enabled, so
|
||||
# an empty reasoning_steps means we should show no panel at all.
|
||||
if self.reasoning_steps:
|
||||
elements.append(
|
||||
_panel(
|
||||
"🤔 {}".format(i18n.t("思考", "Thinking")),
|
||||
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
||||
expanded=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
if self.tool_steps:
|
||||
elements.append(
|
||||
_panel(
|
||||
"🔧 {} ({})".format(i18n.t("工具", "Tools"), len(self.tool_steps)),
|
||||
[
|
||||
_text_row(_format_tool_step(step))
|
||||
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
|
||||
],
|
||||
expanded=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(
|
||||
{
|
||||
"tag": "markdown",
|
||||
"element_id": "stream_md",
|
||||
"content": main_text,
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
|
||||
turn_label = i18n.t("轮", "turn" if self.turns == 1 else "turns")
|
||||
elements.extend(
|
||||
[
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label),
|
||||
"text_size": "notation",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
config: Dict[str, Any] = {
|
||||
"streaming_mode": streaming,
|
||||
"update_multi": True,
|
||||
"enable_forward_interaction": True,
|
||||
"summary": {"content": _summary(main_text, title)},
|
||||
}
|
||||
if streaming:
|
||||
config["streaming_config"] = {
|
||||
"print_frequency_ms": {"default": 40},
|
||||
"print_step": {"default": 4},
|
||||
"print_strategy": "fast",
|
||||
}
|
||||
|
||||
card: Dict[str, Any] = {
|
||||
"schema": "2.0",
|
||||
"config": config,
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
# Hide the status header once the run has finished successfully; a plain
|
||||
# answer needs no "Done" banner. Keep the header for running/stopped/error
|
||||
# so users still get progress and failure signals.
|
||||
if self.status != "done":
|
||||
card["header"] = {
|
||||
"template": template,
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
}
|
||||
return card
|
||||
|
||||
def _commit_reasoning(self) -> None:
|
||||
reasoning = self._reasoning_buffer.strip()
|
||||
if reasoning:
|
||||
self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:])
|
||||
self._reasoning_buffer = ""
|
||||
|
||||
def _mark_running_tools_done(self) -> None:
|
||||
for step in self.tool_steps:
|
||||
if step["status"] == "running":
|
||||
step["status"] = "done"
|
||||
if step.get("elapsed") is None and step.get("started_at") is not None:
|
||||
step["elapsed"] = time.monotonic() - step["started_at"]
|
||||
|
||||
|
||||
def _tool_status_label(status: str) -> str:
|
||||
if status == "running":
|
||||
return i18n.t("执行中", "running")
|
||||
if status == "error":
|
||||
return i18n.t("失败", "error")
|
||||
return i18n.t("完成", "done")
|
||||
|
||||
|
||||
def _format_tool_step(step: Dict[str, Any]) -> str:
|
||||
# Tool name plus its own status and elapsed time, e.g. "search · done · 1.2s".
|
||||
parts = [str(step.get("summary") or "tool"), _tool_status_label(step["status"])]
|
||||
elapsed = step.get("elapsed")
|
||||
if isinstance(elapsed, (int, float)):
|
||||
parts.append("{:.1f}s".format(max(0.0, float(elapsed))))
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": expanded,
|
||||
"background_color": "grey",
|
||||
# Panel title uses markdown so we can shrink the font via text_size
|
||||
# (plain_text titles ignore text_size and break card rendering).
|
||||
"header": {"title": {"tag": "markdown", "content": title, "text_size": "notation"}},
|
||||
"border": {"color": "grey"},
|
||||
"vertical_spacing": "8px",
|
||||
"padding": "4px 8px",
|
||||
"elements": elements,
|
||||
}
|
||||
|
||||
|
||||
def _text_row(content: str, muted: bool = False) -> Dict[str, Any]:
|
||||
text = {
|
||||
"tag": "plain_text",
|
||||
"content": content,
|
||||
"text_size": "notation",
|
||||
}
|
||||
if muted:
|
||||
text["text_color"] = "grey"
|
||||
return {"tag": "div", "text": text}
|
||||
|
||||
|
||||
def _summary(text: str, fallback: str) -> str:
|
||||
preview = " ".join(text.strip().split())
|
||||
return preview[:60] or fallback
|
||||
185
channel/feishu/feishu_scheduler_card.py
Normal file
185
channel/feishu/feishu_scheduler_card.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Feishu scheduler card rendering and callback handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Set
|
||||
|
||||
|
||||
_MAX_TASKS = 20
|
||||
|
||||
|
||||
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
|
||||
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
|
||||
visible = []
|
||||
for task in tasks:
|
||||
action = task.get("action") or {}
|
||||
if action.get("channel_type") != "feishu":
|
||||
continue
|
||||
if action.get("receiver") in receivers:
|
||||
visible.append(task)
|
||||
return visible
|
||||
|
||||
|
||||
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
|
||||
"""Build a Card 2.0 task list with explicit, idempotent actions."""
|
||||
task_list = list(tasks)
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
if not task_list:
|
||||
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
|
||||
else:
|
||||
for index, task in enumerate(task_list[:_MAX_TASKS]):
|
||||
if index:
|
||||
elements.append({"tag": "hr"})
|
||||
task_id = str(task.get("id") or "")
|
||||
receiver = str((task.get("action") or {}).get("receiver") or "")
|
||||
enabled = task.get("enabled", True)
|
||||
status = "Enabled" if enabled else "Disabled"
|
||||
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
|
||||
elements.append(
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
|
||||
task.get("name") or "Unnamed task",
|
||||
status,
|
||||
task_id,
|
||||
_format_schedule(task.get("schedule") or {}),
|
||||
next_run,
|
||||
),
|
||||
}
|
||||
)
|
||||
toggle_action = "disable" if enabled else "enable"
|
||||
toggle_text = "Disable" if enabled else "Enable"
|
||||
toggle_type = "default" if enabled else "primary"
|
||||
elements.append(
|
||||
{
|
||||
"tag": "column_set",
|
||||
"columns": [
|
||||
{
|
||||
"tag": "column",
|
||||
"elements": [
|
||||
_button(
|
||||
toggle_text,
|
||||
toggle_type,
|
||||
toggle_action,
|
||||
task_id,
|
||||
receiver,
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag": "column",
|
||||
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
hidden = len(task_list) - _MAX_TASKS
|
||||
if hidden > 0:
|
||||
elements.extend(
|
||||
[
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "{} more tasks are hidden.".format(hidden),
|
||||
"text_size": "notation",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"config": {"update_multi": True, "enable_forward_interaction": False},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
|
||||
},
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
|
||||
|
||||
def handle_scheduler_action(
|
||||
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply an owned scheduler action and return a Feishu callback response."""
|
||||
if value.get("cowagent") != "scheduler":
|
||||
return {}
|
||||
|
||||
task_id = str(value.get("task_id") or "")
|
||||
action = value.get("action")
|
||||
if not task_id or action not in {"enable", "disable", "delete"}:
|
||||
return _toast("error", "Invalid scheduler action")
|
||||
|
||||
task = task_store.get_task(task_id)
|
||||
if not task:
|
||||
return _response(
|
||||
"info",
|
||||
"Task no longer exists",
|
||||
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
|
||||
)
|
||||
|
||||
task_receiver = (task.get("action") or {}).get("receiver")
|
||||
task_channel = (task.get("action") or {}).get("channel_type")
|
||||
value_receiver = value.get("receiver")
|
||||
if (
|
||||
task_channel != "feishu"
|
||||
or task_receiver not in allowed_receivers
|
||||
or (value_receiver and value_receiver != task_receiver)
|
||||
):
|
||||
return _toast("error", "Task is not available in this chat")
|
||||
|
||||
try:
|
||||
if action == "delete":
|
||||
task_store.delete_task(task_id)
|
||||
message = "Task deleted"
|
||||
else:
|
||||
enabled = action == "enable"
|
||||
task_store.enable_task(task_id, enabled)
|
||||
message = "Task enabled" if enabled else "Task disabled"
|
||||
except (OSError, ValueError) as exc:
|
||||
return _toast("error", "Task update failed: {}".format(exc))
|
||||
|
||||
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
|
||||
return _response("success", message, build_scheduler_card(visible))
|
||||
|
||||
|
||||
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
|
||||
response = _toast(toast_type, content)
|
||||
response["card"] = {"type": "raw", "data": card}
|
||||
return response
|
||||
|
||||
|
||||
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
|
||||
return {"toast": {"type": toast_type, "content": content}}
|
||||
|
||||
|
||||
def _button(
|
||||
text: str,
|
||||
button_type: str,
|
||||
action: str,
|
||||
task_id: str,
|
||||
receiver: str,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": text},
|
||||
"type": button_type,
|
||||
"value": {
|
||||
"cowagent": "scheduler",
|
||||
"action": action,
|
||||
"task_id": task_id,
|
||||
"receiver": receiver,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _format_schedule(schedule: Dict[str, Any]) -> str:
|
||||
schedule_type = schedule.get("type")
|
||||
if schedule_type == "cron":
|
||||
return "cron {}".format(schedule.get("expression") or "?")
|
||||
if schedule_type == "interval":
|
||||
return "every {}s".format(schedule.get("seconds") or "?")
|
||||
if schedule_type == "once":
|
||||
return "once at {}".format(schedule.get("run_at") or "?")
|
||||
return str(schedule_type or "unknown schedule")
|
||||
218
channel/feishu/feishu_static_card.py
Normal file
218
channel/feishu/feishu_static_card.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from typing import Callable, Optional, Tuple
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
_BLOCK_MARKDOWN = re.compile(
|
||||
r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)"
|
||||
)
|
||||
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
|
||||
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
||||
_MARKDOWN_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\s]+)\)")
|
||||
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
||||
_MAX_REDIRECTS = 3
|
||||
_MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def contains_markdown(text: str) -> bool:
|
||||
"""Return whether *text* contains syntax that benefits from card Markdown."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(
|
||||
_BLOCK_MARKDOWN.search(text)
|
||||
or _INLINE_MARKDOWN.search(text)
|
||||
or _TABLE_SEPARATOR.search(text)
|
||||
)
|
||||
|
||||
|
||||
def build_markdown_card(text: str) -> dict:
|
||||
"""Build an inline Card 2.0 payload with one Markdown element."""
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"config": {},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": text,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_text_delivery(text: str) -> Tuple[str, str]:
|
||||
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
|
||||
if contains_markdown(text):
|
||||
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
||||
return "text", json.dumps({"text": text}, ensure_ascii=False)
|
||||
|
||||
|
||||
def resolve_markdown_images(
|
||||
text: str,
|
||||
uploader: Callable[[str], Optional[str]],
|
||||
max_images: int = 5,
|
||||
) -> str:
|
||||
"""Replace remote Markdown image URLs with Feishu image keys."""
|
||||
cache = {}
|
||||
uploaded = 0
|
||||
|
||||
def replace(match):
|
||||
nonlocal uploaded
|
||||
alt = match.group(1).strip() or "image"
|
||||
target = match.group(2).strip()
|
||||
if target.startswith("img_"):
|
||||
return match.group(0)
|
||||
if urlparse(target).scheme not in ("http", "https"):
|
||||
return match.group(0)
|
||||
|
||||
if target not in cache:
|
||||
if uploaded >= max_images:
|
||||
cache[target] = None
|
||||
else:
|
||||
uploaded += 1
|
||||
try:
|
||||
cache[target] = uploader(target)
|
||||
except Exception:
|
||||
cache[target] = None
|
||||
|
||||
image_key = cache[target]
|
||||
if image_key:
|
||||
return "".format(alt, image_key)
|
||||
return "[Image unavailable: {}]".format(alt)
|
||||
|
||||
return _MARKDOWN_IMAGE.sub(replace, text or "")
|
||||
|
||||
|
||||
def validate_public_image_url(url: str) -> None:
|
||||
"""Reject non-HTTP and non-public image targets before downloading."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("unsupported image URL scheme")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("image URL has no hostname")
|
||||
|
||||
try:
|
||||
literal_address = ipaddress.ip_address(parsed.hostname)
|
||||
resolved_addresses = [literal_address]
|
||||
except ValueError:
|
||||
try:
|
||||
addresses = socket.getaddrinfo(
|
||||
parsed.hostname,
|
||||
parsed.port,
|
||||
socket.AF_UNSPEC,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("cannot resolve image hostname") from exc
|
||||
resolved_addresses = [ipaddress.ip_address(item[4][0]) for item in addresses]
|
||||
|
||||
for address in resolved_addresses:
|
||||
if (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
):
|
||||
raise ValueError("image URL resolves to a non-public address")
|
||||
|
||||
|
||||
def download_public_image(
|
||||
url: str,
|
||||
get=requests.get,
|
||||
max_bytes: int = _MAX_REMOTE_IMAGE_BYTES,
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Download a public image with redirect, type, and size checks."""
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
validate_public_image_url(current)
|
||||
response = get(
|
||||
current,
|
||||
headers={"User-Agent": "CowAgent/Feishu"},
|
||||
timeout=(5, 15),
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if response.status_code in _REDIRECT_CODES:
|
||||
location = response.headers.get("Location")
|
||||
response.close()
|
||||
if not location:
|
||||
raise ValueError("image redirect has no location")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
|
||||
if response.status_code != 200:
|
||||
response.close()
|
||||
raise ValueError("image download returned HTTP {}".format(response.status_code))
|
||||
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
|
||||
if not content_type.startswith("image/"):
|
||||
response.close()
|
||||
raise ValueError("remote resource is not an image")
|
||||
|
||||
try:
|
||||
content_length = int(response.headers.get("Content-Length") or 0)
|
||||
except (TypeError, ValueError):
|
||||
content_length = 0
|
||||
if content_length > max_bytes:
|
||||
response.close()
|
||||
raise ValueError("remote image is too large")
|
||||
|
||||
chunks = []
|
||||
downloaded = 0
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue
|
||||
downloaded += len(chunk)
|
||||
if downloaded > max_bytes:
|
||||
raise ValueError("remote image is too large")
|
||||
chunks.append(chunk)
|
||||
finally:
|
||||
response.close()
|
||||
return b"".join(chunks), content_type
|
||||
|
||||
raise ValueError("too many image redirects")
|
||||
|
||||
|
||||
def upload_public_image_to_feishu(
|
||||
url: str,
|
||||
access_token: str,
|
||||
post=requests.post,
|
||||
) -> Optional[str]:
|
||||
"""Download a public image and upload its bytes to Feishu."""
|
||||
payload, content_type = download_public_image(url)
|
||||
extension = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
}.get(content_type, "img")
|
||||
response = post(
|
||||
"https://open.feishu.cn/open-apis/im/v1/images",
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
data={"image_type": "message"},
|
||||
files={
|
||||
"image": (
|
||||
"markdown-image.{}".format(extension),
|
||||
payload,
|
||||
content_type,
|
||||
)
|
||||
},
|
||||
timeout=(5, 15),
|
||||
)
|
||||
body = response.json()
|
||||
if body.get("code") != 0:
|
||||
return None
|
||||
return (body.get("data") or {}).get("image_key")
|
||||
@@ -36,11 +36,13 @@ TELEGRAM_BOT_COMMANDS = [
|
||||
("help", "Show command help"),
|
||||
("status", "Show running status"),
|
||||
("context", "View/clear conversation context (sub: clear)"),
|
||||
("tasks", "List scheduled tasks for this chat"),
|
||||
("skill", "Manage skills (list/search/install/...)"),
|
||||
("memory", "Manage memory (sub: dream)"),
|
||||
("knowledge", "Manage knowledge base (list/on/off)"),
|
||||
("config", "Show current config"),
|
||||
("cancel", "Cancel running agent task"),
|
||||
("steer", "Guide the running agent task"),
|
||||
("logs", "Show recent logs"),
|
||||
("version", "Show version"),
|
||||
]
|
||||
|
||||
@@ -485,6 +485,20 @@
|
||||
<i class="fas fa-microphone text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button id="steer-btn"
|
||||
class="hidden flex-shrink-0 w-10 h-10 items-center justify-center rounded-lg
|
||||
border border-primary-300 dark:border-primary-700
|
||||
text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
disabled:text-slate-300 dark:disabled:text-slate-600
|
||||
disabled:border-slate-200 dark:disabled:border-slate-700
|
||||
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
||||
type="button"
|
||||
data-i18n-title="steer_active"
|
||||
data-i18n-aria-label="steer_active"
|
||||
aria-label="引导当前任务"
|
||||
title="引导当前任务">
|
||||
<i class="fas fa-arrow-turn-up text-sm"></i>
|
||||
</button>
|
||||
<button id="send-btn"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
|
||||
@@ -123,6 +123,8 @@ const I18N = {
|
||||
slash_knowledge_off: '关闭知识库',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
|
||||
steer_active: '引导当前任务',
|
||||
slash_logs: '查看最近日志',
|
||||
slash_version: '查看版本',
|
||||
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||
@@ -217,6 +219,11 @@ const I18N = {
|
||||
task_delete_btn: '删除任务',
|
||||
task_delete_confirm_title: '删除定时任务',
|
||||
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
|
||||
task_run_now: '立即执行',
|
||||
task_run_confirm_title: '立即执行任务',
|
||||
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||
task_run_started: '已开始执行',
|
||||
task_run_failed: '执行失败',
|
||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||
new_chat: '新对话',
|
||||
@@ -370,6 +377,8 @@ const I18N = {
|
||||
slash_knowledge_off: '關閉知識庫',
|
||||
slash_config: '檢視當前設定',
|
||||
slash_cancel: '中止當前正在執行的 Agent 任務',
|
||||
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
|
||||
steer_active: '引導當前任務',
|
||||
slash_logs: '檢視最近日誌',
|
||||
slash_version: '檢視版本',
|
||||
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
||||
@@ -464,6 +473,11 @@ const I18N = {
|
||||
task_delete_btn: '刪除任務',
|
||||
task_delete_confirm_title: '刪除定時任務',
|
||||
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
|
||||
task_run_now: '立即執行',
|
||||
task_run_confirm_title: '立即執行任務',
|
||||
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
|
||||
task_run_started: '已開始執行',
|
||||
task_run_failed: '執行失敗',
|
||||
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
|
||||
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
||||
new_chat: '新對話',
|
||||
@@ -616,6 +630,8 @@ const I18N = {
|
||||
slash_knowledge_off: 'Disable knowledge base',
|
||||
slash_config: 'Show current config',
|
||||
slash_cancel: 'Abort the running Agent task',
|
||||
slash_steer: 'Inject guidance into the running Agent task',
|
||||
steer_active: 'Steer active task',
|
||||
slash_logs: 'Show recent logs',
|
||||
slash_version: 'Show version',
|
||||
input_placeholder: 'Type a message, or press / for commands',
|
||||
@@ -710,6 +726,11 @@ const I18N = {
|
||||
task_delete_btn: 'Delete Task',
|
||||
task_delete_confirm_title: 'Delete Task',
|
||||
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
|
||||
task_run_now: 'Run now',
|
||||
task_run_confirm_title: 'Run task now',
|
||||
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||
task_run_started: 'Run started',
|
||||
task_run_failed: 'Run failed',
|
||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||
new_chat: 'New Chat',
|
||||
@@ -801,6 +822,9 @@ function applyI18n() {
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
el.title = t(el.dataset['i18nTitle']);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
|
||||
el.setAttribute('aria-label', t(el.dataset['i18nAriaLabel']));
|
||||
});
|
||||
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||
});
|
||||
@@ -1139,6 +1163,33 @@ function createMd() {
|
||||
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) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
@@ -1373,6 +1424,7 @@ startPolling();
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const steerBtn = document.getElementById('steer-btn');
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const folderInput = document.getElementById('folder-input');
|
||||
@@ -1707,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
|
||||
sendBtn.classList.add('send-btn-cancel');
|
||||
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
||||
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
||||
updateSteerBtnState();
|
||||
}
|
||||
|
||||
function resetSendBtnSendMode() {
|
||||
@@ -1715,9 +1768,64 @@ function resetSendBtnSendMode() {
|
||||
sendBtn.classList.remove('send-btn-cancel');
|
||||
sendBtn.title = '';
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
||||
steerBtn.classList.add('hidden');
|
||||
steerBtn.classList.remove('flex');
|
||||
steerBtn.disabled = true;
|
||||
updateSendBtnState();
|
||||
}
|
||||
|
||||
function updateSteerBtnState() {
|
||||
// Keep the steer button enabled whenever a task is running so users can
|
||||
// fire successive guidance. Empty-input is guarded in steerActiveTask,
|
||||
// avoiding a jarring disabled/not-allowed state right after each steer.
|
||||
const active = sendBtnMode === 'cancel' && !!activeRequestId;
|
||||
steerBtn.classList.toggle('hidden', !active);
|
||||
steerBtn.classList.toggle('flex', active);
|
||||
steerBtn.disabled = !active || uploadingCount > 0;
|
||||
}
|
||||
|
||||
function steerActiveTask() {
|
||||
const instruction = chatInput.value.trim();
|
||||
if (!instruction || sendBtnMode !== 'cancel' || !activeRequestId) return;
|
||||
|
||||
inputHistory.push(instruction);
|
||||
historyIdx = -1;
|
||||
historySavedDraft = '';
|
||||
addUserMessage(`↪ ${instruction}`, new Date());
|
||||
|
||||
chatInput.value = '';
|
||||
chatInput.style.height = '42px';
|
||||
chatInput.style.overflowY = 'hidden';
|
||||
updateSteerBtnState();
|
||||
|
||||
fetch('/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
message: instruction,
|
||||
steer: true,
|
||||
stream: false,
|
||||
lang: currentLang,
|
||||
}),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.inline_reply) {
|
||||
addBotMessage(data.inline_reply, new Date());
|
||||
} else {
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[steer] request failed', err);
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
})
|
||||
.finally(updateSteerBtnState);
|
||||
}
|
||||
|
||||
steerBtn.addEventListener('click', steerActiveTask);
|
||||
|
||||
function requestCancel() {
|
||||
const reqId = activeRequestId;
|
||||
if (!reqId) return;
|
||||
@@ -1753,10 +1861,12 @@ function updateSendBtnState() {
|
||||
resetSendBtnSendMode();
|
||||
} else {
|
||||
// Don't downgrade a genuinely active Cancel button on input edits.
|
||||
updateSteerBtnState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||
updateSteerBtnState();
|
||||
}
|
||||
|
||||
function renderAttachmentPreview() {
|
||||
@@ -2075,6 +2185,7 @@ const SLASH_COMMANDS = [
|
||||
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
|
||||
{ cmd: '/config', desc: 'slash_config' },
|
||||
{ cmd: '/cancel', desc: 'slash_cancel' },
|
||||
{ cmd: '/steer ', desc: 'slash_steer' },
|
||||
{ cmd: '/logs', desc: 'slash_logs' },
|
||||
{ cmd: '/version', desc: 'slash_version' },
|
||||
];
|
||||
@@ -7908,6 +8019,38 @@ function refreshTasksView() {
|
||||
btn.disabled = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function runTaskNow(task, button) {
|
||||
showConfirmDialog({
|
||||
title: t('task_run_confirm_title'),
|
||||
message: `${task.name || task.id}: ${t('task_run_confirm_msg')}`,
|
||||
okText: t('task_run_now'),
|
||||
onConfirm: () => {
|
||||
const originalHtml = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${t('task_run_now')}`;
|
||||
fetch('/api/scheduler/run', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({task_id: task.id})
|
||||
}).then(r => r.json()).then(res => {
|
||||
if (res.status !== 'success') throw new Error(res.message || t('task_run_failed'));
|
||||
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('task_run_started')}`;
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
button.innerHTML = `<i class="fas fa-triangle-exclamation mr-1"></i>${t('task_run_failed')}`;
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadTasksView() {
|
||||
if (tasksLoaded) return;
|
||||
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
||||
@@ -7969,11 +8112,19 @@ function loadTasksView() {
|
||||
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" class="task-run-now px-2 py-1 rounded-md text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-500/10 transition-colors">
|
||||
<i class="fas fa-play mr-1"></i>${t('task_run_now')}
|
||||
</button>
|
||||
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
|
||||
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
|
||||
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>`;
|
||||
const runButton = card.querySelector('.task-run-now');
|
||||
runButton.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
runTaskNow(task, runButton);
|
||||
});
|
||||
const checkbox = card.querySelector('#' + toggleId);
|
||||
checkbox.addEventListener('change', function() {
|
||||
const newEnabled = this.checked;
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -79,11 +80,42 @@ def _verify_auth_token(token):
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
def _get_bearer_token():
|
||||
"""Extract the token from an `Authorization: Bearer <token>` header.
|
||||
|
||||
The desktop client renders from a file:// origin, so cross-origin cookies
|
||||
to http://127.0.0.1 are unreliable (SameSite=Lax cookies aren't sent). It
|
||||
therefore authenticates via this header instead; browsers keep using the
|
||||
cookie set by /auth/login.
|
||||
"""
|
||||
auth = web.ctx.env.get("HTTP_AUTHORIZATION", "") or ""
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _get_query_token():
|
||||
"""Extract a token from the `token` query param.
|
||||
|
||||
Needed for SSE endpoints: EventSource can't set an Authorization header,
|
||||
and file:// cookies are unreliable, so the desktop client passes the token
|
||||
in the query string for /stream and /api/logs.
|
||||
"""
|
||||
try:
|
||||
return web.input(token="").token or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _check_auth():
|
||||
"""Return True if request is authenticated or password not enabled."""
|
||||
if not _is_password_enabled():
|
||||
return True
|
||||
return _verify_auth_token(web.cookies().get("cow_auth_token", ""))
|
||||
if _verify_auth_token(web.cookies().get("cow_auth_token", "")):
|
||||
return True
|
||||
if _verify_auth_token(_get_bearer_token()):
|
||||
return True
|
||||
return _verify_auth_token(_get_query_token())
|
||||
|
||||
|
||||
def _require_auth():
|
||||
@@ -103,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
|
||||
return "Nothing to cancel." if en else "当前没有可中止的任务。"
|
||||
|
||||
|
||||
def _steer_reply_text(status, lang: str) -> str:
|
||||
from agent.protocol import SteerStatus
|
||||
|
||||
en = (lang or "").lower().startswith("en")
|
||||
messages = {
|
||||
SteerStatus.ACCEPTED: (
|
||||
"↪️ Active task redirected.", "↪️ 已引导当前任务。"
|
||||
),
|
||||
SteerStatus.INACTIVE: (
|
||||
"No active task to steer.", "当前没有可引导的任务。"
|
||||
),
|
||||
SteerStatus.CLOSING: (
|
||||
"The active task is already finishing.", "当前任务已结束,无法再引导。"
|
||||
),
|
||||
SteerStatus.AMBIGUOUS: (
|
||||
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||
),
|
||||
SteerStatus.FULL: (
|
||||
"Too many steering updates are pending; try again after the agent processes them.",
|
||||
"引导指令过多,请等待当前任务处理后再试。",
|
||||
),
|
||||
SteerStatus.INVALID: (
|
||||
"Usage: /steer <instruction>", "用法:/steer <引导指令>"
|
||||
),
|
||||
}
|
||||
english, chinese = messages[status]
|
||||
return english if en else chinese
|
||||
|
||||
|
||||
def _get_upload_dir() -> str:
|
||||
from common.utils import expand_path
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
@@ -881,6 +943,37 @@ class WebChannel(ChatChannel):
|
||||
"inline_reply": msg_text,
|
||||
})
|
||||
|
||||
# Explicit steering also bypasses the normal session queue. The
|
||||
# Web button sends ``steer: true`` with raw input; typed /steer
|
||||
# commands use the same endpoint and semantics as IM channels.
|
||||
steer_requested = bool(json_data.get("steer", False))
|
||||
is_steer_command = (
|
||||
re.match(r"^/steer(?:\s|$)", stripped_prompt) is not None
|
||||
)
|
||||
if steer_requested or is_steer_command:
|
||||
instruction = (
|
||||
(prompt or "").strip()[len("/steer"):].strip()
|
||||
if is_steer_command
|
||||
else (prompt or "").strip()
|
||||
)
|
||||
from bridge.bridge import Bridge
|
||||
result = Bridge().get_agent_bridge().steer_session(
|
||||
session_id, instruction
|
||||
)
|
||||
lang = (json_data.get("lang") or "zh").lower()
|
||||
msg_text = _steer_reply_text(result.status, lang)
|
||||
logger.info(
|
||||
f"[WebChannel] steer fast-path: session={session_id}, "
|
||||
f"status={result.status.value}, lang={lang}"
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"request_id": "",
|
||||
"stream": False,
|
||||
"steered": result.accepted,
|
||||
"inline_reply": msg_text,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Append file references to the prompt (same format as QQ channel)
|
||||
if attachments:
|
||||
file_refs = []
|
||||
@@ -1249,6 +1342,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
urls = (
|
||||
'/', 'RootHandler',
|
||||
'/api/health', 'HealthHandler',
|
||||
'/auth/login', 'AuthLoginHandler',
|
||||
'/auth/check', 'AuthCheckHandler',
|
||||
'/auth/logout', 'AuthLogoutHandler',
|
||||
@@ -1277,6 +1371,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/scheduler/run', 'SchedulerRunHandler',
|
||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||
@@ -1288,6 +1383,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/messages/delete', 'MessageDeleteHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
'/api/version', 'VersionHandler',
|
||||
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
|
||||
'/assets/(.*)', 'AssetsHandler',
|
||||
)
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
@@ -1341,6 +1437,74 @@ class RootHandler:
|
||||
raise web.seeother('/chat')
|
||||
|
||||
|
||||
class HealthHandler:
|
||||
# Unauthenticated liveness probe. The desktop shell polls this to know the
|
||||
# backend is up; it must never require auth (a set web_password would
|
||||
# otherwise make startup hang). Returns no sensitive data.
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
web.header('Cache-Control', 'no-store')
|
||||
return json.dumps({"status": "ok"})
|
||||
|
||||
|
||||
class McpOAuthCallbackHandler:
|
||||
"""OAuth redirect target for MCP servers requiring authorization.
|
||||
|
||||
The browser lands here after the user authorizes a remote MCP server.
|
||||
We exchange the authorization code for tokens and bring the server
|
||||
online. Unauthenticated by design: the OAuth `state` param is the
|
||||
single-use secret that binds this request to a pending authorization.
|
||||
"""
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'text/html; charset=utf-8')
|
||||
params = web.input(code="", state="", error="", error_description="")
|
||||
|
||||
def _page(title: str, message: str) -> str:
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
f"<title>{title}</title></head>"
|
||||
"<body style='font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
|
||||
"max-width:520px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2328'>"
|
||||
f"<h2>{title}</h2><p style='color:#57606a'>{message}</p></body></html>"
|
||||
)
|
||||
|
||||
if params.error:
|
||||
logger.warning(f"[MCP-OAuth] callback error: {params.error} {params.error_description}")
|
||||
return _page("授权失败", f"{params.error}: {params.error_description or ''}")
|
||||
|
||||
if not params.code or not params.state:
|
||||
return _page("参数缺失", "回调缺少 code 或 state 参数。")
|
||||
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import pop_pending
|
||||
from agent.tools.mcp.mcp_client import notify_server_authorized
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] callback import failed: {e}")
|
||||
return _page("内部错误", "OAuth 模块不可用。")
|
||||
|
||||
handler = pop_pending(params.state)
|
||||
if handler is None:
|
||||
return _page("会话已过期", "授权请求不存在或已过期,请重新触发授权。")
|
||||
|
||||
try:
|
||||
ok = handler.finish_authorization(params.code)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] token exchange crashed: {e}")
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
return _page("授权失败", "换取令牌失败,请重试。")
|
||||
|
||||
notify_server_authorized(handler.server_name)
|
||||
logger.info(f"[MCP-OAuth] Server '{handler.server_name}' authorized via web callback")
|
||||
return _page(
|
||||
"授权成功",
|
||||
f"MCP 服务 “{handler.server_name}” 已授权,可以返回聊天继续使用了。",
|
||||
)
|
||||
|
||||
|
||||
class AuthCheckHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
@@ -1368,7 +1532,9 @@ class AuthLoginHandler:
|
||||
token = _create_auth_token()
|
||||
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
||||
path="/", httponly=True, samesite="Lax")
|
||||
return json.dumps({"status": "success"})
|
||||
# Also return the token in the body: the desktop client (file:// origin)
|
||||
# can't rely on the cookie and sends it back via an Authorization header.
|
||||
return json.dumps({"status": "success", "token": token})
|
||||
|
||||
|
||||
class AuthLogoutHandler:
|
||||
@@ -1597,15 +1763,14 @@ class ConfigHandler:
|
||||
_RECOMMENDED_MODELS = [
|
||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
|
||||
# from this web console list for now.
|
||||
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||
# claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
|
||||
const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||
]
|
||||
@@ -1644,7 +1809,7 @@ class ConfigHandler:
|
||||
"api_base_key": "claude_api_base",
|
||||
"api_base_default": "https://api.anthropic.com/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.CLAUDE_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", {
|
||||
"label": "Gemini",
|
||||
@@ -1660,7 +1825,7 @@ class ConfigHandler:
|
||||
"api_base_key": "open_ai_api_base",
|
||||
"api_base_default": "https://api.openai.com/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||
}),
|
||||
("zhipu", {
|
||||
"label": {"zh": "智谱AI", "en": "GLM"},
|
||||
@@ -1692,7 +1857,7 @@ class ConfigHandler:
|
||||
"api_base_key": "moonshot_base_url",
|
||||
"api_base_default": "https://api.moonshot.cn/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
}),
|
||||
("qianfan", {
|
||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||
@@ -1807,7 +1972,7 @@ class ConfigHandler:
|
||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||
|
||||
return json.dumps({
|
||||
result = {
|
||||
"status": "success",
|
||||
"use_agent": use_agent,
|
||||
"title": title,
|
||||
@@ -1824,7 +1989,13 @@ class ConfigHandler:
|
||||
"api_keys": api_keys_masked,
|
||||
"providers": providers,
|
||||
"web_password_masked": masked_pwd,
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
# The desktop app runs on the local trusted machine, so it can edit
|
||||
# the real password in place (cursor at the end, delete to clear).
|
||||
# Browser access only ever sees the masked value.
|
||||
if os.environ.get("COW_DESKTOP") == "1":
|
||||
result["web_password"] = raw_pwd
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
@@ -2230,9 +2401,12 @@ class ModelsHandler:
|
||||
# Anything not listed here intentionally hides the model dropdown so
|
||||
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||
_VISION_PROVIDER_MODELS = {
|
||||
# OpenAI ordering matches the recommended GPT-5.4 family first, then
|
||||
# OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
|
||||
# GPT-5 and the GPT-4.1/4o backstops.
|
||||
"openai": [
|
||||
const.GPT_56_LUNA,
|
||||
const.GPT_56_TERRA,
|
||||
const.GPT_56_SOL,
|
||||
const.GPT_55,
|
||||
const.GPT_54,
|
||||
const.GPT_54_MINI,
|
||||
@@ -2245,7 +2419,7 @@ class ModelsHandler:
|
||||
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
||||
"moonshot": [const.KIMI_K2_6],
|
||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||
"claudeAPI": [const.CLAUDE_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],
|
||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||
@@ -2269,6 +2443,7 @@ class ModelsHandler:
|
||||
const.DOUBAO_SEED_2_1_PRO,
|
||||
const.KIMI_K2_6,
|
||||
const.CLAUDE_SONNET_5,
|
||||
const.CLAUDE_FABLE_5,
|
||||
const.GEMINI_31_FLASH_LITE_PRE,
|
||||
],
|
||||
# Custom OpenAI-compatible providers have no preset list — model
|
||||
@@ -4425,6 +4600,34 @@ class SchedulerHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerRunHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
return json.dumps({"status": "error", "message": "task_id required"})
|
||||
|
||||
from agent.tools.scheduler.integration import get_scheduler_service
|
||||
service = get_scheduler_service()
|
||||
if service is None:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Scheduler service is not running",
|
||||
})
|
||||
|
||||
service.run_task_now(task_id)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"message": f"Task '{task_id}' queued for immediate execution",
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerToggleHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
@@ -4498,11 +4701,33 @@ class SchedulerUpdateHandler:
|
||||
|
||||
# Update action
|
||||
if "action" in body:
|
||||
action = body["action"]
|
||||
channel_type = action.get("channel_type", "web")
|
||||
|
||||
# Get the task's original channel_type
|
||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
||||
original_action = original_task.get("action", {})
|
||||
if not isinstance(original_action, dict):
|
||||
original_action = {}
|
||||
action_patch = body["action"]
|
||||
if not isinstance(action_patch, dict):
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Action must be an object."
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# The Web editor only exposes a subset of action fields. Merge
|
||||
# that patch into the stored action so scheduler metadata such
|
||||
# as notify_session_id, silent, and channel-specific delivery
|
||||
# fields survive unrelated edits.
|
||||
action = dict(original_action)
|
||||
action.update(action_patch)
|
||||
action_type = action.get("type")
|
||||
if action_type == "send_message":
|
||||
action.pop("task_description", None)
|
||||
action.pop("silent", None)
|
||||
elif action_type == "agent_task":
|
||||
action.pop("content", None)
|
||||
|
||||
old_channel = original_action.get("channel_type", "web")
|
||||
channel_type = action.get("channel_type") or old_channel
|
||||
action["channel_type"] = channel_type
|
||||
|
||||
# If channel type changed or no receiver, reject the update.
|
||||
# Note: the web UI disables the channel selector, so this branch
|
||||
|
||||
@@ -7,6 +7,7 @@ from cli.commands.process import start, stop, restart, self_restart, update, sta
|
||||
from cli.commands.context import context
|
||||
from cli.commands.install import install_browser
|
||||
from cli.commands.knowledge import knowledge
|
||||
from cli.commands.backup import backup_command, restore_command
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
||||
@@ -24,6 +25,8 @@ Commands:
|
||||
logs View CowAgent logs.
|
||||
skill Manage CowAgent skills.
|
||||
knowledge Manage knowledge base.
|
||||
backup Back up config and agent workspace.
|
||||
restore Restore a CowAgent backup.
|
||||
install-browser Install browser tool (Playwright + Chromium).
|
||||
|
||||
Tip: Memory index management lives in chat — send /memory status or
|
||||
@@ -74,6 +77,8 @@ main.add_command(status)
|
||||
main.add_command(logs)
|
||||
main.add_command(context)
|
||||
main.add_command(knowledge)
|
||||
main.add_command(backup_command)
|
||||
main.add_command(restore_command)
|
||||
main.add_command(install_browser)
|
||||
|
||||
|
||||
|
||||
332
cli/commands/backup.py
Normal file
332
cli/commands/backup.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Portable local backup and restore commands for CowAgent user data."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable, Optional, Set
|
||||
|
||||
import click
|
||||
|
||||
from cli.utils import get_project_root
|
||||
|
||||
|
||||
BACKUP_FORMAT = "cowagent-backup"
|
||||
BACKUP_VERSION = 1
|
||||
_SKIP_DIRS = {".git", "__pycache__", "tmp"}
|
||||
_SKIP_FILES = {".DS_Store"}
|
||||
|
||||
|
||||
def _data_root() -> Path:
|
||||
configured = os.environ.get("COW_DATA_DIR")
|
||||
return Path(configured).expanduser().resolve() if configured else Path(get_project_root()).resolve()
|
||||
|
||||
|
||||
def _read_config(data_root: Path) -> dict:
|
||||
path = data_root / "config.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _workspace_from_config(config: dict) -> Path:
|
||||
return Path(config.get("agent_workspace") or "~/cow").expanduser().resolve()
|
||||
|
||||
|
||||
def _legacy_user_data_path(data_root: Path, config: dict) -> Path:
|
||||
appdata_dir = config.get("appdata_dir") or ""
|
||||
return (data_root / appdata_dir / "user_datas.pkl").resolve()
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
return os.path.commonpath([str(path.resolve()), str(root.resolve())]) == str(root.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _iter_workspace_files(workspace: Path, excluded: Set[Path]):
|
||||
if not workspace.is_dir():
|
||||
return
|
||||
for current, dirnames, filenames in os.walk(str(workspace), followlinks=False):
|
||||
current_path = Path(current)
|
||||
dirnames[:] = [
|
||||
name for name in dirnames
|
||||
if name not in _SKIP_DIRS and not (current_path / name).is_symlink()
|
||||
]
|
||||
for name in filenames:
|
||||
path = current_path / name
|
||||
if name in _SKIP_FILES or name.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
if path.is_symlink() or path.resolve() in excluded:
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def create_backup_archive(
|
||||
output: Path,
|
||||
data_root: Path,
|
||||
workspace: Path,
|
||||
excluded_paths: Optional[Iterable[Path]] = None,
|
||||
) -> dict:
|
||||
"""Create a portable archive containing config and the agent workspace."""
|
||||
output = Path(output).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
workspace = Path(workspace).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
excluded = {Path(path).expanduser().resolve() for path in (excluded_paths or [])}
|
||||
excluded.add(output)
|
||||
|
||||
config_path = data_root / "config.json"
|
||||
config = _read_config(data_root)
|
||||
legacy_path = _legacy_user_data_path(data_root, config)
|
||||
workspace_files = list(_iter_workspace_files(workspace, excluded))
|
||||
total_bytes = sum(path.stat().st_size for path in workspace_files)
|
||||
|
||||
manifest = {
|
||||
"format": BACKUP_FORMAT,
|
||||
"version": BACKUP_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"workspace_source": str(workspace),
|
||||
"contents": {
|
||||
"config": config_path.is_file(),
|
||||
"legacy_user_data": legacy_path.is_file(),
|
||||
"workspace_files": len(workspace_files),
|
||||
"workspace_bytes": total_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-backup-"))
|
||||
temp_archive = temp_dir / "backup.zip"
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
str(temp_archive), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"manifest.json",
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
)
|
||||
if config_path.is_file():
|
||||
archive.write(str(config_path), "data/config.json")
|
||||
if legacy_path.is_file():
|
||||
archive.write(str(legacy_path), "data/user_datas.pkl")
|
||||
for path in workspace_files:
|
||||
relative = path.relative_to(workspace).as_posix()
|
||||
archive.write(str(path), "workspace/" + relative)
|
||||
os.replace(str(temp_archive), str(output))
|
||||
try:
|
||||
os.chmod(str(output), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
manifest["archive"] = str(output)
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_archive(archive: zipfile.ZipFile) -> dict:
|
||||
names = {info.filename for info in archive.infolist()}
|
||||
if "manifest.json" not in names:
|
||||
raise ValueError("archive is missing manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("archive manifest is invalid") from exc
|
||||
if manifest.get("format") != BACKUP_FORMAT or manifest.get("version") != BACKUP_VERSION:
|
||||
raise ValueError("unsupported CowAgent backup format or version")
|
||||
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
path = PurePosixPath(name)
|
||||
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
|
||||
raise ValueError(f"unsafe archive path: {name!r}")
|
||||
mode = (info.external_attr >> 16) & 0o170000
|
||||
if mode == stat.S_IFLNK:
|
||||
raise ValueError(f"symbolic links are not allowed in backups: {name!r}")
|
||||
if name != "manifest.json" and not name.startswith(("data/", "workspace/")):
|
||||
raise ValueError(f"unexpected archive entry: {name!r}")
|
||||
return manifest
|
||||
|
||||
|
||||
def _extract_validated(archive: zipfile.ZipFile, destination: Path) -> None:
|
||||
for info in archive.infolist():
|
||||
target = destination.joinpath(*PurePosixPath(info.filename).parts)
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(info, "r") as source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, destination: Path, private: bool = False) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=destination.name + ".", dir=str(destination.parent))
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(str(source), temp_name)
|
||||
os.replace(temp_name, str(destination))
|
||||
if private:
|
||||
try:
|
||||
os.chmod(str(destination), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
archive_path: Path,
|
||||
data_root: Path,
|
||||
workspace: Optional[Path] = None,
|
||||
) -> dict:
|
||||
"""Merge a validated backup into the selected data root and workspace."""
|
||||
archive_path = Path(archive_path).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
current_config = _read_config(data_root)
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-restore-"))
|
||||
try:
|
||||
with zipfile.ZipFile(str(archive_path), "r") as archive:
|
||||
manifest = _validate_archive(archive)
|
||||
_extract_validated(archive, temp_dir)
|
||||
|
||||
archived_config_path = temp_dir / "data" / "config.json"
|
||||
archived_config = {}
|
||||
if archived_config_path.is_file():
|
||||
with archived_config_path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("archived config.json must contain an object")
|
||||
archived_config = value
|
||||
|
||||
if workspace is not None:
|
||||
target_workspace = Path(workspace).expanduser().resolve()
|
||||
elif current_config.get("agent_workspace"):
|
||||
target_workspace = _workspace_from_config(current_config)
|
||||
else:
|
||||
# Do not trust an archive-controlled absolute destination on a
|
||||
# fresh machine. Portable restores default to the standard local
|
||||
# workspace unless the operator supplies --workspace.
|
||||
target_workspace = Path("~/cow").expanduser().resolve()
|
||||
|
||||
restored_config = dict(archived_config)
|
||||
if restored_config:
|
||||
restored_config["agent_workspace"] = str(target_workspace)
|
||||
appdata_dir = restored_config.get("appdata_dir") or ""
|
||||
if appdata_dir:
|
||||
archived_appdata = (data_root / appdata_dir).resolve()
|
||||
if not _is_within(archived_appdata, data_root):
|
||||
# Keep legacy user data under the selected data root
|
||||
# instead of writing to an archive-controlled path.
|
||||
restored_config["appdata_dir"] = ""
|
||||
config_temp = temp_dir / "restored-config.json"
|
||||
with config_temp.open("w", encoding="utf-8") as handle:
|
||||
json.dump(restored_config, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
_atomic_copy(config_temp, data_root / "config.json", private=True)
|
||||
|
||||
workspace_root = temp_dir / "workspace"
|
||||
restored_files = 0
|
||||
if workspace_root.is_dir():
|
||||
for source in _iter_workspace_files(workspace_root, set()):
|
||||
relative = source.relative_to(workspace_root)
|
||||
destination = target_workspace / relative
|
||||
if not _is_within(destination, target_workspace):
|
||||
raise ValueError(f"unsafe workspace destination: {relative}")
|
||||
_atomic_copy(source, destination)
|
||||
restored_files += 1
|
||||
|
||||
legacy_source = temp_dir / "data" / "user_datas.pkl"
|
||||
if legacy_source.is_file():
|
||||
effective_config = restored_config or current_config
|
||||
legacy_destination = _legacy_user_data_path(data_root, effective_config)
|
||||
_atomic_copy(legacy_source, legacy_destination, private=True)
|
||||
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"workspace": str(target_workspace),
|
||||
"workspace_files": restored_files,
|
||||
"config_restored": bool(restored_config),
|
||||
"legacy_user_data_restored": legacy_source.is_file(),
|
||||
}
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
|
||||
@click.command("backup")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Output .zip path (default: ./cow-backup-<timestamp>.zip).",
|
||||
)
|
||||
def backup_command(output: Optional[Path]):
|
||||
"""Back up config, persona, memory, skills, knowledge, and schedules."""
|
||||
data_root = _data_root()
|
||||
config = _read_config(data_root)
|
||||
workspace = _workspace_from_config(config)
|
||||
if output is None:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output = Path.cwd() / f"cow-backup-{stamp}.zip"
|
||||
result = create_backup_archive(output, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup created", fg="green"))
|
||||
click.echo(f" Archive: {result['archive']}")
|
||||
click.echo(f" Workspace files: {result['contents']['workspace_files']}")
|
||||
click.echo(" Keep this archive private: it may contain API keys and personal data.")
|
||||
|
||||
|
||||
@click.command("restore")
|
||||
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--workspace",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
help="Restore workspace files to this directory.",
|
||||
)
|
||||
@click.option("--yes", is_flag=True, help="Confirm overwriting matching files.")
|
||||
def restore_command(archive: Path, workspace: Optional[Path], yes: bool):
|
||||
"""Restore a backup without deleting unrelated destination files."""
|
||||
from cli.commands.process import _read_pid
|
||||
|
||||
pid = _read_pid()
|
||||
if pid:
|
||||
raise click.ClickException(
|
||||
f"CowAgent is running (PID: {pid}). Run 'cow stop' before restoring."
|
||||
)
|
||||
if not yes:
|
||||
click.confirm(
|
||||
"Restore this archive and overwrite matching config/workspace files?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
data_root = _data_root()
|
||||
current_config = _read_config(data_root)
|
||||
current_workspace = _workspace_from_config(current_config)
|
||||
has_current_data = (data_root / "config.json").is_file() or current_workspace.is_dir()
|
||||
if has_current_data:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
rollback = archive.resolve().parent / f"cow-pre-restore-{stamp}.zip"
|
||||
create_backup_archive(
|
||||
rollback,
|
||||
data_root,
|
||||
current_workspace,
|
||||
excluded_paths={archive.resolve()},
|
||||
)
|
||||
click.echo(f"Rollback backup: {rollback}")
|
||||
|
||||
result = restore_backup_archive(archive, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup restored", fg="green"))
|
||||
click.echo(f" Workspace: {result['workspace']}")
|
||||
click.echo(f" Restored files: {result['workspace_files']}")
|
||||
@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||
return ret
|
||||
|
||||
|
||||
def _is_frozen() -> bool:
|
||||
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||
|
||||
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
|
||||
playwright is already bundled and we only need to download the browser
|
||||
binary in-process rather than pip-installing anything.
|
||||
"""
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
|
||||
"""Invoke the Playwright CLI, working in both source and frozen builds.
|
||||
|
||||
Source builds shell out to ``python -m playwright <args>``. Frozen builds
|
||||
can't use ``-m`` (the exe isn't a Python interpreter), so we call
|
||||
Playwright's driver entrypoint in-process instead. ``env`` overrides are
|
||||
applied to os.environ for the duration of the call (frozen path) or passed
|
||||
through to the subprocess (source path).
|
||||
"""
|
||||
if not _is_frozen():
|
||||
cmd = [sys.executable, "-m", "playwright"] + args
|
||||
return subprocess.call(cmd, env=env)
|
||||
|
||||
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
|
||||
# returns the Node driver shipped inside the bundle; we spawn it directly.
|
||||
prev_env = {}
|
||||
if env:
|
||||
for k, v in env.items():
|
||||
prev_env[k] = os.environ.get(k)
|
||||
os.environ[k] = v
|
||||
try:
|
||||
from playwright._impl._driver import compute_driver_executable, get_driver_env
|
||||
driver = compute_driver_executable()
|
||||
# compute_driver_executable may return a tuple (node, cli.js) on newer
|
||||
# Playwright, or a single path on older ones.
|
||||
if isinstance(driver, (list, tuple)):
|
||||
cmd = list(driver) + args
|
||||
else:
|
||||
cmd = [str(driver)] + args
|
||||
# get_driver_env() snapshots os.environ, which we've already patched with
|
||||
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
|
||||
# so mirror + pinned browsers dir are honored here too.
|
||||
return subprocess.call(cmd, env=get_driver_env())
|
||||
except Exception as e:
|
||||
# Last resort: try the module main via runpy (works if the frozen build
|
||||
# kept playwright.__main__ importable).
|
||||
try:
|
||||
import runpy
|
||||
sys.argv = ["playwright"] + args
|
||||
runpy.run_module("playwright", run_name="__main__")
|
||||
return 0
|
||||
except SystemExit as se:
|
||||
return int(se.code or 0)
|
||||
except Exception:
|
||||
return 1
|
||||
finally:
|
||||
for k, v in prev_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
||||
"""CLI: colored click output."""
|
||||
if fg == "yellow":
|
||||
@@ -129,6 +192,7 @@ def run_install_browser(
|
||||
stream = stream or _default_stream
|
||||
python = sys.executable
|
||||
legacy_mode = False
|
||||
frozen = _is_frozen()
|
||||
|
||||
_phase(on_phase, _t(
|
||||
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
||||
@@ -159,7 +223,7 @@ def run_install_browser(
|
||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||
if sys.platform == "win32":
|
||||
if sys.platform == "win32" and not frozen:
|
||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||
ret = subprocess.call(
|
||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||
@@ -172,22 +236,52 @@ def run_install_browser(
|
||||
"yellow",
|
||||
)
|
||||
|
||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||
if ret != 0:
|
||||
stream("Failed to install playwright package.", "red")
|
||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||
return 1
|
||||
if frozen:
|
||||
# Desktop bundle: playwright is already shipped inside the app; there is
|
||||
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||||
installed = _get_installed_version()
|
||||
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||||
_phase(on_phase, _t(
|
||||
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
|
||||
"✅ [1/3] Playwright is bundled in the app; skipping install.",
|
||||
))
|
||||
else:
|
||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||
if ret != 0:
|
||||
stream("Failed to install playwright package.", "red")
|
||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||
return 1
|
||||
|
||||
installed = _get_installed_version()
|
||||
if installed:
|
||||
stream(f" playwright {installed} installed.", "green")
|
||||
stream("")
|
||||
_phase(on_phase, _t(
|
||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||
))
|
||||
installed = _get_installed_version()
|
||||
if installed:
|
||||
stream(f" playwright {installed} installed.", "green")
|
||||
stream("")
|
||||
_phase(on_phase, _t(
|
||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||
))
|
||||
|
||||
# With playwright available, prefer the user's system Chrome/Edge: the browser
|
||||
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
|
||||
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
|
||||
# source) — only headless Linux servers, which usually lack a system browser,
|
||||
# fall through to the download below. Honors prefer_system_browser via
|
||||
# resolve_engine, so users who force downloaded Chromium still get it.
|
||||
try:
|
||||
from agent.tools.browser import browser_env
|
||||
summary = browser_env.capability_summary()
|
||||
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
|
||||
sc = summary.get("system_chrome") or {}
|
||||
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
|
||||
_phase(on_phase, _t(
|
||||
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。",
|
||||
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
|
||||
))
|
||||
return 0
|
||||
except Exception as e:
|
||||
stream(f" (system browser probe skipped: {e})", None)
|
||||
|
||||
if sys.platform == "linux":
|
||||
_phase(on_phase, _t(
|
||||
@@ -195,7 +289,7 @@ def run_install_browser(
|
||||
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
||||
))
|
||||
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
||||
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
||||
ret = _playwright_cli(["install-deps", "chromium"])
|
||||
if ret != 0:
|
||||
stream(
|
||||
" Could not auto-install system deps (may need sudo).\n"
|
||||
@@ -238,12 +332,12 @@ def run_install_browser(
|
||||
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
||||
))
|
||||
stream("[3/3] Installing Chromium browser...", "yellow")
|
||||
cmd = [python, "-m", "playwright", "install", "chromium"]
|
||||
pw_args = ["install", "chromium"]
|
||||
|
||||
if _is_headless_linux() and not legacy_mode:
|
||||
ver = _version_tuple(installed or "")
|
||||
if ver >= (1, 57, 0):
|
||||
cmd.append("--only-shell")
|
||||
pw_args.append("--only-shell")
|
||||
stream(" (headless shell for Linux server)", None)
|
||||
else:
|
||||
stream(" (full Chromium)", None)
|
||||
@@ -251,6 +345,15 @@ def run_install_browser(
|
||||
stream(" (full browser for Linux desktop)", None)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Pin the download location so it survives desktop app updates and matches
|
||||
# what the runtime looks up (see browser_env.browsers_download_dir()).
|
||||
try:
|
||||
from agent.tools.browser.browser_env import browsers_download_dir
|
||||
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
use_mirror = _is_china_network()
|
||||
if use_mirror:
|
||||
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
||||
@@ -260,7 +363,7 @@ def run_install_browser(
|
||||
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
||||
))
|
||||
|
||||
ret = subprocess.call(cmd, env=env)
|
||||
ret = _playwright_cli(pw_args, env=env)
|
||||
|
||||
if ret != 0 and use_mirror:
|
||||
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
||||
@@ -268,9 +371,9 @@ def run_install_browser(
|
||||
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
||||
"⚠️ Mirror download failed; retrying with the official CDN…",
|
||||
))
|
||||
env_no_mirror = os.environ.copy()
|
||||
env_no_mirror = dict(env)
|
||||
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
||||
ret = subprocess.call(cmd, env=env_no_mirror)
|
||||
ret = _playwright_cli(pw_args, env=env_no_mirror)
|
||||
|
||||
if ret != 0:
|
||||
stream("Failed to install Chromium.", "red")
|
||||
@@ -282,10 +385,18 @@ def run_install_browser(
|
||||
|
||||
stream("Verifying browser installation...", None)
|
||||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||||
ret = subprocess.call(
|
||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if frozen:
|
||||
# Frozen: no child interpreter to spawn; import in-process instead.
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright # noqa: F401
|
||||
ret = 0
|
||||
except Exception:
|
||||
ret = 1
|
||||
else:
|
||||
ret = subprocess.call(
|
||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if ret != 0:
|
||||
stream(
|
||||
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
||||
|
||||
@@ -30,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_0620 = "claude-3-5-sonnet-20240620"
|
||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
|
||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||
@@ -80,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
||||
GPT_54_MINI = "gpt-5.4-mini"
|
||||
GPT_54_NANO = "gpt-5.4-nano"
|
||||
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
||||
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
|
||||
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
|
||||
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
|
||||
O1 = "o1-preview"
|
||||
O1_MINI = "o1-mini"
|
||||
WHISPER_1 = "whisper-1"
|
||||
@@ -139,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT = "moonshot"
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
||||
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
|
||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||
KIMI_K2 = "kimi-k2"
|
||||
KIMI_K2_5 = "kimi-k2.5"
|
||||
@@ -200,7 +204,7 @@ MODEL_LIST = [
|
||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||
|
||||
# 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", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||
|
||||
@@ -214,6 +218,7 @@ MODEL_LIST = [
|
||||
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
||||
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
||||
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
||||
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
|
||||
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||
O1, O1_MINI,
|
||||
@@ -231,7 +236,7 @@ MODEL_LIST = [
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
|
||||
# ModelScope
|
||||
MODELSCOPE,
|
||||
|
||||
@@ -42,7 +42,5 @@
|
||||
"reasoning_effort": "high",
|
||||
"knowledge": true,
|
||||
"self_evolution_enabled": true,
|
||||
"mcp_tool_retrieval_enabled": false,
|
||||
"mcp_tool_retrieval_threshold": 20,
|
||||
"mcp_tool_retrieval_top_k": 10
|
||||
"mcp_tool_retrieval_enabled": false
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ available_setting = {
|
||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||
"feishu_detailed_card": True, # render normal chat streaming as a detailed card (status header, thinking/tool panels, elapsed time); off keeps the plain typewriter card
|
||||
# DingTalk config
|
||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||
@@ -253,6 +254,7 @@ available_setting = {
|
||||
"web_password": "", # Web console password; empty means no authentication required
|
||||
"web_session_expire_days": 30, # Auth session expiry in days
|
||||
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
|
||||
"mcp_oauth_redirect_base": "", # Base URL for MCP OAuth callback (e.g. http://your-ip:9899); empty uses local web console
|
||||
"agent": True, # whether to enable Agent mode
|
||||
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
|
||||
"agent_max_context_tokens": 50000, # max context tokens in Agent mode
|
||||
|
||||
@@ -105,6 +105,14 @@ hiddenimports += collect_submodules('docx')
|
||||
hiddenimports += collect_submodules('pptx')
|
||||
hiddenimports += collect_submodules('openpyxl')
|
||||
|
||||
# Playwright powers the browser tool. Only the pure-Python package + its bundled
|
||||
# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled
|
||||
# and is either satisfied by the user's system Chrome/Edge (preferred, zero
|
||||
# download) or downloaded on demand into ~/.cow/ms-playwright at first use.
|
||||
# Playwright imports its transport/driver lazily, so list submodules explicitly.
|
||||
hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl']
|
||||
hiddenimports += collect_submodules('playwright')
|
||||
|
||||
# --- Data files -----------------------------------------------------------
|
||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||
# (source, dest_dir_in_bundle).
|
||||
@@ -134,6 +142,12 @@ datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
||||
datas += collect_data_files('docx')
|
||||
datas += collect_data_files('pptx')
|
||||
|
||||
# Playwright ships its Node.js driver + package.json under playwright/driver/.
|
||||
# These are NOT Python modules, so hiddenimports won't pull them in — collect
|
||||
# them as data or `playwright install` / launching fails in the frozen build.
|
||||
# include_py_files=True is required: the driver dir contains .py entrypoints.
|
||||
datas += collect_data_files('playwright', include_py_files=True)
|
||||
|
||||
# --- Excludes -------------------------------------------------------------
|
||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||
# mode), tests/docs, and dev-only packages.
|
||||
@@ -143,7 +157,10 @@ excludes = [
|
||||
'pip',
|
||||
'wheel',
|
||||
'pytest',
|
||||
'playwright', # browser tool is opt-in, not bundled
|
||||
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
|
||||
# so the browser tool works out of the box on desktop. The heavy Chromium
|
||||
# binary is still NOT bundled: it comes from the user's system Chrome/Edge or
|
||||
# is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py.
|
||||
]
|
||||
|
||||
block_cipher = None
|
||||
|
||||
@@ -42,6 +42,12 @@ python-docx
|
||||
openpyxl
|
||||
python-pptx
|
||||
|
||||
# ---- browser tool ----
|
||||
# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB).
|
||||
# The Chromium binary is NOT bundled: the browser tool drives the user's system
|
||||
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
||||
playwright==1.52.0
|
||||
|
||||
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
||||
wechatpy
|
||||
pycryptodome
|
||||
|
||||
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 path from 'path'
|
||||
import os from 'os'
|
||||
@@ -39,6 +39,77 @@ export class PythonBackend extends EventEmitter {
|
||||
return this.status
|
||||
}
|
||||
|
||||
// Cache the resolved PATH so we only spawn a login shell once per process.
|
||||
private resolvedPath: string | null = null
|
||||
|
||||
/**
|
||||
* Build the PATH the backend should run with.
|
||||
*
|
||||
* When launched from Finder/Dock, a GUI app inherits launchd's minimal PATH
|
||||
* (/usr/bin:/bin:...) and never loads ~/.zshrc, so user-installed CLIs like
|
||||
* `linkai`, `node`, or Homebrew tools are invisible to the agent's bash tool.
|
||||
* We recover the real login-shell PATH (macOS/Linux) and merge in common bin
|
||||
* dirs, so the agent can find these commands regardless of how the app started.
|
||||
*/
|
||||
private resolveEnvPath(): string {
|
||||
if (this.resolvedPath !== null) {
|
||||
return this.resolvedPath
|
||||
}
|
||||
|
||||
const sep = path.delimiter
|
||||
const existing = process.env.PATH || ''
|
||||
const parts: string[] = existing ? existing.split(sep) : []
|
||||
|
||||
// Windows GUI apps already inherit the full system PATH; nothing to fix.
|
||||
if (process.platform !== 'win32') {
|
||||
// Ask the user's login shell for its PATH. `-ilc` runs an interactive
|
||||
// login shell so it sources ~/.zshrc / ~/.zprofile etc.
|
||||
try {
|
||||
const shell = process.env.SHELL || '/bin/zsh'
|
||||
const out = execFileSync(shell, ['-ilc', 'echo -n "__PATH__$PATH"'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
const marker = out.lastIndexOf('__PATH__')
|
||||
if (marker !== -1) {
|
||||
const shellPath = out.slice(marker + '__PATH__'.length).trim()
|
||||
if (shellPath) {
|
||||
parts.push(...shellPath.split(sep))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Shell probe failed (unusual shell, timeout). Fall back to the
|
||||
// common dirs below so at least the typical install paths work.
|
||||
}
|
||||
|
||||
const home = os.homedir()
|
||||
parts.push(
|
||||
path.join(home, '.local/bin'),
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/usr/sbin',
|
||||
'/sbin',
|
||||
)
|
||||
}
|
||||
|
||||
// De-duplicate while preserving order (first occurrence wins).
|
||||
const seen = new Set<string>()
|
||||
const merged: string[] = []
|
||||
for (const p of parts) {
|
||||
const dir = p.trim()
|
||||
if (dir && !seen.has(dir)) {
|
||||
seen.add(dir)
|
||||
merged.push(dir)
|
||||
}
|
||||
}
|
||||
|
||||
this.resolvedPath = merged.join(sep)
|
||||
return this.resolvedPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the packaged onedir backend executable shipped with the app.
|
||||
* Returns null when not present (e.g. during local development), so we can
|
||||
@@ -261,6 +332,10 @@ export class PythonBackend extends EventEmitter {
|
||||
// app bundle stays read-only; dev runs omit it and keep using the repo.
|
||||
env: {
|
||||
...process.env,
|
||||
// Recover the user's real PATH (login shell + common bin dirs) so the
|
||||
// agent's bash tool can find CLIs like `linkai`/`node` even when the
|
||||
// app is launched from Finder/Dock with launchd's minimal PATH.
|
||||
PATH: this.resolveEnvPath(),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
COW_DESKTOP: '1',
|
||||
// The shell owns the port: tell the backend to bind exactly here so the
|
||||
@@ -315,7 +390,10 @@ export class PythonBackend extends EventEmitter {
|
||||
const startedAt = Date.now()
|
||||
|
||||
const check = () => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: /config
|
||||
// requires auth once a web_password is set, which would make this poll
|
||||
// 401 forever and hang startup.
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend ready on port ${this.port}`)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
||||
|
||||
let getWindow: () => BrowserWindow | null = () => null
|
||||
|
||||
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||
// flag, so the same source serves the right feed on whatever it runs on.
|
||||
function isLegacyWindows(): boolean {
|
||||
if (process.platform !== 'win32') return false
|
||||
const major = Number((os.release() || '').split('.')[0])
|
||||
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||
return Number.isFinite(major) && major < 10
|
||||
}
|
||||
|
||||
// The update feed. Both entries hit the same Pages Function
|
||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||
// identical either way, so we can freely switch the feed URL between attempts
|
||||
// to fall back from one download origin to the other.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
||||
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||
|
||||
// Which origin the current session prefers, derived from the app UI language
|
||||
|
||||
@@ -5,6 +5,7 @@ import NavRail from './layout/NavRail'
|
||||
import SessionList from './layout/SessionList'
|
||||
import WindowControls from './layout/WindowControls'
|
||||
import StatusScreen from './components/StatusScreen'
|
||||
import LoginGate from './components/LoginGate'
|
||||
import { useBackend } from './hooks/useBackend'
|
||||
import { usePlatform } from './hooks/usePlatform'
|
||||
import { useUIStore } from './store/uiStore'
|
||||
@@ -32,16 +33,45 @@ const App: React.FC = () => {
|
||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||
const [, forceUpdate] = useState(0)
|
||||
// Auth gate for web_password-protected backends. 'checking' until we know
|
||||
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
||||
// the main UI render.
|
||||
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// Once the backend is ready, check whether a web_password is set. If so and
|
||||
// this session isn't authenticated, show the login gate before the app.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') {
|
||||
setAuthState('checking')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.authCheck()
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const needLogin = res.auth_required && !res.authenticated
|
||||
setAuthState(needLogin ? 'need_login' : 'ok')
|
||||
})
|
||||
.catch(() => {
|
||||
// If the check itself fails, don't hard-block the user — assume no auth
|
||||
// is required (backends without web_password never return errors here).
|
||||
if (!cancelled) setAuthState('ok')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// First-run check: once the backend is ready, decide whether to show the
|
||||
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||
// configured (and not dismissed earlier this session); no persisted flag.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') return
|
||||
if (backend.status !== 'ready' || authState !== 'ok') return
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.getModels()
|
||||
@@ -65,7 +95,7 @@ const App: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, maybeOpenOnboarding])
|
||||
}, [backend.status, authState, maybeOpenOnboarding])
|
||||
|
||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||
useEffect(() => initUpdateListener(), [])
|
||||
@@ -91,6 +121,15 @@ const App: React.FC = () => {
|
||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
// Backend is up but we're still resolving auth — keep the loading screen.
|
||||
if (authState === 'checking') {
|
||||
return <StatusScreen status="connecting" onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
if (authState === 'need_login') {
|
||||
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
|
||||
}
|
||||
|
||||
const isChat = location.pathname === '/'
|
||||
const showSessions = isChat && !sessionsCollapsed
|
||||
|
||||
|
||||
@@ -24,8 +24,16 @@ interface ApiResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_KEY = 'cow_auth_token'
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9876'
|
||||
// Bearer token for web_password-protected backends. The desktop renderer
|
||||
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
|
||||
// aren't sent reliably, so we authenticate via an Authorization header
|
||||
// instead. Persisted in localStorage so it survives reloads.
|
||||
private authToken: string | null =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url
|
||||
@@ -35,13 +43,25 @@ class ApiClient {
|
||||
return this.baseUrl
|
||||
}
|
||||
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||
else localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
} catch {
|
||||
// localStorage may be unavailable; in-memory token still works this session
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
// Send cookies for future web_password auth support
|
||||
// Cookies still work for browser access; the desktop app relies on the
|
||||
// Authorization header below.
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
@@ -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 {
|
||||
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
|
||||
}
|
||||
|
||||
async deleteMessage(opts: {
|
||||
@@ -138,11 +166,13 @@ class ApiClient {
|
||||
|
||||
getFileUrl(previewUrl: string): string {
|
||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
||||
return `${this.baseUrl}${previewUrl}`
|
||||
// Served via <img src>, which can't set headers — carry the token in the
|
||||
// query so protected file endpoints load under web_password.
|
||||
return this.withToken(`${this.baseUrl}${previewUrl}`)
|
||||
}
|
||||
|
||||
getServeFileUrl(absPath: string): string {
|
||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
||||
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
@@ -342,6 +372,13 @@ class ApiClient {
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
async runTask(taskId: string): Promise<ApiResult> {
|
||||
return this.request('/api/scheduler/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
}
|
||||
|
||||
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
||||
return this.request('/api/scheduler/toggle', {
|
||||
method: 'POST',
|
||||
@@ -390,7 +427,7 @@ class ApiClient {
|
||||
// ---------------------------------------------------------
|
||||
|
||||
createLogStream(): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
@@ -406,14 +443,19 @@ class ApiClient {
|
||||
return this.request('/auth/check')
|
||||
}
|
||||
|
||||
async authLogin(password: string): Promise<ApiResult> {
|
||||
return this.request('/auth/login', {
|
||||
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
|
||||
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (res.status === 'success' && res.token) {
|
||||
this.setAuthToken(res.token)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async authLogout(): Promise<ApiResult> {
|
||||
this.setAuthToken(null)
|
||||
return this.request('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||
{ cmd: '/install-browser', desc: t('slash_install_browser') },
|
||||
{ cmd: '/config', desc: t('slash_config') },
|
||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||
{ cmd: '/logs', desc: t('slash_logs') },
|
||||
|
||||
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface LoginGateProps {
|
||||
// Called once the password is accepted (auth cookie set), so the app can
|
||||
// proceed to the main UI.
|
||||
onAuthenticated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the backend has a web_password set and the current session isn't
|
||||
* authenticated yet. Submitting the correct password sets an auth cookie
|
||||
* (handled by the backend), after which the app reloads its data.
|
||||
*/
|
||||
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await apiClient.authLogin(password)
|
||||
if (res.status === 'success') {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
setError(t('login_error'))
|
||||
}
|
||||
} catch {
|
||||
setError(t('login_error'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
|
||||
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder={t('login_placeholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !password}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{submitting ? t('login_checking') : t('login_submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginGate
|
||||
@@ -30,6 +30,34 @@ const md: MarkdownIt = new MarkdownIt({
|
||||
},
|
||||
})
|
||||
|
||||
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`)
|
||||
// and CJK full-width punctuation glued to a URL (common in LLM output like
|
||||
// `**https://x**,中文`), turning the whole tail into one broken link. Cut the
|
||||
// URL at the first such char and spill the remainder back as plain text.
|
||||
const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/
|
||||
md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => {
|
||||
for (const blk of state.tokens) {
|
||||
if (blk.type !== 'inline' || !blk.children) continue
|
||||
const ch = blk.children
|
||||
for (let i = 0; i < ch.length; i++) {
|
||||
const open = ch[i]
|
||||
if (open.type !== 'link_open' || open.markup !== 'linkify') continue
|
||||
const textTok = ch[i + 1]
|
||||
const close = ch[i + 2]
|
||||
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue
|
||||
const idx = textTok.content.search(_GREEDY_LINK_CUT)
|
||||
if (idx < 0) continue
|
||||
const keep = textTok.content.slice(0, idx)
|
||||
const spill = textTok.content.slice(idx)
|
||||
textTok.content = keep
|
||||
open.attrSet('href', keep)
|
||||
const spillTok = new state.Token('text', '', 0)
|
||||
spillTok.content = spill
|
||||
ch.splice(i + 3, 0, spillTok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Open links in a new tab safely.
|
||||
const defaultLinkOpen =
|
||||
md.renderer.rules.link_open ||
|
||||
@@ -72,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string
|
||||
/**
|
||||
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||
* the knowledge viewer so index links open the target doc rather than firing
|
||||
* an "application cannot be opened (-120)" error in Electron.
|
||||
*/
|
||||
onInternalLink?: (href: string) => void
|
||||
}
|
||||
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const html = useMemo(() => md.render(content || ''), [content])
|
||||
|
||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||
if (!btn) return
|
||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||
if (!pre) return
|
||||
navigator.clipboard.writeText(pre.textContent || '')
|
||||
const original = btn.textContent
|
||||
btn.textContent = t('msg_copied')
|
||||
btn.classList.add('copied')
|
||||
setTimeout(() => {
|
||||
btn.textContent = original
|
||||
btn.classList.remove('copied')
|
||||
}, 1600)
|
||||
}, [])
|
||||
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||
if (onInternalLink) {
|
||||
const a = target.closest('a') as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
const href = a.getAttribute('href') || ''
|
||||
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||
e.preventDefault()
|
||||
onInternalLink(href)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||
if (!btn) return
|
||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||
if (!pre) return
|
||||
navigator.clipboard.writeText(pre.textContent || '')
|
||||
const original = btn.textContent
|
||||
btn.textContent = t('msg_copied')
|
||||
btn.classList.add('copied')
|
||||
setTimeout(() => {
|
||||
btn.textContent = original
|
||||
btn.classList.remove('copied')
|
||||
}, 1600)
|
||||
},
|
||||
[onInternalLink]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
muted, separated from the final answer by a dashed divider. */}
|
||||
{(hasSteps || hasLiveReasoning) && (
|
||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { t } from '../i18n'
|
||||
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"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Brain size={12} className="flex-shrink-0" />
|
||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
||||
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{expanded && (
|
||||
|
||||
@@ -22,7 +22,10 @@ export function useBackend() {
|
||||
|
||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/config`, {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: once a
|
||||
// web_password is set, /config returns 401 and we'd wrongly treat the
|
||||
// (healthy) backend as unreachable, hanging on "connecting".
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
|
||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: '已中止',
|
||||
msg_self_learned: '自主学习',
|
||||
msg_stop: '停止',
|
||||
thinking_in_progress: '思考中…',
|
||||
thinking_done: '已深度思考',
|
||||
chat_clear_context: '清除上下文',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
chat_load_earlier: '加载更早的消息',
|
||||
chat_send: '发送',
|
||||
chat_attach: '添加附件',
|
||||
slash_hint: '输入 / 查看命令',
|
||||
chat_welcome: '有什么可以帮你的?',
|
||||
chat_empty_hint: '发送一条消息开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '查看工作空间里有哪些文件',
|
||||
example_task_title: '定时任务',
|
||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: '已连接',
|
||||
channels_available_section: '可添加',
|
||||
channels_empty_connected: '暂无已连接的通道',
|
||||
channels_empty: '暂未接入任何通道',
|
||||
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||
channels_save_ok: '已保存',
|
||||
channels_save_error: '保存失败',
|
||||
@@ -347,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
task_delete: '删除',
|
||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||
task_save_error: '保存失败',
|
||||
task_run_now: '立即执行',
|
||||
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||
task_run_started: '任务已开始执行',
|
||||
task_run_error: '执行失败',
|
||||
logs_title: '日志',
|
||||
logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时',
|
||||
@@ -356,6 +365,13 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: '初始化失败',
|
||||
status_error_desc: '客户端初始化失败,请重试',
|
||||
status_retry: '重试',
|
||||
// login (web_password)
|
||||
login_title: '请输入访问密码',
|
||||
login_desc: '此客户端已设置访问密码,请输入以继续',
|
||||
login_placeholder: '访问密码',
|
||||
login_submit: '进入',
|
||||
login_error: '密码错误,请重试',
|
||||
login_checking: '验证中...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: '命令',
|
||||
slash_new: '新建对话',
|
||||
@@ -369,6 +385,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||
slash_knowledge: '查看知识库统计',
|
||||
slash_knowledge_list: '查看知识库文件树',
|
||||
slash_install_browser: '安装浏览器工具',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_logs: '查看最近日志',
|
||||
@@ -504,7 +521,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: 'Cancelled',
|
||||
msg_self_learned: 'Self-learned',
|
||||
msg_stop: 'Stop',
|
||||
thinking_in_progress: 'Thinking…',
|
||||
thinking_done: 'Thought',
|
||||
chat_clear_context: 'Clear context',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
chat_load_earlier: 'Load earlier messages',
|
||||
chat_send: 'Send',
|
||||
chat_attach: 'Attach file',
|
||||
@@ -663,6 +683,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: 'Connected',
|
||||
channels_available_section: 'Available',
|
||||
channels_empty_connected: 'No connected channels yet',
|
||||
channels_empty: 'No channels connected',
|
||||
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||
channels_save_ok: 'Saved',
|
||||
channels_save_error: 'Failed to save',
|
||||
@@ -722,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
task_delete: 'Delete',
|
||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||
task_save_error: 'Failed to save',
|
||||
task_run_now: 'Run now',
|
||||
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||
task_run_started: 'Task run started',
|
||||
task_run_error: 'Failed to run task',
|
||||
logs_title: 'Logs',
|
||||
logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live',
|
||||
@@ -731,6 +757,13 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: 'Initialization Failed',
|
||||
status_error_desc: 'Failed to initialize the client, please retry',
|
||||
status_retry: 'Retry',
|
||||
// login (web_password)
|
||||
login_title: 'Enter access password',
|
||||
login_desc: 'This client is password-protected. Enter the password to continue.',
|
||||
login_placeholder: 'Access password',
|
||||
login_submit: 'Enter',
|
||||
login_error: 'Wrong password, please try again',
|
||||
login_checking: 'Verifying...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: 'Commands',
|
||||
slash_new: 'New chat',
|
||||
@@ -744,6 +777,7 @@ const translations: Record<string, Record<string, string>> = {
|
||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||
slash_knowledge: 'Show knowledge base stats',
|
||||
slash_knowledge_list: 'Show knowledge base file tree',
|
||||
slash_install_browser: 'Install browser tool',
|
||||
slash_config: 'Show current config',
|
||||
slash_cancel: 'Abort the running agent task',
|
||||
slash_logs: 'Show recent logs',
|
||||
|
||||
@@ -27,21 +27,21 @@
|
||||
--danger-border: rgba(239, 68, 68, 0.3);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Light theme — layered neutral surfaces */
|
||||
--bg-base: #fafafa; /* app background */
|
||||
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||
--bg-surface: #ffffff; /* panels, cards */
|
||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
||||
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||
--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-secondary: #52525b; /* body, labels */
|
||||
--text-tertiary: #71717a; /* hints, captions */
|
||||
--text-disabled: #a1a1aa;
|
||||
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||
--text-disabled: #94a3b8; /* slate-400 */
|
||||
|
||||
--border-default: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-subtle: #f0f0f1;
|
||||
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||
--border-strong: #d1d5db; /* gray-300 */
|
||||
--border-subtle: #f3f4f6; /* gray-100 */
|
||||
|
||||
--overlay: rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Headset,
|
||||
Hash,
|
||||
AtSign,
|
||||
RadioTower,
|
||||
} from 'lucide-react'
|
||||
import { t, localizedLabel } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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} />)
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
||||
import type { Attachment, ChatMessage } from '../types'
|
||||
import { useChatStore } from '../store/chatStore'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
|
||||
interface ChatPageProps {
|
||||
baseUrl: string
|
||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||
const clearContext = useChatStore((s) => s.clearContext)
|
||||
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||
|
||||
const messages = session?.messages ?? []
|
||||
const isStreaming = session?.isStreaming ?? false
|
||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const id = newSession()
|
||||
ensureSession(id)
|
||||
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 () => {
|
||||
try {
|
||||
await apiClient.clearContext(activeId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [activeId])
|
||||
await clearContext(activeId)
|
||||
scrollToBottom(true)
|
||||
}, [clearContext, activeId, scrollToBottom])
|
||||
|
||||
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">
|
||||
<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>
|
||||
<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">
|
||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-3 max-w-3xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onRegenerate={handleRegenerate}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onMediaLoad={handleMediaLoad}
|
||||
/>
|
||||
))}
|
||||
{messages.map((msg) =>
|
||||
msg.kind === 'divider' ? (
|
||||
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||
<span
|
||||
className="flex-1 h-px"
|
||||
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
||||
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).
|
||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||
const paths: string[] = []
|
||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | 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.
|
||||
function findTitle(list: KnowledgeList, path: string): string {
|
||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
setContent('')
|
||||
try {
|
||||
const res = await apiClient.readKnowledge(path)
|
||||
setContent(res.content || '')
|
||||
setContent(stripDuplicateH1(res.content || '', title))
|
||||
} catch {
|
||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||
} 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
|
||||
// currently open doc (or open the first one on the initial load).
|
||||
const refresh = useCallback(
|
||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
<Loader2 size={16} className="animate-spin mr-2" />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown content={content} />
|
||||
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Loader2, Clock, CalendarClock } from 'lucide-react'
|
||||
import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
|
||||
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [runStatus, setRunStatus] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const buildSchedule = (): TaskSchedule => {
|
||||
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
if (!window.confirm(t('task_run_confirm'))) return
|
||||
setRunning(true)
|
||||
setRunStatus('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await apiClient.runTask(task.id)
|
||||
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
|
||||
setRunStatus(t('task_run_started'))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('task_run_error'))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
|
||||
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||
{t('task_delete')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
|
||||
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
|
||||
{t('task_run_now')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t('task_cancel')}
|
||||
</Btn>
|
||||
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
|
||||
)}
|
||||
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
||||
|
||||
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setMaxSteps(data.agent_max_steps ?? 20)
|
||||
setThinking(!!data.enable_thinking)
|
||||
setEvolution(!!data.self_evolution_enabled)
|
||||
setPassword(data.web_password_masked || '')
|
||||
// Prefer the real password (desktop only) so it can be edited in place;
|
||||
// fall back to the masked value for browser access.
|
||||
setPassword(data.web_password ?? data.web_password_masked ?? '')
|
||||
setPwDirty(false)
|
||||
|
||||
const ids = data.providers ? Object.keys(data.providers) : []
|
||||
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setTimeout(() => setAgentStatus(''), 2000)
|
||||
}
|
||||
|
||||
// Desktop returns the real password, so the field holds plaintext and can be
|
||||
// saved (including cleared) directly. Browser access only has the masked
|
||||
// value, where a masked string must never be saved as the real password.
|
||||
const hasRealPassword = config?.web_password !== undefined
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!pwDirty || MASK_RE.test(password)) return
|
||||
if (!pwDirty) return
|
||||
if (!hasRealPassword && MASK_RE.test(password)) return
|
||||
try {
|
||||
await apiClient.updateConfig({ web_password: password })
|
||||
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||
@@ -292,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
value={password}
|
||||
placeholder={t('config_password_placeholder')}
|
||||
onFocus={() => {
|
||||
if (!pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
// Browser access shows a mask; clear it on focus so the user
|
||||
// types a fresh password. Desktop holds the real password and
|
||||
// must stay editable in place (cursor at the end).
|
||||
if (!hasRealPassword && !pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
||||
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
|
||||
@@ -31,6 +31,7 @@ interface ChatState {
|
||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||
|
||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||
clearContext: (sid: string) => Promise<boolean>
|
||||
clearLocal: (sid: string) => void
|
||||
}
|
||||
|
||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
cancel: async (sid) => {
|
||||
const s = get().sessions[sid]
|
||||
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 {
|
||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||
} 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) => {
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UIState {
|
||||
/** Session list panel collapsed (hidden) vs expanded. */
|
||||
sessionsCollapsed: boolean
|
||||
toggleSessions: () => void
|
||||
setSessionsCollapsed: (v: boolean) => void
|
||||
|
||||
/** Currently active session id (Chat page). */
|
||||
activeSessionId: string | null
|
||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||
return { sessionsCollapsed: next }
|
||||
}),
|
||||
setSessionsCollapsed: (v) => {
|
||||
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||
set({ sessionsCollapsed: v })
|
||||
},
|
||||
|
||||
activeSessionId: null,
|
||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
||||
// 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). */
|
||||
export interface MessageStep {
|
||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
||||
/** Sequence numbers from backend (for delete/regenerate). */
|
||||
userSeq?: number
|
||||
botSeq?: number
|
||||
/** Self-evolution bubble flag. */
|
||||
kind?: 'evolution'
|
||||
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||
kind?: 'evolution' | 'divider'
|
||||
extras?: Record<string, unknown>
|
||||
isStreaming?: boolean
|
||||
isCancelled?: boolean
|
||||
@@ -230,6 +230,9 @@ export interface ConfigData {
|
||||
api_keys: Record<string, string>
|
||||
providers: Record<string, ProviderMeta>
|
||||
web_password_masked?: string
|
||||
// Real password, only returned to the desktop app (trusted local machine) so
|
||||
// it can be edited in place. Undefined for browser access.
|
||||
web_password?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -73,6 +73,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| `feishu_app_id` | Feishu app App ID | - |
|
||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||
| `feishu_detailed_card` | Use a detailed card (tool calls, thinking process, elapsed time) for streaming replies; off keeps the plain typewriter card | `true` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -84,7 +85,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
|
||||
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
|
||||
|
||||
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
||||
3. (Optional) Under **Callbacks**, add **Card Action Trigger** (`card.action.trigger`) to enable `/tasks` scheduler commands; add the **Message Recalled** (`im.message.recalled_v1`) event to cancel a task by recalling its message.
|
||||
|
||||
4. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||
|
||||
@@ -97,7 +100,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| Text messages | ✅ send/receive |
|
||||
| Image messages | ✅ send/receive |
|
||||
| Voice messages | ✅ send/receive |
|
||||
| Quoted replies | ✅ quoted text and rich-post context |
|
||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
|
||||
| Detailed card | ✅ tool calls, thinking process and elapsed time (controlled by `feishu_detailed_card`, on by default) |
|
||||
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
|
||||
|
||||
<Note>
|
||||
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
||||
@@ -108,3 +115,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
After connection, search for the bot name in Feishu to start a chat.
|
||||
|
||||
To use in groups, add the bot to a group and @-mention it.
|
||||
|
||||
Send `/tasks` in a private chat, or @-mention the bot with `/tasks` in a group, to manage tasks belonging to that chat.
|
||||
|
||||
@@ -94,6 +94,7 @@ On startup, the channel registers a command menu with BotFather. Typing `/` in T
|
||||
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
|
||||
| `/config` | View current config |
|
||||
| `/cancel` | Cancel the running Agent task |
|
||||
| `/steer` | Guide the running Agent task (`/steer <instruction>`) |
|
||||
| `/logs` | View recent logs |
|
||||
| `/version` | Show version |
|
||||
|
||||
|
||||
51
docs/cli/backup.mdx
Normal file
51
docs/cli/backup.mdx
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: Backup and Restore
|
||||
description: Export and restore CowAgent configuration and agent workspace data
|
||||
---
|
||||
|
||||
CowAgent can create a portable local archive for migration or disaster recovery.
|
||||
|
||||
## Create a backup
|
||||
|
||||
```bash
|
||||
cow backup
|
||||
cow backup --output /safe/location/cow-backup.zip
|
||||
```
|
||||
|
||||
The archive contains:
|
||||
|
||||
- `config.json`
|
||||
- Agent persona and user files such as `AGENT.md`, `USER.md`, and `MEMORY.md`
|
||||
- Daily memory, session history, knowledge, custom skills, and scheduled tasks in the configured workspace
|
||||
- Legacy `user_datas.pkl`, when present
|
||||
|
||||
Transient `tmp/` data, caches, Git metadata, and symbolic links are skipped. The
|
||||
archive is written with owner-only permissions where the operating system
|
||||
supports them.
|
||||
|
||||
<Warning>
|
||||
A backup may contain API keys, conversation history, and other personal data.
|
||||
Store and transfer it as a secret. The ZIP file is not encrypted.
|
||||
</Warning>
|
||||
|
||||
## Restore a backup
|
||||
|
||||
Stop CowAgent before restoring:
|
||||
|
||||
```bash
|
||||
cow stop
|
||||
cow restore /safe/location/cow-backup.zip
|
||||
```
|
||||
|
||||
Use `--workspace` to migrate workspace files to a different location:
|
||||
|
||||
```bash
|
||||
cow restore cow-backup.zip --workspace ~/cow-restored
|
||||
```
|
||||
|
||||
Restore validates the archive format and paths before writing anything. It
|
||||
overwrites matching files but does not delete unrelated files already present
|
||||
at the destination. When current CowAgent data exists, the command first
|
||||
creates a `cow-pre-restore-*.zip` rollback archive beside the selected backup.
|
||||
|
||||
For unattended scripts, pass `--yes` to acknowledge overwrites.
|
||||
@@ -33,6 +33,16 @@ Abort the agent task currently running in this session. When the agent is busy w
|
||||
/cancel
|
||||
```
|
||||
|
||||
## steer
|
||||
|
||||
Redirect the Agent task currently running in this session without cancelling it. The instruction is injected at the next safe checkpoint; a tool that is already running may finish, while tools that have not started are skipped. If no task is active, `/steer` does not start or queue a new one. Available across all chat channels.
|
||||
|
||||
```text
|
||||
/steer focus on the failing tests first
|
||||
```
|
||||
|
||||
In the Web console, enter an instruction while a reply is running and click **Steer active task**. Sending an ordinary message still uses the session queue.
|
||||
|
||||
## config
|
||||
|
||||
View or modify runtime configuration. Changes take effect immediately without restarting.
|
||||
|
||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
||||
memory Memory distillation (dream)
|
||||
knowledge View knowledge base stats and structure
|
||||
|
||||
Data portability:
|
||||
backup Back up config and agent workspace
|
||||
restore Restore a CowAgent backup
|
||||
|
||||
Others:
|
||||
help Show this help message
|
||||
version Show version
|
||||
@@ -58,6 +62,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
||||
| `/help` | Show command help |
|
||||
| `/status` | View service status and configuration |
|
||||
| `/cancel` | Abort the currently running agent task |
|
||||
| `/steer <instruction>` | Guide the currently running agent task without queueing a new turn |
|
||||
| `/config` | View or modify runtime configuration |
|
||||
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
|
||||
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |
|
||||
@@ -90,6 +95,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
||||
| start / stop / restart | ✓ | ✗ |
|
||||
| update | ✓ | ✗ |
|
||||
| install-browser | ✓ | ✗ |
|
||||
| backup / restore | ✓ | ✗ |
|
||||
|
||||
<Note>
|
||||
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
"cli/process",
|
||||
"cli/skill",
|
||||
"cli/memory-knowledge",
|
||||
"cli/backup",
|
||||
"cli/general"
|
||||
]
|
||||
}
|
||||
@@ -473,6 +474,7 @@
|
||||
"zh/cli/process",
|
||||
"zh/cli/skill",
|
||||
"zh/cli/memory-knowledge",
|
||||
"zh/cli/backup",
|
||||
"zh/cli/general"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent Desktop client" />
|
||||
|
||||
## Download & Install
|
||||
|
||||
<Card title="Go to the download page" icon="download" href="https://cowagent.ai/download/">
|
||||
@@ -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.
|
||||
- **Command-line deployment**: best for developers or long-running servers with more customization. See [Quick Start](/guide/quick-start).
|
||||
|
||||
## Access from a Browser
|
||||
|
||||
Once the desktop client is running, it listens on port `9876` locally, and its backend is exactly the same Web console. So while the app is open you can also just point your browser at `http://localhost:9876` for the same full experience as the client UI.
|
||||
|
||||
## Local Data Storage
|
||||
|
||||
All data of the desktop client is stored on your machine:
|
||||
|
||||
- **Config directory**: `~/.cow` in your home folder, holding `config.json` (model keys, channels, etc.) along with logs, cache and other runtime data.
|
||||
- **Workspace**: `~/cow` by default, holding chat history, knowledge base, memory, skills, scheduled tasks and other files produced by the Agent.
|
||||
|
||||
Uninstalling the client does not delete these two directories, so your data is preserved across reinstalls. To fully clean up or migrate to another device, just back up or remove the corresponding folders manually.
|
||||
|
||||
@@ -59,3 +59,9 @@ sudo docker compose up -d
|
||||
<Tip>
|
||||
Back up `config.json` before upgrading. For Docker deployments, mount the workspace directory as a volume to persist data across upgrades.
|
||||
</Tip>
|
||||
|
||||
## Desktop client upgrade
|
||||
|
||||
The [desktop client](/guide/desktop) has built-in auto-update: it checks for new versions automatically and prompts you, so you can download and restart to upgrade in one click.
|
||||
|
||||
You can also grab the latest version anytime from the [download page](https://cowagent.ai/download/).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -106,14 +106,14 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
||||
|
||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
@@ -230,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
||||
|
||||
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
||||
|
||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| `feishu_app_id` | Feishu アプリの App ID | - |
|
||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||
| `feishu_detailed_card` | ストリーミング応答を詳細カード(ツール呼び出し、思考過程、経過時間)で表示。無効時は通常のタイプライターカード | `true` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -81,7 +82,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
|
||||
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
||||
|
||||
3. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||
3. (任意)**コールバック** で **カードアクショントリガー**(`card.action.trigger`)を追加すると `/tasks` のスケジューラー管理コマンドを使用できます。**メッセージ撤回**(`im.message.recalled_v1`)イベントを追加すると、メッセージの撤回でタスクをキャンセルできます。
|
||||
|
||||
4. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||
|
||||
@@ -94,7 +97,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| テキストメッセージ | ✅ 送受信 |
|
||||
| 画像メッセージ | ✅ 送受信 |
|
||||
| 音声メッセージ | ✅ 送受信 |
|
||||
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
|
||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
||||
| 詳細カード | ✅ ツール呼び出し、思考過程、経過時間(`feishu_detailed_card` で制御、デフォルト有効) |
|
||||
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||
|
||||
<Note>
|
||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||
@@ -105,3 +112,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
||||
|
||||
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
||||
|
||||
個人チャットでは `/tasks`、グループでは Bot に @メンションして `/tasks` を送ると、そのチャットのタスクを管理できます。
|
||||
|
||||
@@ -94,6 +94,7 @@ description: Telegram Bot API 経由で CowAgent を接続
|
||||
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) |
|
||||
| `/config` | 現在の設定を表示 |
|
||||
| `/cancel` | 実行中の Agent タスクを中断 |
|
||||
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>`) |
|
||||
| `/logs` | 最近のログを表示 |
|
||||
| `/version` | バージョンを表示 |
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
|
||||
/cancel
|
||||
```
|
||||
|
||||
## steer
|
||||
|
||||
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
|
||||
|
||||
```text
|
||||
/steer 失敗しているテストを先に確認して
|
||||
```
|
||||
|
||||
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
|
||||
|
||||
## config
|
||||
|
||||
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
||||
|
||||
@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
|
||||
| `/help` | コマンドヘルプを表示 |
|
||||
| `/status` | サービスの状態と設定を表示 |
|
||||
| `/cancel` | 実行中の Agent タスクを中止 |
|
||||
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
|
||||
| `/config` | 実行時設定の表示・変更 |
|
||||
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
||||
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
||||
|
||||
@@ -5,6 +5,8 @@ description: CowAgent デスクトップクライアント(macOS / Windows)
|
||||
|
||||
CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスクトップクライアントを提供しています。**Python や依存関係を手動でインストールする必要はありません**。ダウンロードしてインストールするだけで、ローカルでスーパー AI アシスタントを実行できます。
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent デスクトップクライアント" />
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
<Card title="ダウンロードページへ" icon="download" href="https://cowagent.ai/download/">
|
||||
@@ -34,3 +36,16 @@ CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスク
|
||||
|
||||
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
|
||||
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/ja/guide/quick-start) を参照。
|
||||
|
||||
## ブラウザからのアクセス
|
||||
|
||||
デスクトップクライアントは起動するとローカルでポート `9876` をリッスンし、そのバックエンドは Web コンソールとまったく同じです。そのため、アプリを開いている間はブラウザで `http://localhost:9876` にアクセスすれば、クライアント UI と同じ完全な体験が得られます。
|
||||
|
||||
## ローカルデータの保存
|
||||
|
||||
デスクトップクライアントのすべてのデータはお使いのマシンに保存されます:
|
||||
|
||||
- **設定ディレクトリ**:ホームフォルダの `~/.cow`。`config.json`(モデルキーやチャネルなどの設定)のほか、ログ・キャッシュなどの実行データが含まれます。
|
||||
- **ワークスペース**:デフォルトは `~/cow`。会話履歴・ナレッジベース・記憶・スキル・定期タスクなど、Agent が生成したファイルが保存されます。
|
||||
|
||||
クライアントをアンインストールしてもこの 2 つのディレクトリは自動削除されないため、再インストール後もデータは保持されます。完全に削除したい場合や別のデバイスへ移行する場合は、該当するフォルダを手動でバックアップまたは削除してください。
|
||||
|
||||
@@ -59,3 +59,9 @@ sudo docker compose up -d
|
||||
<Tip>
|
||||
アップグレード前に `config.json` 設定ファイルのバックアップを推奨します。Docker 環境でデータを保持する場合は、volume マウントでワークスペースディレクトリを永続化できます。
|
||||
</Tip>
|
||||
|
||||
## デスクトップクライアントのアップグレード
|
||||
|
||||
[デスクトップクライアント](/ja/guide/desktop)は自動アップデート機能を内蔵しており、新しいバージョンを自動的に確認して通知します。ワンクリックでダウンロードして再起動し、アップグレードを完了できます。
|
||||
|
||||
最新バージョンは [ダウンロードページ](https://cowagent.ai/download/) からいつでも手動で入手することもできます。
|
||||
|
||||
@@ -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_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||
|
||||
@@ -29,6 +29,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
||||
| モデル | 用途 |
|
||||
| --- | --- |
|
||||
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
||||
| `claude-fable-5` | Claude 5 シリーズのもう一つのフラッグシップモデル |
|
||||
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
||||
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||
|
||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||
|
||||
<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-k3、ernie-5.1。
|
||||
|
||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||
</Note>
|
||||
@@ -20,13 +20,13 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [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 | ✅ | ✅ | ✅ | | | |
|
||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [OpenAI](/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Kimi
|
||||
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
||||
---
|
||||
|
||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k3` と `kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||
|
||||
<Tip>
|
||||
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "kimi-k2.7-code",
|
||||
"model": "kimi-k3",
|
||||
"moonshot_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||
| `model` | `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"model": "gpt-5.6-luna",
|
||||
"open_ai_api_key": "YOUR_API_KEY",
|
||||
"open_ai_api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -22,7 +22,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1`、o シリーズなどをサポート。Agent モードのデフォルトは `gpt-5.5`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1` などをサポート。Agent モードのデフォルトは `gpt-5.6-luna`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
||||
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
||||
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
||||
|
||||
@@ -52,15 +52,15 @@ Web コンソール、ログ、ドキュメントが **繁体字中国語(zh-H
|
||||
## 🔒 セキュリティ強化
|
||||
|
||||
- **機密ファイル読み取り防護**:認証情報などの機密ファイルへのアクセスを強化し、迂回による読み取りを防止します。Thanks @fengyl07 (#2913)
|
||||
- **ブラウザアクセス防護**:ブラウザによる内部ネットワークやクラウドサーバーの内部エンドポイントへのリクエストをブロックし、内部サービスへ誘導されるリスクを低減します。Thanks @christop
|
||||
- **ブラウザアクセス防護**:ブラウザによる内部ネットワークやクラウドサーバーの内部エンドポイントへのリクエストをブロックし、内部サービスへ誘導されるリスクを低減します。Thanks @Jiangrong-W
|
||||
- **設定解析の安全化**:設定内容をより安全な方法で解析し、潜在的なコード実行リスクを回避します。Thanks @shunfeng8421
|
||||
|
||||
## 🛠 改善と修正
|
||||
|
||||
- **カスタムプロバイダー対応**:埋め込みモデルとビジョンモデルでカスタムプロバイダーを利用可能に。あわせて Windows でのメモリ取得の問題を修正しました。Thanks @HnBigVolibear
|
||||
- **ファイル編集の安定性向上**:元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @xiaweiwei67-stack (#2942)
|
||||
- **コマンド出力の文字化け修正**:コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @xiaweiwei67-stack (#2941)
|
||||
- **Azure OpenAI の修正**:Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Eric L
|
||||
- **ファイル編集の安定性向上**:元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @weijun-xia (#2942)
|
||||
- **コマンド出力の文字化け修正**:コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @weijun-xia (#2941)
|
||||
- **Azure OpenAI の修正**:Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Tunnello
|
||||
- **企業向け WeChat スマートボット**:webhook(コールバック)モードの接続ドキュメントを追加しました。Thanks @6vision
|
||||
- **ディープドリームの切り替え**:`deep_dream_enabled` の専用スイッチを追加し、ディープドリームの蒸留を個別に有効・無効化できます。
|
||||
- **安定性**:Web サービスの接続回収を改善し、自己進化に関するいくつかの問題を修正しました (#2924, #2904)
|
||||
|
||||
@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
|
||||
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
**デスクトップクライアント利用者**:playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時:
|
||||
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
|
||||
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
|
||||
</Note>
|
||||
|
||||
## ワークフロー
|
||||
|
||||
Agentがブラウザを使う典型的な流れ:
|
||||
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ:
|
||||
}
|
||||
```
|
||||
|
||||
## ブラウザエンジン
|
||||
|
||||
ブラウザエンジンは自動選択され、設定は不要です:
|
||||
|
||||
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
|
||||
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
|
||||
|
||||
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
|
||||
|
||||
## ログイン状態の永続化
|
||||
|
||||
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
||||
|
||||
@@ -37,6 +37,7 @@ MCP コミュニティ標準に完全準拠しており、Claude Desktop / Curso
|
||||
| `url` | SSE / Streamable HTTP | リモートエンドポイントの URL(`command` と二者択一) |
|
||||
| `type` | リモート | リモートトランスポート種別:`sse` または `streamable-http`(既定は `sse`) |
|
||||
| `headers` | 任意 | リモートリクエストの追加 HTTP ヘッダ(`Authorization` など)。Streamable HTTP のみ |
|
||||
| `scope` | 任意 | OAuth スコープ。OAuth 認可が必要なリモート server のみ使用(任意) |
|
||||
| `disabled` | 任意 | `true` のとき該当サーバーをスキップ。一時的に無効化したいときに便利 |
|
||||
|
||||
### 完全な例
|
||||
@@ -79,6 +80,27 @@ Agent は次のように動作します:
|
||||
1. 既存の MCP 設定ファイルを読み込み、新しい server エントリをマージ(既存の項目は保持)
|
||||
2. 増分の MCP Server を自動でリロードし、次のメッセージから対応する Tool が利用可能に
|
||||
|
||||
## Web 認可(OAuth)
|
||||
|
||||
一部のリモート MCP は OAuth の Web 認可が必要で、そのまま設定すると `401` が返ります。CowAgent は標準的な OAuth フローを内蔵しているため、**token を手動で入力する必要はなく**、通常どおり設定するだけです。例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"xmind": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://app.xmind.com/api/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
server の初回ロードで `401` が返ると、認可が自動的に開始されます。ローカル実行では**自動的にブラウザが開き**、サーバー環境では**認可リンクがログに出力**されるので、ブラウザで開いてください。承認すると server はすぐにオンラインになり、token は期限切れ時に自動更新されるため、再認可は不要です。
|
||||
|
||||
- **Web サービスが必要**:認可コールバックは Web コンソール(既定ポート `9899`)で受け取るため、Web channel が起動している必要があります。
|
||||
- **認証情報の保存**:token は `~/.cow/mcp_oauth.json` に永続化され、再起動後も再利用されます。
|
||||
- **コールバック URL**:既定は `http://127.0.0.1:9899/mcp/oauth/callback`。サーバーに配置し認可用ブラウザが別の端末にある場合は、`config.json` に `mcp_oauth_redirect_base`(例:`http://あなたのIP:9899`)を設定してください。
|
||||
|
||||
## 動作の仕組み
|
||||
|
||||
- **起動時の非同期ロード**:`mcp.json` に設定された全 server はバックグラウンドで非同期に読み込まれ、メインループをブロックしません。会話はすぐに開始できます
|
||||
|
||||
@@ -20,7 +20,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
||||
|
||||
| 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_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 |
|
||||
| --- | --- |
|
||||
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
|
||||
| `claude-fable-5` | Alternative flagship in the Claude 5 family |
|
||||
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
|
||||
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||
|
||||
@@ -13,13 +13,13 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Kimi
|
||||
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
||||
---
|
||||
|
||||
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision.
|
||||
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k3` and `kimi-k2.x` series natively support vision.
|
||||
|
||||
<Tip>
|
||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "kimi-k2.7-code",
|
||||
"model": "kimi-k3",
|
||||
"moonshot_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||
| `model` | Can be `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
||||
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-5.5",
|
||||
"model": "gpt-5.6-luna",
|
||||
"open_ai_api_key": "YOUR_API_KEY",
|
||||
"open_ai_api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -22,7 +22,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
||||
|
||||
| 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_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 |
|
||||
|
||||
@@ -22,6 +22,7 @@ Highlights:
|
||||
- **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
|
||||
|
||||
Docs: [Desktop Client](https://docs.cowagent.ai/guide/desktop)
|
||||
|
||||
## 📚 Knowledge Base
|
||||
|
||||
@@ -53,15 +54,15 @@ Docs: [Models](https://docs.cowagent.ai/models)
|
||||
## 🔒 Security Hardening
|
||||
|
||||
- **Sensitive file read protection**: hardened access to credential and other sensitive files to prevent bypass reads. Thanks @fengyl07 (#2913)
|
||||
- **Browser access protection**: blocks browser requests targeting internal network and cloud server internal endpoints, reducing the risk of being tricked into reaching internal services. Thanks @christop
|
||||
- **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
|
||||
|
||||
## 🛠 Improvements & Fixes
|
||||
|
||||
- **Custom provider support**: embedding and vision models can now use custom providers; also fixed a memory query issue on Windows. Thanks @HnBigVolibear
|
||||
- **More reliable file editing**: better preserves original indentation, and fuzzy matching no longer touches unrelated content. Thanks @xiaweiwei67-stack (#2942)
|
||||
- **Command output encoding fix**: fixed garbled Chinese characters when a command produces large output. Thanks @xiaweiwei67-stack (#2941)
|
||||
- **Azure OpenAI fixes**: fixed streaming output and related configuration issues for Azure OpenAI. Thanks @Eric L
|
||||
- **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 @weijun-xia (#2941)
|
||||
- **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
|
||||
- **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)
|
||||
|
||||
@@ -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.
|
||||
</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
|
||||
|
||||
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
|
||||
|
||||
**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`) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
### Full Example
|
||||
@@ -79,6 +80,27 @@ The Agent will:
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -35,6 +35,17 @@ Create and manage scheduled tasks with natural language:
|
||||
- "Remind me about the meeting tomorrow at 3 PM"
|
||||
- "Show all scheduled tasks"
|
||||
|
||||
## Test-run an existing task
|
||||
|
||||
Open the task in the authenticated Web console or Desktop app and click **Run
|
||||
now**. After you confirm the delivery, CowAgent queues one immediate execution
|
||||
to the task's configured channel and receiver. This user-initiated action is not
|
||||
exposed to the agent's scheduler tool.
|
||||
|
||||
Manual execution works for disabled tasks and does not enable, delete, or
|
||||
reschedule the task. Its original `next_run_at` remains unchanged. A task cannot
|
||||
be run manually while the same task is already executing on schedule.
|
||||
|
||||
<Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||
</Frame>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/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://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -108,13 +108,13 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||
| [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 | ✅ | ✅ | ✅ | | | |
|
||||
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -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 提供雲端託管和企業級支援:
|
||||
>
|
||||
@@ -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/>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/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://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -108,13 +108,13 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||
| [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 | ✅ | ✅ | ✅ | | | |
|
||||
| [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 | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -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 提供云端托管和企业级支持:
|
||||
>
|
||||
@@ -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/>
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| `feishu_app_id` | 飞书应用 App ID | - |
|
||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||
| `feishu_detailed_card` | 流式回复是否使用详细卡片(工具调用、思考过程、耗时展示);关闭时用普通打字机卡片 | `true` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -85,7 +86,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
|
||||
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
||||
|
||||
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||
3. (可选)在 **回调** 中添加 **卡片回传交互**(`card.action.trigger`),以启用 `/tasks` 定时任务管理命令;添加 **撤回消息**(`im.message.recalled_v1`)事件,以支持撤回消息取消任务功能。
|
||||
|
||||
4. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||
|
||||
@@ -98,7 +101,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| 文本消息 | ✅ 收发 |
|
||||
| 图片消息 | ✅ 收发 |
|
||||
| 语音消息 | ✅ 收发 |
|
||||
| 引用回复 | ✅ 引用文本与富文本上下文 |
|
||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
||||
| 详细卡片 | ✅ 工具调用、思考过程与耗时展示(`feishu_detailed_card` 控制,默认开启) |
|
||||
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||
|
||||
<Note>
|
||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||
@@ -109,3 +116,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
||||
|
||||
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
||||
|
||||
私聊发送 `/tasks`,或在群聊中 @机器人并发送 `/tasks`,即可管理属于当前会话的定时任务。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user