mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 06:07:13 +08:00
Compare commits
49 Commits
feat-mcp-o
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d6186bd3 | ||
|
|
a78fb711ad | ||
|
|
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 |
6
.github/scripts/register-releases.mjs
vendored
6
.github/scripts/register-releases.mjs
vendored
@@ -74,6 +74,12 @@ for (const base of fs.readdirSync(dir)) {
|
|||||||
} else if (/x64\.zip$/.test(base)) {
|
} else if (/x64\.zip$/.test(base)) {
|
||||||
platform = 'mac-x64'
|
platform = 'mac-x64'
|
||||||
slot = 'upd'
|
slot = 'upd'
|
||||||
|
} else if (/win7.*\.exe$/i.test(base)) {
|
||||||
|
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||||
|
// segment so it never collides with the standard win exe in the same
|
||||||
|
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||||
|
platform = 'win-legacy'
|
||||||
|
slot = 'main'
|
||||||
} else if (/\.exe$/.test(base)) {
|
} else if (/\.exe$/.test(base)) {
|
||||||
platform = 'win'
|
platform = 'win'
|
||||||
slot = 'main'
|
slot = 'main'
|
||||||
|
|||||||
154
.github/workflows/publish-desktop.yml
vendored
154
.github/workflows/publish-desktop.yml
vendored
@@ -1,154 +0,0 @@
|
|||||||
name: Publish Desktop
|
|
||||||
|
|
||||||
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
|
||||||
# to "live". By this point:
|
|
||||||
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
|
||||||
# registered them in D1 as unpublished (is_latest=0);
|
|
||||||
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
|
||||||
# dmgs and re-uploaded the stapled bytes to R2.
|
|
||||||
#
|
|
||||||
# This workflow, triggered manually with the version to publish:
|
|
||||||
# 1. pulls every artifact for that version back from R2,
|
|
||||||
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
|
||||||
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
|
||||||
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
|
||||||
# 4. creates/updates the GitHub Release and attaches the installers.
|
|
||||||
#
|
|
||||||
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
make_latest:
|
|
||||||
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
github_release:
|
|
||||||
description: "Create/update the GitHub Release and attach installers."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish ${{ github.event.inputs.version }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Guard on Cloudflare secrets
|
|
||||||
id: guard
|
|
||||||
env:
|
|
||||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$CF_TOKEN" ]; then
|
|
||||||
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Resolve version artifacts from D1
|
|
||||||
id: rows
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
|
||||||
# Read them back so we know exactly which objects to pull from R2.
|
|
||||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
|
||||||
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
|
||||||
echo "$out"
|
|
||||||
echo "$out" | node -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
||||||
const rows = (Array.isArray(data) ? data : [data])
|
|
||||||
.flatMap(r => (r.results || []));
|
|
||||||
if (!rows.length) {
|
|
||||||
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
|
||||||
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Download version artifacts from R2
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
|
||||||
# auto-update file (update_filename: zip) for every row. Keys are
|
|
||||||
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
|
||||||
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
|
||||||
base="$(basename "$key")"
|
|
||||||
r2key="desktop/${key}"
|
|
||||||
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
|
||||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
|
||||||
--file "dist/${base}" --remote
|
|
||||||
done
|
|
||||||
echo "Downloaded:"; ls -la dist
|
|
||||||
|
|
||||||
- name: Reminder — mac dmgs must be notarized before publishing
|
|
||||||
run: |
|
|
||||||
# Stapling can only be validated on macOS (xcrun stapler validate),
|
|
||||||
# which this Linux runner doesn't have. The authoritative check runs in
|
|
||||||
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
|
||||||
# This step is just a loud reminder in the log.
|
|
||||||
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
|
||||||
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
|
||||||
|
|
||||||
- name: Update D1 (recompute sha512 + set latest)
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
|
||||||
run: |
|
|
||||||
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
|
||||||
# become latest, so the site keeps serving the last stable build.
|
|
||||||
case "$VER" in
|
|
||||||
*-*) is_pre=1 ;;
|
|
||||||
*) is_pre=0 ;;
|
|
||||||
esac
|
|
||||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
|
||||||
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
|
||||||
else
|
|
||||||
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Re-hash the real (stapled) bytes and re-store every row with both the
|
|
||||||
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
|
||||||
# build stage; --latest also clears the previous latest per platform.
|
|
||||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
|
||||||
echo "==> D1 statements:"; cat d1.sql
|
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
|
||||||
|
|
||||||
- name: Create/update GitHub Release and attach installers
|
|
||||||
if: github.event.inputs.github_release == 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
tag="v${VER}"
|
|
||||||
case "$VER" in
|
|
||||||
*-*) prerelease="--prerelease" ;;
|
|
||||||
*) prerelease="" ;;
|
|
||||||
esac
|
|
||||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "$tag" --generate-notes $prerelease
|
|
||||||
fi
|
|
||||||
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
|
||||||
# zip is the auto-update artifact; attach it too so the GitHub Release
|
|
||||||
# is a complete mirror (nullglob avoids errors when a type is absent).
|
|
||||||
shopt -s nullglob
|
|
||||||
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
|
||||||
--repo "$GITHUB_REPOSITORY" --clobber
|
|
||||||
230
.github/workflows/release-win7.yml
vendored
Normal file
230
.github/workflows/release-win7.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: Release Desktop (Win7 legacy)
|
||||||
|
|
||||||
|
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
|
||||||
|
#
|
||||||
|
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
|
||||||
|
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
|
||||||
|
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
|
||||||
|
#
|
||||||
|
# To support Win7 we must pin BOTH halves to the last versions that still
|
||||||
|
# target it:
|
||||||
|
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
|
||||||
|
# - Python 3.8 (last CPython to support Win7)
|
||||||
|
#
|
||||||
|
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
|
||||||
|
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
|
||||||
|
# then — exactly like the main pipeline — uploads it to R2 and registers a
|
||||||
|
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
|
||||||
|
# stays invisible until promoted, so it can't accidentally get served to Win10
|
||||||
|
# users). Because it's stamped with the SAME version as the standard release,
|
||||||
|
# the download page shows both Windows builds under one version row, and the
|
||||||
|
# /update feed serves each build to its own clients. Delete this file whenever
|
||||||
|
# legacy Windows support is no longer worth maintaining — the main pipeline is
|
||||||
|
# unaffected.
|
||||||
|
#
|
||||||
|
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
|
||||||
|
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Windows x64 (Win7 legacy)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Python 3.8 is the last CPython that supports Windows 7. A backend built
|
||||||
|
# with it (via PyInstaller) still runs on Win7 even though the CI host is
|
||||||
|
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
|
||||||
|
# OS, not the build machine's.
|
||||||
|
- name: Set up Python 3.8
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Build Python backend (PyInstaller, Python 3.8)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
|
||||||
|
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
|
||||||
|
# for this legacy build. We rewrite them into a throwaway requirements
|
||||||
|
# file so the repo's source stays untouched (main pipeline keeps its
|
||||||
|
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
|
||||||
|
sed 's/^playwright==.*/playwright==1.48.0/' \
|
||||||
|
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
|
||||||
|
pip install -r /tmp/requirements-win7.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
# Run from repo root so the spec's relative datas resolve correctly.
|
||||||
|
pyinstaller desktop/build/cowagent-backend.spec \
|
||||||
|
--noconfirm \
|
||||||
|
--distpath desktop/build/dist \
|
||||||
|
--workpath desktop/build/build-work
|
||||||
|
|
||||||
|
- name: Install desktop deps
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Write version into package.json
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
|
||||||
|
# this out of package.json so the repo's committed deps stay on Electron 33
|
||||||
|
# for the main pipeline. electron-builder reads the installed Electron
|
||||||
|
# version from node_modules, so this is all that's needed to package v22.
|
||||||
|
- name: Pin Electron to 22 (last Win7-capable)
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm install --no-save electron@22.3.27
|
||||||
|
|
||||||
|
- name: Compile (vite + tsc)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Same signing setup as the main pipeline: download the signtool CLI (URL
|
||||||
|
# from a repo variable so nothing is hardcoded in a public workflow). Only
|
||||||
|
# runs when a URL is configured; otherwise the build stays unsigned but
|
||||||
|
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
|
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
|
||||||
|
# hook the main pipeline uses (signs app + backend + installer via the
|
||||||
|
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
|
||||||
|
# installer is still produced (unsigned). --publish never emits the exe
|
||||||
|
# without touching any feed. Invoke via node (not npx) to avoid the
|
||||||
|
# Windows npx.cmd wrapper returning early (see release.yml).
|
||||||
|
#
|
||||||
|
# -c.win.artifactName injects a "win7" segment into the file name
|
||||||
|
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
|
||||||
|
# builds differ by ${arch}: it keeps the legacy exe from colliding with
|
||||||
|
# the standard win exe in the same v<version>/ folder, and lets
|
||||||
|
# register-releases.mjs map it to the win-legacy platform by name.
|
||||||
|
- name: Build installer (electron-builder, Electron 22)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
|
run: |
|
||||||
|
node node_modules/electron-builder/cli.js --win --x64 \
|
||||||
|
--config electron-builder.win.js \
|
||||||
|
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
|
||||||
|
--publish never
|
||||||
|
|
||||||
|
# Collect the installer + its blockmap (differential updates). The .yml
|
||||||
|
# feed is NOT uploaded: the /update Function generates it dynamically from
|
||||||
|
# D1 (same as the main pipeline), so it isn't needed here.
|
||||||
|
- name: Upload installer artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: |
|
||||||
|
desktop/release/*.exe
|
||||||
|
desktop/release/*.blockmap
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
|
||||||
|
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
|
||||||
|
# wrangler@latest works (the build job pins Node 20 for Electron 22).
|
||||||
|
#
|
||||||
|
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
|
||||||
|
# build — its "win7" name segment keeps them distinct — and register-releases
|
||||||
|
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
|
||||||
|
# publish workflow). Because the version matches the standard release, the
|
||||||
|
# download page merges both Windows builds into one version row.
|
||||||
|
publish:
|
||||||
|
name: Publish to R2 + D1
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$CF_TOKEN" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Upload installer to R2
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in dist/*.exe dist/*.blockmap; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
|
||||||
|
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||||
|
--file "$f" --remote
|
||||||
|
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
|
||||||
|
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
|
||||||
|
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
|
||||||
|
- name: Register release row in D1
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
@@ -113,6 +113,31 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
# Download the Windows signing CLI. The URL comes from a repo variable, so
|
||||||
|
# nothing about the signing setup is hardcoded in a public workflow. Only
|
||||||
|
# runs on the Windows leg and only when a URL is set; otherwise the build
|
||||||
|
# stays unsigned. SIGNTOOL_PATH is exported for the next step's
|
||||||
|
# electron-builder.win.js to invoke.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
# Unzip and locate the signtool executable regardless of nesting.
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Normalize to a Windows-style path for execFileSync in Node.
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
- name: Build & publish (electron-builder)
|
- name: Build & publish (electron-builder)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -124,8 +149,14 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
# Windows code signing via the signing CLI. Credentials are
|
||||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
# secrets; SIGNTOOL_PATH was exported by the download step above.
|
||||||
|
# COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
|
||||||
|
# with a self-signed cert before buying a real one — no quota used.
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
run: |
|
run: |
|
||||||
# Pick the signing cert for THIS platform only. The mac and win secrets
|
# Pick the signing cert for THIS platform only. The mac and win secrets
|
||||||
# are both present in the job env, but a mac cert must never leak into a
|
# are both present in the job env, but a mac cert must never leak into a
|
||||||
@@ -137,6 +168,10 @@ jobs:
|
|||||||
#
|
#
|
||||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||||
# (an `unset` can return non-zero and abort under errexit).
|
# (an `unset` can return non-zero and abort under errexit).
|
||||||
|
# macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
|
||||||
|
# a local .pfx (EV private keys can't be exported since 2023); it signs
|
||||||
|
# via the CLI wired into electron-builder.win.js instead, using the
|
||||||
|
# SIGNTOOL_* env already set above — nothing to export here.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac)
|
mac)
|
||||||
if [ -n "$MAC_CSC_LINK" ]; then
|
if [ -n "$MAC_CSC_LINK" ]; then
|
||||||
@@ -144,12 +179,6 @@ jobs:
|
|||||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
win)
|
|
||||||
if [ -n "$WIN_CSC_LINK" ]; then
|
|
||||||
export CSC_LINK="$WIN_CSC_LINK"
|
|
||||||
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Never let electron-builder publish: our publish target is a generic
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
@@ -157,19 +186,25 @@ jobs:
|
|||||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# `--publish never` still emits the latest*.yml files.
|
# `--publish never` still emits the latest*.yml files.
|
||||||
#
|
#
|
||||||
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
|
||||||
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||||
# notarization) — it's a pure no-op on Windows. Passing --config on
|
# win -> electron-builder.win.js (wires the sign hook; electron-builder
|
||||||
# Windows was what silently broke the Windows build (it produced no
|
# signs the app, backend and installer)
|
||||||
# installer while the job still reported success; Windows worked fine
|
# HISTORY: passing --config on Windows previously broke the build (no
|
||||||
# before --config was introduced). So Windows uses the plain
|
# installer, job still green). That happened because the MAC config
|
||||||
# package.json build config and only mac uses the dynamic one.
|
# (electron-builder.js) was a no-op on Windows yet still disturbed the
|
||||||
|
# run. The fix is a DEDICATED win config that correctly extends
|
||||||
|
# config.win — not sharing the mac one. If a build ever runs WITHOUT
|
||||||
|
# signing configured, electron-builder.win.js still returns the base
|
||||||
|
# config unchanged (sign hook just skips), so the installer is still
|
||||||
|
# produced.
|
||||||
#
|
#
|
||||||
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||||
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||||
# make bash return before the wrapped process finishes. node skips it.
|
# make bash return before the wrapped process finishes. node skips it.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac) config_arg="--config electron-builder.js" ;;
|
mac) config_arg="--config electron-builder.js" ;;
|
||||||
|
win) config_arg="--config electron-builder.win.js" ;;
|
||||||
*) config_arg="" ;;
|
*) config_arg="" ;;
|
||||||
esac
|
esac
|
||||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
|
|
||||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -202,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
|||||||
|
|
||||||
## 🏷 Changelog
|
## 🏷 Changelog
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — Desktop experience improvements, MCP OAuth authorization, Lark channel enhancements, scheduler improvements and data backup, new models.
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
||||||
|
|||||||
@@ -183,9 +183,15 @@ class ChatService:
|
|||||||
|
|
||||||
# Register a cancel token so /cancel can abort this in-flight run.
|
# Register a cancel token so /cancel can abort this in-flight run.
|
||||||
# IM channels key on session_id (no per-turn request_id here).
|
# 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()
|
registry = get_cancel_registry()
|
||||||
|
steer_registry = get_steer_registry()
|
||||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
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(
|
executor = AgentStreamExecutor(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
@@ -197,6 +203,7 @@ class ChatService:
|
|||||||
messages=messages_copy,
|
messages=messages_copy,
|
||||||
max_context_turns=max_context_turns,
|
max_context_turns=max_context_turns,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
steer_inbox=steer_inbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -217,6 +224,8 @@ class ChatService:
|
|||||||
registry.unregister(session_id)
|
registry.unregister(session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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).
|
# Sync executor messages back to agent (thread-safe).
|
||||||
# The executor may have trimmed context, making its list shorter than
|
# The executor may have trimmed context, making its list shorter than
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ from .cancel import (
|
|||||||
CancelTokenRegistry,
|
CancelTokenRegistry,
|
||||||
get_cancel_registry,
|
get_cancel_registry,
|
||||||
)
|
)
|
||||||
|
from .steer import (
|
||||||
|
SteerInbox,
|
||||||
|
SteerRegistry,
|
||||||
|
SteerResult,
|
||||||
|
SteerStatus,
|
||||||
|
get_steer_registry,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Agent',
|
'Agent',
|
||||||
@@ -25,4 +32,9 @@ __all__ = [
|
|||||||
'AgentCancelledError',
|
'AgentCancelledError',
|
||||||
'CancelTokenRegistry',
|
'CancelTokenRegistry',
|
||||||
'get_cancel_registry',
|
'get_cancel_registry',
|
||||||
|
'SteerInbox',
|
||||||
|
'SteerRegistry',
|
||||||
|
'SteerResult',
|
||||||
|
'SteerStatus',
|
||||||
|
'get_steer_registry',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ class Agent:
|
|||||||
return action
|
return action
|
||||||
|
|
||||||
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
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)
|
Execute single agent task with streaming (based on tool-call)
|
||||||
|
|
||||||
@@ -391,6 +391,7 @@ class Agent:
|
|||||||
- Event callbacks
|
- Event callbacks
|
||||||
- Persistent conversation history across calls
|
- Persistent conversation history across calls
|
||||||
- User-initiated cancellation via ``cancel_event``
|
- User-initiated cancellation via ``cancel_event``
|
||||||
|
- Explicit active-turn guidance via ``steer_inbox``
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_message: User message
|
user_message: User message
|
||||||
@@ -403,6 +404,8 @@ class Agent:
|
|||||||
"[Interrupted by user]" assistant note, and returns the
|
"[Interrupted by user]" assistant note, and returns the
|
||||||
partial response. ``messages`` stays in a valid state
|
partial response. ``messages`` stays in a valid state
|
||||||
(tool_use/tool_result pairs preserved).
|
(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:
|
Returns:
|
||||||
Final response text
|
Final response text
|
||||||
@@ -448,6 +451,7 @@ class Agent:
|
|||||||
messages=messages_copy, # Pass copied message history
|
messages=messages_copy, # Pass copied message history
|
||||||
max_context_turns=max_context_turns,
|
max_context_turns=max_context_turns,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
steer_inbox=steer_inbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class AgentStreamExecutor:
|
|||||||
messages: Optional[List[Dict]] = None,
|
messages: Optional[List[Dict]] = None,
|
||||||
max_context_turns: int = 30,
|
max_context_turns: int = 30,
|
||||||
cancel_event=None,
|
cancel_event=None,
|
||||||
|
steer_inbox=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize stream executor
|
Initialize stream executor
|
||||||
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
|
|||||||
Checked at every safe point (turn boundary, before tool execution,
|
Checked at every safe point (turn boundary, before tool execution,
|
||||||
during LLM streaming). When set, raises AgentCancelledError which
|
during LLM streaming). When set, raises AgentCancelledError which
|
||||||
run_stream catches to gracefully wind down.
|
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.agent = agent
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
|
|||||||
self.on_event = on_event
|
self.on_event = on_event
|
||||||
self.max_context_turns = max_context_turns
|
self.max_context_turns = max_context_turns
|
||||||
self.cancel_event = cancel_event
|
self.cancel_event = cancel_event
|
||||||
|
self.steer_inbox = steer_inbox
|
||||||
|
|
||||||
# Message history - use provided messages or create new list
|
# Message history - use provided messages or create new list
|
||||||
self.messages = messages if messages is not None else []
|
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():
|
if self.cancel_event is not None and self.cancel_event.is_set():
|
||||||
raise AgentCancelledError("agent cancelled by user")
|
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:
|
def _handle_cancelled(self, partial_response: str) -> None:
|
||||||
"""Wind down ``self.messages`` after a user-initiated cancel.
|
"""Wind down ``self.messages`` after a user-initiated cancel.
|
||||||
|
|
||||||
@@ -395,6 +465,10 @@ class AgentStreamExecutor:
|
|||||||
# between turns short-circuits cleanly.
|
# between turns short-circuits cleanly.
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
|
|
||||||
|
steering_updates = self._drain_steering()
|
||||||
|
if steering_updates:
|
||||||
|
self._append_steering(steering_updates)
|
||||||
|
|
||||||
turn += 1
|
turn += 1
|
||||||
logger.info(f"[Agent] Turn {turn}")
|
logger.info(f"[Agent] Turn {turn}")
|
||||||
self._emit_event("turn_start", {"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)
|
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
|
||||||
final_response = assistant_msg
|
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
|
# No tool calls, end loop
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
# 检查是否返回了空响应
|
# 检查是否返回了空响应
|
||||||
@@ -467,6 +559,22 @@ class AgentStreamExecutor:
|
|||||||
# If the explicit-response retry produced tool_calls, skip the break
|
# If the explicit-response retry produced tool_calls, skip the break
|
||||||
# and continue down to the tool execution branch in this same iteration.
|
# and continue down to the tool execution branch in this same iteration.
|
||||||
if not tool_calls:
|
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)")
|
logger.debug(f"✅ Done (no tool calls)")
|
||||||
self._emit_event("turn_end", {
|
self._emit_event("turn_end", {
|
||||||
"turn": turn,
|
"turn": turn,
|
||||||
@@ -499,9 +607,17 @@ class AgentStreamExecutor:
|
|||||||
tool_result_blocks = []
|
tool_result_blocks = []
|
||||||
|
|
||||||
try:
|
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
|
# Honour cancel between tool invocations within the same turn
|
||||||
self._check_cancelled()
|
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)
|
result = self._execute_tool(tool_call)
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
|
|
||||||
@@ -641,6 +757,7 @@ class AgentStreamExecutor:
|
|||||||
|
|
||||||
if turn >= self.max_turns:
|
if turn >= self.max_turns:
|
||||||
logger.warning(f"⚠️ Reached max decision step limit: {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
|
# Force model to summarize without tool calls
|
||||||
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
||||||
@@ -699,6 +816,8 @@ class AgentStreamExecutor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
if self.steer_inbox is not None:
|
||||||
|
self.steer_inbox.close()
|
||||||
final_response = final_response.strip() if final_response else final_response
|
final_response = final_response.strip() if final_response else final_response
|
||||||
if cancelled:
|
if cancelled:
|
||||||
# Emit before agent_end so channels can mark UI as 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')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# BrowserTool (requires playwright)
|
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||||
|
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||||
|
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError as e:
|
|
||||||
logger.info(
|
|
||||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
290
agent/tools/browser/browser_env.py
Normal file
290
agent/tools/browser/browser_env.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Browser environment detection and capability resolution.
|
||||||
|
|
||||||
|
Centralizes everything about *where* a usable browser engine comes from, so
|
||||||
|
both the runtime (browser_service) and the installer (cli/commands/install)
|
||||||
|
agree on the same decisions:
|
||||||
|
|
||||||
|
- Whether the `playwright` Python package is importable.
|
||||||
|
- Whether a system Chrome / Edge is installed (Playwright can drive it via
|
||||||
|
the `channel="chrome"/"msedge"` launcher, no download needed).
|
||||||
|
- Where Playwright's own Chromium download lives (redirected to the writable
|
||||||
|
data dir so it survives frozen/desktop app updates).
|
||||||
|
|
||||||
|
Resolution priority (see resolve_engine):
|
||||||
|
1. system-chrome -> drive the user's installed Chrome / Edge (zero download)
|
||||||
|
2. playwright-chromium -> Playwright's own Chromium, if already downloaded
|
||||||
|
3. none -> nothing usable yet; caller should trigger onboarding
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Playwright browser channels we accept for the "system-chrome" mode, in
|
||||||
|
# preference order. "chrome" covers stable Google Chrome; "msedge" is the
|
||||||
|
# Chromium-based Edge shipped on every Windows 10/11.
|
||||||
|
_PREFERRED_CHANNELS = ("chrome", "msedge", "chrome-beta", "msedge-beta")
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_root() -> str:
|
||||||
|
"""Writable data root (~/.cow on desktop, else CWD-based).
|
||||||
|
|
||||||
|
Mirrors the logic in common/log.py without importing config, to avoid a
|
||||||
|
circular import. The desktop build sets COW_DATA_DIR; source deployments
|
||||||
|
fall back to the current working directory.
|
||||||
|
"""
|
||||||
|
data_dir = os.environ.get("COW_DATA_DIR")
|
||||||
|
if data_dir:
|
||||||
|
return os.path.expanduser(data_dir)
|
||||||
|
return os.getcwd()
|
||||||
|
|
||||||
|
|
||||||
|
def browsers_download_dir() -> str:
|
||||||
|
"""Directory Playwright downloads its Chromium into.
|
||||||
|
|
||||||
|
We pin it under the writable data root (~/.cow/ms-playwright) rather than
|
||||||
|
Playwright's default (~/.cache/ms-playwright or %USERPROFILE%). This keeps
|
||||||
|
the frozen desktop build self-contained and makes the download survive app
|
||||||
|
updates. Set as PLAYWRIGHT_BROWSERS_PATH for both install and runtime.
|
||||||
|
"""
|
||||||
|
return os.path.join(get_data_root(), "ms-playwright")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_browsers_path_env() -> None:
|
||||||
|
"""Point Playwright at our pinned download dir via env var (idempotent).
|
||||||
|
|
||||||
|
Only set it when not already provided by the user, so power users can
|
||||||
|
override the location. Must run before importing playwright's launcher.
|
||||||
|
"""
|
||||||
|
if not os.environ.get("PLAYWRIGHT_BROWSERS_PATH"):
|
||||||
|
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def is_frozen() -> bool:
|
||||||
|
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||||
|
|
||||||
|
In this mode sys.executable is the frozen exe (no pip), so the installer
|
||||||
|
must skip `pip install` and only download the browser binary.
|
||||||
|
"""
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def is_desktop() -> bool:
|
||||||
|
"""True when running as the Electron desktop client (dev or packaged).
|
||||||
|
|
||||||
|
The desktop shell always sets COW_DESKTOP=1 (see python-manager.ts), both in
|
||||||
|
`npm run dev` (runs app.py with the user's Python) and in the packaged build
|
||||||
|
(frozen exe). Desktop users have no `cow` CLI, so onboarding must point them
|
||||||
|
at the in-chat `/install-browser` command rather than a terminal command.
|
||||||
|
"""
|
||||||
|
return os.environ.get("COW_DESKTOP") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def has_playwright_package() -> bool:
|
||||||
|
"""True if the `playwright` Python package can be imported."""
|
||||||
|
try:
|
||||||
|
import playwright # noqa: F401
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_program_dirs() -> list:
|
||||||
|
dirs = []
|
||||||
|
for var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
||||||
|
val = os.environ.get(var)
|
||||||
|
if val:
|
||||||
|
dirs.append(val)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def detect_system_chrome() -> Optional[Dict[str, str]]:
|
||||||
|
"""Locate an installed Chromium-based browser Playwright can drive.
|
||||||
|
|
||||||
|
Returns a dict {"channel": <playwright channel>, "path": <exe path>} for
|
||||||
|
the first match, or None. The `channel` is what we hand to Playwright's
|
||||||
|
launcher; `path` is only informational (Playwright resolves the channel on
|
||||||
|
its own, but we keep the path for logging / onboarding messages).
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
candidates = [
|
||||||
|
("chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||||
|
("msedge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
||||||
|
("chrome-beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"),
|
||||||
|
]
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
prog_dirs = _windows_program_dirs()
|
||||||
|
for base in prog_dirs:
|
||||||
|
candidates.append(("chrome", os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")))
|
||||||
|
candidates.append(("msedge", os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe")))
|
||||||
|
else:
|
||||||
|
# Linux: rely on PATH lookups for the common binaries.
|
||||||
|
path_lookups = [
|
||||||
|
("chrome", "google-chrome"),
|
||||||
|
("chrome", "google-chrome-stable"),
|
||||||
|
("chrome", "chromium"),
|
||||||
|
("chrome", "chromium-browser"),
|
||||||
|
("msedge", "microsoft-edge"),
|
||||||
|
]
|
||||||
|
for channel, binary in path_lookups:
|
||||||
|
found = shutil.which(binary)
|
||||||
|
if found:
|
||||||
|
return {"channel": channel, "path": found}
|
||||||
|
|
||||||
|
for channel, path in candidates:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
return {"channel": channel, "path": path}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_downloaded_chromium() -> bool:
|
||||||
|
"""True if Playwright already has a Chromium download available.
|
||||||
|
|
||||||
|
We check our pinned download dir for a chromium-* folder. This is a
|
||||||
|
lightweight heuristic (avoids importing/launching Playwright just to probe)
|
||||||
|
and matches how Playwright lays browsers out on disk.
|
||||||
|
"""
|
||||||
|
download_dir = browsers_download_dir()
|
||||||
|
if not os.path.isdir(download_dir):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
for name in os.listdir(download_dir):
|
||||||
|
# Playwright names its browser dirs like "chromium-1140",
|
||||||
|
# "chromium_headless_shell-1140".
|
||||||
|
if name.startswith("chromium"):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_engine(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""Decide which browser engine to use, given config and environment.
|
||||||
|
|
||||||
|
Returns a dict describing the launch strategy:
|
||||||
|
{
|
||||||
|
"mode": "system-chrome" | "playwright-chromium" | "none",
|
||||||
|
"channel": Optional[str], # for system-chrome
|
||||||
|
"path": Optional[str], # for system-chrome (informational)
|
||||||
|
"has_playwright": bool,
|
||||||
|
"reason": str, # human-readable, for logging / onboarding
|
||||||
|
}
|
||||||
|
|
||||||
|
Config keys under tools.browser that influence this:
|
||||||
|
- engine: "auto" (default) | "system-chrome" | "chromium"
|
||||||
|
Force a specific engine. "auto" prefers system Chrome, then falls
|
||||||
|
back to a downloaded Chromium.
|
||||||
|
- prefer_system_browser: bool (default True). When False under "auto",
|
||||||
|
skip system Chrome and go straight to Playwright's Chromium.
|
||||||
|
"""
|
||||||
|
config = config or {}
|
||||||
|
apply_browsers_path_env()
|
||||||
|
|
||||||
|
has_pw = has_playwright_package()
|
||||||
|
engine_pref = str(config.get("engine", "auto")).strip().lower()
|
||||||
|
prefer_system = config.get("prefer_system_browser", True)
|
||||||
|
|
||||||
|
if not has_pw:
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": False,
|
||||||
|
"reason": "playwright package not available",
|
||||||
|
}
|
||||||
|
|
||||||
|
system = None
|
||||||
|
if engine_pref in ("auto", "system-chrome") and prefer_system:
|
||||||
|
system = detect_system_chrome()
|
||||||
|
|
||||||
|
if engine_pref == "system-chrome":
|
||||||
|
# Explicitly requested: use system Chrome if found, else report none.
|
||||||
|
if system:
|
||||||
|
return {
|
||||||
|
"mode": "system-chrome",
|
||||||
|
"channel": system["channel"],
|
||||||
|
"path": system["path"],
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": f"using system browser ({system['channel']})",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "engine=system-chrome but no Chrome/Edge found",
|
||||||
|
}
|
||||||
|
|
||||||
|
if engine_pref == "chromium":
|
||||||
|
# Explicitly requested Playwright's own Chromium.
|
||||||
|
if has_downloaded_chromium():
|
||||||
|
return {
|
||||||
|
"mode": "playwright-chromium",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "using downloaded Playwright Chromium",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "engine=chromium but Chromium not downloaded yet",
|
||||||
|
}
|
||||||
|
|
||||||
|
# auto: system Chrome first, then downloaded Chromium.
|
||||||
|
if system:
|
||||||
|
return {
|
||||||
|
"mode": "system-chrome",
|
||||||
|
"channel": system["channel"],
|
||||||
|
"path": system["path"],
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": f"auto: using system browser ({system['channel']})",
|
||||||
|
}
|
||||||
|
if has_downloaded_chromium():
|
||||||
|
return {
|
||||||
|
"mode": "playwright-chromium",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "auto: using downloaded Playwright Chromium",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "no system Chrome/Edge and no downloaded Chromium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def capability_summary(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""High-level browser capability status, for onboarding / diagnostics.
|
||||||
|
|
||||||
|
Combines resolve_engine with raw detection flags so the UI / tool layer can
|
||||||
|
craft a helpful message (e.g. "Chrome detected, click to enable" vs
|
||||||
|
"no browser, will download ~150MB").
|
||||||
|
"""
|
||||||
|
engine = resolve_engine(config)
|
||||||
|
system = detect_system_chrome()
|
||||||
|
return {
|
||||||
|
"ready": engine["mode"] != "none",
|
||||||
|
"engine": engine,
|
||||||
|
"has_playwright": engine["has_playwright"],
|
||||||
|
"has_system_chrome": system is not None,
|
||||||
|
"system_chrome": system,
|
||||||
|
"has_downloaded_chromium": has_downloaded_chromium(),
|
||||||
|
"is_frozen": is_frozen(),
|
||||||
|
"is_desktop": is_desktop(),
|
||||||
|
"browsers_dir": browsers_download_dir(),
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ period of inactivity to free resources.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
@@ -215,6 +216,14 @@ _SNAPSHOT_JS = """
|
|||||||
str(list(_INTERACTIVE_TAGS)),
|
str(list(_INTERACTIVE_TAGS)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Returning the snapshot as ONE JSON string instead of a nested object is a big
|
||||||
|
# win in the frozen desktop build: Playwright serializes a nested return value
|
||||||
|
# node-by-node over many driver<->python protocol round trips, and each round
|
||||||
|
# trip carries fixed overhead that is dramatically amplified in the frozen
|
||||||
|
# bundle (a ~300-node tree can take 20s+). JSON.stringify in-page collapses it
|
||||||
|
# to a single string transfer; Python then json.loads it. Behaviour identical.
|
||||||
|
_SNAPSHOT_JS_STR = "() => JSON.stringify((%s)())" % _SNAPSHOT_JS.strip()
|
||||||
|
|
||||||
|
|
||||||
_BROWSER_DEAD_HINTS = (
|
_BROWSER_DEAD_HINTS = (
|
||||||
"has been closed",
|
"has been closed",
|
||||||
@@ -321,17 +330,32 @@ class BrowserService:
|
|||||||
self._context = None
|
self._context = None
|
||||||
self._page = 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".
|
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
||||||
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
||||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||||
# so cookies / login state survive across runs (default).
|
# so cookies / login state survive across runs (default).
|
||||||
# - fresh: classic launch + new_context, clean state every run.
|
# - fresh: classic launch + new_context, clean state every run.
|
||||||
|
#
|
||||||
|
# Within persistent/fresh, the actual Chromium binary is resolved by
|
||||||
|
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
|
||||||
|
# download) is preferred, falling back to Playwright's own downloaded
|
||||||
|
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
|
||||||
|
# when driving a system browser, else None (bundled Chromium).
|
||||||
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
||||||
persistent_flag = self._config.get("persistent", True)
|
persistent_flag = self._config.get("persistent", True)
|
||||||
user_data_dir_cfg = self._config.get("user_data_dir")
|
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||||
if user_data_dir_cfg is None:
|
if user_data_dir_cfg is None:
|
||||||
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
||||||
|
|
||||||
|
self._channel: Optional[str] = None
|
||||||
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
||||||
if self._cdp_endpoint:
|
if self._cdp_endpoint:
|
||||||
self._launch_mode = "cdp"
|
self._launch_mode = "cdp"
|
||||||
@@ -343,6 +367,38 @@ class BrowserService:
|
|||||||
self._launch_mode = "fresh"
|
self._launch_mode = "fresh"
|
||||||
self._user_data_dir = ""
|
self._user_data_dir = ""
|
||||||
|
|
||||||
|
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||||
|
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||||
|
#
|
||||||
|
# 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 auto-release
|
||||||
idle_cfg = self._config.get("idle_timeout")
|
idle_cfg = self._config.get("idle_timeout")
|
||||||
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||||
@@ -428,11 +484,30 @@ class BrowserService:
|
|||||||
|
|
||||||
def _launch_browser(self):
|
def _launch_browser(self):
|
||||||
"""Launch / connect Chromium on the background thread."""
|
"""Launch / connect Chromium on the background thread."""
|
||||||
|
# Point Playwright at our pinned download dir before any launch so a
|
||||||
|
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import apply_browsers_path_env
|
||||||
|
apply_browsers_path_env()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
|
||||||
|
|
||||||
if self._headless is None:
|
if self._headless is None:
|
||||||
headless_cfg = self._config.get("headless")
|
headless_cfg = self._config.get("headless")
|
||||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
launch_args = ["--disable-dev-shm-usage"]
|
launch_args = [
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||||
|
# browser prompt, and Chrome's background/component network chatter.
|
||||||
|
# These have no effect on page interaction but noticeably speed up
|
||||||
|
# cold starts and each navigation.
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-features=Translate,OptimizationHints",
|
||||||
|
]
|
||||||
if self._headless:
|
if self._headless:
|
||||||
launch_args.append("--no-sandbox")
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
@@ -467,6 +542,8 @@ class BrowserService:
|
|||||||
|
|
||||||
if self._launch_mode == "cdp":
|
if self._launch_mode == "cdp":
|
||||||
self._connect_cdp(viewport)
|
self._connect_cdp(viewport)
|
||||||
|
elif self._launch_mode == "system-cdp":
|
||||||
|
self._launch_system_cdp(launch_args, viewport)
|
||||||
elif self._launch_mode == "persistent":
|
elif self._launch_mode == "persistent":
|
||||||
self._launch_persistent(launch_args, viewport, user_agent)
|
self._launch_persistent(launch_args, viewport, user_agent)
|
||||||
else:
|
else:
|
||||||
@@ -475,12 +552,20 @@ class BrowserService:
|
|||||||
logger.info("[Browser] Browser ready")
|
logger.info("[Browser] Browser ready")
|
||||||
|
|
||||||
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
"""Classic launch: brand new Chromium with an empty context."""
|
"""Classic launch: brand new Chromium with an empty context.
|
||||||
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
|
|
||||||
self._browser = self._playwright.chromium.launch(
|
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
|
||||||
headless=self._headless,
|
the user's installed system browser instead of its own Chromium.
|
||||||
args=launch_args,
|
"""
|
||||||
)
|
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||||
|
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
|
||||||
|
launch_kwargs: Dict[str, Any] = {
|
||||||
|
"headless": self._headless,
|
||||||
|
"args": launch_args,
|
||||||
|
}
|
||||||
|
if self._channel:
|
||||||
|
launch_kwargs["channel"] = self._channel
|
||||||
|
self._browser = self._playwright.chromium.launch(**launch_kwargs)
|
||||||
self._context = self._browser.new_context(
|
self._context = self._browser.new_context(
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
@@ -491,18 +576,25 @@ class BrowserService:
|
|||||||
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
||||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
|
f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
|
||||||
f"profile={self._user_data_dir})"
|
f"profile={self._user_data_dir})"
|
||||||
)
|
)
|
||||||
|
persistent_kwargs: Dict[str, Any] = {
|
||||||
|
"user_data_dir": self._user_data_dir,
|
||||||
|
"headless": self._headless,
|
||||||
|
"args": launch_args,
|
||||||
|
"viewport": viewport,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
}
|
||||||
|
# When driving a system browser, let it use its real UA instead of the
|
||||||
|
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
|
||||||
|
if self._channel:
|
||||||
|
persistent_kwargs["channel"] = self._channel
|
||||||
|
persistent_kwargs.pop("user_agent", None)
|
||||||
try:
|
try:
|
||||||
self._context = self._playwright.chromium.launch_persistent_context(
|
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
|
||||||
user_data_dir=self._user_data_dir,
|
|
||||||
headless=self._headless,
|
|
||||||
args=launch_args,
|
|
||||||
viewport=viewport,
|
|
||||||
user_agent=user_agent,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Profile is locked when another Chromium instance already holds it.
|
# Profile is locked when another Chromium instance already holds it.
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
@@ -520,6 +612,41 @@ class BrowserService:
|
|||||||
self._page = pages[0] if pages else self._context.new_page()
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
self._wire_close_listeners()
|
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]):
|
def _connect_cdp(self, viewport: Dict[str, int]):
|
||||||
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
||||||
endpoint = self._cdp_endpoint
|
endpoint = self._cdp_endpoint
|
||||||
@@ -574,13 +701,27 @@ class BrowserService:
|
|||||||
self._cancel_idle_timer()
|
self._cancel_idle_timer()
|
||||||
|
|
||||||
if self._launch_mode == "cdp":
|
if self._launch_mode == "cdp":
|
||||||
# For CDP, browser.close() only detaches the Playwright client;
|
# For external CDP, browser.close() only detaches the Playwright
|
||||||
# the user's Chrome process and its tabs stay alive.
|
# client; the user's Chrome process and its tabs stay alive.
|
||||||
try:
|
try:
|
||||||
if self._browser:
|
if self._browser:
|
||||||
self._browser.close()
|
self._browser.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[Browser] cdp disconnect error: {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:
|
else:
|
||||||
for obj, label in [
|
for obj, label in [
|
||||||
(self._context, "context"),
|
(self._context, "context"),
|
||||||
@@ -687,11 +828,15 @@ class BrowserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Navigation failed: {e}"}
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||||
|
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||||
|
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||||
|
# short grace period for initial render/XHR, then proceed.
|
||||||
try:
|
try:
|
||||||
page.wait_for_load_state("networkidle", timeout=8000)
|
page.wait_for_load_state("networkidle", timeout=1500)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
title = page.title()
|
title = page.title()
|
||||||
@@ -710,7 +855,11 @@ class BrowserService:
|
|||||||
def _do_snapshot(self, selector: Optional[str] = None) -> str:
|
def _do_snapshot(self, selector: Optional[str] = None) -> str:
|
||||||
page = self._page
|
page = self._page
|
||||||
try:
|
try:
|
||||||
result = page.evaluate(_SNAPSHOT_JS)
|
# Return a single JSON string (not a nested object) to avoid
|
||||||
|
# Playwright's per-node serialization round trips, which are slow
|
||||||
|
# in the frozen build. See _SNAPSHOT_JS_STR.
|
||||||
|
raw = page.evaluate(_SNAPSHOT_JS_STR)
|
||||||
|
result = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"[Snapshot error: {e}]"
|
return f"[Snapshot error: {e}]"
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
|
|||||||
f"({ip_str}), request blocked for security"
|
f"({ip_str}), request blocked for security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_engine_ready(self) -> Optional[ToolResult]:
|
||||||
|
"""Return an actionable onboarding message if no browser engine is ready.
|
||||||
|
|
||||||
|
Returns None when a system Chrome/Edge or a downloaded Chromium is
|
||||||
|
available (so the tool can proceed). Otherwise returns a ToolResult with
|
||||||
|
clear guidance so the agent asks the user to enable the browser instead
|
||||||
|
of surfacing a raw Playwright launch error. CDP mode is exempt (the
|
||||||
|
endpoint is external and validated at connect time).
|
||||||
|
"""
|
||||||
|
if self.config.get("cdp_endpoint"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import capability_summary
|
||||||
|
summary = capability_summary(self.config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] capability probe failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if summary.get("ready"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
|
||||||
|
# in-chat `/install-browser` command. Source / web / server installs use
|
||||||
|
# the `cow install-browser` terminal command.
|
||||||
|
install_hint = (
|
||||||
|
"reply `/install-browser`" if summary.get("is_desktop")
|
||||||
|
else "run `cow install-browser` in a terminal"
|
||||||
|
)
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
|
||||||
|
"skipped automatically if Google Chrome is already installed). "
|
||||||
|
"Do not retry until the user confirms."
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
action = args.get("action", "").strip().lower()
|
action = args.get("action", "").strip().lower()
|
||||||
if not action:
|
if not action:
|
||||||
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
|
|||||||
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||||
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||||
|
|
||||||
|
# Preflight: on desktop the playwright package is bundled but the browser
|
||||||
|
# binary may be missing; return actionable onboarding instead of a cryptic
|
||||||
|
# launch failure.
|
||||||
|
not_ready = self._check_engine_ready()
|
||||||
|
if not_ready is not None:
|
||||||
|
return not_ready
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return handler(self, args)
|
return handler(self, args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
174
agent/tools/browser/chrome_launcher.py
Normal file
174
agent/tools/browser/chrome_launcher.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""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 _clear_stale_singleton_locks(self):
|
||||||
|
"""Remove leftover Chrome Singleton* locks from a crashed/killed run.
|
||||||
|
|
||||||
|
Chrome allows only one instance per user_data_dir and enforces it with
|
||||||
|
SingletonLock / SingletonSocket / SingletonCookie. On a clean exit these
|
||||||
|
are removed, but a crash or force-quit leaves them behind — the next
|
||||||
|
spawn then hands off to the (dead) "existing" instance and exits without
|
||||||
|
opening the debug port, so CDP never comes up (a permanent, non
|
||||||
|
self-healing failure). This profile is private to us, so clearing stale
|
||||||
|
locks before launch is safe: if our own browser were truly alive, the
|
||||||
|
service would still be connected and we wouldn't be re-launching.
|
||||||
|
"""
|
||||||
|
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||||
|
p = os.path.join(self._user_data_dir, name)
|
||||||
|
try:
|
||||||
|
# These are symlinks; use lexists so a dangling link is caught.
|
||||||
|
if os.path.lexists(p):
|
||||||
|
os.remove(p)
|
||||||
|
logger.info(f"[Browser] cleared stale Chrome lock: {name}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug(f"[Browser] could not remove {name}: {e}")
|
||||||
|
|
||||||
|
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._clear_stale_singleton_locks()
|
||||||
|
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):
|
||||||
|
# Capture the port before close() clears it, so the error is useful.
|
||||||
|
port = self._port
|
||||||
|
self.close()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Chrome did not expose a CDP endpoint on port {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}")
|
||||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
|||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
return True # agent ran but produced nothing; don't loop
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
if action.get("silent", False):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
channel = create_channel(channel_type)
|
channel = create_channel(channel_type)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class SchedulerService:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._execution_lock = threading.Lock()
|
||||||
|
self._active_task_ids = set()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the scheduler service"""
|
"""Start the scheduler service"""
|
||||||
@@ -85,7 +87,15 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
if self._is_task_due(task, now):
|
if self._is_task_due(task, now):
|
||||||
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
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:
|
if not ok:
|
||||||
# Leave next_run_at as-is so the next loop retries.
|
# Leave next_run_at as-is so the next loop retries.
|
||||||
# Cron tasks within the catch-up window will keep
|
# Cron tasks within the catch-up window will keep
|
||||||
@@ -107,6 +117,57 @@ class SchedulerService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {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:
|
def _is_task_due(self, task: dict, now: datetime) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a task is due to run
|
Check if a task is due to run
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
|||||||
"schedule_value": {
|
"schedule_value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
|||||||
"channel_type": self.config.get("channel_type", "unknown"),
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
"notify_session_id": notify_session_id,
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
# silent only applies to ai_task; fixed messages always deliver
|
||||||
|
if kwargs.get("silent", False):
|
||||||
|
action["silent"] = True
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
msg = context.kwargs.get("msg")
|
msg = context.kwargs.get("msg")
|
||||||
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
|
|||||||
else:
|
else:
|
||||||
content_desc = f"🤖 AI任务: {ai_task}"
|
content_desc = f"🤖 AI任务: {ai_task}"
|
||||||
|
|
||||||
|
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||||
|
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"✅ 定时任务创建成功\n\n"
|
f"✅ 定时任务创建成功\n\n"
|
||||||
f"📋 任务ID: {task_id}\n"
|
f"📋 任务ID: {task_id}\n"
|
||||||
f"📝 名称: {name}\n"
|
f"📝 名称: {name}\n"
|
||||||
f"⏰ 调度: {schedule_desc}\n"
|
f"⏰ 调度: {schedule_desc}\n"
|
||||||
f"👤 接收者: {receiver_desc}\n"
|
f"👤 接收者: {receiver_desc}\n"
|
||||||
f"{content_desc}\n"
|
f"{content_desc}{silent_desc}\n"
|
||||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -222,14 +215,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -261,14 +247,7 @@ class ToolManager:
|
|||||||
# If there are missing tools, record warnings
|
# If there are missing tools, record warnings
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
for tool_name in missing_tools:
|
for tool_name in missing_tools:
|
||||||
if tool_name == "browser":
|
if tool_name == "google_search":
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif tool_name == "google_search":
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||||
f" Get API key from: https://serper.dev\n"
|
f" Get API key from: https://serper.dev\n"
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge
|
|||||||
import os
|
import os
|
||||||
from typing import Optional, List
|
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_event_handler import AgentEventHandler
|
||||||
from bridge.agent_initializer import AgentInitializer
|
from bridge.agent_initializer import AgentInitializer
|
||||||
from bridge.bridge import Bridge
|
from bridge.bridge import Bridge
|
||||||
@@ -360,6 +366,11 @@ class AgentBridge:
|
|||||||
|
|
||||||
return agent
|
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]:
|
def get_agent(self, session_id: str = None) -> Optional[Agent]:
|
||||||
"""
|
"""
|
||||||
Get agent instance for the given session
|
Get agent instance for the given session
|
||||||
@@ -452,6 +463,8 @@ class AgentBridge:
|
|||||||
agent = None
|
agent = None
|
||||||
request_id = None
|
request_id = None
|
||||||
cancel_event = None
|
cancel_event = None
|
||||||
|
token_key = None
|
||||||
|
steer_inbox = None
|
||||||
try:
|
try:
|
||||||
# Extract session_id from context for user isolation
|
# Extract session_id from context for user isolation
|
||||||
if context:
|
if context:
|
||||||
@@ -534,12 +547,15 @@ class AgentBridge:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if session_id:
|
||||||
|
steer_inbox = get_steer_registry().register(session_id)
|
||||||
# Use agent's run_stream method with event handler
|
# Use agent's run_stream method with event handler
|
||||||
response = agent.run_stream(
|
response = agent.run_stream(
|
||||||
user_message=query,
|
user_message=query,
|
||||||
on_event=event_handler.handle_event,
|
on_event=event_handler.handle_event,
|
||||||
clear_history=clear_history,
|
clear_history=clear_history,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
steer_inbox=steer_inbox,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Clear the mid-run flag so idle scans can review this session.
|
# Clear the mid-run flag so idle scans can review this session.
|
||||||
@@ -562,6 +578,8 @@ class AgentBridge:
|
|||||||
registry.unregister(token_key)
|
registry.unregister(token_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Persist new messages generated during this run
|
||||||
if session_id:
|
if session_id:
|
||||||
@@ -643,6 +661,11 @@ class AgentBridge:
|
|||||||
get_cancel_registry().unregister(request_id or session_id)
|
get_cancel_registry().unregister(request_id or session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)}")
|
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||||||
|
|
||||||
def _schedule_mcp_hot_reload(self, agent):
|
def _schedule_mcp_hot_reload(self, agent):
|
||||||
|
|||||||
@@ -453,6 +453,10 @@ class ChatChannel(Channel):
|
|||||||
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||||
self._handle_cancel_command(context, session_id)
|
self._handle_cancel_command(context, session_id)
|
||||||
return
|
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:
|
with self.lock:
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[chat_channel] /cancel fast-path failed: {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):
|
def consume(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -515,6 +562,42 @@ class ChatChannel(Channel):
|
|||||||
semaphore.release()
|
semaphore.release()
|
||||||
time.sleep(0.2)
|
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对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
# 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
||||||
def cancel_session(self, session_id):
|
def cancel_session(self, session_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ python3 app.py
|
|||||||
2. 进入应用详情 -> 事件订阅
|
2. 进入应用详情 -> 事件订阅
|
||||||
3. 选择 **将事件发送至开发者服务器**
|
3. 选择 **将事件发送至开发者服务器**
|
||||||
4. 填写请求地址: `http://your-domain:9891/`
|
4. 填写请求地址: `http://your-domain:9891/`
|
||||||
5. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
5. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||||
6. 保存配置
|
6. 保存配置
|
||||||
|
|
||||||
### 4. 注意事项
|
### 4. 注意事项
|
||||||
@@ -101,7 +101,7 @@ python3 app.py
|
|||||||
1. 登录[飞书开放平台](https://open.feishu.cn/)
|
1. 登录[飞书开放平台](https://open.feishu.cn/)
|
||||||
2. 进入应用详情 -> 事件订阅
|
2. 进入应用详情 -> 事件订阅
|
||||||
3. 选择 **使用长连接接收事件**
|
3. 选择 **使用长连接接收事件**
|
||||||
4. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
4. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||||
5. 保存配置
|
5. 保存配置
|
||||||
|
|
||||||
### 5. 注意事项
|
### 5. 注意事项
|
||||||
@@ -168,7 +168,7 @@ Address already in use
|
|||||||
### 收不到消息
|
### 收不到消息
|
||||||
|
|
||||||
1. 检查飞书应用的事件订阅配置
|
1. 检查飞书应用的事件订阅配置
|
||||||
2. 确认已添加 `im.message.receive_v1` 事件
|
2. 确认已添加 `im.message.receive_v1` 和 `im.message.recalled_v1` 事件
|
||||||
3. 检查应用权限: 需要 `im:message` 权限
|
3. 检查应用权限: 需要 `im:message` 权限
|
||||||
4. 查看日志中的错误信息
|
4. 查看日志中的错误信息
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ from bridge.context import ContextType
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
from channel.feishu.feishu_message import FeishuMessage
|
from channel.feishu.feishu_message import FeishuMessage
|
||||||
|
from channel.feishu.feishu_static_card import (
|
||||||
|
build_text_delivery,
|
||||||
|
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 import utils
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -240,6 +251,8 @@ class FeiShuChanel(ChatChannel):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
# 历史消息id暂存,用于幂等控制
|
# 历史消息id暂存,用于幂等控制
|
||||||
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
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._http_server = None
|
||||||
self._ws_client = None
|
self._ws_client = None
|
||||||
self._ws_thread = None
|
self._ws_thread = None
|
||||||
@@ -350,6 +363,10 @@ class FeiShuChanel(ChatChannel):
|
|||||||
def _startup_websocket(self):
|
def _startup_websocket(self):
|
||||||
"""启动长连接接收事件(websocket模式)"""
|
"""启动长连接接收事件(websocket模式)"""
|
||||||
_ensure_lark_imported()
|
_ensure_lark_imported()
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||||
|
P2CardActionTriggerResponse,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||||
|
|
||||||
# 创建事件处理器
|
# 创建事件处理器
|
||||||
@@ -372,10 +389,40 @@ class FeiShuChanel(ChatChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def handle_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("", "") \
|
event_handler = (
|
||||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
lark.EventDispatcherHandler.builder("", "")
|
||||||
|
.register_p2_im_message_receive_v1(handle_message_event)
|
||||||
|
.register_p2_im_message_recalled_v1(handle_message_recalled_event)
|
||||||
|
.register_p2_card_action_trigger(handle_card_action)
|
||||||
.build()
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
def start_client_with_retry():
|
def start_client_with_retry():
|
||||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||||
@@ -470,6 +517,120 @@ class FeiShuChanel(ChatChannel):
|
|||||||
# so reaching here means the bot was indeed mentioned.
|
# so reaching here means the bot was indeed mentioned.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _get_scheduler_task_store(self):
|
||||||
|
"""Reuse the live scheduler store, with a path-compatible fallback."""
|
||||||
|
from agent.tools.scheduler.integration import get_task_store
|
||||||
|
|
||||||
|
task_store = get_task_store()
|
||||||
|
if task_store is not None:
|
||||||
|
return task_store
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
|
||||||
|
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
|
||||||
|
|
||||||
|
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
|
||||||
|
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
|
||||||
|
task_store = self._get_scheduler_task_store()
|
||||||
|
receivers = {feishu_msg.other_user_id}
|
||||||
|
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
|
||||||
|
card = build_scheduler_card(tasks)
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + feishu_msg.access_token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
content = json.dumps(card, ensure_ascii=False)
|
||||||
|
|
||||||
|
if is_group and feishu_msg.msg_id:
|
||||||
|
url = (
|
||||||
|
"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||||
|
f"{feishu_msg.msg_id}/reply"
|
||||||
|
)
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json={"msg_type": "interactive", "content": content},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params={"receive_id_type": receive_id_type},
|
||||||
|
json={
|
||||||
|
"receive_id": feishu_msg.other_user_id,
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
logger.info("[FeiShu] scheduler card sent")
|
||||||
|
return True
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] scheduler card failed, "
|
||||||
|
f"code={result.get('code')}, msg={result.get('msg')}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _handle_card_action_event(self, event: dict) -> dict:
|
||||||
|
"""Apply a scheduler card action within its chat/operator ownership scope."""
|
||||||
|
action = event.get("action") or {}
|
||||||
|
value = action.get("value") or {}
|
||||||
|
if value.get("cowagent") != "scheduler":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context = event.get("context") or {}
|
||||||
|
operator = event.get("operator") or {}
|
||||||
|
callback_receivers = {
|
||||||
|
receiver
|
||||||
|
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
|
||||||
|
if receiver
|
||||||
|
}
|
||||||
|
target_receiver = value.get("receiver")
|
||||||
|
allowed_receivers = (
|
||||||
|
{target_receiver}
|
||||||
|
if target_receiver and target_receiver in callback_receivers
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
response = handle_scheduler_action(
|
||||||
|
value,
|
||||||
|
self._get_scheduler_task_store(),
|
||||||
|
allowed_receivers,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[FeiShu] scheduler card action handled, "
|
||||||
|
f"action={value.get('action')}, task_id={value.get('task_id')}"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _handle_message_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):
|
def _handle_message_event(self, event: dict):
|
||||||
"""
|
"""
|
||||||
处理消息事件的核心逻辑
|
处理消息事件的核心逻辑
|
||||||
@@ -521,6 +682,11 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not feishu_msg:
|
if not feishu_msg:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
|
||||||
|
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
|
||||||
|
logger.warning("[FeiShu] /tasks card delivery failed")
|
||||||
|
return
|
||||||
|
|
||||||
# 处理文件缓存逻辑
|
# 处理文件缓存逻辑
|
||||||
from channel.file_cache import get_file_cache
|
from channel.file_cache import get_file_cache
|
||||||
file_cache = get_file_cache()
|
file_cache = get_file_cache()
|
||||||
@@ -591,13 +757,17 @@ class FeiShuChanel(ChatChannel):
|
|||||||
|
|
||||||
context = self._compose_context(
|
context = self._compose_context(
|
||||||
feishu_msg.ctype,
|
feishu_msg.ctype,
|
||||||
feishu_msg.content,
|
feishu_msg.content_with_quote(),
|
||||||
isgroup=is_group,
|
isgroup=is_group,
|
||||||
msg=feishu_msg,
|
msg=feishu_msg,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
no_need_at=True
|
no_need_at=True
|
||||||
)
|
)
|
||||||
if context:
|
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 每产出一段文字时会调用它。
|
# 流式回复模式:向 context 注入 on_event 回调,agent 每产出一段文字时会调用它。
|
||||||
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
||||||
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
||||||
@@ -628,7 +798,18 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||||
reply_content = reply.content
|
reply_content = reply.content
|
||||||
content_key = "text"
|
content_key = "text"
|
||||||
if reply.type == ReplyType.IMAGE_URL:
|
prepared_content_json = None
|
||||||
|
if reply.type == ReplyType.TEXT:
|
||||||
|
# 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)
|
reply_content = self._upload_image_url(reply.content, access_token)
|
||||||
if not reply_content:
|
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
|
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||||
|
|
||||||
# Build content JSON
|
# Build content JSON
|
||||||
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
content_json = prepared_content_json or (
|
||||||
|
json.dumps(reply_content, ensure_ascii=False)
|
||||||
|
if content_key is None
|
||||||
|
else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||||
|
)
|
||||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||||
|
|
||||||
if can_reply:
|
if can_reply:
|
||||||
@@ -714,10 +899,393 @@ class FeiShuChanel(ChatChannel):
|
|||||||
res = res.json()
|
res = res.json()
|
||||||
if res.get("code") == 0:
|
if res.get("code") == 0:
|
||||||
logger.info(f"[FeiShu] send message success")
|
logger.info(f"[FeiShu] send message success")
|
||||||
|
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
|
||||||
|
logger.warning(
|
||||||
|
"[FeiShu] Markdown card failed, falling back to text, "
|
||||||
|
f"code={res.get('code')}, msg={res.get('msg')}"
|
||||||
|
)
|
||||||
|
fallback_data = {
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
if can_reply:
|
||||||
|
fallback_res = requests.post(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=fallback_data,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fallback_data["receive_id"] = context.get("receiver")
|
||||||
|
fallback_res = requests.post(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
json=fallback_data,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
fallback_body = fallback_res.json()
|
||||||
|
if fallback_body.get("code") == 0:
|
||||||
|
logger.info("[FeiShu] text fallback sent successfully")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] text fallback failed, "
|
||||||
|
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
def _make_feishu_stream_callback(self, context, access_token):
|
def _make_feishu_stream_callback(self, context, access_token):
|
||||||
|
"""Route to 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 实现打字机回复。
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
@@ -966,9 +1534,15 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not cid:
|
if not cid:
|
||||||
return
|
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
|
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||||
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
# (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 ""
|
preview = preview_src[:30] if preview_src else ""
|
||||||
full_card = {
|
full_card = {
|
||||||
"schema": "2.0",
|
"schema": "2.0",
|
||||||
@@ -1565,6 +2139,8 @@ class FeishuController:
|
|||||||
FAILED_MSG = '{"success": false}'
|
FAILED_MSG = '{"success": false}'
|
||||||
SUCCESS_MSG = '{"success": true}'
|
SUCCESS_MSG = '{"success": true}'
|
||||||
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
||||||
|
MESSAGE_RECALLED_TYPE = "im.message.recalled_v1"
|
||||||
|
CARD_ACTION_TYPE = "card.action.trigger"
|
||||||
|
|
||||||
def GET(self):
|
def GET(self):
|
||||||
return "Feishu service start success!"
|
return "Feishu service start success!"
|
||||||
@@ -1581,16 +2157,30 @@ class FeishuController:
|
|||||||
varify_res = {"challenge": request.get("challenge")}
|
varify_res = {"challenge": request.get("challenge")}
|
||||||
return json.dumps(varify_res)
|
return json.dumps(varify_res)
|
||||||
|
|
||||||
# 2.消息接收处理
|
# 2. Verify callbacks. Card callbacks may carry the verification
|
||||||
# token 校验
|
# token in event.token while message events carry it in the header.
|
||||||
header = request.get("header")
|
header = request.get("header") or {}
|
||||||
if not header or header.get("token") != channel.feishu_token:
|
event = request.get("event") or {}
|
||||||
|
event_type = header.get("event_type") or request.get("type")
|
||||||
|
callback_token = (
|
||||||
|
header.get("token")
|
||||||
|
or event.get("token")
|
||||||
|
or request.get("token")
|
||||||
|
)
|
||||||
|
if callback_token != channel.feishu_token:
|
||||||
return self.FAILED_MSG
|
return self.FAILED_MSG
|
||||||
|
|
||||||
# 处理消息事件
|
if event_type == self.CARD_ACTION_TYPE and event:
|
||||||
event = request.get("event")
|
return json.dumps(
|
||||||
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
|
channel._handle_card_action_event(event),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Handle message events.
|
||||||
|
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
|
||||||
channel._handle_message_event(event)
|
channel._handle_message_event(event)
|
||||||
|
elif event_type == self.MESSAGE_RECALLED_TYPE and event:
|
||||||
|
channel._handle_message_recalled_event(event)
|
||||||
|
|
||||||
return self.SUCCESS_MSG
|
return self.SUCCESS_MSG
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
|||||||
self.msg_id = msg.get("message_id")
|
self.msg_id = msg.get("message_id")
|
||||||
self.create_time = msg.get("create_time")
|
self.create_time = msg.get("create_time")
|
||||||
self.is_group = is_group
|
self.is_group = is_group
|
||||||
|
self.quoted_content = ""
|
||||||
msg_type = msg.get("message_type")
|
msg_type = msg.get("message_type")
|
||||||
|
|
||||||
if msg_type == "text":
|
if msg_type == "text":
|
||||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
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.from_user_id = sender.get("sender_id").get("open_id")
|
||||||
self.to_user_id = event.get("app_id")
|
self.to_user_id = event.get("app_id")
|
||||||
if is_group:
|
if is_group:
|
||||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
|||||||
# 私聊
|
# 私聊
|
||||||
self.other_user_id = self.from_user_id
|
self.other_user_id = self.from_user_id
|
||||||
self.actual_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"),
|
("help", "Show command help"),
|
||||||
("status", "Show running status"),
|
("status", "Show running status"),
|
||||||
("context", "View/clear conversation context (sub: clear)"),
|
("context", "View/clear conversation context (sub: clear)"),
|
||||||
|
("tasks", "List scheduled tasks for this chat"),
|
||||||
("skill", "Manage skills (list/search/install/...)"),
|
("skill", "Manage skills (list/search/install/...)"),
|
||||||
("memory", "Manage memory (sub: dream)"),
|
("memory", "Manage memory (sub: dream)"),
|
||||||
("knowledge", "Manage knowledge base (list/on/off)"),
|
("knowledge", "Manage knowledge base (list/on/off)"),
|
||||||
("config", "Show current config"),
|
("config", "Show current config"),
|
||||||
("cancel", "Cancel running agent task"),
|
("cancel", "Cancel running agent task"),
|
||||||
|
("steer", "Guide the running agent task"),
|
||||||
("logs", "Show recent logs"),
|
("logs", "Show recent logs"),
|
||||||
("version", "Show version"),
|
("version", "Show version"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -485,6 +485,20 @@
|
|||||||
<i class="fas fa-microphone text-sm"></i>
|
<i class="fas fa-microphone text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
<button id="send-btn"
|
||||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||||
bg-primary-400 text-white hover:bg-primary-500
|
bg-primary-400 text-white hover:bg-primary-500
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ const I18N = {
|
|||||||
slash_knowledge_off: '关闭知识库',
|
slash_knowledge_off: '关闭知识库',
|
||||||
slash_config: '查看当前配置',
|
slash_config: '查看当前配置',
|
||||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||||
|
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
|
||||||
|
steer_active: '引导当前任务',
|
||||||
slash_logs: '查看最近日志',
|
slash_logs: '查看最近日志',
|
||||||
slash_version: '查看版本',
|
slash_version: '查看版本',
|
||||||
input_placeholder: '输入消息,或输入 / 使用指令',
|
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||||
@@ -217,6 +219,11 @@ const I18N = {
|
|||||||
task_delete_btn: '删除任务',
|
task_delete_btn: '删除任务',
|
||||||
task_delete_confirm_title: '删除定时任务',
|
task_delete_confirm_title: '删除定时任务',
|
||||||
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
|
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_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||||
new_chat: '新对话',
|
new_chat: '新对话',
|
||||||
@@ -370,6 +377,8 @@ const I18N = {
|
|||||||
slash_knowledge_off: '關閉知識庫',
|
slash_knowledge_off: '關閉知識庫',
|
||||||
slash_config: '檢視當前設定',
|
slash_config: '檢視當前設定',
|
||||||
slash_cancel: '中止當前正在執行的 Agent 任務',
|
slash_cancel: '中止當前正在執行的 Agent 任務',
|
||||||
|
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
|
||||||
|
steer_active: '引導當前任務',
|
||||||
slash_logs: '檢視最近日誌',
|
slash_logs: '檢視最近日誌',
|
||||||
slash_version: '檢視版本',
|
slash_version: '檢視版本',
|
||||||
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
||||||
@@ -464,6 +473,11 @@ const I18N = {
|
|||||||
task_delete_btn: '刪除任務',
|
task_delete_btn: '刪除任務',
|
||||||
task_delete_confirm_title: '刪除定時任務',
|
task_delete_confirm_title: '刪除定時任務',
|
||||||
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
|
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_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
|
||||||
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
||||||
new_chat: '新對話',
|
new_chat: '新對話',
|
||||||
@@ -616,6 +630,8 @@ const I18N = {
|
|||||||
slash_knowledge_off: 'Disable knowledge base',
|
slash_knowledge_off: 'Disable knowledge base',
|
||||||
slash_config: 'Show current config',
|
slash_config: 'Show current config',
|
||||||
slash_cancel: 'Abort the running Agent task',
|
slash_cancel: 'Abort the running Agent task',
|
||||||
|
slash_steer: 'Inject guidance into the running Agent task',
|
||||||
|
steer_active: 'Steer active task',
|
||||||
slash_logs: 'Show recent logs',
|
slash_logs: 'Show recent logs',
|
||||||
slash_version: 'Show version',
|
slash_version: 'Show version',
|
||||||
input_placeholder: 'Type a message, or press / for commands',
|
input_placeholder: 'Type a message, or press / for commands',
|
||||||
@@ -710,6 +726,11 @@ const I18N = {
|
|||||||
task_delete_btn: 'Delete Task',
|
task_delete_btn: 'Delete Task',
|
||||||
task_delete_confirm_title: 'Delete Task',
|
task_delete_confirm_title: 'Delete Task',
|
||||||
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
|
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_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.',
|
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',
|
new_chat: 'New Chat',
|
||||||
@@ -801,6 +822,9 @@ function applyI18n() {
|
|||||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
el.title = t(el.dataset['i18nTitle']);
|
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 => {
|
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
||||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||||
});
|
});
|
||||||
@@ -1139,6 +1163,33 @@ function createMd() {
|
|||||||
return hljsLib.highlightAuto(str).value;
|
return hljsLib.highlightAuto(str).value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (*)
|
||||||
|
// and CJK full-width punctuation glued to a URL (common in LLM output like
|
||||||
|
// "**https://x**,中文"), turning the whole tail into one broken link. Cut
|
||||||
|
// the URL at the first such char and spill the remainder back as text.
|
||||||
|
var GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/;
|
||||||
|
md.core.ruler.after('linkify', 'fix_greedy_linkify', function(state) {
|
||||||
|
for (var b = 0; b < state.tokens.length; b++) {
|
||||||
|
var blk = state.tokens[b];
|
||||||
|
if (blk.type !== 'inline' || !blk.children) continue;
|
||||||
|
var ch = blk.children;
|
||||||
|
for (var i = 0; i < ch.length; i++) {
|
||||||
|
var open = ch[i];
|
||||||
|
if (open.type !== 'link_open' || open.markup !== 'linkify') continue;
|
||||||
|
var textTok = ch[i + 1], close = ch[i + 2];
|
||||||
|
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue;
|
||||||
|
var idx = textTok.content.search(GREEDY_LINK_CUT);
|
||||||
|
if (idx < 0) continue;
|
||||||
|
var keep = textTok.content.slice(0, idx);
|
||||||
|
var spill = textTok.content.slice(idx);
|
||||||
|
textTok.content = keep;
|
||||||
|
open.attrSet('href', keep);
|
||||||
|
var spillTok = new state.Token('text', '', 0);
|
||||||
|
spillTok.content = spill;
|
||||||
|
ch.splice(i + 3, 0, spillTok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||||||
return self.renderToken(tokens, idx, options);
|
return self.renderToken(tokens, idx, options);
|
||||||
};
|
};
|
||||||
@@ -1373,6 +1424,7 @@ startPolling();
|
|||||||
|
|
||||||
const chatInput = document.getElementById('chat-input');
|
const chatInput = document.getElementById('chat-input');
|
||||||
const sendBtn = document.getElementById('send-btn');
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const steerBtn = document.getElementById('steer-btn');
|
||||||
const messagesDiv = document.getElementById('chat-messages');
|
const messagesDiv = document.getElementById('chat-messages');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
const folderInput = document.getElementById('folder-input');
|
const folderInput = document.getElementById('folder-input');
|
||||||
@@ -1707,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
|
|||||||
sendBtn.classList.add('send-btn-cancel');
|
sendBtn.classList.add('send-btn-cancel');
|
||||||
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
||||||
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
||||||
|
updateSteerBtnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSendBtnSendMode() {
|
function resetSendBtnSendMode() {
|
||||||
@@ -1715,9 +1768,64 @@ function resetSendBtnSendMode() {
|
|||||||
sendBtn.classList.remove('send-btn-cancel');
|
sendBtn.classList.remove('send-btn-cancel');
|
||||||
sendBtn.title = '';
|
sendBtn.title = '';
|
||||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
||||||
|
steerBtn.classList.add('hidden');
|
||||||
|
steerBtn.classList.remove('flex');
|
||||||
|
steerBtn.disabled = true;
|
||||||
updateSendBtnState();
|
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() {
|
function requestCancel() {
|
||||||
const reqId = activeRequestId;
|
const reqId = activeRequestId;
|
||||||
if (!reqId) return;
|
if (!reqId) return;
|
||||||
@@ -1753,10 +1861,12 @@ function updateSendBtnState() {
|
|||||||
resetSendBtnSendMode();
|
resetSendBtnSendMode();
|
||||||
} else {
|
} else {
|
||||||
// Don't downgrade a genuinely active Cancel button on input edits.
|
// Don't downgrade a genuinely active Cancel button on input edits.
|
||||||
|
updateSteerBtnState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||||
|
updateSteerBtnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachmentPreview() {
|
function renderAttachmentPreview() {
|
||||||
@@ -2075,6 +2185,7 @@ const SLASH_COMMANDS = [
|
|||||||
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
|
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
|
||||||
{ cmd: '/config', desc: 'slash_config' },
|
{ cmd: '/config', desc: 'slash_config' },
|
||||||
{ cmd: '/cancel', desc: 'slash_cancel' },
|
{ cmd: '/cancel', desc: 'slash_cancel' },
|
||||||
|
{ cmd: '/steer ', desc: 'slash_steer' },
|
||||||
{ cmd: '/logs', desc: 'slash_logs' },
|
{ cmd: '/logs', desc: 'slash_logs' },
|
||||||
{ cmd: '/version', desc: 'slash_version' },
|
{ cmd: '/version', desc: 'slash_version' },
|
||||||
];
|
];
|
||||||
@@ -7908,6 +8019,38 @@ function refreshTasksView() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}, 500);
|
}, 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() {
|
function loadTasksView() {
|
||||||
if (tasksLoaded) return;
|
if (tasksLoaded) return;
|
||||||
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
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">
|
<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>
|
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||||||
<div class="flex-1"></div>
|
<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}">
|
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
|
||||||
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
|
<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>
|
<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>
|
</label>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
const runButton = card.querySelector('.task-run-now');
|
||||||
|
runButton.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
runTaskNow(task, runButton);
|
||||||
|
});
|
||||||
const checkbox = card.querySelector('#' + toggleId);
|
const checkbox = card.querySelector('#' + toggleId);
|
||||||
checkbox.addEventListener('change', function() {
|
checkbox.addEventListener('change', function() {
|
||||||
const newEnabled = this.checked;
|
const newEnabled = this.checked;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -134,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
|
|||||||
return "Nothing to cancel." if en else "当前没有可中止的任务。"
|
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:
|
def _get_upload_dir() -> str:
|
||||||
from common.utils import expand_path
|
from common.utils import expand_path
|
||||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
@@ -912,6 +943,37 @@ class WebChannel(ChatChannel):
|
|||||||
"inline_reply": msg_text,
|
"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)
|
# Append file references to the prompt (same format as QQ channel)
|
||||||
if attachments:
|
if attachments:
|
||||||
file_refs = []
|
file_refs = []
|
||||||
@@ -1309,6 +1371,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
|
'/api/scheduler/run', 'SchedulerRunHandler',
|
||||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||||
@@ -1700,15 +1763,14 @@ class ConfigHandler:
|
|||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||||
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
|
# claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
|
||||||
# from this web console list for now.
|
const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||||
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
|
||||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||||
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
]
|
]
|
||||||
@@ -1747,7 +1809,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
}),
|
}),
|
||||||
("gemini", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1763,7 +1825,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "open_ai_api_base",
|
"api_base_key": "open_ai_api_base",
|
||||||
"api_base_default": "https://api.openai.com/v1",
|
"api_base_default": "https://api.openai.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||||
}),
|
}),
|
||||||
("zhipu", {
|
("zhipu", {
|
||||||
"label": {"zh": "智谱AI", "en": "GLM"},
|
"label": {"zh": "智谱AI", "en": "GLM"},
|
||||||
@@ -1795,7 +1857,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "moonshot_base_url",
|
"api_base_key": "moonshot_base_url",
|
||||||
"api_base_default": "https://api.moonshot.cn/v1",
|
"api_base_default": "https://api.moonshot.cn/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||||
}),
|
}),
|
||||||
("qianfan", {
|
("qianfan", {
|
||||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||||
@@ -2339,9 +2401,12 @@ class ModelsHandler:
|
|||||||
# Anything not listed here intentionally hides the model dropdown so
|
# Anything not listed here intentionally hides the model dropdown so
|
||||||
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||||
_VISION_PROVIDER_MODELS = {
|
_VISION_PROVIDER_MODELS = {
|
||||||
# OpenAI ordering matches the recommended GPT-5.4 family first, then
|
# OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
|
||||||
# GPT-5 and the GPT-4.1/4o backstops.
|
# GPT-5 and the GPT-4.1/4o backstops.
|
||||||
"openai": [
|
"openai": [
|
||||||
|
const.GPT_56_LUNA,
|
||||||
|
const.GPT_56_TERRA,
|
||||||
|
const.GPT_56_SOL,
|
||||||
const.GPT_55,
|
const.GPT_55,
|
||||||
const.GPT_54,
|
const.GPT_54,
|
||||||
const.GPT_54_MINI,
|
const.GPT_54_MINI,
|
||||||
@@ -2354,7 +2419,7 @@ class ModelsHandler:
|
|||||||
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
||||||
"moonshot": [const.KIMI_K2_6],
|
"moonshot": [const.KIMI_K2_6],
|
||||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||||
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||||
@@ -2378,6 +2443,7 @@ class ModelsHandler:
|
|||||||
const.DOUBAO_SEED_2_1_PRO,
|
const.DOUBAO_SEED_2_1_PRO,
|
||||||
const.KIMI_K2_6,
|
const.KIMI_K2_6,
|
||||||
const.CLAUDE_SONNET_5,
|
const.CLAUDE_SONNET_5,
|
||||||
|
const.CLAUDE_FABLE_5,
|
||||||
const.GEMINI_31_FLASH_LITE_PRE,
|
const.GEMINI_31_FLASH_LITE_PRE,
|
||||||
],
|
],
|
||||||
# Custom OpenAI-compatible providers have no preset list — model
|
# Custom OpenAI-compatible providers have no preset list — model
|
||||||
@@ -4534,6 +4600,34 @@ class SchedulerHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
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:
|
class SchedulerToggleHandler:
|
||||||
def POST(self):
|
def POST(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
@@ -4607,11 +4701,33 @@ class SchedulerUpdateHandler:
|
|||||||
|
|
||||||
# Update action
|
# Update action
|
||||||
if "action" in body:
|
if "action" in body:
|
||||||
action = body["action"]
|
|
||||||
channel_type = action.get("channel_type", "web")
|
|
||||||
|
|
||||||
# Get the task's original channel_type
|
# Get the task's original channel_type
|
||||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
original_action = original_task.get("action", {})
|
||||||
|
if not isinstance(original_action, dict):
|
||||||
|
original_action = {}
|
||||||
|
action_patch = body["action"]
|
||||||
|
if not isinstance(action_patch, dict):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Action must be an object."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# The Web editor only exposes a subset of action fields. Merge
|
||||||
|
# that patch into the stored action so scheduler metadata such
|
||||||
|
# as notify_session_id, silent, and channel-specific delivery
|
||||||
|
# fields survive unrelated edits.
|
||||||
|
action = dict(original_action)
|
||||||
|
action.update(action_patch)
|
||||||
|
action_type = action.get("type")
|
||||||
|
if action_type == "send_message":
|
||||||
|
action.pop("task_description", None)
|
||||||
|
action.pop("silent", None)
|
||||||
|
elif action_type == "agent_task":
|
||||||
|
action.pop("content", None)
|
||||||
|
|
||||||
|
old_channel = original_action.get("channel_type", "web")
|
||||||
|
channel_type = action.get("channel_type") or old_channel
|
||||||
|
action["channel_type"] = channel_type
|
||||||
|
|
||||||
# If channel type changed or no receiver, reject the update.
|
# If channel type changed or no receiver, reject the update.
|
||||||
# Note: the web UI disables the channel selector, so this branch
|
# Note: the web UI disables the channel selector, so this branch
|
||||||
|
|||||||
@@ -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.context import context
|
||||||
from cli.commands.install import install_browser
|
from cli.commands.install import install_browser
|
||||||
from cli.commands.knowledge import knowledge
|
from cli.commands.knowledge import knowledge
|
||||||
|
from cli.commands.backup import backup_command, restore_command
|
||||||
|
|
||||||
|
|
||||||
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
||||||
@@ -24,6 +25,8 @@ Commands:
|
|||||||
logs View CowAgent logs.
|
logs View CowAgent logs.
|
||||||
skill Manage CowAgent skills.
|
skill Manage CowAgent skills.
|
||||||
knowledge Manage knowledge base.
|
knowledge Manage knowledge base.
|
||||||
|
backup Back up config and agent workspace.
|
||||||
|
restore Restore a CowAgent backup.
|
||||||
install-browser Install browser tool (Playwright + Chromium).
|
install-browser Install browser tool (Playwright + Chromium).
|
||||||
|
|
||||||
Tip: Memory index management lives in chat — send /memory status or
|
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(logs)
|
||||||
main.add_command(context)
|
main.add_command(context)
|
||||||
main.add_command(knowledge)
|
main.add_command(knowledge)
|
||||||
|
main.add_command(backup_command)
|
||||||
|
main.add_command(restore_command)
|
||||||
main.add_command(install_browser)
|
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
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def _is_frozen() -> bool:
|
||||||
|
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||||
|
|
||||||
|
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
|
||||||
|
playwright is already bundled and we only need to download the browser
|
||||||
|
binary in-process rather than pip-installing anything.
|
||||||
|
"""
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
|
||||||
|
"""Invoke the Playwright CLI, working in both source and frozen builds.
|
||||||
|
|
||||||
|
Source builds shell out to ``python -m playwright <args>``. Frozen builds
|
||||||
|
can't use ``-m`` (the exe isn't a Python interpreter), so we call
|
||||||
|
Playwright's driver entrypoint in-process instead. ``env`` overrides are
|
||||||
|
applied to os.environ for the duration of the call (frozen path) or passed
|
||||||
|
through to the subprocess (source path).
|
||||||
|
"""
|
||||||
|
if not _is_frozen():
|
||||||
|
cmd = [sys.executable, "-m", "playwright"] + args
|
||||||
|
return subprocess.call(cmd, env=env)
|
||||||
|
|
||||||
|
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
|
||||||
|
# returns the Node driver shipped inside the bundle; we spawn it directly.
|
||||||
|
prev_env = {}
|
||||||
|
if env:
|
||||||
|
for k, v in env.items():
|
||||||
|
prev_env[k] = os.environ.get(k)
|
||||||
|
os.environ[k] = v
|
||||||
|
try:
|
||||||
|
from playwright._impl._driver import compute_driver_executable, get_driver_env
|
||||||
|
driver = compute_driver_executable()
|
||||||
|
# compute_driver_executable may return a tuple (node, cli.js) on newer
|
||||||
|
# Playwright, or a single path on older ones.
|
||||||
|
if isinstance(driver, (list, tuple)):
|
||||||
|
cmd = list(driver) + args
|
||||||
|
else:
|
||||||
|
cmd = [str(driver)] + args
|
||||||
|
# get_driver_env() snapshots os.environ, which we've already patched with
|
||||||
|
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
|
||||||
|
# so mirror + pinned browsers dir are honored here too.
|
||||||
|
return subprocess.call(cmd, env=get_driver_env())
|
||||||
|
except Exception as e:
|
||||||
|
# Last resort: try the module main via runpy (works if the frozen build
|
||||||
|
# kept playwright.__main__ importable).
|
||||||
|
try:
|
||||||
|
import runpy
|
||||||
|
sys.argv = ["playwright"] + args
|
||||||
|
runpy.run_module("playwright", run_name="__main__")
|
||||||
|
return 0
|
||||||
|
except SystemExit as se:
|
||||||
|
return int(se.code or 0)
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
for k, v in prev_env.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
||||||
"""CLI: colored click output."""
|
"""CLI: colored click output."""
|
||||||
if fg == "yellow":
|
if fg == "yellow":
|
||||||
@@ -129,6 +192,7 @@ def run_install_browser(
|
|||||||
stream = stream or _default_stream
|
stream = stream or _default_stream
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
legacy_mode = False
|
legacy_mode = False
|
||||||
|
frozen = _is_frozen()
|
||||||
|
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
||||||
@@ -159,7 +223,7 @@ def run_install_browser(
|
|||||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32" and not frozen:
|
||||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||||
ret = subprocess.call(
|
ret = subprocess.call(
|
||||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||||
@@ -172,22 +236,52 @@ def run_install_browser(
|
|||||||
"yellow",
|
"yellow",
|
||||||
)
|
)
|
||||||
|
|
||||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
if frozen:
|
||||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
# Desktop bundle: playwright is already shipped inside the app; there is
|
||||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||||||
if ret != 0:
|
installed = _get_installed_version()
|
||||||
stream("Failed to install playwright package.", "red")
|
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
_phase(on_phase, _t(
|
||||||
return 1
|
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
|
||||||
|
"✅ [1/3] Playwright is bundled in the app; skipping install.",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||||
|
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||||
|
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||||
|
if ret != 0:
|
||||||
|
stream("Failed to install playwright package.", "red")
|
||||||
|
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||||
|
return 1
|
||||||
|
|
||||||
installed = _get_installed_version()
|
installed = _get_installed_version()
|
||||||
if installed:
|
if installed:
|
||||||
stream(f" playwright {installed} installed.", "green")
|
stream(f" playwright {installed} installed.", "green")
|
||||||
stream("")
|
stream("")
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# With playwright available, prefer the user's system Chrome/Edge: the browser
|
||||||
|
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
|
||||||
|
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
|
||||||
|
# source) — only headless Linux servers, which usually lack a system browser,
|
||||||
|
# fall through to the download below. Honors prefer_system_browser via
|
||||||
|
# resolve_engine, so users who force downloaded Chromium still get it.
|
||||||
|
try:
|
||||||
|
from agent.tools.browser import browser_env
|
||||||
|
summary = browser_env.capability_summary()
|
||||||
|
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
|
||||||
|
sc = summary.get("system_chrome") or {}
|
||||||
|
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
|
||||||
|
_phase(on_phase, _t(
|
||||||
|
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。",
|
||||||
|
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
|
||||||
|
))
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
stream(f" (system browser probe skipped: {e})", None)
|
||||||
|
|
||||||
if sys.platform == "linux":
|
if sys.platform == "linux":
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
@@ -195,7 +289,7 @@ def run_install_browser(
|
|||||||
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
||||||
))
|
))
|
||||||
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
||||||
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
ret = _playwright_cli(["install-deps", "chromium"])
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(
|
stream(
|
||||||
" Could not auto-install system deps (may need sudo).\n"
|
" Could not auto-install system deps (may need sudo).\n"
|
||||||
@@ -238,12 +332,12 @@ def run_install_browser(
|
|||||||
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
||||||
))
|
))
|
||||||
stream("[3/3] Installing Chromium browser...", "yellow")
|
stream("[3/3] Installing Chromium browser...", "yellow")
|
||||||
cmd = [python, "-m", "playwright", "install", "chromium"]
|
pw_args = ["install", "chromium"]
|
||||||
|
|
||||||
if _is_headless_linux() and not legacy_mode:
|
if _is_headless_linux() and not legacy_mode:
|
||||||
ver = _version_tuple(installed or "")
|
ver = _version_tuple(installed or "")
|
||||||
if ver >= (1, 57, 0):
|
if ver >= (1, 57, 0):
|
||||||
cmd.append("--only-shell")
|
pw_args.append("--only-shell")
|
||||||
stream(" (headless shell for Linux server)", None)
|
stream(" (headless shell for Linux server)", None)
|
||||||
else:
|
else:
|
||||||
stream(" (full Chromium)", None)
|
stream(" (full Chromium)", None)
|
||||||
@@ -251,6 +345,15 @@ def run_install_browser(
|
|||||||
stream(" (full browser for Linux desktop)", None)
|
stream(" (full browser for Linux desktop)", None)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
# Pin the download location so it survives desktop app updates and matches
|
||||||
|
# what the runtime looks up (see browser_env.browsers_download_dir()).
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import browsers_download_dir
|
||||||
|
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||||
|
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
use_mirror = _is_china_network()
|
use_mirror = _is_china_network()
|
||||||
if use_mirror:
|
if use_mirror:
|
||||||
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
||||||
@@ -260,7 +363,7 @@ def run_install_browser(
|
|||||||
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
||||||
))
|
))
|
||||||
|
|
||||||
ret = subprocess.call(cmd, env=env)
|
ret = _playwright_cli(pw_args, env=env)
|
||||||
|
|
||||||
if ret != 0 and use_mirror:
|
if ret != 0 and use_mirror:
|
||||||
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
||||||
@@ -268,9 +371,9 @@ def run_install_browser(
|
|||||||
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
||||||
"⚠️ Mirror download failed; retrying with the official CDN…",
|
"⚠️ Mirror download failed; retrying with the official CDN…",
|
||||||
))
|
))
|
||||||
env_no_mirror = os.environ.copy()
|
env_no_mirror = dict(env)
|
||||||
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
||||||
ret = subprocess.call(cmd, env=env_no_mirror)
|
ret = _playwright_cli(pw_args, env=env_no_mirror)
|
||||||
|
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream("Failed to install Chromium.", "red")
|
stream("Failed to install Chromium.", "red")
|
||||||
@@ -282,10 +385,18 @@ def run_install_browser(
|
|||||||
|
|
||||||
stream("Verifying browser installation...", None)
|
stream("Verifying browser installation...", None)
|
||||||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||||||
ret = subprocess.call(
|
if frozen:
|
||||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
# Frozen: no child interpreter to spawn; import in-process instead.
|
||||||
stderr=subprocess.DEVNULL,
|
try:
|
||||||
)
|
from playwright.sync_api import sync_playwright # noqa: F401
|
||||||
|
ret = 0
|
||||||
|
except Exception:
|
||||||
|
ret = 1
|
||||||
|
else:
|
||||||
|
ret = subprocess.call(
|
||||||
|
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(
|
stream(
|
||||||
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
|
|||||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
|
||||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||||
@@ -80,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
|||||||
GPT_54_MINI = "gpt-5.4-mini"
|
GPT_54_MINI = "gpt-5.4-mini"
|
||||||
GPT_54_NANO = "gpt-5.4-nano"
|
GPT_54_NANO = "gpt-5.4-nano"
|
||||||
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
||||||
|
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
|
||||||
|
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
|
||||||
|
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
|
||||||
O1 = "o1-preview"
|
O1 = "o1-preview"
|
||||||
O1_MINI = "o1-mini"
|
O1_MINI = "o1-mini"
|
||||||
WHISPER_1 = "whisper-1"
|
WHISPER_1 = "whisper-1"
|
||||||
@@ -139,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
|
||||||
|
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
|
||||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||||
KIMI_K2 = "kimi-k2"
|
KIMI_K2 = "kimi-k2"
|
||||||
KIMI_K2_5 = "kimi-k2.5"
|
KIMI_K2_5 = "kimi-k2.5"
|
||||||
@@ -200,7 +204,7 @@ MODEL_LIST = [
|
|||||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE_SONNET_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
CLAUDE_SONNET_5, CLAUDE_FABLE_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -214,6 +218,7 @@ MODEL_LIST = [
|
|||||||
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
||||||
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
||||||
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
||||||
|
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
|
||||||
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
||||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||||
O1, O1_MINI,
|
O1, O1_MINI,
|
||||||
@@ -231,7 +236,7 @@ MODEL_LIST = [
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||||
|
|
||||||
# ModelScope
|
# ModelScope
|
||||||
MODELSCOPE,
|
MODELSCOPE,
|
||||||
|
|||||||
@@ -42,7 +42,5 @@
|
|||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
"knowledge": true,
|
"knowledge": true,
|
||||||
"self_evolution_enabled": true,
|
"self_evolution_enabled": true,
|
||||||
"mcp_tool_retrieval_enabled": false,
|
"mcp_tool_retrieval_enabled": false
|
||||||
"mcp_tool_retrieval_threshold": 20,
|
|
||||||
"mcp_tool_retrieval_top_k": 10
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ available_setting = {
|
|||||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||||
|
"feishu_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 config
|
||||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ hiddenimports += collect_submodules('docx')
|
|||||||
hiddenimports += collect_submodules('pptx')
|
hiddenimports += collect_submodules('pptx')
|
||||||
hiddenimports += collect_submodules('openpyxl')
|
hiddenimports += collect_submodules('openpyxl')
|
||||||
|
|
||||||
|
# Playwright powers the browser tool. Only the pure-Python package + its bundled
|
||||||
|
# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled
|
||||||
|
# and is either satisfied by the user's system Chrome/Edge (preferred, zero
|
||||||
|
# download) or downloaded on demand into ~/.cow/ms-playwright at first use.
|
||||||
|
# Playwright imports its transport/driver lazily, so list submodules explicitly.
|
||||||
|
hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl']
|
||||||
|
hiddenimports += collect_submodules('playwright')
|
||||||
|
|
||||||
# --- Data files -----------------------------------------------------------
|
# --- Data files -----------------------------------------------------------
|
||||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||||
# (source, dest_dir_in_bundle).
|
# (source, dest_dir_in_bundle).
|
||||||
@@ -134,6 +142,12 @@ datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
|||||||
datas += collect_data_files('docx')
|
datas += collect_data_files('docx')
|
||||||
datas += collect_data_files('pptx')
|
datas += collect_data_files('pptx')
|
||||||
|
|
||||||
|
# Playwright ships its Node.js driver + package.json under playwright/driver/.
|
||||||
|
# These are NOT Python modules, so hiddenimports won't pull them in — collect
|
||||||
|
# them as data or `playwright install` / launching fails in the frozen build.
|
||||||
|
# include_py_files=True is required: the driver dir contains .py entrypoints.
|
||||||
|
datas += collect_data_files('playwright', include_py_files=True)
|
||||||
|
|
||||||
# --- Excludes -------------------------------------------------------------
|
# --- Excludes -------------------------------------------------------------
|
||||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||||
# mode), tests/docs, and dev-only packages.
|
# mode), tests/docs, and dev-only packages.
|
||||||
@@ -143,7 +157,10 @@ excludes = [
|
|||||||
'pip',
|
'pip',
|
||||||
'wheel',
|
'wheel',
|
||||||
'pytest',
|
'pytest',
|
||||||
'playwright', # browser tool is opt-in, not bundled
|
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
|
||||||
|
# so the browser tool works out of the box on desktop. The heavy Chromium
|
||||||
|
# binary is still NOT bundled: it comes from the user's system Chrome/Edge or
|
||||||
|
# is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py.
|
||||||
]
|
]
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ python-docx
|
|||||||
openpyxl
|
openpyxl
|
||||||
python-pptx
|
python-pptx
|
||||||
|
|
||||||
|
# ---- browser tool ----
|
||||||
|
# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB).
|
||||||
|
# The Chromium binary is NOT bundled: the browser tool drives the user's system
|
||||||
|
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
||||||
|
playwright==1.52.0
|
||||||
|
|
||||||
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
||||||
wechatpy
|
wechatpy
|
||||||
pycryptodome
|
pycryptodome
|
||||||
|
|||||||
122
desktop/electron-builder.win.js
Normal file
122
desktop/electron-builder.win.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic electron-builder config for WINDOWS code signing.
|
||||||
|
*
|
||||||
|
* Mirrors electron-builder.js (which handles mac.binaries) but for Windows.
|
||||||
|
* It wires a signing CLI into electron-builder so that every .exe is signed,
|
||||||
|
* with the private key kept in hardware per the post-2023 code-signing rules.
|
||||||
|
*
|
||||||
|
* A SINGLE sign hook (win.signtoolOptions.sign) covers everything: electron-
|
||||||
|
* builder calls it for EVERY .exe it processes, which includes the app
|
||||||
|
* launcher, the packaged PyInstaller backend (extraResources/backend/
|
||||||
|
* cowagent-backend.exe) and the NSIS installer. We deliberately do NOT add an
|
||||||
|
* afterPack pass — that would sign the backend a second time and waste a paid
|
||||||
|
* signing call on every release.
|
||||||
|
*
|
||||||
|
* PRIVACY: the CLI path and all credentials come from env vars only. Nothing in
|
||||||
|
* this file (or the public workflow) is hardcoded, so a public repo never leaks
|
||||||
|
* any signing configuration.
|
||||||
|
*
|
||||||
|
* DRY-RUN / SKIP: when SIGNTOOL_CERT_CODE is absent we skip signing entirely
|
||||||
|
* (unsigned dev/dry builds keep working). When COW_SIGN_DRY_RUN=1 we pass
|
||||||
|
* --dry-run so the WHOLE pipeline can be validated in CI with a self-signed
|
||||||
|
* cert, WITHOUT a real certificate and WITHOUT consuming any signing quota.
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const config = require('./package.json').build
|
||||||
|
|
||||||
|
// Absolute path to the signing CLI on the runner. Injected by CI so this file
|
||||||
|
// never hardcodes a download URL. e.g. C:\signtool\signtool.exe
|
||||||
|
const SIGNTOOL = process.env.SIGNTOOL_PATH || ''
|
||||||
|
// Dry-run validates the pipeline with a self-signed cert (no quota, no real
|
||||||
|
// cert needed). Any truthy value enables it.
|
||||||
|
const DRY_RUN = !!process.env.COW_SIGN_DRY_RUN
|
||||||
|
|
||||||
|
// In dry-run the CLI still requires these flags to be NON-EMPTY (it validates
|
||||||
|
// presence, not the value, and signs with a self-signed cert). So when no real
|
||||||
|
// credentials are provided during a dry-run, fall back to harmless placeholders
|
||||||
|
// to satisfy the CLI's arg check. Real runs pass the actual secrets through.
|
||||||
|
const PLACEHOLDER = DRY_RUN ? 'dry-run' : ''
|
||||||
|
const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || PLACEHOLDER
|
||||||
|
const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || PLACEHOLDER
|
||||||
|
const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || PLACEHOLDER
|
||||||
|
|
||||||
|
// RFC3161 timestamp server for SHA256. Microsoft's is reliable from CI runners
|
||||||
|
// worldwide; overridable via env if needed.
|
||||||
|
const TIMESTAMP = process.env.SIGNTOOL_TIMESTAMP || 'http://timestamp.acs.microsoft.com'
|
||||||
|
|
||||||
|
// Signing is possible when we have the CLI plus either a real cert code or
|
||||||
|
// explicit dry-run mode (dry-run accepts placeholder credentials).
|
||||||
|
function canSign() {
|
||||||
|
if (!SIGNTOOL || !fs.existsSync(SIGNTOOL)) return false
|
||||||
|
if (DRY_RUN) return true
|
||||||
|
return !!(ACCESS_KEY && ACCESS_SECRET && CERT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a single file in place using the signing CLI. The CLI writes to a
|
||||||
|
* separate --out path (it refuses to overwrite an existing file), so we sign to
|
||||||
|
* a temp file and atomically move it back over the original.
|
||||||
|
*/
|
||||||
|
function signFile(filePath) {
|
||||||
|
const tmpOut = `${filePath}.signed`
|
||||||
|
// Remove a stale temp from a previous failed run (CLI errors if --out exists).
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tmpOut)) fs.rmSync(tmpOut)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'sign',
|
||||||
|
...(DRY_RUN ? ['--dry-run'] : []),
|
||||||
|
`--access-key=${ACCESS_KEY}`,
|
||||||
|
`--access-secret=${ACCESS_SECRET}`,
|
||||||
|
`--cert-code=${CERT_CODE}`,
|
||||||
|
`--file=${filePath}`,
|
||||||
|
`--out=${tmpOut}`,
|
||||||
|
'--sha1=false',
|
||||||
|
'--sha2=true',
|
||||||
|
'--timestamp-rfc3161',
|
||||||
|
TIMESTAMP,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Never print credentials: log only the file being signed.
|
||||||
|
console.log(`[win-sign] signing ${path.basename(filePath)}${DRY_RUN ? ' (dry-run)' : ''}`)
|
||||||
|
execFileSync(SIGNTOOL, args, { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||||
|
|
||||||
|
if (!fs.existsSync(tmpOut)) {
|
||||||
|
throw new Error(`[win-sign] signed output not produced for ${filePath}`)
|
||||||
|
}
|
||||||
|
// Replace the original with the signed copy.
|
||||||
|
fs.rmSync(filePath)
|
||||||
|
fs.renameSync(tmpOut, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// electron-builder calls this for each artifact it generates (app exe, NSIS
|
||||||
|
// installer, uninstaller). Signature: (configuration) => void, where
|
||||||
|
// configuration.path is the file to sign.
|
||||||
|
async function customSign(configuration) {
|
||||||
|
if (!canSign()) {
|
||||||
|
console.warn('[win-sign] signing skipped (no signtool/credentials)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signFile(configuration.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend the base config: attach the sign hook. Only meaningful on Windows
|
||||||
|
// builds (this config is only passed via --config on the win matrix leg).
|
||||||
|
//
|
||||||
|
// electron-builder invokes customSign for EVERY .exe it touches — that already
|
||||||
|
// includes the packaged backend (extraResources/backend/cowagent-backend.exe)
|
||||||
|
// and the NSIS installer, not just the app launcher. So there's no separate
|
||||||
|
// afterPack pass: adding one would sign the backend twice (wasting a paid
|
||||||
|
// signing call per release). Nested PyInstaller .dll/.pyd files are left
|
||||||
|
// unsigned, which Windows Authenticode tolerates (unlike macOS, it doesn't
|
||||||
|
// require deep-signing every nested lib — a signed top-level exe is enough for
|
||||||
|
// SmartScreen/Defender to attribute the publisher).
|
||||||
|
config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } }
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
let getWindow: () => BrowserWindow | null = () => null
|
||||||
|
|
||||||
|
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||||
|
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||||
|
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||||
|
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||||
|
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||||
|
// flag, so the same source serves the right feed on whatever it runs on.
|
||||||
|
function isLegacyWindows(): boolean {
|
||||||
|
if (process.platform !== 'win32') return false
|
||||||
|
const major = Number((os.release() || '').split('.')[0])
|
||||||
|
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||||
|
return Number.isFinite(major) && major < 10
|
||||||
|
}
|
||||||
|
|
||||||
// The update feed. Both entries hit the same Pages Function
|
// The update feed. Both entries hit the same Pages Function
|
||||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||||
// identical either way, so we can freely switch the feed URL between attempts
|
// identical either way, so we can freely switch the feed URL between attempts
|
||||||
// to fall back from one download origin to the other.
|
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||||
|
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||||
|
|
||||||
// Which origin the current session prefers, derived from the app UI language
|
// Which origin the current session prefers, derived from the app UI language
|
||||||
|
|||||||
@@ -372,6 +372,13 @@ class ApiClient {
|
|||||||
return data.tasks
|
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 }> {
|
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
||||||
return this.request('/api/scheduler/toggle', {
|
return this.request('/api/scheduler/toggle', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||||
|
{ cmd: '/install-browser', desc: t('slash_install_browser') },
|
||||||
{ cmd: '/config', desc: t('slash_config') },
|
{ cmd: '/config', desc: t('slash_config') },
|
||||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||||
{ cmd: '/logs', desc: t('slash_logs') },
|
{ cmd: '/logs', desc: t('slash_logs') },
|
||||||
|
|||||||
@@ -30,6 +30,34 @@ const md: MarkdownIt = new MarkdownIt({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`)
|
||||||
|
// and CJK full-width punctuation glued to a URL (common in LLM output like
|
||||||
|
// `**https://x**,中文`), turning the whole tail into one broken link. Cut the
|
||||||
|
// URL at the first such char and spill the remainder back as plain text.
|
||||||
|
const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/
|
||||||
|
md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => {
|
||||||
|
for (const blk of state.tokens) {
|
||||||
|
if (blk.type !== 'inline' || !blk.children) continue
|
||||||
|
const ch = blk.children
|
||||||
|
for (let i = 0; i < ch.length; i++) {
|
||||||
|
const open = ch[i]
|
||||||
|
if (open.type !== 'link_open' || open.markup !== 'linkify') continue
|
||||||
|
const textTok = ch[i + 1]
|
||||||
|
const close = ch[i + 2]
|
||||||
|
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue
|
||||||
|
const idx = textTok.content.search(_GREEDY_LINK_CUT)
|
||||||
|
if (idx < 0) continue
|
||||||
|
const keep = textTok.content.slice(0, idx)
|
||||||
|
const spill = textTok.content.slice(idx)
|
||||||
|
textTok.content = keep
|
||||||
|
open.attrSet('href', keep)
|
||||||
|
const spillTok = new state.Token('text', '', 0)
|
||||||
|
spillTok.content = spill
|
||||||
|
ch.splice(i + 3, 0, spillTok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Open links in a new tab safely.
|
// Open links in a new tab safely.
|
||||||
const defaultLinkOpen =
|
const defaultLinkOpen =
|
||||||
md.renderer.rules.link_open ||
|
md.renderer.rules.link_open ||
|
||||||
@@ -72,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
|||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string
|
content: string
|
||||||
|
/**
|
||||||
|
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||||
|
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||||
|
* the knowledge viewer so index links open the target doc rather than firing
|
||||||
|
* an "application cannot be opened (-120)" error in Electron.
|
||||||
|
*/
|
||||||
|
onInternalLink?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const html = useMemo(() => md.render(content || ''), [content])
|
const html = useMemo(() => md.render(content || ''), [content])
|
||||||
|
|
||||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = useCallback(
|
||||||
const target = e.target as HTMLElement
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
const target = e.target as HTMLElement
|
||||||
if (!btn) return
|
|
||||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||||
if (!pre) return
|
if (onInternalLink) {
|
||||||
navigator.clipboard.writeText(pre.textContent || '')
|
const a = target.closest('a') as HTMLAnchorElement | null
|
||||||
const original = btn.textContent
|
if (a) {
|
||||||
btn.textContent = t('msg_copied')
|
const href = a.getAttribute('href') || ''
|
||||||
btn.classList.add('copied')
|
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||||
setTimeout(() => {
|
e.preventDefault()
|
||||||
btn.textContent = original
|
onInternalLink(href)
|
||||||
btn.classList.remove('copied')
|
return
|
||||||
}, 1600)
|
}
|
||||||
}, [])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard.writeText(pre.textContent || '')
|
||||||
|
const original = btn.textContent
|
||||||
|
btn.textContent = t('msg_copied')
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 1600)
|
||||||
|
},
|
||||||
|
[onInternalLink]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
muted, separated from the final answer by a dashed divider. */}
|
muted, separated from the final answer by a dashed divider. */}
|
||||||
{(hasSteps || hasLiveReasoning) && (
|
{(hasSteps || hasLiveReasoning) && (
|
||||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
|
||||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||||
|
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||||
|
must render after all committed steps (tools/thinking), not at
|
||||||
|
the very top of the bubble. */}
|
||||||
|
{hasLiveReasoning && (
|
||||||
|
<div className={hasSteps ? 'mt-1' : ''}>
|
||||||
|
<ThinkingStep content={message.reasoning!} streaming />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||||
import type { MessageStep } from '../types'
|
import type { MessageStep } from '../types'
|
||||||
|
import { t } from '../i18n'
|
||||||
import Markdown from './Markdown'
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
|||||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<Brain size={12} className="flex-shrink-0" />
|
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: '已中止',
|
msg_cancelled: '已中止',
|
||||||
msg_self_learned: '自主学习',
|
msg_self_learned: '自主学习',
|
||||||
msg_stop: '停止',
|
msg_stop: '停止',
|
||||||
|
thinking_in_progress: '思考中…',
|
||||||
|
thinking_done: '已深度思考',
|
||||||
chat_clear_context: '清除上下文',
|
chat_clear_context: '清除上下文',
|
||||||
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
chat_load_earlier: '加载更早的消息',
|
chat_load_earlier: '加载更早的消息',
|
||||||
chat_send: '发送',
|
chat_send: '发送',
|
||||||
chat_attach: '添加附件',
|
chat_attach: '添加附件',
|
||||||
slash_hint: '输入 / 查看命令',
|
slash_hint: '输入 / 查看命令',
|
||||||
chat_welcome: '有什么可以帮你的?',
|
chat_welcome: '有什么可以帮你的?',
|
||||||
chat_empty_hint: '发送一条消息开始对话',
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务',
|
example_task_title: '定时任务',
|
||||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: '已连接',
|
channels_connected_section: '已连接',
|
||||||
channels_available_section: '可添加',
|
channels_available_section: '可添加',
|
||||||
channels_empty_connected: '暂无已连接的通道',
|
channels_empty_connected: '暂无已连接的通道',
|
||||||
|
channels_empty: '暂未接入任何通道',
|
||||||
|
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||||
channels_save_ok: '已保存',
|
channels_save_ok: '已保存',
|
||||||
channels_save_error: '保存失败',
|
channels_save_error: '保存失败',
|
||||||
@@ -347,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
task_delete: '删除',
|
task_delete: '删除',
|
||||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||||
task_save_error: '保存失败',
|
task_save_error: '保存失败',
|
||||||
|
task_run_now: '立即执行',
|
||||||
|
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||||
|
task_run_started: '任务已开始执行',
|
||||||
|
task_run_error: '执行失败',
|
||||||
logs_title: '日志',
|
logs_title: '日志',
|
||||||
logs_desc: '实时日志输出 (run.log)',
|
logs_desc: '实时日志输出 (run.log)',
|
||||||
logs_live: '实时',
|
logs_live: '实时',
|
||||||
@@ -376,6 +385,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||||
slash_knowledge: '查看知识库统计',
|
slash_knowledge: '查看知识库统计',
|
||||||
slash_knowledge_list: '查看知识库文件树',
|
slash_knowledge_list: '查看知识库文件树',
|
||||||
|
slash_install_browser: '安装浏览器工具',
|
||||||
slash_config: '查看当前配置',
|
slash_config: '查看当前配置',
|
||||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||||
slash_logs: '查看最近日志',
|
slash_logs: '查看最近日志',
|
||||||
@@ -511,7 +521,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: 'Cancelled',
|
msg_cancelled: 'Cancelled',
|
||||||
msg_self_learned: 'Self-learned',
|
msg_self_learned: 'Self-learned',
|
||||||
msg_stop: 'Stop',
|
msg_stop: 'Stop',
|
||||||
|
thinking_in_progress: 'Thinking…',
|
||||||
|
thinking_done: 'Thought',
|
||||||
chat_clear_context: 'Clear context',
|
chat_clear_context: 'Clear context',
|
||||||
|
context_cleared: '— Context above has been cleared —',
|
||||||
chat_load_earlier: 'Load earlier messages',
|
chat_load_earlier: 'Load earlier messages',
|
||||||
chat_send: 'Send',
|
chat_send: 'Send',
|
||||||
chat_attach: 'Attach file',
|
chat_attach: 'Attach file',
|
||||||
@@ -670,6 +683,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: 'Connected',
|
channels_connected_section: 'Connected',
|
||||||
channels_available_section: 'Available',
|
channels_available_section: 'Available',
|
||||||
channels_empty_connected: 'No connected channels yet',
|
channels_empty_connected: 'No connected channels yet',
|
||||||
|
channels_empty: 'No channels connected',
|
||||||
|
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||||
channels_save_ok: 'Saved',
|
channels_save_ok: 'Saved',
|
||||||
channels_save_error: 'Failed to save',
|
channels_save_error: 'Failed to save',
|
||||||
@@ -729,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
task_delete: 'Delete',
|
task_delete: 'Delete',
|
||||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||||
task_save_error: 'Failed to save',
|
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_title: 'Logs',
|
||||||
logs_desc: 'Real-time log output (run.log)',
|
logs_desc: 'Real-time log output (run.log)',
|
||||||
logs_live: 'Live',
|
logs_live: 'Live',
|
||||||
@@ -758,6 +777,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||||
slash_knowledge: 'Show knowledge base stats',
|
slash_knowledge: 'Show knowledge base stats',
|
||||||
slash_knowledge_list: 'Show knowledge base file tree',
|
slash_knowledge_list: 'Show knowledge base file tree',
|
||||||
|
slash_install_browser: 'Install browser tool',
|
||||||
slash_config: 'Show current config',
|
slash_config: 'Show current config',
|
||||||
slash_cancel: 'Abort the running agent task',
|
slash_cancel: 'Abort the running agent task',
|
||||||
slash_logs: 'Show recent logs',
|
slash_logs: 'Show recent logs',
|
||||||
|
|||||||
@@ -27,21 +27,21 @@
|
|||||||
--danger-border: rgba(239, 68, 68, 0.3);
|
--danger-border: rgba(239, 68, 68, 0.3);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Light theme — layered neutral surfaces */
|
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||||
--bg-base: #fafafa; /* app background */
|
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||||
--bg-surface: #ffffff; /* panels, cards */
|
--bg-surface: #ffffff; /* panels, cards */
|
||||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||||
|
|
||||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||||
--text-secondary: #52525b; /* body, labels */
|
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||||
--text-tertiary: #71717a; /* hints, captions */
|
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||||
--text-disabled: #a1a1aa;
|
--text-disabled: #94a3b8; /* slate-400 */
|
||||||
|
|
||||||
--border-default: #e4e4e7;
|
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||||
--border-strong: #d4d4d8;
|
--border-strong: #d1d5db; /* gray-300 */
|
||||||
--border-subtle: #f0f0f1;
|
--border-subtle: #f3f4f6; /* gray-100 */
|
||||||
|
|
||||||
--overlay: rgba(0, 0, 0, 0.4);
|
--overlay: rgba(0, 0, 0, 0.4);
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Headset,
|
Headset,
|
||||||
Hash,
|
Hash,
|
||||||
AtSign,
|
AtSign,
|
||||||
|
RadioTower,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { t, localizedLabel } from '../i18n'
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{connected.length === 0 && !addOpen ? (
|
{connected.length === 0 && !addOpen ? (
|
||||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||||
|
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||||
|
<RadioTower size={26} className="text-info" />
|
||||||
|
</span>
|
||||||
|
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||||
|
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||||
|
{t('channels_empty_desc')}
|
||||||
|
</p>
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Btn variant="primary" onClick={openAdd}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Plus size={15} />
|
||||||
|
{t('channels_add')}
|
||||||
|
</span>
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
|||||||
import type { Attachment, ChatMessage } from '../types'
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
import { useChatStore } from '../store/chatStore'
|
import { useChatStore } from '../store/chatStore'
|
||||||
import { useSessionStore } from '../store/sessionStore'
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
import { useUIStore } from '../store/uiStore'
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
const clearContext = useChatStore((s) => s.clearContext)
|
||||||
|
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||||
|
|
||||||
const messages = session?.messages ?? []
|
const messages = session?.messages ?? []
|
||||||
const isStreaming = session?.isStreaming ?? false
|
const isStreaming = session?.isStreaming ?? false
|
||||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const id = newSession()
|
const id = newSession()
|
||||||
ensureSession(id)
|
ensureSession(id)
|
||||||
loadHistory(id, 1)
|
loadHistory(id, 1)
|
||||||
}, [newSession, ensureSession, loadHistory])
|
// Auto-expand the session list so the user sees the new/switched session.
|
||||||
|
setSessionsCollapsed(false)
|
||||||
|
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||||
|
|
||||||
const handleClearContext = useCallback(async () => {
|
const handleClearContext = useCallback(async () => {
|
||||||
try {
|
await clearContext(activeId)
|
||||||
await apiClient.clearContext(activeId)
|
scrollToBottom(true)
|
||||||
} catch {
|
}, [clearContext, activeId, scrollToBottom])
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [activeId])
|
|
||||||
|
|
||||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||||
|
{t('welcome_subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-3 max-w-3xl mx-auto">
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) =>
|
||||||
<MessageBubble
|
msg.kind === 'divider' ? (
|
||||||
key={msg.id}
|
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||||
message={msg}
|
<span
|
||||||
onRegenerate={handleRegenerate}
|
className="flex-1 h-px"
|
||||||
onEdit={handleEdit}
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
onDelete={handleDelete}
|
/>
|
||||||
onMediaLoad={handleMediaLoad}
|
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||||
/>
|
<span
|
||||||
))}
|
className="flex-1 h-px"
|
||||||
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onMediaLoad={handleMediaLoad}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||||
|
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||||
|
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||||
|
function stripDuplicateH1(content: string, title: string): string {
|
||||||
|
if (!content) return content
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||||
|
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||||
|
if (!m) return content
|
||||||
|
if (norm(m[1]) !== norm(title)) return content
|
||||||
|
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten the tree into category paths (for destination selectors).
|
// Flatten the tree into category paths (for destination selectors).
|
||||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||||
|
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||||
|
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||||
|
for (const f of list.root_files || []) {
|
||||||
|
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
for (const f of dir.files) {
|
||||||
|
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
for (const c of dir.children) {
|
||||||
|
const hit = walk(c, dirPath)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const d of list.tree || []) {
|
||||||
|
const hit = walk(d, '')
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||||
|
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||||
|
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||||
|
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||||
|
const clean = href.split('#')[0].split('?')[0]
|
||||||
|
if (!clean.endsWith('.md')) return null
|
||||||
|
if (clean.startsWith('knowledge/')) {
|
||||||
|
const path = clean.replace(/^knowledge\//, '')
|
||||||
|
return { path, title: findTitle(list, path) }
|
||||||
|
}
|
||||||
|
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||||
|
return { path: clean, title: findTitle(list, clean) }
|
||||||
|
}
|
||||||
|
// Relative/other path: fall back to matching by filename.
|
||||||
|
const filename = clean.split('/').pop() || clean
|
||||||
|
return findFileByName(list, filename)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a document's display title from its path, falling back to the stem.
|
// Resolve a document's display title from its path, falling back to the stem.
|
||||||
function findTitle(list: KnowledgeList, path: string): string {
|
function findTitle(list: KnowledgeList, path: string): string {
|
||||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
setContent('')
|
setContent('')
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.readKnowledge(path)
|
const res = await apiClient.readKnowledge(path)
|
||||||
setContent(res.content || '')
|
setContent(stripDuplicateH1(res.content || '', title))
|
||||||
} catch {
|
} catch {
|
||||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||||
|
// Falls back silently when the target can't be resolved in the current tree.
|
||||||
|
const openInternalLink = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
if (!data) return
|
||||||
|
const hit = resolveKnowledgeLink(data, href)
|
||||||
|
if (hit) void openDoc(hit.path, hit.title)
|
||||||
|
},
|
||||||
|
[data, openDoc]
|
||||||
|
)
|
||||||
|
|
||||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||||
// currently open doc (or open the first one on the initial load).
|
// currently open doc (or open the first one on the initial load).
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
<Loader2 size={16} className="animate-spin mr-2" />
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Markdown content={content} />
|
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
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 [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||||
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [runStatus, setRunStatus] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const buildSchedule = (): TaskSchedule => {
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
|
|||||||
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||||
{t('task_delete')}
|
{t('task_delete')}
|
||||||
</Btn>
|
</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}>
|
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
{t('task_cancel')}
|
{t('task_cancel')}
|
||||||
</Btn>
|
</Btn>
|
||||||
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
|
|||||||
)}
|
)}
|
||||||
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
<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>}
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ChatState {
|
|||||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||||
|
|
||||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||||
|
clearContext: (sid: string) => Promise<boolean>
|
||||||
clearLocal: (sid: string) => void
|
clearLocal: (sid: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
cancel: async (sid) => {
|
cancel: async (sid) => {
|
||||||
const s = get().sessions[sid]
|
const s = get().sessions[sid]
|
||||||
if (!s?.requestId) return
|
if (!s?.requestId) return
|
||||||
|
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||||
|
// cancelled, free the input, and tear down the local SSE stream so no
|
||||||
|
// further deltas render after the user hit stop. The backend still gets
|
||||||
|
// the cancel request to abort the running agent task.
|
||||||
|
patchMessages(sid, (msgs) => {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') {
|
||||||
|
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...msgs]
|
||||||
|
})
|
||||||
|
patchSession(sid, { isStreaming: false, requestId: null })
|
||||||
|
const es = streams[sid]
|
||||||
|
if (es) {
|
||||||
|
es.close()
|
||||||
|
delete streams[sid]
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearContext: async (sid) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.clearContext(sid)
|
||||||
|
if (res.status !== 'success') return false
|
||||||
|
// Append a visual divider so the user sees the context was cleared
|
||||||
|
// (mirrors the web console's context-divider).
|
||||||
|
patchMessages(sid, (msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
id: uid('divider'),
|
||||||
|
role: 'system',
|
||||||
|
kind: 'divider',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearLocal: (sid) => {
|
clearLocal: (sid) => {
|
||||||
const es = streams[sid]
|
const es = streams[sid]
|
||||||
if (es) {
|
if (es) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UIState {
|
|||||||
/** Session list panel collapsed (hidden) vs expanded. */
|
/** Session list panel collapsed (hidden) vs expanded. */
|
||||||
sessionsCollapsed: boolean
|
sessionsCollapsed: boolean
|
||||||
toggleSessions: () => void
|
toggleSessions: () => void
|
||||||
|
setSessionsCollapsed: (v: boolean) => void
|
||||||
|
|
||||||
/** Currently active session id (Chat page). */
|
/** Currently active session id (Chat page). */
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||||
return { sessionsCollapsed: next }
|
return { sessionsCollapsed: next }
|
||||||
}),
|
}),
|
||||||
|
setSessionsCollapsed: (v) => {
|
||||||
|
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||||
|
set({ sessionsCollapsed: v })
|
||||||
|
},
|
||||||
|
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
|||||||
// Chat / messages / streaming
|
// Chat / messages / streaming
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export type Role = 'user' | 'assistant'
|
export type Role = 'user' | 'assistant' | 'system'
|
||||||
|
|
||||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||||
export interface MessageStep {
|
export interface MessageStep {
|
||||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
|||||||
/** Sequence numbers from backend (for delete/regenerate). */
|
/** Sequence numbers from backend (for delete/regenerate). */
|
||||||
userSeq?: number
|
userSeq?: number
|
||||||
botSeq?: number
|
botSeq?: number
|
||||||
/** Self-evolution bubble flag. */
|
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||||
kind?: 'evolution'
|
kind?: 'evolution' | 'divider'
|
||||||
extras?: Record<string, unknown>
|
extras?: Record<string, unknown>
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
isCancelled?: boolean
|
isCancelled?: boolean
|
||||||
|
|||||||
@@ -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_id` | Feishu app App ID | - |
|
||||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
| `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>
|
</Tab>
|
||||||
</Tabs>
|
</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**.
|
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"/>
|
<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 |
|
| Text messages | ✅ send/receive |
|
||||||
| Image messages | ✅ send/receive |
|
| Image messages | ✅ send/receive |
|
||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
|
| Quoted replies | ✅ quoted text and rich-post context |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| 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>
|
<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.
|
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.
|
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.
|
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`) |
|
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
|
||||||
| `/config` | View current config |
|
| `/config` | View current config |
|
||||||
| `/cancel` | Cancel the running Agent task |
|
| `/cancel` | Cancel the running Agent task |
|
||||||
|
| `/steer` | Guide the running Agent task (`/steer <instruction>`) |
|
||||||
| `/logs` | View recent logs |
|
| `/logs` | View recent logs |
|
||||||
| `/version` | Show version |
|
| `/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
|
/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
|
## config
|
||||||
|
|
||||||
View or modify runtime configuration. Changes take effect immediately without restarting.
|
View or modify runtime configuration. Changes take effect immediately without restarting.
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
|||||||
memory Memory distillation (dream)
|
memory Memory distillation (dream)
|
||||||
knowledge View knowledge base stats and structure
|
knowledge View knowledge base stats and structure
|
||||||
|
|
||||||
|
Data portability:
|
||||||
|
backup Back up config and agent workspace
|
||||||
|
restore Restore a CowAgent backup
|
||||||
|
|
||||||
Others:
|
Others:
|
||||||
help Show this help message
|
help Show this help message
|
||||||
version Show version
|
version Show version
|
||||||
@@ -58,6 +62,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
|||||||
| `/help` | Show command help |
|
| `/help` | Show command help |
|
||||||
| `/status` | View service status and configuration |
|
| `/status` | View service status and configuration |
|
||||||
| `/cancel` | Abort the currently running agent task |
|
| `/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 |
|
| `/config` | View or modify runtime configuration |
|
||||||
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
|
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
|
||||||
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |
|
| `/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 | ✓ | ✗ |
|
| start / stop / restart | ✓ | ✗ |
|
||||||
| update | ✓ | ✗ |
|
| update | ✓ | ✗ |
|
||||||
| install-browser | ✓ | ✗ |
|
| install-browser | ✓ | ✗ |
|
||||||
|
| backup / restore | ✓ | ✗ |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
|
`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/process",
|
||||||
"cli/skill",
|
"cli/skill",
|
||||||
"cli/memory-knowledge",
|
"cli/memory-knowledge",
|
||||||
|
"cli/backup",
|
||||||
"cli/general"
|
"cli/general"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -258,6 +259,7 @@
|
|||||||
"group": "Release Notes",
|
"group": "Release Notes",
|
||||||
"pages": [
|
"pages": [
|
||||||
"releases/overview",
|
"releases/overview",
|
||||||
|
"releases/v2.1.4",
|
||||||
"releases/v2.1.3",
|
"releases/v2.1.3",
|
||||||
"releases/v2.1.2",
|
"releases/v2.1.2",
|
||||||
"releases/v2.1.1",
|
"releases/v2.1.1",
|
||||||
@@ -473,6 +475,7 @@
|
|||||||
"zh/cli/process",
|
"zh/cli/process",
|
||||||
"zh/cli/skill",
|
"zh/cli/skill",
|
||||||
"zh/cli/memory-knowledge",
|
"zh/cli/memory-knowledge",
|
||||||
|
"zh/cli/backup",
|
||||||
"zh/cli/general"
|
"zh/cli/general"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -485,6 +488,7 @@
|
|||||||
"group": "发布记录",
|
"group": "发布记录",
|
||||||
"pages": [
|
"pages": [
|
||||||
"zh/releases/overview",
|
"zh/releases/overview",
|
||||||
|
"zh/releases/v2.1.4",
|
||||||
"zh/releases/v2.1.3",
|
"zh/releases/v2.1.3",
|
||||||
"zh/releases/v2.1.2",
|
"zh/releases/v2.1.2",
|
||||||
"zh/releases/v2.1.1",
|
"zh/releases/v2.1.1",
|
||||||
@@ -712,6 +716,7 @@
|
|||||||
"group": "リリースノート",
|
"group": "リリースノート",
|
||||||
"pages": [
|
"pages": [
|
||||||
"ja/releases/overview",
|
"ja/releases/overview",
|
||||||
|
"ja/releases/v2.1.4",
|
||||||
"ja/releases/v2.1.3",
|
"ja/releases/v2.1.3",
|
||||||
"ja/releases/v2.1.2",
|
"ja/releases/v2.1.2",
|
||||||
"ja/releases/v2.1.1",
|
"ja/releases/v2.1.1",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
|
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -106,14 +106,14 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [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 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [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 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [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 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -202,6 +202,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
## 🏷 更新履歴
|
## 🏷 更新履歴
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — デスクトップの体験改善、MCP の OAuth 認可対応、Feishu チャネルの機能向上、定期タスクとデータバックアップ、新モデル追加。
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)(macOS / Windows)、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)(macOS / Windows)、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
||||||
@@ -230,7 +232,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
||||||
|
|
||||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -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_id` | Feishu アプリの App ID | - |
|
||||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
|
| `feishu_detailed_card` | ストリーミング応答を詳細カード(ツール呼び出し、思考過程、経過時間)で表示。無効時は通常のタイプライターカード | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -81,7 +82,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
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"/>
|
<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 ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
|
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
||||||
|
| 詳細カード | ✅ ツール呼び出し、思考過程、経過時間(`feishu_detailed_card` で制御、デフォルト有効) |
|
||||||
|
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
ストリーミング応答には `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 名を検索してチャットを開始できます。
|
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
||||||
|
|
||||||
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
||||||
|
|
||||||
|
個人チャットでは `/tasks`、グループでは Bot に @メンションして `/tasks` を送ると、そのチャットのタスクを管理できます。
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ description: Telegram Bot API 経由で CowAgent を接続
|
|||||||
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) |
|
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) |
|
||||||
| `/config` | 現在の設定を表示 |
|
| `/config` | 現在の設定を表示 |
|
||||||
| `/cancel` | 実行中の Agent タスクを中断 |
|
| `/cancel` | 実行中の Agent タスクを中断 |
|
||||||
|
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>`) |
|
||||||
| `/logs` | 最近のログを表示 |
|
| `/logs` | 最近のログを表示 |
|
||||||
| `/version` | バージョンを表示 |
|
| `/version` | バージョンを表示 |
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer 失敗しているテストを先に確認して
|
||||||
|
```
|
||||||
|
|
||||||
|
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
|
|||||||
| `/help` | コマンドヘルプを表示 |
|
| `/help` | コマンドヘルプを表示 |
|
||||||
| `/status` | サービスの状態と設定を表示 |
|
| `/status` | サービスの状態と設定を表示 |
|
||||||
| `/cancel` | 実行中の Agent タスクを中止 |
|
| `/cancel` | 実行中の Agent タスクを中止 |
|
||||||
|
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
|
||||||
| `/config` | 実行時設定の表示・変更 |
|
| `/config` | 実行時設定の表示・変更 |
|
||||||
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
||||||
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
| `model` | `claude-sonnet-5`、`claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
||||||
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
| モデル | 用途 |
|
| モデル | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
||||||
|
| `claude-fable-5` | Claude 5 シリーズのもう一つのフラッグシップモデル |
|
||||||
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
||||||
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、claude-fable-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k3、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -20,13 +20,13 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k3` と `kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
||||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"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_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
|||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1`、o シリーズなどをサポート。Agent モードのデフォルトは `gpt-5.5`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1` などをサポート。Agent モードのデフォルトは `gpt-5.6-luna`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||||
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
||||||
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
||||||
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
|
|||||||
|
|
||||||
| バージョン | 日付 | 説明 |
|
| バージョン | 日付 | 説明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.4](/ja/releases/v2.1.4) | 2026.07.20 | デスクトップクライアント改善(ブラウザツール、Windows 署名、Win7/8 対応)、MCP OAuth 認証、定期タスク・Feishu チャネル強化、データバックアップと復元、新モデル追加(kimi-k3、gpt-5.6) |
|
||||||
| [2.1.3](/ja/releases/v2.1.3) | 2026.07.08 | デスクトップクライアント正式リリース(macOS / Windows)、ナレッジベースのドキュメント管理強化、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加(claude-sonnet-5、doubao-seed-2.1 など)、セキュリティ強化と改善 |
|
| [2.1.3](/ja/releases/v2.1.3) | 2026.07.08 | デスクトップクライアント正式リリース(macOS / Windows)、ナレッジベースのドキュメント管理強化、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加(claude-sonnet-5、doubao-seed-2.1 など)、セキュリティ強化と改善 |
|
||||||
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル追加(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
|
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル追加(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
|
||||||
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加(MiniMax-M3、qwen3.7-plus など)、各種改善 |
|
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加(MiniMax-M3、qwen3.7-plus など)、各種改善 |
|
||||||
|
|||||||
78
docs/ja/releases/v2.1.4.mdx
Normal file
78
docs/ja/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.4
|
||||||
|
description: "CowAgent 2.1.4:デスクトップの体験改善、MCP の OAuth 認可対応、Feishu チャネルの機能向上に加え、定期タスク・データバックアップ・新モデルなど多数の更新"
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
|
## 🖥 デスクトップクライアント
|
||||||
|
|
||||||
|
前バージョンでデスクトップクライアントを正式リリースしたのに続き、今回はブラウザ機能、システム互換性、使い勝手をさらに向上させました:
|
||||||
|
|
||||||
|
- **ブラウザツール対応**:クライアントにブラウザ機能を内蔵し、システムにインストール済みの Chrome / Edge を優先的に再利用します。ブラウザの起動とアクセス性能も最適化しました。
|
||||||
|
- **UI のブラッシュアップ**:チャット画面、メッセージバブル、ツール呼び出しステップ、チャネルページの見た目と操作性を改善しました。
|
||||||
|
- **Windows コード署名**:Windows インストーラーにコード署名を追加し、インストール時のセキュリティ警告を軽減しました。
|
||||||
|
- **Windows 7/8 対応**:Windows 7/8 などの旧システム向けクライアントに対応し、より多くの環境をカバーします。
|
||||||
|
- **ナレッジベースのリンク修正**:デスクトップ版ナレッジベースのドキュメント内リンクが正しく遷移しない問題を修正しました。
|
||||||
|
- **パスワードログイン**:デスクトップクライアントでログインパスワードの設定に対応し、パスワード設定後にウィンドウが読み込めない問題を修正しました。
|
||||||
|
|
||||||
|
ダウンロード:[CowAgent デスクトップ](https://cowagent.ai/download/)
|
||||||
|
|
||||||
|
ドキュメント:[デスクトップクライアント](https://docs.cowagent.ai/guide/desktop)
|
||||||
|
|
||||||
|
## 🔌 MCP リモートサービスの OAuth 認可
|
||||||
|
|
||||||
|
リモート MCP サービスが **OAuth 認可** に対応しました。ログイン認可が必要なサードパーティ MCP サービスに接続する際、標準の OAuth フローで認証を完了できるため、トークンを手動で設定・管理する必要がなくなります。
|
||||||
|
|
||||||
|
ドキュメント:[MCP ツール](https://docs.cowagent.ai/tools/mcp)
|
||||||
|
|
||||||
|
## ⏰ 定期タスク
|
||||||
|
|
||||||
|
- **サイレントモード**:バックグラウンドで静かに実行され、メッセージを能動的に送信しない定期タスクを作成できます。データ整理や定期アーカイブなど、通知が不要なシーンに最適です。(#2954)
|
||||||
|
- **手動実行**:次回のスケジュールを待たずに、コンソール画面から既存タスクを手動で即時実行できます。(#2958)
|
||||||
|
- **編集時の設定保持**:Web コンソールでタスクを編集する際、モードタイプなどの隠しフィールドが失われることがある問題を修正しました。(#2959)
|
||||||
|
- **クロスチャネルのタスクコマンド**:`/tasks` 管理コマンドを追加し、複数チャネルに対応しました。(#2965)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
ドキュメント:[定期タスク](https://docs.cowagent.ai/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 Feishu チャネルの機能向上
|
||||||
|
|
||||||
|
Feishu チャネルにメッセージ表示と操作性の強化を一連で追加し、使い勝手を向上させました。
|
||||||
|
|
||||||
|
- **ストリーミングカードの改善**:ストリーミングカードに折りたたみパネルを追加し、思考過程、ツール呼び出し、実行時間などを表示します。(#2963)
|
||||||
|
- **Markdown 形式**:非ストリーミング応答と定期配信について、Markdown 構文を含む場合はカードとしてレンダリングし、より見やすく表示します。(#2962)
|
||||||
|
- **定期タスクカード**:`/tasks` コマンドをカード形式で表示し、カード上でタスクの有効化・無効化ができます。(#2961)
|
||||||
|
- **メッセージ引用への対応**:ユーザーがメッセージを引用した際、引用元の内容を自動的にコンテキストへ追加して Agent に送信します。(#2966)
|
||||||
|
- **リモート画像のレンダリング**:Feishu カード内でリモート画像リンクをレンダリングできるようになりました。(#2967)
|
||||||
|
- **メッセージ取り消しでタスクをキャンセル**:Feishu メッセージを取り消すと、対応する実行中またはキュー中のタスクを自動的にキャンセルします。(#2978)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
ドキュメント:[Feishu](https://docs.cowagent.ai/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 データのバックアップと復元
|
||||||
|
|
||||||
|
`cow backup` と `cow restore` コマンドを追加しました。設定、ナレッジベース、メモリなどのローカルデータをワンコマンドでエクスポート・復元でき、移行やバックアップが容易になります。Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
ドキュメント:[データバックアップ](https://docs.cowagent.ai/cli/backup)
|
||||||
|
|
||||||
|
## 🤖 新モデル
|
||||||
|
|
||||||
|
- **kimi-k3** に対応
|
||||||
|
- **gpt-5.6-luna**、**gpt-5.6-terra**、**gpt-5.6-sol** に対応
|
||||||
|
|
||||||
|
ドキュメント:[モデル一覧](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🛠 改善と修正
|
||||||
|
|
||||||
|
- **アクティブタスクのステアリング**:`/steer` コマンドを追加し、タスクの実行中に新しい指示を挿入して、実行中のタスクをその場で誘導・軌道修正できます。Thanks @AaronZ345 (#2977)
|
||||||
|
- **ファイル編集**:ファイル編集時にあいまい一致が誤った位置を特定することがある問題を修正しました。Thanks @weijun-xia (#2945)
|
||||||
|
|
||||||
|
## 📦 アップグレード方法
|
||||||
|
|
||||||
|
- **ソースデプロイ**:`cow update` でワンクリックアップグレード、または最新コードを取得して再起動してください。詳しくは [アップグレードガイド](https://docs.cowagent.ai/guide/upgrade) を参照してください。
|
||||||
|
- **デスクトップクライアント**:クライアント内で更新を確認しワンクリックで更新するか、[ダウンロードページ](https://cowagent.ai/download/) から最新版を入手してください。
|
||||||
|
|
||||||
|
**リリース日**:2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
|
|||||||
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**デスクトップクライアント利用者**:playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時:
|
||||||
|
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
|
||||||
|
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
|
||||||
|
</Note>
|
||||||
|
|
||||||
## ワークフロー
|
## ワークフロー
|
||||||
|
|
||||||
Agentがブラウザを使う典型的な流れ:
|
Agentがブラウザを使う典型的な流れ:
|
||||||
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ブラウザエンジン
|
||||||
|
|
||||||
|
ブラウザエンジンは自動選択され、設定は不要です:
|
||||||
|
|
||||||
|
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
|
||||||
|
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
|
||||||
|
|
||||||
|
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
|
||||||
|
|
||||||
## ログイン状態の永続化
|
## ログイン状態の永続化
|
||||||
|
|
||||||
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Supports `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | Supports `claude-sonnet-5`, `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
||||||
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
| Model | Use Case |
|
| Model | Use Case |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
|
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
|
||||||
|
| `claude-fable-5` | Alternative flagship in the Claude 5 family |
|
||||||
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
|
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
|
||||||
| `claude-opus-4-7` | Earlier Opus flagship |
|
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
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>
|
<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.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| 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_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` |
|
| `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
|
## Image Generation
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, etc. Agent mode defaults to `gpt-5.6-luna`; use `gpt-5.4` for better cost-efficiency |
|
||||||
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||||
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
||||||
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent version history
|
|||||||
|
|
||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.4](/releases/v2.1.4) | 2026.07.20 | Desktop client improvements (browser tool, Windows signing, Win7/8 support), MCP OAuth, scheduled tasks and Feishu channel enhancements, data backup & restore, new models (kimi-k3, gpt-5.6) |
|
||||||
| [2.1.3](/releases/v2.1.3) | 2026.07.08 | Desktop client released (macOS / Windows), knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models (claude-sonnet-5, doubao-seed-2.1, etc.), security hardening and refinements |
|
| [2.1.3](/releases/v2.1.3) | 2026.07.08 | Desktop client released (macOS / Windows), knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models (claude-sonnet-5, doubao-seed-2.1, etc.), security hardening and refinements |
|
||||||
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | Web Console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements |
|
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | Web Console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements |
|
||||||
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |
|
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |
|
||||||
|
|||||||
78
docs/releases/v2.1.4.mdx
Normal file
78
docs/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.4
|
||||||
|
description: "CowAgent 2.1.4: Desktop experience improvements, MCP OAuth authorization, Feishu channel enhancements, plus scheduler, data backup, and new models"
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
|
## 🖥 Desktop Client
|
||||||
|
|
||||||
|
Following the desktop client launched in the previous release, this version further improves browser capabilities, system compatibility, and overall experience:
|
||||||
|
|
||||||
|
- **Browser tool support**: the client bundles browser capabilities and prefers reusing the system's installed Chrome / Edge, with optimized browser startup and access performance.
|
||||||
|
- **UI polish**: refined visuals and interactions for the chat view, message bubbles, tool-call steps, and channel pages.
|
||||||
|
- **Windows code signing**: Windows installers are now code-signed, reducing security warnings during installation.
|
||||||
|
- **Windows 7/8 support**: added client support for legacy systems such as Windows 7/8, covering more environments.
|
||||||
|
- **Knowledge base link fix**: fixed in-document links in the desktop knowledge base that failed to navigate.
|
||||||
|
- **Password login**: the desktop client supports setting a login password, and fixes an issue where the window failed to load after a password was set.
|
||||||
|
|
||||||
|
Download: [CowAgent Desktop](https://cowagent.ai/download/)
|
||||||
|
|
||||||
|
Docs: [Desktop Client](https://docs.cowagent.ai/guide/desktop)
|
||||||
|
|
||||||
|
## 🔌 MCP Remote Server OAuth Authorization
|
||||||
|
|
||||||
|
Remote MCP servers now support **OAuth authorization**. When connecting to third-party MCP servers that require login, authentication can be completed via the standard OAuth flow — no more manually configuring and maintaining tokens.
|
||||||
|
|
||||||
|
Docs: [MCP Tools](https://docs.cowagent.ai/tools/mcp)
|
||||||
|
|
||||||
|
## ⏰ Scheduled Tasks
|
||||||
|
|
||||||
|
- **Silent mode**: create silently-running scheduled tasks that execute in the background without pushing messages — ideal for undisturbed scenarios like data organization or periodic archiving. (#2954)
|
||||||
|
- **Manual run**: manually trigger an existing task to run immediately from the console, without waiting for the next schedule. (#2958)
|
||||||
|
- **Preserve config on edit**: fixed an issue where editing a task in the Web console could drop hidden fields such as the mode type. (#2959)
|
||||||
|
- **Cross-channel task command**: added the `/tasks` management command, compatible across channels. (#2965)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
Docs: [Scheduled Tasks](https://docs.cowagent.ai/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 Feishu Channel Improvements
|
||||||
|
|
||||||
|
The Feishu channel gains a series of message-display and interaction enhancements for a better experience.
|
||||||
|
|
||||||
|
- **Streaming card polish**: streaming cards add collapsible panels showing the thinking process, tool calls, and execution time. (#2963)
|
||||||
|
- **Markdown formatting**: for non-streaming replies and scheduled pushes, messages containing Markdown are rendered as cards for clearer display. (#2962)
|
||||||
|
- **Scheduler cards**: the `/tasks` command is presented as cards, with the ability to enable or disable tasks right from the card. (#2961)
|
||||||
|
- **Quoted message context**: when a user quotes a message, the quoted content is automatically added to the context sent to the Agent. (#2966)
|
||||||
|
- **Remote image rendering**: remote image links can now be rendered inside Feishu cards. (#2967)
|
||||||
|
- **Cancel on message recall**: recalling a Feishu message automatically cancels its corresponding running or queued task. (#2978)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
Docs: [Feishu](https://docs.cowagent.ai/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 Data Backup & Restore
|
||||||
|
|
||||||
|
Added the `cow backup` and `cow restore` commands to export and restore local data — configuration, knowledge base, memory, and more — with one command, making migration and backup easy. Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
Docs: [Data Backup](https://docs.cowagent.ai/cli/backup)
|
||||||
|
|
||||||
|
## 🤖 New Models
|
||||||
|
|
||||||
|
- Added support for **kimi-k3**
|
||||||
|
- Added support for **gpt-5.6-luna**, **gpt-5.6-terra**, and **gpt-5.6-sol**
|
||||||
|
|
||||||
|
Docs: [Models](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
|
- **Active-task steering**: added the `/steer` command to inject new instructions during task execution, guiding or redirecting the running task on the fly. Thanks @AaronZ345 (#2977)
|
||||||
|
- **File editing**: fixed an issue where fuzzy matching could locate the wrong position when editing files. Thanks @weijun-xia (#2945)
|
||||||
|
|
||||||
|
## 📦 How to Upgrade
|
||||||
|
|
||||||
|
- **Source deployment**: run `cow update` for a one-click upgrade, or pull the latest code and restart. See the [upgrade guide](https://docs.cowagent.ai/guide/upgrade).
|
||||||
|
- **Desktop client**: check for updates and update with one click inside the client, or get the latest version from the [download page](https://cowagent.ai/download/).
|
||||||
|
|
||||||
|
**Release date**: 2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
@@ -49,6 +49,12 @@ Control a Chromium browser for web navigation, element interaction and content e
|
|||||||
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
|
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**Desktop client users**: playwright is bundled in the installer, no separate install needed. On first use of the browser tool:
|
||||||
|
- If **Google Chrome / Edge** is installed, it drives the system browser directly with **no download** (recommended);
|
||||||
|
- Otherwise, send `/install-browser` in chat to download a lightweight browser engine into `~/.cow`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
A typical browser workflow for the Agent:
|
A typical browser workflow for the Agent:
|
||||||
@@ -105,6 +111,15 @@ You can override it in `config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Browser Engine
|
||||||
|
|
||||||
|
The browser engine is selected automatically, no configuration needed:
|
||||||
|
|
||||||
|
1. If **Google Chrome / Edge** is detected on the machine, it drives the system browser directly, with **no Chromium download**, using real browser fingerprints;
|
||||||
|
2. Otherwise it falls back to the Chromium engine downloaded into `~/.cow` via `install-browser`.
|
||||||
|
|
||||||
|
Both use the persistent login below and behave identically.
|
||||||
|
|
||||||
## Persistent Login
|
## Persistent Login
|
||||||
|
|
||||||
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
|
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ Create and manage scheduled tasks with natural language:
|
|||||||
- "Remind me about the meeting tomorrow at 3 PM"
|
- "Remind me about the meeting tomorrow at 3 PM"
|
||||||
- "Show all scheduled tasks"
|
- "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>
|
<Frame>
|
||||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||||
</Frame>
|
</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"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -108,13 +108,13 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [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 | ✅ | ✅ | | | | |
|
| [百度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 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -203,6 +203,8 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
## 🏷 更新日誌
|
## 🏷 更新日誌
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — 桌面客戶端體驗最佳化、MCP 支援 OAuth 授權、飛書通道能力增強、定時任務與資料備份、新模型接入
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布(macOS / Windows)、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布(macOS / Windows)、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級(定時任務管理、知識庫分類、多模型自定義廠商)、自主進化最佳化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級(定時任務管理、知識庫分類、多模型自定義廠商)、自主進化最佳化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化
|
||||||
@@ -233,7 +235,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 +254,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
## 🏢 企業服務
|
## 🏢 企業服務
|
||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
|
||||||
|
|
||||||
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
||||||
>
|
>
|
||||||
@@ -262,7 +264,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
**產品諮詢和企業服務** 可聯絡產品客服:
|
**產品諮詢和企業服務** 可聯絡產品客服:
|
||||||
|
|
||||||
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档"></a>
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -108,13 +108,13 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [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 | ✅ | ✅ | | | | |
|
| [百度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 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -203,6 +203,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏷 更新日志
|
## 🏷 更新日志
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — 桌面客户端体验优化、MCP 支持 OAuth 授权、飞书通道能力增强、定时任务优化与数据备份、新模型接入
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
||||||
@@ -233,7 +235,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 +254,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏢 企业服务
|
## 🏢 企业服务
|
||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
|
||||||
|
|
||||||
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
|
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
|
||||||
>
|
>
|
||||||
@@ -262,7 +264,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
**产品咨询和企业服务** 可联系产品客服:
|
**产品咨询和企业服务** 可联系产品客服:
|
||||||
|
|
||||||
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -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_id` | 飞书应用 App ID | - |
|
||||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
|
| `feishu_detailed_card` | 流式回复是否使用详细卡片(工具调用、思考过程、耗时展示);关闭时用普通打字机卡片 | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -85,7 +86,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
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"/>
|
<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` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
|
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
||||||
|
| 详细卡片 | ✅ 工具调用、思考过程与耗时展示(`feishu_detailed_card` 控制,默认开启) |
|
||||||
|
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
流式回复需要机器人具备 `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`,即可管理属于当前会话的定时任务。
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ description: 将 CowAgent 接入 Telegram Bot
|
|||||||
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) |
|
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) |
|
||||||
| `/config` | 查看当前配置 |
|
| `/config` | 查看当前配置 |
|
||||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||||
|
| `/steer` | 引导当前正在运行的 Agent 任务(`/steer <指令>`) |
|
||||||
| `/logs` | 查看最近日志 |
|
| `/logs` | 查看最近日志 |
|
||||||
| `/version` | 查看版本 |
|
| `/version` | 查看版本 |
|
||||||
|
|
||||||
|
|||||||
45
docs/zh/cli/backup.mdx
Normal file
45
docs/zh/cli/backup.mdx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: 备份与恢复
|
||||||
|
description: 导出和恢复 CowAgent 配置及 Agent 工作区数据
|
||||||
|
---
|
||||||
|
|
||||||
|
CowAgent 可以生成便携的本地归档,用于设备迁移或灾难恢复。
|
||||||
|
|
||||||
|
## 创建备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow backup
|
||||||
|
cow backup --output /safe/location/cow-backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
归档包含:
|
||||||
|
|
||||||
|
- `config.json`
|
||||||
|
- `AGENT.md`、`USER.md`、`MEMORY.md` 等 Agent 人格和用户文件
|
||||||
|
- 工作区内的每日记忆、会话历史、知识库、自定义技能和定时任务
|
||||||
|
- 旧版 `user_datas.pkl`(如存在)
|
||||||
|
|
||||||
|
临时 `tmp/` 数据、缓存、Git 元数据和符号链接不会写入归档。在操作系统支持的情况下,归档文件权限会限制为仅当前用户可读写。
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
备份中可能含有 API 密钥、对话历史及其他个人数据,应按密钥文件保存和传输。ZIP 文件本身没有加密。
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
## 恢复备份
|
||||||
|
|
||||||
|
恢复前先停止 CowAgent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow stop
|
||||||
|
cow restore /safe/location/cow-backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移到不同工作区时,可以显式指定路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow restore cow-backup.zip --workspace ~/cow-restored
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复命令会在写入前校验归档格式和所有路径。它会覆盖同名文件,但不会删除目标目录中无关的现有文件。如果检测到当前 CowAgent 数据,会先在所选归档旁创建 `cow-pre-restore-*.zip` 回滚备份。
|
||||||
|
|
||||||
|
无人值守脚本可传入 `--yes` 确认覆盖。
|
||||||
@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入:已经开始的工具可以执行完,尚未开始的工具会跳过。若当前没有运行中的任务,`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer 先处理失败的测试
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
|||||||
memory Memory distillation (dream)
|
memory Memory distillation (dream)
|
||||||
knowledge View knowledge base stats and structure
|
knowledge View knowledge base stats and structure
|
||||||
|
|
||||||
|
Data portability:
|
||||||
|
backup Back up config and agent workspace
|
||||||
|
restore Restore a CowAgent backup
|
||||||
|
|
||||||
Others:
|
Others:
|
||||||
help Show this help message
|
help Show this help message
|
||||||
version Show version
|
version Show version
|
||||||
@@ -58,6 +62,7 @@ Others:
|
|||||||
| `/help` | 显示命令帮助 |
|
| `/help` | 显示命令帮助 |
|
||||||
| `/status` | 查看服务状态和配置 |
|
| `/status` | 查看服务状态和配置 |
|
||||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||||
|
| `/steer <指令>` | 引导当前正在运行的 Agent 任务,不新建排队回合 |
|
||||||
| `/config` | 查看或修改运行时配置 |
|
| `/config` | 查看或修改运行时配置 |
|
||||||
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
|
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
|
||||||
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) |
|
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) |
|
||||||
@@ -92,6 +97,7 @@ Others:
|
|||||||
| start / stop / restart | ✓ | ✗ |
|
| start / stop / restart | ✓ | ✗ |
|
||||||
| update | ✓ | ✗ |
|
| update | ✓ | ✗ |
|
||||||
| install-browser | ✓ | ✗ |
|
| install-browser | ✓ | ✗ |
|
||||||
|
| backup / restore | ✓ | ✗ |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
|
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 支持 `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | 支持 `claude-sonnet-5`、`claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
||||||
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
| 模型 | 适用场景 |
|
| 模型 | 适用场景 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
||||||
|
| `claude-fable-5` | Claude 5 系列的另一款旗舰模型 |
|
||||||
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
||||||
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
||||||
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。
|
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k3` 与 `kimi-k2.x` 系列原生支持视觉。
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
||||||
@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"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_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
||||||
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
|
|||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1`、o 系列等;Agent 模式默认 `gpt-5.5`,追求性价比可改为 `gpt-5.4` |
|
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1` 等;Agent 模式默认 `gpt-5.6-luna`,追求性价比可改为 `gpt-5.4` |
|
||||||
| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
||||||
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
||||||
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
|
|||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
| 版本 | 日期 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.4](/zh/releases/v2.1.4) | 2026.07.20 | 桌面客户端优化(浏览器工具、Windows 签名、Win7/8 支持)、MCP OAuth 授权、定时任务与飞书通道增强、数据备份恢复、新模型接入(kimi-k3、gpt-5.6) |
|
||||||
| [2.1.3](/zh/releases/v2.1.3) | 2026.07.08 | 桌面客户端正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入(claude-sonnet-5、doubao-seed-2.1 等)、安全加固与体验优化 |
|
| [2.1.3](/zh/releases/v2.1.3) | 2026.07.08 | 桌面客户端正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入(claude-sonnet-5、doubao-seed-2.1 等)、安全加固与体验优化 |
|
||||||
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
|
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
|
||||||
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入(MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |
|
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入(MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |
|
||||||
|
|||||||
78
docs/zh/releases/v2.1.4.mdx
Normal file
78
docs/zh/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.4
|
||||||
|
description: CowAgent 2.1.4:桌面客户端体验优化,MCP 支持 OAuth 授权,飞书通道能力增强,定时任务与数据备份等多项更新
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
|
## 🖥 桌面客户端
|
||||||
|
|
||||||
|
在上个版本正式推出桌面客户端后,本次围绕浏览器能力、系统兼容与使用体验做了进一步提升:
|
||||||
|
|
||||||
|
- **支持浏览器工具**:客户端内置浏览器能力并优先复用系统已安装的 Chrome / Edge,并优化浏览器启动及访问性能。
|
||||||
|
- **界面样式优化**:优化对话界面、消息气泡、工具调用步骤与通道页面的视觉与交互细节。
|
||||||
|
- **Windows 代码签名**:Windows 安装包新增代码签名,降低安装时的安全告警。
|
||||||
|
- **支持 Win7/Win8 系统**:新增对 Windows 7/8 等旧版系统的客户端支持,覆盖更多使用环境。
|
||||||
|
- **知识库文档跳转修复**:修复桌面端知识库文档内链接无法正常跳转的问题。
|
||||||
|
- **密码登录**:桌面客户端支持设置登录密码,并修复设置密码后无法加载窗口的问题。
|
||||||
|
|
||||||
|
下载地址:[CowAgent客户端](https://cowagent.ai/zh/download/)
|
||||||
|
|
||||||
|
相关文档:[桌面客户端](https://docs.cowagent.ai/zh/guide/desktop)
|
||||||
|
|
||||||
|
## 🔌 MCP 远程服务 OAuth 授权
|
||||||
|
|
||||||
|
远程 MCP 服务新增 **OAuth 授权** 支持,接入需要登录授权的第三方 MCP 服务时,可通过标准 OAuth 流程完成认证,无需手动配置和维护令牌。
|
||||||
|
|
||||||
|
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
|
||||||
|
|
||||||
|
## ⏰ 定时任务
|
||||||
|
|
||||||
|
- **静默模式**:支持创建静默执行的定时任务,任务后台运行、无主动消息推送,适合数据整理、定期归档等无需打扰的场景。(#2954)
|
||||||
|
- **手动执行**:支持在控制台界面手动触发已有任务立即执行,无需等待下次调度。(#2958)
|
||||||
|
- **编辑保留配置**:修复在 Web 端编辑任务时可能丢失模式类型等隐藏配置的问题。(#2959)
|
||||||
|
- **跨通道任务命令**:新增 `/tasks` 定时任务管理命令,多通道兼容。(#2965)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
相关文档:[定时任务](https://docs.cowagent.ai/zh/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 飞书通道增强
|
||||||
|
|
||||||
|
飞书通道新增一系列消息展示与交互增强,提升使用体验。
|
||||||
|
|
||||||
|
- **流式卡片优化**:流式卡片增加折叠面板,展示思考过程、工具调用、执行耗时等信息。(#2963)
|
||||||
|
- **Markdown 格式**:对于非流式回复与定时推送,在含有 Markdown 语法时以卡片渲染,展示更清晰。(#2962)
|
||||||
|
- **定时任务卡片**:定时任务 `/tasks` 命令以卡片形式呈现,并支持在卡片中启用和关闭任务。(#2961)
|
||||||
|
- **支持消息引用**:用户引用消息时,自动将被引用消息加入上下文发送给 Agent。(#2966)
|
||||||
|
- **远程图片渲染**:支持在飞书卡片中渲染远程图片链接。(#2967)
|
||||||
|
- **撤回消息取消任务**:撤回某条飞书消息时,自动取消其对应正在执行或排队中的任务。(#2978)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
相关文档:[飞书](https://docs.cowagent.ai/zh/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 数据备份与恢复
|
||||||
|
|
||||||
|
新增 `cow backup` 和 `cow restore` 命令,支持一键导出与恢复配置、知识库、记忆等本地数据,便于迁移与备份。Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
相关文档:[数据备份](https://docs.cowagent.ai/zh/cli/backup)
|
||||||
|
|
||||||
|
## 🤖 模型新增
|
||||||
|
|
||||||
|
- 新增支持 **kimi-k3**
|
||||||
|
- 新增支持 **gpt-5.6-luna**、**gpt-5.6-terra**、**gpt-5.6-sol**
|
||||||
|
|
||||||
|
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
|
||||||
|
|
||||||
|
## 🛠 体验优化与修复
|
||||||
|
|
||||||
|
- **任务实时引导**:新增 `/steer` 命令,可在任务执行过程中插入新指令来引导或纠偏当前正在执行的任务。Thanks @AaronZ345 (#2977)
|
||||||
|
- **文件编辑**:修复编辑文件时模糊匹配可能定位到错误位置的问题。Thanks @weijun-xia (#2945)
|
||||||
|
|
||||||
|
## 📦 升级方式
|
||||||
|
|
||||||
|
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
|
||||||
|
- **桌面客户端**:可在客户端内自动检查并一键更新,或前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
|
||||||
|
|
||||||
|
**发布日期**:2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
@@ -49,6 +49,12 @@ description: 控制浏览器访问和操作网页
|
|||||||
2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
|
2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**桌面客户端用户**:playwright 已内置于安装包,无需单独安装。首次使用浏览器工具时:
|
||||||
|
- 若系统已安装 **Google Chrome / Edge**,会直接驱动系统浏览器,**无需任何下载**(推荐);
|
||||||
|
- 若未安装,可在对话中发送 `/install-browser`,自动下载一个精简浏览器内核到 `~/.cow`。
|
||||||
|
</Note>
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
Agent 使用浏览器的典型流程:
|
Agent 使用浏览器的典型流程:
|
||||||
@@ -105,6 +111,15 @@ Agent 使用浏览器的典型流程:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 浏览器内核
|
||||||
|
|
||||||
|
浏览器内核会自动选择,无需配置:
|
||||||
|
|
||||||
|
1. 若检测到本机已安装 **Google Chrome / Edge**,直接驱动系统浏览器,**无需下载 Chromium**,并使用真实浏览器指纹;
|
||||||
|
2. 否则使用 `install-browser` 下载到 `~/.cow` 的 Chromium 内核作为兜底。
|
||||||
|
|
||||||
|
两种方式都使用下面的登录态持久化,行为一致。
|
||||||
|
|
||||||
## 登录态持久化
|
## 登录态持久化
|
||||||
|
|
||||||
**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式:
|
**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式:
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ description: 创建和管理定时任务
|
|||||||
- "明天下午 3 点提醒我开会"
|
- "明天下午 3 点提醒我开会"
|
||||||
- "查看所有定时任务"
|
- "查看所有定时任务"
|
||||||
|
|
||||||
|
## 测试执行已有任务
|
||||||
|
|
||||||
|
在经过身份认证的 Web 控制台或 Desktop 应用中打开任务,点击 **立即执行**。确认发送后,CowAgent 会立即按任务已配置的通道和接收者执行一次。这个入口只允许用户手动触发,不会暴露给 Agent 的 scheduler tool。
|
||||||
|
|
||||||
|
禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行。
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class MoonshotBot(Bot):
|
|||||||
m = model_name.lower()
|
m = model_name.lower()
|
||||||
if cls._is_builtin_reasoning_model(m):
|
if cls._is_builtin_reasoning_model(m):
|
||||||
return False
|
return False
|
||||||
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
return m.startswith("kimi-k3") or m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
||||||
|
|
||||||
def _build_headers(self) -> dict:
|
def _build_headers(self) -> dict:
|
||||||
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user