mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 22:27:13 +08:00
Compare commits
42 Commits
feat-deskt
...
2.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78fb711ad | ||
|
|
b755d18e63 | ||
|
|
3cdc13d69d | ||
|
|
ea0c90f4ea | ||
|
|
f9e7c07af8 | ||
|
|
c4e5d11da9 | ||
|
|
177f494207 | ||
|
|
0c76d851cb | ||
|
|
0783d64f1f | ||
|
|
064740aaad | ||
|
|
8071ea496a | ||
|
|
83209a8b2c | ||
|
|
3db595124c | ||
|
|
6e10b8e3d2 | ||
|
|
2635c12a7f | ||
|
|
64e8f56a7b | ||
|
|
67be468ff1 | ||
|
|
209f1f0a6f | ||
|
|
f7a0184ea2 | ||
|
|
7d8751f04b | ||
|
|
3ee7b164d4 | ||
|
|
d7abbd3baa | ||
|
|
0db8a24441 | ||
|
|
2f86e9b608 | ||
|
|
9d8c6557ae | ||
|
|
257eb5d598 | ||
|
|
9f63453e67 | ||
|
|
5858bda854 | ||
|
|
c784c89fe3 | ||
|
|
186dae059e | ||
|
|
ded3d2c929 | ||
|
|
b55c592714 | ||
|
|
1d226979d1 | ||
|
|
6fad628551 | ||
|
|
8b426ed71d | ||
|
|
c7060c147d | ||
|
|
db49532211 | ||
|
|
1a52de241d | ||
|
|
b35501e6ad | ||
|
|
7f8f690497 | ||
|
|
356b02ac79 | ||
|
|
d5fdd644cf |
6
.github/scripts/register-releases.mjs
vendored
6
.github/scripts/register-releases.mjs
vendored
@@ -74,6 +74,12 @@ for (const base of fs.readdirSync(dir)) {
|
|||||||
} else if (/x64\.zip$/.test(base)) {
|
} else if (/x64\.zip$/.test(base)) {
|
||||||
platform = 'mac-x64'
|
platform = 'mac-x64'
|
||||||
slot = 'upd'
|
slot = 'upd'
|
||||||
|
} else if (/win7.*\.exe$/i.test(base)) {
|
||||||
|
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||||
|
// segment so it never collides with the standard win exe in the same
|
||||||
|
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||||
|
platform = 'win-legacy'
|
||||||
|
slot = 'main'
|
||||||
} else if (/\.exe$/.test(base)) {
|
} else if (/\.exe$/.test(base)) {
|
||||||
platform = 'win'
|
platform = 'win'
|
||||||
slot = 'main'
|
slot = 'main'
|
||||||
|
|||||||
154
.github/workflows/publish-desktop.yml
vendored
154
.github/workflows/publish-desktop.yml
vendored
@@ -1,154 +0,0 @@
|
|||||||
name: Publish Desktop
|
|
||||||
|
|
||||||
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
|
||||||
# to "live". By this point:
|
|
||||||
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
|
||||||
# registered them in D1 as unpublished (is_latest=0);
|
|
||||||
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
|
||||||
# dmgs and re-uploaded the stapled bytes to R2.
|
|
||||||
#
|
|
||||||
# This workflow, triggered manually with the version to publish:
|
|
||||||
# 1. pulls every artifact for that version back from R2,
|
|
||||||
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
|
||||||
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
|
||||||
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
|
||||||
# 4. creates/updates the GitHub Release and attaches the installers.
|
|
||||||
#
|
|
||||||
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
make_latest:
|
|
||||||
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
github_release:
|
|
||||||
description: "Create/update the GitHub Release and attach installers."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish ${{ github.event.inputs.version }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Guard on Cloudflare secrets
|
|
||||||
id: guard
|
|
||||||
env:
|
|
||||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$CF_TOKEN" ]; then
|
|
||||||
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Resolve version artifacts from D1
|
|
||||||
id: rows
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
|
||||||
# Read them back so we know exactly which objects to pull from R2.
|
|
||||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
|
||||||
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
|
||||||
echo "$out"
|
|
||||||
echo "$out" | node -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
||||||
const rows = (Array.isArray(data) ? data : [data])
|
|
||||||
.flatMap(r => (r.results || []));
|
|
||||||
if (!rows.length) {
|
|
||||||
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
|
||||||
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Download version artifacts from R2
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
|
||||||
# auto-update file (update_filename: zip) for every row. Keys are
|
|
||||||
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
|
||||||
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
|
||||||
base="$(basename "$key")"
|
|
||||||
r2key="desktop/${key}"
|
|
||||||
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
|
||||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
|
||||||
--file "dist/${base}" --remote
|
|
||||||
done
|
|
||||||
echo "Downloaded:"; ls -la dist
|
|
||||||
|
|
||||||
- name: Reminder — mac dmgs must be notarized before publishing
|
|
||||||
run: |
|
|
||||||
# Stapling can only be validated on macOS (xcrun stapler validate),
|
|
||||||
# which this Linux runner doesn't have. The authoritative check runs in
|
|
||||||
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
|
||||||
# This step is just a loud reminder in the log.
|
|
||||||
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
|
||||||
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
|
||||||
|
|
||||||
- name: Update D1 (recompute sha512 + set latest)
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
|
||||||
run: |
|
|
||||||
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
|
||||||
# become latest, so the site keeps serving the last stable build.
|
|
||||||
case "$VER" in
|
|
||||||
*-*) is_pre=1 ;;
|
|
||||||
*) is_pre=0 ;;
|
|
||||||
esac
|
|
||||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
|
||||||
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
|
||||||
else
|
|
||||||
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Re-hash the real (stapled) bytes and re-store every row with both the
|
|
||||||
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
|
||||||
# build stage; --latest also clears the previous latest per platform.
|
|
||||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
|
||||||
echo "==> D1 statements:"; cat d1.sql
|
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
|
||||||
|
|
||||||
- name: Create/update GitHub Release and attach installers
|
|
||||||
if: github.event.inputs.github_release == 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
tag="v${VER}"
|
|
||||||
case "$VER" in
|
|
||||||
*-*) prerelease="--prerelease" ;;
|
|
||||||
*) prerelease="" ;;
|
|
||||||
esac
|
|
||||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "$tag" --generate-notes $prerelease
|
|
||||||
fi
|
|
||||||
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
|
||||||
# zip is the auto-update artifact; attach it too so the GitHub Release
|
|
||||||
# is a complete mirror (nullglob avoids errors when a type is absent).
|
|
||||||
shopt -s nullglob
|
|
||||||
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
|
||||||
--repo "$GITHUB_REPOSITORY" --clobber
|
|
||||||
230
.github/workflows/release-win7.yml
vendored
Normal file
230
.github/workflows/release-win7.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: Release Desktop (Win7 legacy)
|
||||||
|
|
||||||
|
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
|
||||||
|
#
|
||||||
|
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
|
||||||
|
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
|
||||||
|
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
|
||||||
|
#
|
||||||
|
# To support Win7 we must pin BOTH halves to the last versions that still
|
||||||
|
# target it:
|
||||||
|
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
|
||||||
|
# - Python 3.8 (last CPython to support Win7)
|
||||||
|
#
|
||||||
|
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
|
||||||
|
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
|
||||||
|
# then — exactly like the main pipeline — uploads it to R2 and registers a
|
||||||
|
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
|
||||||
|
# stays invisible until promoted, so it can't accidentally get served to Win10
|
||||||
|
# users). Because it's stamped with the SAME version as the standard release,
|
||||||
|
# the download page shows both Windows builds under one version row, and the
|
||||||
|
# /update feed serves each build to its own clients. Delete this file whenever
|
||||||
|
# legacy Windows support is no longer worth maintaining — the main pipeline is
|
||||||
|
# unaffected.
|
||||||
|
#
|
||||||
|
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
|
||||||
|
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Windows x64 (Win7 legacy)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Python 3.8 is the last CPython that supports Windows 7. A backend built
|
||||||
|
# with it (via PyInstaller) still runs on Win7 even though the CI host is
|
||||||
|
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
|
||||||
|
# OS, not the build machine's.
|
||||||
|
- name: Set up Python 3.8
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Build Python backend (PyInstaller, Python 3.8)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
|
||||||
|
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
|
||||||
|
# for this legacy build. We rewrite them into a throwaway requirements
|
||||||
|
# file so the repo's source stays untouched (main pipeline keeps its
|
||||||
|
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
|
||||||
|
sed 's/^playwright==.*/playwright==1.48.0/' \
|
||||||
|
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
|
||||||
|
pip install -r /tmp/requirements-win7.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
# Run from repo root so the spec's relative datas resolve correctly.
|
||||||
|
pyinstaller desktop/build/cowagent-backend.spec \
|
||||||
|
--noconfirm \
|
||||||
|
--distpath desktop/build/dist \
|
||||||
|
--workpath desktop/build/build-work
|
||||||
|
|
||||||
|
- name: Install desktop deps
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Write version into package.json
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
|
||||||
|
# this out of package.json so the repo's committed deps stay on Electron 33
|
||||||
|
# for the main pipeline. electron-builder reads the installed Electron
|
||||||
|
# version from node_modules, so this is all that's needed to package v22.
|
||||||
|
- name: Pin Electron to 22 (last Win7-capable)
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm install --no-save electron@22.3.27
|
||||||
|
|
||||||
|
- name: Compile (vite + tsc)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Same signing setup as the main pipeline: download the signtool CLI (URL
|
||||||
|
# from a repo variable so nothing is hardcoded in a public workflow). Only
|
||||||
|
# runs when a URL is configured; otherwise the build stays unsigned but
|
||||||
|
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
|
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
|
||||||
|
# hook the main pipeline uses (signs app + backend + installer via the
|
||||||
|
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
|
||||||
|
# installer is still produced (unsigned). --publish never emits the exe
|
||||||
|
# without touching any feed. Invoke via node (not npx) to avoid the
|
||||||
|
# Windows npx.cmd wrapper returning early (see release.yml).
|
||||||
|
#
|
||||||
|
# -c.win.artifactName injects a "win7" segment into the file name
|
||||||
|
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
|
||||||
|
# builds differ by ${arch}: it keeps the legacy exe from colliding with
|
||||||
|
# the standard win exe in the same v<version>/ folder, and lets
|
||||||
|
# register-releases.mjs map it to the win-legacy platform by name.
|
||||||
|
- name: Build installer (electron-builder, Electron 22)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
|
run: |
|
||||||
|
node node_modules/electron-builder/cli.js --win --x64 \
|
||||||
|
--config electron-builder.win.js \
|
||||||
|
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
|
||||||
|
--publish never
|
||||||
|
|
||||||
|
# Collect the installer + its blockmap (differential updates). The .yml
|
||||||
|
# feed is NOT uploaded: the /update Function generates it dynamically from
|
||||||
|
# D1 (same as the main pipeline), so it isn't needed here.
|
||||||
|
- name: Upload installer artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: |
|
||||||
|
desktop/release/*.exe
|
||||||
|
desktop/release/*.blockmap
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
|
||||||
|
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
|
||||||
|
# wrangler@latest works (the build job pins Node 20 for Electron 22).
|
||||||
|
#
|
||||||
|
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
|
||||||
|
# build — its "win7" name segment keeps them distinct — and register-releases
|
||||||
|
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
|
||||||
|
# publish workflow). Because the version matches the standard release, the
|
||||||
|
# download page merges both Windows builds into one version row.
|
||||||
|
publish:
|
||||||
|
name: Publish to R2 + D1
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$CF_TOKEN" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Upload installer to R2
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in dist/*.exe dist/*.blockmap; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
|
||||||
|
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||||
|
--file "$f" --remote
|
||||||
|
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
|
||||||
|
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
|
||||||
|
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
|
||||||
|
- name: Register release row in D1
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
@@ -113,7 +113,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -202,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
|||||||
|
|
||||||
## 🏷 Changelog
|
## 🏷 Changelog
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — Desktop experience improvements, MCP OAuth authorization, Lark channel enhancements, scheduler improvements and data backup, new models.
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
||||||
|
|||||||
@@ -183,9 +183,15 @@ class ChatService:
|
|||||||
|
|
||||||
# Register a cancel token so /cancel can abort this in-flight run.
|
# Register a cancel token so /cancel can abort this in-flight run.
|
||||||
# IM channels key on session_id (no per-turn request_id here).
|
# IM channels key on session_id (no per-turn request_id here).
|
||||||
from agent.protocol import get_cancel_registry
|
from agent.protocol import get_cancel_registry, get_steer_registry
|
||||||
registry = get_cancel_registry()
|
registry = get_cancel_registry()
|
||||||
|
steer_registry = get_steer_registry()
|
||||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||||
|
steer_inbox = (
|
||||||
|
steer_registry.register(session_id)
|
||||||
|
if session_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
executor = AgentStreamExecutor(
|
executor = AgentStreamExecutor(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
@@ -197,6 +203,7 @@ class ChatService:
|
|||||||
messages=messages_copy,
|
messages=messages_copy,
|
||||||
max_context_turns=max_context_turns,
|
max_context_turns=max_context_turns,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
steer_inbox=steer_inbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -217,6 +224,8 @@ class ChatService:
|
|||||||
registry.unregister(session_id)
|
registry.unregister(session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if session_id and steer_inbox is not None:
|
||||||
|
steer_registry.unregister(session_id, steer_inbox)
|
||||||
|
|
||||||
# Sync executor messages back to agent (thread-safe).
|
# Sync executor messages back to agent (thread-safe).
|
||||||
# The executor may have trimmed context, making its list shorter than
|
# The executor may have trimmed context, making its list shorter than
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ from .cancel import (
|
|||||||
CancelTokenRegistry,
|
CancelTokenRegistry,
|
||||||
get_cancel_registry,
|
get_cancel_registry,
|
||||||
)
|
)
|
||||||
|
from .steer import (
|
||||||
|
SteerInbox,
|
||||||
|
SteerRegistry,
|
||||||
|
SteerResult,
|
||||||
|
SteerStatus,
|
||||||
|
get_steer_registry,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Agent',
|
'Agent',
|
||||||
@@ -25,4 +32,9 @@ __all__ = [
|
|||||||
'AgentCancelledError',
|
'AgentCancelledError',
|
||||||
'CancelTokenRegistry',
|
'CancelTokenRegistry',
|
||||||
'get_cancel_registry',
|
'get_cancel_registry',
|
||||||
|
'SteerInbox',
|
||||||
|
'SteerRegistry',
|
||||||
|
'SteerResult',
|
||||||
|
'SteerStatus',
|
||||||
|
'get_steer_registry',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ class Agent:
|
|||||||
return action
|
return action
|
||||||
|
|
||||||
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
||||||
skill_filter=None, cancel_event=None) -> str:
|
skill_filter=None, cancel_event=None, steer_inbox=None) -> str:
|
||||||
"""
|
"""
|
||||||
Execute single agent task with streaming (based on tool-call)
|
Execute single agent task with streaming (based on tool-call)
|
||||||
|
|
||||||
@@ -391,6 +391,7 @@ class Agent:
|
|||||||
- Event callbacks
|
- Event callbacks
|
||||||
- Persistent conversation history across calls
|
- Persistent conversation history across calls
|
||||||
- User-initiated cancellation via ``cancel_event``
|
- User-initiated cancellation via ``cancel_event``
|
||||||
|
- Explicit active-turn guidance via ``steer_inbox``
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_message: User message
|
user_message: User message
|
||||||
@@ -403,6 +404,8 @@ class Agent:
|
|||||||
"[Interrupted by user]" assistant note, and returns the
|
"[Interrupted by user]" assistant note, and returns the
|
||||||
partial response. ``messages`` stays in a valid state
|
partial response. ``messages`` stays in a valid state
|
||||||
(tool_use/tool_result pairs preserved).
|
(tool_use/tool_result pairs preserved).
|
||||||
|
steer_inbox: Optional SteerInbox drained at safe checkpoints. New
|
||||||
|
instructions guide this run without entering the normal queue.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Final response text
|
Final response text
|
||||||
@@ -448,6 +451,7 @@ class Agent:
|
|||||||
messages=messages_copy, # Pass copied message history
|
messages=messages_copy, # Pass copied message history
|
||||||
max_context_turns=max_context_turns,
|
max_context_turns=max_context_turns,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
steer_inbox=steer_inbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
@@ -484,4 +488,4 @@ class Agent:
|
|||||||
def clear_history(self):
|
def clear_history(self):
|
||||||
"""Clear conversation history and captured actions"""
|
"""Clear conversation history and captured actions"""
|
||||||
self.messages = []
|
self.messages = []
|
||||||
self.captured_actions = []
|
self.captured_actions = []
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class AgentStreamExecutor:
|
|||||||
messages: Optional[List[Dict]] = None,
|
messages: Optional[List[Dict]] = None,
|
||||||
max_context_turns: int = 30,
|
max_context_turns: int = 30,
|
||||||
cancel_event=None,
|
cancel_event=None,
|
||||||
|
steer_inbox=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize stream executor
|
Initialize stream executor
|
||||||
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
|
|||||||
Checked at every safe point (turn boundary, before tool execution,
|
Checked at every safe point (turn boundary, before tool execution,
|
||||||
during LLM streaming). When set, raises AgentCancelledError which
|
during LLM streaming). When set, raises AgentCancelledError which
|
||||||
run_stream catches to gracefully wind down.
|
run_stream catches to gracefully wind down.
|
||||||
|
steer_inbox: Optional SteerInbox for explicit instructions sent to
|
||||||
|
this active run. Drained only at message-safe checkpoints.
|
||||||
"""
|
"""
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
|
|||||||
self.on_event = on_event
|
self.on_event = on_event
|
||||||
self.max_context_turns = max_context_turns
|
self.max_context_turns = max_context_turns
|
||||||
self.cancel_event = cancel_event
|
self.cancel_event = cancel_event
|
||||||
|
self.steer_inbox = steer_inbox
|
||||||
|
|
||||||
# Message history - use provided messages or create new list
|
# Message history - use provided messages or create new list
|
||||||
self.messages = messages if messages is not None else []
|
self.messages = messages if messages is not None else []
|
||||||
@@ -145,6 +149,72 @@ class AgentStreamExecutor:
|
|||||||
if self.cancel_event is not None and self.cancel_event.is_set():
|
if self.cancel_event is not None and self.cancel_event.is_set():
|
||||||
raise AgentCancelledError("agent cancelled by user")
|
raise AgentCancelledError("agent cancelled by user")
|
||||||
|
|
||||||
|
def _drain_steering(self) -> List[str]:
|
||||||
|
if self.steer_inbox is None:
|
||||||
|
return []
|
||||||
|
return self.steer_inbox.drain()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _steering_text(updates: List[str]) -> str:
|
||||||
|
if len(updates) == 1:
|
||||||
|
body = updates[0]
|
||||||
|
else:
|
||||||
|
body = "\n".join(f"{idx}. {text}" for idx, text in enumerate(updates, 1))
|
||||||
|
return (
|
||||||
|
"[Steering update for the active task]\n"
|
||||||
|
"Use this new instruction for the current task before continuing.\n\n"
|
||||||
|
f"{body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _append_steering(
|
||||||
|
self,
|
||||||
|
updates: List[str],
|
||||||
|
pending_tool_calls: Optional[List[Dict]] = None,
|
||||||
|
content_blocks: Optional[List[Dict]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append guidance, closing any tool_use blocks that will be skipped."""
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
blocks = content_blocks if content_blocks is not None else []
|
||||||
|
for tool_call in pending_tool_calls or []:
|
||||||
|
blocks.append({
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tool_call["id"],
|
||||||
|
"content": "Skipped because the user redirected the active task.",
|
||||||
|
"is_error": True,
|
||||||
|
})
|
||||||
|
blocks.append({"type": "text", "text": self._steering_text(updates)})
|
||||||
|
if content_blocks is None:
|
||||||
|
self.messages.append({"role": "user", "content": blocks})
|
||||||
|
self._emit_event("agent_steered", {"count": len(updates)})
|
||||||
|
logger.info(f"[Agent] Applied {len(updates)} steering update(s)")
|
||||||
|
|
||||||
|
def _close_or_apply_final_steering(self) -> bool:
|
||||||
|
"""Return True only when the run can finish without losing a steer."""
|
||||||
|
updates = self._drain_steering()
|
||||||
|
if updates:
|
||||||
|
self._append_steering(updates)
|
||||||
|
return False
|
||||||
|
if self.steer_inbox is None:
|
||||||
|
return True
|
||||||
|
if self.steer_inbox.close_if_empty():
|
||||||
|
return True
|
||||||
|
updates = self._drain_steering()
|
||||||
|
if updates:
|
||||||
|
self._append_steering(updates)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _drain_and_close_steering(self) -> None:
|
||||||
|
"""Preserve any final guidance before the max-step summary call."""
|
||||||
|
if self.steer_inbox is None:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
updates = self._drain_steering()
|
||||||
|
if updates:
|
||||||
|
self._append_steering(updates)
|
||||||
|
if self.steer_inbox.close_if_empty():
|
||||||
|
return
|
||||||
|
|
||||||
def _handle_cancelled(self, partial_response: str) -> None:
|
def _handle_cancelled(self, partial_response: str) -> None:
|
||||||
"""Wind down ``self.messages`` after a user-initiated cancel.
|
"""Wind down ``self.messages`` after a user-initiated cancel.
|
||||||
|
|
||||||
@@ -395,6 +465,10 @@ class AgentStreamExecutor:
|
|||||||
# between turns short-circuits cleanly.
|
# between turns short-circuits cleanly.
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
|
|
||||||
|
steering_updates = self._drain_steering()
|
||||||
|
if steering_updates:
|
||||||
|
self._append_steering(steering_updates)
|
||||||
|
|
||||||
turn += 1
|
turn += 1
|
||||||
logger.info(f"[Agent] Turn {turn}")
|
logger.info(f"[Agent] Turn {turn}")
|
||||||
self._emit_event("turn_start", {"turn": turn})
|
self._emit_event("turn_start", {"turn": turn})
|
||||||
@@ -403,6 +477,24 @@ class AgentStreamExecutor:
|
|||||||
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
|
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
|
||||||
final_response = assistant_msg
|
final_response = assistant_msg
|
||||||
|
|
||||||
|
# A steer that arrived while the model was streaming takes
|
||||||
|
# precedence over its proposed continuation. Tool calls have
|
||||||
|
# already been written to history, so close every one with a
|
||||||
|
# synthetic result before asking the model to reconsider.
|
||||||
|
steering_updates = self._drain_steering()
|
||||||
|
if steering_updates:
|
||||||
|
self._append_steering(
|
||||||
|
steering_updates,
|
||||||
|
pending_tool_calls=tool_calls,
|
||||||
|
)
|
||||||
|
self._emit_event("turn_end", {
|
||||||
|
"turn": turn,
|
||||||
|
"has_tool_calls": bool(tool_calls),
|
||||||
|
"tool_count": len(tool_calls),
|
||||||
|
"steered": True,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
# No tool calls, end loop
|
# No tool calls, end loop
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
# 检查是否返回了空响应
|
# 检查是否返回了空响应
|
||||||
@@ -467,6 +559,22 @@ class AgentStreamExecutor:
|
|||||||
# If the explicit-response retry produced tool_calls, skip the break
|
# If the explicit-response retry produced tool_calls, skip the break
|
||||||
# and continue down to the tool execution branch in this same iteration.
|
# and continue down to the tool execution branch in this same iteration.
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
|
steering_updates = self._drain_steering()
|
||||||
|
if steering_updates:
|
||||||
|
self._append_steering(steering_updates)
|
||||||
|
self._emit_event("turn_end", {
|
||||||
|
"turn": turn,
|
||||||
|
"has_tool_calls": False,
|
||||||
|
"steered": True,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
if not self._close_or_apply_final_steering():
|
||||||
|
self._emit_event("turn_end", {
|
||||||
|
"turn": turn,
|
||||||
|
"has_tool_calls": False,
|
||||||
|
"steered": True,
|
||||||
|
})
|
||||||
|
continue
|
||||||
logger.debug(f"✅ Done (no tool calls)")
|
logger.debug(f"✅ Done (no tool calls)")
|
||||||
self._emit_event("turn_end", {
|
self._emit_event("turn_end", {
|
||||||
"turn": turn,
|
"turn": turn,
|
||||||
@@ -499,9 +607,17 @@ class AgentStreamExecutor:
|
|||||||
tool_result_blocks = []
|
tool_result_blocks = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for tool_call in tool_calls:
|
for tool_index, tool_call in enumerate(tool_calls):
|
||||||
# Honour cancel between tool invocations within the same turn
|
# Honour cancel between tool invocations within the same turn
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
|
steering_updates = self._drain_steering()
|
||||||
|
if steering_updates:
|
||||||
|
self._append_steering(
|
||||||
|
steering_updates,
|
||||||
|
pending_tool_calls=tool_calls[tool_index:],
|
||||||
|
content_blocks=tool_result_blocks,
|
||||||
|
)
|
||||||
|
break
|
||||||
result = self._execute_tool(tool_call)
|
result = self._execute_tool(tool_call)
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
|
|
||||||
@@ -641,6 +757,7 @@ class AgentStreamExecutor:
|
|||||||
|
|
||||||
if turn >= self.max_turns:
|
if turn >= self.max_turns:
|
||||||
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
|
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
|
||||||
|
self._drain_and_close_steering()
|
||||||
|
|
||||||
# Force model to summarize without tool calls
|
# Force model to summarize without tool calls
|
||||||
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
||||||
@@ -699,6 +816,8 @@ class AgentStreamExecutor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
if self.steer_inbox is not None:
|
||||||
|
self.steer_inbox.close()
|
||||||
final_response = final_response.strip() if final_response else final_response
|
final_response = final_response.strip() if final_response else final_response
|
||||||
if cancelled:
|
if cancelled:
|
||||||
# Emit before agent_end so channels can mark UI as cancelled
|
# Emit before agent_end so channels can mark UI as cancelled
|
||||||
|
|||||||
127
agent/protocol/steer.py
Normal file
127
agent/protocol/steer.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Thread-safe active-run steering primitives.
|
||||||
|
|
||||||
|
Steering is deliberately separate from the normal per-session message queue.
|
||||||
|
An instruction is accepted only while exactly one run for the scoped session
|
||||||
|
is active; idle sessions never start a new run as a side effect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Deque, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
|
||||||
|
class SteerStatus(str, Enum):
|
||||||
|
ACCEPTED = "accepted"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
AMBIGUOUS = "ambiguous"
|
||||||
|
INVALID = "invalid"
|
||||||
|
FULL = "full"
|
||||||
|
CLOSING = "closing"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SteerResult:
|
||||||
|
status: SteerStatus
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accepted(self) -> bool:
|
||||||
|
return self.status == SteerStatus.ACCEPTED
|
||||||
|
|
||||||
|
|
||||||
|
class SteerInbox:
|
||||||
|
"""Bounded inbox owned by one active agent run."""
|
||||||
|
|
||||||
|
def __init__(self, max_pending: int = 16, max_chars: int = 8000):
|
||||||
|
self.max_pending = max(1, int(max_pending))
|
||||||
|
self.max_chars = max(1, int(max_chars))
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._pending: Deque[str] = deque()
|
||||||
|
self._accepting = True
|
||||||
|
|
||||||
|
def submit(self, instruction: str) -> SteerResult:
|
||||||
|
text = (instruction or "").strip()
|
||||||
|
if not text or len(text) > self.max_chars:
|
||||||
|
return SteerResult(SteerStatus.INVALID)
|
||||||
|
with self._lock:
|
||||||
|
if not self._accepting:
|
||||||
|
return SteerResult(SteerStatus.CLOSING)
|
||||||
|
if len(self._pending) >= self.max_pending:
|
||||||
|
return SteerResult(SteerStatus.FULL)
|
||||||
|
self._pending.append(text)
|
||||||
|
return SteerResult(SteerStatus.ACCEPTED)
|
||||||
|
|
||||||
|
def drain(self) -> List[str]:
|
||||||
|
with self._lock:
|
||||||
|
items = list(self._pending)
|
||||||
|
self._pending.clear()
|
||||||
|
return items
|
||||||
|
|
||||||
|
def close_if_empty(self) -> bool:
|
||||||
|
"""Atomically stop accepting when no instruction is pending.
|
||||||
|
|
||||||
|
This closes the race between a final empty drain and an agent run
|
||||||
|
returning: after this method succeeds, submitters receive CLOSING.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._pending:
|
||||||
|
return False
|
||||||
|
self._accepting = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._accepting = False
|
||||||
|
|
||||||
|
|
||||||
|
class SteerRegistry:
|
||||||
|
"""Map a scoped agent/session key to its active run inboxes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._by_session: Dict[str, Set[SteerInbox]] = {}
|
||||||
|
|
||||||
|
def register(self, session_id: str, inbox: Optional[SteerInbox] = None) -> SteerInbox:
|
||||||
|
inbox = inbox or SteerInbox()
|
||||||
|
if not session_id:
|
||||||
|
return inbox
|
||||||
|
with self._lock:
|
||||||
|
self._by_session.setdefault(session_id, set()).add(inbox)
|
||||||
|
return inbox
|
||||||
|
|
||||||
|
def unregister(self, session_id: str, inbox: Optional[SteerInbox]) -> None:
|
||||||
|
if not session_id or inbox is None:
|
||||||
|
return
|
||||||
|
inbox.close()
|
||||||
|
with self._lock:
|
||||||
|
bucket = self._by_session.get(session_id)
|
||||||
|
if bucket is None:
|
||||||
|
return
|
||||||
|
bucket.discard(inbox)
|
||||||
|
if not bucket:
|
||||||
|
self._by_session.pop(session_id, None)
|
||||||
|
|
||||||
|
def submit(self, session_id: str, instruction: str) -> SteerResult:
|
||||||
|
if not (instruction or "").strip():
|
||||||
|
return SteerResult(SteerStatus.INVALID)
|
||||||
|
with self._lock:
|
||||||
|
inboxes = list(self._by_session.get(session_id, ()))
|
||||||
|
if not inboxes:
|
||||||
|
return SteerResult(SteerStatus.INACTIVE)
|
||||||
|
if len(inboxes) != 1:
|
||||||
|
return SteerResult(SteerStatus.AMBIGUOUS)
|
||||||
|
return inboxes[0].submit(instruction)
|
||||||
|
|
||||||
|
def active_count(self, session_id: str) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._by_session.get(session_id, ()))
|
||||||
|
|
||||||
|
|
||||||
|
_registry = SteerRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_steer_registry() -> SteerRegistry:
|
||||||
|
return _registry
|
||||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
|||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# BrowserTool (requires playwright)
|
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||||
|
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||||
|
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError as e:
|
|
||||||
logger.info(
|
|
||||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ period of inactivity to free resources.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
@@ -215,6 +216,14 @@ _SNAPSHOT_JS = """
|
|||||||
str(list(_INTERACTIVE_TAGS)),
|
str(list(_INTERACTIVE_TAGS)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Returning the snapshot as ONE JSON string instead of a nested object is a big
|
||||||
|
# win in the frozen desktop build: Playwright serializes a nested return value
|
||||||
|
# node-by-node over many driver<->python protocol round trips, and each round
|
||||||
|
# trip carries fixed overhead that is dramatically amplified in the frozen
|
||||||
|
# bundle (a ~300-node tree can take 20s+). JSON.stringify in-page collapses it
|
||||||
|
# to a single string transfer; Python then json.loads it. Behaviour identical.
|
||||||
|
_SNAPSHOT_JS_STR = "() => JSON.stringify((%s)())" % _SNAPSHOT_JS.strip()
|
||||||
|
|
||||||
|
|
||||||
_BROWSER_DEAD_HINTS = (
|
_BROWSER_DEAD_HINTS = (
|
||||||
"has been closed",
|
"has been closed",
|
||||||
@@ -321,6 +330,14 @@ class BrowserService:
|
|||||||
self._context = None
|
self._context = None
|
||||||
self._page = None
|
self._page = None
|
||||||
|
|
||||||
|
# When we drive a system Chrome/Edge, we spawn it ourselves with a
|
||||||
|
# debugging port and attach over CDP (see chrome_launcher). This avoids
|
||||||
|
# the macOS Automation prompt + multi-second stall that
|
||||||
|
# chromium.launch(channel=...) incurs. Holds the child process owner.
|
||||||
|
self._chrome_launcher = None
|
||||||
|
# Path to the system browser executable when using system-chrome mode.
|
||||||
|
self._system_exe: Optional[str] = None
|
||||||
|
|
||||||
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
||||||
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
||||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||||
@@ -352,13 +369,29 @@ class BrowserService:
|
|||||||
|
|
||||||
# Resolve which browser engine to drive (system Chrome vs downloaded
|
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||||
# Chromium). Deferred detection failures are surfaced at launch time.
|
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||||
|
#
|
||||||
|
# For a system Chrome/Edge we DON'T use chromium.launch(channel=...):
|
||||||
|
# that "takes over" another app and triggers the macOS Automation
|
||||||
|
# prompt + a long stall. Instead we spawn the browser ourselves with a
|
||||||
|
# debugging port and attach over CDP (self._launch_mode = "system-cdp").
|
||||||
|
# `self._system_exe` is the browser executable; the persistent
|
||||||
|
# user_data_dir keeps login state across sessions.
|
||||||
if self._launch_mode != "cdp":
|
if self._launch_mode != "cdp":
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_env import resolve_engine
|
from agent.tools.browser.browser_env import resolve_engine
|
||||||
engine = resolve_engine(self._config)
|
engine = resolve_engine(self._config)
|
||||||
if engine["mode"] == "system-chrome":
|
if engine["mode"] == "system-chrome":
|
||||||
self._channel = engine["channel"]
|
self._channel = engine["channel"]
|
||||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
self._system_exe = engine.get("path")
|
||||||
|
# Only switch to spawn+CDP when we actually know the exe
|
||||||
|
# path (macOS/Windows/Linux detection returns it). Persist
|
||||||
|
# login state in a dedicated profile dir.
|
||||||
|
if self._system_exe:
|
||||||
|
self._launch_mode = "system-cdp"
|
||||||
|
if not self._user_data_dir:
|
||||||
|
self._user_data_dir = expand_path(_DEFAULT_USER_DATA_DIR)
|
||||||
|
logger.info(f"[Browser] Engine resolved: {engine['reason']} "
|
||||||
|
f"(spawn+CDP={bool(self._system_exe)})")
|
||||||
elif engine["mode"] == "playwright-chromium":
|
elif engine["mode"] == "playwright-chromium":
|
||||||
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||||
else:
|
else:
|
||||||
@@ -463,7 +496,18 @@ class BrowserService:
|
|||||||
headless_cfg = self._config.get("headless")
|
headless_cfg = self._config.get("headless")
|
||||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
launch_args = ["--disable-dev-shm-usage"]
|
launch_args = [
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||||
|
# browser prompt, and Chrome's background/component network chatter.
|
||||||
|
# These have no effect on page interaction but noticeably speed up
|
||||||
|
# cold starts and each navigation.
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-features=Translate,OptimizationHints",
|
||||||
|
]
|
||||||
if self._headless:
|
if self._headless:
|
||||||
launch_args.append("--no-sandbox")
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
@@ -498,6 +542,8 @@ class BrowserService:
|
|||||||
|
|
||||||
if self._launch_mode == "cdp":
|
if self._launch_mode == "cdp":
|
||||||
self._connect_cdp(viewport)
|
self._connect_cdp(viewport)
|
||||||
|
elif self._launch_mode == "system-cdp":
|
||||||
|
self._launch_system_cdp(launch_args, viewport)
|
||||||
elif self._launch_mode == "persistent":
|
elif self._launch_mode == "persistent":
|
||||||
self._launch_persistent(launch_args, viewport, user_agent)
|
self._launch_persistent(launch_args, viewport, user_agent)
|
||||||
else:
|
else:
|
||||||
@@ -566,6 +612,41 @@ class BrowserService:
|
|||||||
self._page = pages[0] if pages else self._context.new_page()
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
self._wire_close_listeners()
|
self._wire_close_listeners()
|
||||||
|
|
||||||
|
def _launch_system_cdp(self, launch_args: List[str], viewport: Dict[str, int]):
|
||||||
|
"""Spawn the user's system Chrome/Edge with a debugging port, attach via CDP.
|
||||||
|
|
||||||
|
This is the default for system browsers. Unlike launch(channel=...), it
|
||||||
|
does not "take over" the browser app, so it avoids the macOS Automation
|
||||||
|
prompt / long stall. Login state persists in the isolated user_data_dir.
|
||||||
|
"""
|
||||||
|
from agent.tools.browser.chrome_launcher import ChromeLauncher
|
||||||
|
|
||||||
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
logger.info(
|
||||||
|
f"[Browser] Launching system:{self._channel} via spawn+CDP "
|
||||||
|
f"(headless={self._headless}, profile={self._user_data_dir})"
|
||||||
|
)
|
||||||
|
self._chrome_launcher = ChromeLauncher(
|
||||||
|
executable=self._system_exe,
|
||||||
|
user_data_dir=self._user_data_dir,
|
||||||
|
extra_args=launch_args,
|
||||||
|
headless=self._headless,
|
||||||
|
)
|
||||||
|
endpoint = self._chrome_launcher.launch()
|
||||||
|
|
||||||
|
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
|
||||||
|
# The spawned Chrome opens its own default context (backed by
|
||||||
|
# user_data_dir); reuse it so cookies / logins persist.
|
||||||
|
contexts = self._browser.contexts
|
||||||
|
self._context = contexts[0] if contexts else self._browser.new_context(viewport=viewport)
|
||||||
|
pages = self._context.pages
|
||||||
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
|
try:
|
||||||
|
self._page.set_viewport_size(viewport)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._wire_close_listeners()
|
||||||
|
|
||||||
def _connect_cdp(self, viewport: Dict[str, int]):
|
def _connect_cdp(self, viewport: Dict[str, int]):
|
||||||
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
||||||
endpoint = self._cdp_endpoint
|
endpoint = self._cdp_endpoint
|
||||||
@@ -620,13 +701,27 @@ class BrowserService:
|
|||||||
self._cancel_idle_timer()
|
self._cancel_idle_timer()
|
||||||
|
|
||||||
if self._launch_mode == "cdp":
|
if self._launch_mode == "cdp":
|
||||||
# For CDP, browser.close() only detaches the Playwright client;
|
# For external CDP, browser.close() only detaches the Playwright
|
||||||
# the user's Chrome process and its tabs stay alive.
|
# client; the user's Chrome process and its tabs stay alive.
|
||||||
try:
|
try:
|
||||||
if self._browser:
|
if self._browser:
|
||||||
self._browser.close()
|
self._browser.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
||||||
|
elif self._launch_mode == "system-cdp":
|
||||||
|
# We own the spawned Chrome: detach the CDP client, then kill the
|
||||||
|
# process we started so it doesn't linger.
|
||||||
|
try:
|
||||||
|
if self._browser:
|
||||||
|
self._browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] system-cdp disconnect error: {e}")
|
||||||
|
try:
|
||||||
|
if self._chrome_launcher:
|
||||||
|
self._chrome_launcher.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] chrome launcher close error: {e}")
|
||||||
|
self._chrome_launcher = None
|
||||||
else:
|
else:
|
||||||
for obj, label in [
|
for obj, label in [
|
||||||
(self._context, "context"),
|
(self._context, "context"),
|
||||||
@@ -733,11 +828,15 @@ class BrowserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Navigation failed: {e}"}
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||||
|
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||||
|
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||||
|
# short grace period for initial render/XHR, then proceed.
|
||||||
try:
|
try:
|
||||||
page.wait_for_load_state("networkidle", timeout=8000)
|
page.wait_for_load_state("networkidle", timeout=1500)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
title = page.title()
|
title = page.title()
|
||||||
@@ -756,7 +855,11 @@ class BrowserService:
|
|||||||
def _do_snapshot(self, selector: Optional[str] = None) -> str:
|
def _do_snapshot(self, selector: Optional[str] = None) -> str:
|
||||||
page = self._page
|
page = self._page
|
||||||
try:
|
try:
|
||||||
result = page.evaluate(_SNAPSHOT_JS)
|
# Return a single JSON string (not a nested object) to avoid
|
||||||
|
# Playwright's per-node serialization round trips, which are slow
|
||||||
|
# in the frozen build. See _SNAPSHOT_JS_STR.
|
||||||
|
raw = page.evaluate(_SNAPSHOT_JS_STR)
|
||||||
|
result = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"[Snapshot error: {e}]"
|
return f"[Snapshot error: {e}]"
|
||||||
|
|
||||||
|
|||||||
174
agent/tools/browser/chrome_launcher.py
Normal file
174
agent/tools/browser/chrome_launcher.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Spawn a system Chrome/Edge with a DevTools debugging port for CDP control.
|
||||||
|
|
||||||
|
Why this exists: driving a system browser via Playwright's
|
||||||
|
``chromium.launch(channel="chrome")`` makes the app *take over* another app's
|
||||||
|
process, which on macOS triggers a TCC "Automation" permission prompt and a
|
||||||
|
multi-second (sometimes 100s+) stall on first use. Launching Chrome ourselves
|
||||||
|
with ``--remote-debugging-port`` and attaching via ``connect_over_cdp`` avoids
|
||||||
|
that entirely — from the OS's view it's just a process listening on a local
|
||||||
|
port — and matches how Codex / Claude Code drive the user's real browser.
|
||||||
|
|
||||||
|
The launched process uses an isolated ``--user-data-dir`` so it never fights
|
||||||
|
the user's day-to-day browser profile, while still persisting login state
|
||||||
|
across sessions inside that dir.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class ChromeLauncher:
|
||||||
|
"""Own the lifecycle of a debugging-enabled Chrome/Edge child process."""
|
||||||
|
|
||||||
|
def __init__(self, executable: str, user_data_dir: str,
|
||||||
|
extra_args: Optional[List[str]] = None,
|
||||||
|
headless: bool = False):
|
||||||
|
self._executable = executable
|
||||||
|
self._user_data_dir = user_data_dir
|
||||||
|
self._extra_args = extra_args or []
|
||||||
|
self._headless = headless
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._port: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint(self) -> str:
|
||||||
|
"""CDP HTTP endpoint (only valid after a successful launch())."""
|
||||||
|
return f"http://127.0.0.1:{self._port}" if self._port else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _free_port() -> int:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
def _clear_stale_singleton_locks(self):
|
||||||
|
"""Remove leftover Chrome Singleton* locks from a crashed/killed run.
|
||||||
|
|
||||||
|
Chrome allows only one instance per user_data_dir and enforces it with
|
||||||
|
SingletonLock / SingletonSocket / SingletonCookie. On a clean exit these
|
||||||
|
are removed, but a crash or force-quit leaves them behind — the next
|
||||||
|
spawn then hands off to the (dead) "existing" instance and exits without
|
||||||
|
opening the debug port, so CDP never comes up (a permanent, non
|
||||||
|
self-healing failure). This profile is private to us, so clearing stale
|
||||||
|
locks before launch is safe: if our own browser were truly alive, the
|
||||||
|
service would still be connected and we wouldn't be re-launching.
|
||||||
|
"""
|
||||||
|
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||||
|
p = os.path.join(self._user_data_dir, name)
|
||||||
|
try:
|
||||||
|
# These are symlinks; use lexists so a dangling link is caught.
|
||||||
|
if os.path.lexists(p):
|
||||||
|
os.remove(p)
|
||||||
|
logger.info(f"[Browser] cleared stale Chrome lock: {name}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug(f"[Browser] could not remove {name}: {e}")
|
||||||
|
|
||||||
|
def launch(self, ready_timeout: float = 25.0) -> str:
|
||||||
|
"""Spawn Chrome and block until its CDP endpoint answers.
|
||||||
|
|
||||||
|
Returns the CDP endpoint URL. Raises RuntimeError if the endpoint never
|
||||||
|
comes up (the child process is killed in that case).
|
||||||
|
"""
|
||||||
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
self._clear_stale_singleton_locks()
|
||||||
|
self._port = self._free_port()
|
||||||
|
|
||||||
|
args = [
|
||||||
|
self._executable,
|
||||||
|
f"--remote-debugging-port={self._port}",
|
||||||
|
f"--user-data-dir={self._user_data_dir}",
|
||||||
|
# Trim first-run overhead and background chatter for faster starts.
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-features=Translate,OptimizationHints",
|
||||||
|
# A blank first tab keeps startup cheap and predictable.
|
||||||
|
"about:blank",
|
||||||
|
]
|
||||||
|
if self._headless:
|
||||||
|
args.insert(1, "--headless=new")
|
||||||
|
args[1:1] = self._extra_args
|
||||||
|
|
||||||
|
popen_kwargs = {}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Detach from any console and never flash a window on Windows.
|
||||||
|
popen_kwargs["creationflags"] = (
|
||||||
|
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# New session so the child isn't tied to the parent's controlling
|
||||||
|
# terminal / process group (clean teardown, no signal bleed).
|
||||||
|
popen_kwargs["start_new_session"] = True
|
||||||
|
|
||||||
|
logger.info(f"[Browser] Spawning {os.path.basename(self._executable)} "
|
||||||
|
f"on CDP port {self._port} (profile={self._user_data_dir})")
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
**popen_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._wait_ready(ready_timeout):
|
||||||
|
# Capture the port before close() clears it, so the error is useful.
|
||||||
|
port = self._port
|
||||||
|
self.close()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Chrome did not expose a CDP endpoint on port {port} "
|
||||||
|
f"within {ready_timeout:.0f}s"
|
||||||
|
)
|
||||||
|
return self.endpoint
|
||||||
|
|
||||||
|
def _wait_ready(self, timeout: float) -> bool:
|
||||||
|
"""Poll DevTools /json/version until Chrome is listening (or times out)."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
url = f"http://127.0.0.1:{self._port}/json/version"
|
||||||
|
while time.time() < deadline:
|
||||||
|
# Bail out early if the process died on startup.
|
||||||
|
if self._proc and self._proc.poll() is not None:
|
||||||
|
logger.error(
|
||||||
|
f"[Browser] Chrome exited early (code={self._proc.returncode}) "
|
||||||
|
"before opening the CDP port"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=1) as r:
|
||||||
|
if r.status == 200:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.15)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
return self._proc is not None and self._proc.poll() is None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Terminate the spawned Chrome process (idempotent)."""
|
||||||
|
proc = self._proc
|
||||||
|
self._proc = None
|
||||||
|
self._port = None
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
if proc.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=8)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] error terminating Chrome process: {e}")
|
||||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
|||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
return True # agent ran but produced nothing; don't loop
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
if action.get("silent", False):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
channel = create_channel(channel_type)
|
channel = create_channel(channel_type)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class SchedulerService:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._execution_lock = threading.Lock()
|
||||||
|
self._active_task_ids = set()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the scheduler service"""
|
"""Start the scheduler service"""
|
||||||
@@ -85,7 +87,15 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
if self._is_task_due(task, now):
|
if self._is_task_due(task, now):
|
||||||
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
||||||
ok = self._execute_task(task)
|
if not self._claim_task(task['id']):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} is already running; skipping this tick"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ok = self._execute_task(task)
|
||||||
|
finally:
|
||||||
|
self._release_task(task['id'])
|
||||||
if not ok:
|
if not ok:
|
||||||
# Leave next_run_at as-is so the next loop retries.
|
# Leave next_run_at as-is so the next loop retries.
|
||||||
# Cron tasks within the catch-up window will keep
|
# Cron tasks within the catch-up window will keep
|
||||||
@@ -106,6 +116,57 @@ class SchedulerService:
|
|||||||
logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}")
|
logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
||||||
|
|
||||||
|
def run_task_now(self, task_id: str) -> None:
|
||||||
|
"""Queue one immediate execution without changing the task schedule.
|
||||||
|
|
||||||
|
Disabled and one-time tasks may be run manually for testing. The
|
||||||
|
stored ``next_run_at`` remains unchanged, so a manual run never
|
||||||
|
consumes or delays the next scheduled occurrence.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the task does not exist.
|
||||||
|
RuntimeError: if the same task is already executing.
|
||||||
|
"""
|
||||||
|
task = self.task_store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
raise ValueError(f"Task '{task_id}' not found")
|
||||||
|
if not self._claim_task(task_id):
|
||||||
|
raise RuntimeError(f"Task '{task_id}' is already running")
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
now = datetime.now()
|
||||||
|
try:
|
||||||
|
logger.info(f"[Scheduler] Manually executing task: {task_id} - {task.get('name', '')}")
|
||||||
|
ok = self._execute_task(task)
|
||||||
|
if ok:
|
||||||
|
self.task_store.update_task(task_id, {
|
||||||
|
"last_run_at": now.isoformat(),
|
||||||
|
"last_manual_run_at": now.isoformat(),
|
||||||
|
})
|
||||||
|
logger.info(f"[Scheduler] Manual execution completed: {task_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Scheduler] Manual execution failed: {task_id}")
|
||||||
|
finally:
|
||||||
|
self._release_task(task_id)
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_run,
|
||||||
|
daemon=True,
|
||||||
|
name=f"scheduler-manual-{task_id}",
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _claim_task(self, task_id: str) -> bool:
|
||||||
|
"""Prevent scheduled and manual runs of the same task from overlapping."""
|
||||||
|
with self._execution_lock:
|
||||||
|
if task_id in self._active_task_ids:
|
||||||
|
return False
|
||||||
|
self._active_task_ids.add(task_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _release_task(self, task_id: str) -> None:
|
||||||
|
with self._execution_lock:
|
||||||
|
self._active_task_ids.discard(task_id)
|
||||||
|
|
||||||
def _is_task_due(self, task: dict, now: datetime) -> bool:
|
def _is_task_due(self, task: dict, now: datetime) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
|||||||
"schedule_value": {
|
"schedule_value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
|||||||
"channel_type": self.config.get("channel_type", "unknown"),
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
"notify_session_id": notify_session_id,
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
# silent only applies to ai_task; fixed messages always deliver
|
||||||
|
if kwargs.get("silent", False):
|
||||||
|
action["silent"] = True
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
msg = context.kwargs.get("msg")
|
msg = context.kwargs.get("msg")
|
||||||
@@ -216,14 +224,17 @@ class SchedulerTool(BaseTool):
|
|||||||
content_desc = f"💬 固定消息: {message}"
|
content_desc = f"💬 固定消息: {message}"
|
||||||
else:
|
else:
|
||||||
content_desc = f"🤖 AI任务: {ai_task}"
|
content_desc = f"🤖 AI任务: {ai_task}"
|
||||||
|
|
||||||
|
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||||
|
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"✅ 定时任务创建成功\n\n"
|
f"✅ 定时任务创建成功\n\n"
|
||||||
f"📋 任务ID: {task_id}\n"
|
f"📋 任务ID: {task_id}\n"
|
||||||
f"📝 名称: {name}\n"
|
f"📝 名称: {name}\n"
|
||||||
f"⏰ 调度: {schedule_desc}\n"
|
f"⏰ 调度: {schedule_desc}\n"
|
||||||
f"👤 接收者: {receiver_desc}\n"
|
f"👤 接收者: {receiver_desc}\n"
|
||||||
f"{content_desc}\n"
|
f"{content_desc}{silent_desc}\n"
|
||||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -222,14 +215,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -261,14 +247,7 @@ class ToolManager:
|
|||||||
# If there are missing tools, record warnings
|
# If there are missing tools, record warnings
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
for tool_name in missing_tools:
|
for tool_name in missing_tools:
|
||||||
if tool_name == "browser":
|
if tool_name == "google_search":
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif tool_name == "google_search":
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||||
f" Get API key from: https://serper.dev\n"
|
f" Get API key from: https://serper.dev\n"
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge
|
|||||||
import os
|
import os
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
|
from agent.protocol import (
|
||||||
|
Agent,
|
||||||
|
LLMModel,
|
||||||
|
LLMRequest,
|
||||||
|
get_cancel_registry,
|
||||||
|
get_steer_registry,
|
||||||
|
)
|
||||||
from bridge.agent_event_handler import AgentEventHandler
|
from bridge.agent_event_handler import AgentEventHandler
|
||||||
from bridge.agent_initializer import AgentInitializer
|
from bridge.agent_initializer import AgentInitializer
|
||||||
from bridge.bridge import Bridge
|
from bridge.bridge import Bridge
|
||||||
@@ -360,6 +366,11 @@ class AgentBridge:
|
|||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
|
def steer_session(self, session_id: str, instruction: str):
|
||||||
|
"""Inject an explicit instruction into one active session."""
|
||||||
|
logger.info(f"[AgentBridge] steer new instruction: session={session_id}, content={instruction}")
|
||||||
|
return get_steer_registry().submit(session_id, instruction)
|
||||||
|
|
||||||
def get_agent(self, session_id: str = None) -> Optional[Agent]:
|
def get_agent(self, session_id: str = None) -> Optional[Agent]:
|
||||||
"""
|
"""
|
||||||
Get agent instance for the given session
|
Get agent instance for the given session
|
||||||
@@ -452,6 +463,8 @@ class AgentBridge:
|
|||||||
agent = None
|
agent = None
|
||||||
request_id = None
|
request_id = None
|
||||||
cancel_event = None
|
cancel_event = None
|
||||||
|
token_key = None
|
||||||
|
steer_inbox = None
|
||||||
try:
|
try:
|
||||||
# Extract session_id from context for user isolation
|
# Extract session_id from context for user isolation
|
||||||
if context:
|
if context:
|
||||||
@@ -534,12 +547,15 @@ class AgentBridge:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if session_id:
|
||||||
|
steer_inbox = get_steer_registry().register(session_id)
|
||||||
# Use agent's run_stream method with event handler
|
# Use agent's run_stream method with event handler
|
||||||
response = agent.run_stream(
|
response = agent.run_stream(
|
||||||
user_message=query,
|
user_message=query,
|
||||||
on_event=event_handler.handle_event,
|
on_event=event_handler.handle_event,
|
||||||
clear_history=clear_history,
|
clear_history=clear_history,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
steer_inbox=steer_inbox,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Clear the mid-run flag so idle scans can review this session.
|
# Clear the mid-run flag so idle scans can review this session.
|
||||||
@@ -562,6 +578,8 @@ class AgentBridge:
|
|||||||
registry.unregister(token_key)
|
registry.unregister(token_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if session_id and steer_inbox is not None:
|
||||||
|
get_steer_registry().unregister(session_id, steer_inbox)
|
||||||
|
|
||||||
# Persist new messages generated during this run
|
# Persist new messages generated during this run
|
||||||
if session_id:
|
if session_id:
|
||||||
@@ -643,6 +661,11 @@ class AgentBridge:
|
|||||||
get_cancel_registry().unregister(request_id or session_id)
|
get_cancel_registry().unregister(request_id or session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if session_id and steer_inbox is not None:
|
||||||
|
try:
|
||||||
|
get_steer_registry().unregister(session_id, steer_inbox)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||||||
|
|
||||||
def _schedule_mcp_hot_reload(self, agent):
|
def _schedule_mcp_hot_reload(self, agent):
|
||||||
|
|||||||
@@ -453,6 +453,10 @@ class ChatChannel(Channel):
|
|||||||
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||||
self._handle_cancel_command(context, session_id)
|
self._handle_cancel_command(context, session_id)
|
||||||
return
|
return
|
||||||
|
if re.match(r"^/steer(?:\s|$)", stripped):
|
||||||
|
instruction = context.content.strip()[len("/steer"):].strip()
|
||||||
|
self._handle_steer_command(context, session_id, instruction)
|
||||||
|
return
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
||||||
|
|
||||||
|
def _handle_steer_command(
|
||||||
|
self,
|
||||||
|
context: Context,
|
||||||
|
session_id: str,
|
||||||
|
instruction: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send explicit guidance to the active run without queueing it."""
|
||||||
|
try:
|
||||||
|
from agent.protocol import SteerStatus
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
|
||||||
|
result = Bridge().get_agent_bridge().steer_session(session_id, instruction)
|
||||||
|
messages = {
|
||||||
|
SteerStatus.ACCEPTED: _t(
|
||||||
|
"↪️ 已引导当前任务。", "↪️ Active task redirected."
|
||||||
|
),
|
||||||
|
SteerStatus.INACTIVE: _t(
|
||||||
|
"当前没有可引导的任务。", "No active task to steer."
|
||||||
|
),
|
||||||
|
SteerStatus.CLOSING: _t(
|
||||||
|
"当前任务已结束,无法再引导。", "The active task is already finishing."
|
||||||
|
),
|
||||||
|
SteerStatus.AMBIGUOUS: _t(
|
||||||
|
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||||
|
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||||
|
),
|
||||||
|
SteerStatus.FULL: _t(
|
||||||
|
"引导指令过多,请等待当前任务处理后再试。",
|
||||||
|
"Too many steering updates are pending; try again after the agent processes them.",
|
||||||
|
),
|
||||||
|
SteerStatus.INVALID: _t(
|
||||||
|
"用法:/steer <引导指令>", "Usage: /steer <instruction>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
text = messages[result.status]
|
||||||
|
logger.info(
|
||||||
|
f"[chat_channel] /steer fast-path: session={session_id}, "
|
||||||
|
f"status={result.status.value}"
|
||||||
|
)
|
||||||
|
self._send_reply(context, Reply(ReplyType.TEXT, text))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[chat_channel] /steer fast-path failed: {e}")
|
||||||
|
|
||||||
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
||||||
def consume(self):
|
def consume(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -515,6 +562,42 @@ class ChatChannel(Channel):
|
|||||||
semaphore.release()
|
semaphore.release()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
def cancel_message(self, session_id: str, message_id: str):
|
||||||
|
"""Cancel one channel message without disturbing later queued work.
|
||||||
|
|
||||||
|
Queued contexts are matched by their original channel message ID. An
|
||||||
|
in-flight agent run is cancelled through the per-request token that the
|
||||||
|
channel placed on the context before dispatch.
|
||||||
|
"""
|
||||||
|
removed = 0
|
||||||
|
with self.lock:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if session is not None:
|
||||||
|
context_queue = session[0]
|
||||||
|
kept = []
|
||||||
|
for _ in range(context_queue.qsize()):
|
||||||
|
context = context_queue.get_nowait()
|
||||||
|
context_queue.task_done()
|
||||||
|
message = context.get("msg") if context is not None else None
|
||||||
|
if getattr(message, "msg_id", None) == message_id:
|
||||||
|
removed += 1
|
||||||
|
else:
|
||||||
|
kept.append(context)
|
||||||
|
for context in kept:
|
||||||
|
context_queue.put(context)
|
||||||
|
|
||||||
|
from agent.protocol import get_cancel_registry
|
||||||
|
|
||||||
|
active = get_cancel_registry().cancel_request(message_id)
|
||||||
|
logger.info(
|
||||||
|
"[chat_channel] message recall: session=%s, message=%s, queued=%s, active=%s",
|
||||||
|
session_id,
|
||||||
|
message_id,
|
||||||
|
removed,
|
||||||
|
active,
|
||||||
|
)
|
||||||
|
return removed, active
|
||||||
|
|
||||||
# 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
# 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
||||||
def cancel_session(self, session_id):
|
def cancel_session(self, session_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ python3 app.py
|
|||||||
2. 进入应用详情 -> 事件订阅
|
2. 进入应用详情 -> 事件订阅
|
||||||
3. 选择 **将事件发送至开发者服务器**
|
3. 选择 **将事件发送至开发者服务器**
|
||||||
4. 填写请求地址: `http://your-domain:9891/`
|
4. 填写请求地址: `http://your-domain:9891/`
|
||||||
5. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
5. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||||
6. 保存配置
|
6. 保存配置
|
||||||
|
|
||||||
### 4. 注意事项
|
### 4. 注意事项
|
||||||
@@ -101,7 +101,7 @@ python3 app.py
|
|||||||
1. 登录[飞书开放平台](https://open.feishu.cn/)
|
1. 登录[飞书开放平台](https://open.feishu.cn/)
|
||||||
2. 进入应用详情 -> 事件订阅
|
2. 进入应用详情 -> 事件订阅
|
||||||
3. 选择 **使用长连接接收事件**
|
3. 选择 **使用长连接接收事件**
|
||||||
4. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
4. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||||
5. 保存配置
|
5. 保存配置
|
||||||
|
|
||||||
### 5. 注意事项
|
### 5. 注意事项
|
||||||
@@ -168,7 +168,7 @@ Address already in use
|
|||||||
### 收不到消息
|
### 收不到消息
|
||||||
|
|
||||||
1. 检查飞书应用的事件订阅配置
|
1. 检查飞书应用的事件订阅配置
|
||||||
2. 确认已添加 `im.message.receive_v1` 事件
|
2. 确认已添加 `im.message.receive_v1` 和 `im.message.recalled_v1` 事件
|
||||||
3. 检查应用权限: 需要 `im:message` 权限
|
3. 检查应用权限: 需要 `im:message` 权限
|
||||||
4. 查看日志中的错误信息
|
4. 查看日志中的错误信息
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ from bridge.context import ContextType
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
from channel.feishu.feishu_message import FeishuMessage
|
from channel.feishu.feishu_message import FeishuMessage
|
||||||
|
from channel.feishu.feishu_static_card import (
|
||||||
|
build_text_delivery,
|
||||||
|
resolve_markdown_images,
|
||||||
|
upload_public_image_to_feishu,
|
||||||
|
)
|
||||||
|
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||||
|
from channel.feishu.feishu_scheduler_card import (
|
||||||
|
build_scheduler_card,
|
||||||
|
handle_scheduler_action,
|
||||||
|
tasks_for_receivers,
|
||||||
|
)
|
||||||
from common import utils
|
from common import utils
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -240,6 +251,8 @@ class FeiShuChanel(ChatChannel):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
# 历史消息id暂存,用于幂等控制
|
# 历史消息id暂存,用于幂等控制
|
||||||
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
||||||
|
# Route recall events back to the session that accepted the message.
|
||||||
|
self._message_sessions = ExpiredDict(60 * 60 * 7.1)
|
||||||
self._http_server = None
|
self._http_server = None
|
||||||
self._ws_client = None
|
self._ws_client = None
|
||||||
self._ws_thread = None
|
self._ws_thread = None
|
||||||
@@ -350,6 +363,10 @@ class FeiShuChanel(ChatChannel):
|
|||||||
def _startup_websocket(self):
|
def _startup_websocket(self):
|
||||||
"""启动长连接接收事件(websocket模式)"""
|
"""启动长连接接收事件(websocket模式)"""
|
||||||
_ensure_lark_imported()
|
_ensure_lark_imported()
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||||
|
P2CardActionTriggerResponse,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||||
|
|
||||||
# 创建事件处理器
|
# 创建事件处理器
|
||||||
@@ -372,10 +389,40 @@ class FeiShuChanel(ChatChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def handle_message_recalled_event(
|
||||||
|
data: lark.im.v1.P2ImMessageRecalledV1,
|
||||||
|
) -> None:
|
||||||
|
"""Cancel only the task created by the recalled Feishu message."""
|
||||||
|
try:
|
||||||
|
logger.info("[FeiShu] websocket received message recall event")
|
||||||
|
event_dict = json.loads(lark.JSON.marshal(data))
|
||||||
|
self._handle_message_recalled_event(event_dict.get("event", {}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[FeiShu] websocket handle message recall error: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_card_action(data):
|
||||||
|
"""Handle Card 2.0 button callbacks and update the card in place."""
|
||||||
|
try:
|
||||||
|
event_dict = json.loads(lark.JSON.marshal(data))
|
||||||
|
response = self._handle_card_action_event(event_dict.get("event", {}))
|
||||||
|
return P2CardActionTriggerResponse(response)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
|
||||||
|
return P2CardActionTriggerResponse(
|
||||||
|
{"toast": {"type": "error", "content": "Task update failed"}}
|
||||||
|
)
|
||||||
|
|
||||||
# 构建事件分发器
|
# 构建事件分发器
|
||||||
event_handler = lark.EventDispatcherHandler.builder("", "") \
|
event_handler = (
|
||||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
lark.EventDispatcherHandler.builder("", "")
|
||||||
|
.register_p2_im_message_receive_v1(handle_message_event)
|
||||||
|
.register_p2_im_message_recalled_v1(handle_message_recalled_event)
|
||||||
|
.register_p2_card_action_trigger(handle_card_action)
|
||||||
.build()
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
def start_client_with_retry():
|
def start_client_with_retry():
|
||||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||||
@@ -470,6 +517,120 @@ class FeiShuChanel(ChatChannel):
|
|||||||
# so reaching here means the bot was indeed mentioned.
|
# so reaching here means the bot was indeed mentioned.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _get_scheduler_task_store(self):
|
||||||
|
"""Reuse the live scheduler store, with a path-compatible fallback."""
|
||||||
|
from agent.tools.scheduler.integration import get_task_store
|
||||||
|
|
||||||
|
task_store = get_task_store()
|
||||||
|
if task_store is not None:
|
||||||
|
return task_store
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
|
||||||
|
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
|
||||||
|
|
||||||
|
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
|
||||||
|
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
|
||||||
|
task_store = self._get_scheduler_task_store()
|
||||||
|
receivers = {feishu_msg.other_user_id}
|
||||||
|
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
|
||||||
|
card = build_scheduler_card(tasks)
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + feishu_msg.access_token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
content = json.dumps(card, ensure_ascii=False)
|
||||||
|
|
||||||
|
if is_group and feishu_msg.msg_id:
|
||||||
|
url = (
|
||||||
|
"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||||
|
f"{feishu_msg.msg_id}/reply"
|
||||||
|
)
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json={"msg_type": "interactive", "content": content},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params={"receive_id_type": receive_id_type},
|
||||||
|
json={
|
||||||
|
"receive_id": feishu_msg.other_user_id,
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
logger.info("[FeiShu] scheduler card sent")
|
||||||
|
return True
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] scheduler card failed, "
|
||||||
|
f"code={result.get('code')}, msg={result.get('msg')}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _handle_card_action_event(self, event: dict) -> dict:
|
||||||
|
"""Apply a scheduler card action within its chat/operator ownership scope."""
|
||||||
|
action = event.get("action") or {}
|
||||||
|
value = action.get("value") or {}
|
||||||
|
if value.get("cowagent") != "scheduler":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context = event.get("context") or {}
|
||||||
|
operator = event.get("operator") or {}
|
||||||
|
callback_receivers = {
|
||||||
|
receiver
|
||||||
|
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
|
||||||
|
if receiver
|
||||||
|
}
|
||||||
|
target_receiver = value.get("receiver")
|
||||||
|
allowed_receivers = (
|
||||||
|
{target_receiver}
|
||||||
|
if target_receiver and target_receiver in callback_receivers
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
response = handle_scheduler_action(
|
||||||
|
value,
|
||||||
|
self._get_scheduler_task_store(),
|
||||||
|
allowed_receivers,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[FeiShu] scheduler card action handled, "
|
||||||
|
f"action={value.get('action')}, task_id={value.get('task_id')}"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _handle_message_recalled_event(self, event: dict):
|
||||||
|
"""Cancel one recalled message while preserving later queued messages."""
|
||||||
|
message_id = event.get("message_id")
|
||||||
|
if not message_id:
|
||||||
|
logger.warning(f"[FeiShu] invalid message recall event: {event}")
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
session_id = self._message_sessions.get(message_id)
|
||||||
|
if not session_id:
|
||||||
|
logger.info(
|
||||||
|
f"[FeiShu] ignored recall for unknown message, message_id={message_id}"
|
||||||
|
)
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
result = self.cancel_message(session_id, message_id)
|
||||||
|
self._message_sessions.pop(message_id, None)
|
||||||
|
logger.info(
|
||||||
|
"[FeiShu] recalled message cancelled, "
|
||||||
|
f"message_id={message_id}, session_id={session_id}, "
|
||||||
|
f"queued={result[0]}, active={result[1]}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def _handle_message_event(self, event: dict):
|
def _handle_message_event(self, event: dict):
|
||||||
"""
|
"""
|
||||||
处理消息事件的核心逻辑
|
处理消息事件的核心逻辑
|
||||||
@@ -521,6 +682,11 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not feishu_msg:
|
if not feishu_msg:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
|
||||||
|
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
|
||||||
|
logger.warning("[FeiShu] /tasks card delivery failed")
|
||||||
|
return
|
||||||
|
|
||||||
# 处理文件缓存逻辑
|
# 处理文件缓存逻辑
|
||||||
from channel.file_cache import get_file_cache
|
from channel.file_cache import get_file_cache
|
||||||
file_cache = get_file_cache()
|
file_cache = get_file_cache()
|
||||||
@@ -591,13 +757,17 @@ class FeiShuChanel(ChatChannel):
|
|||||||
|
|
||||||
context = self._compose_context(
|
context = self._compose_context(
|
||||||
feishu_msg.ctype,
|
feishu_msg.ctype,
|
||||||
feishu_msg.content,
|
feishu_msg.content_with_quote(),
|
||||||
isgroup=is_group,
|
isgroup=is_group,
|
||||||
msg=feishu_msg,
|
msg=feishu_msg,
|
||||||
receive_id_type=receive_id_type,
|
receive_id_type=receive_id_type,
|
||||||
no_need_at=True
|
no_need_at=True
|
||||||
)
|
)
|
||||||
if context:
|
if context:
|
||||||
|
# Feishu recall events only include message_id/chat_id. Keep the
|
||||||
|
# accepted route and use message_id as the agent cancellation key.
|
||||||
|
context["request_id"] = msg_id
|
||||||
|
self._message_sessions[msg_id] = context["session_id"]
|
||||||
# 流式回复模式:向 context 注入 on_event 回调,agent 每产出一段文字时会调用它。
|
# 流式回复模式:向 context 注入 on_event 回调,agent 每产出一段文字时会调用它。
|
||||||
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
||||||
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
||||||
@@ -628,7 +798,18 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||||
reply_content = reply.content
|
reply_content = reply.content
|
||||||
content_key = "text"
|
content_key = "text"
|
||||||
if reply.type == ReplyType.IMAGE_URL:
|
prepared_content_json = None
|
||||||
|
if reply.type == ReplyType.TEXT:
|
||||||
|
# Render Markdown text replies as Feishu cards; falls back to plain text automatically.
|
||||||
|
delivery_text = resolve_markdown_images(
|
||||||
|
reply.content,
|
||||||
|
lambda url: upload_public_image_to_feishu(
|
||||||
|
url,
|
||||||
|
access_token,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
msg_type, prepared_content_json = build_text_delivery(delivery_text)
|
||||||
|
elif reply.type == ReplyType.IMAGE_URL:
|
||||||
# 图片上传
|
# 图片上传
|
||||||
reply_content = self._upload_image_url(reply.content, access_token)
|
reply_content = self._upload_image_url(reply.content, access_token)
|
||||||
if not reply_content:
|
if not reply_content:
|
||||||
@@ -690,7 +871,11 @@ class FeiShuChanel(ChatChannel):
|
|||||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||||
|
|
||||||
# Build content JSON
|
# Build content JSON
|
||||||
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
content_json = prepared_content_json or (
|
||||||
|
json.dumps(reply_content, ensure_ascii=False)
|
||||||
|
if content_key is None
|
||||||
|
else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||||
|
)
|
||||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||||
|
|
||||||
if can_reply:
|
if can_reply:
|
||||||
@@ -714,10 +899,393 @@ class FeiShuChanel(ChatChannel):
|
|||||||
res = res.json()
|
res = res.json()
|
||||||
if res.get("code") == 0:
|
if res.get("code") == 0:
|
||||||
logger.info(f"[FeiShu] send message success")
|
logger.info(f"[FeiShu] send message success")
|
||||||
|
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
|
||||||
|
logger.warning(
|
||||||
|
"[FeiShu] Markdown card failed, falling back to text, "
|
||||||
|
f"code={res.get('code')}, msg={res.get('msg')}"
|
||||||
|
)
|
||||||
|
fallback_data = {
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
if can_reply:
|
||||||
|
fallback_res = requests.post(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=fallback_data,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fallback_data["receive_id"] = context.get("receiver")
|
||||||
|
fallback_res = requests.post(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
json=fallback_data,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
fallback_body = fallback_res.json()
|
||||||
|
if fallback_body.get("code") == 0:
|
||||||
|
logger.info("[FeiShu] text fallback sent successfully")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] text fallback failed, "
|
||||||
|
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
def _make_feishu_stream_callback(self, context, access_token):
|
def _make_feishu_stream_callback(self, context, access_token):
|
||||||
|
"""Route to detailed or plain streaming callback based on config.
|
||||||
|
|
||||||
|
feishu_detailed_card 默认开启:普通对话使用带状态头、
|
||||||
|
思考/工具面板与耗时的详细卡片。关闭后回退到原有的打字机文本卡片
|
||||||
|
(_make_feishu_stream_callback_plain)。
|
||||||
|
"""
|
||||||
|
if conf().get("feishu_detailed_card", True):
|
||||||
|
return self._make_feishu_stream_callback_progress(context, access_token)
|
||||||
|
return self._make_feishu_stream_callback_plain(context, access_token)
|
||||||
|
|
||||||
|
def _make_feishu_stream_callback_progress(self, context, access_token):
|
||||||
|
"""
|
||||||
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
|
||||||
|
随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去
|
||||||
|
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
|
||||||
|
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
|
||||||
|
(流式模式下不受 10 QPS 限制)
|
||||||
|
3. message_end(本轮触发工具调用)→ 原地刷新 Reasoning / Tools 面板,
|
||||||
|
后续 turn 继续复用同一张卡片
|
||||||
|
4. agent_end → 用 final_response、最终状态和耗时整卡更新,关闭 streaming_mode,
|
||||||
|
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
|
||||||
|
|
||||||
|
前提条件:
|
||||||
|
- 机器人已开通 cardkit:card:write 权限
|
||||||
|
- 飞书客户端 7.20+
|
||||||
|
|
||||||
|
失败降级:
|
||||||
|
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
|
||||||
|
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
|
||||||
|
"""
|
||||||
|
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
|
||||||
|
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
|
||||||
|
progress_state = FeishuProgressState()
|
||||||
|
card_id = [None]
|
||||||
|
message_id = [None]
|
||||||
|
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
|
||||||
|
# 事件各自触发一次创建+发送,导致发出多张卡片。
|
||||||
|
init_in_flight = [False]
|
||||||
|
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||||
|
disabled = [False]
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
# ---- 异步推送队列 ----------------------------------------------------
|
||||||
|
# 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。
|
||||||
|
# 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。
|
||||||
|
# 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个
|
||||||
|
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
|
||||||
|
import queue as _queue
|
||||||
|
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
|
||||||
|
|
||||||
|
def _push_worker():
|
||||||
|
while True:
|
||||||
|
snapshot = push_queue.get()
|
||||||
|
if snapshot is None:
|
||||||
|
push_queue.task_done()
|
||||||
|
return
|
||||||
|
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
|
||||||
|
merged_count = 1
|
||||||
|
stop = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
nxt = push_queue.get_nowait()
|
||||||
|
except _queue.Empty:
|
||||||
|
break
|
||||||
|
merged_count += 1
|
||||||
|
if nxt is None:
|
||||||
|
stop = True
|
||||||
|
break
|
||||||
|
snapshot = nxt
|
||||||
|
try:
|
||||||
|
_stream_update_text(snapshot)
|
||||||
|
finally:
|
||||||
|
for _ in range(merged_count):
|
||||||
|
push_queue.task_done()
|
||||||
|
if stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
|
||||||
|
push_thread.start()
|
||||||
|
|
||||||
|
def _drain_push_queue():
|
||||||
|
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain,
|
||||||
|
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
|
||||||
|
try:
|
||||||
|
push_queue.join()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg = context.get("msg")
|
||||||
|
is_group = context.get("isgroup", False)
|
||||||
|
receiver = context.get("receiver")
|
||||||
|
receive_id_type = context.get("receive_id_type", "open_id")
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + access_token,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
# 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件
|
||||||
|
ELEMENT_ID = "stream_md"
|
||||||
|
# 操作序号,每次 PUT 必须严格递增(飞书要求)
|
||||||
|
sequence = [0]
|
||||||
|
|
||||||
|
def _next_sequence():
|
||||||
|
with lock:
|
||||||
|
sequence[0] += 1
|
||||||
|
return sequence[0]
|
||||||
|
|
||||||
|
def _build_card_json():
|
||||||
|
"""Build the initial streaming Card 2.0 payload."""
|
||||||
|
with lock:
|
||||||
|
card = progress_state.build_card(streaming=True)
|
||||||
|
return json.dumps(card, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _create_and_send_card():
|
||||||
|
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
|
||||||
|
try:
|
||||||
|
# 步骤 1: 创建卡片实体
|
||||||
|
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
|
||||||
|
create_body = {"type": "card_json", "data": _build_card_json()}
|
||||||
|
res = requests.post(
|
||||||
|
create_url, headers=headers, json=create_body, timeout=(5, 10)
|
||||||
|
)
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: create card failed "
|
||||||
|
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
|
||||||
|
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
|
||||||
|
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
|
||||||
|
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
|
||||||
|
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
return
|
||||||
|
cid = res_json["data"]["card_id"]
|
||||||
|
with lock:
|
||||||
|
card_id[0] = cid
|
||||||
|
|
||||||
|
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send)
|
||||||
|
content_payload = json.dumps(
|
||||||
|
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
|
||||||
|
)
|
||||||
|
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
|
||||||
|
if can_reply:
|
||||||
|
send_url = (
|
||||||
|
f"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||||
|
f"{msg.msg_id}/reply"
|
||||||
|
)
|
||||||
|
send_body = {"msg_type": "interactive", "content": content_payload}
|
||||||
|
send_res = requests.post(
|
||||||
|
send_url, headers=headers, json=send_body, timeout=(5, 10)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||||
|
params = {"receive_id_type": receive_id_type}
|
||||||
|
send_body = {
|
||||||
|
"receive_id": receiver,
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"content": content_payload,
|
||||||
|
}
|
||||||
|
send_res = requests.post(
|
||||||
|
send_url, headers=headers, params=params, json=send_body,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
send_json = send_res.json()
|
||||||
|
if send_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
return
|
||||||
|
mid = send_json["data"]["message_id"]
|
||||||
|
with lock:
|
||||||
|
message_id[0] = mid
|
||||||
|
logger.info(
|
||||||
|
f"[FeiShu] Stream: card created and sent, "
|
||||||
|
f"card_id={cid}, message_id={mid}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = False
|
||||||
|
|
||||||
|
def _stream_update_text(full_text):
|
||||||
|
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
|
||||||
|
with lock:
|
||||||
|
cid = card_id[0]
|
||||||
|
if not cid:
|
||||||
|
return
|
||||||
|
url = (
|
||||||
|
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
|
||||||
|
f"{cid}/elements/{ELEMENT_ID}/content"
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"content": full_text,
|
||||||
|
"sequence": _next_sequence(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: update text failed: {res_json}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
|
||||||
|
|
||||||
|
def _update_full_card(streaming: bool):
|
||||||
|
"""Refresh panels, header, footer and streaming state in one update."""
|
||||||
|
with lock:
|
||||||
|
cid = card_id[0]
|
||||||
|
full_card = progress_state.build_card(streaming=streaming)
|
||||||
|
if not cid:
|
||||||
|
return
|
||||||
|
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
|
||||||
|
put_body = {
|
||||||
|
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
|
||||||
|
"sequence": _next_sequence(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: full card update failed: {res_json}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: full card update exception: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_event(event: dict):
|
||||||
|
event_type = event.get("type")
|
||||||
|
data = event.get("data", {})
|
||||||
|
|
||||||
|
# 一旦降级,本次回复不再做任何流式操作
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "agent_start":
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
if card_id[0] is None and not init_in_flight[0]:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
_create_and_send_card()
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type in ("turn_start", "reasoning_update"):
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "message_update":
|
||||||
|
delta = data.get("delta", "")
|
||||||
|
if not delta:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 第一段:判断是否需要初始化(创建卡片 + 发送)
|
||||||
|
need_init = False
|
||||||
|
with lock:
|
||||||
|
if card_id[0] is None and not init_in_flight[0]:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
need_init = True
|
||||||
|
|
||||||
|
if need_init:
|
||||||
|
_create_and_send_card()
|
||||||
|
# 初始化失败已标记 disabled,下次循环直接 return
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 第二段:累加文本,把快照丢给 push worker 异步推送。
|
||||||
|
# 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk
|
||||||
|
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。
|
||||||
|
snapshot = ""
|
||||||
|
should_push = False
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
if card_id[0]:
|
||||||
|
snapshot = progress_state.current_text
|
||||||
|
should_push = True
|
||||||
|
|
||||||
|
if should_push:
|
||||||
|
push_queue.put(snapshot)
|
||||||
|
|
||||||
|
elif event_type in ("tool_execution_start", "tool_execution_end"):
|
||||||
|
# Refresh the Tools panel as each tool starts/finishes so users
|
||||||
|
# see live status and per-tool elapsed time.
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
has_card = card_id[0] is not None
|
||||||
|
if has_card:
|
||||||
|
_drain_push_queue()
|
||||||
|
_update_full_card(streaming=True)
|
||||||
|
|
||||||
|
elif event_type == "message_end":
|
||||||
|
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
||||||
|
tool_calls = data.get("tool_calls", []) or []
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
if not tool_calls:
|
||||||
|
return
|
||||||
|
_drain_push_queue()
|
||||||
|
_update_full_card(streaming=True)
|
||||||
|
|
||||||
|
elif event_type == "agent_cancelled":
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
|
||||||
|
elif event_type == "agent_end":
|
||||||
|
# Finalize the same card with a status header and elapsed footer.
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
final_text = progress_state.current_text
|
||||||
|
has_card = card_id[0] is not None
|
||||||
|
init_busy = init_in_flight[0]
|
||||||
|
final_text = resolve_markdown_images(
|
||||||
|
final_text,
|
||||||
|
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
progress_state.current_text = final_text
|
||||||
|
context["feishu_streamed"] = True
|
||||||
|
|
||||||
|
if not has_card and not init_busy:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
_create_and_send_card()
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
_drain_push_queue()
|
||||||
|
_stream_update_text(final_text)
|
||||||
|
_update_full_card(streaming=False)
|
||||||
|
push_queue.put(None)
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
|
def _make_feishu_stream_callback_plain(self, context, access_token):
|
||||||
"""
|
"""
|
||||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
@@ -966,9 +1534,15 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not cid:
|
if not cid:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
preview_text = final_text
|
||||||
|
final_text = resolve_markdown_images(
|
||||||
|
final_text,
|
||||||
|
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||||
|
)
|
||||||
|
|
||||||
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||||
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
||||||
preview_src = (final_text or "").strip().replace("\n", " ")
|
preview_src = (preview_text or "").strip().replace("\n", " ")
|
||||||
preview = preview_src[:30] if preview_src else ""
|
preview = preview_src[:30] if preview_src else ""
|
||||||
full_card = {
|
full_card = {
|
||||||
"schema": "2.0",
|
"schema": "2.0",
|
||||||
@@ -1565,6 +2139,8 @@ class FeishuController:
|
|||||||
FAILED_MSG = '{"success": false}'
|
FAILED_MSG = '{"success": false}'
|
||||||
SUCCESS_MSG = '{"success": true}'
|
SUCCESS_MSG = '{"success": true}'
|
||||||
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
||||||
|
MESSAGE_RECALLED_TYPE = "im.message.recalled_v1"
|
||||||
|
CARD_ACTION_TYPE = "card.action.trigger"
|
||||||
|
|
||||||
def GET(self):
|
def GET(self):
|
||||||
return "Feishu service start success!"
|
return "Feishu service start success!"
|
||||||
@@ -1581,16 +2157,30 @@ class FeishuController:
|
|||||||
varify_res = {"challenge": request.get("challenge")}
|
varify_res = {"challenge": request.get("challenge")}
|
||||||
return json.dumps(varify_res)
|
return json.dumps(varify_res)
|
||||||
|
|
||||||
# 2.消息接收处理
|
# 2. Verify callbacks. Card callbacks may carry the verification
|
||||||
# token 校验
|
# token in event.token while message events carry it in the header.
|
||||||
header = request.get("header")
|
header = request.get("header") or {}
|
||||||
if not header or header.get("token") != channel.feishu_token:
|
event = request.get("event") or {}
|
||||||
|
event_type = header.get("event_type") or request.get("type")
|
||||||
|
callback_token = (
|
||||||
|
header.get("token")
|
||||||
|
or event.get("token")
|
||||||
|
or request.get("token")
|
||||||
|
)
|
||||||
|
if callback_token != channel.feishu_token:
|
||||||
return self.FAILED_MSG
|
return self.FAILED_MSG
|
||||||
|
|
||||||
# 处理消息事件
|
if event_type == self.CARD_ACTION_TYPE and event:
|
||||||
event = request.get("event")
|
return json.dumps(
|
||||||
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
|
channel._handle_card_action_event(event),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Handle message events.
|
||||||
|
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
|
||||||
channel._handle_message_event(event)
|
channel._handle_message_event(event)
|
||||||
|
elif event_type == self.MESSAGE_RECALLED_TYPE and event:
|
||||||
|
channel._handle_message_recalled_event(event)
|
||||||
|
|
||||||
return self.SUCCESS_MSG
|
return self.SUCCESS_MSG
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
|||||||
self.msg_id = msg.get("message_id")
|
self.msg_id = msg.get("message_id")
|
||||||
self.create_time = msg.get("create_time")
|
self.create_time = msg.get("create_time")
|
||||||
self.is_group = is_group
|
self.is_group = is_group
|
||||||
|
self.quoted_content = ""
|
||||||
msg_type = msg.get("message_type")
|
msg_type = msg.get("message_type")
|
||||||
|
|
||||||
if msg_type == "text":
|
if msg_type == "text":
|
||||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
||||||
|
|
||||||
|
if self.ctype == ContextType.TEXT:
|
||||||
|
self.quoted_content = self._fetch_quoted_content(msg.get("parent_id"))
|
||||||
|
|
||||||
self.from_user_id = sender.get("sender_id").get("open_id")
|
self.from_user_id = sender.get("sender_id").get("open_id")
|
||||||
self.to_user_id = event.get("app_id")
|
self.to_user_id = event.get("app_id")
|
||||||
if is_group:
|
if is_group:
|
||||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
|||||||
# 私聊
|
# 私聊
|
||||||
self.other_user_id = self.from_user_id
|
self.other_user_id = self.from_user_id
|
||||||
self.actual_user_id = self.from_user_id
|
self.actual_user_id = self.from_user_id
|
||||||
|
|
||||||
|
def content_with_quote(self) -> str:
|
||||||
|
"""Return user text with optional quoted-message context for the agent."""
|
||||||
|
if not self.quoted_content:
|
||||||
|
return self.content
|
||||||
|
return (
|
||||||
|
"[Quoted message]\n{}\n[/Quoted message]\n\n{}".format(
|
||||||
|
self.quoted_content,
|
||||||
|
self.content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_quoted_content(self, parent_id: str) -> str:
|
||||||
|
"""Fetch one parent message, degrading to an empty quote on failure."""
|
||||||
|
if not parent_id or not self.access_token:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
url = "https://open.feishu.cn/open-apis/im/v1/messages/{}".format(parent_id)
|
||||||
|
headers = {"Authorization": "Bearer " + self.access_token}
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
params={"card_msg_content_type": "raw_card_content"},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"[FeiShu] quoted message fetch failed, parent_id=%s, status=%s",
|
||||||
|
parent_id,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
body = response.json()
|
||||||
|
items = (body.get("data") or {}).get("items") or []
|
||||||
|
if body.get("code") != 0 or not items:
|
||||||
|
return ""
|
||||||
|
return self._extract_quoted_text(items[0])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[FeiShu] quoted message fetch error, parent_id=%s: %s",
|
||||||
|
parent_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_quoted_text(item: dict) -> str:
|
||||||
|
msg_type = item.get("msg_type")
|
||||||
|
raw_content = (item.get("body") or {}).get("content") or ""
|
||||||
|
try:
|
||||||
|
content = json.loads(raw_content)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if msg_type == "text":
|
||||||
|
return str(content.get("text") or "").strip()
|
||||||
|
if msg_type == "post":
|
||||||
|
# Some message-history payloads wrap post content in a locale key.
|
||||||
|
if "content" not in content:
|
||||||
|
localized = next(
|
||||||
|
(value for value in content.values() if isinstance(value, dict)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if localized:
|
||||||
|
content = localized
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
title = str(content.get("title") or "").strip()
|
||||||
|
if title:
|
||||||
|
parts.append(title)
|
||||||
|
for block in content.get("content") or []:
|
||||||
|
if not isinstance(block, list):
|
||||||
|
continue
|
||||||
|
for element in block:
|
||||||
|
if not isinstance(element, dict):
|
||||||
|
continue
|
||||||
|
tag = element.get("tag")
|
||||||
|
text = str(element.get("text") or "").strip()
|
||||||
|
if tag == "text" and text:
|
||||||
|
parts.append(text)
|
||||||
|
elif tag == "a" and text:
|
||||||
|
href = str(element.get("href") or "").strip()
|
||||||
|
parts.append("{} ({})".format(text, href) if href else text)
|
||||||
|
elif tag == "img":
|
||||||
|
parts.append("[Image]")
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
if msg_type == "image":
|
||||||
|
return "[Image]"
|
||||||
|
if msg_type == "file":
|
||||||
|
return "[File: {}]".format(content.get("file_name") or "file")
|
||||||
|
if msg_type == "audio":
|
||||||
|
return "[Audio]"
|
||||||
|
if msg_type == "media":
|
||||||
|
return "[Video: {}]".format(content.get("file_name") or "video")
|
||||||
|
return ""
|
||||||
|
|||||||
247
channel/feishu/feishu_progress_card.py
Normal file
247
channel/feishu/feishu_progress_card.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""State and Card 2.0 rendering for a Feishu agent run."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from common import i18n
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_PANEL_STEPS = 10
|
||||||
|
_MAX_STEP_CHARS = 800
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuProgressState:
|
||||||
|
"""Reduce CowAgent stream events into one renderable Feishu card state."""
|
||||||
|
|
||||||
|
def __init__(self, started_at: Optional[float] = None):
|
||||||
|
self.started_at = time.monotonic() if started_at is None else started_at
|
||||||
|
self.status = "running"
|
||||||
|
self.turns = 0
|
||||||
|
self.current_text = ""
|
||||||
|
self._reasoning_buffer = ""
|
||||||
|
self.reasoning_steps: List[str] = []
|
||||||
|
self.tool_steps: List[Dict[str, Any]] = []
|
||||||
|
self._tool_index: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
def consume(self, event: Dict[str, Any]) -> None:
|
||||||
|
"""Consume one event emitted by ``AgentStreamHandler``."""
|
||||||
|
event_type = event.get("type")
|
||||||
|
data = event.get("data") or {}
|
||||||
|
|
||||||
|
if event_type == "turn_start":
|
||||||
|
self._mark_running_tools_done()
|
||||||
|
turn = data.get("turn")
|
||||||
|
if isinstance(turn, int):
|
||||||
|
self.turns = max(self.turns, turn)
|
||||||
|
else:
|
||||||
|
self.turns += 1
|
||||||
|
if self.turns > 1:
|
||||||
|
self.current_text = ""
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "reasoning_update":
|
||||||
|
self._reasoning_buffer += str(data.get("delta") or "")
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "message_update":
|
||||||
|
self.current_text += str(data.get("delta") or "")
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "message_end":
|
||||||
|
self._commit_reasoning()
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "tool_execution_start":
|
||||||
|
tool_id = data.get("tool_call_id")
|
||||||
|
step = {
|
||||||
|
"summary": str(data.get("tool_name") or "tool"),
|
||||||
|
"status": "running",
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
"elapsed": None,
|
||||||
|
}
|
||||||
|
self.tool_steps.append(step)
|
||||||
|
if tool_id:
|
||||||
|
self._tool_index[tool_id] = step
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "tool_execution_end":
|
||||||
|
tool_id = data.get("tool_call_id")
|
||||||
|
step = self._tool_index.get(tool_id) if tool_id else None
|
||||||
|
if step is None:
|
||||||
|
# Fall back to the most recent running step when no id match.
|
||||||
|
step = next((s for s in reversed(self.tool_steps) if s["status"] == "running"), None)
|
||||||
|
if step is not None:
|
||||||
|
step["status"] = "error" if data.get("status") not in (None, "success") else "done"
|
||||||
|
elapsed = data.get("execution_time")
|
||||||
|
if elapsed is None and step.get("started_at") is not None:
|
||||||
|
elapsed = time.monotonic() - step["started_at"]
|
||||||
|
step["elapsed"] = elapsed
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "agent_cancelled":
|
||||||
|
self.cancelled = True
|
||||||
|
self.status = "stopped"
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "agent_end":
|
||||||
|
self._commit_reasoning()
|
||||||
|
self._mark_running_tools_done()
|
||||||
|
cancelled = self.cancelled or bool(data.get("cancelled"))
|
||||||
|
if cancelled:
|
||||||
|
self.status = "stopped"
|
||||||
|
self.current_text = self.current_text.rstrip() or "_(stopped)_"
|
||||||
|
else:
|
||||||
|
self.status = "done"
|
||||||
|
final_response = data.get("final_response")
|
||||||
|
if final_response:
|
||||||
|
self.current_text = str(final_response)
|
||||||
|
|
||||||
|
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
|
||||||
|
"""Render the current state as a Feishu Card 2.0 object."""
|
||||||
|
# Localized status header text; en/zh/zh-Hant via i18n.t.
|
||||||
|
title, template = {
|
||||||
|
"running": (i18n.t("处理中", "Working"), "blue"),
|
||||||
|
"done": (i18n.t("完成", "Done"), "green"),
|
||||||
|
"stopped": (i18n.t("已停止", "Stopped"), "grey"),
|
||||||
|
"error": (i18n.t("出错", "Error"), "red"),
|
||||||
|
}.get(self.status, (i18n.t("处理中", "Working"), "blue"))
|
||||||
|
|
||||||
|
main_text = self.current_text or "..."
|
||||||
|
elements: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Only render the Reasoning panel when there is real reasoning content.
|
||||||
|
# Upstream emits reasoning_update only when deep thinking is enabled, so
|
||||||
|
# an empty reasoning_steps means we should show no panel at all.
|
||||||
|
if self.reasoning_steps:
|
||||||
|
elements.append(
|
||||||
|
_panel(
|
||||||
|
"🤔 {}".format(i18n.t("思考", "Thinking")),
|
||||||
|
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
||||||
|
expanded=streaming,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.tool_steps:
|
||||||
|
elements.append(
|
||||||
|
_panel(
|
||||||
|
"🔧 {} ({})".format(i18n.t("工具", "Tools"), len(self.tool_steps)),
|
||||||
|
[
|
||||||
|
_text_row(_format_tool_step(step))
|
||||||
|
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
|
||||||
|
],
|
||||||
|
expanded=streaming,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"element_id": "stream_md",
|
||||||
|
"content": main_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
|
||||||
|
turn_label = i18n.t("轮", "turn" if self.turns == 1 else "turns")
|
||||||
|
elements.extend(
|
||||||
|
[
|
||||||
|
{"tag": "hr"},
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label),
|
||||||
|
"text_size": "notation",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
config: Dict[str, Any] = {
|
||||||
|
"streaming_mode": streaming,
|
||||||
|
"update_multi": True,
|
||||||
|
"enable_forward_interaction": True,
|
||||||
|
"summary": {"content": _summary(main_text, title)},
|
||||||
|
}
|
||||||
|
if streaming:
|
||||||
|
config["streaming_config"] = {
|
||||||
|
"print_frequency_ms": {"default": 40},
|
||||||
|
"print_step": {"default": 4},
|
||||||
|
"print_strategy": "fast",
|
||||||
|
}
|
||||||
|
|
||||||
|
card: Dict[str, Any] = {
|
||||||
|
"schema": "2.0",
|
||||||
|
"config": config,
|
||||||
|
"body": {"elements": elements},
|
||||||
|
}
|
||||||
|
# Hide the status header once the run has finished successfully; a plain
|
||||||
|
# answer needs no "Done" banner. Keep the header for running/stopped/error
|
||||||
|
# so users still get progress and failure signals.
|
||||||
|
if self.status != "done":
|
||||||
|
card["header"] = {
|
||||||
|
"template": template,
|
||||||
|
"title": {"tag": "plain_text", "content": title},
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _commit_reasoning(self) -> None:
|
||||||
|
reasoning = self._reasoning_buffer.strip()
|
||||||
|
if reasoning:
|
||||||
|
self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:])
|
||||||
|
self._reasoning_buffer = ""
|
||||||
|
|
||||||
|
def _mark_running_tools_done(self) -> None:
|
||||||
|
for step in self.tool_steps:
|
||||||
|
if step["status"] == "running":
|
||||||
|
step["status"] = "done"
|
||||||
|
if step.get("elapsed") is None and step.get("started_at") is not None:
|
||||||
|
step["elapsed"] = time.monotonic() - step["started_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_status_label(status: str) -> str:
|
||||||
|
if status == "running":
|
||||||
|
return i18n.t("执行中", "running")
|
||||||
|
if status == "error":
|
||||||
|
return i18n.t("失败", "error")
|
||||||
|
return i18n.t("完成", "done")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_tool_step(step: Dict[str, Any]) -> str:
|
||||||
|
# Tool name plus its own status and elapsed time, e.g. "search · done · 1.2s".
|
||||||
|
parts = [str(step.get("summary") or "tool"), _tool_status_label(step["status"])]
|
||||||
|
elapsed = step.get("elapsed")
|
||||||
|
if isinstance(elapsed, (int, float)):
|
||||||
|
parts.append("{:.1f}s".format(max(0.0, float(elapsed))))
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tag": "collapsible_panel",
|
||||||
|
"expanded": expanded,
|
||||||
|
"background_color": "grey",
|
||||||
|
# Panel title uses markdown so we can shrink the font via text_size
|
||||||
|
# (plain_text titles ignore text_size and break card rendering).
|
||||||
|
"header": {"title": {"tag": "markdown", "content": title, "text_size": "notation"}},
|
||||||
|
"border": {"color": "grey"},
|
||||||
|
"vertical_spacing": "8px",
|
||||||
|
"padding": "4px 8px",
|
||||||
|
"elements": elements,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_row(content: str, muted: bool = False) -> Dict[str, Any]:
|
||||||
|
text = {
|
||||||
|
"tag": "plain_text",
|
||||||
|
"content": content,
|
||||||
|
"text_size": "notation",
|
||||||
|
}
|
||||||
|
if muted:
|
||||||
|
text["text_color"] = "grey"
|
||||||
|
return {"tag": "div", "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(text: str, fallback: str) -> str:
|
||||||
|
preview = " ".join(text.strip().split())
|
||||||
|
return preview[:60] or fallback
|
||||||
185
channel/feishu/feishu_scheduler_card.py
Normal file
185
channel/feishu/feishu_scheduler_card.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Feishu scheduler card rendering and callback handling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Set
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_TASKS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
|
||||||
|
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
|
||||||
|
visible = []
|
||||||
|
for task in tasks:
|
||||||
|
action = task.get("action") or {}
|
||||||
|
if action.get("channel_type") != "feishu":
|
||||||
|
continue
|
||||||
|
if action.get("receiver") in receivers:
|
||||||
|
visible.append(task)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
|
||||||
|
"""Build a Card 2.0 task list with explicit, idempotent actions."""
|
||||||
|
task_list = list(tasks)
|
||||||
|
elements: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if not task_list:
|
||||||
|
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
|
||||||
|
else:
|
||||||
|
for index, task in enumerate(task_list[:_MAX_TASKS]):
|
||||||
|
if index:
|
||||||
|
elements.append({"tag": "hr"})
|
||||||
|
task_id = str(task.get("id") or "")
|
||||||
|
receiver = str((task.get("action") or {}).get("receiver") or "")
|
||||||
|
enabled = task.get("enabled", True)
|
||||||
|
status = "Enabled" if enabled else "Disabled"
|
||||||
|
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
|
||||||
|
task.get("name") or "Unnamed task",
|
||||||
|
status,
|
||||||
|
task_id,
|
||||||
|
_format_schedule(task.get("schedule") or {}),
|
||||||
|
next_run,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
toggle_action = "disable" if enabled else "enable"
|
||||||
|
toggle_text = "Disable" if enabled else "Enable"
|
||||||
|
toggle_type = "default" if enabled else "primary"
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"tag": "column_set",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"tag": "column",
|
||||||
|
"elements": [
|
||||||
|
_button(
|
||||||
|
toggle_text,
|
||||||
|
toggle_type,
|
||||||
|
toggle_action,
|
||||||
|
task_id,
|
||||||
|
receiver,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "column",
|
||||||
|
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
hidden = len(task_list) - _MAX_TASKS
|
||||||
|
if hidden > 0:
|
||||||
|
elements.extend(
|
||||||
|
[
|
||||||
|
{"tag": "hr"},
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": "{} more tasks are hidden.".format(hidden),
|
||||||
|
"text_size": "notation",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": "2.0",
|
||||||
|
"config": {"update_multi": True, "enable_forward_interaction": False},
|
||||||
|
"header": {
|
||||||
|
"template": "blue",
|
||||||
|
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
|
||||||
|
},
|
||||||
|
"body": {"elements": elements},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_scheduler_action(
|
||||||
|
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Apply an owned scheduler action and return a Feishu callback response."""
|
||||||
|
if value.get("cowagent") != "scheduler":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
task_id = str(value.get("task_id") or "")
|
||||||
|
action = value.get("action")
|
||||||
|
if not task_id or action not in {"enable", "disable", "delete"}:
|
||||||
|
return _toast("error", "Invalid scheduler action")
|
||||||
|
|
||||||
|
task = task_store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return _response(
|
||||||
|
"info",
|
||||||
|
"Task no longer exists",
|
||||||
|
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
task_receiver = (task.get("action") or {}).get("receiver")
|
||||||
|
task_channel = (task.get("action") or {}).get("channel_type")
|
||||||
|
value_receiver = value.get("receiver")
|
||||||
|
if (
|
||||||
|
task_channel != "feishu"
|
||||||
|
or task_receiver not in allowed_receivers
|
||||||
|
or (value_receiver and value_receiver != task_receiver)
|
||||||
|
):
|
||||||
|
return _toast("error", "Task is not available in this chat")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == "delete":
|
||||||
|
task_store.delete_task(task_id)
|
||||||
|
message = "Task deleted"
|
||||||
|
else:
|
||||||
|
enabled = action == "enable"
|
||||||
|
task_store.enable_task(task_id, enabled)
|
||||||
|
message = "Task enabled" if enabled else "Task disabled"
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return _toast("error", "Task update failed: {}".format(exc))
|
||||||
|
|
||||||
|
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
|
||||||
|
return _response("success", message, build_scheduler_card(visible))
|
||||||
|
|
||||||
|
|
||||||
|
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
response = _toast(toast_type, content)
|
||||||
|
response["card"] = {"type": "raw", "data": card}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
|
||||||
|
return {"toast": {"type": toast_type, "content": content}}
|
||||||
|
|
||||||
|
|
||||||
|
def _button(
|
||||||
|
text: str,
|
||||||
|
button_type: str,
|
||||||
|
action: str,
|
||||||
|
task_id: str,
|
||||||
|
receiver: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": text},
|
||||||
|
"type": button_type,
|
||||||
|
"value": {
|
||||||
|
"cowagent": "scheduler",
|
||||||
|
"action": action,
|
||||||
|
"task_id": task_id,
|
||||||
|
"receiver": receiver,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_schedule(schedule: Dict[str, Any]) -> str:
|
||||||
|
schedule_type = schedule.get("type")
|
||||||
|
if schedule_type == "cron":
|
||||||
|
return "cron {}".format(schedule.get("expression") or "?")
|
||||||
|
if schedule_type == "interval":
|
||||||
|
return "every {}s".format(schedule.get("seconds") or "?")
|
||||||
|
if schedule_type == "once":
|
||||||
|
return "once at {}".format(schedule.get("run_at") or "?")
|
||||||
|
return str(schedule_type or "unknown schedule")
|
||||||
218
channel/feishu/feishu_static_card.py
Normal file
218
channel/feishu/feishu_static_card.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from typing import Callable, Optional, Tuple
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
_BLOCK_MARKDOWN = re.compile(
|
||||||
|
r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)"
|
||||||
|
)
|
||||||
|
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
|
||||||
|
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
||||||
|
_MARKDOWN_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\s]+)\)")
|
||||||
|
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
||||||
|
_MAX_REDIRECTS = 3
|
||||||
|
_MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def contains_markdown(text: str) -> bool:
|
||||||
|
"""Return whether *text* contains syntax that benefits from card Markdown."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
_BLOCK_MARKDOWN.search(text)
|
||||||
|
or _INLINE_MARKDOWN.search(text)
|
||||||
|
or _TABLE_SEPARATOR.search(text)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown_card(text: str) -> dict:
|
||||||
|
"""Build an inline Card 2.0 payload with one Markdown element."""
|
||||||
|
return {
|
||||||
|
"schema": "2.0",
|
||||||
|
"config": {},
|
||||||
|
"body": {
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": text,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_text_delivery(text: str) -> Tuple[str, str]:
|
||||||
|
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
|
||||||
|
if contains_markdown(text):
|
||||||
|
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
||||||
|
return "text", json.dumps({"text": text}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_markdown_images(
|
||||||
|
text: str,
|
||||||
|
uploader: Callable[[str], Optional[str]],
|
||||||
|
max_images: int = 5,
|
||||||
|
) -> str:
|
||||||
|
"""Replace remote Markdown image URLs with Feishu image keys."""
|
||||||
|
cache = {}
|
||||||
|
uploaded = 0
|
||||||
|
|
||||||
|
def replace(match):
|
||||||
|
nonlocal uploaded
|
||||||
|
alt = match.group(1).strip() or "image"
|
||||||
|
target = match.group(2).strip()
|
||||||
|
if target.startswith("img_"):
|
||||||
|
return match.group(0)
|
||||||
|
if urlparse(target).scheme not in ("http", "https"):
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
if target not in cache:
|
||||||
|
if uploaded >= max_images:
|
||||||
|
cache[target] = None
|
||||||
|
else:
|
||||||
|
uploaded += 1
|
||||||
|
try:
|
||||||
|
cache[target] = uploader(target)
|
||||||
|
except Exception:
|
||||||
|
cache[target] = None
|
||||||
|
|
||||||
|
image_key = cache[target]
|
||||||
|
if image_key:
|
||||||
|
return "".format(alt, image_key)
|
||||||
|
return "[Image unavailable: {}]".format(alt)
|
||||||
|
|
||||||
|
return _MARKDOWN_IMAGE.sub(replace, text or "")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_public_image_url(url: str) -> None:
|
||||||
|
"""Reject non-HTTP and non-public image targets before downloading."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError("unsupported image URL scheme")
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise ValueError("image URL has no hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
literal_address = ipaddress.ip_address(parsed.hostname)
|
||||||
|
resolved_addresses = [literal_address]
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
addresses = socket.getaddrinfo(
|
||||||
|
parsed.hostname,
|
||||||
|
parsed.port,
|
||||||
|
socket.AF_UNSPEC,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise ValueError("cannot resolve image hostname") from exc
|
||||||
|
resolved_addresses = [ipaddress.ip_address(item[4][0]) for item in addresses]
|
||||||
|
|
||||||
|
for address in resolved_addresses:
|
||||||
|
if (
|
||||||
|
address.is_private
|
||||||
|
or address.is_loopback
|
||||||
|
or address.is_link_local
|
||||||
|
or address.is_reserved
|
||||||
|
or address.is_multicast
|
||||||
|
or address.is_unspecified
|
||||||
|
):
|
||||||
|
raise ValueError("image URL resolves to a non-public address")
|
||||||
|
|
||||||
|
|
||||||
|
def download_public_image(
|
||||||
|
url: str,
|
||||||
|
get=requests.get,
|
||||||
|
max_bytes: int = _MAX_REMOTE_IMAGE_BYTES,
|
||||||
|
) -> Tuple[bytes, str]:
|
||||||
|
"""Download a public image with redirect, type, and size checks."""
|
||||||
|
current = url
|
||||||
|
for _ in range(_MAX_REDIRECTS + 1):
|
||||||
|
validate_public_image_url(current)
|
||||||
|
response = get(
|
||||||
|
current,
|
||||||
|
headers={"User-Agent": "CowAgent/Feishu"},
|
||||||
|
timeout=(5, 15),
|
||||||
|
allow_redirects=False,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in _REDIRECT_CODES:
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
response.close()
|
||||||
|
if not location:
|
||||||
|
raise ValueError("image redirect has no location")
|
||||||
|
current = urljoin(current, location)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
response.close()
|
||||||
|
raise ValueError("image download returned HTTP {}".format(response.status_code))
|
||||||
|
|
||||||
|
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
response.close()
|
||||||
|
raise ValueError("remote resource is not an image")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_length = int(response.headers.get("Content-Length") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
content_length = 0
|
||||||
|
if content_length > max_bytes:
|
||||||
|
response.close()
|
||||||
|
raise ValueError("remote image is too large")
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
downloaded = 0
|
||||||
|
try:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded > max_bytes:
|
||||||
|
raise ValueError("remote image is too large")
|
||||||
|
chunks.append(chunk)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
return b"".join(chunks), content_type
|
||||||
|
|
||||||
|
raise ValueError("too many image redirects")
|
||||||
|
|
||||||
|
|
||||||
|
def upload_public_image_to_feishu(
|
||||||
|
url: str,
|
||||||
|
access_token: str,
|
||||||
|
post=requests.post,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Download a public image and upload its bytes to Feishu."""
|
||||||
|
payload, content_type = download_public_image(url)
|
||||||
|
extension = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/bmp": "bmp",
|
||||||
|
}.get(content_type, "img")
|
||||||
|
response = post(
|
||||||
|
"https://open.feishu.cn/open-apis/im/v1/images",
|
||||||
|
headers={"Authorization": "Bearer " + access_token},
|
||||||
|
data={"image_type": "message"},
|
||||||
|
files={
|
||||||
|
"image": (
|
||||||
|
"markdown-image.{}".format(extension),
|
||||||
|
payload,
|
||||||
|
content_type,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
timeout=(5, 15),
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
if body.get("code") != 0:
|
||||||
|
return None
|
||||||
|
return (body.get("data") or {}).get("image_key")
|
||||||
@@ -36,11 +36,13 @@ TELEGRAM_BOT_COMMANDS = [
|
|||||||
("help", "Show command help"),
|
("help", "Show command help"),
|
||||||
("status", "Show running status"),
|
("status", "Show running status"),
|
||||||
("context", "View/clear conversation context (sub: clear)"),
|
("context", "View/clear conversation context (sub: clear)"),
|
||||||
|
("tasks", "List scheduled tasks for this chat"),
|
||||||
("skill", "Manage skills (list/search/install/...)"),
|
("skill", "Manage skills (list/search/install/...)"),
|
||||||
("memory", "Manage memory (sub: dream)"),
|
("memory", "Manage memory (sub: dream)"),
|
||||||
("knowledge", "Manage knowledge base (list/on/off)"),
|
("knowledge", "Manage knowledge base (list/on/off)"),
|
||||||
("config", "Show current config"),
|
("config", "Show current config"),
|
||||||
("cancel", "Cancel running agent task"),
|
("cancel", "Cancel running agent task"),
|
||||||
|
("steer", "Guide the running agent task"),
|
||||||
("logs", "Show recent logs"),
|
("logs", "Show recent logs"),
|
||||||
("version", "Show version"),
|
("version", "Show version"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -485,6 +485,20 @@
|
|||||||
<i class="fas fa-microphone text-sm"></i>
|
<i class="fas fa-microphone text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="steer-btn"
|
||||||
|
class="hidden flex-shrink-0 w-10 h-10 items-center justify-center rounded-lg
|
||||||
|
border border-primary-300 dark:border-primary-700
|
||||||
|
text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||||
|
disabled:text-slate-300 dark:disabled:text-slate-600
|
||||||
|
disabled:border-slate-200 dark:disabled:border-slate-700
|
||||||
|
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
||||||
|
type="button"
|
||||||
|
data-i18n-title="steer_active"
|
||||||
|
data-i18n-aria-label="steer_active"
|
||||||
|
aria-label="引导当前任务"
|
||||||
|
title="引导当前任务">
|
||||||
|
<i class="fas fa-arrow-turn-up text-sm"></i>
|
||||||
|
</button>
|
||||||
<button id="send-btn"
|
<button id="send-btn"
|
||||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||||
bg-primary-400 text-white hover:bg-primary-500
|
bg-primary-400 text-white hover:bg-primary-500
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ const I18N = {
|
|||||||
slash_knowledge_off: '关闭知识库',
|
slash_knowledge_off: '关闭知识库',
|
||||||
slash_config: '查看当前配置',
|
slash_config: '查看当前配置',
|
||||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||||
|
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
|
||||||
|
steer_active: '引导当前任务',
|
||||||
slash_logs: '查看最近日志',
|
slash_logs: '查看最近日志',
|
||||||
slash_version: '查看版本',
|
slash_version: '查看版本',
|
||||||
input_placeholder: '输入消息,或输入 / 使用指令',
|
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||||
@@ -217,6 +219,11 @@ const I18N = {
|
|||||||
task_delete_btn: '删除任务',
|
task_delete_btn: '删除任务',
|
||||||
task_delete_confirm_title: '删除定时任务',
|
task_delete_confirm_title: '删除定时任务',
|
||||||
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
|
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
|
||||||
|
task_run_now: '立即执行',
|
||||||
|
task_run_confirm_title: '立即执行任务',
|
||||||
|
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||||
|
task_run_started: '已开始执行',
|
||||||
|
task_run_failed: '执行失败',
|
||||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||||
new_chat: '新对话',
|
new_chat: '新对话',
|
||||||
@@ -370,6 +377,8 @@ const I18N = {
|
|||||||
slash_knowledge_off: '關閉知識庫',
|
slash_knowledge_off: '關閉知識庫',
|
||||||
slash_config: '檢視當前設定',
|
slash_config: '檢視當前設定',
|
||||||
slash_cancel: '中止當前正在執行的 Agent 任務',
|
slash_cancel: '中止當前正在執行的 Agent 任務',
|
||||||
|
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
|
||||||
|
steer_active: '引導當前任務',
|
||||||
slash_logs: '檢視最近日誌',
|
slash_logs: '檢視最近日誌',
|
||||||
slash_version: '檢視版本',
|
slash_version: '檢視版本',
|
||||||
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
||||||
@@ -464,6 +473,11 @@ const I18N = {
|
|||||||
task_delete_btn: '刪除任務',
|
task_delete_btn: '刪除任務',
|
||||||
task_delete_confirm_title: '刪除定時任務',
|
task_delete_confirm_title: '刪除定時任務',
|
||||||
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
|
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
|
||||||
|
task_run_now: '立即執行',
|
||||||
|
task_run_confirm_title: '立即執行任務',
|
||||||
|
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
|
||||||
|
task_run_started: '已開始執行',
|
||||||
|
task_run_failed: '執行失敗',
|
||||||
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
|
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
|
||||||
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
||||||
new_chat: '新對話',
|
new_chat: '新對話',
|
||||||
@@ -616,6 +630,8 @@ const I18N = {
|
|||||||
slash_knowledge_off: 'Disable knowledge base',
|
slash_knowledge_off: 'Disable knowledge base',
|
||||||
slash_config: 'Show current config',
|
slash_config: 'Show current config',
|
||||||
slash_cancel: 'Abort the running Agent task',
|
slash_cancel: 'Abort the running Agent task',
|
||||||
|
slash_steer: 'Inject guidance into the running Agent task',
|
||||||
|
steer_active: 'Steer active task',
|
||||||
slash_logs: 'Show recent logs',
|
slash_logs: 'Show recent logs',
|
||||||
slash_version: 'Show version',
|
slash_version: 'Show version',
|
||||||
input_placeholder: 'Type a message, or press / for commands',
|
input_placeholder: 'Type a message, or press / for commands',
|
||||||
@@ -710,6 +726,11 @@ const I18N = {
|
|||||||
task_delete_btn: 'Delete Task',
|
task_delete_btn: 'Delete Task',
|
||||||
task_delete_confirm_title: 'Delete Task',
|
task_delete_confirm_title: 'Delete Task',
|
||||||
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
|
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
|
||||||
|
task_run_now: 'Run now',
|
||||||
|
task_run_confirm_title: 'Run task now',
|
||||||
|
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||||
|
task_run_started: 'Run started',
|
||||||
|
task_run_failed: 'Run failed',
|
||||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||||
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||||
new_chat: 'New Chat',
|
new_chat: 'New Chat',
|
||||||
@@ -801,6 +822,9 @@ function applyI18n() {
|
|||||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
el.title = t(el.dataset['i18nTitle']);
|
el.title = t(el.dataset['i18nTitle']);
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
|
||||||
|
el.setAttribute('aria-label', t(el.dataset['i18nAriaLabel']));
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
||||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||||
});
|
});
|
||||||
@@ -1400,6 +1424,7 @@ startPolling();
|
|||||||
|
|
||||||
const chatInput = document.getElementById('chat-input');
|
const chatInput = document.getElementById('chat-input');
|
||||||
const sendBtn = document.getElementById('send-btn');
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const steerBtn = document.getElementById('steer-btn');
|
||||||
const messagesDiv = document.getElementById('chat-messages');
|
const messagesDiv = document.getElementById('chat-messages');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
const folderInput = document.getElementById('folder-input');
|
const folderInput = document.getElementById('folder-input');
|
||||||
@@ -1734,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
|
|||||||
sendBtn.classList.add('send-btn-cancel');
|
sendBtn.classList.add('send-btn-cancel');
|
||||||
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
||||||
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
||||||
|
updateSteerBtnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSendBtnSendMode() {
|
function resetSendBtnSendMode() {
|
||||||
@@ -1742,9 +1768,64 @@ function resetSendBtnSendMode() {
|
|||||||
sendBtn.classList.remove('send-btn-cancel');
|
sendBtn.classList.remove('send-btn-cancel');
|
||||||
sendBtn.title = '';
|
sendBtn.title = '';
|
||||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
||||||
|
steerBtn.classList.add('hidden');
|
||||||
|
steerBtn.classList.remove('flex');
|
||||||
|
steerBtn.disabled = true;
|
||||||
updateSendBtnState();
|
updateSendBtnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSteerBtnState() {
|
||||||
|
// Keep the steer button enabled whenever a task is running so users can
|
||||||
|
// fire successive guidance. Empty-input is guarded in steerActiveTask,
|
||||||
|
// avoiding a jarring disabled/not-allowed state right after each steer.
|
||||||
|
const active = sendBtnMode === 'cancel' && !!activeRequestId;
|
||||||
|
steerBtn.classList.toggle('hidden', !active);
|
||||||
|
steerBtn.classList.toggle('flex', active);
|
||||||
|
steerBtn.disabled = !active || uploadingCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function steerActiveTask() {
|
||||||
|
const instruction = chatInput.value.trim();
|
||||||
|
if (!instruction || sendBtnMode !== 'cancel' || !activeRequestId) return;
|
||||||
|
|
||||||
|
inputHistory.push(instruction);
|
||||||
|
historyIdx = -1;
|
||||||
|
historySavedDraft = '';
|
||||||
|
addUserMessage(`↪ ${instruction}`, new Date());
|
||||||
|
|
||||||
|
chatInput.value = '';
|
||||||
|
chatInput.style.height = '42px';
|
||||||
|
chatInput.style.overflowY = 'hidden';
|
||||||
|
updateSteerBtnState();
|
||||||
|
|
||||||
|
fetch('/message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
message: instruction,
|
||||||
|
steer: true,
|
||||||
|
stream: false,
|
||||||
|
lang: currentLang,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success' && data.inline_reply) {
|
||||||
|
addBotMessage(data.inline_reply, new Date());
|
||||||
|
} else {
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[steer] request failed', err);
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
})
|
||||||
|
.finally(updateSteerBtnState);
|
||||||
|
}
|
||||||
|
|
||||||
|
steerBtn.addEventListener('click', steerActiveTask);
|
||||||
|
|
||||||
function requestCancel() {
|
function requestCancel() {
|
||||||
const reqId = activeRequestId;
|
const reqId = activeRequestId;
|
||||||
if (!reqId) return;
|
if (!reqId) return;
|
||||||
@@ -1780,10 +1861,12 @@ function updateSendBtnState() {
|
|||||||
resetSendBtnSendMode();
|
resetSendBtnSendMode();
|
||||||
} else {
|
} else {
|
||||||
// Don't downgrade a genuinely active Cancel button on input edits.
|
// Don't downgrade a genuinely active Cancel button on input edits.
|
||||||
|
updateSteerBtnState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||||
|
updateSteerBtnState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachmentPreview() {
|
function renderAttachmentPreview() {
|
||||||
@@ -2102,6 +2185,7 @@ const SLASH_COMMANDS = [
|
|||||||
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
|
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
|
||||||
{ cmd: '/config', desc: 'slash_config' },
|
{ cmd: '/config', desc: 'slash_config' },
|
||||||
{ cmd: '/cancel', desc: 'slash_cancel' },
|
{ cmd: '/cancel', desc: 'slash_cancel' },
|
||||||
|
{ cmd: '/steer ', desc: 'slash_steer' },
|
||||||
{ cmd: '/logs', desc: 'slash_logs' },
|
{ cmd: '/logs', desc: 'slash_logs' },
|
||||||
{ cmd: '/version', desc: 'slash_version' },
|
{ cmd: '/version', desc: 'slash_version' },
|
||||||
];
|
];
|
||||||
@@ -7935,6 +8019,38 @@ function refreshTasksView() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runTaskNow(task, button) {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: t('task_run_confirm_title'),
|
||||||
|
message: `${task.name || task.id}: ${t('task_run_confirm_msg')}`,
|
||||||
|
okText: t('task_run_now'),
|
||||||
|
onConfirm: () => {
|
||||||
|
const originalHtml = button.innerHTML;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${t('task_run_now')}`;
|
||||||
|
fetch('/api/scheduler/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({task_id: task.id})
|
||||||
|
}).then(r => r.json()).then(res => {
|
||||||
|
if (res.status !== 'success') throw new Error(res.message || t('task_run_failed'));
|
||||||
|
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('task_run_started')}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
button.disabled = false;
|
||||||
|
}, 1500);
|
||||||
|
}).catch(() => {
|
||||||
|
button.innerHTML = `<i class="fas fa-triangle-exclamation mr-1"></i>${t('task_run_failed')}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
button.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadTasksView() {
|
function loadTasksView() {
|
||||||
if (tasksLoaded) return;
|
if (tasksLoaded) return;
|
||||||
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
||||||
@@ -7996,11 +8112,19 @@ function loadTasksView() {
|
|||||||
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
||||||
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
<button type="button" class="task-run-now px-2 py-1 rounded-md text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-500/10 transition-colors">
|
||||||
|
<i class="fas fa-play mr-1"></i>${t('task_run_now')}
|
||||||
|
</button>
|
||||||
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
|
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
|
||||||
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
|
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
|
||||||
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||||
</label>
|
</label>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
const runButton = card.querySelector('.task-run-now');
|
||||||
|
runButton.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
runTaskNow(task, runButton);
|
||||||
|
});
|
||||||
const checkbox = card.querySelector('#' + toggleId);
|
const checkbox = card.querySelector('#' + toggleId);
|
||||||
checkbox.addEventListener('change', function() {
|
checkbox.addEventListener('change', function() {
|
||||||
const newEnabled = this.checked;
|
const newEnabled = this.checked;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -134,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
|
|||||||
return "Nothing to cancel." if en else "当前没有可中止的任务。"
|
return "Nothing to cancel." if en else "当前没有可中止的任务。"
|
||||||
|
|
||||||
|
|
||||||
|
def _steer_reply_text(status, lang: str) -> str:
|
||||||
|
from agent.protocol import SteerStatus
|
||||||
|
|
||||||
|
en = (lang or "").lower().startswith("en")
|
||||||
|
messages = {
|
||||||
|
SteerStatus.ACCEPTED: (
|
||||||
|
"↪️ Active task redirected.", "↪️ 已引导当前任务。"
|
||||||
|
),
|
||||||
|
SteerStatus.INACTIVE: (
|
||||||
|
"No active task to steer.", "当前没有可引导的任务。"
|
||||||
|
),
|
||||||
|
SteerStatus.CLOSING: (
|
||||||
|
"The active task is already finishing.", "当前任务已结束,无法再引导。"
|
||||||
|
),
|
||||||
|
SteerStatus.AMBIGUOUS: (
|
||||||
|
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||||
|
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||||
|
),
|
||||||
|
SteerStatus.FULL: (
|
||||||
|
"Too many steering updates are pending; try again after the agent processes them.",
|
||||||
|
"引导指令过多,请等待当前任务处理后再试。",
|
||||||
|
),
|
||||||
|
SteerStatus.INVALID: (
|
||||||
|
"Usage: /steer <instruction>", "用法:/steer <引导指令>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
english, chinese = messages[status]
|
||||||
|
return english if en else chinese
|
||||||
|
|
||||||
|
|
||||||
def _get_upload_dir() -> str:
|
def _get_upload_dir() -> str:
|
||||||
from common.utils import expand_path
|
from common.utils import expand_path
|
||||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
@@ -912,6 +943,37 @@ class WebChannel(ChatChannel):
|
|||||||
"inline_reply": msg_text,
|
"inline_reply": msg_text,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Explicit steering also bypasses the normal session queue. The
|
||||||
|
# Web button sends ``steer: true`` with raw input; typed /steer
|
||||||
|
# commands use the same endpoint and semantics as IM channels.
|
||||||
|
steer_requested = bool(json_data.get("steer", False))
|
||||||
|
is_steer_command = (
|
||||||
|
re.match(r"^/steer(?:\s|$)", stripped_prompt) is not None
|
||||||
|
)
|
||||||
|
if steer_requested or is_steer_command:
|
||||||
|
instruction = (
|
||||||
|
(prompt or "").strip()[len("/steer"):].strip()
|
||||||
|
if is_steer_command
|
||||||
|
else (prompt or "").strip()
|
||||||
|
)
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
result = Bridge().get_agent_bridge().steer_session(
|
||||||
|
session_id, instruction
|
||||||
|
)
|
||||||
|
lang = (json_data.get("lang") or "zh").lower()
|
||||||
|
msg_text = _steer_reply_text(result.status, lang)
|
||||||
|
logger.info(
|
||||||
|
f"[WebChannel] steer fast-path: session={session_id}, "
|
||||||
|
f"status={result.status.value}, lang={lang}"
|
||||||
|
)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"request_id": "",
|
||||||
|
"stream": False,
|
||||||
|
"steered": result.accepted,
|
||||||
|
"inline_reply": msg_text,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
# Append file references to the prompt (same format as QQ channel)
|
# Append file references to the prompt (same format as QQ channel)
|
||||||
if attachments:
|
if attachments:
|
||||||
file_refs = []
|
file_refs = []
|
||||||
@@ -1309,6 +1371,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
|
'/api/scheduler/run', 'SchedulerRunHandler',
|
||||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||||
@@ -1707,7 +1770,7 @@ class ConfigHandler:
|
|||||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||||
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
]
|
]
|
||||||
@@ -1794,7 +1857,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "moonshot_base_url",
|
"api_base_key": "moonshot_base_url",
|
||||||
"api_base_default": "https://api.moonshot.cn/v1",
|
"api_base_default": "https://api.moonshot.cn/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||||
}),
|
}),
|
||||||
("qianfan", {
|
("qianfan", {
|
||||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||||
@@ -4537,6 +4600,34 @@ class SchedulerHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerRunHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
|
||||||
|
from agent.tools.scheduler.integration import get_scheduler_service
|
||||||
|
service = get_scheduler_service()
|
||||||
|
if service is None:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Scheduler service is not running",
|
||||||
|
})
|
||||||
|
|
||||||
|
service.run_task_now(task_id)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Task '{task_id}' queued for immediate execution",
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
class SchedulerToggleHandler:
|
class SchedulerToggleHandler:
|
||||||
def POST(self):
|
def POST(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
@@ -4610,11 +4701,33 @@ class SchedulerUpdateHandler:
|
|||||||
|
|
||||||
# Update action
|
# Update action
|
||||||
if "action" in body:
|
if "action" in body:
|
||||||
action = body["action"]
|
|
||||||
channel_type = action.get("channel_type", "web")
|
|
||||||
|
|
||||||
# Get the task's original channel_type
|
# Get the task's original channel_type
|
||||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
original_action = original_task.get("action", {})
|
||||||
|
if not isinstance(original_action, dict):
|
||||||
|
original_action = {}
|
||||||
|
action_patch = body["action"]
|
||||||
|
if not isinstance(action_patch, dict):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Action must be an object."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# The Web editor only exposes a subset of action fields. Merge
|
||||||
|
# that patch into the stored action so scheduler metadata such
|
||||||
|
# as notify_session_id, silent, and channel-specific delivery
|
||||||
|
# fields survive unrelated edits.
|
||||||
|
action = dict(original_action)
|
||||||
|
action.update(action_patch)
|
||||||
|
action_type = action.get("type")
|
||||||
|
if action_type == "send_message":
|
||||||
|
action.pop("task_description", None)
|
||||||
|
action.pop("silent", None)
|
||||||
|
elif action_type == "agent_task":
|
||||||
|
action.pop("content", None)
|
||||||
|
|
||||||
|
old_channel = original_action.get("channel_type", "web")
|
||||||
|
channel_type = action.get("channel_type") or old_channel
|
||||||
|
action["channel_type"] = channel_type
|
||||||
|
|
||||||
# If channel type changed or no receiver, reject the update.
|
# If channel type changed or no receiver, reject the update.
|
||||||
# Note: the web UI disables the channel selector, so this branch
|
# Note: the web UI disables the channel selector, so this branch
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from cli.commands.process import start, stop, restart, self_restart, update, sta
|
|||||||
from cli.commands.context import context
|
from cli.commands.context import context
|
||||||
from cli.commands.install import install_browser
|
from cli.commands.install import install_browser
|
||||||
from cli.commands.knowledge import knowledge
|
from cli.commands.knowledge import knowledge
|
||||||
|
from cli.commands.backup import backup_command, restore_command
|
||||||
|
|
||||||
|
|
||||||
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
||||||
@@ -24,6 +25,8 @@ Commands:
|
|||||||
logs View CowAgent logs.
|
logs View CowAgent logs.
|
||||||
skill Manage CowAgent skills.
|
skill Manage CowAgent skills.
|
||||||
knowledge Manage knowledge base.
|
knowledge Manage knowledge base.
|
||||||
|
backup Back up config and agent workspace.
|
||||||
|
restore Restore a CowAgent backup.
|
||||||
install-browser Install browser tool (Playwright + Chromium).
|
install-browser Install browser tool (Playwright + Chromium).
|
||||||
|
|
||||||
Tip: Memory index management lives in chat — send /memory status or
|
Tip: Memory index management lives in chat — send /memory status or
|
||||||
@@ -74,6 +77,8 @@ main.add_command(status)
|
|||||||
main.add_command(logs)
|
main.add_command(logs)
|
||||||
main.add_command(context)
|
main.add_command(context)
|
||||||
main.add_command(knowledge)
|
main.add_command(knowledge)
|
||||||
|
main.add_command(backup_command)
|
||||||
|
main.add_command(restore_command)
|
||||||
main.add_command(install_browser)
|
main.add_command(install_browser)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
332
cli/commands/backup.py
Normal file
332
cli/commands/backup.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""Portable local backup and restore commands for CowAgent user data."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Iterable, Optional, Set
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cli.utils import get_project_root
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_FORMAT = "cowagent-backup"
|
||||||
|
BACKUP_VERSION = 1
|
||||||
|
_SKIP_DIRS = {".git", "__pycache__", "tmp"}
|
||||||
|
_SKIP_FILES = {".DS_Store"}
|
||||||
|
|
||||||
|
|
||||||
|
def _data_root() -> Path:
|
||||||
|
configured = os.environ.get("COW_DATA_DIR")
|
||||||
|
return Path(configured).expanduser().resolve() if configured else Path(get_project_root()).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_config(data_root: Path) -> dict:
|
||||||
|
path = data_root / "config.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle)
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_from_config(config: dict) -> Path:
|
||||||
|
return Path(config.get("agent_workspace") or "~/cow").expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_user_data_path(data_root: Path, config: dict) -> Path:
|
||||||
|
appdata_dir = config.get("appdata_dir") or ""
|
||||||
|
return (data_root / appdata_dir / "user_datas.pkl").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_within(path: Path, root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
return os.path.commonpath([str(path.resolve()), str(root.resolve())]) == str(root.resolve())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_workspace_files(workspace: Path, excluded: Set[Path]):
|
||||||
|
if not workspace.is_dir():
|
||||||
|
return
|
||||||
|
for current, dirnames, filenames in os.walk(str(workspace), followlinks=False):
|
||||||
|
current_path = Path(current)
|
||||||
|
dirnames[:] = [
|
||||||
|
name for name in dirnames
|
||||||
|
if name not in _SKIP_DIRS and not (current_path / name).is_symlink()
|
||||||
|
]
|
||||||
|
for name in filenames:
|
||||||
|
path = current_path / name
|
||||||
|
if name in _SKIP_FILES or name.endswith((".pyc", ".pyo")):
|
||||||
|
continue
|
||||||
|
if path.is_symlink() or path.resolve() in excluded:
|
||||||
|
continue
|
||||||
|
if path.is_file():
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup_archive(
|
||||||
|
output: Path,
|
||||||
|
data_root: Path,
|
||||||
|
workspace: Path,
|
||||||
|
excluded_paths: Optional[Iterable[Path]] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a portable archive containing config and the agent workspace."""
|
||||||
|
output = Path(output).expanduser().resolve()
|
||||||
|
data_root = Path(data_root).expanduser().resolve()
|
||||||
|
workspace = Path(workspace).expanduser().resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
excluded = {Path(path).expanduser().resolve() for path in (excluded_paths or [])}
|
||||||
|
excluded.add(output)
|
||||||
|
|
||||||
|
config_path = data_root / "config.json"
|
||||||
|
config = _read_config(data_root)
|
||||||
|
legacy_path = _legacy_user_data_path(data_root, config)
|
||||||
|
workspace_files = list(_iter_workspace_files(workspace, excluded))
|
||||||
|
total_bytes = sum(path.stat().st_size for path in workspace_files)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"format": BACKUP_FORMAT,
|
||||||
|
"version": BACKUP_VERSION,
|
||||||
|
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||||
|
"workspace_source": str(workspace),
|
||||||
|
"contents": {
|
||||||
|
"config": config_path.is_file(),
|
||||||
|
"legacy_user_data": legacy_path.is_file(),
|
||||||
|
"workspace_files": len(workspace_files),
|
||||||
|
"workspace_bytes": total_bytes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-backup-"))
|
||||||
|
temp_archive = temp_dir / "backup.zip"
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
str(temp_archive), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
|
||||||
|
) as archive:
|
||||||
|
archive.writestr(
|
||||||
|
"manifest.json",
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
)
|
||||||
|
if config_path.is_file():
|
||||||
|
archive.write(str(config_path), "data/config.json")
|
||||||
|
if legacy_path.is_file():
|
||||||
|
archive.write(str(legacy_path), "data/user_datas.pkl")
|
||||||
|
for path in workspace_files:
|
||||||
|
relative = path.relative_to(workspace).as_posix()
|
||||||
|
archive.write(str(path), "workspace/" + relative)
|
||||||
|
os.replace(str(temp_archive), str(output))
|
||||||
|
try:
|
||||||
|
os.chmod(str(output), stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||||
|
|
||||||
|
manifest["archive"] = str(output)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_archive(archive: zipfile.ZipFile) -> dict:
|
||||||
|
names = {info.filename for info in archive.infolist()}
|
||||||
|
if "manifest.json" not in names:
|
||||||
|
raise ValueError("archive is missing manifest.json")
|
||||||
|
try:
|
||||||
|
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError("archive manifest is invalid") from exc
|
||||||
|
if manifest.get("format") != BACKUP_FORMAT or manifest.get("version") != BACKUP_VERSION:
|
||||||
|
raise ValueError("unsupported CowAgent backup format or version")
|
||||||
|
|
||||||
|
for info in archive.infolist():
|
||||||
|
name = info.filename
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
|
||||||
|
raise ValueError(f"unsafe archive path: {name!r}")
|
||||||
|
mode = (info.external_attr >> 16) & 0o170000
|
||||||
|
if mode == stat.S_IFLNK:
|
||||||
|
raise ValueError(f"symbolic links are not allowed in backups: {name!r}")
|
||||||
|
if name != "manifest.json" and not name.startswith(("data/", "workspace/")):
|
||||||
|
raise ValueError(f"unexpected archive entry: {name!r}")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_validated(archive: zipfile.ZipFile, destination: Path) -> None:
|
||||||
|
for info in archive.infolist():
|
||||||
|
target = destination.joinpath(*PurePosixPath(info.filename).parts)
|
||||||
|
if info.is_dir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with archive.open(info, "r") as source, target.open("wb") as output:
|
||||||
|
shutil.copyfileobj(source, output)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_copy(source: Path, destination: Path, private: bool = False) -> None:
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temp_name = tempfile.mkstemp(prefix=destination.name + ".", dir=str(destination.parent))
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
shutil.copy2(str(source), temp_name)
|
||||||
|
os.replace(temp_name, str(destination))
|
||||||
|
if private:
|
||||||
|
try:
|
||||||
|
os.chmod(str(destination), stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_name):
|
||||||
|
os.remove(temp_name)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup_archive(
|
||||||
|
archive_path: Path,
|
||||||
|
data_root: Path,
|
||||||
|
workspace: Optional[Path] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Merge a validated backup into the selected data root and workspace."""
|
||||||
|
archive_path = Path(archive_path).expanduser().resolve()
|
||||||
|
data_root = Path(data_root).expanduser().resolve()
|
||||||
|
current_config = _read_config(data_root)
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-restore-"))
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(str(archive_path), "r") as archive:
|
||||||
|
manifest = _validate_archive(archive)
|
||||||
|
_extract_validated(archive, temp_dir)
|
||||||
|
|
||||||
|
archived_config_path = temp_dir / "data" / "config.json"
|
||||||
|
archived_config = {}
|
||||||
|
if archived_config_path.is_file():
|
||||||
|
with archived_config_path.open("r", encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("archived config.json must contain an object")
|
||||||
|
archived_config = value
|
||||||
|
|
||||||
|
if workspace is not None:
|
||||||
|
target_workspace = Path(workspace).expanduser().resolve()
|
||||||
|
elif current_config.get("agent_workspace"):
|
||||||
|
target_workspace = _workspace_from_config(current_config)
|
||||||
|
else:
|
||||||
|
# Do not trust an archive-controlled absolute destination on a
|
||||||
|
# fresh machine. Portable restores default to the standard local
|
||||||
|
# workspace unless the operator supplies --workspace.
|
||||||
|
target_workspace = Path("~/cow").expanduser().resolve()
|
||||||
|
|
||||||
|
restored_config = dict(archived_config)
|
||||||
|
if restored_config:
|
||||||
|
restored_config["agent_workspace"] = str(target_workspace)
|
||||||
|
appdata_dir = restored_config.get("appdata_dir") or ""
|
||||||
|
if appdata_dir:
|
||||||
|
archived_appdata = (data_root / appdata_dir).resolve()
|
||||||
|
if not _is_within(archived_appdata, data_root):
|
||||||
|
# Keep legacy user data under the selected data root
|
||||||
|
# instead of writing to an archive-controlled path.
|
||||||
|
restored_config["appdata_dir"] = ""
|
||||||
|
config_temp = temp_dir / "restored-config.json"
|
||||||
|
with config_temp.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(restored_config, handle, ensure_ascii=False, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
_atomic_copy(config_temp, data_root / "config.json", private=True)
|
||||||
|
|
||||||
|
workspace_root = temp_dir / "workspace"
|
||||||
|
restored_files = 0
|
||||||
|
if workspace_root.is_dir():
|
||||||
|
for source in _iter_workspace_files(workspace_root, set()):
|
||||||
|
relative = source.relative_to(workspace_root)
|
||||||
|
destination = target_workspace / relative
|
||||||
|
if not _is_within(destination, target_workspace):
|
||||||
|
raise ValueError(f"unsafe workspace destination: {relative}")
|
||||||
|
_atomic_copy(source, destination)
|
||||||
|
restored_files += 1
|
||||||
|
|
||||||
|
legacy_source = temp_dir / "data" / "user_datas.pkl"
|
||||||
|
if legacy_source.is_file():
|
||||||
|
effective_config = restored_config or current_config
|
||||||
|
legacy_destination = _legacy_user_data_path(data_root, effective_config)
|
||||||
|
_atomic_copy(legacy_source, legacy_destination, private=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"manifest": manifest,
|
||||||
|
"workspace": str(target_workspace),
|
||||||
|
"workspace_files": restored_files,
|
||||||
|
"config_restored": bool(restored_config),
|
||||||
|
"legacy_user_data_restored": legacy_source.is_file(),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("backup")
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(dir_okay=False, path_type=Path),
|
||||||
|
help="Output .zip path (default: ./cow-backup-<timestamp>.zip).",
|
||||||
|
)
|
||||||
|
def backup_command(output: Optional[Path]):
|
||||||
|
"""Back up config, persona, memory, skills, knowledge, and schedules."""
|
||||||
|
data_root = _data_root()
|
||||||
|
config = _read_config(data_root)
|
||||||
|
workspace = _workspace_from_config(config)
|
||||||
|
if output is None:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
output = Path.cwd() / f"cow-backup-{stamp}.zip"
|
||||||
|
result = create_backup_archive(output, data_root, workspace)
|
||||||
|
click.echo(click.style("✓ Backup created", fg="green"))
|
||||||
|
click.echo(f" Archive: {result['archive']}")
|
||||||
|
click.echo(f" Workspace files: {result['contents']['workspace_files']}")
|
||||||
|
click.echo(" Keep this archive private: it may contain API keys and personal data.")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("restore")
|
||||||
|
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
|
@click.option(
|
||||||
|
"--workspace",
|
||||||
|
type=click.Path(file_okay=False, path_type=Path),
|
||||||
|
help="Restore workspace files to this directory.",
|
||||||
|
)
|
||||||
|
@click.option("--yes", is_flag=True, help="Confirm overwriting matching files.")
|
||||||
|
def restore_command(archive: Path, workspace: Optional[Path], yes: bool):
|
||||||
|
"""Restore a backup without deleting unrelated destination files."""
|
||||||
|
from cli.commands.process import _read_pid
|
||||||
|
|
||||||
|
pid = _read_pid()
|
||||||
|
if pid:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"CowAgent is running (PID: {pid}). Run 'cow stop' before restoring."
|
||||||
|
)
|
||||||
|
if not yes:
|
||||||
|
click.confirm(
|
||||||
|
"Restore this archive and overwrite matching config/workspace files?",
|
||||||
|
abort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
data_root = _data_root()
|
||||||
|
current_config = _read_config(data_root)
|
||||||
|
current_workspace = _workspace_from_config(current_config)
|
||||||
|
has_current_data = (data_root / "config.json").is_file() or current_workspace.is_dir()
|
||||||
|
if has_current_data:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
rollback = archive.resolve().parent / f"cow-pre-restore-{stamp}.zip"
|
||||||
|
create_backup_archive(
|
||||||
|
rollback,
|
||||||
|
data_root,
|
||||||
|
current_workspace,
|
||||||
|
excluded_paths={archive.resolve()},
|
||||||
|
)
|
||||||
|
click.echo(f"Rollback backup: {rollback}")
|
||||||
|
|
||||||
|
result = restore_backup_archive(archive, data_root, workspace)
|
||||||
|
click.echo(click.style("✓ Backup restored", fg="green"))
|
||||||
|
click.echo(f" Workspace: {result['workspace']}")
|
||||||
|
click.echo(f" Restored files: {result['workspace_files']}")
|
||||||
@@ -142,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
|
||||||
|
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
|
||||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||||
KIMI_K2 = "kimi-k2"
|
KIMI_K2 = "kimi-k2"
|
||||||
KIMI_K2_5 = "kimi-k2.5"
|
KIMI_K2_5 = "kimi-k2.5"
|
||||||
@@ -235,7 +236,7 @@ MODEL_LIST = [
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||||
|
|
||||||
# ModelScope
|
# ModelScope
|
||||||
MODELSCOPE,
|
MODELSCOPE,
|
||||||
|
|||||||
@@ -42,7 +42,5 @@
|
|||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
"knowledge": true,
|
"knowledge": true,
|
||||||
"self_evolution_enabled": true,
|
"self_evolution_enabled": true,
|
||||||
"mcp_tool_retrieval_enabled": false,
|
"mcp_tool_retrieval_enabled": false
|
||||||
"mcp_tool_retrieval_threshold": 20,
|
|
||||||
"mcp_tool_retrieval_top_k": 10
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ available_setting = {
|
|||||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||||
|
"feishu_detailed_card": True, # render normal chat streaming as a detailed card (status header, thinking/tool panels, elapsed time); off keeps the plain typewriter card
|
||||||
# DingTalk config
|
# DingTalk config
|
||||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
let getWindow: () => BrowserWindow | null = () => null
|
||||||
|
|
||||||
|
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||||
|
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||||
|
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||||
|
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||||
|
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||||
|
// flag, so the same source serves the right feed on whatever it runs on.
|
||||||
|
function isLegacyWindows(): boolean {
|
||||||
|
if (process.platform !== 'win32') return false
|
||||||
|
const major = Number((os.release() || '').split('.')[0])
|
||||||
|
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||||
|
return Number.isFinite(major) && major < 10
|
||||||
|
}
|
||||||
|
|
||||||
// The update feed. Both entries hit the same Pages Function
|
// The update feed. Both entries hit the same Pages Function
|
||||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||||
// identical either way, so we can freely switch the feed URL between attempts
|
// identical either way, so we can freely switch the feed URL between attempts
|
||||||
// to fall back from one download origin to the other.
|
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||||
|
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||||
|
|
||||||
// Which origin the current session prefers, derived from the app UI language
|
// Which origin the current session prefers, derived from the app UI language
|
||||||
|
|||||||
@@ -372,6 +372,13 @@ class ApiClient {
|
|||||||
return data.tasks
|
return data.tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runTask(taskId: string): Promise<ApiResult> {
|
||||||
|
return this.request('/api/scheduler/run', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ task_id: taskId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
||||||
return this.request('/api/scheduler/toggle', {
|
return this.request('/api/scheduler/toggle', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -100,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
|||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string
|
content: string
|
||||||
|
/**
|
||||||
|
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||||
|
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||||
|
* the knowledge viewer so index links open the target doc rather than firing
|
||||||
|
* an "application cannot be opened (-120)" error in Electron.
|
||||||
|
*/
|
||||||
|
onInternalLink?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const html = useMemo(() => md.render(content || ''), [content])
|
const html = useMemo(() => md.render(content || ''), [content])
|
||||||
|
|
||||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = useCallback(
|
||||||
const target = e.target as HTMLElement
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
const target = e.target as HTMLElement
|
||||||
if (!btn) return
|
|
||||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||||
if (!pre) return
|
if (onInternalLink) {
|
||||||
navigator.clipboard.writeText(pre.textContent || '')
|
const a = target.closest('a') as HTMLAnchorElement | null
|
||||||
const original = btn.textContent
|
if (a) {
|
||||||
btn.textContent = t('msg_copied')
|
const href = a.getAttribute('href') || ''
|
||||||
btn.classList.add('copied')
|
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||||
setTimeout(() => {
|
e.preventDefault()
|
||||||
btn.textContent = original
|
onInternalLink(href)
|
||||||
btn.classList.remove('copied')
|
return
|
||||||
}, 1600)
|
}
|
||||||
}, [])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard.writeText(pre.textContent || '')
|
||||||
|
const original = btn.textContent
|
||||||
|
btn.textContent = t('msg_copied')
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 1600)
|
||||||
|
},
|
||||||
|
[onInternalLink]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
muted, separated from the final answer by a dashed divider. */}
|
muted, separated from the final answer by a dashed divider. */}
|
||||||
{(hasSteps || hasLiveReasoning) && (
|
{(hasSteps || hasLiveReasoning) && (
|
||||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
|
||||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||||
|
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||||
|
must render after all committed steps (tools/thinking), not at
|
||||||
|
the very top of the bubble. */}
|
||||||
|
{hasLiveReasoning && (
|
||||||
|
<div className={hasSteps ? 'mt-1' : ''}>
|
||||||
|
<ThinkingStep content={message.reasoning!} streaming />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||||
import type { MessageStep } from '../types'
|
import type { MessageStep } from '../types'
|
||||||
|
import { t } from '../i18n'
|
||||||
import Markdown from './Markdown'
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
|||||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<Brain size={12} className="flex-shrink-0" />
|
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: '已中止',
|
msg_cancelled: '已中止',
|
||||||
msg_self_learned: '自主学习',
|
msg_self_learned: '自主学习',
|
||||||
msg_stop: '停止',
|
msg_stop: '停止',
|
||||||
|
thinking_in_progress: '思考中…',
|
||||||
|
thinking_done: '已深度思考',
|
||||||
chat_clear_context: '清除上下文',
|
chat_clear_context: '清除上下文',
|
||||||
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
chat_load_earlier: '加载更早的消息',
|
chat_load_earlier: '加载更早的消息',
|
||||||
chat_send: '发送',
|
chat_send: '发送',
|
||||||
chat_attach: '添加附件',
|
chat_attach: '添加附件',
|
||||||
slash_hint: '输入 / 查看命令',
|
slash_hint: '输入 / 查看命令',
|
||||||
chat_welcome: '有什么可以帮你的?',
|
chat_welcome: '有什么可以帮你的?',
|
||||||
chat_empty_hint: '发送一条消息开始对话',
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务',
|
example_task_title: '定时任务',
|
||||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: '已连接',
|
channels_connected_section: '已连接',
|
||||||
channels_available_section: '可添加',
|
channels_available_section: '可添加',
|
||||||
channels_empty_connected: '暂无已连接的通道',
|
channels_empty_connected: '暂无已连接的通道',
|
||||||
|
channels_empty: '暂未接入任何通道',
|
||||||
|
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||||
channels_save_ok: '已保存',
|
channels_save_ok: '已保存',
|
||||||
channels_save_error: '保存失败',
|
channels_save_error: '保存失败',
|
||||||
@@ -347,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
task_delete: '删除',
|
task_delete: '删除',
|
||||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||||
task_save_error: '保存失败',
|
task_save_error: '保存失败',
|
||||||
|
task_run_now: '立即执行',
|
||||||
|
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||||
|
task_run_started: '任务已开始执行',
|
||||||
|
task_run_error: '执行失败',
|
||||||
logs_title: '日志',
|
logs_title: '日志',
|
||||||
logs_desc: '实时日志输出 (run.log)',
|
logs_desc: '实时日志输出 (run.log)',
|
||||||
logs_live: '实时',
|
logs_live: '实时',
|
||||||
@@ -512,7 +521,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: 'Cancelled',
|
msg_cancelled: 'Cancelled',
|
||||||
msg_self_learned: 'Self-learned',
|
msg_self_learned: 'Self-learned',
|
||||||
msg_stop: 'Stop',
|
msg_stop: 'Stop',
|
||||||
|
thinking_in_progress: 'Thinking…',
|
||||||
|
thinking_done: 'Thought',
|
||||||
chat_clear_context: 'Clear context',
|
chat_clear_context: 'Clear context',
|
||||||
|
context_cleared: '— Context above has been cleared —',
|
||||||
chat_load_earlier: 'Load earlier messages',
|
chat_load_earlier: 'Load earlier messages',
|
||||||
chat_send: 'Send',
|
chat_send: 'Send',
|
||||||
chat_attach: 'Attach file',
|
chat_attach: 'Attach file',
|
||||||
@@ -671,6 +683,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: 'Connected',
|
channels_connected_section: 'Connected',
|
||||||
channels_available_section: 'Available',
|
channels_available_section: 'Available',
|
||||||
channels_empty_connected: 'No connected channels yet',
|
channels_empty_connected: 'No connected channels yet',
|
||||||
|
channels_empty: 'No channels connected',
|
||||||
|
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||||
channels_save_ok: 'Saved',
|
channels_save_ok: 'Saved',
|
||||||
channels_save_error: 'Failed to save',
|
channels_save_error: 'Failed to save',
|
||||||
@@ -730,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
task_delete: 'Delete',
|
task_delete: 'Delete',
|
||||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||||
task_save_error: 'Failed to save',
|
task_save_error: 'Failed to save',
|
||||||
|
task_run_now: 'Run now',
|
||||||
|
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||||
|
task_run_started: 'Task run started',
|
||||||
|
task_run_error: 'Failed to run task',
|
||||||
logs_title: 'Logs',
|
logs_title: 'Logs',
|
||||||
logs_desc: 'Real-time log output (run.log)',
|
logs_desc: 'Real-time log output (run.log)',
|
||||||
logs_live: 'Live',
|
logs_live: 'Live',
|
||||||
|
|||||||
@@ -27,21 +27,21 @@
|
|||||||
--danger-border: rgba(239, 68, 68, 0.3);
|
--danger-border: rgba(239, 68, 68, 0.3);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Light theme — layered neutral surfaces */
|
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||||
--bg-base: #fafafa; /* app background */
|
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||||
--bg-surface: #ffffff; /* panels, cards */
|
--bg-surface: #ffffff; /* panels, cards */
|
||||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||||
|
|
||||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||||
--text-secondary: #52525b; /* body, labels */
|
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||||
--text-tertiary: #71717a; /* hints, captions */
|
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||||
--text-disabled: #a1a1aa;
|
--text-disabled: #94a3b8; /* slate-400 */
|
||||||
|
|
||||||
--border-default: #e4e4e7;
|
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||||
--border-strong: #d4d4d8;
|
--border-strong: #d1d5db; /* gray-300 */
|
||||||
--border-subtle: #f0f0f1;
|
--border-subtle: #f3f4f6; /* gray-100 */
|
||||||
|
|
||||||
--overlay: rgba(0, 0, 0, 0.4);
|
--overlay: rgba(0, 0, 0, 0.4);
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Headset,
|
Headset,
|
||||||
Hash,
|
Hash,
|
||||||
AtSign,
|
AtSign,
|
||||||
|
RadioTower,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { t, localizedLabel } from '../i18n'
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{connected.length === 0 && !addOpen ? (
|
{connected.length === 0 && !addOpen ? (
|
||||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||||
|
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||||
|
<RadioTower size={26} className="text-info" />
|
||||||
|
</span>
|
||||||
|
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||||
|
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||||
|
{t('channels_empty_desc')}
|
||||||
|
</p>
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Btn variant="primary" onClick={openAdd}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Plus size={15} />
|
||||||
|
{t('channels_add')}
|
||||||
|
</span>
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
|||||||
import type { Attachment, ChatMessage } from '../types'
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
import { useChatStore } from '../store/chatStore'
|
import { useChatStore } from '../store/chatStore'
|
||||||
import { useSessionStore } from '../store/sessionStore'
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
import { useUIStore } from '../store/uiStore'
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
const clearContext = useChatStore((s) => s.clearContext)
|
||||||
|
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||||
|
|
||||||
const messages = session?.messages ?? []
|
const messages = session?.messages ?? []
|
||||||
const isStreaming = session?.isStreaming ?? false
|
const isStreaming = session?.isStreaming ?? false
|
||||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const id = newSession()
|
const id = newSession()
|
||||||
ensureSession(id)
|
ensureSession(id)
|
||||||
loadHistory(id, 1)
|
loadHistory(id, 1)
|
||||||
}, [newSession, ensureSession, loadHistory])
|
// Auto-expand the session list so the user sees the new/switched session.
|
||||||
|
setSessionsCollapsed(false)
|
||||||
|
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||||
|
|
||||||
const handleClearContext = useCallback(async () => {
|
const handleClearContext = useCallback(async () => {
|
||||||
try {
|
await clearContext(activeId)
|
||||||
await apiClient.clearContext(activeId)
|
scrollToBottom(true)
|
||||||
} catch {
|
}, [clearContext, activeId, scrollToBottom])
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [activeId])
|
|
||||||
|
|
||||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||||
|
{t('welcome_subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-3 max-w-3xl mx-auto">
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) =>
|
||||||
<MessageBubble
|
msg.kind === 'divider' ? (
|
||||||
key={msg.id}
|
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||||
message={msg}
|
<span
|
||||||
onRegenerate={handleRegenerate}
|
className="flex-1 h-px"
|
||||||
onEdit={handleEdit}
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
onDelete={handleDelete}
|
/>
|
||||||
onMediaLoad={handleMediaLoad}
|
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||||
/>
|
<span
|
||||||
))}
|
className="flex-1 h-px"
|
||||||
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onMediaLoad={handleMediaLoad}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||||
|
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||||
|
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||||
|
function stripDuplicateH1(content: string, title: string): string {
|
||||||
|
if (!content) return content
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||||
|
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||||
|
if (!m) return content
|
||||||
|
if (norm(m[1]) !== norm(title)) return content
|
||||||
|
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten the tree into category paths (for destination selectors).
|
// Flatten the tree into category paths (for destination selectors).
|
||||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||||
|
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||||
|
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||||
|
for (const f of list.root_files || []) {
|
||||||
|
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
for (const f of dir.files) {
|
||||||
|
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
for (const c of dir.children) {
|
||||||
|
const hit = walk(c, dirPath)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const d of list.tree || []) {
|
||||||
|
const hit = walk(d, '')
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||||
|
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||||
|
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||||
|
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||||
|
const clean = href.split('#')[0].split('?')[0]
|
||||||
|
if (!clean.endsWith('.md')) return null
|
||||||
|
if (clean.startsWith('knowledge/')) {
|
||||||
|
const path = clean.replace(/^knowledge\//, '')
|
||||||
|
return { path, title: findTitle(list, path) }
|
||||||
|
}
|
||||||
|
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||||
|
return { path: clean, title: findTitle(list, clean) }
|
||||||
|
}
|
||||||
|
// Relative/other path: fall back to matching by filename.
|
||||||
|
const filename = clean.split('/').pop() || clean
|
||||||
|
return findFileByName(list, filename)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a document's display title from its path, falling back to the stem.
|
// Resolve a document's display title from its path, falling back to the stem.
|
||||||
function findTitle(list: KnowledgeList, path: string): string {
|
function findTitle(list: KnowledgeList, path: string): string {
|
||||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
setContent('')
|
setContent('')
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.readKnowledge(path)
|
const res = await apiClient.readKnowledge(path)
|
||||||
setContent(res.content || '')
|
setContent(stripDuplicateH1(res.content || '', title))
|
||||||
} catch {
|
} catch {
|
||||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||||
|
// Falls back silently when the target can't be resolved in the current tree.
|
||||||
|
const openInternalLink = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
if (!data) return
|
||||||
|
const hit = resolveKnowledgeLink(data, href)
|
||||||
|
if (hit) void openDoc(hit.path, hit.title)
|
||||||
|
},
|
||||||
|
[data, openDoc]
|
||||||
|
)
|
||||||
|
|
||||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||||
// currently open doc (or open the first one on the initial load).
|
// currently open doc (or open the first one on the initial load).
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
<Loader2 size={16} className="animate-spin mr-2" />
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Markdown content={content} />
|
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Loader2, Clock, CalendarClock } from 'lucide-react'
|
import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
|
|||||||
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||||
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [runStatus, setRunStatus] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const buildSchedule = (): TaskSchedule => {
|
const buildSchedule = (): TaskSchedule => {
|
||||||
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runNow = async () => {
|
||||||
|
if (!window.confirm(t('task_run_confirm'))) return
|
||||||
|
setRunning(true)
|
||||||
|
setRunStatus('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await apiClient.runTask(task.id)
|
||||||
|
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
|
||||||
|
setRunStatus(t('task_run_started'))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t('task_run_error'))
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
|
|||||||
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||||
{t('task_delete')}
|
{t('task_delete')}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
|
||||||
|
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
|
||||||
|
{t('task_run_now')}
|
||||||
|
</Btn>
|
||||||
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
{t('task_cancel')}
|
{t('task_cancel')}
|
||||||
</Btn>
|
</Btn>
|
||||||
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
|
|||||||
)}
|
)}
|
||||||
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
||||||
|
|
||||||
|
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
|
||||||
{error && <p className="text-xs text-danger">{error}</p>}
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ChatState {
|
|||||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||||
|
|
||||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||||
|
clearContext: (sid: string) => Promise<boolean>
|
||||||
clearLocal: (sid: string) => void
|
clearLocal: (sid: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
cancel: async (sid) => {
|
cancel: async (sid) => {
|
||||||
const s = get().sessions[sid]
|
const s = get().sessions[sid]
|
||||||
if (!s?.requestId) return
|
if (!s?.requestId) return
|
||||||
|
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||||
|
// cancelled, free the input, and tear down the local SSE stream so no
|
||||||
|
// further deltas render after the user hit stop. The backend still gets
|
||||||
|
// the cancel request to abort the running agent task.
|
||||||
|
patchMessages(sid, (msgs) => {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') {
|
||||||
|
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...msgs]
|
||||||
|
})
|
||||||
|
patchSession(sid, { isStreaming: false, requestId: null })
|
||||||
|
const es = streams[sid]
|
||||||
|
if (es) {
|
||||||
|
es.close()
|
||||||
|
delete streams[sid]
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearContext: async (sid) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.clearContext(sid)
|
||||||
|
if (res.status !== 'success') return false
|
||||||
|
// Append a visual divider so the user sees the context was cleared
|
||||||
|
// (mirrors the web console's context-divider).
|
||||||
|
patchMessages(sid, (msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
id: uid('divider'),
|
||||||
|
role: 'system',
|
||||||
|
kind: 'divider',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearLocal: (sid) => {
|
clearLocal: (sid) => {
|
||||||
const es = streams[sid]
|
const es = streams[sid]
|
||||||
if (es) {
|
if (es) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UIState {
|
|||||||
/** Session list panel collapsed (hidden) vs expanded. */
|
/** Session list panel collapsed (hidden) vs expanded. */
|
||||||
sessionsCollapsed: boolean
|
sessionsCollapsed: boolean
|
||||||
toggleSessions: () => void
|
toggleSessions: () => void
|
||||||
|
setSessionsCollapsed: (v: boolean) => void
|
||||||
|
|
||||||
/** Currently active session id (Chat page). */
|
/** Currently active session id (Chat page). */
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||||
return { sessionsCollapsed: next }
|
return { sessionsCollapsed: next }
|
||||||
}),
|
}),
|
||||||
|
setSessionsCollapsed: (v) => {
|
||||||
|
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||||
|
set({ sessionsCollapsed: v })
|
||||||
|
},
|
||||||
|
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
|||||||
// Chat / messages / streaming
|
// Chat / messages / streaming
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export type Role = 'user' | 'assistant'
|
export type Role = 'user' | 'assistant' | 'system'
|
||||||
|
|
||||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||||
export interface MessageStep {
|
export interface MessageStep {
|
||||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
|||||||
/** Sequence numbers from backend (for delete/regenerate). */
|
/** Sequence numbers from backend (for delete/regenerate). */
|
||||||
userSeq?: number
|
userSeq?: number
|
||||||
botSeq?: number
|
botSeq?: number
|
||||||
/** Self-evolution bubble flag. */
|
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||||
kind?: 'evolution'
|
kind?: 'evolution' | 'divider'
|
||||||
extras?: Record<string, unknown>
|
extras?: Record<string, unknown>
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
isCancelled?: boolean
|
isCancelled?: boolean
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_id` | Feishu app App ID | - |
|
| `feishu_app_id` | Feishu app App ID | - |
|
||||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||||
|
| `feishu_detailed_card` | Use a detailed card (tool calls, thinking process, elapsed time) for streaming replies; off keeps the plain typewriter card | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -84,7 +85,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
|
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
|
||||||
|
|
||||||
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
3. (Optional) Under **Callbacks**, add **Card Action Trigger** (`card.action.trigger`) to enable `/tasks` scheduler commands; add the **Message Recalled** (`im.message.recalled_v1`) event to cancel a task by recalling its message.
|
||||||
|
|
||||||
|
4. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
@@ -97,7 +100,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| Text messages | ✅ send/receive |
|
| Text messages | ✅ send/receive |
|
||||||
| Image messages | ✅ send/receive |
|
| Image messages | ✅ send/receive |
|
||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
|
| Quoted replies | ✅ quoted text and rich-post context |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
|
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
|
||||||
|
| Detailed card | ✅ tool calls, thinking process and elapsed time (controlled by `feishu_detailed_card`, on by default) |
|
||||||
|
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
||||||
@@ -108,3 +115,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
After connection, search for the bot name in Feishu to start a chat.
|
After connection, search for the bot name in Feishu to start a chat.
|
||||||
|
|
||||||
To use in groups, add the bot to a group and @-mention it.
|
To use in groups, add the bot to a group and @-mention it.
|
||||||
|
|
||||||
|
Send `/tasks` in a private chat, or @-mention the bot with `/tasks` in a group, to manage tasks belonging to that chat.
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ On startup, the channel registers a command menu with BotFather. Typing `/` in T
|
|||||||
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
|
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
|
||||||
| `/config` | View current config |
|
| `/config` | View current config |
|
||||||
| `/cancel` | Cancel the running Agent task |
|
| `/cancel` | Cancel the running Agent task |
|
||||||
|
| `/steer` | Guide the running Agent task (`/steer <instruction>`) |
|
||||||
| `/logs` | View recent logs |
|
| `/logs` | View recent logs |
|
||||||
| `/version` | Show version |
|
| `/version` | Show version |
|
||||||
|
|
||||||
|
|||||||
51
docs/cli/backup.mdx
Normal file
51
docs/cli/backup.mdx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Backup and Restore
|
||||||
|
description: Export and restore CowAgent configuration and agent workspace data
|
||||||
|
---
|
||||||
|
|
||||||
|
CowAgent can create a portable local archive for migration or disaster recovery.
|
||||||
|
|
||||||
|
## Create a backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow backup
|
||||||
|
cow backup --output /safe/location/cow-backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive contains:
|
||||||
|
|
||||||
|
- `config.json`
|
||||||
|
- Agent persona and user files such as `AGENT.md`, `USER.md`, and `MEMORY.md`
|
||||||
|
- Daily memory, session history, knowledge, custom skills, and scheduled tasks in the configured workspace
|
||||||
|
- Legacy `user_datas.pkl`, when present
|
||||||
|
|
||||||
|
Transient `tmp/` data, caches, Git metadata, and symbolic links are skipped. The
|
||||||
|
archive is written with owner-only permissions where the operating system
|
||||||
|
supports them.
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
A backup may contain API keys, conversation history, and other personal data.
|
||||||
|
Store and transfer it as a secret. The ZIP file is not encrypted.
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
## Restore a backup
|
||||||
|
|
||||||
|
Stop CowAgent before restoring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow stop
|
||||||
|
cow restore /safe/location/cow-backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--workspace` to migrate workspace files to a different location:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow restore cow-backup.zip --workspace ~/cow-restored
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore validates the archive format and paths before writing anything. It
|
||||||
|
overwrites matching files but does not delete unrelated files already present
|
||||||
|
at the destination. When current CowAgent data exists, the command first
|
||||||
|
creates a `cow-pre-restore-*.zip` rollback archive beside the selected backup.
|
||||||
|
|
||||||
|
For unattended scripts, pass `--yes` to acknowledge overwrites.
|
||||||
@@ -33,6 +33,16 @@ Abort the agent task currently running in this session. When the agent is busy w
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
Redirect the Agent task currently running in this session without cancelling it. The instruction is injected at the next safe checkpoint; a tool that is already running may finish, while tools that have not started are skipped. If no task is active, `/steer` does not start or queue a new one. Available across all chat channels.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer focus on the failing tests first
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Web console, enter an instruction while a reply is running and click **Steer active task**. Sending an ordinary message still uses the session queue.
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
View or modify runtime configuration. Changes take effect immediately without restarting.
|
View or modify runtime configuration. Changes take effect immediately without restarting.
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
|||||||
memory Memory distillation (dream)
|
memory Memory distillation (dream)
|
||||||
knowledge View knowledge base stats and structure
|
knowledge View knowledge base stats and structure
|
||||||
|
|
||||||
|
Data portability:
|
||||||
|
backup Back up config and agent workspace
|
||||||
|
restore Restore a CowAgent backup
|
||||||
|
|
||||||
Others:
|
Others:
|
||||||
help Show this help message
|
help Show this help message
|
||||||
version Show version
|
version Show version
|
||||||
@@ -58,6 +62,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
|||||||
| `/help` | Show command help |
|
| `/help` | Show command help |
|
||||||
| `/status` | View service status and configuration |
|
| `/status` | View service status and configuration |
|
||||||
| `/cancel` | Abort the currently running agent task |
|
| `/cancel` | Abort the currently running agent task |
|
||||||
|
| `/steer <instruction>` | Guide the currently running agent task without queueing a new turn |
|
||||||
| `/config` | View or modify runtime configuration |
|
| `/config` | View or modify runtime configuration |
|
||||||
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
|
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
|
||||||
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |
|
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |
|
||||||
@@ -90,6 +95,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
|||||||
| start / stop / restart | ✓ | ✗ |
|
| start / stop / restart | ✓ | ✗ |
|
||||||
| update | ✓ | ✗ |
|
| update | ✓ | ✗ |
|
||||||
| install-browser | ✓ | ✗ |
|
| install-browser | ✓ | ✗ |
|
||||||
|
| backup / restore | ✓ | ✗ |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
|
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
|
||||||
|
|||||||
@@ -246,6 +246,7 @@
|
|||||||
"cli/process",
|
"cli/process",
|
||||||
"cli/skill",
|
"cli/skill",
|
||||||
"cli/memory-knowledge",
|
"cli/memory-knowledge",
|
||||||
|
"cli/backup",
|
||||||
"cli/general"
|
"cli/general"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -258,6 +259,7 @@
|
|||||||
"group": "Release Notes",
|
"group": "Release Notes",
|
||||||
"pages": [
|
"pages": [
|
||||||
"releases/overview",
|
"releases/overview",
|
||||||
|
"releases/v2.1.4",
|
||||||
"releases/v2.1.3",
|
"releases/v2.1.3",
|
||||||
"releases/v2.1.2",
|
"releases/v2.1.2",
|
||||||
"releases/v2.1.1",
|
"releases/v2.1.1",
|
||||||
@@ -473,6 +475,7 @@
|
|||||||
"zh/cli/process",
|
"zh/cli/process",
|
||||||
"zh/cli/skill",
|
"zh/cli/skill",
|
||||||
"zh/cli/memory-knowledge",
|
"zh/cli/memory-knowledge",
|
||||||
|
"zh/cli/backup",
|
||||||
"zh/cli/general"
|
"zh/cli/general"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -485,6 +488,7 @@
|
|||||||
"group": "发布记录",
|
"group": "发布记录",
|
||||||
"pages": [
|
"pages": [
|
||||||
"zh/releases/overview",
|
"zh/releases/overview",
|
||||||
|
"zh/releases/v2.1.4",
|
||||||
"zh/releases/v2.1.3",
|
"zh/releases/v2.1.3",
|
||||||
"zh/releases/v2.1.2",
|
"zh/releases/v2.1.2",
|
||||||
"zh/releases/v2.1.1",
|
"zh/releases/v2.1.1",
|
||||||
@@ -712,6 +716,7 @@
|
|||||||
"group": "リリースノート",
|
"group": "リリースノート",
|
||||||
"pages": [
|
"pages": [
|
||||||
"ja/releases/overview",
|
"ja/releases/overview",
|
||||||
|
"ja/releases/v2.1.4",
|
||||||
"ja/releases/v2.1.3",
|
"ja/releases/v2.1.3",
|
||||||
"ja/releases/v2.1.2",
|
"ja/releases/v2.1.2",
|
||||||
"ja/releases/v2.1.1",
|
"ja/releases/v2.1.1",
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -202,6 +202,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
## 🏷 更新履歴
|
## 🏷 更新履歴
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — デスクトップの体験改善、MCP の OAuth 認可対応、Feishu チャネルの機能向上、定期タスクとデータバックアップ、新モデル追加。
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)(macOS / Windows)、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)(macOS / Windows)、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_id` | Feishu アプリの App ID | - |
|
| `feishu_app_id` | Feishu アプリの App ID | - |
|
||||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
|
| `feishu_detailed_card` | ストリーミング応答を詳細カード(ツール呼び出し、思考過程、経過時間)で表示。無効時は通常のタイプライターカード | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -81,7 +82,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
||||||
|
|
||||||
3. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
3. (任意)**コールバック** で **カードアクショントリガー**(`card.action.trigger`)を追加すると `/tasks` のスケジューラー管理コマンドを使用できます。**メッセージ撤回**(`im.message.recalled_v1`)イベントを追加すると、メッセージの撤回でタスクをキャンセルできます。
|
||||||
|
|
||||||
|
4. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
@@ -94,7 +97,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| テキストメッセージ | ✅ 送受信 |
|
| テキストメッセージ | ✅ 送受信 |
|
||||||
| 画像メッセージ | ✅ 送受信 |
|
| 画像メッセージ | ✅ 送受信 |
|
||||||
| 音声メッセージ | ✅ 送受信 |
|
| 音声メッセージ | ✅ 送受信 |
|
||||||
|
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
|
||||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
|
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
||||||
|
| 詳細カード | ✅ ツール呼び出し、思考過程、経過時間(`feishu_detailed_card` で制御、デフォルト有効) |
|
||||||
|
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||||
@@ -105,3 +112,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
||||||
|
|
||||||
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
||||||
|
|
||||||
|
個人チャットでは `/tasks`、グループでは Bot に @メンションして `/tasks` を送ると、そのチャットのタスクを管理できます。
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ description: Telegram Bot API 経由で CowAgent を接続
|
|||||||
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) |
|
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off`) |
|
||||||
| `/config` | 現在の設定を表示 |
|
| `/config` | 現在の設定を表示 |
|
||||||
| `/cancel` | 実行中の Agent タスクを中断 |
|
| `/cancel` | 実行中の Agent タスクを中断 |
|
||||||
|
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>`) |
|
||||||
| `/logs` | 最近のログを表示 |
|
| `/logs` | 最近のログを表示 |
|
||||||
| `/version` | バージョンを表示 |
|
| `/version` | バージョンを表示 |
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer 失敗しているテストを先に確認して
|
||||||
|
```
|
||||||
|
|
||||||
|
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
|
|||||||
| `/help` | コマンドヘルプを表示 |
|
| `/help` | コマンドヘルプを表示 |
|
||||||
| `/status` | サービスの状態と設定を表示 |
|
| `/status` | サービスの状態と設定を表示 |
|
||||||
| `/cancel` | 実行中の Agent タスクを中止 |
|
| `/cancel` | 実行中の Agent タスクを中止 |
|
||||||
|
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
|
||||||
| `/config` | 実行時設定の表示・変更 |
|
| `/config` | 実行時設定の表示・変更 |
|
||||||
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
||||||
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、claude-fable-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、claude-fable-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k3、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -26,7 +26,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k3` と `kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
||||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
| `model` | `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||||
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
78
docs/ja/releases/v2.1.4.mdx
Normal file
78
docs/ja/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.4
|
||||||
|
description: "CowAgent 2.1.4:デスクトップの体験改善、MCP の OAuth 認可対応、Feishu チャネルの機能向上に加え、定期タスク・データバックアップ・新モデルなど多数の更新"
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
|
## 🖥 デスクトップクライアント
|
||||||
|
|
||||||
|
前バージョンでデスクトップクライアントを正式リリースしたのに続き、今回はブラウザ機能、システム互換性、使い勝手をさらに向上させました:
|
||||||
|
|
||||||
|
- **ブラウザツール対応**:クライアントにブラウザ機能を内蔵し、システムにインストール済みの Chrome / Edge を優先的に再利用します。ブラウザの起動とアクセス性能も最適化しました。
|
||||||
|
- **UI のブラッシュアップ**:チャット画面、メッセージバブル、ツール呼び出しステップ、チャネルページの見た目と操作性を改善しました。
|
||||||
|
- **Windows コード署名**:Windows インストーラーにコード署名を追加し、インストール時のセキュリティ警告を軽減しました。
|
||||||
|
- **Windows 7/8 対応**:Windows 7/8 などの旧システム向けクライアントに対応し、より多くの環境をカバーします。
|
||||||
|
- **ナレッジベースのリンク修正**:デスクトップ版ナレッジベースのドキュメント内リンクが正しく遷移しない問題を修正しました。
|
||||||
|
- **パスワードログイン**:デスクトップクライアントでログインパスワードの設定に対応し、パスワード設定後にウィンドウが読み込めない問題を修正しました。
|
||||||
|
|
||||||
|
ダウンロード:[CowAgent デスクトップ](https://cowagent.ai/download/)
|
||||||
|
|
||||||
|
ドキュメント:[デスクトップクライアント](https://docs.cowagent.ai/guide/desktop)
|
||||||
|
|
||||||
|
## 🔌 MCP リモートサービスの OAuth 認可
|
||||||
|
|
||||||
|
リモート MCP サービスが **OAuth 認可** に対応しました。ログイン認可が必要なサードパーティ MCP サービスに接続する際、標準の OAuth フローで認証を完了できるため、トークンを手動で設定・管理する必要がなくなります。
|
||||||
|
|
||||||
|
ドキュメント:[MCP ツール](https://docs.cowagent.ai/tools/mcp)
|
||||||
|
|
||||||
|
## ⏰ 定期タスク
|
||||||
|
|
||||||
|
- **サイレントモード**:バックグラウンドで静かに実行され、メッセージを能動的に送信しない定期タスクを作成できます。データ整理や定期アーカイブなど、通知が不要なシーンに最適です。(#2954)
|
||||||
|
- **手動実行**:次回のスケジュールを待たずに、コンソール画面から既存タスクを手動で即時実行できます。(#2958)
|
||||||
|
- **編集時の設定保持**:Web コンソールでタスクを編集する際、モードタイプなどの隠しフィールドが失われることがある問題を修正しました。(#2959)
|
||||||
|
- **クロスチャネルのタスクコマンド**:`/tasks` 管理コマンドを追加し、複数チャネルに対応しました。(#2965)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
ドキュメント:[定期タスク](https://docs.cowagent.ai/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 Feishu チャネルの機能向上
|
||||||
|
|
||||||
|
Feishu チャネルにメッセージ表示と操作性の強化を一連で追加し、使い勝手を向上させました。
|
||||||
|
|
||||||
|
- **ストリーミングカードの改善**:ストリーミングカードに折りたたみパネルを追加し、思考過程、ツール呼び出し、実行時間などを表示します。(#2963)
|
||||||
|
- **Markdown 形式**:非ストリーミング応答と定期配信について、Markdown 構文を含む場合はカードとしてレンダリングし、より見やすく表示します。(#2962)
|
||||||
|
- **定期タスクカード**:`/tasks` コマンドをカード形式で表示し、カード上でタスクの有効化・無効化ができます。(#2961)
|
||||||
|
- **メッセージ引用への対応**:ユーザーがメッセージを引用した際、引用元の内容を自動的にコンテキストへ追加して Agent に送信します。(#2966)
|
||||||
|
- **リモート画像のレンダリング**:Feishu カード内でリモート画像リンクをレンダリングできるようになりました。(#2967)
|
||||||
|
- **メッセージ取り消しでタスクをキャンセル**:Feishu メッセージを取り消すと、対応する実行中またはキュー中のタスクを自動的にキャンセルします。(#2978)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
ドキュメント:[Feishu](https://docs.cowagent.ai/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 データのバックアップと復元
|
||||||
|
|
||||||
|
`cow backup` と `cow restore` コマンドを追加しました。設定、ナレッジベース、メモリなどのローカルデータをワンコマンドでエクスポート・復元でき、移行やバックアップが容易になります。Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
ドキュメント:[データバックアップ](https://docs.cowagent.ai/cli/backup)
|
||||||
|
|
||||||
|
## 🤖 新モデル
|
||||||
|
|
||||||
|
- **kimi-k3** に対応
|
||||||
|
- **gpt-5.6-luna**、**gpt-5.6-terra**、**gpt-5.6-sol** に対応
|
||||||
|
|
||||||
|
ドキュメント:[モデル一覧](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🛠 改善と修正
|
||||||
|
|
||||||
|
- **アクティブタスクのステアリング**:`/steer` コマンドを追加し、タスクの実行中に新しい指示を挿入して、実行中のタスクをその場で誘導・軌道修正できます。Thanks @AaronZ345 (#2977)
|
||||||
|
- **ファイル編集**:ファイル編集時にあいまい一致が誤った位置を特定することがある問題を修正しました。Thanks @weijun-xia (#2945)
|
||||||
|
|
||||||
|
## 📦 アップグレード方法
|
||||||
|
|
||||||
|
- **ソースデプロイ**:`cow update` でワンクリックアップグレード、または最新コードを取得して再起動してください。詳しくは [アップグレードガイド](https://docs.cowagent.ai/guide/upgrade) を参照してください。
|
||||||
|
- **デスクトップクライアント**:クライアント内で更新を確認しワンクリックで更新するか、[ダウンロードページ](https://cowagent.ai/download/) から最新版を入手してください。
|
||||||
|
|
||||||
|
**リリース日**:2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
@@ -19,7 +19,7 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
|||||||
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision.
|
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k3` and `kimi-k2.x` series natively support vision.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||||
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
| `model` | Can be `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||||
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
||||||
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
78
docs/releases/v2.1.4.mdx
Normal file
78
docs/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.4
|
||||||
|
description: "CowAgent 2.1.4: Desktop experience improvements, MCP OAuth authorization, Feishu channel enhancements, plus scheduler, data backup, and new models"
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
|
## 🖥 Desktop Client
|
||||||
|
|
||||||
|
Following the desktop client launched in the previous release, this version further improves browser capabilities, system compatibility, and overall experience:
|
||||||
|
|
||||||
|
- **Browser tool support**: the client bundles browser capabilities and prefers reusing the system's installed Chrome / Edge, with optimized browser startup and access performance.
|
||||||
|
- **UI polish**: refined visuals and interactions for the chat view, message bubbles, tool-call steps, and channel pages.
|
||||||
|
- **Windows code signing**: Windows installers are now code-signed, reducing security warnings during installation.
|
||||||
|
- **Windows 7/8 support**: added client support for legacy systems such as Windows 7/8, covering more environments.
|
||||||
|
- **Knowledge base link fix**: fixed in-document links in the desktop knowledge base that failed to navigate.
|
||||||
|
- **Password login**: the desktop client supports setting a login password, and fixes an issue where the window failed to load after a password was set.
|
||||||
|
|
||||||
|
Download: [CowAgent Desktop](https://cowagent.ai/download/)
|
||||||
|
|
||||||
|
Docs: [Desktop Client](https://docs.cowagent.ai/guide/desktop)
|
||||||
|
|
||||||
|
## 🔌 MCP Remote Server OAuth Authorization
|
||||||
|
|
||||||
|
Remote MCP servers now support **OAuth authorization**. When connecting to third-party MCP servers that require login, authentication can be completed via the standard OAuth flow — no more manually configuring and maintaining tokens.
|
||||||
|
|
||||||
|
Docs: [MCP Tools](https://docs.cowagent.ai/tools/mcp)
|
||||||
|
|
||||||
|
## ⏰ Scheduled Tasks
|
||||||
|
|
||||||
|
- **Silent mode**: create silently-running scheduled tasks that execute in the background without pushing messages — ideal for undisturbed scenarios like data organization or periodic archiving. (#2954)
|
||||||
|
- **Manual run**: manually trigger an existing task to run immediately from the console, without waiting for the next schedule. (#2958)
|
||||||
|
- **Preserve config on edit**: fixed an issue where editing a task in the Web console could drop hidden fields such as the mode type. (#2959)
|
||||||
|
- **Cross-channel task command**: added the `/tasks` management command, compatible across channels. (#2965)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
Docs: [Scheduled Tasks](https://docs.cowagent.ai/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 Feishu Channel Improvements
|
||||||
|
|
||||||
|
The Feishu channel gains a series of message-display and interaction enhancements for a better experience.
|
||||||
|
|
||||||
|
- **Streaming card polish**: streaming cards add collapsible panels showing the thinking process, tool calls, and execution time. (#2963)
|
||||||
|
- **Markdown formatting**: for non-streaming replies and scheduled pushes, messages containing Markdown are rendered as cards for clearer display. (#2962)
|
||||||
|
- **Scheduler cards**: the `/tasks` command is presented as cards, with the ability to enable or disable tasks right from the card. (#2961)
|
||||||
|
- **Quoted message context**: when a user quotes a message, the quoted content is automatically added to the context sent to the Agent. (#2966)
|
||||||
|
- **Remote image rendering**: remote image links can now be rendered inside Feishu cards. (#2967)
|
||||||
|
- **Cancel on message recall**: recalling a Feishu message automatically cancels its corresponding running or queued task. (#2978)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
Docs: [Feishu](https://docs.cowagent.ai/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 Data Backup & Restore
|
||||||
|
|
||||||
|
Added the `cow backup` and `cow restore` commands to export and restore local data — configuration, knowledge base, memory, and more — with one command, making migration and backup easy. Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
Docs: [Data Backup](https://docs.cowagent.ai/cli/backup)
|
||||||
|
|
||||||
|
## 🤖 New Models
|
||||||
|
|
||||||
|
- Added support for **kimi-k3**
|
||||||
|
- Added support for **gpt-5.6-luna**, **gpt-5.6-terra**, and **gpt-5.6-sol**
|
||||||
|
|
||||||
|
Docs: [Models](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
|
- **Active-task steering**: added the `/steer` command to inject new instructions during task execution, guiding or redirecting the running task on the fly. Thanks @AaronZ345 (#2977)
|
||||||
|
- **File editing**: fixed an issue where fuzzy matching could locate the wrong position when editing files. Thanks @weijun-xia (#2945)
|
||||||
|
|
||||||
|
## 📦 How to Upgrade
|
||||||
|
|
||||||
|
- **Source deployment**: run `cow update` for a one-click upgrade, or pull the latest code and restart. See the [upgrade guide](https://docs.cowagent.ai/guide/upgrade).
|
||||||
|
- **Desktop client**: check for updates and update with one click inside the client, or get the latest version from the [download page](https://cowagent.ai/download/).
|
||||||
|
|
||||||
|
**Release date**: 2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
@@ -35,6 +35,17 @@ Create and manage scheduled tasks with natural language:
|
|||||||
- "Remind me about the meeting tomorrow at 3 PM"
|
- "Remind me about the meeting tomorrow at 3 PM"
|
||||||
- "Show all scheduled tasks"
|
- "Show all scheduled tasks"
|
||||||
|
|
||||||
|
## Test-run an existing task
|
||||||
|
|
||||||
|
Open the task in the authenticated Web console or Desktop app and click **Run
|
||||||
|
now**. After you confirm the delivery, CowAgent queues one immediate execution
|
||||||
|
to the task's configured channel and receiver. This user-initiated action is not
|
||||||
|
exposed to the agent's scheduler tool.
|
||||||
|
|
||||||
|
Manual execution works for disabled tasks and does not enable, delete, or
|
||||||
|
reschedule the task. Its original `next_run_at` remains unchanged. A task cannot
|
||||||
|
be run manually while the same task is already executing on schedule.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -203,6 +203,8 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
## 🏷 更新日誌
|
## 🏷 更新日誌
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — 桌面客戶端體驗最佳化、MCP 支援 OAuth 授權、飛書通道能力增強、定時任務與資料備份、新模型接入
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布(macOS / Windows)、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布(macOS / Windows)、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級(定時任務管理、知識庫分類、多模型自定義廠商)、自主進化最佳化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級(定時任務管理、知識庫分類、多模型自定義廠商)、自主進化最佳化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -203,6 +203,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏷 更新日志
|
## 🏷 更新日志
|
||||||
|
|
||||||
|
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — 桌面客户端体验优化、MCP 支持 OAuth 授权、飞书通道能力增强、定时任务优化与数据备份、新模型接入
|
||||||
|
|
||||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_id` | 飞书应用 App ID | - |
|
| `feishu_app_id` | 飞书应用 App ID | - |
|
||||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
|
| `feishu_detailed_card` | 流式回复是否使用详细卡片(工具调用、思考过程、耗时展示);关闭时用普通打字机卡片 | `true` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -85,7 +86,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
||||||
|
|
||||||
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
3. (可选)在 **回调** 中添加 **卡片回传交互**(`card.action.trigger`),以启用 `/tasks` 定时任务管理命令;添加 **撤回消息**(`im.message.recalled_v1`)事件,以支持撤回消息取消任务功能。
|
||||||
|
|
||||||
|
4. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
@@ -98,7 +101,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 文本消息 | ✅ 收发 |
|
| 文本消息 | ✅ 收发 |
|
||||||
| 图片消息 | ✅ 收发 |
|
| 图片消息 | ✅ 收发 |
|
||||||
| 语音消息 | ✅ 收发 |
|
| 语音消息 | ✅ 收发 |
|
||||||
|
| 引用回复 | ✅ 引用文本与富文本上下文 |
|
||||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
|
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
||||||
|
| 详细卡片 | ✅ 工具调用、思考过程与耗时展示(`feishu_detailed_card` 控制,默认开启) |
|
||||||
|
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||||
@@ -109,3 +116,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
||||||
|
|
||||||
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
||||||
|
|
||||||
|
私聊发送 `/tasks`,或在群聊中 @机器人并发送 `/tasks`,即可管理属于当前会话的定时任务。
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ description: 将 CowAgent 接入 Telegram Bot
|
|||||||
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) |
|
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) |
|
||||||
| `/config` | 查看当前配置 |
|
| `/config` | 查看当前配置 |
|
||||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||||
|
| `/steer` | 引导当前正在运行的 Agent 任务(`/steer <指令>`) |
|
||||||
| `/logs` | 查看最近日志 |
|
| `/logs` | 查看最近日志 |
|
||||||
| `/version` | 查看版本 |
|
| `/version` | 查看版本 |
|
||||||
|
|
||||||
|
|||||||
45
docs/zh/cli/backup.mdx
Normal file
45
docs/zh/cli/backup.mdx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: 备份与恢复
|
||||||
|
description: 导出和恢复 CowAgent 配置及 Agent 工作区数据
|
||||||
|
---
|
||||||
|
|
||||||
|
CowAgent 可以生成便携的本地归档,用于设备迁移或灾难恢复。
|
||||||
|
|
||||||
|
## 创建备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow backup
|
||||||
|
cow backup --output /safe/location/cow-backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
归档包含:
|
||||||
|
|
||||||
|
- `config.json`
|
||||||
|
- `AGENT.md`、`USER.md`、`MEMORY.md` 等 Agent 人格和用户文件
|
||||||
|
- 工作区内的每日记忆、会话历史、知识库、自定义技能和定时任务
|
||||||
|
- 旧版 `user_datas.pkl`(如存在)
|
||||||
|
|
||||||
|
临时 `tmp/` 数据、缓存、Git 元数据和符号链接不会写入归档。在操作系统支持的情况下,归档文件权限会限制为仅当前用户可读写。
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
备份中可能含有 API 密钥、对话历史及其他个人数据,应按密钥文件保存和传输。ZIP 文件本身没有加密。
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
## 恢复备份
|
||||||
|
|
||||||
|
恢复前先停止 CowAgent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow stop
|
||||||
|
cow restore /safe/location/cow-backup.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移到不同工作区时,可以显式指定路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow restore cow-backup.zip --workspace ~/cow-restored
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复命令会在写入前校验归档格式和所有路径。它会覆盖同名文件,但不会删除目标目录中无关的现有文件。如果检测到当前 CowAgent 数据,会先在所选归档旁创建 `cow-pre-restore-*.zip` 回滚备份。
|
||||||
|
|
||||||
|
无人值守脚本可传入 `--yes` 确认覆盖。
|
||||||
@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入:已经开始的工具可以执行完,尚未开始的工具会跳过。若当前没有运行中的任务,`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer 先处理失败的测试
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
|||||||
memory Memory distillation (dream)
|
memory Memory distillation (dream)
|
||||||
knowledge View knowledge base stats and structure
|
knowledge View knowledge base stats and structure
|
||||||
|
|
||||||
|
Data portability:
|
||||||
|
backup Back up config and agent workspace
|
||||||
|
restore Restore a CowAgent backup
|
||||||
|
|
||||||
Others:
|
Others:
|
||||||
help Show this help message
|
help Show this help message
|
||||||
version Show version
|
version Show version
|
||||||
@@ -58,6 +62,7 @@ Others:
|
|||||||
| `/help` | 显示命令帮助 |
|
| `/help` | 显示命令帮助 |
|
||||||
| `/status` | 查看服务状态和配置 |
|
| `/status` | 查看服务状态和配置 |
|
||||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||||
|
| `/steer <指令>` | 引导当前正在运行的 Agent 任务,不新建排队回合 |
|
||||||
| `/config` | 查看或修改运行时配置 |
|
| `/config` | 查看或修改运行时配置 |
|
||||||
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
|
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
|
||||||
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) |
|
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30) |
|
||||||
@@ -92,6 +97,7 @@ Others:
|
|||||||
| start / stop / restart | ✓ | ✗ |
|
| start / stop / restart | ✓ | ✗ |
|
||||||
| update | ✓ | ✗ |
|
| update | ✓ | ✗ |
|
||||||
| install-browser | ✓ | ✗ |
|
| install-browser | ✓ | ✗ |
|
||||||
|
| backup / restore | ✓ | ✗ |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
|
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。
|
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k3` 与 `kimi-k2.x` 系列原生支持视觉。
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
||||||
@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 可填 `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
|
| `model` | 可填 `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
|
||||||
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
||||||
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
78
docs/zh/releases/v2.1.4.mdx
Normal file
78
docs/zh/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.4
|
||||||
|
description: CowAgent 2.1.4:桌面客户端体验优化,MCP 支持 OAuth 授权,飞书通道能力增强,定时任务与数据备份等多项更新
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.4) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.4)
|
||||||
|
|
||||||
|
## 🖥 桌面客户端
|
||||||
|
|
||||||
|
在上个版本正式推出桌面客户端后,本次围绕浏览器能力、系统兼容与使用体验做了进一步提升:
|
||||||
|
|
||||||
|
- **支持浏览器工具**:客户端内置浏览器能力并优先复用系统已安装的 Chrome / Edge,并优化浏览器启动及访问性能。
|
||||||
|
- **界面样式优化**:优化对话界面、消息气泡、工具调用步骤与通道页面的视觉与交互细节。
|
||||||
|
- **Windows 代码签名**:Windows 安装包新增代码签名,降低安装时的安全告警。
|
||||||
|
- **支持 Win7/Win8 系统**:新增对 Windows 7/8 等旧版系统的客户端支持,覆盖更多使用环境。
|
||||||
|
- **知识库文档跳转修复**:修复桌面端知识库文档内链接无法正常跳转的问题。
|
||||||
|
- **密码登录**:桌面客户端支持设置登录密码,并修复设置密码后无法加载窗口的问题。
|
||||||
|
|
||||||
|
下载地址:[CowAgent客户端](https://cowagent.ai/zh/download/)
|
||||||
|
|
||||||
|
相关文档:[桌面客户端](https://docs.cowagent.ai/zh/guide/desktop)
|
||||||
|
|
||||||
|
## 🔌 MCP 远程服务 OAuth 授权
|
||||||
|
|
||||||
|
远程 MCP 服务新增 **OAuth 授权** 支持,接入需要登录授权的第三方 MCP 服务时,可通过标准 OAuth 流程完成认证,无需手动配置和维护令牌。
|
||||||
|
|
||||||
|
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
|
||||||
|
|
||||||
|
## ⏰ 定时任务
|
||||||
|
|
||||||
|
- **静默模式**:支持创建静默执行的定时任务,任务后台运行、无主动消息推送,适合数据整理、定期归档等无需打扰的场景。(#2954)
|
||||||
|
- **手动执行**:支持在控制台界面手动触发已有任务立即执行,无需等待下次调度。(#2958)
|
||||||
|
- **编辑保留配置**:修复在 Web 端编辑任务时可能丢失模式类型等隐藏配置的问题。(#2959)
|
||||||
|
- **跨通道任务命令**:新增 `/tasks` 定时任务管理命令,多通道兼容。(#2965)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
相关文档:[定时任务](https://docs.cowagent.ai/zh/tools/scheduler)
|
||||||
|
|
||||||
|
## 💬 飞书通道增强
|
||||||
|
|
||||||
|
飞书通道新增一系列消息展示与交互增强,提升使用体验。
|
||||||
|
|
||||||
|
- **流式卡片优化**:流式卡片增加折叠面板,展示思考过程、工具调用、执行耗时等信息。(#2963)
|
||||||
|
- **Markdown 格式**:对于非流式回复与定时推送,在含有 Markdown 语法时以卡片渲染,展示更清晰。(#2962)
|
||||||
|
- **定时任务卡片**:定时任务 `/tasks` 命令以卡片形式呈现,并支持在卡片中启用和关闭任务。(#2961)
|
||||||
|
- **支持消息引用**:用户引用消息时,自动将被引用消息加入上下文发送给 Agent。(#2966)
|
||||||
|
- **远程图片渲染**:支持在飞书卡片中渲染远程图片链接。(#2967)
|
||||||
|
- **撤回消息取消任务**:撤回某条飞书消息时,自动取消其对应正在执行或排队中的任务。(#2978)
|
||||||
|
|
||||||
|
Thanks @AaronZ345
|
||||||
|
|
||||||
|
相关文档:[飞书](https://docs.cowagent.ai/zh/channels/feishu)
|
||||||
|
|
||||||
|
## 💾 数据备份与恢复
|
||||||
|
|
||||||
|
新增 `cow backup` 和 `cow restore` 命令,支持一键导出与恢复配置、知识库、记忆等本地数据,便于迁移与备份。Thanks @AaronZ345 (#2957)
|
||||||
|
|
||||||
|
相关文档:[数据备份](https://docs.cowagent.ai/zh/cli/backup)
|
||||||
|
|
||||||
|
## 🤖 模型新增
|
||||||
|
|
||||||
|
- 新增支持 **kimi-k3**
|
||||||
|
- 新增支持 **gpt-5.6-luna**、**gpt-5.6-terra**、**gpt-5.6-sol**
|
||||||
|
|
||||||
|
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
|
||||||
|
|
||||||
|
## 🛠 体验优化与修复
|
||||||
|
|
||||||
|
- **任务实时引导**:新增 `/steer` 命令,可在任务执行过程中插入新指令来引导或纠偏当前正在执行的任务。Thanks @AaronZ345 (#2977)
|
||||||
|
- **文件编辑**:修复编辑文件时模糊匹配可能定位到错误位置的问题。Thanks @weijun-xia (#2945)
|
||||||
|
|
||||||
|
## 📦 升级方式
|
||||||
|
|
||||||
|
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
|
||||||
|
- **桌面客户端**:可在客户端内自动检查并一键更新,或前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
|
||||||
|
|
||||||
|
**发布日期**:2026.07.20 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.3...2.1.4)
|
||||||
@@ -35,6 +35,12 @@ description: 创建和管理定时任务
|
|||||||
- "明天下午 3 点提醒我开会"
|
- "明天下午 3 点提醒我开会"
|
||||||
- "查看所有定时任务"
|
- "查看所有定时任务"
|
||||||
|
|
||||||
|
## 测试执行已有任务
|
||||||
|
|
||||||
|
在经过身份认证的 Web 控制台或 Desktop 应用中打开任务,点击 **立即执行**。确认发送后,CowAgent 会立即按任务已配置的通道和接收者执行一次。这个入口只允许用户手动触发,不会暴露给 Agent 的 scheduler tool。
|
||||||
|
|
||||||
|
禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行。
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class MoonshotBot(Bot):
|
|||||||
m = model_name.lower()
|
m = model_name.lower()
|
||||||
if cls._is_builtin_reasoning_model(m):
|
if cls._is_builtin_reasoning_model(m):
|
||||||
return False
|
return False
|
||||||
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
return m.startswith("kimi-k3") or m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
||||||
|
|
||||||
def _build_headers(self) -> dict:
|
def _build_headers(self) -> dict:
|
||||||
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ KNOWN_COMMANDS = {
|
|||||||
"help", "version", "status", "logs",
|
"help", "version", "status", "logs",
|
||||||
"start", "stop", "restart",
|
"start", "stop", "restart",
|
||||||
"cancel",
|
"cancel",
|
||||||
"skill", "context", "config",
|
"skill", "context", "config", "tasks",
|
||||||
"knowledge", "memory",
|
"knowledge", "memory",
|
||||||
"install-browser",
|
"install-browser",
|
||||||
}
|
}
|
||||||
@@ -357,6 +357,7 @@ class CowCliPlugin(Plugin):
|
|||||||
"/logs [N]: Show the last N log lines (default 20)",
|
"/logs [N]: Show the last N log lines (default 20)",
|
||||||
"/context: Show current conversation context",
|
"/context: Show current conversation context",
|
||||||
"/context clear: Clear current conversation context",
|
"/context clear: Clear current conversation context",
|
||||||
|
"/tasks: List scheduled tasks for this chat",
|
||||||
"/skill list: List installed skills",
|
"/skill list: List installed skills",
|
||||||
"/skill list --remote: Browse Skill Hub",
|
"/skill list --remote: Browse Skill Hub",
|
||||||
"/skill search <keyword>: Search skills",
|
"/skill search <keyword>: Search skills",
|
||||||
@@ -385,6 +386,7 @@ class CowCliPlugin(Plugin):
|
|||||||
"/logs [N]: 查看最近N条日志 (默认20)",
|
"/logs [N]: 查看最近N条日志 (默认20)",
|
||||||
"/context: 查看当前对话上下文信息",
|
"/context: 查看当前对话上下文信息",
|
||||||
"/context clear: 清除当前对话上下文",
|
"/context clear: 清除当前对话上下文",
|
||||||
|
"/tasks: 查看当前会话的定时任务",
|
||||||
"/skill list: 查看已安装的技能",
|
"/skill list: 查看已安装的技能",
|
||||||
"/skill list --remote: 浏览技能广场",
|
"/skill list --remote: 浏览技能广场",
|
||||||
"/skill search <关键词>: 搜索技能",
|
"/skill search <关键词>: 搜索技能",
|
||||||
@@ -407,6 +409,85 @@ class CowCliPlugin(Plugin):
|
|||||||
def _cmd_version(self, args: str, e_context, **_) -> str:
|
def _cmd_version(self, args: str, e_context, **_) -> str:
|
||||||
return f"CowAgent v{__version__}"
|
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.
|
# cancel — abort the in-flight agent run for the current session.
|
||||||
# Fallback handler; in practice chat_channel/web_channel intercept
|
# Fallback handler; in practice chat_channel/web_channel intercept
|
||||||
|
|||||||
4
run.sh
4
run.sh
@@ -604,7 +604,7 @@ select_model() {
|
|||||||
"GLM (glm-5.2, etc.)" \
|
"GLM (glm-5.2, etc.)" \
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
||||||
"Doubao (doubao-seed-2.1, etc.)" \
|
"Doubao (doubao-seed-2.1, etc.)" \
|
||||||
"Kimi (kimi-k2.7-code, etc.)" \
|
"Kimi (kimi-k3, etc.)" \
|
||||||
"MiMo (mimo-v2.5-pro, etc.)" \
|
"MiMo (mimo-v2.5-pro, etc.)" \
|
||||||
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
||||||
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
||||||
@@ -636,7 +636,7 @@ configure_model() {
|
|||||||
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
||||||
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
||||||
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-1-pro-260628" "ARK_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" ;;
|
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
|
||||||
11)
|
11)
|
||||||
# Show where to obtain a LinkAI key (zh users -> console page).
|
# Show where to obtain a LinkAI key (zh users -> console page).
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ $ModelChoices = @{
|
|||||||
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
||||||
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
||||||
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-1-pro-260628"; Field = "ark_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" }
|
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 }
|
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
||||||
}
|
}
|
||||||
@@ -478,7 +478,7 @@ function Select-Model {
|
|||||||
"GLM (glm-5.2, etc.)",
|
"GLM (glm-5.2, etc.)",
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
||||||
"Doubao (doubao-seed-2.1, etc.)",
|
"Doubao (doubao-seed-2.1, etc.)",
|
||||||
"Kimi (kimi-k2.7-code, etc.)",
|
"Kimi (kimi-k3, etc.)",
|
||||||
"MiMo (mimo-v2.5-pro, etc.)",
|
"MiMo (mimo-v2.5-pro, etc.)",
|
||||||
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
||||||
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
||||||
|
|||||||
229
tests/test_agent_steering.py
Normal file
229
tests/test_agent_steering.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from agent.protocol.agent_stream import AgentStreamExecutor
|
||||||
|
from agent.protocol.steer import (
|
||||||
|
SteerInbox,
|
||||||
|
SteerRegistry,
|
||||||
|
SteerResult,
|
||||||
|
SteerStatus,
|
||||||
|
)
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
from channel.chat_channel import ChatChannel
|
||||||
|
|
||||||
|
|
||||||
|
class _ScriptedExecutor(AgentStreamExecutor):
|
||||||
|
def __init__(self, responses, inbox, steer_after_tool=None):
|
||||||
|
super().__init__(
|
||||||
|
agent=SimpleNamespace(),
|
||||||
|
model=SimpleNamespace(model="test-model"),
|
||||||
|
system_prompt="",
|
||||||
|
tools=[],
|
||||||
|
max_turns=8,
|
||||||
|
messages=[],
|
||||||
|
steer_inbox=inbox,
|
||||||
|
)
|
||||||
|
self.responses = list(responses)
|
||||||
|
self.executed = []
|
||||||
|
self.steer_after_tool = steer_after_tool
|
||||||
|
|
||||||
|
def _is_thinking_enabled(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _trim_messages(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _validate_and_fix_messages(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _call_llm_stream(self, retry_on_empty=True):
|
||||||
|
text, tool_calls, callback = self.responses.pop(0)
|
||||||
|
content = []
|
||||||
|
if text:
|
||||||
|
content.append({"type": "text", "text": text})
|
||||||
|
content.extend({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": call["id"],
|
||||||
|
"name": call["name"],
|
||||||
|
"input": call.get("arguments", {}),
|
||||||
|
} for call in tool_calls)
|
||||||
|
self.messages.append({"role": "assistant", "content": content})
|
||||||
|
if callback:
|
||||||
|
callback()
|
||||||
|
return text, tool_calls
|
||||||
|
|
||||||
|
def _execute_tool(self, tool_call):
|
||||||
|
self.executed.append(tool_call["name"])
|
||||||
|
if self.steer_after_tool == tool_call["name"]:
|
||||||
|
self.steer_inbox.submit("use the new target")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"result": f"finished {tool_call['name']}",
|
||||||
|
"execution_time": 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(name):
|
||||||
|
return {"id": f"call-{name}", "name": name, "arguments": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _blocks(messages, block_type):
|
||||||
|
return [
|
||||||
|
block
|
||||||
|
for message in messages
|
||||||
|
for block in (message.get("content") or [])
|
||||||
|
if isinstance(block, dict) and block.get("type") == block_type
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_accepts_only_one_active_run_and_preserves_order():
|
||||||
|
registry = SteerRegistry()
|
||||||
|
inbox = registry.register("research::session")
|
||||||
|
|
||||||
|
assert registry.submit("other::session", "ignored").status == SteerStatus.INACTIVE
|
||||||
|
assert registry.submit("research::session", "first").accepted
|
||||||
|
assert registry.submit("research::session", "second").accepted
|
||||||
|
assert inbox.drain() == ["first", "second"]
|
||||||
|
|
||||||
|
second = registry.register("research::session")
|
||||||
|
assert registry.submit("research::session", "ambiguous").status == SteerStatus.AMBIGUOUS
|
||||||
|
registry.unregister("research::session", second)
|
||||||
|
registry.unregister("research::session", inbox)
|
||||||
|
assert registry.submit("research::session", "late").status == SteerStatus.INACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_inbox_bounds_and_atomic_close_gate():
|
||||||
|
inbox = SteerInbox(max_pending=1, max_chars=5)
|
||||||
|
assert inbox.submit("").status == SteerStatus.INVALID
|
||||||
|
assert inbox.submit("123456").status == SteerStatus.INVALID
|
||||||
|
assert inbox.submit("first").accepted
|
||||||
|
assert inbox.submit("again").status == SteerStatus.FULL
|
||||||
|
assert not inbox.close_if_empty()
|
||||||
|
assert inbox.drain() == ["first"]
|
||||||
|
assert inbox.close_if_empty()
|
||||||
|
assert inbox.submit("late").status == SteerStatus.CLOSING
|
||||||
|
|
||||||
|
|
||||||
|
def test_steer_arriving_during_model_skips_all_proposed_tools():
|
||||||
|
inbox = SteerInbox()
|
||||||
|
executor = _ScriptedExecutor([
|
||||||
|
("old plan", [_tool("one"), _tool("two")], lambda: inbox.submit("change course")),
|
||||||
|
("new answer", [], None),
|
||||||
|
], inbox)
|
||||||
|
|
||||||
|
assert executor.run_stream("start") == "new answer"
|
||||||
|
assert executor.executed == []
|
||||||
|
results = _blocks(executor.messages, "tool_result")
|
||||||
|
assert {block["tool_use_id"] for block in results} == {"call-one", "call-two"}
|
||||||
|
assert all(block.get("is_error") for block in results)
|
||||||
|
assert "change course" in "\n".join(
|
||||||
|
block["text"] for block in _blocks(executor.messages, "text")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_steer_between_tools_keeps_completed_result_and_skips_remaining_tool():
|
||||||
|
inbox = SteerInbox()
|
||||||
|
executor = _ScriptedExecutor([
|
||||||
|
("", [_tool("one"), _tool("two")], None),
|
||||||
|
("redirected answer", [], None),
|
||||||
|
], inbox, steer_after_tool="one")
|
||||||
|
|
||||||
|
assert executor.run_stream("start") == "redirected answer"
|
||||||
|
assert executor.executed == ["one"]
|
||||||
|
results = {block["tool_use_id"]: block for block in _blocks(executor.messages, "tool_result")}
|
||||||
|
assert not results["call-one"].get("is_error", False)
|
||||||
|
assert results["call-two"]["is_error"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_agent_bridge(result):
|
||||||
|
bridge = SimpleNamespace(
|
||||||
|
steer_session=Mock(return_value=result),
|
||||||
|
)
|
||||||
|
return bridge, SimpleNamespace(get_agent_bridge=lambda: bridge)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_steer_command_bypasses_the_normal_queue(monkeypatch):
|
||||||
|
bridge, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
|
||||||
|
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
|
||||||
|
channel = object.__new__(ChatChannel)
|
||||||
|
channel.sessions = {}
|
||||||
|
channel.lock = threading.Lock()
|
||||||
|
channel._send_reply = Mock()
|
||||||
|
context = Context(ContextType.TEXT, "/steer focus on tests", {
|
||||||
|
"session_id": "session",
|
||||||
|
})
|
||||||
|
|
||||||
|
ChatChannel.produce(channel, context)
|
||||||
|
|
||||||
|
assert channel.sessions == {}
|
||||||
|
bridge.steer_session.assert_called_once_with("session", "focus on tests")
|
||||||
|
reply_text = channel._send_reply.call_args.args[1].content
|
||||||
|
assert "redirect" in reply_text.lower() or "已引导" in reply_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_chat_message_keeps_using_the_session_queue(monkeypatch):
|
||||||
|
_, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
|
||||||
|
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
|
||||||
|
monkeypatch.setattr("channel.chat_channel.conf", lambda: {
|
||||||
|
"concurrency_in_session": 1,
|
||||||
|
})
|
||||||
|
channel = object.__new__(ChatChannel)
|
||||||
|
channel.sessions = {}
|
||||||
|
channel.lock = threading.Lock()
|
||||||
|
context = Context(ContextType.TEXT, "ordinary message", {
|
||||||
|
"session_id": "session",
|
||||||
|
})
|
||||||
|
|
||||||
|
ChatChannel.produce(channel, context)
|
||||||
|
|
||||||
|
assert list(channel.sessions) == ["session"]
|
||||||
|
assert channel.sessions["session"][0].get() is context
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_steer_button_payload_is_handled_inline(monkeypatch):
|
||||||
|
from channel.web import web_channel
|
||||||
|
|
||||||
|
bridge, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
|
||||||
|
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
|
||||||
|
monkeypatch.setattr(web_channel.web, "data", lambda: json.dumps({
|
||||||
|
"session_id": "session",
|
||||||
|
"message": "focus on tests",
|
||||||
|
"steer": True,
|
||||||
|
"lang": "en",
|
||||||
|
}).encode())
|
||||||
|
raw_class = web_channel.WebChannel.__closure__[0].cell_contents
|
||||||
|
instance = object.__new__(raw_class)
|
||||||
|
|
||||||
|
response = json.loads(raw_class.post_message(instance))
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
"status": "success",
|
||||||
|
"request_id": "",
|
||||||
|
"stream": False,
|
||||||
|
"steered": True,
|
||||||
|
"inline_reply": "↪️ Active task redirected.",
|
||||||
|
}
|
||||||
|
bridge.steer_session.assert_called_once_with("session", "focus on tests")
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_steer_does_not_start_a_run_when_session_is_idle(monkeypatch):
|
||||||
|
from channel.web import web_channel
|
||||||
|
|
||||||
|
_, factory = _fake_agent_bridge(SteerResult(SteerStatus.INACTIVE))
|
||||||
|
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
|
||||||
|
monkeypatch.setattr(web_channel.web, "data", lambda: json.dumps({
|
||||||
|
"session_id": "idle",
|
||||||
|
"message": "/steer change course",
|
||||||
|
"stream": True,
|
||||||
|
"lang": "en",
|
||||||
|
}).encode())
|
||||||
|
raw_class = web_channel.WebChannel.__closure__[0].cell_contents
|
||||||
|
instance = object.__new__(raw_class)
|
||||||
|
|
||||||
|
response = json.loads(raw_class.post_message(instance))
|
||||||
|
|
||||||
|
assert response["stream"] is False
|
||||||
|
assert response["steered"] is False
|
||||||
|
assert response["inline_reply"] == "No active task to steer."
|
||||||
110
tests/test_cli_backup.py
Normal file
110
tests/test_cli_backup.py
Normal 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
114
tests/test_cow_cli_tasks.py
Normal 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
|
||||||
151
tests/test_feishu_markdown_images.py
Normal file
151
tests/test_feishu_markdown_images.py
Normal 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\n"
|
||||||
|
"Again "
|
||||||
|
)
|
||||||
|
|
||||||
|
result = feishu_static_card.resolve_markdown_images(markdown, upload)
|
||||||
|
|
||||||
|
assert result == (
|
||||||
|
"Before\n\nAgain "
|
||||||
|
)
|
||||||
|
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 = " [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: "
|
||||||
|
|
||||||
|
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"
|
||||||
136
tests/test_feishu_message_recall.py
Normal file
136
tests/test_feishu_message_recall.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
from channel.chat_channel import ChatChannel
|
||||||
|
from channel.feishu import feishu_channel
|
||||||
|
from channel.feishu.feishu_channel import FeishuController, FeiShuChanel
|
||||||
|
from common.dequeue import Dequeue
|
||||||
|
from common.expired_dict import ExpiredDict
|
||||||
|
|
||||||
|
|
||||||
|
def _context(message_id: str) -> Context:
|
||||||
|
return Context(
|
||||||
|
ContextType.TEXT,
|
||||||
|
message_id,
|
||||||
|
{
|
||||||
|
"session_id": "session-1",
|
||||||
|
"msg": SimpleNamespace(msg_id=message_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_chat_channel(*contexts: Context) -> ChatChannel:
|
||||||
|
channel = ChatChannel.__new__(ChatChannel)
|
||||||
|
channel.lock = threading.RLock()
|
||||||
|
channel.futures = {}
|
||||||
|
queue = Dequeue()
|
||||||
|
for context in contexts:
|
||||||
|
queue.put(context)
|
||||||
|
channel.sessions = {"session-1": [queue, MagicMock()]}
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_message_removes_only_recalled_queued_context(monkeypatch):
|
||||||
|
channel = _bare_chat_channel(_context("m1"), _context("m2"), _context("m3"))
|
||||||
|
registry = MagicMock()
|
||||||
|
registry.cancel_request.return_value = False
|
||||||
|
monkeypatch.setattr("agent.protocol.get_cancel_registry", lambda: registry)
|
||||||
|
|
||||||
|
queued, active = channel.cancel_message("session-1", "m2")
|
||||||
|
|
||||||
|
assert (queued, active) == (1, False)
|
||||||
|
remaining = channel.sessions["session-1"][0]
|
||||||
|
assert [remaining.get_nowait().get("msg").msg_id for _ in range(2)] == ["m1", "m3"]
|
||||||
|
registry.cancel_request.assert_called_once_with("m2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_message_targets_active_request_without_clearing_queue(monkeypatch):
|
||||||
|
channel = _bare_chat_channel(_context("later"))
|
||||||
|
registry = MagicMock()
|
||||||
|
registry.cancel_request.return_value = True
|
||||||
|
monkeypatch.setattr("agent.protocol.get_cancel_registry", lambda: registry)
|
||||||
|
|
||||||
|
queued, active = channel.cancel_message("session-1", "active")
|
||||||
|
|
||||||
|
assert (queued, active) == (0, True)
|
||||||
|
remaining = channel.sessions["session-1"][0]
|
||||||
|
assert remaining.get_nowait().get("msg").msg_id == "later"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_message_uses_message_id_for_precise_recall(monkeypatch):
|
||||||
|
channel = FeiShuChanel()
|
||||||
|
channel.receivedMsgs = ExpiredDict(60)
|
||||||
|
channel._message_sessions = ExpiredDict(60)
|
||||||
|
monkeypatch.setattr(channel, "fetch_access_token", lambda: "tenant-token")
|
||||||
|
monkeypatch.setattr(channel, "_make_feishu_stream_callback", lambda *_: MagicMock())
|
||||||
|
produced = []
|
||||||
|
monkeypatch.setattr(channel, "produce", produced.append)
|
||||||
|
|
||||||
|
channel._handle_message_event(
|
||||||
|
{
|
||||||
|
"app_id": "cli_bot",
|
||||||
|
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||||
|
"message": {
|
||||||
|
"message_id": "om_recall_me",
|
||||||
|
"chat_id": "oc_chat",
|
||||||
|
"chat_type": "p2p",
|
||||||
|
"message_type": "text",
|
||||||
|
"create_time": str(int(time.time() * 1000)),
|
||||||
|
"content": json.dumps({"text": "long task"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(produced) == 1
|
||||||
|
assert produced[0]["request_id"] == "om_recall_me"
|
||||||
|
assert channel._message_sessions.get("om_recall_me") == "ou_user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_recall_cancels_only_the_original_message(monkeypatch):
|
||||||
|
channel = FeiShuChanel()
|
||||||
|
channel._message_sessions = ExpiredDict(60)
|
||||||
|
channel._message_sessions["om_recalled"] = "session-1"
|
||||||
|
cancel_message = MagicMock(return_value=(0, True))
|
||||||
|
monkeypatch.setattr(channel, "cancel_message", cancel_message)
|
||||||
|
|
||||||
|
result = channel._handle_message_recalled_event(
|
||||||
|
{"message_id": "om_recalled", "chat_id": "oc_chat"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == (0, True)
|
||||||
|
cancel_message.assert_called_once_with("session-1", "om_recalled")
|
||||||
|
assert channel._message_sessions.get("om_recalled") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_recall_ignores_unknown_message():
|
||||||
|
channel = FeiShuChanel()
|
||||||
|
channel._message_sessions = ExpiredDict(60)
|
||||||
|
|
||||||
|
assert channel._handle_message_recalled_event({"message_id": "unknown"}) == (0, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feishu_webhook_routes_message_recall(monkeypatch):
|
||||||
|
channel = FeiShuChanel()
|
||||||
|
channel.feishu_token = "verification-token"
|
||||||
|
handle_recall = MagicMock(return_value=(1, False))
|
||||||
|
monkeypatch.setattr(channel, "_handle_message_recalled_event", handle_recall)
|
||||||
|
event = {"message_id": "om_recalled", "chat_id": "oc_chat"}
|
||||||
|
request = {
|
||||||
|
"header": {
|
||||||
|
"event_type": "im.message.recalled_v1",
|
||||||
|
"token": "verification-token",
|
||||||
|
},
|
||||||
|
"event": event,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
feishu_channel.web,
|
||||||
|
"data",
|
||||||
|
lambda: json.dumps(request).encode("utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(FeishuController().POST()) == {"success": True}
|
||||||
|
handle_recall.assert_called_once_with(event)
|
||||||
139
tests/test_feishu_progress_card.py
Normal file
139
tests/test_feishu_progress_card.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||||
|
from common import i18n
|
||||||
|
|
||||||
|
# Card text is localized via i18n.t; lock English so assertions are stable
|
||||||
|
# regardless of the host machine locale.
|
||||||
|
i18n.set_language("en")
|
||||||
|
|
||||||
|
|
||||||
|
def _panels(card):
|
||||||
|
return [
|
||||||
|
element
|
||||||
|
for element in card["body"]["elements"]
|
||||||
|
if element.get("tag") == "collapsible_panel"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_card_has_status_header_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
|
||||||
|
# No reasoning yet: the Reasoning panel must not be rendered.
|
||||||
|
assert _panels(card) == []
|
||||||
|
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"}]}})
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "tool_execution_start",
|
||||||
|
"data": {"tool_call_id": "t1", "tool_name": "read_file"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=True, now=103.0)
|
||||||
|
panels = _panels(card)
|
||||||
|
assert panels[0]["header"]["title"]["content"] == "🤔 Thinking"
|
||||||
|
assert panels[1]["header"]["title"]["content"] == "🔧 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"]
|
||||||
|
|
||||||
|
# Tool end marks the step done and shows its own elapsed time.
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "tool_execution_end",
|
||||||
|
"data": {"tool_call_id": "t1", "status": "success", "execution_time": 1.2},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
done_card = state.build_card(streaming=True, now=104.0)
|
||||||
|
tool_row = _panels(done_card)[1]["elements"][0]["text"]["content"]
|
||||||
|
assert "done" in tool_row
|
||||||
|
assert "1.2s" in tool_row
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Successful completion hides the status header entirely.
|
||||||
|
assert "header" not in card
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_text_is_localized_in_chinese():
|
||||||
|
try:
|
||||||
|
i18n.set_language("zh")
|
||||||
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
state.consume({"type": "turn_start", "data": {"turn": 1}})
|
||||||
|
state.consume({"type": "reasoning_update", "data": {"delta": "思考"}})
|
||||||
|
state.consume({"type": "message_end", "data": {"tool_calls": [{"name": "read_file"}]}})
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "tool_execution_start",
|
||||||
|
"data": {"tool_call_id": "t1", "tool_name": "read_file"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=True, now=102.0)
|
||||||
|
|
||||||
|
assert card["header"]["title"]["content"] == "处理中"
|
||||||
|
panels = _panels(card)
|
||||||
|
assert panels[0]["header"]["title"]["content"] == "🤔 思考"
|
||||||
|
assert panels[1]["header"]["title"]["content"] == "🔧 工具 (1)"
|
||||||
|
assert "执行中" in panels[1]["elements"][0]["text"]["content"]
|
||||||
|
footer = card["body"]["elements"][-1]
|
||||||
|
assert footer["content"] == "2.0s · 1 轮"
|
||||||
|
finally:
|
||||||
|
i18n.set_language("en")
|
||||||
105
tests/test_feishu_quoted_context.py
Normal file
105
tests/test_feishu_quoted_context.py
Normal 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?"
|
||||||
140
tests/test_feishu_scheduler_card.py
Normal file
140
tests/test_feishu_scheduler_card.py
Normal 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"]
|
||||||
27
tests/test_feishu_static_card.py
Normal file
27
tests/test_feishu_static_card.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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_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
|
||||||
77
tests/test_scheduler_run_now.py
Normal file
77
tests/test_scheduler_run_now.py
Normal 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
|
||||||
92
tests/test_scheduler_silent.py
Normal file
92
tests/test_scheduler_silent.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Regression tests for silent scheduled agent tasks."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from agent.tools.scheduler.integration import _execute_agent_task
|
||||||
|
from agent.tools.scheduler.scheduler_tool import SchedulerTool
|
||||||
|
|
||||||
|
|
||||||
|
class _Context(dict):
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
def add_task(self, task):
|
||||||
|
self.added.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
class _AgentBridge:
|
||||||
|
def __init__(self, content="maintenance complete"):
|
||||||
|
self.content = content
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def agent_reply(self, task_description, **kwargs):
|
||||||
|
self.calls.append((task_description, kwargs))
|
||||||
|
return SimpleNamespace(content=self.content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerSilentMode(unittest.TestCase):
|
||||||
|
def test_schema_exposes_silent_for_agent_tasks(self):
|
||||||
|
silent = SchedulerTool.params["properties"]["silent"]
|
||||||
|
|
||||||
|
self.assertEqual(silent["type"], "boolean")
|
||||||
|
self.assertFalse(silent["default"])
|
||||||
|
|
||||||
|
def test_create_persists_silent_on_agent_task(self):
|
||||||
|
tool = SchedulerTool({"channel_type": "web"})
|
||||||
|
store = _TaskStore()
|
||||||
|
tool.task_store = store
|
||||||
|
tool.current_context = _Context(
|
||||||
|
receiver="user-1",
|
||||||
|
session_id="session-1",
|
||||||
|
isgroup=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.execute(
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"name": "refresh token",
|
||||||
|
"ai_task": "refresh the token",
|
||||||
|
"schedule_type": "interval",
|
||||||
|
"schedule_value": "3000",
|
||||||
|
"silent": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "success")
|
||||||
|
self.assertEqual(len(store.added), 1)
|
||||||
|
self.assertIs(store.added[0]["action"]["silent"], True)
|
||||||
|
|
||||||
|
def test_silent_agent_task_executes_without_delivery(self):
|
||||||
|
bridge = _AgentBridge()
|
||||||
|
task = {
|
||||||
|
"id": "task-1",
|
||||||
|
"action": {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "rotate logs",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"is_group": False,
|
||||||
|
"channel_type": "web",
|
||||||
|
"silent": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("channel.channel_factory.create_channel") as create_channel:
|
||||||
|
result = _execute_agent_task(task, bridge)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(len(bridge.calls), 1)
|
||||||
|
create_channel.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
168
tests/test_scheduler_web_update.py
Normal file
168
tests/test_scheduler_web_update.py
Normal 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"
|
||||||
Reference in New Issue
Block a user