mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 21:07:28 +08:00
Compare commits
35 Commits
feat-deskt
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)) {
|
||||
platform = 'mac-x64'
|
||||
slot = 'upd'
|
||||
} else if (/win7.*\.exe$/i.test(base)) {
|
||||
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||
// segment so it never collides with the standard win exe in the same
|
||||
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||
platform = 'win-legacy'
|
||||
slot = 'main'
|
||||
} else if (/\.exe$/.test(base)) {
|
||||
platform = 'win'
|
||||
slot = 'main'
|
||||
|
||||
154
.github/workflows/publish-desktop.yml
vendored
154
.github/workflows/publish-desktop.yml
vendored
@@ -1,154 +0,0 @@
|
||||
name: Publish Desktop
|
||||
|
||||
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
||||
# to "live". By this point:
|
||||
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
||||
# registered them in D1 as unpublished (is_latest=0);
|
||||
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
||||
# dmgs and re-uploaded the stapled bytes to R2.
|
||||
#
|
||||
# This workflow, triggered manually with the version to publish:
|
||||
# 1. pulls every artifact for that version back from R2,
|
||||
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
||||
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
||||
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
||||
# 4. creates/updates the GitHub Release and attaches the installers.
|
||||
#
|
||||
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
||||
type: string
|
||||
required: true
|
||||
make_latest:
|
||||
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
||||
type: boolean
|
||||
default: true
|
||||
github_release:
|
||||
description: "Create/update the GitHub Release and attach installers."
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish ${{ github.event.inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Guard on Cloudflare secrets
|
||||
id: guard
|
||||
env:
|
||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$CF_TOKEN" ]; then
|
||||
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve version artifacts from D1
|
||||
id: rows
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
||||
# Read them back so we know exactly which objects to pull from R2.
|
||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
||||
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
||||
echo "$out"
|
||||
echo "$out" | node -e '
|
||||
const fs = require("fs");
|
||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const rows = (Array.isArray(data) ? data : [data])
|
||||
.flatMap(r => (r.results || []));
|
||||
if (!rows.length) {
|
||||
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
||||
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
||||
'
|
||||
|
||||
- name: Download version artifacts from R2
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
||||
# auto-update file (update_filename: zip) for every row. Keys are
|
||||
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
||||
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
||||
base="$(basename "$key")"
|
||||
r2key="desktop/${key}"
|
||||
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
||||
--file "dist/${base}" --remote
|
||||
done
|
||||
echo "Downloaded:"; ls -la dist
|
||||
|
||||
- name: Reminder — mac dmgs must be notarized before publishing
|
||||
run: |
|
||||
# Stapling can only be validated on macOS (xcrun stapler validate),
|
||||
# which this Linux runner doesn't have. The authoritative check runs in
|
||||
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
||||
# This step is just a loud reminder in the log.
|
||||
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
||||
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
||||
|
||||
- name: Update D1 (recompute sha512 + set latest)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
||||
run: |
|
||||
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
||||
# become latest, so the site keeps serving the last stable build.
|
||||
case "$VER" in
|
||||
*-*) is_pre=1 ;;
|
||||
*) is_pre=0 ;;
|
||||
esac
|
||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
||||
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
||||
else
|
||||
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
||||
fi
|
||||
|
||||
# Re-hash the real (stapled) bytes and re-store every row with both the
|
||||
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
||||
# build stage; --latest also clears the previous latest per platform.
|
||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
||||
echo "==> D1 statements:"; cat d1.sql
|
||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||
|
||||
- name: Create/update GitHub Release and attach installers
|
||||
if: github.event.inputs.github_release == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
tag="v${VER}"
|
||||
case "$VER" in
|
||||
*-*) prerelease="--prerelease" ;;
|
||||
*) prerelease="" ;;
|
||||
esac
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--title "$tag" --generate-notes $prerelease
|
||||
fi
|
||||
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
||||
# zip is the auto-update artifact; attach it too so the GitHub Release
|
||||
# is a complete mirror (nullglob avoids errors when a type is absent).
|
||||
shopt -s nullglob
|
||||
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
230
.github/workflows/release-win7.yml
vendored
Normal file
230
.github/workflows/release-win7.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
name: Release Desktop (Win7 legacy)
|
||||
|
||||
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
|
||||
#
|
||||
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
|
||||
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
|
||||
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
|
||||
#
|
||||
# To support Win7 we must pin BOTH halves to the last versions that still
|
||||
# target it:
|
||||
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
|
||||
# - Python 3.8 (last CPython to support Win7)
|
||||
#
|
||||
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
|
||||
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
|
||||
# then — exactly like the main pipeline — uploads it to R2 and registers a
|
||||
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
|
||||
# stays invisible until promoted, so it can't accidentally get served to Win10
|
||||
# users). Because it's stamped with the SAME version as the standard release,
|
||||
# the download page shows both Windows builds under one version row, and the
|
||||
# /update feed serves each build to its own clients. Delete this file whenever
|
||||
# legacy Windows support is no longer worth maintaining — the main pipeline is
|
||||
# unaffected.
|
||||
#
|
||||
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
|
||||
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
|
||||
type: string
|
||||
default: "0.0.0"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Windows x64 (Win7 legacy)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Python 3.8 is the last CPython that supports Windows 7. A backend built
|
||||
# with it (via PyInstaller) still runs on Win7 even though the CI host is
|
||||
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
|
||||
# OS, not the build machine's.
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Build Python backend (PyInstaller, Python 3.8)
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
|
||||
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
|
||||
# for this legacy build. We rewrite them into a throwaway requirements
|
||||
# file so the repo's source stays untouched (main pipeline keeps its
|
||||
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
|
||||
sed 's/^playwright==.*/playwright==1.48.0/' \
|
||||
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
|
||||
pip install -r /tmp/requirements-win7.txt
|
||||
pip install pyinstaller
|
||||
# Run from repo root so the spec's relative datas resolve correctly.
|
||||
pyinstaller desktop/build/cowagent-backend.spec \
|
||||
--noconfirm \
|
||||
--distpath desktop/build/dist \
|
||||
--workpath desktop/build/build-work
|
||||
|
||||
- name: Install desktop deps
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Write version into package.json
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
|
||||
# this out of package.json so the repo's committed deps stay on Electron 33
|
||||
# for the main pipeline. electron-builder reads the installed Electron
|
||||
# version from node_modules, so this is all that's needed to package v22.
|
||||
- name: Pin Electron to 22 (last Win7-capable)
|
||||
working-directory: desktop
|
||||
run: npm install --no-save electron@22.3.27
|
||||
|
||||
- name: Compile (vite + tsc)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
run: npm run build
|
||||
|
||||
# Same signing setup as the main pipeline: download the signtool CLI (URL
|
||||
# from a repo variable so nothing is hardcoded in a public workflow). Only
|
||||
# runs when a URL is configured; otherwise the build stays unsigned but
|
||||
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
|
||||
- name: Download Windows signing CLI
|
||||
if: vars.SIGNTOOL_CLI_URL != ''
|
||||
shell: bash
|
||||
env:
|
||||
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/signtool"
|
||||
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||
if [ -z "$exe" ]; then
|
||||
echo "signtool.exe not found in downloaded archive" >&2
|
||||
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||
echo "resolved signtool: $exe"
|
||||
|
||||
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
|
||||
# hook the main pipeline uses (signs app + backend + installer via the
|
||||
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
|
||||
# installer is still produced (unsigned). --publish never emits the exe
|
||||
# without touching any feed. Invoke via node (not npx) to avoid the
|
||||
# Windows npx.cmd wrapper returning early (see release.yml).
|
||||
#
|
||||
# -c.win.artifactName injects a "win7" segment into the file name
|
||||
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
|
||||
# builds differ by ${arch}: it keeps the legacy exe from colliding with
|
||||
# the standard win exe in the same v<version>/ folder, and lets
|
||||
# register-releases.mjs map it to the win-legacy platform by name.
|
||||
- name: Build installer (electron-builder, Electron 22)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
env:
|
||||
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||
run: |
|
||||
node node_modules/electron-builder/cli.js --win --x64 \
|
||||
--config electron-builder.win.js \
|
||||
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
|
||||
--publish never
|
||||
|
||||
# Collect the installer + its blockmap (differential updates). The .yml
|
||||
# feed is NOT uploaded: the /update Function generates it dynamically from
|
||||
# D1 (same as the main pipeline), so it isn't needed here.
|
||||
- name: Upload installer artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cowagent-win7-x64
|
||||
path: |
|
||||
desktop/release/*.exe
|
||||
desktop/release/*.blockmap
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
|
||||
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
|
||||
# wrangler@latest works (the build job pins Node 20 for Electron 22).
|
||||
#
|
||||
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
|
||||
# build — its "win7" name segment keeps them distinct — and register-releases
|
||||
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
|
||||
# publish workflow). Because the version matches the standard release, the
|
||||
# download page merges both Windows builds into one version row.
|
||||
publish:
|
||||
name: Publish to R2 + D1
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Guard on Cloudflare secrets
|
||||
id: guard
|
||||
env:
|
||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
if [ -n "$CF_TOKEN" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
|
||||
fi
|
||||
|
||||
- name: Download build artifact
|
||||
if: steps.guard.outputs.enabled == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cowagent-win7-x64
|
||||
path: dist
|
||||
|
||||
- name: Upload installer to R2
|
||||
if: steps.guard.outputs.enabled == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for f in dist/*.exe dist/*.blockmap; do
|
||||
base="$(basename "$f")"
|
||||
key="desktop/v${VER}/${base}"
|
||||
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
|
||||
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||
--file "$f" --remote
|
||||
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
|
||||
done
|
||||
|
||||
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
|
||||
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
|
||||
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
|
||||
- name: Register release row in D1
|
||||
if: steps.guard.outputs.enabled == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||
echo "==> D1 statements:"; cat d1.sql
|
||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||
@@ -113,7 +113,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||
|
||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
||||
Terminal = _optional_tools.get('Terminal')
|
||||
|
||||
|
||||
# BrowserTool (requires playwright)
|
||||
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||
def _import_browser_tool():
|
||||
from common.log import logger
|
||||
try:
|
||||
from agent.tools.browser.browser_tool import BrowserTool
|
||||
return BrowserTool
|
||||
except ImportError as e:
|
||||
logger.info(
|
||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||
return None
|
||||
|
||||
@@ -463,7 +463,18 @@ class BrowserService:
|
||||
headless_cfg = self._config.get("headless")
|
||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||
|
||||
launch_args = ["--disable-dev-shm-usage"]
|
||||
launch_args = [
|
||||
"--disable-dev-shm-usage",
|
||||
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||
# browser prompt, and Chrome's background/component network chatter.
|
||||
# These have no effect on page interaction but noticeably speed up
|
||||
# cold starts and each navigation.
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,OptimizationHints",
|
||||
]
|
||||
if self._headless:
|
||||
launch_args.append("--no-sandbox")
|
||||
|
||||
@@ -733,11 +744,15 @@ class BrowserService:
|
||||
except Exception as e:
|
||||
return {"error": f"Navigation failed: {e}"}
|
||||
|
||||
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||
# short grace period for initial render/XHR, then proceed.
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=8000)
|
||||
page.wait_for_load_state("networkidle", timeout=1500)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
try:
|
||||
title = page.title()
|
||||
|
||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||
return True # agent ran but produced nothing; don't loop
|
||||
|
||||
if action.get("silent", False):
|
||||
logger.info(
|
||||
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||
)
|
||||
return True
|
||||
|
||||
from channel.channel_factory import create_channel
|
||||
channel = create_channel(channel_type)
|
||||
if not channel:
|
||||
|
||||
@@ -41,6 +41,8 @@ class SchedulerService:
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self._lock = threading.Lock()
|
||||
self._execution_lock = threading.Lock()
|
||||
self._active_task_ids = set()
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler service"""
|
||||
@@ -85,7 +87,15 @@ class SchedulerService:
|
||||
try:
|
||||
if self._is_task_due(task, now):
|
||||
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
||||
if not self._claim_task(task['id']):
|
||||
logger.info(
|
||||
f"[Scheduler] Task {task['id']} is already running; skipping this tick"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
ok = self._execute_task(task)
|
||||
finally:
|
||||
self._release_task(task['id'])
|
||||
if not ok:
|
||||
# Leave next_run_at as-is so the next loop retries.
|
||||
# Cron tasks within the catch-up window will keep
|
||||
@@ -107,6 +117,57 @@ class SchedulerService:
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
||||
|
||||
def run_task_now(self, task_id: str) -> None:
|
||||
"""Queue one immediate execution without changing the task schedule.
|
||||
|
||||
Disabled and one-time tasks may be run manually for testing. The
|
||||
stored ``next_run_at`` remains unchanged, so a manual run never
|
||||
consumes or delays the next scheduled occurrence.
|
||||
|
||||
Raises:
|
||||
ValueError: if the task does not exist.
|
||||
RuntimeError: if the same task is already executing.
|
||||
"""
|
||||
task = self.task_store.get_task(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task '{task_id}' not found")
|
||||
if not self._claim_task(task_id):
|
||||
raise RuntimeError(f"Task '{task_id}' is already running")
|
||||
|
||||
def _run():
|
||||
now = datetime.now()
|
||||
try:
|
||||
logger.info(f"[Scheduler] Manually executing task: {task_id} - {task.get('name', '')}")
|
||||
ok = self._execute_task(task)
|
||||
if ok:
|
||||
self.task_store.update_task(task_id, {
|
||||
"last_run_at": now.isoformat(),
|
||||
"last_manual_run_at": now.isoformat(),
|
||||
})
|
||||
logger.info(f"[Scheduler] Manual execution completed: {task_id}")
|
||||
else:
|
||||
logger.warning(f"[Scheduler] Manual execution failed: {task_id}")
|
||||
finally:
|
||||
self._release_task(task_id)
|
||||
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name=f"scheduler-manual-{task_id}",
|
||||
).start()
|
||||
|
||||
def _claim_task(self, task_id: str) -> bool:
|
||||
"""Prevent scheduled and manual runs of the same task from overlapping."""
|
||||
with self._execution_lock:
|
||||
if task_id in self._active_task_ids:
|
||||
return False
|
||||
self._active_task_ids.add(task_id)
|
||||
return True
|
||||
|
||||
def _release_task(self, task_id: str) -> None:
|
||||
with self._execution_lock:
|
||||
self._active_task_ids.discard(task_id)
|
||||
|
||||
def _is_task_due(self, task: dict, now: datetime) -> bool:
|
||||
"""
|
||||
Check if a task is due to run
|
||||
|
||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
||||
"schedule_value": {
|
||||
"type": "string",
|
||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||
},
|
||||
"silent": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
||||
"channel_type": self.config.get("channel_type", "unknown"),
|
||||
"notify_session_id": notify_session_id,
|
||||
}
|
||||
# silent only applies to ai_task; fixed messages always deliver
|
||||
if kwargs.get("silent", False):
|
||||
action["silent"] = True
|
||||
|
||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||
msg = context.kwargs.get("msg")
|
||||
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
|
||||
else:
|
||||
content_desc = f"🤖 AI任务: {ai_task}"
|
||||
|
||||
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||
|
||||
return (
|
||||
f"✅ 定时任务创建成功\n\n"
|
||||
f"📋 任务ID: {task_id}\n"
|
||||
f"📝 名称: {name}\n"
|
||||
f"⏰ 调度: {schedule_desc}\n"
|
||||
f"👤 接收者: {receiver_desc}\n"
|
||||
f"{content_desc}\n"
|
||||
f"{content_desc}{silent_desc}\n"
|
||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||
)
|
||||
|
||||
|
||||
@@ -152,14 +152,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif "markdownify" in error_msg:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -222,14 +215,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif "markdownify" in error_msg:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -261,14 +247,7 @@ class ToolManager:
|
||||
# If there are missing tools, record warnings
|
||||
if missing_tools:
|
||||
for tool_name in missing_tools:
|
||||
if tool_name == "browser":
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif tool_name == "google_search":
|
||||
if tool_name == "google_search":
|
||||
logger.warning(
|
||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||
f" Get API key from: https://serper.dev\n"
|
||||
|
||||
@@ -28,6 +28,17 @@ from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.feishu.feishu_message import FeishuMessage
|
||||
from channel.feishu.feishu_static_card import (
|
||||
build_text_delivery,
|
||||
resolve_markdown_images,
|
||||
upload_public_image_to_feishu,
|
||||
)
|
||||
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||
from channel.feishu.feishu_scheduler_card import (
|
||||
build_scheduler_card,
|
||||
handle_scheduler_action,
|
||||
tasks_for_receivers,
|
||||
)
|
||||
from common import utils
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
@@ -350,6 +361,10 @@ class FeiShuChanel(ChatChannel):
|
||||
def _startup_websocket(self):
|
||||
"""启动长连接接收事件(websocket模式)"""
|
||||
_ensure_lark_imported()
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||
|
||||
# 创建事件处理器
|
||||
@@ -372,10 +387,25 @@ class FeiShuChanel(ChatChannel):
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
||||
|
||||
def handle_card_action(data):
|
||||
"""Handle Card 2.0 button callbacks and update the card in place."""
|
||||
try:
|
||||
event_dict = json.loads(lark.JSON.marshal(data))
|
||||
response = self._handle_card_action_event(event_dict.get("event", {}))
|
||||
return P2CardActionTriggerResponse(response)
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
|
||||
return P2CardActionTriggerResponse(
|
||||
{"toast": {"type": "error", "content": "Task update failed"}}
|
||||
)
|
||||
|
||||
# 构建事件分发器
|
||||
event_handler = lark.EventDispatcherHandler.builder("", "") \
|
||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
||||
event_handler = (
|
||||
lark.EventDispatcherHandler.builder("", "")
|
||||
.register_p2_im_message_receive_v1(handle_message_event)
|
||||
.register_p2_card_action_trigger(handle_card_action)
|
||||
.build()
|
||||
)
|
||||
|
||||
def start_client_with_retry():
|
||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||
@@ -470,6 +500,97 @@ class FeiShuChanel(ChatChannel):
|
||||
# so reaching here means the bot was indeed mentioned.
|
||||
return True
|
||||
|
||||
def _get_scheduler_task_store(self):
|
||||
"""Reuse the live scheduler store, with a path-compatible fallback."""
|
||||
from agent.tools.scheduler.integration import get_task_store
|
||||
|
||||
task_store = get_task_store()
|
||||
if task_store is not None:
|
||||
return task_store
|
||||
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
|
||||
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
|
||||
|
||||
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
|
||||
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
|
||||
task_store = self._get_scheduler_task_store()
|
||||
receivers = {feishu_msg.other_user_id}
|
||||
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
|
||||
card = build_scheduler_card(tasks)
|
||||
headers = {
|
||||
"Authorization": "Bearer " + feishu_msg.access_token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
content = json.dumps(card, ensure_ascii=False)
|
||||
|
||||
if is_group and feishu_msg.msg_id:
|
||||
url = (
|
||||
"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||
f"{feishu_msg.msg_id}/reply"
|
||||
)
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json={"msg_type": "interactive", "content": content},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
else:
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
params={"receive_id_type": receive_id_type},
|
||||
json={
|
||||
"receive_id": feishu_msg.other_user_id,
|
||||
"msg_type": "interactive",
|
||||
"content": content,
|
||||
},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
logger.info("[FeiShu] scheduler card sent")
|
||||
return True
|
||||
logger.error(
|
||||
"[FeiShu] scheduler card failed, "
|
||||
f"code={result.get('code')}, msg={result.get('msg')}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _handle_card_action_event(self, event: dict) -> dict:
|
||||
"""Apply a scheduler card action within its chat/operator ownership scope."""
|
||||
action = event.get("action") or {}
|
||||
value = action.get("value") or {}
|
||||
if value.get("cowagent") != "scheduler":
|
||||
return {}
|
||||
|
||||
context = event.get("context") or {}
|
||||
operator = event.get("operator") or {}
|
||||
callback_receivers = {
|
||||
receiver
|
||||
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
|
||||
if receiver
|
||||
}
|
||||
target_receiver = value.get("receiver")
|
||||
allowed_receivers = (
|
||||
{target_receiver}
|
||||
if target_receiver and target_receiver in callback_receivers
|
||||
else set()
|
||||
)
|
||||
response = handle_scheduler_action(
|
||||
value,
|
||||
self._get_scheduler_task_store(),
|
||||
allowed_receivers,
|
||||
)
|
||||
logger.info(
|
||||
"[FeiShu] scheduler card action handled, "
|
||||
f"action={value.get('action')}, task_id={value.get('task_id')}"
|
||||
)
|
||||
return response
|
||||
|
||||
def _handle_message_event(self, event: dict):
|
||||
"""
|
||||
处理消息事件的核心逻辑
|
||||
@@ -521,6 +642,11 @@ class FeiShuChanel(ChatChannel):
|
||||
if not feishu_msg:
|
||||
return
|
||||
|
||||
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
|
||||
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
|
||||
logger.warning("[FeiShu] /tasks card delivery failed")
|
||||
return
|
||||
|
||||
# 处理文件缓存逻辑
|
||||
from channel.file_cache import get_file_cache
|
||||
file_cache = get_file_cache()
|
||||
@@ -591,7 +717,7 @@ class FeiShuChanel(ChatChannel):
|
||||
|
||||
context = self._compose_context(
|
||||
feishu_msg.ctype,
|
||||
feishu_msg.content,
|
||||
feishu_msg.content_with_quote(),
|
||||
isgroup=is_group,
|
||||
msg=feishu_msg,
|
||||
receive_id_type=receive_id_type,
|
||||
@@ -628,7 +754,18 @@ class FeiShuChanel(ChatChannel):
|
||||
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||
reply_content = reply.content
|
||||
content_key = "text"
|
||||
if reply.type == ReplyType.IMAGE_URL:
|
||||
prepared_content_json = None
|
||||
if reply.type == ReplyType.TEXT:
|
||||
# Render Markdown text replies as Feishu cards; falls back to plain text automatically.
|
||||
delivery_text = resolve_markdown_images(
|
||||
reply.content,
|
||||
lambda url: upload_public_image_to_feishu(
|
||||
url,
|
||||
access_token,
|
||||
),
|
||||
)
|
||||
msg_type, prepared_content_json = build_text_delivery(delivery_text)
|
||||
elif reply.type == ReplyType.IMAGE_URL:
|
||||
# 图片上传
|
||||
reply_content = self._upload_image_url(reply.content, access_token)
|
||||
if not reply_content:
|
||||
@@ -690,7 +827,11 @@ class FeiShuChanel(ChatChannel):
|
||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||
|
||||
# Build content JSON
|
||||
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||
content_json = prepared_content_json or (
|
||||
json.dumps(reply_content, ensure_ascii=False)
|
||||
if content_key is None
|
||||
else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||
)
|
||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||
|
||||
if can_reply:
|
||||
@@ -714,10 +855,393 @@ class FeiShuChanel(ChatChannel):
|
||||
res = res.json()
|
||||
if res.get("code") == 0:
|
||||
logger.info(f"[FeiShu] send message success")
|
||||
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
|
||||
logger.warning(
|
||||
"[FeiShu] Markdown card failed, falling back to text, "
|
||||
f"code={res.get('code')}, msg={res.get('msg')}"
|
||||
)
|
||||
fallback_data = {
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
|
||||
}
|
||||
if can_reply:
|
||||
fallback_res = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=fallback_data,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
else:
|
||||
fallback_data["receive_id"] = context.get("receiver")
|
||||
fallback_res = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=fallback_data,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
fallback_body = fallback_res.json()
|
||||
if fallback_body.get("code") == 0:
|
||||
logger.info("[FeiShu] text fallback sent successfully")
|
||||
else:
|
||||
logger.error(
|
||||
"[FeiShu] text fallback failed, "
|
||||
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||
|
||||
def _make_feishu_stream_callback(self, context, access_token):
|
||||
"""Route to detailed or plain streaming callback based on config.
|
||||
|
||||
feishu_detailed_card 默认开启:普通对话使用带状态头、
|
||||
思考/工具面板与耗时的详细卡片。关闭后回退到原有的打字机文本卡片
|
||||
(_make_feishu_stream_callback_plain)。
|
||||
"""
|
||||
if conf().get("feishu_detailed_card", True):
|
||||
return self._make_feishu_stream_callback_progress(context, access_token)
|
||||
return self._make_feishu_stream_callback_plain(context, access_token)
|
||||
|
||||
def _make_feishu_stream_callback_progress(self, context, access_token):
|
||||
"""
|
||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||
|
||||
流程:
|
||||
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
|
||||
随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去
|
||||
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
|
||||
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
|
||||
(流式模式下不受 10 QPS 限制)
|
||||
3. message_end(本轮触发工具调用)→ 原地刷新 Reasoning / Tools 面板,
|
||||
后续 turn 继续复用同一张卡片
|
||||
4. agent_end → 用 final_response、最终状态和耗时整卡更新,关闭 streaming_mode,
|
||||
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
|
||||
|
||||
前提条件:
|
||||
- 机器人已开通 cardkit:card:write 权限
|
||||
- 飞书客户端 7.20+
|
||||
|
||||
失败降级:
|
||||
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
|
||||
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
|
||||
"""
|
||||
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
|
||||
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
|
||||
progress_state = FeishuProgressState()
|
||||
card_id = [None]
|
||||
message_id = [None]
|
||||
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
|
||||
# 事件各自触发一次创建+发送,导致发出多张卡片。
|
||||
init_in_flight = [False]
|
||||
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||
disabled = [False]
|
||||
lock = threading.Lock()
|
||||
|
||||
# ---- 异步推送队列 ----------------------------------------------------
|
||||
# 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。
|
||||
# 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。
|
||||
# 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个
|
||||
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
|
||||
import queue as _queue
|
||||
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
|
||||
|
||||
def _push_worker():
|
||||
while True:
|
||||
snapshot = push_queue.get()
|
||||
if snapshot is None:
|
||||
push_queue.task_done()
|
||||
return
|
||||
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
|
||||
merged_count = 1
|
||||
stop = False
|
||||
while True:
|
||||
try:
|
||||
nxt = push_queue.get_nowait()
|
||||
except _queue.Empty:
|
||||
break
|
||||
merged_count += 1
|
||||
if nxt is None:
|
||||
stop = True
|
||||
break
|
||||
snapshot = nxt
|
||||
try:
|
||||
_stream_update_text(snapshot)
|
||||
finally:
|
||||
for _ in range(merged_count):
|
||||
push_queue.task_done()
|
||||
if stop:
|
||||
return
|
||||
|
||||
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
|
||||
push_thread.start()
|
||||
|
||||
def _drain_push_queue():
|
||||
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain,
|
||||
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
|
||||
try:
|
||||
push_queue.join()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg = context.get("msg")
|
||||
is_group = context.get("isgroup", False)
|
||||
receiver = context.get("receiver")
|
||||
receive_id_type = context.get("receive_id_type", "open_id")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
# 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件
|
||||
ELEMENT_ID = "stream_md"
|
||||
# 操作序号,每次 PUT 必须严格递增(飞书要求)
|
||||
sequence = [0]
|
||||
|
||||
def _next_sequence():
|
||||
with lock:
|
||||
sequence[0] += 1
|
||||
return sequence[0]
|
||||
|
||||
def _build_card_json():
|
||||
"""Build the initial streaming Card 2.0 payload."""
|
||||
with lock:
|
||||
card = progress_state.build_card(streaming=True)
|
||||
return json.dumps(card, ensure_ascii=False)
|
||||
|
||||
def _create_and_send_card():
|
||||
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
|
||||
try:
|
||||
# 步骤 1: 创建卡片实体
|
||||
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
|
||||
create_body = {"type": "card_json", "data": _build_card_json()}
|
||||
res = requests.post(
|
||||
create_url, headers=headers, json=create_body, timeout=(5, 10)
|
||||
)
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: create card failed "
|
||||
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
|
||||
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
|
||||
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
|
||||
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
|
||||
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
return
|
||||
cid = res_json["data"]["card_id"]
|
||||
with lock:
|
||||
card_id[0] = cid
|
||||
|
||||
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send)
|
||||
content_payload = json.dumps(
|
||||
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
|
||||
)
|
||||
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
|
||||
if can_reply:
|
||||
send_url = (
|
||||
f"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||
f"{msg.msg_id}/reply"
|
||||
)
|
||||
send_body = {"msg_type": "interactive", "content": content_payload}
|
||||
send_res = requests.post(
|
||||
send_url, headers=headers, json=send_body, timeout=(5, 10)
|
||||
)
|
||||
else:
|
||||
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
params = {"receive_id_type": receive_id_type}
|
||||
send_body = {
|
||||
"receive_id": receiver,
|
||||
"msg_type": "interactive",
|
||||
"content": content_payload,
|
||||
}
|
||||
send_res = requests.post(
|
||||
send_url, headers=headers, params=params, json=send_body,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
send_json = send_res.json()
|
||||
if send_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
return
|
||||
mid = send_json["data"]["message_id"]
|
||||
with lock:
|
||||
message_id[0] = mid
|
||||
logger.info(
|
||||
f"[FeiShu] Stream: card created and sent, "
|
||||
f"card_id={cid}, message_id={mid}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
finally:
|
||||
with lock:
|
||||
init_in_flight[0] = False
|
||||
|
||||
def _stream_update_text(full_text):
|
||||
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
|
||||
with lock:
|
||||
cid = card_id[0]
|
||||
if not cid:
|
||||
return
|
||||
url = (
|
||||
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
|
||||
f"{cid}/elements/{ELEMENT_ID}/content"
|
||||
)
|
||||
body = {
|
||||
"content": full_text,
|
||||
"sequence": _next_sequence(),
|
||||
}
|
||||
try:
|
||||
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: update text failed: {res_json}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
|
||||
|
||||
def _update_full_card(streaming: bool):
|
||||
"""Refresh panels, header, footer and streaming state in one update."""
|
||||
with lock:
|
||||
cid = card_id[0]
|
||||
full_card = progress_state.build_card(streaming=streaming)
|
||||
if not cid:
|
||||
return
|
||||
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
|
||||
put_body = {
|
||||
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
|
||||
"sequence": _next_sequence(),
|
||||
}
|
||||
try:
|
||||
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: full card update failed: {res_json}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: full card update exception: {e}"
|
||||
)
|
||||
|
||||
def on_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
data = event.get("data", {})
|
||||
|
||||
# 一旦降级,本次回复不再做任何流式操作
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
if event_type == "agent_start":
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if card_id[0] is None and not init_in_flight[0]:
|
||||
init_in_flight[0] = True
|
||||
_create_and_send_card()
|
||||
return
|
||||
|
||||
if event_type in ("turn_start", "reasoning_update"):
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
return
|
||||
|
||||
if event_type == "message_update":
|
||||
delta = data.get("delta", "")
|
||||
if not delta:
|
||||
return
|
||||
|
||||
# 第一段:判断是否需要初始化(创建卡片 + 发送)
|
||||
need_init = False
|
||||
with lock:
|
||||
if card_id[0] is None and not init_in_flight[0]:
|
||||
init_in_flight[0] = True
|
||||
need_init = True
|
||||
|
||||
if need_init:
|
||||
_create_and_send_card()
|
||||
# 初始化失败已标记 disabled,下次循环直接 return
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
# 第二段:累加文本,把快照丢给 push worker 异步推送。
|
||||
# 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk
|
||||
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。
|
||||
snapshot = ""
|
||||
should_push = False
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if card_id[0]:
|
||||
snapshot = progress_state.current_text
|
||||
should_push = True
|
||||
|
||||
if should_push:
|
||||
push_queue.put(snapshot)
|
||||
|
||||
elif event_type in ("tool_execution_start", "tool_execution_end"):
|
||||
# Refresh the Tools panel as each tool starts/finishes so users
|
||||
# see live status and per-tool elapsed time.
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
has_card = card_id[0] is not None
|
||||
if has_card:
|
||||
_drain_push_queue()
|
||||
_update_full_card(streaming=True)
|
||||
|
||||
elif event_type == "message_end":
|
||||
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
||||
tool_calls = data.get("tool_calls", []) or []
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if not tool_calls:
|
||||
return
|
||||
_drain_push_queue()
|
||||
_update_full_card(streaming=True)
|
||||
|
||||
elif event_type == "agent_cancelled":
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
|
||||
elif event_type == "agent_end":
|
||||
# Finalize the same card with a status header and elapsed footer.
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
final_text = progress_state.current_text
|
||||
has_card = card_id[0] is not None
|
||||
init_busy = init_in_flight[0]
|
||||
final_text = resolve_markdown_images(
|
||||
final_text,
|
||||
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||
)
|
||||
with lock:
|
||||
progress_state.current_text = final_text
|
||||
context["feishu_streamed"] = True
|
||||
|
||||
if not has_card and not init_busy:
|
||||
with lock:
|
||||
init_in_flight[0] = True
|
||||
_create_and_send_card()
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
_drain_push_queue()
|
||||
_stream_update_text(final_text)
|
||||
_update_full_card(streaming=False)
|
||||
push_queue.put(None)
|
||||
|
||||
return on_event
|
||||
|
||||
def _make_feishu_stream_callback_plain(self, context, access_token):
|
||||
"""
|
||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||
|
||||
@@ -966,9 +1490,15 @@ class FeiShuChanel(ChatChannel):
|
||||
if not cid:
|
||||
return
|
||||
|
||||
preview_text = final_text
|
||||
final_text = resolve_markdown_images(
|
||||
final_text,
|
||||
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||
)
|
||||
|
||||
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
||||
preview_src = (final_text or "").strip().replace("\n", " ")
|
||||
preview_src = (preview_text or "").strip().replace("\n", " ")
|
||||
preview = preview_src[:30] if preview_src else ""
|
||||
full_card = {
|
||||
"schema": "2.0",
|
||||
@@ -1565,6 +2095,7 @@ class FeishuController:
|
||||
FAILED_MSG = '{"success": false}'
|
||||
SUCCESS_MSG = '{"success": true}'
|
||||
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
||||
CARD_ACTION_TYPE = "card.action.trigger"
|
||||
|
||||
def GET(self):
|
||||
return "Feishu service start success!"
|
||||
@@ -1581,15 +2112,27 @@ class FeishuController:
|
||||
varify_res = {"challenge": request.get("challenge")}
|
||||
return json.dumps(varify_res)
|
||||
|
||||
# 2.消息接收处理
|
||||
# token 校验
|
||||
header = request.get("header")
|
||||
if not header or header.get("token") != channel.feishu_token:
|
||||
# 2. Verify callbacks. Card callbacks may carry the verification
|
||||
# token in event.token while message events carry it in the header.
|
||||
header = request.get("header") or {}
|
||||
event = request.get("event") or {}
|
||||
event_type = header.get("event_type") or request.get("type")
|
||||
callback_token = (
|
||||
header.get("token")
|
||||
or event.get("token")
|
||||
or request.get("token")
|
||||
)
|
||||
if callback_token != channel.feishu_token:
|
||||
return self.FAILED_MSG
|
||||
|
||||
# 处理消息事件
|
||||
event = request.get("event")
|
||||
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
|
||||
if event_type == self.CARD_ACTION_TYPE and event:
|
||||
return json.dumps(
|
||||
channel._handle_card_action_event(event),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 3. Handle message events.
|
||||
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
|
||||
channel._handle_message_event(event)
|
||||
|
||||
return self.SUCCESS_MSG
|
||||
|
||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
||||
self.msg_id = msg.get("message_id")
|
||||
self.create_time = msg.get("create_time")
|
||||
self.is_group = is_group
|
||||
self.quoted_content = ""
|
||||
msg_type = msg.get("message_type")
|
||||
|
||||
if msg_type == "text":
|
||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
||||
|
||||
if self.ctype == ContextType.TEXT:
|
||||
self.quoted_content = self._fetch_quoted_content(msg.get("parent_id"))
|
||||
|
||||
self.from_user_id = sender.get("sender_id").get("open_id")
|
||||
self.to_user_id = event.get("app_id")
|
||||
if is_group:
|
||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
||||
# 私聊
|
||||
self.other_user_id = self.from_user_id
|
||||
self.actual_user_id = self.from_user_id
|
||||
|
||||
def content_with_quote(self) -> str:
|
||||
"""Return user text with optional quoted-message context for the agent."""
|
||||
if not self.quoted_content:
|
||||
return self.content
|
||||
return (
|
||||
"[Quoted message]\n{}\n[/Quoted message]\n\n{}".format(
|
||||
self.quoted_content,
|
||||
self.content,
|
||||
)
|
||||
)
|
||||
|
||||
def _fetch_quoted_content(self, parent_id: str) -> str:
|
||||
"""Fetch one parent message, degrading to an empty quote on failure."""
|
||||
if not parent_id or not self.access_token:
|
||||
return ""
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages/{}".format(parent_id)
|
||||
headers = {"Authorization": "Bearer " + self.access_token}
|
||||
try:
|
||||
response = requests.get(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params={"card_msg_content_type": "raw_card_content"},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch failed, parent_id=%s, status=%s",
|
||||
parent_id,
|
||||
response.status_code,
|
||||
)
|
||||
return ""
|
||||
body = response.json()
|
||||
items = (body.get("data") or {}).get("items") or []
|
||||
if body.get("code") != 0 or not items:
|
||||
return ""
|
||||
return self._extract_quoted_text(items[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch error, parent_id=%s: %s",
|
||||
parent_id,
|
||||
exc,
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_quoted_text(item: dict) -> str:
|
||||
msg_type = item.get("msg_type")
|
||||
raw_content = (item.get("body") or {}).get("content") or ""
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
if msg_type == "text":
|
||||
return str(content.get("text") or "").strip()
|
||||
if msg_type == "post":
|
||||
# Some message-history payloads wrap post content in a locale key.
|
||||
if "content" not in content:
|
||||
localized = next(
|
||||
(value for value in content.values() if isinstance(value, dict)),
|
||||
None,
|
||||
)
|
||||
if localized:
|
||||
content = localized
|
||||
|
||||
parts = []
|
||||
title = str(content.get("title") or "").strip()
|
||||
if title:
|
||||
parts.append(title)
|
||||
for block in content.get("content") or []:
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
for element in block:
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
tag = element.get("tag")
|
||||
text = str(element.get("text") or "").strip()
|
||||
if tag == "text" and text:
|
||||
parts.append(text)
|
||||
elif tag == "a" and text:
|
||||
href = str(element.get("href") or "").strip()
|
||||
parts.append("{} ({})".format(text, href) if href else text)
|
||||
elif tag == "img":
|
||||
parts.append("[Image]")
|
||||
return "\n".join(parts).strip()
|
||||
if msg_type == "image":
|
||||
return "[Image]"
|
||||
if msg_type == "file":
|
||||
return "[File: {}]".format(content.get("file_name") or "file")
|
||||
if msg_type == "audio":
|
||||
return "[Audio]"
|
||||
if msg_type == "media":
|
||||
return "[Video: {}]".format(content.get("file_name") or "video")
|
||||
return ""
|
||||
|
||||
247
channel/feishu/feishu_progress_card.py
Normal file
247
channel/feishu/feishu_progress_card.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""State and Card 2.0 rendering for a Feishu agent run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from common import i18n
|
||||
|
||||
|
||||
_MAX_PANEL_STEPS = 10
|
||||
_MAX_STEP_CHARS = 800
|
||||
|
||||
|
||||
class FeishuProgressState:
|
||||
"""Reduce CowAgent stream events into one renderable Feishu card state."""
|
||||
|
||||
def __init__(self, started_at: Optional[float] = None):
|
||||
self.started_at = time.monotonic() if started_at is None else started_at
|
||||
self.status = "running"
|
||||
self.turns = 0
|
||||
self.current_text = ""
|
||||
self._reasoning_buffer = ""
|
||||
self.reasoning_steps: List[str] = []
|
||||
self.tool_steps: List[Dict[str, Any]] = []
|
||||
self._tool_index: Dict[str, Dict[str, Any]] = {}
|
||||
self.cancelled = False
|
||||
|
||||
def consume(self, event: Dict[str, Any]) -> None:
|
||||
"""Consume one event emitted by ``AgentStreamHandler``."""
|
||||
event_type = event.get("type")
|
||||
data = event.get("data") or {}
|
||||
|
||||
if event_type == "turn_start":
|
||||
self._mark_running_tools_done()
|
||||
turn = data.get("turn")
|
||||
if isinstance(turn, int):
|
||||
self.turns = max(self.turns, turn)
|
||||
else:
|
||||
self.turns += 1
|
||||
if self.turns > 1:
|
||||
self.current_text = ""
|
||||
return
|
||||
|
||||
if event_type == "reasoning_update":
|
||||
self._reasoning_buffer += str(data.get("delta") or "")
|
||||
return
|
||||
|
||||
if event_type == "message_update":
|
||||
self.current_text += str(data.get("delta") or "")
|
||||
return
|
||||
|
||||
if event_type == "message_end":
|
||||
self._commit_reasoning()
|
||||
return
|
||||
|
||||
if event_type == "tool_execution_start":
|
||||
tool_id = data.get("tool_call_id")
|
||||
step = {
|
||||
"summary": str(data.get("tool_name") or "tool"),
|
||||
"status": "running",
|
||||
"started_at": time.monotonic(),
|
||||
"elapsed": None,
|
||||
}
|
||||
self.tool_steps.append(step)
|
||||
if tool_id:
|
||||
self._tool_index[tool_id] = step
|
||||
return
|
||||
|
||||
if event_type == "tool_execution_end":
|
||||
tool_id = data.get("tool_call_id")
|
||||
step = self._tool_index.get(tool_id) if tool_id else None
|
||||
if step is None:
|
||||
# Fall back to the most recent running step when no id match.
|
||||
step = next((s for s in reversed(self.tool_steps) if s["status"] == "running"), None)
|
||||
if step is not None:
|
||||
step["status"] = "error" if data.get("status") not in (None, "success") else "done"
|
||||
elapsed = data.get("execution_time")
|
||||
if elapsed is None and step.get("started_at") is not None:
|
||||
elapsed = time.monotonic() - step["started_at"]
|
||||
step["elapsed"] = elapsed
|
||||
return
|
||||
|
||||
if event_type == "agent_cancelled":
|
||||
self.cancelled = True
|
||||
self.status = "stopped"
|
||||
return
|
||||
|
||||
if event_type == "agent_end":
|
||||
self._commit_reasoning()
|
||||
self._mark_running_tools_done()
|
||||
cancelled = self.cancelled or bool(data.get("cancelled"))
|
||||
if cancelled:
|
||||
self.status = "stopped"
|
||||
self.current_text = self.current_text.rstrip() or "_(stopped)_"
|
||||
else:
|
||||
self.status = "done"
|
||||
final_response = data.get("final_response")
|
||||
if final_response:
|
||||
self.current_text = str(final_response)
|
||||
|
||||
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""Render the current state as a Feishu Card 2.0 object."""
|
||||
# Localized status header text; en/zh/zh-Hant via i18n.t.
|
||||
title, template = {
|
||||
"running": (i18n.t("处理中", "Working"), "blue"),
|
||||
"done": (i18n.t("完成", "Done"), "green"),
|
||||
"stopped": (i18n.t("已停止", "Stopped"), "grey"),
|
||||
"error": (i18n.t("出错", "Error"), "red"),
|
||||
}.get(self.status, (i18n.t("处理中", "Working"), "blue"))
|
||||
|
||||
main_text = self.current_text or "..."
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
# Only render the Reasoning panel when there is real reasoning content.
|
||||
# Upstream emits reasoning_update only when deep thinking is enabled, so
|
||||
# an empty reasoning_steps means we should show no panel at all.
|
||||
if self.reasoning_steps:
|
||||
elements.append(
|
||||
_panel(
|
||||
"🤔 {}".format(i18n.t("思考", "Thinking")),
|
||||
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
||||
expanded=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
if self.tool_steps:
|
||||
elements.append(
|
||||
_panel(
|
||||
"🔧 {} ({})".format(i18n.t("工具", "Tools"), len(self.tool_steps)),
|
||||
[
|
||||
_text_row(_format_tool_step(step))
|
||||
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
|
||||
],
|
||||
expanded=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(
|
||||
{
|
||||
"tag": "markdown",
|
||||
"element_id": "stream_md",
|
||||
"content": main_text,
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
|
||||
turn_label = i18n.t("轮", "turn" if self.turns == 1 else "turns")
|
||||
elements.extend(
|
||||
[
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label),
|
||||
"text_size": "notation",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
config: Dict[str, Any] = {
|
||||
"streaming_mode": streaming,
|
||||
"update_multi": True,
|
||||
"enable_forward_interaction": True,
|
||||
"summary": {"content": _summary(main_text, title)},
|
||||
}
|
||||
if streaming:
|
||||
config["streaming_config"] = {
|
||||
"print_frequency_ms": {"default": 40},
|
||||
"print_step": {"default": 4},
|
||||
"print_strategy": "fast",
|
||||
}
|
||||
|
||||
card: Dict[str, Any] = {
|
||||
"schema": "2.0",
|
||||
"config": config,
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
# Hide the status header once the run has finished successfully; a plain
|
||||
# answer needs no "Done" banner. Keep the header for running/stopped/error
|
||||
# so users still get progress and failure signals.
|
||||
if self.status != "done":
|
||||
card["header"] = {
|
||||
"template": template,
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
}
|
||||
return card
|
||||
|
||||
def _commit_reasoning(self) -> None:
|
||||
reasoning = self._reasoning_buffer.strip()
|
||||
if reasoning:
|
||||
self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:])
|
||||
self._reasoning_buffer = ""
|
||||
|
||||
def _mark_running_tools_done(self) -> None:
|
||||
for step in self.tool_steps:
|
||||
if step["status"] == "running":
|
||||
step["status"] = "done"
|
||||
if step.get("elapsed") is None and step.get("started_at") is not None:
|
||||
step["elapsed"] = time.monotonic() - step["started_at"]
|
||||
|
||||
|
||||
def _tool_status_label(status: str) -> str:
|
||||
if status == "running":
|
||||
return i18n.t("执行中", "running")
|
||||
if status == "error":
|
||||
return i18n.t("失败", "error")
|
||||
return i18n.t("完成", "done")
|
||||
|
||||
|
||||
def _format_tool_step(step: Dict[str, Any]) -> str:
|
||||
# Tool name plus its own status and elapsed time, e.g. "search · done · 1.2s".
|
||||
parts = [str(step.get("summary") or "tool"), _tool_status_label(step["status"])]
|
||||
elapsed = step.get("elapsed")
|
||||
if isinstance(elapsed, (int, float)):
|
||||
parts.append("{:.1f}s".format(max(0.0, float(elapsed))))
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": expanded,
|
||||
"background_color": "grey",
|
||||
# Panel title uses markdown so we can shrink the font via text_size
|
||||
# (plain_text titles ignore text_size and break card rendering).
|
||||
"header": {"title": {"tag": "markdown", "content": title, "text_size": "notation"}},
|
||||
"border": {"color": "grey"},
|
||||
"vertical_spacing": "8px",
|
||||
"padding": "4px 8px",
|
||||
"elements": elements,
|
||||
}
|
||||
|
||||
|
||||
def _text_row(content: str, muted: bool = False) -> Dict[str, Any]:
|
||||
text = {
|
||||
"tag": "plain_text",
|
||||
"content": content,
|
||||
"text_size": "notation",
|
||||
}
|
||||
if muted:
|
||||
text["text_color"] = "grey"
|
||||
return {"tag": "div", "text": text}
|
||||
|
||||
|
||||
def _summary(text: str, fallback: str) -> str:
|
||||
preview = " ".join(text.strip().split())
|
||||
return preview[:60] or fallback
|
||||
185
channel/feishu/feishu_scheduler_card.py
Normal file
185
channel/feishu/feishu_scheduler_card.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Feishu scheduler card rendering and callback handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Set
|
||||
|
||||
|
||||
_MAX_TASKS = 20
|
||||
|
||||
|
||||
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
|
||||
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
|
||||
visible = []
|
||||
for task in tasks:
|
||||
action = task.get("action") or {}
|
||||
if action.get("channel_type") != "feishu":
|
||||
continue
|
||||
if action.get("receiver") in receivers:
|
||||
visible.append(task)
|
||||
return visible
|
||||
|
||||
|
||||
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
|
||||
"""Build a Card 2.0 task list with explicit, idempotent actions."""
|
||||
task_list = list(tasks)
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
if not task_list:
|
||||
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
|
||||
else:
|
||||
for index, task in enumerate(task_list[:_MAX_TASKS]):
|
||||
if index:
|
||||
elements.append({"tag": "hr"})
|
||||
task_id = str(task.get("id") or "")
|
||||
receiver = str((task.get("action") or {}).get("receiver") or "")
|
||||
enabled = task.get("enabled", True)
|
||||
status = "Enabled" if enabled else "Disabled"
|
||||
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
|
||||
elements.append(
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
|
||||
task.get("name") or "Unnamed task",
|
||||
status,
|
||||
task_id,
|
||||
_format_schedule(task.get("schedule") or {}),
|
||||
next_run,
|
||||
),
|
||||
}
|
||||
)
|
||||
toggle_action = "disable" if enabled else "enable"
|
||||
toggle_text = "Disable" if enabled else "Enable"
|
||||
toggle_type = "default" if enabled else "primary"
|
||||
elements.append(
|
||||
{
|
||||
"tag": "column_set",
|
||||
"columns": [
|
||||
{
|
||||
"tag": "column",
|
||||
"elements": [
|
||||
_button(
|
||||
toggle_text,
|
||||
toggle_type,
|
||||
toggle_action,
|
||||
task_id,
|
||||
receiver,
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag": "column",
|
||||
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
hidden = len(task_list) - _MAX_TASKS
|
||||
if hidden > 0:
|
||||
elements.extend(
|
||||
[
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "{} more tasks are hidden.".format(hidden),
|
||||
"text_size": "notation",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"config": {"update_multi": True, "enable_forward_interaction": False},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
|
||||
},
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
|
||||
|
||||
def handle_scheduler_action(
|
||||
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply an owned scheduler action and return a Feishu callback response."""
|
||||
if value.get("cowagent") != "scheduler":
|
||||
return {}
|
||||
|
||||
task_id = str(value.get("task_id") or "")
|
||||
action = value.get("action")
|
||||
if not task_id or action not in {"enable", "disable", "delete"}:
|
||||
return _toast("error", "Invalid scheduler action")
|
||||
|
||||
task = task_store.get_task(task_id)
|
||||
if not task:
|
||||
return _response(
|
||||
"info",
|
||||
"Task no longer exists",
|
||||
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
|
||||
)
|
||||
|
||||
task_receiver = (task.get("action") or {}).get("receiver")
|
||||
task_channel = (task.get("action") or {}).get("channel_type")
|
||||
value_receiver = value.get("receiver")
|
||||
if (
|
||||
task_channel != "feishu"
|
||||
or task_receiver not in allowed_receivers
|
||||
or (value_receiver and value_receiver != task_receiver)
|
||||
):
|
||||
return _toast("error", "Task is not available in this chat")
|
||||
|
||||
try:
|
||||
if action == "delete":
|
||||
task_store.delete_task(task_id)
|
||||
message = "Task deleted"
|
||||
else:
|
||||
enabled = action == "enable"
|
||||
task_store.enable_task(task_id, enabled)
|
||||
message = "Task enabled" if enabled else "Task disabled"
|
||||
except (OSError, ValueError) as exc:
|
||||
return _toast("error", "Task update failed: {}".format(exc))
|
||||
|
||||
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
|
||||
return _response("success", message, build_scheduler_card(visible))
|
||||
|
||||
|
||||
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
|
||||
response = _toast(toast_type, content)
|
||||
response["card"] = {"type": "raw", "data": card}
|
||||
return response
|
||||
|
||||
|
||||
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
|
||||
return {"toast": {"type": toast_type, "content": content}}
|
||||
|
||||
|
||||
def _button(
|
||||
text: str,
|
||||
button_type: str,
|
||||
action: str,
|
||||
task_id: str,
|
||||
receiver: str,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": text},
|
||||
"type": button_type,
|
||||
"value": {
|
||||
"cowagent": "scheduler",
|
||||
"action": action,
|
||||
"task_id": task_id,
|
||||
"receiver": receiver,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _format_schedule(schedule: Dict[str, Any]) -> str:
|
||||
schedule_type = schedule.get("type")
|
||||
if schedule_type == "cron":
|
||||
return "cron {}".format(schedule.get("expression") or "?")
|
||||
if schedule_type == "interval":
|
||||
return "every {}s".format(schedule.get("seconds") or "?")
|
||||
if schedule_type == "once":
|
||||
return "once at {}".format(schedule.get("run_at") or "?")
|
||||
return str(schedule_type or "unknown schedule")
|
||||
218
channel/feishu/feishu_static_card.py
Normal file
218
channel/feishu/feishu_static_card.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from typing import Callable, Optional, Tuple
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
_BLOCK_MARKDOWN = re.compile(
|
||||
r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)"
|
||||
)
|
||||
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
|
||||
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
||||
_MARKDOWN_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\s]+)\)")
|
||||
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
||||
_MAX_REDIRECTS = 3
|
||||
_MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def contains_markdown(text: str) -> bool:
|
||||
"""Return whether *text* contains syntax that benefits from card Markdown."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(
|
||||
_BLOCK_MARKDOWN.search(text)
|
||||
or _INLINE_MARKDOWN.search(text)
|
||||
or _TABLE_SEPARATOR.search(text)
|
||||
)
|
||||
|
||||
|
||||
def build_markdown_card(text: str) -> dict:
|
||||
"""Build an inline Card 2.0 payload with one Markdown element."""
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"config": {},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": text,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_text_delivery(text: str) -> Tuple[str, str]:
|
||||
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
|
||||
if contains_markdown(text):
|
||||
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
||||
return "text", json.dumps({"text": text}, ensure_ascii=False)
|
||||
|
||||
|
||||
def resolve_markdown_images(
|
||||
text: str,
|
||||
uploader: Callable[[str], Optional[str]],
|
||||
max_images: int = 5,
|
||||
) -> str:
|
||||
"""Replace remote Markdown image URLs with Feishu image keys."""
|
||||
cache = {}
|
||||
uploaded = 0
|
||||
|
||||
def replace(match):
|
||||
nonlocal uploaded
|
||||
alt = match.group(1).strip() or "image"
|
||||
target = match.group(2).strip()
|
||||
if target.startswith("img_"):
|
||||
return match.group(0)
|
||||
if urlparse(target).scheme not in ("http", "https"):
|
||||
return match.group(0)
|
||||
|
||||
if target not in cache:
|
||||
if uploaded >= max_images:
|
||||
cache[target] = None
|
||||
else:
|
||||
uploaded += 1
|
||||
try:
|
||||
cache[target] = uploader(target)
|
||||
except Exception:
|
||||
cache[target] = None
|
||||
|
||||
image_key = cache[target]
|
||||
if image_key:
|
||||
return "".format(alt, image_key)
|
||||
return "[Image unavailable: {}]".format(alt)
|
||||
|
||||
return _MARKDOWN_IMAGE.sub(replace, text or "")
|
||||
|
||||
|
||||
def validate_public_image_url(url: str) -> None:
|
||||
"""Reject non-HTTP and non-public image targets before downloading."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("unsupported image URL scheme")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("image URL has no hostname")
|
||||
|
||||
try:
|
||||
literal_address = ipaddress.ip_address(parsed.hostname)
|
||||
resolved_addresses = [literal_address]
|
||||
except ValueError:
|
||||
try:
|
||||
addresses = socket.getaddrinfo(
|
||||
parsed.hostname,
|
||||
parsed.port,
|
||||
socket.AF_UNSPEC,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("cannot resolve image hostname") from exc
|
||||
resolved_addresses = [ipaddress.ip_address(item[4][0]) for item in addresses]
|
||||
|
||||
for address in resolved_addresses:
|
||||
if (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
):
|
||||
raise ValueError("image URL resolves to a non-public address")
|
||||
|
||||
|
||||
def download_public_image(
|
||||
url: str,
|
||||
get=requests.get,
|
||||
max_bytes: int = _MAX_REMOTE_IMAGE_BYTES,
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Download a public image with redirect, type, and size checks."""
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
validate_public_image_url(current)
|
||||
response = get(
|
||||
current,
|
||||
headers={"User-Agent": "CowAgent/Feishu"},
|
||||
timeout=(5, 15),
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if response.status_code in _REDIRECT_CODES:
|
||||
location = response.headers.get("Location")
|
||||
response.close()
|
||||
if not location:
|
||||
raise ValueError("image redirect has no location")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
|
||||
if response.status_code != 200:
|
||||
response.close()
|
||||
raise ValueError("image download returned HTTP {}".format(response.status_code))
|
||||
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
|
||||
if not content_type.startswith("image/"):
|
||||
response.close()
|
||||
raise ValueError("remote resource is not an image")
|
||||
|
||||
try:
|
||||
content_length = int(response.headers.get("Content-Length") or 0)
|
||||
except (TypeError, ValueError):
|
||||
content_length = 0
|
||||
if content_length > max_bytes:
|
||||
response.close()
|
||||
raise ValueError("remote image is too large")
|
||||
|
||||
chunks = []
|
||||
downloaded = 0
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue
|
||||
downloaded += len(chunk)
|
||||
if downloaded > max_bytes:
|
||||
raise ValueError("remote image is too large")
|
||||
chunks.append(chunk)
|
||||
finally:
|
||||
response.close()
|
||||
return b"".join(chunks), content_type
|
||||
|
||||
raise ValueError("too many image redirects")
|
||||
|
||||
|
||||
def upload_public_image_to_feishu(
|
||||
url: str,
|
||||
access_token: str,
|
||||
post=requests.post,
|
||||
) -> Optional[str]:
|
||||
"""Download a public image and upload its bytes to Feishu."""
|
||||
payload, content_type = download_public_image(url)
|
||||
extension = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
}.get(content_type, "img")
|
||||
response = post(
|
||||
"https://open.feishu.cn/open-apis/im/v1/images",
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
data={"image_type": "message"},
|
||||
files={
|
||||
"image": (
|
||||
"markdown-image.{}".format(extension),
|
||||
payload,
|
||||
content_type,
|
||||
)
|
||||
},
|
||||
timeout=(5, 15),
|
||||
)
|
||||
body = response.json()
|
||||
if body.get("code") != 0:
|
||||
return None
|
||||
return (body.get("data") or {}).get("image_key")
|
||||
@@ -36,6 +36,7 @@ TELEGRAM_BOT_COMMANDS = [
|
||||
("help", "Show command help"),
|
||||
("status", "Show running status"),
|
||||
("context", "View/clear conversation context (sub: clear)"),
|
||||
("tasks", "List scheduled tasks for this chat"),
|
||||
("skill", "Manage skills (list/search/install/...)"),
|
||||
("memory", "Manage memory (sub: dream)"),
|
||||
("knowledge", "Manage knowledge base (list/on/off)"),
|
||||
|
||||
@@ -217,6 +217,11 @@ const I18N = {
|
||||
task_delete_btn: '删除任务',
|
||||
task_delete_confirm_title: '删除定时任务',
|
||||
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
|
||||
task_run_now: '立即执行',
|
||||
task_run_confirm_title: '立即执行任务',
|
||||
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||
task_run_started: '已开始执行',
|
||||
task_run_failed: '执行失败',
|
||||
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
|
||||
new_chat: '新对话',
|
||||
@@ -464,6 +469,11 @@ const I18N = {
|
||||
task_delete_btn: '刪除任務',
|
||||
task_delete_confirm_title: '刪除定時任務',
|
||||
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
|
||||
task_run_now: '立即執行',
|
||||
task_run_confirm_title: '立即執行任務',
|
||||
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
|
||||
task_run_started: '已開始執行',
|
||||
task_run_failed: '執行失敗',
|
||||
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
|
||||
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
||||
new_chat: '新對話',
|
||||
@@ -710,6 +720,11 @@ const I18N = {
|
||||
task_delete_btn: 'Delete Task',
|
||||
task_delete_confirm_title: 'Delete Task',
|
||||
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
|
||||
task_run_now: 'Run now',
|
||||
task_run_confirm_title: 'Run task now',
|
||||
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||
task_run_started: 'Run started',
|
||||
task_run_failed: 'Run failed',
|
||||
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
|
||||
new_chat: 'New Chat',
|
||||
@@ -7935,6 +7950,38 @@ function refreshTasksView() {
|
||||
btn.disabled = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function runTaskNow(task, button) {
|
||||
showConfirmDialog({
|
||||
title: t('task_run_confirm_title'),
|
||||
message: `${task.name || task.id}: ${t('task_run_confirm_msg')}`,
|
||||
okText: t('task_run_now'),
|
||||
onConfirm: () => {
|
||||
const originalHtml = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${t('task_run_now')}`;
|
||||
fetch('/api/scheduler/run', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({task_id: task.id})
|
||||
}).then(r => r.json()).then(res => {
|
||||
if (res.status !== 'success') throw new Error(res.message || t('task_run_failed'));
|
||||
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('task_run_started')}`;
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
button.innerHTML = `<i class="fas fa-triangle-exclamation mr-1"></i>${t('task_run_failed')}`;
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHtml;
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadTasksView() {
|
||||
if (tasksLoaded) return;
|
||||
fetch('/api/scheduler').then(r => r.json()).then(data => {
|
||||
@@ -7996,11 +8043,19 @@ function loadTasksView() {
|
||||
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" class="task-run-now px-2 py-1 rounded-md text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-500/10 transition-colors">
|
||||
<i class="fas fa-play mr-1"></i>${t('task_run_now')}
|
||||
</button>
|
||||
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
|
||||
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
|
||||
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>`;
|
||||
const runButton = card.querySelector('.task-run-now');
|
||||
runButton.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
runTaskNow(task, runButton);
|
||||
});
|
||||
const checkbox = card.querySelector('#' + toggleId);
|
||||
checkbox.addEventListener('change', function() {
|
||||
const newEnabled = this.checked;
|
||||
|
||||
@@ -1309,6 +1309,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/scheduler/run', 'SchedulerRunHandler',
|
||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||
@@ -1707,7 +1708,7 @@ class ConfigHandler:
|
||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||
]
|
||||
@@ -1794,7 +1795,7 @@ class ConfigHandler:
|
||||
"api_base_key": "moonshot_base_url",
|
||||
"api_base_default": "https://api.moonshot.cn/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
}),
|
||||
("qianfan", {
|
||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||
@@ -4537,6 +4538,34 @@ class SchedulerHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerRunHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
return json.dumps({"status": "error", "message": "task_id required"})
|
||||
|
||||
from agent.tools.scheduler.integration import get_scheduler_service
|
||||
service = get_scheduler_service()
|
||||
if service is None:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Scheduler service is not running",
|
||||
})
|
||||
|
||||
service.run_task_now(task_id)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"message": f"Task '{task_id}' queued for immediate execution",
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerToggleHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
@@ -4610,11 +4639,33 @@ class SchedulerUpdateHandler:
|
||||
|
||||
# Update action
|
||||
if "action" in body:
|
||||
action = body["action"]
|
||||
channel_type = action.get("channel_type", "web")
|
||||
|
||||
# Get the task's original channel_type
|
||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
||||
original_action = original_task.get("action", {})
|
||||
if not isinstance(original_action, dict):
|
||||
original_action = {}
|
||||
action_patch = body["action"]
|
||||
if not isinstance(action_patch, dict):
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Action must be an object."
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# The Web editor only exposes a subset of action fields. Merge
|
||||
# that patch into the stored action so scheduler metadata such
|
||||
# as notify_session_id, silent, and channel-specific delivery
|
||||
# fields survive unrelated edits.
|
||||
action = dict(original_action)
|
||||
action.update(action_patch)
|
||||
action_type = action.get("type")
|
||||
if action_type == "send_message":
|
||||
action.pop("task_description", None)
|
||||
action.pop("silent", None)
|
||||
elif action_type == "agent_task":
|
||||
action.pop("content", None)
|
||||
|
||||
old_channel = original_action.get("channel_type", "web")
|
||||
channel_type = action.get("channel_type") or old_channel
|
||||
action["channel_type"] = channel_type
|
||||
|
||||
# If channel type changed or no receiver, reject the update.
|
||||
# Note: the web UI disables the channel selector, so this branch
|
||||
|
||||
@@ -7,6 +7,7 @@ from cli.commands.process import start, stop, restart, self_restart, update, sta
|
||||
from cli.commands.context import context
|
||||
from cli.commands.install import install_browser
|
||||
from cli.commands.knowledge import knowledge
|
||||
from cli.commands.backup import backup_command, restore_command
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
||||
@@ -24,6 +25,8 @@ Commands:
|
||||
logs View CowAgent logs.
|
||||
skill Manage CowAgent skills.
|
||||
knowledge Manage knowledge base.
|
||||
backup Back up config and agent workspace.
|
||||
restore Restore a CowAgent backup.
|
||||
install-browser Install browser tool (Playwright + Chromium).
|
||||
|
||||
Tip: Memory index management lives in chat — send /memory status or
|
||||
@@ -74,6 +77,8 @@ main.add_command(status)
|
||||
main.add_command(logs)
|
||||
main.add_command(context)
|
||||
main.add_command(knowledge)
|
||||
main.add_command(backup_command)
|
||||
main.add_command(restore_command)
|
||||
main.add_command(install_browser)
|
||||
|
||||
|
||||
|
||||
332
cli/commands/backup.py
Normal file
332
cli/commands/backup.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Portable local backup and restore commands for CowAgent user data."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable, Optional, Set
|
||||
|
||||
import click
|
||||
|
||||
from cli.utils import get_project_root
|
||||
|
||||
|
||||
BACKUP_FORMAT = "cowagent-backup"
|
||||
BACKUP_VERSION = 1
|
||||
_SKIP_DIRS = {".git", "__pycache__", "tmp"}
|
||||
_SKIP_FILES = {".DS_Store"}
|
||||
|
||||
|
||||
def _data_root() -> Path:
|
||||
configured = os.environ.get("COW_DATA_DIR")
|
||||
return Path(configured).expanduser().resolve() if configured else Path(get_project_root()).resolve()
|
||||
|
||||
|
||||
def _read_config(data_root: Path) -> dict:
|
||||
path = data_root / "config.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _workspace_from_config(config: dict) -> Path:
|
||||
return Path(config.get("agent_workspace") or "~/cow").expanduser().resolve()
|
||||
|
||||
|
||||
def _legacy_user_data_path(data_root: Path, config: dict) -> Path:
|
||||
appdata_dir = config.get("appdata_dir") or ""
|
||||
return (data_root / appdata_dir / "user_datas.pkl").resolve()
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
return os.path.commonpath([str(path.resolve()), str(root.resolve())]) == str(root.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _iter_workspace_files(workspace: Path, excluded: Set[Path]):
|
||||
if not workspace.is_dir():
|
||||
return
|
||||
for current, dirnames, filenames in os.walk(str(workspace), followlinks=False):
|
||||
current_path = Path(current)
|
||||
dirnames[:] = [
|
||||
name for name in dirnames
|
||||
if name not in _SKIP_DIRS and not (current_path / name).is_symlink()
|
||||
]
|
||||
for name in filenames:
|
||||
path = current_path / name
|
||||
if name in _SKIP_FILES or name.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
if path.is_symlink() or path.resolve() in excluded:
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def create_backup_archive(
|
||||
output: Path,
|
||||
data_root: Path,
|
||||
workspace: Path,
|
||||
excluded_paths: Optional[Iterable[Path]] = None,
|
||||
) -> dict:
|
||||
"""Create a portable archive containing config and the agent workspace."""
|
||||
output = Path(output).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
workspace = Path(workspace).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
excluded = {Path(path).expanduser().resolve() for path in (excluded_paths or [])}
|
||||
excluded.add(output)
|
||||
|
||||
config_path = data_root / "config.json"
|
||||
config = _read_config(data_root)
|
||||
legacy_path = _legacy_user_data_path(data_root, config)
|
||||
workspace_files = list(_iter_workspace_files(workspace, excluded))
|
||||
total_bytes = sum(path.stat().st_size for path in workspace_files)
|
||||
|
||||
manifest = {
|
||||
"format": BACKUP_FORMAT,
|
||||
"version": BACKUP_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"workspace_source": str(workspace),
|
||||
"contents": {
|
||||
"config": config_path.is_file(),
|
||||
"legacy_user_data": legacy_path.is_file(),
|
||||
"workspace_files": len(workspace_files),
|
||||
"workspace_bytes": total_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-backup-"))
|
||||
temp_archive = temp_dir / "backup.zip"
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
str(temp_archive), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"manifest.json",
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
)
|
||||
if config_path.is_file():
|
||||
archive.write(str(config_path), "data/config.json")
|
||||
if legacy_path.is_file():
|
||||
archive.write(str(legacy_path), "data/user_datas.pkl")
|
||||
for path in workspace_files:
|
||||
relative = path.relative_to(workspace).as_posix()
|
||||
archive.write(str(path), "workspace/" + relative)
|
||||
os.replace(str(temp_archive), str(output))
|
||||
try:
|
||||
os.chmod(str(output), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
manifest["archive"] = str(output)
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_archive(archive: zipfile.ZipFile) -> dict:
|
||||
names = {info.filename for info in archive.infolist()}
|
||||
if "manifest.json" not in names:
|
||||
raise ValueError("archive is missing manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("archive manifest is invalid") from exc
|
||||
if manifest.get("format") != BACKUP_FORMAT or manifest.get("version") != BACKUP_VERSION:
|
||||
raise ValueError("unsupported CowAgent backup format or version")
|
||||
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
path = PurePosixPath(name)
|
||||
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
|
||||
raise ValueError(f"unsafe archive path: {name!r}")
|
||||
mode = (info.external_attr >> 16) & 0o170000
|
||||
if mode == stat.S_IFLNK:
|
||||
raise ValueError(f"symbolic links are not allowed in backups: {name!r}")
|
||||
if name != "manifest.json" and not name.startswith(("data/", "workspace/")):
|
||||
raise ValueError(f"unexpected archive entry: {name!r}")
|
||||
return manifest
|
||||
|
||||
|
||||
def _extract_validated(archive: zipfile.ZipFile, destination: Path) -> None:
|
||||
for info in archive.infolist():
|
||||
target = destination.joinpath(*PurePosixPath(info.filename).parts)
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(info, "r") as source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, destination: Path, private: bool = False) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=destination.name + ".", dir=str(destination.parent))
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(str(source), temp_name)
|
||||
os.replace(temp_name, str(destination))
|
||||
if private:
|
||||
try:
|
||||
os.chmod(str(destination), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
archive_path: Path,
|
||||
data_root: Path,
|
||||
workspace: Optional[Path] = None,
|
||||
) -> dict:
|
||||
"""Merge a validated backup into the selected data root and workspace."""
|
||||
archive_path = Path(archive_path).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
current_config = _read_config(data_root)
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-restore-"))
|
||||
try:
|
||||
with zipfile.ZipFile(str(archive_path), "r") as archive:
|
||||
manifest = _validate_archive(archive)
|
||||
_extract_validated(archive, temp_dir)
|
||||
|
||||
archived_config_path = temp_dir / "data" / "config.json"
|
||||
archived_config = {}
|
||||
if archived_config_path.is_file():
|
||||
with archived_config_path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("archived config.json must contain an object")
|
||||
archived_config = value
|
||||
|
||||
if workspace is not None:
|
||||
target_workspace = Path(workspace).expanduser().resolve()
|
||||
elif current_config.get("agent_workspace"):
|
||||
target_workspace = _workspace_from_config(current_config)
|
||||
else:
|
||||
# Do not trust an archive-controlled absolute destination on a
|
||||
# fresh machine. Portable restores default to the standard local
|
||||
# workspace unless the operator supplies --workspace.
|
||||
target_workspace = Path("~/cow").expanduser().resolve()
|
||||
|
||||
restored_config = dict(archived_config)
|
||||
if restored_config:
|
||||
restored_config["agent_workspace"] = str(target_workspace)
|
||||
appdata_dir = restored_config.get("appdata_dir") or ""
|
||||
if appdata_dir:
|
||||
archived_appdata = (data_root / appdata_dir).resolve()
|
||||
if not _is_within(archived_appdata, data_root):
|
||||
# Keep legacy user data under the selected data root
|
||||
# instead of writing to an archive-controlled path.
|
||||
restored_config["appdata_dir"] = ""
|
||||
config_temp = temp_dir / "restored-config.json"
|
||||
with config_temp.open("w", encoding="utf-8") as handle:
|
||||
json.dump(restored_config, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
_atomic_copy(config_temp, data_root / "config.json", private=True)
|
||||
|
||||
workspace_root = temp_dir / "workspace"
|
||||
restored_files = 0
|
||||
if workspace_root.is_dir():
|
||||
for source in _iter_workspace_files(workspace_root, set()):
|
||||
relative = source.relative_to(workspace_root)
|
||||
destination = target_workspace / relative
|
||||
if not _is_within(destination, target_workspace):
|
||||
raise ValueError(f"unsafe workspace destination: {relative}")
|
||||
_atomic_copy(source, destination)
|
||||
restored_files += 1
|
||||
|
||||
legacy_source = temp_dir / "data" / "user_datas.pkl"
|
||||
if legacy_source.is_file():
|
||||
effective_config = restored_config or current_config
|
||||
legacy_destination = _legacy_user_data_path(data_root, effective_config)
|
||||
_atomic_copy(legacy_source, legacy_destination, private=True)
|
||||
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"workspace": str(target_workspace),
|
||||
"workspace_files": restored_files,
|
||||
"config_restored": bool(restored_config),
|
||||
"legacy_user_data_restored": legacy_source.is_file(),
|
||||
}
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
|
||||
@click.command("backup")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Output .zip path (default: ./cow-backup-<timestamp>.zip).",
|
||||
)
|
||||
def backup_command(output: Optional[Path]):
|
||||
"""Back up config, persona, memory, skills, knowledge, and schedules."""
|
||||
data_root = _data_root()
|
||||
config = _read_config(data_root)
|
||||
workspace = _workspace_from_config(config)
|
||||
if output is None:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output = Path.cwd() / f"cow-backup-{stamp}.zip"
|
||||
result = create_backup_archive(output, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup created", fg="green"))
|
||||
click.echo(f" Archive: {result['archive']}")
|
||||
click.echo(f" Workspace files: {result['contents']['workspace_files']}")
|
||||
click.echo(" Keep this archive private: it may contain API keys and personal data.")
|
||||
|
||||
|
||||
@click.command("restore")
|
||||
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--workspace",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
help="Restore workspace files to this directory.",
|
||||
)
|
||||
@click.option("--yes", is_flag=True, help="Confirm overwriting matching files.")
|
||||
def restore_command(archive: Path, workspace: Optional[Path], yes: bool):
|
||||
"""Restore a backup without deleting unrelated destination files."""
|
||||
from cli.commands.process import _read_pid
|
||||
|
||||
pid = _read_pid()
|
||||
if pid:
|
||||
raise click.ClickException(
|
||||
f"CowAgent is running (PID: {pid}). Run 'cow stop' before restoring."
|
||||
)
|
||||
if not yes:
|
||||
click.confirm(
|
||||
"Restore this archive and overwrite matching config/workspace files?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
data_root = _data_root()
|
||||
current_config = _read_config(data_root)
|
||||
current_workspace = _workspace_from_config(current_config)
|
||||
has_current_data = (data_root / "config.json").is_file() or current_workspace.is_dir()
|
||||
if has_current_data:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
rollback = archive.resolve().parent / f"cow-pre-restore-{stamp}.zip"
|
||||
create_backup_archive(
|
||||
rollback,
|
||||
data_root,
|
||||
current_workspace,
|
||||
excluded_paths={archive.resolve()},
|
||||
)
|
||||
click.echo(f"Rollback backup: {rollback}")
|
||||
|
||||
result = restore_backup_archive(archive, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup restored", fg="green"))
|
||||
click.echo(f" Workspace: {result['workspace']}")
|
||||
click.echo(f" Restored files: {result['workspace_files']}")
|
||||
@@ -142,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT = "moonshot"
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
||||
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
|
||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||
KIMI_K2 = "kimi-k2"
|
||||
KIMI_K2_5 = "kimi-k2.5"
|
||||
@@ -235,7 +236,7 @@ MODEL_LIST = [
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
|
||||
# ModelScope
|
||||
MODELSCOPE,
|
||||
|
||||
@@ -42,7 +42,5 @@
|
||||
"reasoning_effort": "high",
|
||||
"knowledge": true,
|
||||
"self_evolution_enabled": true,
|
||||
"mcp_tool_retrieval_enabled": false,
|
||||
"mcp_tool_retrieval_threshold": 20,
|
||||
"mcp_tool_retrieval_top_k": 10
|
||||
"mcp_tool_retrieval_enabled": false
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ available_setting = {
|
||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||
"feishu_detailed_card": True, # render normal chat streaming as a detailed card (status header, thinking/tool panels, elapsed time); off keeps the plain typewriter card
|
||||
# DingTalk config
|
||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
||||
|
||||
let getWindow: () => BrowserWindow | null = () => null
|
||||
|
||||
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||
// flag, so the same source serves the right feed on whatever it runs on.
|
||||
function isLegacyWindows(): boolean {
|
||||
if (process.platform !== 'win32') return false
|
||||
const major = Number((os.release() || '').split('.')[0])
|
||||
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||
return Number.isFinite(major) && major < 10
|
||||
}
|
||||
|
||||
// The update feed. Both entries hit the same Pages Function
|
||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||
// identical either way, so we can freely switch the feed URL between attempts
|
||||
// to fall back from one download origin to the other.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
||||
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||
|
||||
// Which origin the current session prefers, derived from the app UI language
|
||||
|
||||
@@ -372,6 +372,13 @@ class ApiClient {
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
async runTask(taskId: string): Promise<ApiResult> {
|
||||
return this.request('/api/scheduler/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
}
|
||||
|
||||
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
||||
return this.request('/api/scheduler/toggle', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -100,16 +100,38 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string
|
||||
/**
|
||||
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||
* the knowledge viewer so index links open the target doc rather than firing
|
||||
* an "application cannot be opened (-120)" error in Electron.
|
||||
*/
|
||||
onInternalLink?: (href: string) => void
|
||||
}
|
||||
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const html = useMemo(() => md.render(content || ''), [content])
|
||||
|
||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||
if (onInternalLink) {
|
||||
const a = target.closest('a') as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
const href = a.getAttribute('href') || ''
|
||||
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||
e.preventDefault()
|
||||
onInternalLink(href)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||
if (!btn) return
|
||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||
@@ -122,7 +144,9 @@ const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
btn.textContent = original
|
||||
btn.classList.remove('copied')
|
||||
}, 1600)
|
||||
}, [])
|
||||
},
|
||||
[onInternalLink]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
muted, separated from the final answer by a dashed divider. */}
|
||||
{(hasSteps || hasLiveReasoning) && (
|
||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||
must render after all committed steps (tools/thinking), not at
|
||||
the very top of the bubble. */}
|
||||
{hasLiveReasoning && (
|
||||
<div className={hasSteps ? 'mt-1' : ''}>
|
||||
<ThinkingStep content={message.reasoning!} streaming />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
||||
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||
import type { MessageStep } from '../types'
|
||||
import { t } from '../i18n'
|
||||
import Markdown from './Markdown'
|
||||
|
||||
/**
|
||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Brain size={12} className="flex-shrink-0" />
|
||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
||||
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{expanded && (
|
||||
|
||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: '已中止',
|
||||
msg_self_learned: '自主学习',
|
||||
msg_stop: '停止',
|
||||
thinking_in_progress: '思考中…',
|
||||
thinking_done: '已深度思考',
|
||||
chat_clear_context: '清除上下文',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
chat_load_earlier: '加载更早的消息',
|
||||
chat_send: '发送',
|
||||
chat_attach: '添加附件',
|
||||
slash_hint: '输入 / 查看命令',
|
||||
chat_welcome: '有什么可以帮你的?',
|
||||
chat_empty_hint: '发送一条消息开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '查看工作空间里有哪些文件',
|
||||
example_task_title: '定时任务',
|
||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: '已连接',
|
||||
channels_available_section: '可添加',
|
||||
channels_empty_connected: '暂无已连接的通道',
|
||||
channels_empty: '暂未接入任何通道',
|
||||
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||
channels_save_ok: '已保存',
|
||||
channels_save_error: '保存失败',
|
||||
@@ -347,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
task_delete: '删除',
|
||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||
task_save_error: '保存失败',
|
||||
task_run_now: '立即执行',
|
||||
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||
task_run_started: '任务已开始执行',
|
||||
task_run_error: '执行失败',
|
||||
logs_title: '日志',
|
||||
logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时',
|
||||
@@ -512,7 +521,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: 'Cancelled',
|
||||
msg_self_learned: 'Self-learned',
|
||||
msg_stop: 'Stop',
|
||||
thinking_in_progress: 'Thinking…',
|
||||
thinking_done: 'Thought',
|
||||
chat_clear_context: 'Clear context',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
chat_load_earlier: 'Load earlier messages',
|
||||
chat_send: 'Send',
|
||||
chat_attach: 'Attach file',
|
||||
@@ -671,6 +683,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: 'Connected',
|
||||
channels_available_section: 'Available',
|
||||
channels_empty_connected: 'No connected channels yet',
|
||||
channels_empty: 'No channels connected',
|
||||
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||
channels_save_ok: 'Saved',
|
||||
channels_save_error: 'Failed to save',
|
||||
@@ -730,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
task_delete: 'Delete',
|
||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||
task_save_error: 'Failed to save',
|
||||
task_run_now: 'Run now',
|
||||
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||
task_run_started: 'Task run started',
|
||||
task_run_error: 'Failed to run task',
|
||||
logs_title: 'Logs',
|
||||
logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live',
|
||||
|
||||
@@ -27,21 +27,21 @@
|
||||
--danger-border: rgba(239, 68, 68, 0.3);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Light theme — layered neutral surfaces */
|
||||
--bg-base: #fafafa; /* app background */
|
||||
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||
--bg-surface: #ffffff; /* panels, cards */
|
||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
||||
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
||||
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||
|
||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
||||
--text-secondary: #52525b; /* body, labels */
|
||||
--text-tertiary: #71717a; /* hints, captions */
|
||||
--text-disabled: #a1a1aa;
|
||||
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||
--text-disabled: #94a3b8; /* slate-400 */
|
||||
|
||||
--border-default: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-subtle: #f0f0f1;
|
||||
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||
--border-strong: #d1d5db; /* gray-300 */
|
||||
--border-subtle: #f3f4f6; /* gray-100 */
|
||||
|
||||
--overlay: rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Headset,
|
||||
Hash,
|
||||
AtSign,
|
||||
RadioTower,
|
||||
} from 'lucide-react'
|
||||
import { t, localizedLabel } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{connected.length === 0 && !addOpen ? (
|
||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||
<RadioTower size={26} className="text-info" />
|
||||
</span>
|
||||
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||
{t('channels_empty_desc')}
|
||||
</p>
|
||||
{available.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<Btn variant="primary" onClick={openAdd}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Plus size={15} />
|
||||
{t('channels_add')}
|
||||
</span>
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
||||
import type { Attachment, ChatMessage } from '../types'
|
||||
import { useChatStore } from '../store/chatStore'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
|
||||
interface ChatPageProps {
|
||||
baseUrl: string
|
||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||
const clearContext = useChatStore((s) => s.clearContext)
|
||||
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||
|
||||
const messages = session?.messages ?? []
|
||||
const isStreaming = session?.isStreaming ?? false
|
||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const id = newSession()
|
||||
ensureSession(id)
|
||||
loadHistory(id, 1)
|
||||
}, [newSession, ensureSession, loadHistory])
|
||||
// Auto-expand the session list so the user sees the new/switched session.
|
||||
setSessionsCollapsed(false)
|
||||
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||
|
||||
const handleClearContext = useCallback(async () => {
|
||||
try {
|
||||
await apiClient.clearContext(activeId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [activeId])
|
||||
await clearContext(activeId)
|
||||
scrollToBottom(true)
|
||||
}, [clearContext, activeId, scrollToBottom])
|
||||
|
||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||
|
||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||
{t('welcome_subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||
@@ -274,7 +278,20 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-3 max-w-3xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
{messages.map((msg) =>
|
||||
msg.kind === 'divider' ? (
|
||||
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||
<span
|
||||
className="flex-1 h-px"
|
||||
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||
/>
|
||||
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||
<span
|
||||
className="flex-1 h-px"
|
||||
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
@@ -283,7 +300,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
onDelete={handleDelete}
|
||||
onMediaLoad={handleMediaLoad}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||
function stripDuplicateH1(content: string, title: string): string {
|
||||
if (!content) return content
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||
if (!m) return content
|
||||
if (norm(m[1]) !== norm(title)) return content
|
||||
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||
}
|
||||
|
||||
// Flatten the tree into category paths (for destination selectors).
|
||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||
const paths: string[] = []
|
||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
||||
return null
|
||||
}
|
||||
|
||||
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||
for (const f of list.root_files || []) {
|
||||
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||
}
|
||||
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||
for (const f of dir.files) {
|
||||
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||
}
|
||||
for (const c of dir.children) {
|
||||
const hit = walk(c, dirPath)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
for (const d of list.tree || []) {
|
||||
const hit = walk(d, '')
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||
const clean = href.split('#')[0].split('?')[0]
|
||||
if (!clean.endsWith('.md')) return null
|
||||
if (clean.startsWith('knowledge/')) {
|
||||
const path = clean.replace(/^knowledge\//, '')
|
||||
return { path, title: findTitle(list, path) }
|
||||
}
|
||||
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||
return { path: clean, title: findTitle(list, clean) }
|
||||
}
|
||||
// Relative/other path: fall back to matching by filename.
|
||||
const filename = clean.split('/').pop() || clean
|
||||
return findFileByName(list, filename)
|
||||
}
|
||||
|
||||
// Resolve a document's display title from its path, falling back to the stem.
|
||||
function findTitle(list: KnowledgeList, path: string): string {
|
||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
setContent('')
|
||||
try {
|
||||
const res = await apiClient.readKnowledge(path)
|
||||
setContent(res.content || '')
|
||||
setContent(stripDuplicateH1(res.content || '', title))
|
||||
} catch {
|
||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||
} finally {
|
||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||
// Falls back silently when the target can't be resolved in the current tree.
|
||||
const openInternalLink = useCallback(
|
||||
(href: string) => {
|
||||
if (!data) return
|
||||
const hit = resolveKnowledgeLink(data, href)
|
||||
if (hit) void openDoc(hit.path, hit.title)
|
||||
},
|
||||
[data, openDoc]
|
||||
)
|
||||
|
||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||
// currently open doc (or open the first one on the initial load).
|
||||
const refresh = useCallback(
|
||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
<Loader2 size={16} className="animate-spin mr-2" />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown content={content} />
|
||||
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Loader2, Clock, CalendarClock } from 'lucide-react'
|
||||
import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
|
||||
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [runStatus, setRunStatus] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const buildSchedule = (): TaskSchedule => {
|
||||
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
if (!window.confirm(t('task_run_confirm'))) return
|
||||
setRunning(true)
|
||||
setRunStatus('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await apiClient.runTask(task.id)
|
||||
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
|
||||
setRunStatus(t('task_run_started'))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('task_run_error'))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
|
||||
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||
{t('task_delete')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
|
||||
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
|
||||
{t('task_run_now')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t('task_cancel')}
|
||||
</Btn>
|
||||
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
|
||||
)}
|
||||
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
||||
|
||||
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ interface ChatState {
|
||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||
|
||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||
clearContext: (sid: string) => Promise<boolean>
|
||||
clearLocal: (sid: string) => void
|
||||
}
|
||||
|
||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
cancel: async (sid) => {
|
||||
const s = get().sessions[sid]
|
||||
if (!s?.requestId) return
|
||||
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||
// cancelled, free the input, and tear down the local SSE stream so no
|
||||
// further deltas render after the user hit stop. The backend still gets
|
||||
// the cancel request to abort the running agent task.
|
||||
patchMessages(sid, (msgs) => {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'assistant') {
|
||||
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||
break
|
||||
}
|
||||
}
|
||||
return [...msgs]
|
||||
})
|
||||
patchSession(sid, { isStreaming: false, requestId: null })
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
es.close()
|
||||
delete streams[sid]
|
||||
}
|
||||
try {
|
||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||
} catch {
|
||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
clearContext: async (sid) => {
|
||||
try {
|
||||
const res = await apiClient.clearContext(sid)
|
||||
if (res.status !== 'success') return false
|
||||
// Append a visual divider so the user sees the context was cleared
|
||||
// (mirrors the web console's context-divider).
|
||||
patchMessages(sid, (msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
id: uid('divider'),
|
||||
role: 'system',
|
||||
kind: 'divider',
|
||||
content: '',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
clearLocal: (sid) => {
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UIState {
|
||||
/** Session list panel collapsed (hidden) vs expanded. */
|
||||
sessionsCollapsed: boolean
|
||||
toggleSessions: () => void
|
||||
setSessionsCollapsed: (v: boolean) => void
|
||||
|
||||
/** Currently active session id (Chat page). */
|
||||
activeSessionId: string | null
|
||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||
return { sessionsCollapsed: next }
|
||||
}),
|
||||
setSessionsCollapsed: (v) => {
|
||||
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||
set({ sessionsCollapsed: v })
|
||||
},
|
||||
|
||||
activeSessionId: null,
|
||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
||||
// Chat / messages / streaming
|
||||
// ============================================================
|
||||
|
||||
export type Role = 'user' | 'assistant'
|
||||
export type Role = 'user' | 'assistant' | 'system'
|
||||
|
||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||
export interface MessageStep {
|
||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
||||
/** Sequence numbers from backend (for delete/regenerate). */
|
||||
userSeq?: number
|
||||
botSeq?: number
|
||||
/** Self-evolution bubble flag. */
|
||||
kind?: 'evolution'
|
||||
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||
kind?: 'evolution' | 'divider'
|
||||
extras?: Record<string, unknown>
|
||||
isStreaming?: boolean
|
||||
isCancelled?: boolean
|
||||
|
||||
@@ -73,6 +73,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| `feishu_app_id` | Feishu app App ID | - |
|
||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||
| `feishu_detailed_card` | Use a detailed card (tool calls, thinking process, elapsed time) for streaming replies; off keeps the plain typewriter card | `true` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -84,7 +85,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
|
||||
2. Click **Add Event**, search for "Receive Message" and choose **Receive Message v2.0**.
|
||||
|
||||
3. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
||||
3. (Optional) Under **Callbacks**, add **Card Action Trigger** (`card.action.trigger`) to use the `/tasks` controls. Only needed for `/tasks` scheduler management; skip it otherwise.
|
||||
|
||||
4. Click **Version Management & Release**, create a version and apply for **Production Release**. Approve the request in the Feishu client:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||
|
||||
@@ -97,7 +100,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| Text messages | ✅ send/receive |
|
||||
| Image messages | ✅ send/receive |
|
||||
| Voice messages | ✅ send/receive |
|
||||
| Quoted replies | ✅ quoted text and rich-post context |
|
||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||
| Markdown card | ✅ remote images are uploaded to Feishu for static and final streaming cards |
|
||||
| Detailed card | ✅ tool calls, thinking process and elapsed time (controlled by `feishu_detailed_card`, on by default) |
|
||||
| Scheduler controls | ✅ `/tasks` list with enable, disable and delete buttons |
|
||||
|
||||
<Note>
|
||||
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
||||
@@ -108,3 +115,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
After connection, search for the bot name in Feishu to start a chat.
|
||||
|
||||
To use in groups, add the bot to a group and @-mention it.
|
||||
|
||||
Send `/tasks` in a private chat, or @-mention the bot with `/tasks` in a group, to manage tasks belonging to that chat.
|
||||
|
||||
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.
|
||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
||||
memory Memory distillation (dream)
|
||||
knowledge View knowledge base stats and structure
|
||||
|
||||
Data portability:
|
||||
backup Back up config and agent workspace
|
||||
restore Restore a CowAgent backup
|
||||
|
||||
Others:
|
||||
help Show this help message
|
||||
version Show version
|
||||
@@ -90,6 +94,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
||||
| start / stop / restart | ✓ | ✗ |
|
||||
| update | ✓ | ✗ |
|
||||
| install-browser | ✓ | ✗ |
|
||||
| backup / restore | ✓ | ✗ |
|
||||
|
||||
<Note>
|
||||
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
"cli/process",
|
||||
"cli/skill",
|
||||
"cli/memory-knowledge",
|
||||
"cli/backup",
|
||||
"cli/general"
|
||||
]
|
||||
}
|
||||
@@ -473,6 +474,7 @@
|
||||
"zh/cli/process",
|
||||
"zh/cli/skill",
|
||||
"zh/cli/memory-knowledge",
|
||||
"zh/cli/backup",
|
||||
"zh/cli/general"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
|
||||
@@ -70,6 +70,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| `feishu_app_id` | Feishu アプリの App ID | - |
|
||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||
| `feishu_detailed_card` | ストリーミング応答を詳細カード(ツール呼び出し、思考過程、経過時間)で表示。無効時は通常のタイプライターカード | `true` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -81,7 +82,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
|
||||
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
||||
|
||||
3. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||
3. (任意)**コールバック** で **カードアクショントリガー**(`card.action.trigger`)を追加すると `/tasks` の操作ボタンを使用できます。`/tasks` のスケジューラー管理を使う場合のみ必要で、それ以外はスキップできます。
|
||||
|
||||
4. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||
|
||||
@@ -94,7 +97,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| テキストメッセージ | ✅ 送受信 |
|
||||
| 画像メッセージ | ✅ 送受信 |
|
||||
| 音声メッセージ | ✅ 送受信 |
|
||||
| 引用返信 | ✅ 引用テキストとリッチテキストのコンテキスト |
|
||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||
| Markdown カード | ✅ 静的カードとストリーミング最終状態でリモート画像を Feishu にアップロード |
|
||||
| 詳細カード | ✅ ツール呼び出し、思考過程、経過時間(`feishu_detailed_card` で制御、デフォルト有効) |
|
||||
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||
|
||||
<Note>
|
||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||
@@ -105,3 +112,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
||||
|
||||
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
||||
|
||||
個人チャットでは `/tasks`、グループでは Bot に @メンションして `/tasks` を送ると、そのチャットのタスクを管理できます。
|
||||
|
||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||
|
||||
<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 で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||
</Note>
|
||||
@@ -26,7 +26,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Kimi
|
||||
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
||||
---
|
||||
|
||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k3` と `kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||
|
||||
<Tip>
|
||||
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "kimi-k2.7-code",
|
||||
"model": "kimi-k3",
|
||||
"moonshot_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| パラメータ | 説明 |
|
||||
| --- | --- |
|
||||
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||
| `model` | `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
||||
|
||||
|
||||
@@ -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 | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Kimi
|
||||
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
||||
---
|
||||
|
||||
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision.
|
||||
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k3` and `kimi-k2.x` series natively support vision.
|
||||
|
||||
<Tip>
|
||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "kimi-k2.7-code",
|
||||
"model": "kimi-k3",
|
||||
"moonshot_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||
| `model` | Can be `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
||||
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ Create and manage scheduled tasks with natural language:
|
||||
- "Remind me about the meeting tomorrow at 3 PM"
|
||||
- "Show all scheduled tasks"
|
||||
|
||||
## Test-run an existing task
|
||||
|
||||
Open the task in the authenticated Web console or Desktop app and click **Run
|
||||
now**. After you confirm the delivery, CowAgent queues one immediate execution
|
||||
to the task's configured channel and receiver. This user-initiated action is not
|
||||
exposed to the agent's scheduler tool.
|
||||
|
||||
Manual execution works for disabled tasks and does not enable, delete, or
|
||||
reschedule the task. Its original `next_run_at` remains unchanged. A task cannot
|
||||
be run manually while the same task is already executing on schedule.
|
||||
|
||||
<Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||
</Frame>
|
||||
|
||||
@@ -114,7 +114,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
||||
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -114,7 +114,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -74,6 +74,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| `feishu_app_id` | 飞书应用 App ID | - |
|
||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||
| `feishu_detailed_card` | 流式回复是否使用详细卡片(工具调用、思考过程、耗时展示);关闭时用普通打字机卡片 | `true` |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -85,7 +86,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
|
||||
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
||||
|
||||
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||
3. (可选)在 **回调** 中添加 **卡片回传交互**(`card.action.trigger`),以启用 `/tasks` 操作按钮。仅使用 `/tasks` 定时任务管理时需要,否则可跳过。
|
||||
|
||||
4. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||
|
||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||
|
||||
@@ -98,7 +101,11 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
| 文本消息 | ✅ 收发 |
|
||||
| 图片消息 | ✅ 收发 |
|
||||
| 语音消息 | ✅ 收发 |
|
||||
| 引用回复 | ✅ 引用文本与富文本上下文 |
|
||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||
| Markdown 卡片 | ✅ 静态卡片与流式最终态会将远程图片上传到飞书 |
|
||||
| 详细卡片 | ✅ 工具调用、思考过程与耗时展示(`feishu_detailed_card` 控制,默认开启) |
|
||||
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||
|
||||
<Note>
|
||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||
@@ -109,3 +116,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
||||
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
||||
|
||||
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
||||
|
||||
私聊发送 `/tasks`,或在群聊中 @机器人并发送 `/tasks`,即可管理属于当前会话的定时任务。
|
||||
|
||||
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` 确认覆盖。
|
||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
||||
memory Memory distillation (dream)
|
||||
knowledge View knowledge base stats and structure
|
||||
|
||||
Data portability:
|
||||
backup Back up config and agent workspace
|
||||
restore Restore a CowAgent backup
|
||||
|
||||
Others:
|
||||
help Show this help message
|
||||
version Show version
|
||||
@@ -92,6 +96,7 @@ Others:
|
||||
| start / stop / restart | ✓ | ✗ |
|
||||
| update | ✓ | ✗ |
|
||||
| install-browser | ✓ | ✗ |
|
||||
| backup / restore | ✓ | ✗ |
|
||||
|
||||
<Note>
|
||||
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
|
||||
|
||||
@@ -20,7 +20,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Kimi](/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Kimi
|
||||
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
||||
---
|
||||
|
||||
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。
|
||||
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k3` 与 `kimi-k2.x` 系列原生支持视觉。
|
||||
|
||||
<Tip>
|
||||
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
||||
@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "kimi-k2.7-code",
|
||||
"model": "kimi-k3",
|
||||
"moonshot_api_key": "YOUR_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `model` | 可填 `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
|
||||
| `model` | 可填 `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
|
||||
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
||||
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
||||
|
||||
|
||||
76
docs/zh/releases/v2.1.4.mdx
Normal file
76
docs/zh/releases/v2.1.4.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
## 🛠 体验优化与修复
|
||||
|
||||
- **文件编辑**:修复编辑文件时模糊匹配可能定位到错误位置的问题。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 点提醒我开会"
|
||||
- "查看所有定时任务"
|
||||
|
||||
## 测试执行已有任务
|
||||
|
||||
在经过身份认证的 Web 控制台或 Desktop 应用中打开任务,点击 **立即执行**。确认发送后,CowAgent 会立即按任务已配置的通道和接收者执行一次。这个入口只允许用户手动触发,不会暴露给 Agent 的 scheduler tool。
|
||||
|
||||
禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行。
|
||||
|
||||
<Frame>
|
||||
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />
|
||||
</Frame>
|
||||
|
||||
@@ -61,7 +61,7 @@ class MoonshotBot(Bot):
|
||||
m = model_name.lower()
|
||||
if cls._is_builtin_reasoning_model(m):
|
||||
return False
|
||||
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
||||
return m.startswith("kimi-k3") or m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
||||
|
||||
def _build_headers(self) -> dict:
|
||||
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
||||
|
||||
@@ -35,7 +35,7 @@ KNOWN_COMMANDS = {
|
||||
"help", "version", "status", "logs",
|
||||
"start", "stop", "restart",
|
||||
"cancel",
|
||||
"skill", "context", "config",
|
||||
"skill", "context", "config", "tasks",
|
||||
"knowledge", "memory",
|
||||
"install-browser",
|
||||
}
|
||||
@@ -357,6 +357,7 @@ class CowCliPlugin(Plugin):
|
||||
"/logs [N]: Show the last N log lines (default 20)",
|
||||
"/context: Show current conversation context",
|
||||
"/context clear: Clear current conversation context",
|
||||
"/tasks: List scheduled tasks for this chat",
|
||||
"/skill list: List installed skills",
|
||||
"/skill list --remote: Browse Skill Hub",
|
||||
"/skill search <keyword>: Search skills",
|
||||
@@ -385,6 +386,7 @@ class CowCliPlugin(Plugin):
|
||||
"/logs [N]: 查看最近N条日志 (默认20)",
|
||||
"/context: 查看当前对话上下文信息",
|
||||
"/context clear: 清除当前对话上下文",
|
||||
"/tasks: 查看当前会话的定时任务",
|
||||
"/skill list: 查看已安装的技能",
|
||||
"/skill list --remote: 浏览技能广场",
|
||||
"/skill search <关键词>: 搜索技能",
|
||||
@@ -407,6 +409,85 @@ class CowCliPlugin(Plugin):
|
||||
def _cmd_version(self, args: str, e_context, **_) -> str:
|
||||
return f"CowAgent v{__version__}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tasks — read-only scheduler list scoped to the current chat.
|
||||
# Feishu intercepts /tasks before this plugin to keep its interactive card;
|
||||
# every other channel receives this plain-text view.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cmd_tasks(self, args: str, e_context, session_id: str = "", **_) -> str:
|
||||
from agent.tools.scheduler.integration import get_task_store
|
||||
|
||||
task_store = get_task_store()
|
||||
if task_store is None:
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
from common.utils import expand_path
|
||||
|
||||
workspace = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
task_store = TaskStore(os.path.join(workspace, "scheduler", "tasks.json"))
|
||||
|
||||
channel_type = ""
|
||||
receiver = ""
|
||||
if e_context is not None:
|
||||
context = e_context["context"]
|
||||
channel_type = context.get("channel_type", "") or ""
|
||||
receiver = context.get("receiver", "") or ""
|
||||
session_id = context.get("session_id", "") or session_id
|
||||
|
||||
visible = []
|
||||
for task in task_store.list_tasks():
|
||||
action = task.get("action") or {}
|
||||
if receiver:
|
||||
if action.get("receiver") != receiver:
|
||||
continue
|
||||
if channel_type and action.get("channel_type") != channel_type:
|
||||
continue
|
||||
elif session_id not in {
|
||||
action.get("receiver"),
|
||||
action.get("notify_session_id"),
|
||||
}:
|
||||
continue
|
||||
visible.append(task)
|
||||
|
||||
return self._format_tasks(visible)
|
||||
|
||||
@staticmethod
|
||||
def _format_tasks(tasks) -> str:
|
||||
if not tasks:
|
||||
return _t(
|
||||
"📅 当前会话暂无定时任务。",
|
||||
"📅 No scheduled tasks in this chat.",
|
||||
)
|
||||
|
||||
lines = [_t("📅 当前会话的定时任务", "📅 Scheduled tasks in this chat"), ""]
|
||||
for index, task in enumerate(tasks[:20], 1):
|
||||
enabled = task.get("enabled", True)
|
||||
status = "✅" if enabled else "⏸️"
|
||||
schedule = task.get("schedule") or {}
|
||||
schedule_type = schedule.get("type")
|
||||
if schedule_type == "cron":
|
||||
schedule_text = "cron {}".format(schedule.get("expression") or "?")
|
||||
elif schedule_type == "interval":
|
||||
schedule_text = "every {}s".format(schedule.get("seconds") or "?")
|
||||
elif schedule_type == "once":
|
||||
schedule_text = "once at {}".format(schedule.get("run_at") or "?")
|
||||
else:
|
||||
schedule_text = str(schedule_type or "unknown")
|
||||
|
||||
next_run = str(task.get("next_run_at") or "-").replace("T", " ")
|
||||
lines.extend(
|
||||
[
|
||||
"{}. {} {}".format(index, status, task.get("name") or "Unnamed task"),
|
||||
" ID: {}".format(task.get("id") or "-"),
|
||||
" {} · Next: {}".format(schedule_text, next_run),
|
||||
]
|
||||
)
|
||||
|
||||
hidden = len(tasks) - 20
|
||||
if hidden > 0:
|
||||
lines.append(_t("\n另有 {} 个任务未显示。", "\n{} more tasks are hidden.").format(hidden))
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# cancel — abort the in-flight agent run for the current session.
|
||||
# Fallback handler; in practice chat_channel/web_channel intercept
|
||||
|
||||
4
run.sh
4
run.sh
@@ -604,7 +604,7 @@ select_model() {
|
||||
"GLM (glm-5.2, etc.)" \
|
||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
||||
"Doubao (doubao-seed-2.1, etc.)" \
|
||||
"Kimi (kimi-k2.7-code, etc.)" \
|
||||
"Kimi (kimi-k3, etc.)" \
|
||||
"MiMo (mimo-v2.5-pro, etc.)" \
|
||||
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
||||
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
||||
@@ -636,7 +636,7 @@ configure_model() {
|
||||
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
||||
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
||||
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-1-pro-260628" "ARK_KEY" ;;
|
||||
9) read_model_config "Kimi (Moonshot)" "kimi-k2.7-code" "MOONSHOT_KEY" ;;
|
||||
9) read_model_config "Kimi (Moonshot)" "kimi-k3" "MOONSHOT_KEY" ;;
|
||||
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
|
||||
11)
|
||||
# Show where to obtain a LinkAI key (zh users -> console page).
|
||||
|
||||
@@ -461,7 +461,7 @@ $ModelChoices = @{
|
||||
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
||||
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
||||
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-1-pro-260628"; Field = "ark_api_key" }
|
||||
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.7-code"; Field = "moonshot_api_key" }
|
||||
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k3"; Field = "moonshot_api_key" }
|
||||
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
|
||||
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
||||
}
|
||||
@@ -478,7 +478,7 @@ function Select-Model {
|
||||
"GLM (glm-5.2, etc.)",
|
||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
||||
"Doubao (doubao-seed-2.1, etc.)",
|
||||
"Kimi (kimi-k2.7-code, etc.)",
|
||||
"Kimi (kimi-k3, etc.)",
|
||||
"MiMo (mimo-v2.5-pro, etc.)",
|
||||
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
||||
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
||||
|
||||
110
tests/test_cli_backup.py
Normal file
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"
|
||||
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