Compare commits

..

40 Commits

Author SHA1 Message Date
zhayujie
0783d64f1f Merge pull request #2957 from AaronZ345/feat/local-data-backup
feat(cli): add portable data backup and restore
2026-07-19 12:02:25 +08:00
zhayujie
064740aaad Merge pull request #2958 from AaronZ345/feat/scheduler-run-now
feat(scheduler): add user-confirmed run-now controls
2026-07-19 11:52:10 +08:00
zhayujie
8071ea496a Merge pull request #2967 from AaronZ345/agent/feishu-markdown-images
feat: render remote Markdown images in Feishu cards
2026-07-19 11:24:32 +08:00
zhayujie
83209a8b2c Merge pull request #2966 from AaronZ345/agent/feishu-quoted-context
feat: include quoted Feishu messages in agent context
2026-07-19 11:23:53 +08:00
zhayujie
3db595124c Merge pull request #2965 from AaronZ345/agent/cross-channel-tasks
feat: make /tasks available across chat channels
2026-07-19 11:23:43 +08:00
AaronZ345
6e10b8e3d2 feat: render remote images in Feishu cards 2026-07-17 17:15:37 +08:00
AaronZ345
2635c12a7f feat: include Feishu quoted message context 2026-07-17 17:09:48 +08:00
AaronZ345
64e8f56a7b feat: add cross-channel tasks command 2026-07-17 17:06:08 +08:00
AaronZ345
67be468ff1 feat(cli): add portable data backup and restore 2026-07-17 16:51:56 +08:00
AaronZ345
209f1f0a6f refactor(scheduler): move manual runs to console controls 2026-07-17 16:51:56 +08:00
AaronZ345
f7a0184ea2 feat(scheduler): run existing tasks immediately 2026-07-17 16:51:56 +08:00
zhayujie
7d8751f04b Merge pull request #2961 from AaronZ345/agent/feishu-scheduler-cards 2026-07-17 16:50:13 +08:00
zhayujie
3ee7b164d4 Merge branch 'feishu-progress-2963' 2026-07-17 16:33:42 +08:00
zhayujie
d7abbd3baa Merge pull request #2963 from AaronZ345/agent/feishu-rich-progress-card
feat(feishu): add rich single-card progress
2026-07-17 16:16:53 +08:00
zhayujie
0db8a24441 Merge pull request #2962 from AaronZ345/agent/feishu-markdown-cards
feat(feishu): render Markdown replies as cards
2026-07-17 15:59:17 +08:00
zhayujie
2f86e9b608 docs: update kimi k3 docs 2026-07-17 14:55:00 +08:00
AaronZ345
9d8c6557ae feat(feishu): add scheduler action cards 2026-07-17 12:54:26 +08:00
AaronZ345
257eb5d598 feat(feishu): add rich progress cards 2026-07-17 12:54:26 +08:00
AaronZ345
9f63453e67 feat(feishu): render markdown replies as cards 2026-07-17 12:54:26 +08:00
zhayujie
5858bda854 Merge pull request #2959 from AaronZ345/fix/scheduler-preserve-hidden-fields
fix(scheduler): preserve hidden action fields on web edits
2026-07-17 12:13:23 +08:00
AaronZ345
c784c89fe3 fix(scheduler): preserve hidden fields on web edits 2026-07-17 11:58:44 +08:00
zhayujie
186dae059e feat(model): support kimi-k3 2026-07-17 11:35:07 +08:00
zhayujie
ded3d2c929 Merge branch 'agent/scheduler-silent-mode' 2026-07-17 10:53:46 +08:00
zhayujie
b55c592714 feat: refine scheduler silent mode to avoid mislabeling delivery tasks 2026-07-17 10:53:29 +08:00
zhayujie
1d226979d1 perf(browser): speed up navigation + fix desktop knowledge viewer links 2026-07-16 23:32:06 +08:00
zhayujie
6fad628551 feat(desktop): support win-legacy desktop CI 2026-07-16 23:02:37 +08:00
zhayujie
8b426ed71d feat: optimize desktop client style
- light theme colors, fix chat cancel logic, add clear-context feedback, polish thinking step icon/order, and align channels empty state with the web console
2026-07-16 19:31:56 +08:00
zhayujie
c7060c147d fix: windows desktop workflow 2026-07-16 17:00:21 +08:00
zhayujie
db49532211 feat(desktop): publish win7 client in workflow 2026-07-16 16:41:27 +08:00
zhayujie
1a52de241d fix: playwright version in win7 2026-07-16 16:19:49 +08:00
zhayujie
b35501e6ad feat(desktop): add win7 workflow 2026-07-16 15:08:15 +08:00
zhayujie
7f8f690497 refactor(tools): remove dead playwright-missing branches 2026-07-16 14:56:23 +08:00
zhayujie
356b02ac79 Merge pull request #2955 from zhayujie/feat-desktop-browser
feat(browser): reuse system Chrome/Edge, bundle playwright for desktop
2026-07-14 18:03:22 +08:00
zhayujie
5d55ec0f8c feat(browser): reuse system Chrome/Edge, bundle playwright for desktop 2026-07-14 18:02:27 +08:00
AaronZ345
d5fdd644cf feat: add silent scheduler agent tasks 2026-07-14 12:08:37 +08:00
zhayujie
eeb4b7981e feat(model): add gpt-5.6-luna, terra, sol 2026-07-14 11:27:03 +08:00
zhayujie
5f1c98881d feat(model): restore the claude-fable-5 model 2026-07-14 11:07:36 +08:00
zhayujie
94d0f56689 fix(desktop): make Windows dry-run signing work 2026-07-13 17:49:50 +08:00
zhayujie
4d690341a7 feat(desktop): add Windows code signing 2026-07-13 16:40:32 +08:00
zhayujie
d8c419227c Merge pull request #2950 from zhayujie/feat-mcp-oauth
feat(mcp): auto OAuth authorization for remote MCP servers
2026-07-13 12:01:32 +08:00
92 changed files with 4703 additions and 422 deletions

View File

@@ -74,6 +74,12 @@ for (const base of fs.readdirSync(dir)) {
} else if (/x64\.zip$/.test(base)) {
platform = 'mac-x64'
slot = 'upd'
} else if (/win7.*\.exe$/i.test(base)) {
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
// segment so it never collides with the standard win exe in the same
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
platform = 'win-legacy'
slot = 'main'
} else if (/\.exe$/.test(base)) {
platform = 'win'
slot = 'main'

View File

@@ -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
View 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

View File

@@ -113,6 +113,31 @@ jobs:
shell: bash
run: npm run build
# Download the Windows signing CLI. The URL comes from a repo variable, so
# nothing about the signing setup is hardcoded in a public workflow. Only
# runs on the Windows leg and only when a URL is set; otherwise the build
# stays unsigned. SIGNTOOL_PATH is exported for the next step's
# electron-builder.win.js to invoke.
- name: Download Windows signing CLI
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
shell: bash
env:
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
run: |
mkdir -p "$RUNNER_TEMP/signtool"
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
# Unzip and locate the signtool executable regardless of nesting.
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
if [ -z "$exe" ]; then
echo "signtool.exe not found in downloaded archive" >&2
find "$RUNNER_TEMP/signtool" -type f >&2
exit 1
fi
# Normalize to a Windows-style path for execFileSync in Node.
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
echo "resolved signtool: $exe"
- name: Build & publish (electron-builder)
working-directory: desktop
shell: bash
@@ -124,8 +149,14 @@ jobs:
# is the correct state for unsigned builds.
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
# Windows code signing via the signing CLI. Credentials are
# secrets; SIGNTOOL_PATH was exported by the download step above.
# COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
# with a self-signed cert before buying a real one — no quota used.
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
run: |
# Pick the signing cert for THIS platform only. The mac and win secrets
# are both present in the job env, but a mac cert must never leak into a
@@ -137,6 +168,10 @@ jobs:
#
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
# (an `unset` can return non-zero and abort under errexit).
# macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
# a local .pfx (EV private keys can't be exported since 2023); it signs
# via the CLI wired into electron-builder.win.js instead, using the
# SIGNTOOL_* env already set above — nothing to export here.
case "${{ matrix.platform }}" in
mac)
if [ -n "$MAC_CSC_LINK" ]; then
@@ -144,12 +179,6 @@ jobs:
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
fi
;;
win)
if [ -n "$WIN_CSC_LINK" ]; then
export CSC_LINK="$WIN_CSC_LINK"
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
fi
;;
esac
# Never let electron-builder publish: our publish target is a generic
@@ -157,19 +186,25 @@ jobs:
# installers to R2 and register them in D1 ourselves (publish-r2 job).
# `--publish never` still emits the latest*.yml files.
#
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
# inject mac.binaries (the backend Mach-O files to hardened-sign for
# notarization) — it's a pure no-op on Windows. Passing --config on
# Windows was what silently broke the Windows build (it produced no
# installer while the job still reported success; Windows worked fine
# before --config was introduced). So Windows uses the plain
# package.json build config and only mac uses the dynamic one.
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
# mac -> electron-builder.js (injects mac.binaries for signing)
# win -> electron-builder.win.js (wires the sign hook; electron-builder
# signs the app, backend and installer)
# HISTORY: passing --config on Windows previously broke the build (no
# installer, job still green). That happened because the MAC config
# (electron-builder.js) was a no-op on Windows yet still disturbed the
# run. The fix is a DEDICATED win config that correctly extends
# config.win — not sharing the mac one. If a build ever runs WITHOUT
# signing configured, electron-builder.win.js still returns the base
# config unchanged (sign hook just skips), so the installer is still
# produced.
#
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
# make bash return before the wrapped process finishes. node skips it.
case "${{ matrix.platform }}" in
mac) config_arg="--config electron-builder.js" ;;
win) config_arg="--config electron-builder.win.js" ;;
*) config_arg="" ;;
esac
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never

View File

@@ -106,14 +106,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |

View File

@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
Terminal = _optional_tools.get('Terminal')
# BrowserTool (requires playwright)
# BrowserTool: playwright is soft-imported inside browser_service, so this
# import always succeeds even without playwright. Readiness (playwright pkg /
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
def _import_browser_tool():
from common.log import logger
try:
from agent.tools.browser.browser_tool import BrowserTool
return BrowserTool
except ImportError as e:
logger.info(
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
return None
except Exception as e:
logger.error(f"[Tools] BrowserTool failed to load: {e}")
return None

View 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(),
}

View File

@@ -326,12 +326,19 @@ class BrowserService:
# - persistent: launch with launch_persistent_context using a user_data_dir
# so cookies / login state survive across runs (default).
# - fresh: classic launch + new_context, clean state every run.
#
# Within persistent/fresh, the actual Chromium binary is resolved by
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
# download) is preferred, falling back to Playwright's own downloaded
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
# when driving a system browser, else None (bundled Chromium).
cdp_endpoint = self._config.get("cdp_endpoint") or ""
persistent_flag = self._config.get("persistent", True)
user_data_dir_cfg = self._config.get("user_data_dir")
if user_data_dir_cfg is None:
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
self._channel: Optional[str] = None
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
if self._cdp_endpoint:
self._launch_mode = "cdp"
@@ -343,6 +350,22 @@ class BrowserService:
self._launch_mode = "fresh"
self._user_data_dir = ""
# Resolve which browser engine to drive (system Chrome vs downloaded
# Chromium). Deferred detection failures are surfaced at launch time.
if self._launch_mode != "cdp":
try:
from agent.tools.browser.browser_env import resolve_engine
engine = resolve_engine(self._config)
if engine["mode"] == "system-chrome":
self._channel = engine["channel"]
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
elif engine["mode"] == "playwright-chromium":
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
else:
logger.info(f"[Browser] No ready engine yet: {engine['reason']}")
except Exception as e:
logger.debug(f"[Browser] Engine resolution skipped: {e}")
# Idle auto-release
idle_cfg = self._config.get("idle_timeout")
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
@@ -428,11 +451,30 @@ class BrowserService:
def _launch_browser(self):
"""Launch / connect Chromium on the background thread."""
# Point Playwright at our pinned download dir before any launch so a
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
try:
from agent.tools.browser.browser_env import apply_browsers_path_env
apply_browsers_path_env()
except Exception as e:
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
if self._headless is None:
headless_cfg = self._config.get("headless")
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
launch_args = ["--disable-dev-shm-usage"]
launch_args = [
"--disable-dev-shm-usage",
# Trim first-launch overhead: skip the first-run wizard, the default
# browser prompt, and Chrome's background/component network chatter.
# These have no effect on page interaction but noticeably speed up
# cold starts and each navigation.
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,OptimizationHints",
]
if self._headless:
launch_args.append("--no-sandbox")
@@ -475,12 +517,20 @@ class BrowserService:
logger.info("[Browser] Browser ready")
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
"""Classic launch: brand new Chromium with an empty context."""
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
self._browser = self._playwright.chromium.launch(
headless=self._headless,
args=launch_args,
)
"""Classic launch: brand new Chromium with an empty context.
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
the user's installed system browser instead of its own Chromium.
"""
engine_label = f"system:{self._channel}" if self._channel else "chromium"
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
launch_kwargs: Dict[str, Any] = {
"headless": self._headless,
"args": launch_args,
}
if self._channel:
launch_kwargs["channel"] = self._channel
self._browser = self._playwright.chromium.launch(**launch_kwargs)
self._context = self._browser.new_context(
viewport=viewport,
user_agent=user_agent,
@@ -491,18 +541,25 @@ class BrowserService:
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
"""Launch Chromium with a persistent user_data_dir so login state survives."""
os.makedirs(self._user_data_dir, exist_ok=True)
engine_label = f"system:{self._channel}" if self._channel else "chromium"
logger.info(
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
f"profile={self._user_data_dir})"
)
persistent_kwargs: Dict[str, Any] = {
"user_data_dir": self._user_data_dir,
"headless": self._headless,
"args": launch_args,
"viewport": viewport,
"user_agent": user_agent,
}
# When driving a system browser, let it use its real UA instead of the
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
if self._channel:
persistent_kwargs["channel"] = self._channel
persistent_kwargs.pop("user_agent", None)
try:
self._context = self._playwright.chromium.launch_persistent_context(
user_data_dir=self._user_data_dir,
headless=self._headless,
args=launch_args,
viewport=viewport,
user_agent=user_agent,
)
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
except Exception as e:
# Profile is locked when another Chromium instance already holds it.
msg = str(e).lower()
@@ -687,11 +744,15 @@ class BrowserService:
except Exception as e:
return {"error": f"Navigation failed: {e}"}
# SPAs keep long-lived connections (websockets, polling, analytics) and
# rarely reach true "networkidle", so waiting the full timeout is wasted
# time. domcontentloaded already gives a usable DOM; give the page a
# short grace period for initial render/XHR, then proceed.
try:
page.wait_for_load_state("networkidle", timeout=8000)
page.wait_for_load_state("networkidle", timeout=1500)
except Exception:
pass
page.wait_for_timeout(500)
page.wait_for_timeout(300)
try:
title = page.title()

View File

@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
f"({ip_str}), request blocked for security"
)
def _check_engine_ready(self) -> Optional[ToolResult]:
"""Return an actionable onboarding message if no browser engine is ready.
Returns None when a system Chrome/Edge or a downloaded Chromium is
available (so the tool can proceed). Otherwise returns a ToolResult with
clear guidance so the agent asks the user to enable the browser instead
of surfacing a raw Playwright launch error. CDP mode is exempt (the
endpoint is external and validated at connect time).
"""
if self.config.get("cdp_endpoint"):
return None
try:
from agent.tools.browser.browser_env import capability_summary
summary = capability_summary(self.config)
except Exception as e:
logger.debug(f"[Browser] capability probe failed: {e}")
return None
if summary.get("ready"):
return None
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
# in-chat `/install-browser` command. Source / web / server installs use
# the `cow install-browser` terminal command.
install_hint = (
"reply `/install-browser`" if summary.get("is_desktop")
else "run `cow install-browser` in a terminal"
)
return ToolResult.fail(
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
"skipped automatically if Google Chrome is already installed). "
"Do not retry until the user confirms."
)
def execute(self, args: Dict[str, Any]) -> ToolResult:
action = args.get("action", "").strip().lower()
if not action:
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
# Preflight: on desktop the playwright package is bundled but the browser
# binary may be missing; return actionable onboarding instead of a cryptic
# launch failure.
not_ready = self._check_engine_ready()
if not_ready is not None:
return not_ready
try:
return handler(self, args)
except Exception as e:

View File

@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
return True # agent ran but produced nothing; don't loop
if action.get("silent", False):
logger.info(
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
)
return True
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
if not channel:

View File

@@ -41,6 +41,8 @@ class SchedulerService:
self.running = False
self.thread = None
self._lock = threading.Lock()
self._execution_lock = threading.Lock()
self._active_task_ids = set()
def start(self):
"""Start the scheduler service"""
@@ -85,7 +87,15 @@ class SchedulerService:
try:
if self._is_task_due(task, now):
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
ok = self._execute_task(task)
if not self._claim_task(task['id']):
logger.info(
f"[Scheduler] Task {task['id']} is already running; skipping this tick"
)
continue
try:
ok = self._execute_task(task)
finally:
self._release_task(task['id'])
if not ok:
# Leave next_run_at as-is so the next loop retries.
# Cron tasks within the catch-up window will keep
@@ -106,6 +116,57 @@ class SchedulerService:
logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}")
except Exception as e:
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
def run_task_now(self, task_id: str) -> None:
"""Queue one immediate execution without changing the task schedule.
Disabled and one-time tasks may be run manually for testing. The
stored ``next_run_at`` remains unchanged, so a manual run never
consumes or delays the next scheduled occurrence.
Raises:
ValueError: if the task does not exist.
RuntimeError: if the same task is already executing.
"""
task = self.task_store.get_task(task_id)
if not task:
raise ValueError(f"Task '{task_id}' not found")
if not self._claim_task(task_id):
raise RuntimeError(f"Task '{task_id}' is already running")
def _run():
now = datetime.now()
try:
logger.info(f"[Scheduler] Manually executing task: {task_id} - {task.get('name', '')}")
ok = self._execute_task(task)
if ok:
self.task_store.update_task(task_id, {
"last_run_at": now.isoformat(),
"last_manual_run_at": now.isoformat(),
})
logger.info(f"[Scheduler] Manual execution completed: {task_id}")
else:
logger.warning(f"[Scheduler] Manual execution failed: {task_id}")
finally:
self._release_task(task_id)
threading.Thread(
target=_run,
daemon=True,
name=f"scheduler-manual-{task_id}",
).start()
def _claim_task(self, task_id: str) -> bool:
"""Prevent scheduled and manual runs of the same task from overlapping."""
with self._execution_lock:
if task_id in self._active_task_ids:
return False
self._active_task_ids.add(task_id)
return True
def _release_task(self, task_id: str) -> None:
with self._execution_lock:
self._active_task_ids.discard(task_id)
def _is_task_due(self, task: dict, now: datetime) -> bool:
"""

View File

@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
"schedule_value": {
"type": "string",
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
},
"silent": {
"type": "boolean",
"default": False,
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
}
},
"required": ["action"]
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
"channel_type": self.config.get("channel_type", "unknown"),
"notify_session_id": notify_session_id,
}
# silent only applies to ai_task; fixed messages always deliver
if kwargs.get("silent", False):
action["silent"] = True
# 针对钉钉单聊,额外存储 sender_staff_id
msg = context.kwargs.get("msg")
@@ -216,14 +224,17 @@ class SchedulerTool(BaseTool):
content_desc = f"💬 固定消息: {message}"
else:
content_desc = f"🤖 AI任务: {ai_task}"
# Warn the user at creation time so a mistaken silent flag is easy to spot
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
return (
f"✅ 定时任务创建成功\n\n"
f"📋 任务ID: {task_id}\n"
f"📝 名称: {name}\n"
f"⏰ 调度: {schedule_desc}\n"
f"👤 接收者: {receiver_desc}\n"
f"{content_desc}\n"
f"{content_desc}{silent_desc}\n"
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
)

View File

@@ -152,14 +152,7 @@ class ToolManager:
except ImportError as e:
# Handle missing dependencies with helpful messages
error_msg = str(e)
if "playwright" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
if "markdownify" in error_msg:
logger.warning(
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
f" Install with: pip install markdownify"
@@ -222,14 +215,7 @@ class ToolManager:
except ImportError as e:
# Handle missing dependencies with helpful messages
error_msg = str(e)
if "playwright" in error_msg:
logger.warning(
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif "markdownify" in error_msg:
if "markdownify" in error_msg:
logger.warning(
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
f" Install with: pip install markdownify"
@@ -261,14 +247,7 @@ class ToolManager:
# If there are missing tools, record warnings
if missing_tools:
for tool_name in missing_tools:
if tool_name == "browser":
logger.warning(
f"[ToolManager] Browser tool is configured but not loaded.\n"
f" To enable browser tool, run:\n"
f" pip install playwright\n"
f" playwright install chromium"
)
elif tool_name == "google_search":
if tool_name == "google_search":
logger.warning(
f"[ToolManager] Google Search tool is configured but may need API key.\n"
f" Get API key from: https://serper.dev\n"

View File

@@ -28,6 +28,17 @@ from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.feishu.feishu_message import FeishuMessage
from channel.feishu.feishu_static_card import (
build_text_delivery,
resolve_markdown_images,
upload_public_image_to_feishu,
)
from channel.feishu.feishu_progress_card import FeishuProgressState
from channel.feishu.feishu_scheduler_card import (
build_scheduler_card,
handle_scheduler_action,
tasks_for_receivers,
)
from common import utils
from common.expired_dict import ExpiredDict
from common.log import logger
@@ -350,6 +361,10 @@ class FeiShuChanel(ChatChannel):
def _startup_websocket(self):
"""启动长连接接收事件(websocket模式)"""
_ensure_lark_imported()
from lark_oapi.event.callback.model.p2_card_action_trigger import (
P2CardActionTriggerResponse,
)
logger.debug("[FeiShu] Starting in websocket mode...")
# 创建事件处理器
@@ -372,10 +387,25 @@ class FeiShuChanel(ChatChannel):
except Exception as e:
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
def handle_card_action(data):
"""Handle Card 2.0 button callbacks and update the card in place."""
try:
event_dict = json.loads(lark.JSON.marshal(data))
response = self._handle_card_action_event(event_dict.get("event", {}))
return P2CardActionTriggerResponse(response)
except Exception as e:
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
return P2CardActionTriggerResponse(
{"toast": {"type": "error", "content": "Task update failed"}}
)
# 构建事件分发器
event_handler = lark.EventDispatcherHandler.builder("", "") \
.register_p2_im_message_receive_v1(handle_message_event) \
event_handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(handle_message_event)
.register_p2_card_action_trigger(handle_card_action)
.build()
)
def start_client_with_retry():
"""Run ws client in this thread with its own event loop to avoid conflicts."""
@@ -470,6 +500,97 @@ class FeiShuChanel(ChatChannel):
# so reaching here means the bot was indeed mentioned.
return True
def _get_scheduler_task_store(self):
"""Reuse the live scheduler store, with a path-compatible fallback."""
from agent.tools.scheduler.integration import get_task_store
task_store = get_task_store()
if task_store is not None:
return task_store
from agent.tools.scheduler.task_store import TaskStore
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
task_store = self._get_scheduler_task_store()
receivers = {feishu_msg.other_user_id}
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
card = build_scheduler_card(tasks)
headers = {
"Authorization": "Bearer " + feishu_msg.access_token,
"Content-Type": "application/json",
}
content = json.dumps(card, ensure_ascii=False)
if is_group and feishu_msg.msg_id:
url = (
"https://open.feishu.cn/open-apis/im/v1/messages/"
f"{feishu_msg.msg_id}/reply"
)
response = requests.post(
url,
headers=headers,
json={"msg_type": "interactive", "content": content},
timeout=(5, 10),
)
else:
url = "https://open.feishu.cn/open-apis/im/v1/messages"
response = requests.post(
url,
headers=headers,
params={"receive_id_type": receive_id_type},
json={
"receive_id": feishu_msg.other_user_id,
"msg_type": "interactive",
"content": content,
},
timeout=(5, 10),
)
result = response.json()
if result.get("code") == 0:
logger.info("[FeiShu] scheduler card sent")
return True
logger.error(
"[FeiShu] scheduler card failed, "
f"code={result.get('code')}, msg={result.get('msg')}"
)
return False
def _handle_card_action_event(self, event: dict) -> dict:
"""Apply a scheduler card action within its chat/operator ownership scope."""
action = event.get("action") or {}
value = action.get("value") or {}
if value.get("cowagent") != "scheduler":
return {}
context = event.get("context") or {}
operator = event.get("operator") or {}
callback_receivers = {
receiver
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
if receiver
}
target_receiver = value.get("receiver")
allowed_receivers = (
{target_receiver}
if target_receiver and target_receiver in callback_receivers
else set()
)
response = handle_scheduler_action(
value,
self._get_scheduler_task_store(),
allowed_receivers,
)
logger.info(
"[FeiShu] scheduler card action handled, "
f"action={value.get('action')}, task_id={value.get('task_id')}"
)
return response
def _handle_message_event(self, event: dict):
"""
处理消息事件的核心逻辑
@@ -521,6 +642,11 @@ class FeiShuChanel(ChatChannel):
if not feishu_msg:
return
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
logger.warning("[FeiShu] /tasks card delivery failed")
return
# 处理文件缓存逻辑
from channel.file_cache import get_file_cache
file_cache = get_file_cache()
@@ -591,7 +717,7 @@ class FeiShuChanel(ChatChannel):
context = self._compose_context(
feishu_msg.ctype,
feishu_msg.content,
feishu_msg.content_with_quote(),
isgroup=is_group,
msg=feishu_msg,
receive_id_type=receive_id_type,
@@ -628,7 +754,23 @@ class FeiShuChanel(ChatChannel):
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
reply_content = reply.content
content_key = "text"
if reply.type == ReplyType.IMAGE_URL:
prepared_content_json = None
if reply.type == ReplyType.TEXT:
delivery_text = reply.content
markdown_cards_enabled = conf().get("feishu_markdown_card", True)
if markdown_cards_enabled:
delivery_text = resolve_markdown_images(
delivery_text,
lambda url: upload_public_image_to_feishu(
url,
access_token,
),
)
msg_type, prepared_content_json = build_text_delivery(
delivery_text,
enabled=markdown_cards_enabled,
)
elif reply.type == ReplyType.IMAGE_URL:
# 图片上传
reply_content = self._upload_image_url(reply.content, access_token)
if not reply_content:
@@ -690,7 +832,11 @@ class FeiShuChanel(ChatChannel):
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
# Build content JSON
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
content_json = prepared_content_json or (
json.dumps(reply_content, ensure_ascii=False)
if content_key is None
else json.dumps({content_key: reply_content}, ensure_ascii=False)
)
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
if can_reply:
@@ -714,10 +860,383 @@ class FeiShuChanel(ChatChannel):
res = res.json()
if res.get("code") == 0:
logger.info(f"[FeiShu] send message success")
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
logger.warning(
"[FeiShu] Markdown card failed, falling back to text, "
f"code={res.get('code')}, msg={res.get('msg')}"
)
fallback_data = {
"msg_type": "text",
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
}
if can_reply:
fallback_res = requests.post(
url=url,
headers=headers,
json=fallback_data,
timeout=(5, 10),
)
else:
fallback_data["receive_id"] = context.get("receiver")
fallback_res = requests.post(
url=url,
headers=headers,
params=params,
json=fallback_data,
timeout=(5, 10),
)
fallback_body = fallback_res.json()
if fallback_body.get("code") == 0:
logger.info("[FeiShu] text fallback sent successfully")
else:
logger.error(
"[FeiShu] text fallback failed, "
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
)
else:
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
def _make_feishu_stream_callback(self, context, access_token):
"""Route to progress or plain streaming callback based on config.
feishu_progress_card 默认关闭:普通对话走原有的打字机文本卡片
(_make_feishu_stream_callback_plain)。开启后才使用带状态头、
Reasoning/Tools 面板与耗时的富进度卡片。
"""
if conf().get("feishu_progress_card", False):
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 == "message_end":
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
tool_calls = data.get("tool_calls", []) or []
with lock:
progress_state.consume(event)
if not tool_calls:
return
_drain_push_queue()
_update_full_card(streaming=True)
elif event_type == "agent_cancelled":
with lock:
progress_state.consume(event)
elif event_type == "agent_end":
# Finalize the same card with a status header and elapsed footer.
with lock:
progress_state.consume(event)
final_text = progress_state.current_text
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
final_text = resolve_markdown_images(
final_text,
lambda url: upload_public_image_to_feishu(url, access_token),
)
with lock:
progress_state.current_text = final_text
context["feishu_streamed"] = True
if not has_card and not init_busy:
with lock:
init_in_flight[0] = True
_create_and_send_card()
with lock:
if disabled[0]:
return
_drain_push_queue()
_stream_update_text(final_text)
_update_full_card(streaming=False)
push_queue.put(None)
return on_event
def _make_feishu_stream_callback_plain(self, context, access_token):
"""
基于飞书官方"流式更新卡片"API 实现打字机回复。
@@ -966,9 +1485,15 @@ class FeiShuChanel(ChatChannel):
if not cid:
return
preview_text = final_text
final_text = resolve_markdown_images(
final_text,
lambda url: upload_public_image_to_feishu(url, access_token),
)
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
# settings 接口的 config 不接受 summary 字段,会报 code=2200
preview_src = (final_text or "").strip().replace("\n", " ")
preview_src = (preview_text or "").strip().replace("\n", " ")
preview = preview_src[:30] if preview_src else ""
full_card = {
"schema": "2.0",
@@ -1565,6 +2090,7 @@ class FeishuController:
FAILED_MSG = '{"success": false}'
SUCCESS_MSG = '{"success": true}'
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
CARD_ACTION_TYPE = "card.action.trigger"
def GET(self):
return "Feishu service start success!"
@@ -1581,15 +2107,27 @@ class FeishuController:
varify_res = {"challenge": request.get("challenge")}
return json.dumps(varify_res)
# 2.消息接收处理
# token 校验
header = request.get("header")
if not header or header.get("token") != channel.feishu_token:
# 2. Verify callbacks. Card callbacks may carry the verification
# token in event.token while message events carry it in the header.
header = request.get("header") or {}
event = request.get("event") or {}
event_type = header.get("event_type") or request.get("type")
callback_token = (
header.get("token")
or event.get("token")
or request.get("token")
)
if callback_token != channel.feishu_token:
return self.FAILED_MSG
# 处理消息事件
event = request.get("event")
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
if event_type == self.CARD_ACTION_TYPE and event:
return json.dumps(
channel._handle_card_action_event(event),
ensure_ascii=False,
)
# 3. Handle message events.
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
channel._handle_message_event(event)
return self.SUCCESS_MSG

View File

@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
self.msg_id = msg.get("message_id")
self.create_time = msg.get("create_time")
self.is_group = is_group
self.quoted_content = ""
msg_type = msg.get("message_type")
if msg_type == "text":
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
else:
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
if self.ctype == ContextType.TEXT:
self.quoted_content = self._fetch_quoted_content(msg.get("parent_id"))
self.from_user_id = sender.get("sender_id").get("open_id")
self.to_user_id = event.get("app_id")
if is_group:
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
# 私聊
self.other_user_id = self.from_user_id
self.actual_user_id = self.from_user_id
def content_with_quote(self) -> str:
"""Return user text with optional quoted-message context for the agent."""
if not self.quoted_content:
return self.content
return (
"[Quoted message]\n{}\n[/Quoted message]\n\n{}".format(
self.quoted_content,
self.content,
)
)
def _fetch_quoted_content(self, parent_id: str) -> str:
"""Fetch one parent message, degrading to an empty quote on failure."""
if not parent_id or not self.access_token:
return ""
url = "https://open.feishu.cn/open-apis/im/v1/messages/{}".format(parent_id)
headers = {"Authorization": "Bearer " + self.access_token}
try:
response = requests.get(
url=url,
headers=headers,
params={"card_msg_content_type": "raw_card_content"},
timeout=(5, 10),
)
if response.status_code != 200:
logger.warning(
"[FeiShu] quoted message fetch failed, parent_id=%s, status=%s",
parent_id,
response.status_code,
)
return ""
body = response.json()
items = (body.get("data") or {}).get("items") or []
if body.get("code") != 0 or not items:
return ""
return self._extract_quoted_text(items[0])
except Exception as exc:
logger.warning(
"[FeiShu] quoted message fetch error, parent_id=%s: %s",
parent_id,
exc,
)
return ""
@staticmethod
def _extract_quoted_text(item: dict) -> str:
msg_type = item.get("msg_type")
raw_content = (item.get("body") or {}).get("content") or ""
try:
content = json.loads(raw_content)
except (TypeError, ValueError):
return ""
if msg_type == "text":
return str(content.get("text") or "").strip()
if msg_type == "post":
# Some message-history payloads wrap post content in a locale key.
if "content" not in content:
localized = next(
(value for value in content.values() if isinstance(value, dict)),
None,
)
if localized:
content = localized
parts = []
title = str(content.get("title") or "").strip()
if title:
parts.append(title)
for block in content.get("content") or []:
if not isinstance(block, list):
continue
for element in block:
if not isinstance(element, dict):
continue
tag = element.get("tag")
text = str(element.get("text") or "").strip()
if tag == "text" and text:
parts.append(text)
elif tag == "a" and text:
href = str(element.get("href") or "").strip()
parts.append("{} ({})".format(text, href) if href else text)
elif tag == "img":
parts.append("[Image]")
return "\n".join(parts).strip()
if msg_type == "image":
return "[Image]"
if msg_type == "file":
return "[File: {}]".format(content.get("file_name") or "file")
if msg_type == "audio":
return "[Audio]"
if msg_type == "media":
return "[Video: {}]".format(content.get("file_name") or "video")
return ""

View File

@@ -0,0 +1,204 @@
"""State and Card 2.0 rendering for a Feishu agent run."""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
_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, str]] = []
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()
for tool_call in data.get("tool_calls") or []:
self.tool_steps.append(
{
"summary": _format_tool_call(tool_call),
"status": "running",
}
)
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."""
title, template = {
"running": ("Working", "blue"),
"done": ("Done", "green"),
"stopped": ("Stopped", "grey"),
"error": ("Error", "red"),
}.get(self.status, ("Working", "blue"))
main_text = self.current_text or "..."
elements: List[Dict[str, Any]] = []
if self.reasoning_steps:
elements.append(
_panel(
"Reasoning ({})".format(len(self.reasoning_steps)),
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
expanded=streaming,
)
)
elif streaming:
elements.append(
_panel("Reasoning", [_text_row("Thinking...", muted=True)], expanded=True)
)
if self.tool_steps:
elements.append(
_panel(
"Tools ({})".format(len(self.tool_steps)),
[
_text_row("{} · {}".format(step["summary"], step["status"]))
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 = "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",
}
return {
"schema": "2.0",
"config": config,
"header": {
"template": template,
"title": {"tag": "plain_text", "content": title},
},
"body": {"elements": elements},
}
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"
def _format_tool_call(tool_call: Dict[str, Any]) -> str:
# Tool arguments can contain user data or credentials. The progress card
# only needs an activity label, so never echo raw arguments into chat.
return str(tool_call.get("name") or "tool")
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
return {
"tag": "collapsible_panel",
"expanded": expanded,
"background_color": "grey",
"header": {"title": {"tag": "plain_text", "content": title}},
"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

View 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")

View 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, enabled: bool = True) -> Tuple[str, str]:
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
if enabled and 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")

View File

@@ -36,6 +36,7 @@ TELEGRAM_BOT_COMMANDS = [
("help", "Show command help"),
("status", "Show running status"),
("context", "View/clear conversation context (sub: clear)"),
("tasks", "List scheduled tasks for this chat"),
("skill", "Manage skills (list/search/install/...)"),
("memory", "Manage memory (sub: dream)"),
("knowledge", "Manage knowledge base (list/on/off)"),

View File

@@ -217,6 +217,11 @@ const I18N = {
task_delete_btn: '删除任务',
task_delete_confirm_title: '删除定时任务',
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
task_run_now: '立即执行',
task_run_confirm_title: '立即执行任务',
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '已开始执行',
task_run_failed: '执行失败',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
new_chat: '新对话',
@@ -464,6 +469,11 @@ const I18N = {
task_delete_btn: '刪除任務',
task_delete_confirm_title: '刪除定時任務',
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
task_run_now: '立即執行',
task_run_confirm_title: '立即執行任務',
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
task_run_started: '已開始執行',
task_run_failed: '執行失敗',
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
new_chat: '新對話',
@@ -710,6 +720,11 @@ const I18N = {
task_delete_btn: 'Delete Task',
task_delete_confirm_title: 'Delete Task',
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
task_run_now: 'Run now',
task_run_confirm_title: 'Run task now',
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Run started',
task_run_failed: 'Run failed',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
new_chat: 'New Chat',
@@ -1139,6 +1154,33 @@ function createMd() {
return hljsLib.highlightAuto(str).value;
}
});
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (*)
// and CJK full-width punctuation glued to a URL (common in LLM output like
// "**https://x**,中文"), turning the whole tail into one broken link. Cut
// the URL at the first such char and spill the remainder back as text.
var GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/;
md.core.ruler.after('linkify', 'fix_greedy_linkify', function(state) {
for (var b = 0; b < state.tokens.length; b++) {
var blk = state.tokens[b];
if (blk.type !== 'inline' || !blk.children) continue;
var ch = blk.children;
for (var i = 0; i < ch.length; i++) {
var open = ch[i];
if (open.type !== 'link_open' || open.markup !== 'linkify') continue;
var textTok = ch[i + 1], close = ch[i + 2];
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue;
var idx = textTok.content.search(GREEDY_LINK_CUT);
if (idx < 0) continue;
var keep = textTok.content.slice(0, idx);
var spill = textTok.content.slice(idx);
textTok.content = keep;
open.attrSet('href', keep);
var spillTok = new state.Token('text', '', 0);
spillTok.content = spill;
ch.splice(i + 3, 0, spillTok);
}
}
});
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
@@ -7908,6 +7950,38 @@ function refreshTasksView() {
btn.disabled = false;
}, 500);
}
function runTaskNow(task, button) {
showConfirmDialog({
title: t('task_run_confirm_title'),
message: `${task.name || task.id}: ${t('task_run_confirm_msg')}`,
okText: t('task_run_now'),
onConfirm: () => {
const originalHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${t('task_run_now')}`;
fetch('/api/scheduler/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({task_id: task.id})
}).then(r => r.json()).then(res => {
if (res.status !== 'success') throw new Error(res.message || t('task_run_failed'));
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('task_run_started')}`;
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 1500);
}).catch(() => {
button.innerHTML = `<i class="fas fa-triangle-exclamation mr-1"></i>${t('task_run_failed')}`;
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 2000);
});
}
});
}
function loadTasksView() {
if (tasksLoaded) return;
fetch('/api/scheduler').then(r => r.json()).then(data => {
@@ -7969,11 +8043,19 @@ function loadTasksView() {
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
<div class="flex-1"></div>
<button type="button" class="task-run-now px-2 py-1 rounded-md text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-500/10 transition-colors">
<i class="fas fa-play mr-1"></i>${t('task_run_now')}
</button>
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
</label>
</div>`;
const runButton = card.querySelector('.task-run-now');
runButton.addEventListener('click', function(e) {
e.stopPropagation();
runTaskNow(task, runButton);
});
const checkbox = card.querySelector('#' + toggleId);
checkbox.addEventListener('change', function() {
const newEnabled = this.checked;

View File

@@ -1309,6 +1309,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/run', 'SchedulerRunHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
'/api/scheduler/delete', 'SchedulerDeleteHandler',
@@ -1700,15 +1701,14 @@ class ConfigHandler:
_RECOMMENDED_MODELS = [
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
# from this web console list for now.
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
# claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
]
@@ -1747,7 +1747,7 @@ class ConfigHandler:
"api_base_key": "claude_api_base",
"api_base_default": "https://api.anthropic.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
}),
("gemini", {
"label": "Gemini",
@@ -1763,7 +1763,7 @@ class ConfigHandler:
"api_base_key": "open_ai_api_base",
"api_base_default": "https://api.openai.com/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
}),
("zhipu", {
"label": {"zh": "智谱AI", "en": "GLM"},
@@ -1795,7 +1795,7 @@ class ConfigHandler:
"api_base_key": "moonshot_base_url",
"api_base_default": "https://api.moonshot.cn/v1",
"api_base_placeholder": _PLACEHOLDER_V1,
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
}),
("qianfan", {
"label": {"zh": "百度千帆", "en": "ERNIE"},
@@ -2339,9 +2339,12 @@ class ModelsHandler:
# Anything not listed here intentionally hides the model dropdown so
# users cannot pin a chat-only model and silently get a 4xx at runtime.
_VISION_PROVIDER_MODELS = {
# OpenAI ordering matches the recommended GPT-5.4 family first, then
# OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
# GPT-5 and the GPT-4.1/4o backstops.
"openai": [
const.GPT_56_LUNA,
const.GPT_56_TERRA,
const.GPT_56_SOL,
const.GPT_55,
const.GPT_54,
const.GPT_54_MINI,
@@ -2354,7 +2357,7 @@ class ModelsHandler:
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
"moonshot": [const.KIMI_K2_6],
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
"qianfan": [const.ERNIE_45_TURBO_VL],
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
@@ -2378,6 +2381,7 @@ class ModelsHandler:
const.DOUBAO_SEED_2_1_PRO,
const.KIMI_K2_6,
const.CLAUDE_SONNET_5,
const.CLAUDE_FABLE_5,
const.GEMINI_31_FLASH_LITE_PRE,
],
# Custom OpenAI-compatible providers have no preset list — model
@@ -4534,6 +4538,34 @@ class SchedulerHandler:
return json.dumps({"status": "error", "message": str(e)})
class SchedulerRunHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
task_id = body.get("task_id")
if not task_id:
return json.dumps({"status": "error", "message": "task_id required"})
from agent.tools.scheduler.integration import get_scheduler_service
service = get_scheduler_service()
if service is None:
return json.dumps({
"status": "error",
"message": "Scheduler service is not running",
})
service.run_task_now(task_id)
return json.dumps({
"status": "success",
"message": f"Task '{task_id}' queued for immediate execution",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class SchedulerToggleHandler:
def POST(self):
_require_auth()
@@ -4607,11 +4639,33 @@ class SchedulerUpdateHandler:
# Update action
if "action" in body:
action = body["action"]
channel_type = action.get("channel_type", "web")
# Get the task's original channel_type
old_channel = original_task.get("action", {}).get("channel_type", "web")
original_action = original_task.get("action", {})
if not isinstance(original_action, dict):
original_action = {}
action_patch = body["action"]
if not isinstance(action_patch, dict):
return json.dumps({
"status": "error",
"message": "Action must be an object."
}, ensure_ascii=False)
# The Web editor only exposes a subset of action fields. Merge
# that patch into the stored action so scheduler metadata such
# as notify_session_id, silent, and channel-specific delivery
# fields survive unrelated edits.
action = dict(original_action)
action.update(action_patch)
action_type = action.get("type")
if action_type == "send_message":
action.pop("task_description", None)
action.pop("silent", None)
elif action_type == "agent_task":
action.pop("content", None)
old_channel = original_action.get("channel_type", "web")
channel_type = action.get("channel_type") or old_channel
action["channel_type"] = channel_type
# If channel type changed or no receiver, reject the update.
# Note: the web UI disables the channel selector, so this branch

View File

@@ -7,6 +7,7 @@ from cli.commands.process import start, stop, restart, self_restart, update, sta
from cli.commands.context import context
from cli.commands.install import install_browser
from cli.commands.knowledge import knowledge
from cli.commands.backup import backup_command, restore_command
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
@@ -24,6 +25,8 @@ Commands:
logs View CowAgent logs.
skill Manage CowAgent skills.
knowledge Manage knowledge base.
backup Back up config and agent workspace.
restore Restore a CowAgent backup.
install-browser Install browser tool (Playwright + Chromium).
Tip: Memory index management lives in chat — send /memory status or
@@ -74,6 +77,8 @@ main.add_command(status)
main.add_command(logs)
main.add_command(context)
main.add_command(knowledge)
main.add_command(backup_command)
main.add_command(restore_command)
main.add_command(install_browser)

332
cli/commands/backup.py Normal file
View 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']}")

View File

@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
return ret
def _is_frozen() -> bool:
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
playwright is already bundled and we only need to download the browser
binary in-process rather than pip-installing anything.
"""
return bool(getattr(sys, "frozen", False))
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
"""Invoke the Playwright CLI, working in both source and frozen builds.
Source builds shell out to ``python -m playwright <args>``. Frozen builds
can't use ``-m`` (the exe isn't a Python interpreter), so we call
Playwright's driver entrypoint in-process instead. ``env`` overrides are
applied to os.environ for the duration of the call (frozen path) or passed
through to the subprocess (source path).
"""
if not _is_frozen():
cmd = [sys.executable, "-m", "playwright"] + args
return subprocess.call(cmd, env=env)
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
# returns the Node driver shipped inside the bundle; we spawn it directly.
prev_env = {}
if env:
for k, v in env.items():
prev_env[k] = os.environ.get(k)
os.environ[k] = v
try:
from playwright._impl._driver import compute_driver_executable, get_driver_env
driver = compute_driver_executable()
# compute_driver_executable may return a tuple (node, cli.js) on newer
# Playwright, or a single path on older ones.
if isinstance(driver, (list, tuple)):
cmd = list(driver) + args
else:
cmd = [str(driver)] + args
# get_driver_env() snapshots os.environ, which we've already patched with
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
# so mirror + pinned browsers dir are honored here too.
return subprocess.call(cmd, env=get_driver_env())
except Exception as e:
# Last resort: try the module main via runpy (works if the frozen build
# kept playwright.__main__ importable).
try:
import runpy
sys.argv = ["playwright"] + args
runpy.run_module("playwright", run_name="__main__")
return 0
except SystemExit as se:
return int(se.code or 0)
except Exception:
return 1
finally:
for k, v in prev_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
"""CLI: colored click output."""
if fg == "yellow":
@@ -129,6 +192,7 @@ def run_install_browser(
stream = stream or _default_stream
python = sys.executable
legacy_mode = False
frozen = _is_frozen()
_phase(on_phase, _t(
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
@@ -159,7 +223,7 @@ def run_install_browser(
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
if sys.platform == "win32":
if sys.platform == "win32" and not frozen:
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
ret = subprocess.call(
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
@@ -172,22 +236,52 @@ def run_install_browser(
"yellow",
)
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream)
if ret != 0:
stream("Failed to install playwright package.", "red")
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
return 1
if frozen:
# Desktop bundle: playwright is already shipped inside the app; there is
# no pip and nothing to install. Skip straight to downloading Chromium.
installed = _get_installed_version()
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
_phase(on_phase, _t(
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
"✅ [1/3] Playwright is bundled in the app; skipping install.",
))
else:
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream)
if ret != 0:
stream("Failed to install playwright package.", "red")
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
return 1
installed = _get_installed_version()
if installed:
stream(f" playwright {installed} installed.", "green")
stream("")
_phase(on_phase, _t(
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
))
installed = _get_installed_version()
if installed:
stream(f" playwright {installed} installed.", "green")
stream("")
_phase(on_phase, _t(
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
))
# With playwright available, prefer the user's system Chrome/Edge: the browser
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
# source) — only headless Linux servers, which usually lack a system browser,
# fall through to the download below. Honors prefer_system_browser via
# resolve_engine, so users who force downloaded Chromium still get it.
try:
from agent.tools.browser import browser_env
summary = browser_env.capability_summary()
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
sc = summary.get("system_chrome") or {}
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
_phase(on_phase, _t(
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium浏览器工具已就绪。",
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
))
return 0
except Exception as e:
stream(f" (system browser probe skipped: {e})", None)
if sys.platform == "linux":
_phase(on_phase, _t(
@@ -195,7 +289,7 @@ def run_install_browser(
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
))
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
ret = _playwright_cli(["install-deps", "chromium"])
if ret != 0:
stream(
" Could not auto-install system deps (may need sudo).\n"
@@ -238,12 +332,12 @@ def run_install_browser(
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
))
stream("[3/3] Installing Chromium browser...", "yellow")
cmd = [python, "-m", "playwright", "install", "chromium"]
pw_args = ["install", "chromium"]
if _is_headless_linux() and not legacy_mode:
ver = _version_tuple(installed or "")
if ver >= (1, 57, 0):
cmd.append("--only-shell")
pw_args.append("--only-shell")
stream(" (headless shell for Linux server)", None)
else:
stream(" (full Chromium)", None)
@@ -251,6 +345,15 @@ def run_install_browser(
stream(" (full browser for Linux desktop)", None)
env = os.environ.copy()
# Pin the download location so it survives desktop app updates and matches
# what the runtime looks up (see browser_env.browsers_download_dir()).
try:
from agent.tools.browser.browser_env import browsers_download_dir
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
except Exception:
pass
use_mirror = _is_china_network()
if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
@@ -260,7 +363,7 @@ def run_install_browser(
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
))
ret = subprocess.call(cmd, env=env)
ret = _playwright_cli(pw_args, env=env)
if ret != 0 and use_mirror:
stream(" Mirror download failed, retrying with official CDN...", "yellow")
@@ -268,9 +371,9 @@ def run_install_browser(
"⚠️ 镜像下载失败,正在改用官方源重试…",
"⚠️ Mirror download failed; retrying with the official CDN…",
))
env_no_mirror = os.environ.copy()
env_no_mirror = dict(env)
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
ret = subprocess.call(cmd, env=env_no_mirror)
ret = _playwright_cli(pw_args, env=env_no_mirror)
if ret != 0:
stream("Failed to install Chromium.", "red")
@@ -282,10 +385,18 @@ def run_install_browser(
stream("Verifying browser installation...", None)
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL,
)
if frozen:
# Frozen: no child interpreter to spawn; import in-process instead.
try:
from playwright.sync_api import sync_playwright # noqa: F401
ret = 0
except Exception:
ret = 1
else:
ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL,
)
if ret != 0:
stream(
" Warning: playwright import failed. Browser tool may not work on this system.\n"

View File

@@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
CLAUDE_4_OPUS = "claude-opus-4-0"
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
@@ -80,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
GPT_54_MINI = "gpt-5.4-mini"
GPT_54_NANO = "gpt-5.4-nano"
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
O1 = "o1-preview"
O1_MINI = "o1-mini"
WHISPER_1 = "whisper-1"
@@ -139,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
# Kimi (Moonshot)
MOONSHOT = "moonshot"
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
KIMI_K2 = "kimi-k2"
KIMI_K2_5 = "kimi-k2.5"
@@ -200,7 +204,7 @@ MODEL_LIST = [
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
# Claude
CLAUDE_SONNET_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
CLAUDE_SONNET_5, CLAUDE_FABLE_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
@@ -214,6 +218,7 @@ MODEL_LIST = [
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
GPT_41, GPT_41_MINI, GPT_41_NANO,
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
GPT_5, GPT_5_MINI, GPT_5_NANO,
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
O1, O1_MINI,
@@ -231,7 +236,7 @@ MODEL_LIST = [
# Kimi (Moonshot)
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
# ModelScope
MODELSCOPE,

View File

@@ -28,6 +28,8 @@
"feishu_app_id": "",
"feishu_app_secret": "",
"feishu_stream_reply": true,
"feishu_markdown_card": true,
"feishu_progress_card": false,
"dingtalk_client_id": "",
"dingtalk_client_secret": "",
"wecom_bot_id": "",

View File

@@ -178,6 +178,8 @@ available_setting = {
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
"feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure
"feishu_progress_card": False, # render normal chat streaming as a rich progress card (status header, reasoning/tool panels, elapsed time); off by default keeps the plain typewriter card
# DingTalk config
"dingtalk_client_id": "", # DingTalk bot Client ID
"dingtalk_client_secret": "", # DingTalk bot Client Secret

View File

@@ -105,6 +105,14 @@ hiddenimports += collect_submodules('docx')
hiddenimports += collect_submodules('pptx')
hiddenimports += collect_submodules('openpyxl')
# Playwright powers the browser tool. Only the pure-Python package + its bundled
# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled
# and is either satisfied by the user's system Chrome/Edge (preferred, zero
# download) or downloaded on demand into ~/.cow/ms-playwright at first use.
# Playwright imports its transport/driver lazily, so list submodules explicitly.
hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl']
hiddenimports += collect_submodules('playwright')
# --- Data files -----------------------------------------------------------
# Runtime-read files/dirs that must travel with the executable. Paths are
# (source, dest_dir_in_bundle).
@@ -134,6 +142,12 @@ datas += collect_data_files('tiktoken_ext', include_py_files=False)
datas += collect_data_files('docx')
datas += collect_data_files('pptx')
# Playwright ships its Node.js driver + package.json under playwright/driver/.
# These are NOT Python modules, so hiddenimports won't pull them in — collect
# them as data or `playwright install` / launching fails in the frozen build.
# include_py_files=True is required: the driver dir contains .py entrypoints.
datas += collect_data_files('playwright', include_py_files=True)
# --- Excludes -------------------------------------------------------------
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
# mode), tests/docs, and dev-only packages.
@@ -143,7 +157,10 @@ excludes = [
'pip',
'wheel',
'pytest',
'playwright', # browser tool is opt-in, not bundled
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
# so the browser tool works out of the box on desktop. The heavy Chromium
# binary is still NOT bundled: it comes from the user's system Chrome/Edge or
# is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py.
]
block_cipher = None

View File

@@ -42,6 +42,12 @@ python-docx
openpyxl
python-pptx
# ---- browser tool ----
# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB).
# The Chromium binary is NOT bundled: the browser tool drives the user's system
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
playwright==1.52.0
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
wechatpy
pycryptodome

View 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

View File

@@ -1,5 +1,6 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs'
import os from 'os'
import path from 'path'
// electron-updater is CommonJS: its members live on module.exports, with no
// meaningful default export. Under module=commonjs + esModuleInterop, a named
@@ -19,12 +20,26 @@ export type UpdateStatus =
let getWindow: () => BrowserWindow | null = () => null
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
// update to OTHER legacy builds — never the standard build (Electron 33 won't
// launch on Win7). The update Function serves that build under /update/legacy/.
// We detect the old OS at runtime (os.release() reports the Windows NT version:
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
// flag, so the same source serves the right feed on whatever it runs on.
function isLegacyWindows(): boolean {
if (process.platform !== 'win32') return false
const major = Number((os.release() || '').split('.')[0])
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
return Number.isFinite(major) && major < 10
}
// The update feed. Both entries hit the same Pages Function
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
// downloads to the China CDN mirror instead of R2. The feed metadata is
// identical either way, so we can freely switch the feed URL between attempts
// to fall back from one download origin to the other.
const FEED_BASE = 'https://cowagent.ai/update/'
// to fall back from one download origin to the other. Legacy Windows appends a
// /legacy/ segment so it gets the win-legacy release instead of the standard.
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
// Which origin the current session prefers, derived from the app UI language

View File

@@ -372,6 +372,13 @@ class ApiClient {
return data.tasks
}
async runTask(taskId: string): Promise<ApiResult> {
return this.request('/api/scheduler/run', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/toggle', {
method: 'POST',

View File

@@ -53,6 +53,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
{ cmd: '/knowledge', desc: t('slash_knowledge') },
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
{ cmd: '/install-browser', desc: t('slash_install_browser') },
{ cmd: '/config', desc: t('slash_config') },
{ cmd: '/cancel', desc: t('slash_cancel') },
{ cmd: '/logs', desc: t('slash_logs') },

View File

@@ -30,6 +30,34 @@ const md: MarkdownIt = new MarkdownIt({
},
})
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`)
// and CJK full-width punctuation glued to a URL (common in LLM output like
// `**https://x**,中文`), turning the whole tail into one broken link. Cut the
// URL at the first such char and spill the remainder back as plain text.
const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/
md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => {
for (const blk of state.tokens) {
if (blk.type !== 'inline' || !blk.children) continue
const ch = blk.children
for (let i = 0; i < ch.length; i++) {
const open = ch[i]
if (open.type !== 'link_open' || open.markup !== 'linkify') continue
const textTok = ch[i + 1]
const close = ch[i + 2]
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue
const idx = textTok.content.search(_GREEDY_LINK_CUT)
if (idx < 0) continue
const keep = textTok.content.slice(0, idx)
const spill = textTok.content.slice(idx)
textTok.content = keep
open.attrSet('href', keep)
const spillTok = new state.Token('text', '', 0)
spillTok.content = spill
ch.splice(i + 3, 0, spillTok)
}
}
})
// Open links in a new tab safely.
const defaultLinkOpen =
md.renderer.rules.link_open ||
@@ -72,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
interface MarkdownProps {
content: string
/**
* Intercept clicks on internal document links (relative `.md` hrefs). When
* provided, such links open in-app instead of being handed to the OS. Used by
* the knowledge viewer so index links open the target doc rather than firing
* an "application cannot be opened (-120)" error in Electron.
*/
onInternalLink?: (href: string) => void
}
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
const rootRef = useRef<HTMLDivElement>(null)
const html = useMemo(() => md.render(content || ''), [content])
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const btn = target.closest('.code-copy-btn') as HTMLElement | null
if (!btn) return
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
if (!pre) return
navigator.clipboard.writeText(pre.textContent || '')
const original = btn.textContent
btn.textContent = t('msg_copied')
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = original
btn.classList.remove('copied')
}, 1600)
}, [])
// Delegate clicks: copy buttons on code blocks, and internal doc links.
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
// Internal knowledge links (relative *.md), when a handler is provided.
if (onInternalLink) {
const a = target.closest('a') as HTMLAnchorElement | null
if (a) {
const href = a.getAttribute('href') || ''
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
e.preventDefault()
onInternalLink(href)
return
}
}
}
const btn = target.closest('.code-copy-btn') as HTMLElement | null
if (!btn) return
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
if (!pre) return
navigator.clipboard.writeText(pre.textContent || '')
const original = btn.textContent
btn.textContent = t('msg_copied')
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = original
btn.classList.remove('copied')
}, 1600)
},
[onInternalLink]
)
return (
<div

View File

@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
muted, separated from the final answer by a dashed divider. */}
{(hasSteps || hasLiveReasoning) && (
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
{hasSteps && <MessageSteps steps={message.steps!} />}
{/* Live reasoning is the current, not-yet-committed thinking, so it
must render after all committed steps (tools/thinking), not at
the very top of the bubble. */}
{hasLiveReasoning && (
<div className={hasSteps ? 'mt-1' : ''}>
<ThinkingStep content={message.reasoning!} streaming />
</div>
)}
</div>
)}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
import type { MessageStep } from '../types'
import { t } from '../i18n'
import Markdown from './Markdown'
/**
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<Brain size={12} className="flex-shrink-0" />
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
</div>
{expanded && (

View File

@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
msg_cancelled: '已中止',
msg_self_learned: '自主学习',
msg_stop: '停止',
thinking_in_progress: '思考中…',
thinking_done: '已深度思考',
chat_clear_context: '清除上下文',
context_cleared: '— 以上内容已从上下文中移除 —',
chat_load_earlier: '加载更早的消息',
chat_send: '发送',
chat_attach: '添加附件',
slash_hint: '输入 / 查看命令',
chat_welcome: '有什么可以帮你的?',
chat_empty_hint: '发送一条消息开始对话',
welcome_subtitle: '我可以帮你解问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
welcome_subtitle: '我可以帮你解问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理',
example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '定时任务',
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
channels_connected_section: '已连接',
channels_available_section: '可添加',
channels_empty_connected: '暂无已连接的通道',
channels_empty: '暂未接入任何通道',
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
channels_save_ok: '已保存',
channels_save_error: '保存失败',
@@ -347,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: '删除',
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
task_save_error: '保存失败',
task_run_now: '立即执行',
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '任务已开始执行',
task_run_error: '执行失败',
logs_title: '日志',
logs_desc: '实时日志输出 (run.log)',
logs_live: '实时',
@@ -376,6 +385,7 @@ const translations: Record<string, Record<string, string>> = {
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
slash_knowledge: '查看知识库统计',
slash_knowledge_list: '查看知识库文件树',
slash_install_browser: '安装浏览器工具',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_logs: '查看最近日志',
@@ -511,7 +521,10 @@ const translations: Record<string, Record<string, string>> = {
msg_cancelled: 'Cancelled',
msg_self_learned: 'Self-learned',
msg_stop: 'Stop',
thinking_in_progress: 'Thinking…',
thinking_done: 'Thought',
chat_clear_context: 'Clear context',
context_cleared: '— Context above has been cleared —',
chat_load_earlier: 'Load earlier messages',
chat_send: 'Send',
chat_attach: 'Attach file',
@@ -670,6 +683,8 @@ const translations: Record<string, Record<string, string>> = {
channels_connected_section: 'Connected',
channels_available_section: 'Available',
channels_empty_connected: 'No connected channels yet',
channels_empty: 'No channels connected',
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
channels_save_ok: 'Saved',
channels_save_error: 'Failed to save',
@@ -729,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: 'Delete',
task_delete_confirm: 'Delete this task? This cannot be undone.',
task_save_error: 'Failed to save',
task_run_now: 'Run now',
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Task run started',
task_run_error: 'Failed to run task',
logs_title: 'Logs',
logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live',
@@ -758,6 +777,7 @@ const translations: Record<string, Record<string, string>> = {
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
slash_knowledge: 'Show knowledge base stats',
slash_knowledge_list: 'Show knowledge base file tree',
slash_install_browser: 'Install browser tool',
slash_config: 'Show current config',
slash_cancel: 'Abort the running agent task',
slash_logs: 'Show recent logs',

View File

@@ -27,21 +27,21 @@
--danger-border: rgba(239, 68, 68, 0.3);
--info: #3b82f6;
/* Light theme — layered neutral surfaces */
--bg-base: #fafafa; /* app background */
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
--bg-base: #f9fafb; /* app background (gray-50) */
--bg-surface: #ffffff; /* panels, cards */
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
--bg-elevated: #ffffff; /* popovers, menus, modals */
--bg-inset: #f4f4f5; /* inputs, code blocks */
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
--text-secondary: #52525b; /* body, labels */
--text-tertiary: #71717a; /* hints, captions */
--text-disabled: #a1a1aa;
--text-primary: #1e293b; /* headings, primary text (slate-800) */
--text-secondary: #475569; /* body, labels (slate-600) */
--text-tertiary: #64748b; /* hints, captions (slate-500) */
--text-disabled: #94a3b8; /* slate-400 */
--border-default: #e4e4e7;
--border-strong: #d4d4d8;
--border-subtle: #f0f0f1;
--border-default: #e5e7eb; /* gray-200 (web console border) */
--border-strong: #d1d5db; /* gray-300 */
--border-subtle: #f3f4f6; /* gray-100 */
--overlay: rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);

View File

@@ -13,6 +13,7 @@ import {
Headset,
Hash,
AtSign,
RadioTower,
} from 'lucide-react'
import { t, localizedLabel } from '../i18n'
import apiClient from '../api/client'
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
) : (
<div className="space-y-3">
{connected.length === 0 && !addOpen ? (
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
<RadioTower size={26} className="text-info" />
</span>
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
{t('channels_empty_desc')}
</p>
{available.length > 0 && (
<div className="mt-5">
<Btn variant="primary" onClick={openAdd}>
<span className="flex items-center gap-1.5">
<Plus size={15} />
{t('channels_add')}
</span>
</Btn>
</div>
)}
</div>
) : (
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
)}

View File

@@ -17,6 +17,7 @@ import apiClient from '../api/client'
import type { Attachment, ChatMessage } from '../types'
import { useChatStore } from '../store/chatStore'
import { useSessionStore } from '../store/sessionStore'
import { useUIStore } from '../store/uiStore'
interface ChatPageProps {
baseUrl: string
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const deleteMessage = useChatStore((s) => s.deleteMessage)
const loadHistory = useChatStore((s) => s.loadHistory)
const ensureSession = useChatStore((s) => s.ensureSession)
const clearContext = useChatStore((s) => s.clearContext)
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
const messages = session?.messages ?? []
const isStreaming = session?.isStreaming ?? false
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const id = newSession()
ensureSession(id)
loadHistory(id, 1)
}, [newSession, ensureSession, loadHistory])
// Auto-expand the session list so the user sees the new/switched session.
setSessionsCollapsed(false)
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
const handleClearContext = useCallback(async () => {
try {
await apiClient.clearContext(activeId)
} catch {
/* ignore */
}
}, [activeId])
await clearContext(activeId)
scrollToBottom(true)
}, [clearContext, activeId, scrollToBottom])
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
{t('welcome_subtitle')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
</div>
) : (
<div className="py-3 max-w-3xl mx-auto">
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onMediaLoad={handleMediaLoad}
/>
))}
{messages.map((msg) =>
msg.kind === 'divider' ? (
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
<span
className="flex-1 h-px"
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
/>
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
<span
className="flex-1 h-px"
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
/>
</div>
) : (
<MessageBubble
key={msg.id}
message={msg}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onMediaLoad={handleMediaLoad}
/>
)
)}
<div ref={bottomRef} />
</div>
)}

View File

@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// The viewer already shows the doc title above the body, so a leading `# H1`
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
// right after it) when it matches the title; leave the body untouched otherwise.
function stripDuplicateH1(content: string, title: string): string {
if (!content) return content
const norm = (s: string) => s.trim().toLowerCase()
// Skip a leading blank/whitespace region, then match the first `# heading`.
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
if (!m) return content
if (norm(m[1]) !== norm(title)) return content
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
}
// Flatten the tree into category paths (for destination selectors).
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
const paths: string[] = []
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
return null
}
// Find a document by its bare filename anywhere in the tree (root files first,
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
for (const f of list.root_files || []) {
if (f.name === filename) return { path: f.name, title: f.title || f.name }
}
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
for (const f of dir.files) {
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
}
for (const c of dir.children) {
const hit = walk(c, dirPath)
if (hit) return hit
}
return null
}
for (const d of list.tree || []) {
const hit = walk(d, '')
if (hit) return hit
}
return null
}
// Resolve a relative `.md` link (from a document body) into a knowledge path.
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
const clean = href.split('#')[0].split('?')[0]
if (!clean.endsWith('.md')) return null
if (clean.startsWith('knowledge/')) {
const path = clean.replace(/^knowledge\//, '')
return { path, title: findTitle(list, path) }
}
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
return { path: clean, title: findTitle(list, clean) }
}
// Relative/other path: fall back to matching by filename.
const filename = clean.split('/').pop() || clean
return findFileByName(list, filename)
}
// Resolve a document's display title from its path, falling back to the stem.
function findTitle(list: KnowledgeList, path: string): string {
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
setContent('')
try {
const res = await apiClient.readKnowledge(path)
setContent(res.content || '')
setContent(stripDuplicateH1(res.content || '', title))
} catch {
setContent(`> ${t('knowledge_doc_load_error')}`)
} finally {
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
}
}, [])
// Open an internal knowledge link (relative `.md`) from within a doc body.
// Falls back silently when the target can't be resolved in the current tree.
const openInternalLink = useCallback(
(href: string) => {
if (!data) return
const hit = resolveKnowledgeLink(data, href)
if (hit) void openDoc(hit.path, hit.title)
},
[data, openDoc]
)
// Reload the tree. When targetPath is given, open it; otherwise keep the
// currently open doc (or open the first one on the initial load).
const refresh = useCallback(
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
<Loader2 size={16} className="animate-spin mr-2" />
</div>
) : (
<Markdown content={content} />
<Markdown content={content} onInternalLink={openInternalLink} />
)}
</div>
)}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { Loader2, Clock, CalendarClock } from 'lucide-react'
import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import apiClient from '../api/client'
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
const [saving, setSaving] = useState(false)
const [running, setRunning] = useState(false)
const [runStatus, setRunStatus] = useState('')
const [error, setError] = useState('')
const buildSchedule = (): TaskSchedule => {
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
}
}
const runNow = async () => {
if (!window.confirm(t('task_run_confirm'))) return
setRunning(true)
setRunStatus('')
setError('')
try {
const result = await apiClient.runTask(task.id)
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
setRunStatus(t('task_run_started'))
} catch (e) {
setError(e instanceof Error ? e.message : t('task_run_error'))
} finally {
setRunning(false)
}
}
return (
<Modal
open
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
{t('task_delete')}
</Btn>
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
{t('task_run_now')}
</Btn>
<Btn variant="ghost" onClick={onClose} disabled={saving}>
{t('task_cancel')}
</Btn>
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
)}
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
</Modal>
)

View File

@@ -31,6 +31,7 @@ interface ChatState {
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
loadHistory: (sid: string, page?: number) => Promise<void>
clearContext: (sid: string) => Promise<boolean>
clearLocal: (sid: string) => void
}
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
cancel: async (sid) => {
const s = get().sessions[sid]
if (!s?.requestId) return
// Optimistically stop the UI right away: mark the last assistant bubble
// cancelled, free the input, and tear down the local SSE stream so no
// further deltas render after the user hit stop. The backend still gets
// the cancel request to abort the running agent task.
patchMessages(sid, (msgs) => {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'assistant') {
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
break
}
}
return [...msgs]
})
patchSession(sid, { isStreaming: false, requestId: null })
const es = streams[sid]
if (es) {
es.close()
delete streams[sid]
}
try {
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
} catch {
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
}
},
clearContext: async (sid) => {
try {
const res = await apiClient.clearContext(sid)
if (res.status !== 'success') return false
// Append a visual divider so the user sees the context was cleared
// (mirrors the web console's context-divider).
patchMessages(sid, (msgs) => [
...msgs,
{
id: uid('divider'),
role: 'system',
kind: 'divider',
content: '',
timestamp: Date.now() / 1000,
},
])
return true
} catch {
return false
}
},
clearLocal: (sid) => {
const es = streams[sid]
if (es) {

View File

@@ -12,6 +12,7 @@ interface UIState {
/** Session list panel collapsed (hidden) vs expanded. */
sessionsCollapsed: boolean
toggleSessions: () => void
setSessionsCollapsed: (v: boolean) => void
/** Currently active session id (Chat page). */
activeSessionId: string | null
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
return { sessionsCollapsed: next }
}),
setSessionsCollapsed: (v) => {
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
set({ sessionsCollapsed: v })
},
activeSessionId: null,
setActiveSessionId: (id) => set({ activeSessionId: id }),

View File

@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
// Chat / messages / streaming
// ============================================================
export type Role = 'user' | 'assistant'
export type Role = 'user' | 'assistant' | 'system'
/** A single ordered step inside an assistant turn (matches backend history). */
export interface MessageStep {
@@ -83,8 +83,8 @@ export interface ChatMessage {
/** Sequence numbers from backend (for delete/regenerate). */
userSeq?: number
botSeq?: number
/** Self-evolution bubble flag. */
kind?: 'evolution'
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
kind?: 'evolution' | 'divider'
extras?: Record<string, unknown>
isStreaming?: boolean
isCancelled?: boolean

View File

@@ -64,7 +64,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
"channel_type": "feishu",
"feishu_app_id": "YOUR_APP_ID",
"feishu_app_secret": "YOUR_APP_SECRET",
"feishu_stream_reply": true
"feishu_stream_reply": true,
"feishu_markdown_card": true
}
```
@@ -73,6 +74,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| `feishu_app_id` | Feishu app App ID | - |
| `feishu_app_secret` | Feishu app App Secret | - |
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
| `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` |
| `feishu_progress_card` | Use a rich progress card (status header, reasoning/tool panels, elapsed time) for normal chat streaming; off keeps the plain typewriter card | `false` |
</Tab>
</Tabs>
@@ -84,7 +87,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
3. (Optional) Under **Callbacks**, add **Card Action Trigger** (`card.action.trigger`) to use the `/tasks` controls. Only needed for `/tasks` scheduler management; skip it otherwise.
4. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
@@ -97,7 +102,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| Text messages | ✅ send/receive |
| Image messages | ✅ send/receive |
| Voice messages | ✅ send/receive |
| Quoted replies | ✅ quoted text and rich-post context |
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
| Rich progress card | ✅ status header, reasoning/tool panels and elapsed time (enable with `feishu_progress_card: true`, off by default) |
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
<Note>
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
@@ -108,3 +117,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
After connection, search for the bot name in Feishu to start a chat.
To use in groups, add the bot to a group and @-mention it.
Send `/tasks` in a private chat, or @-mention the bot with `/tasks` in a group, to manage tasks belonging to that chat.

51
docs/cli/backup.mdx Normal file
View 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.

View File

@@ -44,6 +44,10 @@ Memory & Knowledge:
memory Memory distillation (dream)
knowledge View knowledge base stats and structure
Data portability:
backup Back up config and agent workspace
restore Restore a CowAgent backup
Others:
help Show this help message
version Show version
@@ -90,6 +94,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
| start / stop / restart | ✓ | ✗ |
| update | ✓ | ✗ |
| install-browser | ✓ | ✗ |
| backup / restore | ✓ | ✗ |
<Note>
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.

View File

@@ -246,6 +246,7 @@
"cli/process",
"cli/skill",
"cli/memory-knowledge",
"cli/backup",
"cli/general"
]
}
@@ -473,6 +474,7 @@
"zh/cli/process",
"zh/cli/skill",
"zh/cli/memory-knowledge",
"zh/cli/backup",
"zh/cli/general"
]
}

View File

@@ -1,10 +1,10 @@
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント" /></a>
</p>
<p align="center">
@@ -106,14 +106,14 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
@@ -230,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
<br/>

View File

@@ -61,7 +61,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
"channel_type": "feishu",
"feishu_app_id": "YOUR_APP_ID",
"feishu_app_secret": "YOUR_APP_SECRET",
"feishu_stream_reply": true
"feishu_stream_reply": true,
"feishu_markdown_card": true
}
```
@@ -70,6 +71,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| `feishu_app_id` | Feishu アプリの App ID | - |
| `feishu_app_secret` | Feishu アプリの App Secret | - |
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
| `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` |
| `feishu_progress_card` | 通常会話のストリーミングをリッチ進捗カード(ステータス、推論/ツールパネル、経過時間)で表示。無効時は通常のタイプライターカード | `false` |
</Tab>
</Tabs>
@@ -81,7 +84,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
3. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
3. (任意)**コールバック** で **カードアクショントリガー**`card.action.trigger`)を追加すると `/tasks` の操作ボタンを使用できます。`/tasks` のスケジューラー管理を使う場合のみ必要で、それ以外はスキップできます。
4. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
@@ -94,7 +99,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| テキストメッセージ | ✅ 送受信 |
| 画像メッセージ | ✅ 送受信 |
| 音声メッセージ | ✅ 送受信 |
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
| ストリーミング応答 | ✅Feishu cardkit ストリーミングカードベース) |
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
<Note>
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
@@ -105,3 +114,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
個人チャットでは `/tasks`、グループでは Bot に @メンションして `/tasks` を送ると、そのチャットのタスクを管理できます。

View File

@@ -20,7 +20,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
| パラメータ | 説明 |
| --- | --- |
| `model` | `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
| `model` | `claude-sonnet-5`、`claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
@@ -29,6 +29,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
| モデル | 用途 |
| --- | --- |
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
| `claude-fable-5` | Claude 5 シリーズのもう一つのフラッグシップモデル |
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |

View File

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

View File

@@ -3,7 +3,7 @@ title: Kimi
description: KimiMoonshotモデル設定テキスト対話 + 画像理解)
---
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k3` と `kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
<Tip>
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
```json
{
"model": "kimi-k2.7-code",
"model": "kimi-k3",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| パラメータ | 説明 |
| --- | --- |
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
| `model` | `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |

View File

@@ -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` など。
## 画像生成

View File

@@ -14,7 +14,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
```json
{
"model": "gpt-5.5",
"model": "gpt-5.6-luna",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.openai.com/v1"
}
@@ -22,7 +22,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
| パラメータ | 説明 |
| --- | --- |
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1`、o シリーズなどをサポート。Agent モードのデフォルトは `gpt-5.5`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1` などをサポート。Agent モードのデフォルトは `gpt-5.6-luna`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |

View File

@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
2. ブラウザToolは依存関係が大きい約300MBため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
</Note>
<Note>
**デスクトップクライアント利用者**playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
</Note>
## ワークフロー
Agentがブラウザを使う典型的な流れ
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ
}
```
## ブラウザエンジン
ブラウザエンジンは自動選択され、設定は不要です:
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
## ログイン状態の永続化
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります

View File

@@ -20,7 +20,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
| Parameter | Description |
| --- | --- |
| `model` | Supports `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `model` | Supports `claude-sonnet-5`, `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
@@ -29,6 +29,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
| Model | Use Case |
| --- | --- |
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
| `claude-fable-5` | Alternative flagship in the Claude 5 family |
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
| `claude-opus-4-7` | Earlier Opus flagship |
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |

View File

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

View File

@@ -3,7 +3,7 @@ title: Kimi
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
---
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision.
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k3` and `kimi-k2.x` series natively support vision.
<Tip>
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
```json
{
"model": "kimi-k2.7-code",
"model": "kimi-k3",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| Parameter | Description |
| --- | --- |
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
| `model` | Can be `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |

View File

@@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v
}
```
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-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

View File

@@ -14,7 +14,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
```json
{
"model": "gpt-5.5",
"model": "gpt-5.6-luna",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.openai.com/v1"
}
@@ -22,7 +22,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
| Parameter | Description |
| --- | --- |
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, etc. Agent mode defaults to `gpt-5.6-luna`; use `gpt-5.4` for better cost-efficiency |
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |

View File

@@ -49,6 +49,12 @@ Control a Chromium browser for web navigation, element interaction and content e
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
</Note>
<Note>
**Desktop client users**: playwright is bundled in the installer, no separate install needed. On first use of the browser tool:
- If **Google Chrome / Edge** is installed, it drives the system browser directly with **no download** (recommended);
- Otherwise, send `/install-browser` in chat to download a lightweight browser engine into `~/.cow`.
</Note>
## Workflow
A typical browser workflow for the Agent:
@@ -105,6 +111,15 @@ You can override it in `config.json`:
}
```
## Browser Engine
The browser engine is selected automatically, no configuration needed:
1. If **Google Chrome / Edge** is detected on the machine, it drives the system browser directly, with **no Chromium download**, using real browser fingerprints;
2. Otherwise it falls back to the Chromium engine downloaded into `~/.cow` via `install-browser`.
Both use the persistent login below and behave identically.
## Persistent Login
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:

View File

@@ -35,6 +35,17 @@ Create and manage scheduled tasks with natural language:
- "Remind me about the meeting tomorrow at 3 PM"
- "Show all scheduled tasks"
## Test-run an existing task
Open the task in the authenticated Web console or Desktop app and click **Run
now**. After you confirm the delivery, CowAgent queues one immediate execution
to the task's configured channel and receiver. This user-initiated action is not
exposed to the agent's scheduler tool.
Manual execution works for disabled tasks and does not enable, delete, or
reschedule the task. Its original `next_run_at` remains unchanged. A task cannot
be run manually while the same task is already executing on schedule.
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
</Frame>

View File

@@ -1,10 +1,10 @@
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件" /></a>
</p>
<p align="center">
@@ -108,13 +108,13 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -233,7 +233,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
掃碼加入微信開源交流群:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
也可透過以下方式獲取支援:
@@ -252,7 +252,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
## 🏢 企業服務
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
>
@@ -262,7 +262,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
**產品諮詢和企業服務** 可聯絡產品客服:
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
<br/>

View File

@@ -1,10 +1,10 @@
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
<p align="center">
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档"></a>
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档" /></a>
</p>
<p align="center">
@@ -108,13 +108,13 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -233,7 +233,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
扫码加入微信开源交流群:
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
也可通过以下方式获取支持:
@@ -252,7 +252,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
## 🏢 企业服务
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
>
@@ -262,7 +262,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
**产品咨询和企业服务** 可联系产品客服:
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
<br/>

View File

@@ -65,7 +65,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
"channel_type": "feishu",
"feishu_app_id": "YOUR_APP_ID",
"feishu_app_secret": "YOUR_APP_SECRET",
"feishu_stream_reply": true
"feishu_stream_reply": true,
"feishu_markdown_card": true
}
```
@@ -74,6 +75,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| `feishu_app_id` | 飞书应用 App ID | - |
| `feishu_app_secret` | 飞书应用 App Secret | - |
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
| `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` |
| `feishu_progress_card` | 普通对话流式回复是否使用富进度卡片(状态头、思考/工具面板、耗时);关闭时用普通打字机卡片 | `false` |
</Tab>
</Tabs>
@@ -85,7 +88,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
3. (可选)在 **回调** 中添加 **卡片回传交互**`card.action.trigger`),以启用 `/tasks` 操作按钮。仅使用 `/tasks` 定时任务管理时需要,否则可跳过。
4. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
@@ -98,7 +103,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
| 文本消息 | ✅ 收发 |
| 图片消息 | ✅ 收发 |
| 语音消息 | ✅ 收发 |
| 引用回复 | ✅ 引用文本与富文本上下文 |
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
<Note>
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
@@ -109,3 +118,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
私聊发送 `/tasks`,或在群聊中 @机器人并发送 `/tasks`,即可管理属于当前会话的定时任务。

45
docs/zh/cli/backup.mdx Normal file
View 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` 确认覆盖。

View File

@@ -44,6 +44,10 @@ Memory & Knowledge:
memory Memory distillation (dream)
knowledge View knowledge base stats and structure
Data portability:
backup Back up config and agent workspace
restore Restore a CowAgent backup
Others:
help Show this help message
version Show version
@@ -92,6 +96,7 @@ Others:
| start / stop / restart | ✓ | ✗ |
| update | ✓ | ✗ |
| install-browser | ✓ | ✗ |
| backup / restore | ✓ | ✗ |
<Note>
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。

View File

@@ -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_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
@@ -29,6 +29,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
| 模型 | 适用场景 |
| --- | --- |
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
| `claude-fable-5` | Claude 5 系列的另一款旗舰模型 |
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |

View File

@@ -14,13 +14,13 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
| [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 | ✅ | ✅ | ✅ | | | |
| [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 | ✅ | ✅ | | ✅ | | ✅ |
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| [豆包 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 | ✅ | ✅ | | | | |
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

@@ -3,7 +3,7 @@ title: Kimi
description: KimiMoonshot模型配置文本对话 + 图像理解)
---
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k3` 与 `kimi-k2.x` 系列原生支持视觉。
<Tip>
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
```json
{
"model": "kimi-k2.7-code",
"model": "kimi-k3",
"moonshot_api_key": "YOUR_API_KEY"
}
```
| 参数 | 说明 |
| --- | --- |
| `model` | 可填 `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
| `model` | 可填 `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |

View File

@@ -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` 等。
## 图像生成

View File

@@ -14,7 +14,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
```json
{
"model": "gpt-5.5",
"model": "gpt-5.6-luna",
"open_ai_api_key": "YOUR_API_KEY",
"open_ai_api_base": "https://api.openai.com/v1"
}
@@ -22,7 +22,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
| 参数 | 说明 |
| --- | --- |
| `model` | 与 OpenAI 接口的 [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_base` | 可选,修改可接入第三方代理 |
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |

View File

@@ -0,0 +1,54 @@
---
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)
## 🖥 桌面客户端
在 2.1.3 正式推出桌面客户端后,本次围绕浏览器能力、系统兼容与使用体验做了进一步打磨。
- **浏览器复用系统 Chrome/Edge**:内置浏览器工具优先复用系统已安装的 Chrome / Edge同时为客户端内置 Playwright无需额外手动安装浏览器即可使用网页访问、信息检索等能力。
- **浏览器访问提速**:优化页面导航流程,网页打开与内容抓取更快更稳。
- **新增 Win7 客户端**:新增对 Windows 7 等旧版系统的客户端支持,覆盖更多使用环境。
- **Windows 代码签名**Windows 安装包新增代码签名,降低安装时的安全告警。
- **知识库文档跳转修复**:修复桌面端知识库文档内链接无法正常跳转的问题。
- **界面样式优化**:优化对话界面、消息气泡、工具调用步骤与通道页面的视觉与交互细节。
下载地址:[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)
## ⏰ 定时任务静默模式
定时任务新增 **静默模式**任务在后台执行时不再主动推送中间过程仅在需要时输出结果避免打扰。同时修正了对消息投递类任务的误判让任务标记更准确。Thanks @AaronZ345 (#2954)
此外,修复了在 Web 界面编辑定时任务时可能丢失静默模式等隐藏配置的问题编辑后任务设置得以完整保留。Thanks @AaronZ345 (#2959)
相关文档:[定时任务](https://docs.cowagent.ai/zh/tools/scheduler)
## 🤖 模型新增
- 新增支持 **kimi-k3**
- 新增支持 **gpt-5.6-luna**、**gpt-5.6-terra**、**gpt-5.6-sol**
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
## 🛠 体验优化与修复
- **文件编辑更稳**统一模糊匹配的唯一性校验逻辑编辑文件时的定位更可靠。Thanks @weijun-xia (#2945)
- **稳定性提升**:清理浏览器工具中的冗余逻辑分支,优化整体运行稳定性。
## 📦 升级方式
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
- **桌面客户端**:可在客户端内自动检查并一键更新,或前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
**发布日期**2026.07.17 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)

View File

@@ -49,6 +49,12 @@ description: 控制浏览器访问和操作网页
2. 浏览器工具依赖较重约300MB为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
</Note>
<Note>
**桌面客户端用户**playwright 已内置于安装包,无需单独安装。首次使用浏览器工具时:
- 若系统已安装 **Google Chrome / Edge**,会直接驱动系统浏览器,**无需任何下载**(推荐);
- 若未安装,可在对话中发送 `/install-browser`,自动下载一个精简浏览器内核到 `~/.cow`。
</Note>
## 工作流程
Agent 使用浏览器的典型流程:
@@ -105,6 +111,15 @@ Agent 使用浏览器的典型流程:
}
```
## 浏览器内核
浏览器内核会自动选择,无需配置:
1. 若检测到本机已安装 **Google Chrome / Edge**,直接驱动系统浏览器,**无需下载 Chromium**,并使用真实浏览器指纹;
2. 否则使用 `install-browser` 下载到 `~/.cow` 的 Chromium 内核作为兜底。
两种方式都使用下面的登录态持久化,行为一致。
## 登录态持久化
**只需登录一次目标网站Agent 后续可直接使用**。提供两种方式:

View File

@@ -35,6 +35,12 @@ description: 创建和管理定时任务
- "明天下午 3 点提醒我开会"
- "查看所有定时任务"
## 测试执行已有任务
在经过身份认证的 Web 控制台或 Desktop 应用中打开任务,点击 **立即执行**。确认发送后CowAgent 会立即按任务已配置的通道和接收者执行一次。这个入口只允许用户手动触发,不会暴露给 Agent 的 scheduler tool。
禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行。
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
</Frame>

View File

@@ -61,7 +61,7 @@ class MoonshotBot(Bot):
m = model_name.lower()
if cls._is_builtin_reasoning_model(m):
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:
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""

View File

@@ -29,6 +29,24 @@ class OpenAICompatibleBot:
Subclasses only need to override get_api_config() to provide their specific API settings.
"""
@staticmethod
def _is_gpt5_reasoning_model(model_name: str) -> bool:
"""Whether the model is a GPT-5.x / o-series reasoning model.
Covers gpt-5, gpt-5.4/5.5/5.6 (including suffixed variants like
gpt-5.6-sol / gpt-5.6-luna) and the o1/o3/o4 families. These models
only accept default sampling params and, on /v1/chat/completions,
reject reasoning_effort together with function tools.
"""
if not model_name or not isinstance(model_name, str):
return False
name = model_name.lower()
if name.startswith("gpt-5"):
return True
if name.startswith(("o1", "o3", "o4")):
return True
return False
def get_api_config(self):
"""
Get API configuration for this bot.
@@ -99,8 +117,10 @@ class OpenAICompatibleBot:
"presence_penalty": kwargs.get("presence_penalty", api_config.get('default_presence_penalty', 0.0)),
"stream": stream
}
# GPT-5 / GPT-5.5 / o1 series only accept default temperature/top_p and reject penalty params
if model_name in ("gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5.5", "o1", "o1-mini"):
# GPT-5.x / o-series reasoning models only accept default
# temperature/top_p and reject penalty params.
is_gpt5_reasoning = self._is_gpt5_reasoning_model(model_name)
if is_gpt5_reasoning:
for key in ("temperature", "top_p", "frequency_penalty", "presence_penalty"):
request_params.pop(key, None)
@@ -112,6 +132,12 @@ class OpenAICompatibleBot:
if tools:
request_params["tools"] = tools
request_params["tool_choice"] = kwargs.get("tool_choice", "auto")
# GPT-5.x reasoning models reject function tools combined with
# reasoning_effort on /v1/chat/completions unless it is "none".
# Force "none" so agent tool calling works without migrating to
# the Responses API.
if is_gpt5_reasoning:
request_params["reasoning_effort"] = "none"
# Make API call with proper configuration
api_key = api_config.get('api_key')

View File

@@ -35,7 +35,7 @@ KNOWN_COMMANDS = {
"help", "version", "status", "logs",
"start", "stop", "restart",
"cancel",
"skill", "context", "config",
"skill", "context", "config", "tasks",
"knowledge", "memory",
"install-browser",
}
@@ -357,6 +357,7 @@ class CowCliPlugin(Plugin):
"/logs [N]: Show the last N log lines (default 20)",
"/context: Show current conversation context",
"/context clear: Clear current conversation context",
"/tasks: List scheduled tasks for this chat",
"/skill list: List installed skills",
"/skill list --remote: Browse Skill Hub",
"/skill search <keyword>: Search skills",
@@ -385,6 +386,7 @@ class CowCliPlugin(Plugin):
"/logs [N]: 查看最近N条日志 (默认20)",
"/context: 查看当前对话上下文信息",
"/context clear: 清除当前对话上下文",
"/tasks: 查看当前会话的定时任务",
"/skill list: 查看已安装的技能",
"/skill list --remote: 浏览技能广场",
"/skill search <关键词>: 搜索技能",
@@ -407,6 +409,85 @@ class CowCliPlugin(Plugin):
def _cmd_version(self, args: str, e_context, **_) -> str:
return f"CowAgent v{__version__}"
# ------------------------------------------------------------------
# tasks — read-only scheduler list scoped to the current chat.
# Feishu intercepts /tasks before this plugin to keep its interactive card;
# every other channel receives this plain-text view.
# ------------------------------------------------------------------
def _cmd_tasks(self, args: str, e_context, session_id: str = "", **_) -> str:
from agent.tools.scheduler.integration import get_task_store
task_store = get_task_store()
if task_store is None:
from agent.tools.scheduler.task_store import TaskStore
from common.utils import expand_path
workspace = expand_path(conf().get("agent_workspace", "~/cow"))
task_store = TaskStore(os.path.join(workspace, "scheduler", "tasks.json"))
channel_type = ""
receiver = ""
if e_context is not None:
context = e_context["context"]
channel_type = context.get("channel_type", "") or ""
receiver = context.get("receiver", "") or ""
session_id = context.get("session_id", "") or session_id
visible = []
for task in task_store.list_tasks():
action = task.get("action") or {}
if receiver:
if action.get("receiver") != receiver:
continue
if channel_type and action.get("channel_type") != channel_type:
continue
elif session_id not in {
action.get("receiver"),
action.get("notify_session_id"),
}:
continue
visible.append(task)
return self._format_tasks(visible)
@staticmethod
def _format_tasks(tasks) -> str:
if not tasks:
return _t(
"📅 当前会话暂无定时任务。",
"📅 No scheduled tasks in this chat.",
)
lines = [_t("📅 当前会话的定时任务", "📅 Scheduled tasks in this chat"), ""]
for index, task in enumerate(tasks[:20], 1):
enabled = task.get("enabled", True)
status = "" if enabled else "⏸️"
schedule = task.get("schedule") or {}
schedule_type = schedule.get("type")
if schedule_type == "cron":
schedule_text = "cron {}".format(schedule.get("expression") or "?")
elif schedule_type == "interval":
schedule_text = "every {}s".format(schedule.get("seconds") or "?")
elif schedule_type == "once":
schedule_text = "once at {}".format(schedule.get("run_at") or "?")
else:
schedule_text = str(schedule_type or "unknown")
next_run = str(task.get("next_run_at") or "-").replace("T", " ")
lines.extend(
[
"{}. {} {}".format(index, status, task.get("name") or "Unnamed task"),
" ID: {}".format(task.get("id") or "-"),
" {} · Next: {}".format(schedule_text, next_run),
]
)
hidden = len(tasks) - 20
if hidden > 0:
lines.append(_t("\n另有 {} 个任务未显示。", "\n{} more tasks are hidden.").format(hidden))
return "\n".join(lines)
# ------------------------------------------------------------------
# cancel — abort the in-flight agent run for the current session.
# Fallback handler; in practice chat_channel/web_channel intercept
@@ -813,8 +894,8 @@ class CowCliPlugin(Plugin):
"you can also run `cow install-browser` in a terminal.",
)
return _t(
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)",
"✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).",
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具。",
"✅ Installation finished. Restart CowAgent to use the browser tool.",
)
# ------------------------------------------------------------------

8
run.sh
View File

@@ -599,12 +599,12 @@ select_model() {
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
"OpenAI (gpt-5.5, etc.)" \
"OpenAI (gpt-5.6-luna, etc.)" \
"MiniMax (MiniMax-M3, etc.)" \
"GLM (glm-5.2, etc.)" \
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
"Doubao (doubao-seed-2.1, etc.)" \
"Kimi (kimi-k2.7-code, etc.)" \
"Kimi (kimi-k3, etc.)" \
"MiMo (mimo-v2.5-pro, etc.)" \
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
@@ -631,12 +631,12 @@ configure_model() {
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
4) read_model_config "OpenAI" "gpt-5.6-luna" "OPENAI_KEY" ;;
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-1-pro-260628" "ARK_KEY" ;;
9) read_model_config "Kimi (Moonshot)" "kimi-k2.7-code" "MOONSHOT_KEY" ;;
9) read_model_config "Kimi (Moonshot)" "kimi-k3" "MOONSHOT_KEY" ;;
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
11)
# Show where to obtain a LinkAI key (zh users -> console page).

View File

@@ -456,12 +456,12 @@ $ModelChoices = @{
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
4 = @{ Provider = "OpenAI"; Default = "gpt-5.6-luna"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-1-pro-260628"; Field = "ark_api_key" }
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.7-code"; Field = "moonshot_api_key" }
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k3"; Field = "moonshot_api_key" }
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
}
@@ -473,12 +473,12 @@ function Select-Model {
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
"OpenAI (gpt-5.5, etc.)",
"OpenAI (gpt-5.6-luna, etc.)",
"MiniMax (MiniMax-M3, etc.)",
"GLM (glm-5.2, etc.)",
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
"Doubao (doubao-seed-2.1, etc.)",
"Kimi (kimi-k2.7-code, etc.)",
"Kimi (kimi-k3, etc.)",
"MiMo (mimo-v2.5-pro, etc.)",
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")

110
tests/test_cli_backup.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests for portable CowAgent backup archives."""
import json
import zipfile
from pathlib import Path
import pytest
from cli.commands.backup import create_backup_archive, restore_backup_archive
def _write_json(path: Path, value: dict):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value), encoding="utf-8")
def test_backup_restore_round_trip(tmp_path):
source_data = tmp_path / "source-data"
source_workspace = tmp_path / "source-workspace"
_write_json(source_data / "config.json", {
"agent_workspace": str(source_workspace),
"open_ai_api_key": "secret-value",
})
(source_workspace / "memory").mkdir(parents=True)
(source_workspace / "scheduler").mkdir()
(source_workspace / "knowledge").mkdir()
(source_workspace / "USER.md").write_text("# User\n", encoding="utf-8")
(source_workspace / "MEMORY.md").write_text("remember this\n", encoding="utf-8")
(source_workspace / "memory" / "2026-07-17.md").write_text("daily\n", encoding="utf-8")
(source_workspace / "scheduler" / "tasks.json").write_text("{}\n", encoding="utf-8")
(source_workspace / "knowledge" / "index.md").write_text("# Index\n", encoding="utf-8")
(source_workspace / "tmp").mkdir()
(source_workspace / "tmp" / "scratch.txt").write_text("skip", encoding="utf-8")
archive = tmp_path / "cow-backup.zip"
summary = create_backup_archive(archive, source_data, source_workspace)
assert summary["contents"]["workspace_files"] == 5
assert archive.exists()
target_data = tmp_path / "target-data"
target_workspace = tmp_path / "target-workspace"
result = restore_backup_archive(archive, target_data, target_workspace)
restored_config = json.loads((target_data / "config.json").read_text(encoding="utf-8"))
assert restored_config["agent_workspace"] == str(target_workspace.resolve())
assert restored_config["open_ai_api_key"] == "secret-value"
assert (target_workspace / "MEMORY.md").read_text(encoding="utf-8") == "remember this\n"
assert (target_workspace / "scheduler" / "tasks.json").exists()
assert (target_workspace / "knowledge" / "index.md").exists()
assert not (target_workspace / "tmp" / "scratch.txt").exists()
assert result["workspace_files"] == 5
def test_restore_merges_without_deleting_unrelated_files(tmp_path):
source_data = tmp_path / "source-data"
source_workspace = tmp_path / "source-workspace"
_write_json(source_data / "config.json", {"agent_workspace": str(source_workspace)})
source_workspace.mkdir()
(source_workspace / "MEMORY.md").write_text("new\n", encoding="utf-8")
archive = tmp_path / "cow-backup.zip"
create_backup_archive(archive, source_data, source_workspace)
target_workspace = tmp_path / "target-workspace"
target_workspace.mkdir()
(target_workspace / "MEMORY.md").write_text("old\n", encoding="utf-8")
(target_workspace / "keep.txt").write_text("keep\n", encoding="utf-8")
restore_backup_archive(archive, tmp_path / "target-data", target_workspace)
assert (target_workspace / "MEMORY.md").read_text(encoding="utf-8") == "new\n"
assert (target_workspace / "keep.txt").read_text(encoding="utf-8") == "keep\n"
def test_restore_rejects_path_traversal(tmp_path):
archive = tmp_path / "malicious.zip"
manifest = {"format": "cowagent-backup", "version": 1}
with zipfile.ZipFile(str(archive), "w") as output:
output.writestr("manifest.json", json.dumps(manifest))
output.writestr("workspace/../../escape.txt", "nope")
with pytest.raises(ValueError, match="unsafe archive path"):
restore_backup_archive(archive, tmp_path / "data", tmp_path / "workspace")
def test_fresh_restore_ignores_archive_controlled_destinations(tmp_path, monkeypatch):
source_data = tmp_path / "source-data"
source_workspace = tmp_path / "source-workspace"
outside_appdata = tmp_path / "outside-appdata"
archive_workspace = tmp_path / "archive-controlled-workspace"
_write_json(source_data / "config.json", {
"agent_workspace": str(archive_workspace),
"appdata_dir": str(outside_appdata),
})
source_workspace.mkdir()
(source_workspace / "MEMORY.md").write_text("portable\n", encoding="utf-8")
outside_appdata.mkdir()
(outside_appdata / "user_datas.pkl").write_bytes(b"legacy")
archive = tmp_path / "cow-backup.zip"
create_backup_archive(archive, source_data, source_workspace)
fake_home = tmp_path / "home"
monkeypatch.setenv("HOME", str(fake_home))
target_data = tmp_path / "target-data"
result = restore_backup_archive(archive, target_data)
assert result["workspace"] == str((fake_home / "cow").resolve())
assert (fake_home / "cow" / "MEMORY.md").exists()
assert not archive_workspace.exists()
assert (target_data / "user_datas.pkl").read_bytes() == b"legacy"
restored_config = json.loads((target_data / "config.json").read_text(encoding="utf-8"))
assert restored_config["appdata_dir"] == ""

114
tests/test_cow_cli_tasks.py Normal file
View File

@@ -0,0 +1,114 @@
import os
from types import SimpleNamespace
import plugins
_old_plugin_path = plugins.instance.current_plugin_path
plugins.instance.current_plugin_path = os.path.join(os.getcwd(), "plugins", "cow_cli")
try:
from plugins.cow_cli.cow_cli import KNOWN_COMMANDS
finally:
plugins.instance.current_plugin_path = _old_plugin_path
CowCliPlugin = plugins.instance.plugins["COW_CLI"]
class FakeTaskStore:
def __init__(self, tasks):
self.tasks = tasks
def list_tasks(self):
return list(self.tasks)
def _task(task_id, receiver, channel_type, enabled=True, notify_session_id=None):
action = {
"receiver": receiver,
"channel_type": channel_type,
}
if notify_session_id:
action["notify_session_id"] = notify_session_id
return {
"id": task_id,
"name": f"Task {task_id}",
"enabled": enabled,
"schedule": {"type": "cron", "expression": "0 9 * * *"},
"next_run_at": "2026-07-18T09:00:00",
"action": action,
}
def _event_context(channel_type, receiver, session_id):
context = SimpleNamespace(
kwargs={
"channel_type": channel_type,
"receiver": receiver,
"session_id": session_id,
}
)
context.get = context.kwargs.get
return {"context": context}
def test_tasks_is_a_known_command_and_is_listed_in_help():
plugin = CowCliPlugin()
assert "tasks" in KNOWN_COMMANDS
assert "/tasks" in plugin._cmd_help("", None)
def test_tasks_command_only_lists_tasks_owned_by_current_channel_and_receiver(monkeypatch):
store = FakeTaskStore(
[
_task("mine", "user-1", "telegram"),
_task("other-channel", "user-1", "feishu"),
_task("other-user", "user-2", "telegram"),
]
)
monkeypatch.setattr(
"agent.tools.scheduler.integration.get_task_store", lambda: store
)
plugin = CowCliPlugin()
result = plugin._cmd_tasks(
"", _event_context("telegram", "user-1", "session-1")
)
assert "mine" in result
assert "other-channel" not in result
assert "other-user" not in result
def test_tasks_command_uses_session_identity_without_channel_context(monkeypatch):
store = FakeTaskStore(
[
_task("direct", "session-1", "web"),
_task("notified", "chat-1", "feishu", notify_session_id="session-1"),
_task("hidden", "session-2", "web"),
]
)
monkeypatch.setattr(
"agent.tools.scheduler.integration.get_task_store", lambda: store
)
plugin = CowCliPlugin()
result = plugin._cmd_tasks("", None, session_id="session-1")
assert "direct" in result
assert "notified" in result
assert "hidden" not in result
def test_tasks_command_formats_empty_state(monkeypatch):
monkeypatch.setattr(
"agent.tools.scheduler.integration.get_task_store",
lambda: FakeTaskStore([]),
)
plugin = CowCliPlugin()
result = plugin._cmd_tasks(
"", _event_context("slack", "channel-1", "session-1")
)
assert "task" in result.lower() or "任务" in result

View File

@@ -0,0 +1,151 @@
import socket
import pytest
from channel.feishu import feishu_static_card
class FakeResponse:
def __init__(self, status_code=200, headers=None, chunks=None):
self.status_code = status_code
self.headers = headers or {}
self._chunks = chunks or []
self.closed = False
def iter_content(self, chunk_size=8192):
yield from self._chunks
def close(self):
self.closed = True
def json(self):
return getattr(self, "json_body", {})
def test_remote_markdown_images_are_uploaded_once_and_replaced_with_image_keys():
calls = []
def upload(url):
calls.append(url)
return "img_v2_chart"
markdown = (
"Before\n![chart](https://cdn.example.com/chart.png)\n"
"Again ![same](https://cdn.example.com/chart.png)"
)
result = feishu_static_card.resolve_markdown_images(markdown, upload)
assert result == (
"Before\n![chart](img_v2_chart)\nAgain ![same](img_v2_chart)"
)
assert calls == ["https://cdn.example.com/chart.png"]
def test_existing_feishu_image_keys_and_regular_links_are_untouched():
def unexpected(_url):
raise AssertionError("uploader should not be called")
markdown = "![ready](img_v2_existing) [docs](https://example.com/docs)"
assert feishu_static_card.resolve_markdown_images(markdown, unexpected) == markdown
def test_failed_remote_image_upload_degrades_to_readable_alt_text():
markdown = "Result: ![latency chart](https://cdn.example.com/chart.png)"
result = feishu_static_card.resolve_markdown_images(markdown, lambda _url: None)
assert result == "Result: [Image unavailable: latency chart]"
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1/image.png",
"http://169.254.169.254/latest/meta-data",
"http://[::1]/image.png",
],
)
def test_public_image_guard_rejects_non_public_literal_addresses(url):
with pytest.raises(ValueError, match="non-public"):
feishu_static_card.validate_public_image_url(url)
def test_image_download_revalidates_redirect_targets(monkeypatch):
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))
],
)
redirect = FakeResponse(
status_code=302,
headers={"Location": "http://127.0.0.1/private.png"},
)
with pytest.raises(ValueError, match="non-public"):
feishu_static_card.download_public_image(
"https://example.com/image.png", get=lambda *args, **kwargs: redirect
)
assert redirect.closed is True
def test_image_download_enforces_content_type_and_size(monkeypatch):
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *args, **kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))
],
)
not_image = FakeResponse(
headers={"Content-Type": "text/html"},
chunks=[b"not an image"],
)
too_large = FakeResponse(
headers={"Content-Type": "image/png", "Content-Length": "11"},
chunks=[b"01234567890"],
)
with pytest.raises(ValueError, match="not an image"):
feishu_static_card.download_public_image(
"https://example.com/image.png", get=lambda *args, **kwargs: not_image
)
with pytest.raises(ValueError, match="too large"):
feishu_static_card.download_public_image(
"https://example.com/image.png",
get=lambda *args, **kwargs: too_large,
max_bytes=10,
)
def test_public_image_is_uploaded_to_feishu_without_a_temp_file(monkeypatch):
monkeypatch.setattr(
feishu_static_card,
"download_public_image",
lambda _url: (b"png-bytes", "image/png"),
)
response = FakeResponse()
response.json_body = {
"code": 0,
"data": {"image_key": "img_v2_uploaded"},
}
calls = []
def post(url, **kwargs):
calls.append((url, kwargs))
return response
image_key = feishu_static_card.upload_public_image_to_feishu(
"https://example.com/image.png", "tenant-token", post=post
)
assert image_key == "img_v2_uploaded"
assert calls[0][1]["headers"] == {"Authorization": "Bearer tenant-token"}
filename, payload, content_type = calls[0][1]["files"]["image"]
assert filename == "markdown-image.png"
assert payload == b"png-bytes"
assert content_type == "image/png"

View File

@@ -0,0 +1,103 @@
from channel.feishu.feishu_progress_card import FeishuProgressState
def _panels(card):
return [
element
for element in card["body"]["elements"]
if element.get("tag") == "collapsible_panel"
]
def test_running_card_has_status_reasoning_lane_and_stream_target():
state = FeishuProgressState(started_at=100.0)
card = state.build_card(streaming=True, now=102.0)
assert card["header"]["template"] == "blue"
assert card["header"]["title"]["content"] == "Working"
assert card["config"]["streaming_mode"] is True
assert _panels(card)[0]["header"]["title"]["content"] == "Reasoning"
stream = next(
element
for element in card["body"]["elements"]
if element.get("element_id") == "stream_md"
)
assert stream["content"] == "..."
def test_tool_turn_populates_reasoning_and_tools_lanes():
state = FeishuProgressState(started_at=100.0)
state.consume({"type": "turn_start", "data": {"turn": 1}})
state.consume({"type": "reasoning_update", "data": {"delta": "Need the latest files."}})
state.consume({"type": "message_update", "data": {"delta": "Checking now."}})
state.consume(
{
"type": "message_end",
"data": {
"tool_calls": [
{"name": "read_file", "arguments": {"path": "README.md"}}
]
},
}
)
card = state.build_card(streaming=True, now=103.0)
panels = _panels(card)
assert [panel["header"]["title"]["content"] for panel in panels] == [
"Reasoning (1)",
"Tools (1)",
]
assert panels[0]["elements"][0]["text"]["content"] == "Need the latest files."
assert "read_file" in panels[1]["elements"][0]["text"]["content"]
assert "running" in panels[1]["elements"][0]["text"]["content"]
state.consume({"type": "turn_start", "data": {"turn": 2}})
done_card = state.build_card(streaming=True, now=104.0)
assert "done" in _panels(done_card)[1]["elements"][0]["text"]["content"]
def test_agent_end_finalizes_status_body_and_footer():
state = FeishuProgressState(started_at=100.0)
state.consume({"type": "turn_start", "data": {"turn": 1}})
state.consume(
{
"type": "agent_end",
"data": {"final_response": "**Finished**", "cancelled": False},
}
)
card = state.build_card(streaming=False, now=105.2)
assert card["header"]["template"] == "green"
assert card["header"]["title"]["content"] == "Done"
assert card["config"]["streaming_mode"] is False
assert next(
element["content"]
for element in card["body"]["elements"]
if element.get("element_id") == "stream_md"
) == "**Finished**"
footer = card["body"]["elements"][-1]
assert footer["text_size"] == "notation"
assert footer["content"] == "5.2s · 1 turn"
def test_cancelled_agent_uses_partial_output_and_stopped_status():
state = FeishuProgressState(started_at=100.0)
state.consume({"type": "message_update", "data": {"delta": "partial"}})
state.consume({"type": "agent_cancelled", "data": {}})
state.consume(
{
"type": "agent_end",
"data": {"final_response": "stale response", "cancelled": True},
}
)
card = state.build_card(streaming=False, now=101.0)
assert card["header"]["title"]["content"] == "Stopped"
assert next(
element["content"]
for element in card["body"]["elements"]
if element.get("element_id") == "stream_md"
) == "partial"

View File

@@ -0,0 +1,105 @@
import json
from types import SimpleNamespace
from channel.feishu.feishu_message import FeishuMessage
def _event(parent_id="om_parent", text="What does this mean?"):
return {
"app_id": "cli_bot",
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_child",
"parent_id": parent_id,
"chat_id": "oc_chat",
"message_type": "text",
"content": json.dumps({"text": text}),
},
}
def _response(body, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: body)
def test_text_reply_fetches_parent_without_replacing_user_message(monkeypatch):
response = _response(
{
"code": 0,
"data": {
"items": [
{
"msg_type": "text",
"body": {"content": json.dumps({"text": "Original answer"})},
}
]
},
}
)
monkeypatch.setattr(
"channel.feishu.feishu_message.requests.get", lambda **kwargs: response
)
message = FeishuMessage(_event(), access_token="tenant-token")
assert message.content == "What does this mean?"
assert message.quoted_content == "Original answer"
assert message.content_with_quote() == (
"[Quoted message]\nOriginal answer\n[/Quoted message]\n\n"
"What does this mean?"
)
def test_post_reply_extracts_title_and_text(monkeypatch):
post = {
"title": "Release note",
"content": [
[
{"tag": "text", "text": "Fixed the scheduler."},
{"tag": "a", "text": "Details", "href": "https://example.com"},
]
],
}
response = _response(
{
"code": 0,
"data": {
"items": [
{"msg_type": "post", "body": {"content": json.dumps(post)}}
]
},
}
)
monkeypatch.setattr(
"channel.feishu.feishu_message.requests.get", lambda **kwargs: response
)
message = FeishuMessage(_event(), access_token="tenant-token")
assert message.quoted_content == (
"Release note\nFixed the scheduler.\nDetails (https://example.com)"
)
def test_quote_fetch_failure_keeps_current_message_usable(monkeypatch):
def fail(**kwargs):
raise TimeoutError("network timeout")
monkeypatch.setattr("channel.feishu.feishu_message.requests.get", fail)
message = FeishuMessage(_event(), access_token="tenant-token")
assert message.quoted_content == ""
assert message.content_with_quote() == "What does this mean?"
def test_message_without_parent_does_not_fetch(monkeypatch):
def unexpected(**kwargs):
raise AssertionError("quote API should not be called")
monkeypatch.setattr("channel.feishu.feishu_message.requests.get", unexpected)
message = FeishuMessage(_event(parent_id=""), access_token="tenant-token")
assert message.quoted_content == ""
assert message.content_with_quote() == "What does this mean?"

View File

@@ -0,0 +1,140 @@
from copy import deepcopy
from channel.feishu.feishu_scheduler_card import (
build_scheduler_card,
handle_scheduler_action,
tasks_for_receivers,
)
class FakeTaskStore:
def __init__(self, tasks):
self.tasks = {task["id"]: deepcopy(task) for task in tasks}
self.enable_calls = []
self.delete_calls = []
def list_tasks(self):
return list(self.tasks.values())
def get_task(self, task_id):
return self.tasks.get(task_id)
def enable_task(self, task_id, enabled=True):
self.enable_calls.append((task_id, enabled))
self.tasks[task_id]["enabled"] = enabled
def delete_task(self, task_id):
self.delete_calls.append(task_id)
del self.tasks[task_id]
def _task(task_id, receiver, enabled=True):
return {
"id": task_id,
"name": "Daily report",
"enabled": enabled,
"schedule": {"type": "cron", "expression": "0 9 * * *"},
"next_run_at": "2026-07-18T09:00:00",
"action": {"receiver": receiver, "channel_type": "feishu"},
}
def test_scheduler_card_has_explicit_idempotent_actions():
card = build_scheduler_card([_task("task-1", "chat-1")])
assert card["schema"] == "2.0"
assert card["config"]["enable_forward_interaction"] is False
assert card["header"]["title"]["content"] == "Scheduled tasks"
action_row = next(
element for element in card["body"]["elements"] if element.get("tag") == "column_set"
)
values = [column["elements"][0]["value"] for column in action_row["columns"]]
assert values == [
{
"cowagent": "scheduler",
"action": "disable",
"task_id": "task-1",
"receiver": "chat-1",
},
{
"cowagent": "scheduler",
"action": "delete",
"task_id": "task-1",
"receiver": "chat-1",
},
]
def test_tasks_are_scoped_to_callback_chat_or_operator():
tasks = [
_task("group", "chat-1"),
_task("private", "user-1"),
_task("other", "chat-2"),
]
visible = tasks_for_receivers(tasks, {"chat-1", "user-1"})
assert [task["id"] for task in visible] == ["group", "private"]
def test_disable_action_is_idempotent_and_returns_refreshed_card():
store = FakeTaskStore([_task("task-1", "chat-1")])
value = {"cowagent": "scheduler", "action": "disable", "task_id": "task-1"}
first = handle_scheduler_action(value, store, {"chat-1"})
second = handle_scheduler_action(value, store, {"chat-1"})
assert store.enable_calls == [("task-1", False), ("task-1", False)]
assert first["toast"] == {"type": "success", "content": "Task disabled"}
assert second["card"]["type"] == "raw"
refreshed_columns = next(
element
for element in second["card"]["data"]["body"]["elements"]
if element.get("tag") == "column_set"
)["columns"]
assert refreshed_columns[0]["elements"][0]["value"]["action"] == "enable"
def test_action_rejects_task_from_another_receiver():
store = FakeTaskStore([_task("task-1", "chat-2")])
response = handle_scheduler_action(
{"cowagent": "scheduler", "action": "delete", "task_id": "task-1"},
store,
{"chat-1", "user-1"},
)
assert response["toast"] == {"type": "error", "content": "Task is not available in this chat"}
assert store.delete_calls == []
def test_action_rejects_mismatched_receiver_embedded_in_card_value():
store = FakeTaskStore([_task("task-1", "user-1")])
response = handle_scheduler_action(
{
"cowagent": "scheduler",
"action": "delete",
"task_id": "task-1",
"receiver": "chat-1",
},
store,
{"chat-1"},
)
assert response["toast"] == {"type": "error", "content": "Task is not available in this chat"}
assert store.delete_calls == []
def test_delete_action_removes_owned_task():
store = FakeTaskStore([_task("task-1", "user-1")])
response = handle_scheduler_action(
{"cowagent": "scheduler", "action": "delete", "task_id": "task-1"},
store,
{"user-1"},
)
assert store.delete_calls == ["task-1"]
assert response["toast"] == {"type": "success", "content": "Task deleted"}
assert "No scheduled tasks" in response["card"]["data"]["body"]["elements"][0]["content"]

View File

@@ -0,0 +1,34 @@
import json
from channel.feishu.feishu_static_card import build_text_delivery, contains_markdown
def test_plain_text_keeps_native_feishu_text_message():
msg_type, content = build_text_delivery("hello from CowAgent")
assert msg_type == "text"
assert json.loads(content) == {"text": "hello from CowAgent"}
def test_markdown_reply_uses_card_2_markdown_element():
msg_type, content = build_text_delivery("**Build complete**\n\n- tests passed")
card = json.loads(content)
assert msg_type == "interactive"
assert card["schema"] == "2.0"
assert card["body"]["elements"] == [
{"tag": "markdown", "content": "**Build complete**\n\n- tests passed"}
]
def test_markdown_cards_can_be_disabled():
msg_type, content = build_text_delivery("# Status", enabled=False)
assert msg_type == "text"
assert json.loads(content) == {"text": "# Status"}
def test_markdown_detection_avoids_common_plain_text_punctuation():
assert contains_markdown("release 1.2.0 - all checks passed") is False
assert contains_markdown("Use `cow --help` for details") is True
assert contains_markdown("| Item | State |\n| --- | --- |\n| API | ready |") is True

View File

@@ -0,0 +1,77 @@
"""Tests for immediate execution of persisted scheduler tasks."""
import threading
import time
from datetime import datetime, timedelta
import pytest
from agent.tools.scheduler.scheduler_service import SchedulerService
from agent.tools.scheduler.scheduler_tool import SchedulerTool
from agent.tools.scheduler.task_store import TaskStore
def _task(task_id="task-1", enabled=False):
return {
"id": task_id,
"name": "refresh index",
"enabled": enabled,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"next_run_at": (datetime.now() + timedelta(hours=2)).isoformat(),
"schedule": {"type": "interval", "seconds": 7200},
"action": {"type": "send_message", "content": "done"},
}
def test_manual_run_executes_disabled_task_without_moving_schedule(tmp_path):
store = TaskStore(str(tmp_path / "tasks.json"))
task = _task()
store.add_task(task)
completed = threading.Event()
def execute(received):
assert received["id"] == "task-1"
completed.set()
return True
service = SchedulerService(store, execute)
service.run_task_now("task-1")
assert completed.wait(timeout=2)
deadline = time.time() + 2
while time.time() < deadline:
updated = store.get_task("task-1")
if updated.get("last_manual_run_at"):
break
time.sleep(0.01)
updated = store.get_task("task-1")
assert updated["next_run_at"] == task["next_run_at"]
assert updated["enabled"] is False
assert updated["last_run_at"] == updated["last_manual_run_at"]
def test_manual_run_rejects_duplicate_execution(tmp_path):
store = TaskStore(str(tmp_path / "tasks.json"))
store.add_task(_task())
release = threading.Event()
started = threading.Event()
def execute(_task_data):
started.set()
release.wait(timeout=2)
return True
service = SchedulerService(store, execute)
service.run_task_now("task-1")
assert started.wait(timeout=2)
with pytest.raises(RuntimeError, match="already running"):
service.run_task_now("task-1")
release.set()
def test_scheduler_tool_does_not_expose_manual_execution():
actions = SchedulerTool.params["properties"]["action"]["enum"]
assert "run" not in actions

View File

@@ -0,0 +1,92 @@
"""Regression tests for silent scheduled agent tasks."""
import os
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from agent.tools.scheduler.integration import _execute_agent_task
from agent.tools.scheduler.scheduler_tool import SchedulerTool
class _Context(dict):
kwargs = {}
class _TaskStore:
def __init__(self):
self.added = []
def add_task(self, task):
self.added.append(task)
class _AgentBridge:
def __init__(self, content="maintenance complete"):
self.content = content
self.calls = []
def agent_reply(self, task_description, **kwargs):
self.calls.append((task_description, kwargs))
return SimpleNamespace(content=self.content)
class TestSchedulerSilentMode(unittest.TestCase):
def test_schema_exposes_silent_for_agent_tasks(self):
silent = SchedulerTool.params["properties"]["silent"]
self.assertEqual(silent["type"], "boolean")
self.assertFalse(silent["default"])
def test_create_persists_silent_on_agent_task(self):
tool = SchedulerTool({"channel_type": "web"})
store = _TaskStore()
tool.task_store = store
tool.current_context = _Context(
receiver="user-1",
session_id="session-1",
isgroup=False,
)
result = tool.execute(
{
"action": "create",
"name": "refresh token",
"ai_task": "refresh the token",
"schedule_type": "interval",
"schedule_value": "3000",
"silent": True,
}
)
self.assertEqual(result.status, "success")
self.assertEqual(len(store.added), 1)
self.assertIs(store.added[0]["action"]["silent"], True)
def test_silent_agent_task_executes_without_delivery(self):
bridge = _AgentBridge()
task = {
"id": "task-1",
"action": {
"type": "agent_task",
"task_description": "rotate logs",
"receiver": "user-1",
"is_group": False,
"channel_type": "web",
"silent": True,
},
}
with patch("channel.channel_factory.create_channel") as create_channel:
result = _execute_agent_task(task, bridge)
self.assertTrue(result)
self.assertEqual(len(bridge.calls), 1)
create_channel.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,168 @@
"""Regression tests for scheduler edits made through the Web console."""
import json
import sys
import types
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import Mock, patch
from agent.tools.scheduler.task_store import TaskStore
# Keep this unit test independent from the optional web.py dependency.
if "web" not in sys.modules:
web_stub = types.ModuleType("web")
web_stub.HTTPError = type("HTTPError", (Exception,), {})
web_stub.cookies = lambda: {}
web_stub.header = lambda *args, **kwargs: None
web_stub.data = lambda: b"{}"
web_stub.input = lambda **kwargs: types.SimpleNamespace(**kwargs)
web_stub.setcookie = lambda *args, **kwargs: None
web_stub.seeother = lambda *args, **kwargs: Exception("seeother")
web_stub.notfound = lambda *args, **kwargs: Exception("notfound")
web_stub.badrequest = lambda *args, **kwargs: Exception("badrequest")
web_stub.application = lambda *args, **kwargs: types.SimpleNamespace(wsgifunc=lambda: None)
web_stub.httpserver = types.SimpleNamespace(
LogMiddleware=type("LogMiddleware", (), {"log": lambda *args, **kwargs: None}),
StaticMiddleware=lambda app: app,
WSGIServer=lambda *args, **kwargs: types.SimpleNamespace(serve_forever=lambda: None),
)
sys.modules["web"] = web_stub
from channel.web import web_channel
SchedulerUpdateHandler = web_channel.SchedulerUpdateHandler
def _store_task(tmp_path, action):
store = TaskStore(str(tmp_path / "scheduler" / "tasks.json"))
store.add_task({
"id": "task-1",
"name": "maintenance",
"enabled": True,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"next_run_at": (datetime.now() + timedelta(hours=1)).isoformat(),
"schedule": {"type": "interval", "seconds": 3600},
"action": action,
})
return store
def _post_update(tmp_path, payload):
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=json.dumps(payload).encode()), \
patch("channel.web.web_channel._get_workspace_root", return_value=str(tmp_path)):
return json.loads(SchedulerUpdateHandler().POST())
def test_web_manual_run_is_authenticated_and_delegates_to_scheduler():
assert hasattr(web_channel, "SchedulerRunHandler")
service = Mock()
with patch("channel.web.web_channel._require_auth") as require_auth, \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=b'{"task_id":"task-1"}'), \
patch("agent.tools.scheduler.integration.get_scheduler_service", return_value=service):
response = json.loads(web_channel.SchedulerRunHandler().POST())
require_auth.assert_called_once_with()
service.run_task_now.assert_called_once_with("task-1")
assert response == {
"status": "success",
"message": "Task 'task-1' queued for immediate execution",
}
def test_web_manual_run_rejects_unavailable_scheduler():
assert hasattr(web_channel, "SchedulerRunHandler")
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=b'{"task_id":"task-1"}'), \
patch("agent.tools.scheduler.integration.get_scheduler_service", return_value=None):
response = json.loads(web_channel.SchedulerRunHandler().POST())
assert response == {
"status": "error",
"message": "Scheduler service is not running",
}
def test_manual_run_is_exposed_by_explicit_web_and_desktop_controls():
root = Path(__file__).parents[1]
web_source = (root / "channel/web/web_channel.py").read_text(encoding="utf-8")
web_console = (root / "channel/web/static/js/console.js").read_text(encoding="utf-8")
desktop_client = (root / "desktop/src/renderer/src/api/client.ts").read_text(encoding="utf-8")
desktop_page = (root / "desktop/src/renderer/src/pages/TasksPage.tsx").read_text(encoding="utf-8")
assert "'/api/scheduler/run', 'SchedulerRunHandler'" in web_source
assert "function runTaskNow(task, button)" in web_console
assert "fetch('/api/scheduler/run'" in web_console
web_run = web_console[web_console.index("function runTaskNow(task, button)"):]
assert "showConfirmDialog({" in web_run[:2500]
assert "async runTask(taskId: string)" in desktop_client
assert "'/api/scheduler/run'" in desktop_client
assert "const runNow = async ()" in desktop_page
assert "window.confirm(t('task_run_confirm'))" in desktop_page
def test_web_edit_preserves_hidden_agent_action_fields(tmp_path):
store = _store_task(tmp_path, {
"type": "agent_task",
"task_description": "refresh the index",
"receiver": "user-1",
"receiver_name": "User",
"is_group": False,
"channel_type": "feishu",
"notify_session_id": "session-1",
"silent": True,
"delivery_extension": {"trace": True},
})
result = _post_update(tmp_path, {
"task_id": "task-1",
"name": "renamed maintenance",
"action": {
"type": "agent_task",
"task_description": "refresh both indexes",
"receiver": "user-1",
"channel_type": "feishu",
},
})
assert result["status"] == "success"
action = store.get_task("task-1")["action"]
assert action["task_description"] == "refresh both indexes"
assert action["silent"] is True
assert action["notify_session_id"] == "session-1"
assert action["delivery_extension"] == {"trace": True}
def test_switch_to_message_drops_agent_only_fields(tmp_path):
store = _store_task(tmp_path, {
"type": "agent_task",
"task_description": "refresh the index",
"receiver": "user-1",
"channel_type": "web",
"notify_session_id": "session-1",
"silent": True,
})
result = _post_update(tmp_path, {
"task_id": "task-1",
"action": {
"type": "send_message",
"content": "Index refresh reminder",
"receiver": "user-1",
"channel_type": "web",
},
})
assert result["status"] == "success"
action = store.get_task("task-1")["action"]
assert action["content"] == "Index refresh reminder"
assert "task_description" not in action
assert "silent" not in action
assert action["notify_session_id"] == "session-1"