Compare commits

...

34 Commits

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

View File

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

View File

@@ -1,154 +0,0 @@
name: Publish Desktop
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
# to "live". By this point:
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
# registered them in D1 as unpublished (is_latest=0);
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
# dmgs and re-uploaded the stapled bytes to R2.
#
# This workflow, triggered manually with the version to publish:
# 1. pulls every artifact for that version back from R2,
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
# 3. flips is_latest=1 for that version (clearing the previous latest per
# platform) UNLESS it's a pre-release, which is recorded but never latest,
# 4. creates/updates the GitHub Release and attaches the installers.
#
# Run only after the mac dmgs for this version are notarized + re-uploaded.
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
type: string
required: true
make_latest:
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
type: boolean
default: true
github_release:
description: "Create/update the GitHub Release and attach installers."
type: boolean
default: true
permissions:
contents: write
jobs:
publish:
name: Publish ${{ github.event.inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Guard on Cloudflare secrets
id: guard
env:
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -z "$CF_TOKEN" ]; then
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
exit 1
fi
- name: Resolve version artifacts from D1
id: rows
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ github.event.inputs.version }}
run: |
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
# Read them back so we know exactly which objects to pull from R2.
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
echo "$out"
echo "$out" | node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(0, "utf8"));
const rows = (Array.isArray(data) ? data : [data])
.flatMap(r => (r.results || []));
if (!rows.length) {
console.error("No D1 rows for this version — did stage 1 (build) run?");
process.exit(1);
}
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
fs.writeFileSync("rows.json", JSON.stringify(rows));
'
- name: Download version artifacts from R2
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
run: |
mkdir -p dist
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
# auto-update file (update_filename: zip) for every row. Keys are
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
base="$(basename "$key")"
r2key="desktop/${key}"
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
--file "dist/${base}" --remote
done
echo "Downloaded:"; ls -la dist
- name: Reminder — mac dmgs must be notarized before publishing
run: |
# Stapling can only be validated on macOS (xcrun stapler validate),
# which this Linux runner doesn't have. The authoritative check runs in
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
# This step is just a loud reminder in the log.
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
- name: Update D1 (recompute sha512 + set latest)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ github.event.inputs.version }}
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
run: |
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
# become latest, so the site keeps serving the last stable build.
case "$VER" in
*-*) is_pre=1 ;;
*) is_pre=0 ;;
esac
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
latest_flag="--latest"; echo "==> Publishing $VER as latest."
else
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
fi
# Re-hash the real (stapled) bytes and re-store every row with both the
# dmg (manual) and mac zip (auto-update) columns. Same script as the
# build stage; --latest also clears the previous latest per platform.
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
echo "==> D1 statements:"; cat d1.sql
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
- name: Create/update GitHub Release and attach installers
if: github.event.inputs.github_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VER: ${{ github.event.inputs.version }}
run: |
tag="v${VER}"
case "$VER" in
*-*) prerelease="--prerelease" ;;
*) prerelease="" ;;
esac
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
--title "$tag" --generate-notes $prerelease
fi
# --clobber so re-runs overwrite the stapled/updated assets. The mac
# zip is the auto-update artifact; attach it too so the GitHub Release
# is a complete mirror (nullglob avoids errors when a type is absent).
shopt -s nullglob
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
--repo "$GITHUB_REPOSITORY" --clobber

230
.github/workflows/release-win7.yml vendored Normal file
View File

@@ -0,0 +1,230 @@
name: Release Desktop (Win7 legacy)
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
#
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
#
# To support Win7 we must pin BOTH halves to the last versions that still
# target it:
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
# - Python 3.8 (last CPython to support Win7)
#
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
# then — exactly like the main pipeline — uploads it to R2 and registers a
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
# stays invisible until promoted, so it can't accidentally get served to Win10
# users). Because it's stamped with the SAME version as the standard release,
# the download page shows both Windows builds under one version row, and the
# /update feed serves each build to its own clients. Delete this file whenever
# legacy Windows support is no longer worth maintaining — the main pipeline is
# unaffected.
#
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
on:
workflow_dispatch:
inputs:
version:
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
type: string
default: "0.0.0"
permissions:
contents: read
jobs:
build:
name: Build Windows x64 (Win7 legacy)
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Python 3.8 is the last CPython that supports Windows 7. A backend built
# with it (via PyInstaller) still runs on Win7 even though the CI host is
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
# OS, not the build machine's.
- name: Set up Python 3.8
uses: actions/setup-python@v5
with:
python-version: "3.8"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build Python backend (PyInstaller, Python 3.8)
shell: bash
run: |
python -m pip install --upgrade pip
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
# for this legacy build. We rewrite them into a throwaway requirements
# file so the repo's source stays untouched (main pipeline keeps its
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
sed 's/^playwright==.*/playwright==1.48.0/' \
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
pip install -r /tmp/requirements-win7.txt
pip install pyinstaller
# Run from repo root so the spec's relative datas resolve correctly.
pyinstaller desktop/build/cowagent-backend.spec \
--noconfirm \
--distpath desktop/build/dist \
--workpath desktop/build/build-work
- name: Install desktop deps
working-directory: desktop
run: npm ci
- name: Write version into package.json
working-directory: desktop
shell: bash
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
# this out of package.json so the repo's committed deps stay on Electron 33
# for the main pipeline. electron-builder reads the installed Electron
# version from node_modules, so this is all that's needed to package v22.
- name: Pin Electron to 22 (last Win7-capable)
working-directory: desktop
run: npm install --no-save electron@22.3.27
- name: Compile (vite + tsc)
working-directory: desktop
shell: bash
run: npm run build
# Same signing setup as the main pipeline: download the signtool CLI (URL
# from a repo variable so nothing is hardcoded in a public workflow). Only
# runs when a URL is configured; otherwise the build stays unsigned but
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
- name: Download Windows signing CLI
if: vars.SIGNTOOL_CLI_URL != ''
shell: bash
env:
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
run: |
mkdir -p "$RUNNER_TEMP/signtool"
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
if [ -z "$exe" ]; then
echo "signtool.exe not found in downloaded archive" >&2
find "$RUNNER_TEMP/signtool" -type f >&2
exit 1
fi
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
echo "resolved signtool: $exe"
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
# hook the main pipeline uses (signs app + backend + installer via the
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
# installer is still produced (unsigned). --publish never emits the exe
# without touching any feed. Invoke via node (not npx) to avoid the
# Windows npx.cmd wrapper returning early (see release.yml).
#
# -c.win.artifactName injects a "win7" segment into the file name
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
# builds differ by ${arch}: it keeps the legacy exe from colliding with
# the standard win exe in the same v<version>/ folder, and lets
# register-releases.mjs map it to the win-legacy platform by name.
- name: Build installer (electron-builder, Electron 22)
working-directory: desktop
shell: bash
env:
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
run: |
node node_modules/electron-builder/cli.js --win --x64 \
--config electron-builder.win.js \
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
--publish never
# Collect the installer + its blockmap (differential updates). The .yml
# feed is NOT uploaded: the /update Function generates it dynamically from
# D1 (same as the main pipeline), so it isn't needed here.
- name: Upload installer artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: cowagent-win7-x64
path: |
desktop/release/*.exe
desktop/release/*.blockmap
if-no-files-found: warn
retention-days: 7
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
# wrangler@latest works (the build job pins Node 20 for Electron 22).
#
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
# build — its "win7" name segment keeps them distinct — and register-releases
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
# publish workflow). Because the version matches the standard release, the
# download page merges both Windows builds into one version row.
publish:
name: Publish to R2 + D1
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Guard on Cloudflare secrets
id: guard
env:
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -n "$CF_TOKEN" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
fi
- name: Download build artifact
if: steps.guard.outputs.enabled == 'true'
uses: actions/download-artifact@v4
with:
name: cowagent-win7-x64
path: dist
- name: Upload installer to R2
if: steps.guard.outputs.enabled == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
VER: ${{ github.event.inputs.version }}
run: |
shopt -s nullglob
for f in dist/*.exe dist/*.blockmap; do
base="$(basename "$f")"
key="desktop/v${VER}/${base}"
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
--file "$f" --remote
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
done
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
- name: Register release row in D1
if: steps.guard.outputs.enabled == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ github.event.inputs.version }}
run: |
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
echo "==> D1 statements:"; cat d1.sql
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql

View File

@@ -113,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 | ✅ | ✅ | | | ✅ | |

View File

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

View File

@@ -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()

View File

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

View File

@@ -41,6 +41,8 @@ class SchedulerService:
self.running = False
self.thread = None
self._lock = threading.Lock()
self._execution_lock = threading.Lock()
self._active_task_ids = set()
def start(self):
"""Start the scheduler service"""
@@ -85,7 +87,15 @@ class SchedulerService:
try:
if self._is_task_due(task, now):
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
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

View File

@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
"schedule_value": {
"type": "string",
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
},
"silent": {
"type": "boolean",
"default": False,
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
}
},
"required": ["action"]
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
"channel_type": self.config.get("channel_type", "unknown"),
"notify_session_id": notify_session_id,
}
# silent only applies to ai_task; fixed messages always deliver
if kwargs.get("silent", False):
action["silent"] = True
# 针对钉钉单聊,额外存储 sender_staff_id
msg = context.kwargs.get("msg")
@@ -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 '未知'}"
)

View File

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

View File

@@ -28,6 +28,17 @@ from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.feishu.feishu_message import FeishuMessage
from channel.feishu.feishu_static_card import (
build_text_delivery,
resolve_markdown_images,
upload_public_image_to_feishu,
)
from channel.feishu.feishu_progress_card import FeishuProgressState
from channel.feishu.feishu_scheduler_card import (
build_scheduler_card,
handle_scheduler_action,
tasks_for_receivers,
)
from common import utils
from common.expired_dict import ExpiredDict
from common.log import logger
@@ -350,6 +361,10 @@ class FeiShuChanel(ChatChannel):
def _startup_websocket(self):
"""启动长连接接收事件(websocket模式)"""
_ensure_lark_imported()
from lark_oapi.event.callback.model.p2_card_action_trigger import (
P2CardActionTriggerResponse,
)
logger.debug("[FeiShu] Starting in websocket mode...")
# 创建事件处理器
@@ -372,10 +387,25 @@ class FeiShuChanel(ChatChannel):
except Exception as e:
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
def handle_card_action(data):
"""Handle Card 2.0 button callbacks and update the card in place."""
try:
event_dict = json.loads(lark.JSON.marshal(data))
response = self._handle_card_action_event(event_dict.get("event", {}))
return P2CardActionTriggerResponse(response)
except Exception as e:
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
return P2CardActionTriggerResponse(
{"toast": {"type": "error", "content": "Task update failed"}}
)
# 构建事件分发器
event_handler = lark.EventDispatcherHandler.builder("", "") \
.register_p2_im_message_receive_v1(handle_message_event) \
event_handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(handle_message_event)
.register_p2_card_action_trigger(handle_card_action)
.build()
)
def start_client_with_retry():
"""Run ws client in this thread with its own event loop to avoid conflicts."""
@@ -470,6 +500,97 @@ class FeiShuChanel(ChatChannel):
# so reaching here means the bot was indeed mentioned.
return True
def _get_scheduler_task_store(self):
"""Reuse the live scheduler store, with a path-compatible fallback."""
from agent.tools.scheduler.integration import get_task_store
task_store = get_task_store()
if task_store is not None:
return task_store
from agent.tools.scheduler.task_store import TaskStore
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
task_store = self._get_scheduler_task_store()
receivers = {feishu_msg.other_user_id}
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
card = build_scheduler_card(tasks)
headers = {
"Authorization": "Bearer " + feishu_msg.access_token,
"Content-Type": "application/json",
}
content = json.dumps(card, ensure_ascii=False)
if is_group and feishu_msg.msg_id:
url = (
"https://open.feishu.cn/open-apis/im/v1/messages/"
f"{feishu_msg.msg_id}/reply"
)
response = requests.post(
url,
headers=headers,
json={"msg_type": "interactive", "content": content},
timeout=(5, 10),
)
else:
url = "https://open.feishu.cn/open-apis/im/v1/messages"
response = requests.post(
url,
headers=headers,
params={"receive_id_type": receive_id_type},
json={
"receive_id": feishu_msg.other_user_id,
"msg_type": "interactive",
"content": content,
},
timeout=(5, 10),
)
result = response.json()
if result.get("code") == 0:
logger.info("[FeiShu] scheduler card sent")
return True
logger.error(
"[FeiShu] scheduler card failed, "
f"code={result.get('code')}, msg={result.get('msg')}"
)
return False
def _handle_card_action_event(self, event: dict) -> dict:
"""Apply a scheduler card action within its chat/operator ownership scope."""
action = event.get("action") or {}
value = action.get("value") or {}
if value.get("cowagent") != "scheduler":
return {}
context = event.get("context") or {}
operator = event.get("operator") or {}
callback_receivers = {
receiver
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
if receiver
}
target_receiver = value.get("receiver")
allowed_receivers = (
{target_receiver}
if target_receiver and target_receiver in callback_receivers
else set()
)
response = handle_scheduler_action(
value,
self._get_scheduler_task_store(),
allowed_receivers,
)
logger.info(
"[FeiShu] scheduler card action handled, "
f"action={value.get('action')}, task_id={value.get('task_id')}"
)
return response
def _handle_message_event(self, event: dict):
"""
处理消息事件的核心逻辑
@@ -521,6 +642,11 @@ class FeiShuChanel(ChatChannel):
if not feishu_msg:
return
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
logger.warning("[FeiShu] /tasks card delivery failed")
return
# 处理文件缓存逻辑
from channel.file_cache import get_file_cache
file_cache = get_file_cache()
@@ -591,7 +717,7 @@ class FeiShuChanel(ChatChannel):
context = self._compose_context(
feishu_msg.ctype,
feishu_msg.content,
feishu_msg.content_with_quote(),
isgroup=is_group,
msg=feishu_msg,
receive_id_type=receive_id_type,
@@ -628,7 +754,23 @@ class FeiShuChanel(ChatChannel):
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
reply_content = reply.content
content_key = "text"
if reply.type == ReplyType.IMAGE_URL:
prepared_content_json = None
if reply.type == ReplyType.TEXT:
delivery_text = reply.content
markdown_cards_enabled = conf().get("feishu_markdown_card", True)
if markdown_cards_enabled:
delivery_text = resolve_markdown_images(
delivery_text,
lambda url: upload_public_image_to_feishu(
url,
access_token,
),
)
msg_type, prepared_content_json = build_text_delivery(
delivery_text,
enabled=markdown_cards_enabled,
)
elif reply.type == ReplyType.IMAGE_URL:
# 图片上传
reply_content = self._upload_image_url(reply.content, access_token)
if not reply_content:
@@ -690,7 +832,11 @@ class FeiShuChanel(ChatChannel):
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
# Build content JSON
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
content_json = prepared_content_json or (
json.dumps(reply_content, ensure_ascii=False)
if content_key is None
else json.dumps({content_key: reply_content}, ensure_ascii=False)
)
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
if can_reply:
@@ -714,10 +860,383 @@ class FeiShuChanel(ChatChannel):
res = res.json()
if res.get("code") == 0:
logger.info(f"[FeiShu] send message success")
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
logger.warning(
"[FeiShu] Markdown card failed, falling back to text, "
f"code={res.get('code')}, msg={res.get('msg')}"
)
fallback_data = {
"msg_type": "text",
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
}
if can_reply:
fallback_res = requests.post(
url=url,
headers=headers,
json=fallback_data,
timeout=(5, 10),
)
else:
fallback_data["receive_id"] = context.get("receiver")
fallback_res = requests.post(
url=url,
headers=headers,
params=params,
json=fallback_data,
timeout=(5, 10),
)
fallback_body = fallback_res.json()
if fallback_body.get("code") == 0:
logger.info("[FeiShu] text fallback sent successfully")
else:
logger.error(
"[FeiShu] text fallback failed, "
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
)
else:
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
def _make_feishu_stream_callback(self, context, access_token):
"""Route to progress or plain streaming callback based on config.
feishu_progress_card 默认关闭:普通对话走原有的打字机文本卡片
(_make_feishu_stream_callback_plain)。开启后才使用带状态头、
Reasoning/Tools 面板与耗时的富进度卡片。
"""
if conf().get("feishu_progress_card", False):
return self._make_feishu_stream_callback_progress(context, access_token)
return self._make_feishu_stream_callback_plain(context, access_token)
def _make_feishu_stream_callback_progress(self, context, access_token):
"""
基于飞书官方"流式更新卡片"API 实现打字机回复。
流程:
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
随后用 POST /im/v1/messages或 reply以 card_id 把卡片发出去
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
(流式模式下不受 10 QPS 限制)
3. message_end本轮触发工具调用→ 原地刷新 Reasoning / Tools 面板,
后续 turn 继续复用同一张卡片
4. agent_end → 用 final_response、最终状态和耗时整卡更新关闭 streaming_mode
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
前提条件:
- 机器人已开通 cardkit:card:write 权限
- 飞书客户端 7.20+
失败降级:
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
"""
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
progress_state = FeishuProgressState()
card_id = [None]
message_id = [None]
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
# 事件各自触发一次创建+发送,导致发出多张卡片。
init_in_flight = [False]
# 一旦初始化失败就长期标记为 disabled本次回复不再尝试任何流式调用
disabled = [False]
lock = threading.Lock()
# ---- 异步推送队列 ----------------------------------------------------
# 同步 requests.put 单次 100~300ms会阻塞 LLM stream 线程读下一个 chunk。
# 把推送丢给独立 worker 线程消费 queue回调本身只做内存追加立即返回。
# 队列里只放"最新累积文本"的快照worker 用 deduplication 避免重复推同一个
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
import queue as _queue
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
def _push_worker():
while True:
snapshot = push_queue.get()
if snapshot is None:
push_queue.task_done()
return
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
merged_count = 1
stop = False
while True:
try:
nxt = push_queue.get_nowait()
except _queue.Empty:
break
merged_count += 1
if nxt is None:
stop = True
break
snapshot = nxt
try:
_stream_update_text(snapshot)
finally:
for _ in range(merged_count):
push_queue.task_done()
if stop:
return
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
push_thread.start()
def _drain_push_queue():
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
try:
push_queue.join()
except Exception:
pass
msg = context.get("msg")
is_group = context.get("isgroup", False)
receiver = context.get("receiver")
receive_id_type = context.get("receive_id_type", "open_id")
headers = {
"Authorization": "Bearer " + access_token,
"Content-Type": "application/json; charset=utf-8",
}
# 卡片中富文本组件的 element_id后续所有 PUT 流式更新都打到这个组件
ELEMENT_ID = "stream_md"
# 操作序号,每次 PUT 必须严格递增(飞书要求)
sequence = [0]
def _next_sequence():
with lock:
sequence[0] += 1
return sequence[0]
def _build_card_json():
"""Build the initial streaming Card 2.0 payload."""
with lock:
card = progress_state.build_card(streaming=True)
return json.dumps(card, ensure_ascii=False)
def _create_and_send_card():
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
try:
# 步骤 1: 创建卡片实体
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
create_body = {"type": "card_json", "data": _build_card_json()}
res = requests.post(
create_url, headers=headers, json=create_body, timeout=(5, 10)
)
res_json = res.json()
if res_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: create card failed "
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
)
with lock:
disabled[0] = True
return
cid = res_json["data"]["card_id"]
with lock:
card_id[0] = cid
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply单聊直接 send
content_payload = json.dumps(
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
)
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
if can_reply:
send_url = (
f"https://open.feishu.cn/open-apis/im/v1/messages/"
f"{msg.msg_id}/reply"
)
send_body = {"msg_type": "interactive", "content": content_payload}
send_res = requests.post(
send_url, headers=headers, json=send_body, timeout=(5, 10)
)
else:
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
params = {"receive_id_type": receive_id_type}
send_body = {
"receive_id": receiver,
"msg_type": "interactive",
"content": content_payload,
}
send_res = requests.post(
send_url, headers=headers, params=params, json=send_body,
timeout=(5, 10),
)
send_json = send_res.json()
if send_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
)
with lock:
disabled[0] = True
return
mid = send_json["data"]["message_id"]
with lock:
message_id[0] = mid
logger.info(
f"[FeiShu] Stream: card created and sent, "
f"card_id={cid}, message_id={mid}"
)
except Exception as e:
logger.warning(
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
)
with lock:
disabled[0] = True
finally:
with lock:
init_in_flight[0] = False
def _stream_update_text(full_text):
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
with lock:
cid = card_id[0]
if not cid:
return
url = (
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
f"{cid}/elements/{ELEMENT_ID}/content"
)
body = {
"content": full_text,
"sequence": _next_sequence(),
}
try:
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
res_json = res.json()
if res_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: update text failed: {res_json}"
)
except Exception as e:
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
def _update_full_card(streaming: bool):
"""Refresh panels, header, footer and streaming state in one update."""
with lock:
cid = card_id[0]
full_card = progress_state.build_card(streaming=streaming)
if not cid:
return
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
put_body = {
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
"sequence": _next_sequence(),
}
try:
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
res_json = res.json()
if res_json.get("code") != 0:
logger.warning(
f"[FeiShu] Stream: full card update failed: {res_json}"
)
except Exception as e:
logger.warning(
f"[FeiShu] Stream: full card update exception: {e}"
)
def on_event(event: dict):
event_type = event.get("type")
data = event.get("data", {})
# 一旦降级,本次回复不再做任何流式操作
with lock:
if disabled[0]:
return
if event_type == "agent_start":
with lock:
progress_state.consume(event)
if card_id[0] is None and not init_in_flight[0]:
init_in_flight[0] = True
_create_and_send_card()
return
if event_type in ("turn_start", "reasoning_update"):
with lock:
progress_state.consume(event)
return
if event_type == "message_update":
delta = data.get("delta", "")
if not delta:
return
# 第一段:判断是否需要初始化(创建卡片 + 发送)
need_init = False
with lock:
if card_id[0] is None and not init_in_flight[0]:
init_in_flight[0] = True
need_init = True
if need_init:
_create_and_send_card()
# 初始化失败已标记 disabled下次循环直接 return
with lock:
if disabled[0]:
return
# 第二段:累加文本,把快照丢给 push worker 异步推送。
# 这里不能直接 requests.put否则会阻塞 LLM stream 线程读下一个 chunk
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms累积起来非常卡
snapshot = ""
should_push = False
with lock:
progress_state.consume(event)
if card_id[0]:
snapshot = progress_state.current_text
should_push = True
if should_push:
push_queue.put(snapshot)
elif event_type == "message_end":
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
tool_calls = data.get("tool_calls", []) or []
with lock:
progress_state.consume(event)
if not tool_calls:
return
_drain_push_queue()
_update_full_card(streaming=True)
elif event_type == "agent_cancelled":
with lock:
progress_state.consume(event)
elif event_type == "agent_end":
# Finalize the same card with a status header and elapsed footer.
with lock:
progress_state.consume(event)
final_text = progress_state.current_text
has_card = card_id[0] is not None
init_busy = init_in_flight[0]
final_text = resolve_markdown_images(
final_text,
lambda url: upload_public_image_to_feishu(url, access_token),
)
with lock:
progress_state.current_text = final_text
context["feishu_streamed"] = True
if not has_card and not init_busy:
with lock:
init_in_flight[0] = True
_create_and_send_card()
with lock:
if disabled[0]:
return
_drain_push_queue()
_stream_update_text(final_text)
_update_full_card(streaming=False)
push_queue.put(None)
return on_event
def _make_feishu_stream_callback_plain(self, context, access_token):
"""
基于飞书官方"流式更新卡片"API 实现打字机回复。
@@ -966,9 +1485,15 @@ class FeiShuChanel(ChatChannel):
if not cid:
return
preview_text = final_text
final_text = resolve_markdown_images(
final_text,
lambda url: upload_public_image_to_feishu(url, access_token),
)
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
# settings 接口的 config 不接受 summary 字段,会报 code=2200
preview_src = (final_text or "").strip().replace("\n", " ")
preview_src = (preview_text or "").strip().replace("\n", " ")
preview = preview_src[:30] if preview_src else ""
full_card = {
"schema": "2.0",
@@ -1565,6 +2090,7 @@ class FeishuController:
FAILED_MSG = '{"success": false}'
SUCCESS_MSG = '{"success": true}'
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
CARD_ACTION_TYPE = "card.action.trigger"
def GET(self):
return "Feishu service start success!"
@@ -1581,15 +2107,27 @@ class FeishuController:
varify_res = {"challenge": request.get("challenge")}
return json.dumps(varify_res)
# 2.消息接收处理
# token 校验
header = request.get("header")
if not header or header.get("token") != channel.feishu_token:
# 2. Verify callbacks. Card callbacks may carry the verification
# token in event.token while message events carry it in the header.
header = request.get("header") or {}
event = request.get("event") or {}
event_type = header.get("event_type") or request.get("type")
callback_token = (
header.get("token")
or event.get("token")
or request.get("token")
)
if callback_token != channel.feishu_token:
return self.FAILED_MSG
# 处理消息事件
event = request.get("event")
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
if event_type == self.CARD_ACTION_TYPE and event:
return json.dumps(
channel._handle_card_action_event(event),
ensure_ascii=False,
)
# 3. Handle message events.
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
channel._handle_message_event(event)
return self.SUCCESS_MSG

View File

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

View File

@@ -0,0 +1,204 @@
"""State and Card 2.0 rendering for a Feishu agent run."""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
_MAX_PANEL_STEPS = 10
_MAX_STEP_CHARS = 800
class FeishuProgressState:
"""Reduce CowAgent stream events into one renderable Feishu card state."""
def __init__(self, started_at: Optional[float] = None):
self.started_at = time.monotonic() if started_at is None else started_at
self.status = "running"
self.turns = 0
self.current_text = ""
self._reasoning_buffer = ""
self.reasoning_steps: List[str] = []
self.tool_steps: List[Dict[str, str]] = []
self.cancelled = False
def consume(self, event: Dict[str, Any]) -> None:
"""Consume one event emitted by ``AgentStreamHandler``."""
event_type = event.get("type")
data = event.get("data") or {}
if event_type == "turn_start":
self._mark_running_tools_done()
turn = data.get("turn")
if isinstance(turn, int):
self.turns = max(self.turns, turn)
else:
self.turns += 1
if self.turns > 1:
self.current_text = ""
return
if event_type == "reasoning_update":
self._reasoning_buffer += str(data.get("delta") or "")
return
if event_type == "message_update":
self.current_text += str(data.get("delta") or "")
return
if event_type == "message_end":
self._commit_reasoning()
for tool_call in data.get("tool_calls") or []:
self.tool_steps.append(
{
"summary": _format_tool_call(tool_call),
"status": "running",
}
)
return
if event_type == "agent_cancelled":
self.cancelled = True
self.status = "stopped"
return
if event_type == "agent_end":
self._commit_reasoning()
self._mark_running_tools_done()
cancelled = self.cancelled or bool(data.get("cancelled"))
if cancelled:
self.status = "stopped"
self.current_text = self.current_text.rstrip() or "_(stopped)_"
else:
self.status = "done"
final_response = data.get("final_response")
if final_response:
self.current_text = str(final_response)
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
"""Render the current state as a Feishu Card 2.0 object."""
title, template = {
"running": ("Working", "blue"),
"done": ("Done", "green"),
"stopped": ("Stopped", "grey"),
"error": ("Error", "red"),
}.get(self.status, ("Working", "blue"))
main_text = self.current_text or "..."
elements: List[Dict[str, Any]] = []
if self.reasoning_steps:
elements.append(
_panel(
"Reasoning ({})".format(len(self.reasoning_steps)),
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
expanded=streaming,
)
)
elif streaming:
elements.append(
_panel("Reasoning", [_text_row("Thinking...", muted=True)], expanded=True)
)
if self.tool_steps:
elements.append(
_panel(
"Tools ({})".format(len(self.tool_steps)),
[
_text_row("{} · {}".format(step["summary"], step["status"]))
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
],
expanded=streaming,
)
)
elements.append(
{
"tag": "markdown",
"element_id": "stream_md",
"content": main_text,
}
)
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
turn_label = "turn" if self.turns == 1 else "turns"
elements.extend(
[
{"tag": "hr"},
{
"tag": "markdown",
"content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label),
"text_size": "notation",
},
]
)
config: Dict[str, Any] = {
"streaming_mode": streaming,
"update_multi": True,
"enable_forward_interaction": True,
"summary": {"content": _summary(main_text, title)},
}
if streaming:
config["streaming_config"] = {
"print_frequency_ms": {"default": 40},
"print_step": {"default": 4},
"print_strategy": "fast",
}
return {
"schema": "2.0",
"config": config,
"header": {
"template": template,
"title": {"tag": "plain_text", "content": title},
},
"body": {"elements": elements},
}
def _commit_reasoning(self) -> None:
reasoning = self._reasoning_buffer.strip()
if reasoning:
self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:])
self._reasoning_buffer = ""
def _mark_running_tools_done(self) -> None:
for step in self.tool_steps:
if step["status"] == "running":
step["status"] = "done"
def _format_tool_call(tool_call: Dict[str, Any]) -> str:
# Tool arguments can contain user data or credentials. The progress card
# only needs an activity label, so never echo raw arguments into chat.
return str(tool_call.get("name") or "tool")
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
return {
"tag": "collapsible_panel",
"expanded": expanded,
"background_color": "grey",
"header": {"title": {"tag": "plain_text", "content": title}},
"border": {"color": "grey"},
"vertical_spacing": "8px",
"padding": "4px 8px",
"elements": elements,
}
def _text_row(content: str, muted: bool = False) -> Dict[str, Any]:
text = {
"tag": "plain_text",
"content": content,
"text_size": "notation",
}
if muted:
text["text_color"] = "grey"
return {"tag": "div", "text": text}
def _summary(text: str, fallback: str) -> str:
preview = " ".join(text.strip().split())
return preview[:60] or fallback

View File

@@ -0,0 +1,185 @@
"""Feishu scheduler card rendering and callback handling."""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Set
_MAX_TASKS = 20
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
visible = []
for task in tasks:
action = task.get("action") or {}
if action.get("channel_type") != "feishu":
continue
if action.get("receiver") in receivers:
visible.append(task)
return visible
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
"""Build a Card 2.0 task list with explicit, idempotent actions."""
task_list = list(tasks)
elements: List[Dict[str, Any]] = []
if not task_list:
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
else:
for index, task in enumerate(task_list[:_MAX_TASKS]):
if index:
elements.append({"tag": "hr"})
task_id = str(task.get("id") or "")
receiver = str((task.get("action") or {}).get("receiver") or "")
enabled = task.get("enabled", True)
status = "Enabled" if enabled else "Disabled"
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
elements.append(
{
"tag": "markdown",
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
task.get("name") or "Unnamed task",
status,
task_id,
_format_schedule(task.get("schedule") or {}),
next_run,
),
}
)
toggle_action = "disable" if enabled else "enable"
toggle_text = "Disable" if enabled else "Enable"
toggle_type = "default" if enabled else "primary"
elements.append(
{
"tag": "column_set",
"columns": [
{
"tag": "column",
"elements": [
_button(
toggle_text,
toggle_type,
toggle_action,
task_id,
receiver,
)
],
},
{
"tag": "column",
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
},
],
}
)
hidden = len(task_list) - _MAX_TASKS
if hidden > 0:
elements.extend(
[
{"tag": "hr"},
{
"tag": "markdown",
"content": "{} more tasks are hidden.".format(hidden),
"text_size": "notation",
},
]
)
return {
"schema": "2.0",
"config": {"update_multi": True, "enable_forward_interaction": False},
"header": {
"template": "blue",
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
},
"body": {"elements": elements},
}
def handle_scheduler_action(
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
) -> Dict[str, Any]:
"""Apply an owned scheduler action and return a Feishu callback response."""
if value.get("cowagent") != "scheduler":
return {}
task_id = str(value.get("task_id") or "")
action = value.get("action")
if not task_id or action not in {"enable", "disable", "delete"}:
return _toast("error", "Invalid scheduler action")
task = task_store.get_task(task_id)
if not task:
return _response(
"info",
"Task no longer exists",
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
)
task_receiver = (task.get("action") or {}).get("receiver")
task_channel = (task.get("action") or {}).get("channel_type")
value_receiver = value.get("receiver")
if (
task_channel != "feishu"
or task_receiver not in allowed_receivers
or (value_receiver and value_receiver != task_receiver)
):
return _toast("error", "Task is not available in this chat")
try:
if action == "delete":
task_store.delete_task(task_id)
message = "Task deleted"
else:
enabled = action == "enable"
task_store.enable_task(task_id, enabled)
message = "Task enabled" if enabled else "Task disabled"
except (OSError, ValueError) as exc:
return _toast("error", "Task update failed: {}".format(exc))
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
return _response("success", message, build_scheduler_card(visible))
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
response = _toast(toast_type, content)
response["card"] = {"type": "raw", "data": card}
return response
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
return {"toast": {"type": toast_type, "content": content}}
def _button(
text: str,
button_type: str,
action: str,
task_id: str,
receiver: str,
) -> Dict[str, Any]:
return {
"tag": "button",
"text": {"tag": "plain_text", "content": text},
"type": button_type,
"value": {
"cowagent": "scheduler",
"action": action,
"task_id": task_id,
"receiver": receiver,
},
}
def _format_schedule(schedule: Dict[str, Any]) -> str:
schedule_type = schedule.get("type")
if schedule_type == "cron":
return "cron {}".format(schedule.get("expression") or "?")
if schedule_type == "interval":
return "every {}s".format(schedule.get("seconds") or "?")
if schedule_type == "once":
return "once at {}".format(schedule.get("run_at") or "?")
return str(schedule_type or "unknown schedule")

View File

@@ -0,0 +1,218 @@
"""Helpers for choosing the native Feishu delivery format for text replies."""
import ipaddress
import json
import re
import socket
from typing import Callable, Optional, Tuple
from urllib.parse import urljoin, urlparse
import requests
_BLOCK_MARKDOWN = re.compile(
r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)"
)
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
_MARKDOWN_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\s]+)\)")
_REDIRECT_CODES = {301, 302, 303, 307, 308}
_MAX_REDIRECTS = 3
_MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024
def contains_markdown(text: str) -> bool:
"""Return whether *text* contains syntax that benefits from card Markdown."""
if not text:
return False
return bool(
_BLOCK_MARKDOWN.search(text)
or _INLINE_MARKDOWN.search(text)
or _TABLE_SEPARATOR.search(text)
)
def build_markdown_card(text: str) -> dict:
"""Build an inline Card 2.0 payload with one Markdown element."""
return {
"schema": "2.0",
"config": {},
"body": {
"elements": [
{
"tag": "markdown",
"content": text,
}
]
},
}
def build_text_delivery(text: str, enabled: bool = True) -> Tuple[str, str]:
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
if enabled and contains_markdown(text):
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
return "text", json.dumps({"text": text}, ensure_ascii=False)
def resolve_markdown_images(
text: str,
uploader: Callable[[str], Optional[str]],
max_images: int = 5,
) -> str:
"""Replace remote Markdown image URLs with Feishu image keys."""
cache = {}
uploaded = 0
def replace(match):
nonlocal uploaded
alt = match.group(1).strip() or "image"
target = match.group(2).strip()
if target.startswith("img_"):
return match.group(0)
if urlparse(target).scheme not in ("http", "https"):
return match.group(0)
if target not in cache:
if uploaded >= max_images:
cache[target] = None
else:
uploaded += 1
try:
cache[target] = uploader(target)
except Exception:
cache[target] = None
image_key = cache[target]
if image_key:
return "![{}]({})".format(alt, image_key)
return "[Image unavailable: {}]".format(alt)
return _MARKDOWN_IMAGE.sub(replace, text or "")
def validate_public_image_url(url: str) -> None:
"""Reject non-HTTP and non-public image targets before downloading."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("unsupported image URL scheme")
if not parsed.hostname:
raise ValueError("image URL has no hostname")
try:
literal_address = ipaddress.ip_address(parsed.hostname)
resolved_addresses = [literal_address]
except ValueError:
try:
addresses = socket.getaddrinfo(
parsed.hostname,
parsed.port,
socket.AF_UNSPEC,
socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise ValueError("cannot resolve image hostname") from exc
resolved_addresses = [ipaddress.ip_address(item[4][0]) for item in addresses]
for address in resolved_addresses:
if (
address.is_private
or address.is_loopback
or address.is_link_local
or address.is_reserved
or address.is_multicast
or address.is_unspecified
):
raise ValueError("image URL resolves to a non-public address")
def download_public_image(
url: str,
get=requests.get,
max_bytes: int = _MAX_REMOTE_IMAGE_BYTES,
) -> Tuple[bytes, str]:
"""Download a public image with redirect, type, and size checks."""
current = url
for _ in range(_MAX_REDIRECTS + 1):
validate_public_image_url(current)
response = get(
current,
headers={"User-Agent": "CowAgent/Feishu"},
timeout=(5, 15),
allow_redirects=False,
stream=True,
)
if response.status_code in _REDIRECT_CODES:
location = response.headers.get("Location")
response.close()
if not location:
raise ValueError("image redirect has no location")
current = urljoin(current, location)
continue
if response.status_code != 200:
response.close()
raise ValueError("image download returned HTTP {}".format(response.status_code))
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
if not content_type.startswith("image/"):
response.close()
raise ValueError("remote resource is not an image")
try:
content_length = int(response.headers.get("Content-Length") or 0)
except (TypeError, ValueError):
content_length = 0
if content_length > max_bytes:
response.close()
raise ValueError("remote image is too large")
chunks = []
downloaded = 0
try:
for chunk in response.iter_content(chunk_size=8192):
if not chunk:
continue
downloaded += len(chunk)
if downloaded > max_bytes:
raise ValueError("remote image is too large")
chunks.append(chunk)
finally:
response.close()
return b"".join(chunks), content_type
raise ValueError("too many image redirects")
def upload_public_image_to_feishu(
url: str,
access_token: str,
post=requests.post,
) -> Optional[str]:
"""Download a public image and upload its bytes to Feishu."""
payload, content_type = download_public_image(url)
extension = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
}.get(content_type, "img")
response = post(
"https://open.feishu.cn/open-apis/im/v1/images",
headers={"Authorization": "Bearer " + access_token},
data={"image_type": "message"},
files={
"image": (
"markdown-image.{}".format(extension),
payload,
content_type,
)
},
timeout=(5, 15),
)
body = response.json()
if body.get("code") != 0:
return None
return (body.get("data") or {}).get("image_key")

View File

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

View File

@@ -217,6 +217,11 @@ const I18N = {
task_delete_btn: '删除任务',
task_delete_confirm_title: '删除定时任务',
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
task_run_now: '立即执行',
task_run_confirm_title: '立即执行任务',
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '已开始执行',
task_run_failed: '执行失败',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
new_chat: '新对话',
@@ -464,6 +469,11 @@ const I18N = {
task_delete_btn: '刪除任務',
task_delete_confirm_title: '刪除定時任務',
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
task_run_now: '立即執行',
task_run_confirm_title: '立即執行任務',
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
task_run_started: '已開始執行',
task_run_failed: '執行失敗',
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
new_chat: '新對話',
@@ -710,6 +720,11 @@ const I18N = {
task_delete_btn: 'Delete Task',
task_delete_confirm_title: 'Delete Task',
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
task_run_now: 'Run now',
task_run_confirm_title: 'Run task now',
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Run started',
task_run_failed: 'Run failed',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
new_chat: 'New Chat',
@@ -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;

View File

@@ -1309,6 +1309,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/run', 'SchedulerRunHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
'/api/scheduler/delete', 'SchedulerDeleteHandler',
@@ -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

View File

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

332
cli/commands/backup.py Normal file
View File

@@ -0,0 +1,332 @@
"""Portable local backup and restore commands for CowAgent user data."""
import json
import os
import shutil
import stat
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Iterable, Optional, Set
import click
from cli.utils import get_project_root
BACKUP_FORMAT = "cowagent-backup"
BACKUP_VERSION = 1
_SKIP_DIRS = {".git", "__pycache__", "tmp"}
_SKIP_FILES = {".DS_Store"}
def _data_root() -> Path:
configured = os.environ.get("COW_DATA_DIR")
return Path(configured).expanduser().resolve() if configured else Path(get_project_root()).resolve()
def _read_config(data_root: Path) -> dict:
path = data_root / "config.json"
if not path.is_file():
return {}
try:
with path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
return value if isinstance(value, dict) else {}
except (OSError, ValueError):
return {}
def _workspace_from_config(config: dict) -> Path:
return Path(config.get("agent_workspace") or "~/cow").expanduser().resolve()
def _legacy_user_data_path(data_root: Path, config: dict) -> Path:
appdata_dir = config.get("appdata_dir") or ""
return (data_root / appdata_dir / "user_datas.pkl").resolve()
def _is_within(path: Path, root: Path) -> bool:
try:
return os.path.commonpath([str(path.resolve()), str(root.resolve())]) == str(root.resolve())
except (OSError, ValueError):
return False
def _iter_workspace_files(workspace: Path, excluded: Set[Path]):
if not workspace.is_dir():
return
for current, dirnames, filenames in os.walk(str(workspace), followlinks=False):
current_path = Path(current)
dirnames[:] = [
name for name in dirnames
if name not in _SKIP_DIRS and not (current_path / name).is_symlink()
]
for name in filenames:
path = current_path / name
if name in _SKIP_FILES or name.endswith((".pyc", ".pyo")):
continue
if path.is_symlink() or path.resolve() in excluded:
continue
if path.is_file():
yield path
def create_backup_archive(
output: Path,
data_root: Path,
workspace: Path,
excluded_paths: Optional[Iterable[Path]] = None,
) -> dict:
"""Create a portable archive containing config and the agent workspace."""
output = Path(output).expanduser().resolve()
data_root = Path(data_root).expanduser().resolve()
workspace = Path(workspace).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
excluded = {Path(path).expanduser().resolve() for path in (excluded_paths or [])}
excluded.add(output)
config_path = data_root / "config.json"
config = _read_config(data_root)
legacy_path = _legacy_user_data_path(data_root, config)
workspace_files = list(_iter_workspace_files(workspace, excluded))
total_bytes = sum(path.stat().st_size for path in workspace_files)
manifest = {
"format": BACKUP_FORMAT,
"version": BACKUP_VERSION,
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"workspace_source": str(workspace),
"contents": {
"config": config_path.is_file(),
"legacy_user_data": legacy_path.is_file(),
"workspace_files": len(workspace_files),
"workspace_bytes": total_bytes,
},
}
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-backup-"))
temp_archive = temp_dir / "backup.zip"
try:
with zipfile.ZipFile(
str(temp_archive), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
) as archive:
archive.writestr(
"manifest.json",
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
)
if config_path.is_file():
archive.write(str(config_path), "data/config.json")
if legacy_path.is_file():
archive.write(str(legacy_path), "data/user_datas.pkl")
for path in workspace_files:
relative = path.relative_to(workspace).as_posix()
archive.write(str(path), "workspace/" + relative)
os.replace(str(temp_archive), str(output))
try:
os.chmod(str(output), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
finally:
shutil.rmtree(str(temp_dir), ignore_errors=True)
manifest["archive"] = str(output)
return manifest
def _validate_archive(archive: zipfile.ZipFile) -> dict:
names = {info.filename for info in archive.infolist()}
if "manifest.json" not in names:
raise ValueError("archive is missing manifest.json")
try:
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError("archive manifest is invalid") from exc
if manifest.get("format") != BACKUP_FORMAT or manifest.get("version") != BACKUP_VERSION:
raise ValueError("unsupported CowAgent backup format or version")
for info in archive.infolist():
name = info.filename
path = PurePosixPath(name)
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
raise ValueError(f"unsafe archive path: {name!r}")
mode = (info.external_attr >> 16) & 0o170000
if mode == stat.S_IFLNK:
raise ValueError(f"symbolic links are not allowed in backups: {name!r}")
if name != "manifest.json" and not name.startswith(("data/", "workspace/")):
raise ValueError(f"unexpected archive entry: {name!r}")
return manifest
def _extract_validated(archive: zipfile.ZipFile, destination: Path) -> None:
for info in archive.infolist():
target = destination.joinpath(*PurePosixPath(info.filename).parts)
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(info, "r") as source, target.open("wb") as output:
shutil.copyfileobj(source, output)
def _atomic_copy(source: Path, destination: Path, private: bool = False) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=destination.name + ".", dir=str(destination.parent))
os.close(fd)
try:
shutil.copy2(str(source), temp_name)
os.replace(temp_name, str(destination))
if private:
try:
os.chmod(str(destination), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
finally:
if os.path.exists(temp_name):
os.remove(temp_name)
def restore_backup_archive(
archive_path: Path,
data_root: Path,
workspace: Optional[Path] = None,
) -> dict:
"""Merge a validated backup into the selected data root and workspace."""
archive_path = Path(archive_path).expanduser().resolve()
data_root = Path(data_root).expanduser().resolve()
current_config = _read_config(data_root)
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-restore-"))
try:
with zipfile.ZipFile(str(archive_path), "r") as archive:
manifest = _validate_archive(archive)
_extract_validated(archive, temp_dir)
archived_config_path = temp_dir / "data" / "config.json"
archived_config = {}
if archived_config_path.is_file():
with archived_config_path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ValueError("archived config.json must contain an object")
archived_config = value
if workspace is not None:
target_workspace = Path(workspace).expanduser().resolve()
elif current_config.get("agent_workspace"):
target_workspace = _workspace_from_config(current_config)
else:
# Do not trust an archive-controlled absolute destination on a
# fresh machine. Portable restores default to the standard local
# workspace unless the operator supplies --workspace.
target_workspace = Path("~/cow").expanduser().resolve()
restored_config = dict(archived_config)
if restored_config:
restored_config["agent_workspace"] = str(target_workspace)
appdata_dir = restored_config.get("appdata_dir") or ""
if appdata_dir:
archived_appdata = (data_root / appdata_dir).resolve()
if not _is_within(archived_appdata, data_root):
# Keep legacy user data under the selected data root
# instead of writing to an archive-controlled path.
restored_config["appdata_dir"] = ""
config_temp = temp_dir / "restored-config.json"
with config_temp.open("w", encoding="utf-8") as handle:
json.dump(restored_config, handle, ensure_ascii=False, indent=2)
handle.write("\n")
_atomic_copy(config_temp, data_root / "config.json", private=True)
workspace_root = temp_dir / "workspace"
restored_files = 0
if workspace_root.is_dir():
for source in _iter_workspace_files(workspace_root, set()):
relative = source.relative_to(workspace_root)
destination = target_workspace / relative
if not _is_within(destination, target_workspace):
raise ValueError(f"unsafe workspace destination: {relative}")
_atomic_copy(source, destination)
restored_files += 1
legacy_source = temp_dir / "data" / "user_datas.pkl"
if legacy_source.is_file():
effective_config = restored_config or current_config
legacy_destination = _legacy_user_data_path(data_root, effective_config)
_atomic_copy(legacy_source, legacy_destination, private=True)
return {
"manifest": manifest,
"workspace": str(target_workspace),
"workspace_files": restored_files,
"config_restored": bool(restored_config),
"legacy_user_data_restored": legacy_source.is_file(),
}
finally:
shutil.rmtree(str(temp_dir), ignore_errors=True)
@click.command("backup")
@click.option(
"--output",
"-o",
type=click.Path(dir_okay=False, path_type=Path),
help="Output .zip path (default: ./cow-backup-<timestamp>.zip).",
)
def backup_command(output: Optional[Path]):
"""Back up config, persona, memory, skills, knowledge, and schedules."""
data_root = _data_root()
config = _read_config(data_root)
workspace = _workspace_from_config(config)
if output is None:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
output = Path.cwd() / f"cow-backup-{stamp}.zip"
result = create_backup_archive(output, data_root, workspace)
click.echo(click.style("✓ Backup created", fg="green"))
click.echo(f" Archive: {result['archive']}")
click.echo(f" Workspace files: {result['contents']['workspace_files']}")
click.echo(" Keep this archive private: it may contain API keys and personal data.")
@click.command("restore")
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--workspace",
type=click.Path(file_okay=False, path_type=Path),
help="Restore workspace files to this directory.",
)
@click.option("--yes", is_flag=True, help="Confirm overwriting matching files.")
def restore_command(archive: Path, workspace: Optional[Path], yes: bool):
"""Restore a backup without deleting unrelated destination files."""
from cli.commands.process import _read_pid
pid = _read_pid()
if pid:
raise click.ClickException(
f"CowAgent is running (PID: {pid}). Run 'cow stop' before restoring."
)
if not yes:
click.confirm(
"Restore this archive and overwrite matching config/workspace files?",
abort=True,
)
data_root = _data_root()
current_config = _read_config(data_root)
current_workspace = _workspace_from_config(current_config)
has_current_data = (data_root / "config.json").is_file() or current_workspace.is_dir()
if has_current_data:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
rollback = archive.resolve().parent / f"cow-pre-restore-{stamp}.zip"
create_backup_archive(
rollback,
data_root,
current_workspace,
excluded_paths={archive.resolve()},
)
click.echo(f"Rollback backup: {rollback}")
result = restore_backup_archive(archive, data_root, workspace)
click.echo(click.style("✓ Backup restored", fg="green"))
click.echo(f" Workspace: {result['workspace']}")
click.echo(f" Restored files: {result['workspace_files']}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
msg_cancelled: '已中止',
msg_self_learned: '自主学习',
msg_stop: '停止',
thinking_in_progress: '思考中…',
thinking_done: '已深度思考',
chat_clear_context: '清除上下文',
context_cleared: '— 以上内容已从上下文中移除 —',
chat_load_earlier: '加载更早的消息',
chat_send: '发送',
chat_attach: '添加附件',
slash_hint: '输入 / 查看命令',
chat_welcome: '有什么可以帮你的?',
chat_empty_hint: '发送一条消息开始对话',
welcome_subtitle: '我可以帮你解问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
welcome_subtitle: '我可以帮你解问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理',
example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '定时任务',
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
channels_connected_section: '已连接',
channels_available_section: '可添加',
channels_empty_connected: '暂无已连接的通道',
channels_empty: '暂未接入任何通道',
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
channels_save_ok: '已保存',
channels_save_error: '保存失败',
@@ -347,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: '删除',
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
task_save_error: '保存失败',
task_run_now: '立即执行',
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '任务已开始执行',
task_run_error: '执行失败',
logs_title: '日志',
logs_desc: '实时日志输出 (run.log)',
logs_live: '实时',
@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

51
docs/cli/backup.mdx Normal file
View File

@@ -0,0 +1,51 @@
---
title: Backup and Restore
description: Export and restore CowAgent configuration and agent workspace data
---
CowAgent can create a portable local archive for migration or disaster recovery.
## Create a backup
```bash
cow backup
cow backup --output /safe/location/cow-backup.zip
```
The archive contains:
- `config.json`
- Agent persona and user files such as `AGENT.md`, `USER.md`, and `MEMORY.md`
- Daily memory, session history, knowledge, custom skills, and scheduled tasks in the configured workspace
- Legacy `user_datas.pkl`, when present
Transient `tmp/` data, caches, Git metadata, and symbolic links are skipped. The
archive is written with owner-only permissions where the operating system
supports them.
<Warning>
A backup may contain API keys, conversation history, and other personal data.
Store and transfer it as a secret. The ZIP file is not encrypted.
</Warning>
## Restore a backup
Stop CowAgent before restoring:
```bash
cow stop
cow restore /safe/location/cow-backup.zip
```
Use `--workspace` to migrate workspace files to a different location:
```bash
cow restore cow-backup.zip --workspace ~/cow-restored
```
Restore validates the archive format and paths before writing anything. It
overwrites matching files but does not delete unrelated files already present
at the destination. When current CowAgent data exists, the command first
creates a `cow-pre-restore-*.zip` rollback archive beside the selected backup.
For unattended scripts, pass `--yes` to acknowledge overwrites.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |

View File

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

View File

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

View File

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

View File

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

View File

@@ -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+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

@@ -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+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

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

45
docs/zh/cli/backup.mdx Normal file
View File

@@ -0,0 +1,45 @@
---
title: 备份与恢复
description: 导出和恢复 CowAgent 配置及 Agent 工作区数据
---
CowAgent 可以生成便携的本地归档,用于设备迁移或灾难恢复。
## 创建备份
```bash
cow backup
cow backup --output /safe/location/cow-backup.zip
```
归档包含:
- `config.json`
- `AGENT.md`、`USER.md`、`MEMORY.md` 等 Agent 人格和用户文件
- 工作区内的每日记忆、会话历史、知识库、自定义技能和定时任务
- 旧版 `user_datas.pkl`(如存在)
临时 `tmp/` 数据、缓存、Git 元数据和符号链接不会写入归档。在操作系统支持的情况下,归档文件权限会限制为仅当前用户可读写。
<Warning>
备份中可能含有 API 密钥、对话历史及其他个人数据应按密钥文件保存和传输。ZIP 文件本身没有加密。
</Warning>
## 恢复备份
恢复前先停止 CowAgent
```bash
cow stop
cow restore /safe/location/cow-backup.zip
```
迁移到不同工作区时,可以显式指定路径:
```bash
cow restore cow-backup.zip --workspace ~/cow-restored
```
恢复命令会在写入前校验归档格式和所有路径。它会覆盖同名文件,但不会删除目标目录中无关的现有文件。如果检测到当前 CowAgent 数据,会先在所选归档旁创建 `cow-pre-restore-*.zip` 回滚备份。
无人值守脚本可传入 `--yes` 确认覆盖。

View File

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

View File

@@ -20,7 +20,7 @@ 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+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ class MoonshotBot(Bot):
m = model_name.lower()
if cls._is_builtin_reasoning_model(m):
return False
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
return m.startswith("kimi-k3") or m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
def _build_headers(self) -> dict:
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""

View File

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

@@ -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).

View File

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

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

114
tests/test_cow_cli_tasks.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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