mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 19:27:11 +08:00
Compare commits
23 Commits
feat-deskt
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8751f04b | ||
|
|
3ee7b164d4 | ||
|
|
d7abbd3baa | ||
|
|
0db8a24441 | ||
|
|
2f86e9b608 | ||
|
|
9d8c6557ae | ||
|
|
257eb5d598 | ||
|
|
9f63453e67 | ||
|
|
5858bda854 | ||
|
|
c784c89fe3 | ||
|
|
186dae059e | ||
|
|
ded3d2c929 | ||
|
|
b55c592714 | ||
|
|
1d226979d1 | ||
|
|
6fad628551 | ||
|
|
8b426ed71d | ||
|
|
c7060c147d | ||
|
|
db49532211 | ||
|
|
1a52de241d | ||
|
|
b35501e6ad | ||
|
|
7f8f690497 | ||
|
|
356b02ac79 | ||
|
|
d5fdd644cf |
6
.github/scripts/register-releases.mjs
vendored
6
.github/scripts/register-releases.mjs
vendored
@@ -74,6 +74,12 @@ for (const base of fs.readdirSync(dir)) {
|
|||||||
} else if (/x64\.zip$/.test(base)) {
|
} else if (/x64\.zip$/.test(base)) {
|
||||||
platform = 'mac-x64'
|
platform = 'mac-x64'
|
||||||
slot = 'upd'
|
slot = 'upd'
|
||||||
|
} else if (/win7.*\.exe$/i.test(base)) {
|
||||||
|
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||||
|
// segment so it never collides with the standard win exe in the same
|
||||||
|
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||||
|
platform = 'win-legacy'
|
||||||
|
slot = 'main'
|
||||||
} else if (/\.exe$/.test(base)) {
|
} else if (/\.exe$/.test(base)) {
|
||||||
platform = 'win'
|
platform = 'win'
|
||||||
slot = 'main'
|
slot = 'main'
|
||||||
|
|||||||
154
.github/workflows/publish-desktop.yml
vendored
154
.github/workflows/publish-desktop.yml
vendored
@@ -1,154 +0,0 @@
|
|||||||
name: Publish Desktop
|
|
||||||
|
|
||||||
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
|
||||||
# to "live". By this point:
|
|
||||||
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
|
||||||
# registered them in D1 as unpublished (is_latest=0);
|
|
||||||
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
|
||||||
# dmgs and re-uploaded the stapled bytes to R2.
|
|
||||||
#
|
|
||||||
# This workflow, triggered manually with the version to publish:
|
|
||||||
# 1. pulls every artifact for that version back from R2,
|
|
||||||
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
|
||||||
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
|
||||||
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
|
||||||
# 4. creates/updates the GitHub Release and attaches the installers.
|
|
||||||
#
|
|
||||||
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
make_latest:
|
|
||||||
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
github_release:
|
|
||||||
description: "Create/update the GitHub Release and attach installers."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish ${{ github.event.inputs.version }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Guard on Cloudflare secrets
|
|
||||||
id: guard
|
|
||||||
env:
|
|
||||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$CF_TOKEN" ]; then
|
|
||||||
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Resolve version artifacts from D1
|
|
||||||
id: rows
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
|
||||||
# Read them back so we know exactly which objects to pull from R2.
|
|
||||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
|
||||||
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
|
||||||
echo "$out"
|
|
||||||
echo "$out" | node -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
||||||
const rows = (Array.isArray(data) ? data : [data])
|
|
||||||
.flatMap(r => (r.results || []));
|
|
||||||
if (!rows.length) {
|
|
||||||
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
|
||||||
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Download version artifacts from R2
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
|
||||||
# auto-update file (update_filename: zip) for every row. Keys are
|
|
||||||
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
|
||||||
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
|
||||||
base="$(basename "$key")"
|
|
||||||
r2key="desktop/${key}"
|
|
||||||
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
|
||||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
|
||||||
--file "dist/${base}" --remote
|
|
||||||
done
|
|
||||||
echo "Downloaded:"; ls -la dist
|
|
||||||
|
|
||||||
- name: Reminder — mac dmgs must be notarized before publishing
|
|
||||||
run: |
|
|
||||||
# Stapling can only be validated on macOS (xcrun stapler validate),
|
|
||||||
# which this Linux runner doesn't have. The authoritative check runs in
|
|
||||||
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
|
||||||
# This step is just a loud reminder in the log.
|
|
||||||
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
|
||||||
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
|
||||||
|
|
||||||
- name: Update D1 (recompute sha512 + set latest)
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
|
||||||
run: |
|
|
||||||
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
|
||||||
# become latest, so the site keeps serving the last stable build.
|
|
||||||
case "$VER" in
|
|
||||||
*-*) is_pre=1 ;;
|
|
||||||
*) is_pre=0 ;;
|
|
||||||
esac
|
|
||||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
|
||||||
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
|
||||||
else
|
|
||||||
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Re-hash the real (stapled) bytes and re-store every row with both the
|
|
||||||
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
|
||||||
# build stage; --latest also clears the previous latest per platform.
|
|
||||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
|
||||||
echo "==> D1 statements:"; cat d1.sql
|
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
|
||||||
|
|
||||||
- name: Create/update GitHub Release and attach installers
|
|
||||||
if: github.event.inputs.github_release == 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
tag="v${VER}"
|
|
||||||
case "$VER" in
|
|
||||||
*-*) prerelease="--prerelease" ;;
|
|
||||||
*) prerelease="" ;;
|
|
||||||
esac
|
|
||||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "$tag" --generate-notes $prerelease
|
|
||||||
fi
|
|
||||||
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
|
||||||
# zip is the auto-update artifact; attach it too so the GitHub Release
|
|
||||||
# is a complete mirror (nullglob avoids errors when a type is absent).
|
|
||||||
shopt -s nullglob
|
|
||||||
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
|
||||||
--repo "$GITHUB_REPOSITORY" --clobber
|
|
||||||
230
.github/workflows/release-win7.yml
vendored
Normal file
230
.github/workflows/release-win7.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: Release Desktop (Win7 legacy)
|
||||||
|
|
||||||
|
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
|
||||||
|
#
|
||||||
|
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
|
||||||
|
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
|
||||||
|
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
|
||||||
|
#
|
||||||
|
# To support Win7 we must pin BOTH halves to the last versions that still
|
||||||
|
# target it:
|
||||||
|
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
|
||||||
|
# - Python 3.8 (last CPython to support Win7)
|
||||||
|
#
|
||||||
|
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
|
||||||
|
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
|
||||||
|
# then — exactly like the main pipeline — uploads it to R2 and registers a
|
||||||
|
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
|
||||||
|
# stays invisible until promoted, so it can't accidentally get served to Win10
|
||||||
|
# users). Because it's stamped with the SAME version as the standard release,
|
||||||
|
# the download page shows both Windows builds under one version row, and the
|
||||||
|
# /update feed serves each build to its own clients. Delete this file whenever
|
||||||
|
# legacy Windows support is no longer worth maintaining — the main pipeline is
|
||||||
|
# unaffected.
|
||||||
|
#
|
||||||
|
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
|
||||||
|
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Windows x64 (Win7 legacy)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Python 3.8 is the last CPython that supports Windows 7. A backend built
|
||||||
|
# with it (via PyInstaller) still runs on Win7 even though the CI host is
|
||||||
|
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
|
||||||
|
# OS, not the build machine's.
|
||||||
|
- name: Set up Python 3.8
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Build Python backend (PyInstaller, Python 3.8)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
|
||||||
|
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
|
||||||
|
# for this legacy build. We rewrite them into a throwaway requirements
|
||||||
|
# file so the repo's source stays untouched (main pipeline keeps its
|
||||||
|
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
|
||||||
|
sed 's/^playwright==.*/playwright==1.48.0/' \
|
||||||
|
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
|
||||||
|
pip install -r /tmp/requirements-win7.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
# Run from repo root so the spec's relative datas resolve correctly.
|
||||||
|
pyinstaller desktop/build/cowagent-backend.spec \
|
||||||
|
--noconfirm \
|
||||||
|
--distpath desktop/build/dist \
|
||||||
|
--workpath desktop/build/build-work
|
||||||
|
|
||||||
|
- name: Install desktop deps
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Write version into package.json
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
|
||||||
|
# this out of package.json so the repo's committed deps stay on Electron 33
|
||||||
|
# for the main pipeline. electron-builder reads the installed Electron
|
||||||
|
# version from node_modules, so this is all that's needed to package v22.
|
||||||
|
- name: Pin Electron to 22 (last Win7-capable)
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm install --no-save electron@22.3.27
|
||||||
|
|
||||||
|
- name: Compile (vite + tsc)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Same signing setup as the main pipeline: download the signtool CLI (URL
|
||||||
|
# from a repo variable so nothing is hardcoded in a public workflow). Only
|
||||||
|
# runs when a URL is configured; otherwise the build stays unsigned but
|
||||||
|
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
|
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
|
||||||
|
# hook the main pipeline uses (signs app + backend + installer via the
|
||||||
|
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
|
||||||
|
# installer is still produced (unsigned). --publish never emits the exe
|
||||||
|
# without touching any feed. Invoke via node (not npx) to avoid the
|
||||||
|
# Windows npx.cmd wrapper returning early (see release.yml).
|
||||||
|
#
|
||||||
|
# -c.win.artifactName injects a "win7" segment into the file name
|
||||||
|
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
|
||||||
|
# builds differ by ${arch}: it keeps the legacy exe from colliding with
|
||||||
|
# the standard win exe in the same v<version>/ folder, and lets
|
||||||
|
# register-releases.mjs map it to the win-legacy platform by name.
|
||||||
|
- name: Build installer (electron-builder, Electron 22)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
|
run: |
|
||||||
|
node node_modules/electron-builder/cli.js --win --x64 \
|
||||||
|
--config electron-builder.win.js \
|
||||||
|
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
|
||||||
|
--publish never
|
||||||
|
|
||||||
|
# Collect the installer + its blockmap (differential updates). The .yml
|
||||||
|
# feed is NOT uploaded: the /update Function generates it dynamically from
|
||||||
|
# D1 (same as the main pipeline), so it isn't needed here.
|
||||||
|
- name: Upload installer artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: |
|
||||||
|
desktop/release/*.exe
|
||||||
|
desktop/release/*.blockmap
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
|
||||||
|
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
|
||||||
|
# wrangler@latest works (the build job pins Node 20 for Electron 22).
|
||||||
|
#
|
||||||
|
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
|
||||||
|
# build — its "win7" name segment keeps them distinct — and register-releases
|
||||||
|
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
|
||||||
|
# publish workflow). Because the version matches the standard release, the
|
||||||
|
# download page merges both Windows builds into one version row.
|
||||||
|
publish:
|
||||||
|
name: Publish to R2 + D1
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$CF_TOKEN" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Upload installer to R2
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in dist/*.exe dist/*.blockmap; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
|
||||||
|
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||||
|
--file "$f" --remote
|
||||||
|
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
|
||||||
|
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
|
||||||
|
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
|
||||||
|
- name: Register release row in D1
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
@@ -113,7 +113,7 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||||
|
|||||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
|||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# BrowserTool (requires playwright)
|
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||||
|
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||||
|
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError as e:
|
|
||||||
logger.info(
|
|
||||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -463,7 +463,18 @@ class BrowserService:
|
|||||||
headless_cfg = self._config.get("headless")
|
headless_cfg = self._config.get("headless")
|
||||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
launch_args = ["--disable-dev-shm-usage"]
|
launch_args = [
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||||
|
# browser prompt, and Chrome's background/component network chatter.
|
||||||
|
# These have no effect on page interaction but noticeably speed up
|
||||||
|
# cold starts and each navigation.
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-features=Translate,OptimizationHints",
|
||||||
|
]
|
||||||
if self._headless:
|
if self._headless:
|
||||||
launch_args.append("--no-sandbox")
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
@@ -733,11 +744,15 @@ class BrowserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Navigation failed: {e}"}
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||||
|
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||||
|
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||||
|
# short grace period for initial render/XHR, then proceed.
|
||||||
try:
|
try:
|
||||||
page.wait_for_load_state("networkidle", timeout=8000)
|
page.wait_for_load_state("networkidle", timeout=1500)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
title = page.title()
|
title = page.title()
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
|||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
return True # agent ran but produced nothing; don't loop
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
if action.get("silent", False):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
channel = create_channel(channel_type)
|
channel = create_channel(channel_type)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
|||||||
"schedule_value": {
|
"schedule_value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
|||||||
"channel_type": self.config.get("channel_type", "unknown"),
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
"notify_session_id": notify_session_id,
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
# silent only applies to ai_task; fixed messages always deliver
|
||||||
|
if kwargs.get("silent", False):
|
||||||
|
action["silent"] = True
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
msg = context.kwargs.get("msg")
|
msg = context.kwargs.get("msg")
|
||||||
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
|
|||||||
else:
|
else:
|
||||||
content_desc = f"🤖 AI任务: {ai_task}"
|
content_desc = f"🤖 AI任务: {ai_task}"
|
||||||
|
|
||||||
|
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||||
|
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"✅ 定时任务创建成功\n\n"
|
f"✅ 定时任务创建成功\n\n"
|
||||||
f"📋 任务ID: {task_id}\n"
|
f"📋 任务ID: {task_id}\n"
|
||||||
f"📝 名称: {name}\n"
|
f"📝 名称: {name}\n"
|
||||||
f"⏰ 调度: {schedule_desc}\n"
|
f"⏰ 调度: {schedule_desc}\n"
|
||||||
f"👤 接收者: {receiver_desc}\n"
|
f"👤 接收者: {receiver_desc}\n"
|
||||||
f"{content_desc}\n"
|
f"{content_desc}{silent_desc}\n"
|
||||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -222,14 +215,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -261,14 +247,7 @@ class ToolManager:
|
|||||||
# If there are missing tools, record warnings
|
# If there are missing tools, record warnings
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
for tool_name in missing_tools:
|
for tool_name in missing_tools:
|
||||||
if tool_name == "browser":
|
if tool_name == "google_search":
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif tool_name == "google_search":
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||||
f" Get API key from: https://serper.dev\n"
|
f" Get API key from: https://serper.dev\n"
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ from bridge.context import ContextType
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
from channel.feishu.feishu_message import FeishuMessage
|
from channel.feishu.feishu_message import FeishuMessage
|
||||||
|
from channel.feishu.feishu_static_card import build_text_delivery
|
||||||
|
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||||
|
from channel.feishu.feishu_scheduler_card import (
|
||||||
|
build_scheduler_card,
|
||||||
|
handle_scheduler_action,
|
||||||
|
tasks_for_receivers,
|
||||||
|
)
|
||||||
from common import utils
|
from common import utils
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -350,6 +357,10 @@ class FeiShuChanel(ChatChannel):
|
|||||||
def _startup_websocket(self):
|
def _startup_websocket(self):
|
||||||
"""启动长连接接收事件(websocket模式)"""
|
"""启动长连接接收事件(websocket模式)"""
|
||||||
_ensure_lark_imported()
|
_ensure_lark_imported()
|
||||||
|
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||||
|
P2CardActionTriggerResponse,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||||
|
|
||||||
# 创建事件处理器
|
# 创建事件处理器
|
||||||
@@ -372,10 +383,25 @@ class FeiShuChanel(ChatChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def handle_card_action(data):
|
||||||
|
"""Handle Card 2.0 button callbacks and update the card in place."""
|
||||||
|
try:
|
||||||
|
event_dict = json.loads(lark.JSON.marshal(data))
|
||||||
|
response = self._handle_card_action_event(event_dict.get("event", {}))
|
||||||
|
return P2CardActionTriggerResponse(response)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
|
||||||
|
return P2CardActionTriggerResponse(
|
||||||
|
{"toast": {"type": "error", "content": "Task update failed"}}
|
||||||
|
)
|
||||||
|
|
||||||
# 构建事件分发器
|
# 构建事件分发器
|
||||||
event_handler = lark.EventDispatcherHandler.builder("", "") \
|
event_handler = (
|
||||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
lark.EventDispatcherHandler.builder("", "")
|
||||||
|
.register_p2_im_message_receive_v1(handle_message_event)
|
||||||
|
.register_p2_card_action_trigger(handle_card_action)
|
||||||
.build()
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
def start_client_with_retry():
|
def start_client_with_retry():
|
||||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||||
@@ -470,6 +496,97 @@ class FeiShuChanel(ChatChannel):
|
|||||||
# so reaching here means the bot was indeed mentioned.
|
# so reaching here means the bot was indeed mentioned.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _get_scheduler_task_store(self):
|
||||||
|
"""Reuse the live scheduler store, with a path-compatible fallback."""
|
||||||
|
from agent.tools.scheduler.integration import get_task_store
|
||||||
|
|
||||||
|
task_store = get_task_store()
|
||||||
|
if task_store is not None:
|
||||||
|
return task_store
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
|
||||||
|
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
|
||||||
|
|
||||||
|
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
|
||||||
|
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
|
||||||
|
task_store = self._get_scheduler_task_store()
|
||||||
|
receivers = {feishu_msg.other_user_id}
|
||||||
|
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
|
||||||
|
card = build_scheduler_card(tasks)
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + feishu_msg.access_token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
content = json.dumps(card, ensure_ascii=False)
|
||||||
|
|
||||||
|
if is_group and feishu_msg.msg_id:
|
||||||
|
url = (
|
||||||
|
"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||||
|
f"{feishu_msg.msg_id}/reply"
|
||||||
|
)
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json={"msg_type": "interactive", "content": content},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params={"receive_id_type": receive_id_type},
|
||||||
|
json={
|
||||||
|
"receive_id": feishu_msg.other_user_id,
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
if result.get("code") == 0:
|
||||||
|
logger.info("[FeiShu] scheduler card sent")
|
||||||
|
return True
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] scheduler card failed, "
|
||||||
|
f"code={result.get('code')}, msg={result.get('msg')}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _handle_card_action_event(self, event: dict) -> dict:
|
||||||
|
"""Apply a scheduler card action within its chat/operator ownership scope."""
|
||||||
|
action = event.get("action") or {}
|
||||||
|
value = action.get("value") or {}
|
||||||
|
if value.get("cowagent") != "scheduler":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context = event.get("context") or {}
|
||||||
|
operator = event.get("operator") or {}
|
||||||
|
callback_receivers = {
|
||||||
|
receiver
|
||||||
|
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
|
||||||
|
if receiver
|
||||||
|
}
|
||||||
|
target_receiver = value.get("receiver")
|
||||||
|
allowed_receivers = (
|
||||||
|
{target_receiver}
|
||||||
|
if target_receiver and target_receiver in callback_receivers
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
response = handle_scheduler_action(
|
||||||
|
value,
|
||||||
|
self._get_scheduler_task_store(),
|
||||||
|
allowed_receivers,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[FeiShu] scheduler card action handled, "
|
||||||
|
f"action={value.get('action')}, task_id={value.get('task_id')}"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
def _handle_message_event(self, event: dict):
|
def _handle_message_event(self, event: dict):
|
||||||
"""
|
"""
|
||||||
处理消息事件的核心逻辑
|
处理消息事件的核心逻辑
|
||||||
@@ -521,6 +638,11 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not feishu_msg:
|
if not feishu_msg:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
|
||||||
|
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
|
||||||
|
logger.warning("[FeiShu] /tasks card delivery failed")
|
||||||
|
return
|
||||||
|
|
||||||
# 处理文件缓存逻辑
|
# 处理文件缓存逻辑
|
||||||
from channel.file_cache import get_file_cache
|
from channel.file_cache import get_file_cache
|
||||||
file_cache = get_file_cache()
|
file_cache = get_file_cache()
|
||||||
@@ -628,7 +750,13 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||||
reply_content = reply.content
|
reply_content = reply.content
|
||||||
content_key = "text"
|
content_key = "text"
|
||||||
if reply.type == ReplyType.IMAGE_URL:
|
prepared_content_json = None
|
||||||
|
if reply.type == ReplyType.TEXT:
|
||||||
|
msg_type, prepared_content_json = build_text_delivery(
|
||||||
|
reply.content,
|
||||||
|
enabled=conf().get("feishu_markdown_card", True),
|
||||||
|
)
|
||||||
|
elif reply.type == ReplyType.IMAGE_URL:
|
||||||
# 图片上传
|
# 图片上传
|
||||||
reply_content = self._upload_image_url(reply.content, access_token)
|
reply_content = self._upload_image_url(reply.content, access_token)
|
||||||
if not reply_content:
|
if not reply_content:
|
||||||
@@ -690,7 +818,11 @@ class FeiShuChanel(ChatChannel):
|
|||||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||||
|
|
||||||
# Build content JSON
|
# Build content JSON
|
||||||
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
content_json = prepared_content_json or (
|
||||||
|
json.dumps(reply_content, ensure_ascii=False)
|
||||||
|
if content_key is None
|
||||||
|
else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||||
|
)
|
||||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||||
|
|
||||||
if can_reply:
|
if can_reply:
|
||||||
@@ -714,10 +846,377 @@ class FeiShuChanel(ChatChannel):
|
|||||||
res = res.json()
|
res = res.json()
|
||||||
if res.get("code") == 0:
|
if res.get("code") == 0:
|
||||||
logger.info(f"[FeiShu] send message success")
|
logger.info(f"[FeiShu] send message success")
|
||||||
|
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
|
||||||
|
logger.warning(
|
||||||
|
"[FeiShu] Markdown card failed, falling back to text, "
|
||||||
|
f"code={res.get('code')}, msg={res.get('msg')}"
|
||||||
|
)
|
||||||
|
fallback_data = {
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
if can_reply:
|
||||||
|
fallback_res = requests.post(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=fallback_data,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fallback_data["receive_id"] = context.get("receiver")
|
||||||
|
fallback_res = requests.post(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
json=fallback_data,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
fallback_body = fallback_res.json()
|
||||||
|
if fallback_body.get("code") == 0:
|
||||||
|
logger.info("[FeiShu] text fallback sent successfully")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] text fallback failed, "
|
||||||
|
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
def _make_feishu_stream_callback(self, context, access_token):
|
def _make_feishu_stream_callback(self, context, access_token):
|
||||||
|
"""Route to 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]
|
||||||
|
context["feishu_streamed"] = True
|
||||||
|
|
||||||
|
if not has_card and not init_busy:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
_create_and_send_card()
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
_drain_push_queue()
|
||||||
|
_stream_update_text(final_text)
|
||||||
|
_update_full_card(streaming=False)
|
||||||
|
push_queue.put(None)
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
|
def _make_feishu_stream_callback_plain(self, context, access_token):
|
||||||
"""
|
"""
|
||||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
@@ -1565,6 +2064,7 @@ class FeishuController:
|
|||||||
FAILED_MSG = '{"success": false}'
|
FAILED_MSG = '{"success": false}'
|
||||||
SUCCESS_MSG = '{"success": true}'
|
SUCCESS_MSG = '{"success": true}'
|
||||||
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
||||||
|
CARD_ACTION_TYPE = "card.action.trigger"
|
||||||
|
|
||||||
def GET(self):
|
def GET(self):
|
||||||
return "Feishu service start success!"
|
return "Feishu service start success!"
|
||||||
@@ -1581,15 +2081,27 @@ class FeishuController:
|
|||||||
varify_res = {"challenge": request.get("challenge")}
|
varify_res = {"challenge": request.get("challenge")}
|
||||||
return json.dumps(varify_res)
|
return json.dumps(varify_res)
|
||||||
|
|
||||||
# 2.消息接收处理
|
# 2. Verify callbacks. Card callbacks may carry the verification
|
||||||
# token 校验
|
# token in event.token while message events carry it in the header.
|
||||||
header = request.get("header")
|
header = request.get("header") or {}
|
||||||
if not header or header.get("token") != channel.feishu_token:
|
event = request.get("event") or {}
|
||||||
|
event_type = header.get("event_type") or request.get("type")
|
||||||
|
callback_token = (
|
||||||
|
header.get("token")
|
||||||
|
or event.get("token")
|
||||||
|
or request.get("token")
|
||||||
|
)
|
||||||
|
if callback_token != channel.feishu_token:
|
||||||
return self.FAILED_MSG
|
return self.FAILED_MSG
|
||||||
|
|
||||||
# 处理消息事件
|
if event_type == self.CARD_ACTION_TYPE and event:
|
||||||
event = request.get("event")
|
return json.dumps(
|
||||||
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
|
channel._handle_card_action_event(event),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Handle message events.
|
||||||
|
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
|
||||||
channel._handle_message_event(event)
|
channel._handle_message_event(event)
|
||||||
|
|
||||||
return self.SUCCESS_MSG
|
return self.SUCCESS_MSG
|
||||||
|
|||||||
204
channel/feishu/feishu_progress_card.py
Normal file
204
channel/feishu/feishu_progress_card.py
Normal 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
|
||||||
185
channel/feishu/feishu_scheduler_card.py
Normal file
185
channel/feishu/feishu_scheduler_card.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Feishu scheduler card rendering and callback handling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Set
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_TASKS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
|
||||||
|
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
|
||||||
|
visible = []
|
||||||
|
for task in tasks:
|
||||||
|
action = task.get("action") or {}
|
||||||
|
if action.get("channel_type") != "feishu":
|
||||||
|
continue
|
||||||
|
if action.get("receiver") in receivers:
|
||||||
|
visible.append(task)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
|
||||||
|
"""Build a Card 2.0 task list with explicit, idempotent actions."""
|
||||||
|
task_list = list(tasks)
|
||||||
|
elements: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if not task_list:
|
||||||
|
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
|
||||||
|
else:
|
||||||
|
for index, task in enumerate(task_list[:_MAX_TASKS]):
|
||||||
|
if index:
|
||||||
|
elements.append({"tag": "hr"})
|
||||||
|
task_id = str(task.get("id") or "")
|
||||||
|
receiver = str((task.get("action") or {}).get("receiver") or "")
|
||||||
|
enabled = task.get("enabled", True)
|
||||||
|
status = "Enabled" if enabled else "Disabled"
|
||||||
|
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
|
||||||
|
task.get("name") or "Unnamed task",
|
||||||
|
status,
|
||||||
|
task_id,
|
||||||
|
_format_schedule(task.get("schedule") or {}),
|
||||||
|
next_run,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
toggle_action = "disable" if enabled else "enable"
|
||||||
|
toggle_text = "Disable" if enabled else "Enable"
|
||||||
|
toggle_type = "default" if enabled else "primary"
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"tag": "column_set",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"tag": "column",
|
||||||
|
"elements": [
|
||||||
|
_button(
|
||||||
|
toggle_text,
|
||||||
|
toggle_type,
|
||||||
|
toggle_action,
|
||||||
|
task_id,
|
||||||
|
receiver,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "column",
|
||||||
|
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
hidden = len(task_list) - _MAX_TASKS
|
||||||
|
if hidden > 0:
|
||||||
|
elements.extend(
|
||||||
|
[
|
||||||
|
{"tag": "hr"},
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": "{} more tasks are hidden.".format(hidden),
|
||||||
|
"text_size": "notation",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": "2.0",
|
||||||
|
"config": {"update_multi": True, "enable_forward_interaction": False},
|
||||||
|
"header": {
|
||||||
|
"template": "blue",
|
||||||
|
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
|
||||||
|
},
|
||||||
|
"body": {"elements": elements},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_scheduler_action(
|
||||||
|
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Apply an owned scheduler action and return a Feishu callback response."""
|
||||||
|
if value.get("cowagent") != "scheduler":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
task_id = str(value.get("task_id") or "")
|
||||||
|
action = value.get("action")
|
||||||
|
if not task_id or action not in {"enable", "disable", "delete"}:
|
||||||
|
return _toast("error", "Invalid scheduler action")
|
||||||
|
|
||||||
|
task = task_store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return _response(
|
||||||
|
"info",
|
||||||
|
"Task no longer exists",
|
||||||
|
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
task_receiver = (task.get("action") or {}).get("receiver")
|
||||||
|
task_channel = (task.get("action") or {}).get("channel_type")
|
||||||
|
value_receiver = value.get("receiver")
|
||||||
|
if (
|
||||||
|
task_channel != "feishu"
|
||||||
|
or task_receiver not in allowed_receivers
|
||||||
|
or (value_receiver and value_receiver != task_receiver)
|
||||||
|
):
|
||||||
|
return _toast("error", "Task is not available in this chat")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == "delete":
|
||||||
|
task_store.delete_task(task_id)
|
||||||
|
message = "Task deleted"
|
||||||
|
else:
|
||||||
|
enabled = action == "enable"
|
||||||
|
task_store.enable_task(task_id, enabled)
|
||||||
|
message = "Task enabled" if enabled else "Task disabled"
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return _toast("error", "Task update failed: {}".format(exc))
|
||||||
|
|
||||||
|
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
|
||||||
|
return _response("success", message, build_scheduler_card(visible))
|
||||||
|
|
||||||
|
|
||||||
|
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
response = _toast(toast_type, content)
|
||||||
|
response["card"] = {"type": "raw", "data": card}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
|
||||||
|
return {"toast": {"type": toast_type, "content": content}}
|
||||||
|
|
||||||
|
|
||||||
|
def _button(
|
||||||
|
text: str,
|
||||||
|
button_type: str,
|
||||||
|
action: str,
|
||||||
|
task_id: str,
|
||||||
|
receiver: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": text},
|
||||||
|
"type": button_type,
|
||||||
|
"value": {
|
||||||
|
"cowagent": "scheduler",
|
||||||
|
"action": action,
|
||||||
|
"task_id": task_id,
|
||||||
|
"receiver": receiver,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_schedule(schedule: Dict[str, Any]) -> str:
|
||||||
|
schedule_type = schedule.get("type")
|
||||||
|
if schedule_type == "cron":
|
||||||
|
return "cron {}".format(schedule.get("expression") or "?")
|
||||||
|
if schedule_type == "interval":
|
||||||
|
return "every {}s".format(schedule.get("seconds") or "?")
|
||||||
|
if schedule_type == "once":
|
||||||
|
return "once at {}".format(schedule.get("run_at") or "?")
|
||||||
|
return str(schedule_type or "unknown schedule")
|
||||||
46
channel/feishu/feishu_static_card.py
Normal file
46
channel/feishu/feishu_static_card.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
_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*$")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1707,7 +1707,7 @@ class ConfigHandler:
|
|||||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||||
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
]
|
]
|
||||||
@@ -1794,7 +1794,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "moonshot_base_url",
|
"api_base_key": "moonshot_base_url",
|
||||||
"api_base_default": "https://api.moonshot.cn/v1",
|
"api_base_default": "https://api.moonshot.cn/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||||
}),
|
}),
|
||||||
("qianfan", {
|
("qianfan", {
|
||||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||||
@@ -4610,11 +4610,33 @@ class SchedulerUpdateHandler:
|
|||||||
|
|
||||||
# Update action
|
# Update action
|
||||||
if "action" in body:
|
if "action" in body:
|
||||||
action = body["action"]
|
|
||||||
channel_type = action.get("channel_type", "web")
|
|
||||||
|
|
||||||
# Get the task's original channel_type
|
# Get the task's original channel_type
|
||||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
original_action = original_task.get("action", {})
|
||||||
|
if not isinstance(original_action, dict):
|
||||||
|
original_action = {}
|
||||||
|
action_patch = body["action"]
|
||||||
|
if not isinstance(action_patch, dict):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Action must be an object."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# The Web editor only exposes a subset of action fields. Merge
|
||||||
|
# that patch into the stored action so scheduler metadata such
|
||||||
|
# as notify_session_id, silent, and channel-specific delivery
|
||||||
|
# fields survive unrelated edits.
|
||||||
|
action = dict(original_action)
|
||||||
|
action.update(action_patch)
|
||||||
|
action_type = action.get("type")
|
||||||
|
if action_type == "send_message":
|
||||||
|
action.pop("task_description", None)
|
||||||
|
action.pop("silent", None)
|
||||||
|
elif action_type == "agent_task":
|
||||||
|
action.pop("content", None)
|
||||||
|
|
||||||
|
old_channel = original_action.get("channel_type", "web")
|
||||||
|
channel_type = action.get("channel_type") or old_channel
|
||||||
|
action["channel_type"] = channel_type
|
||||||
|
|
||||||
# If channel type changed or no receiver, reject the update.
|
# If channel type changed or no receiver, reject the update.
|
||||||
# Note: the web UI disables the channel selector, so this branch
|
# Note: the web UI disables the channel selector, so this branch
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
|
||||||
|
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
|
||||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||||
KIMI_K2 = "kimi-k2"
|
KIMI_K2 = "kimi-k2"
|
||||||
KIMI_K2_5 = "kimi-k2.5"
|
KIMI_K2_5 = "kimi-k2.5"
|
||||||
@@ -235,7 +236,7 @@ MODEL_LIST = [
|
|||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||||
|
|
||||||
# ModelScope
|
# ModelScope
|
||||||
MODELSCOPE,
|
MODELSCOPE,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
"feishu_app_id": "",
|
"feishu_app_id": "",
|
||||||
"feishu_app_secret": "",
|
"feishu_app_secret": "",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true,
|
||||||
|
"feishu_markdown_card": true,
|
||||||
|
"feishu_progress_card": false,
|
||||||
"dingtalk_client_id": "",
|
"dingtalk_client_id": "",
|
||||||
"dingtalk_client_secret": "",
|
"dingtalk_client_secret": "",
|
||||||
"wecom_bot_id": "",
|
"wecom_bot_id": "",
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ available_setting = {
|
|||||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||||
|
"feishu_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 config
|
||||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
let getWindow: () => BrowserWindow | null = () => null
|
||||||
|
|
||||||
|
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||||
|
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||||
|
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||||
|
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||||
|
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||||
|
// flag, so the same source serves the right feed on whatever it runs on.
|
||||||
|
function isLegacyWindows(): boolean {
|
||||||
|
if (process.platform !== 'win32') return false
|
||||||
|
const major = Number((os.release() || '').split('.')[0])
|
||||||
|
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||||
|
return Number.isFinite(major) && major < 10
|
||||||
|
}
|
||||||
|
|
||||||
// The update feed. Both entries hit the same Pages Function
|
// The update feed. Both entries hit the same Pages Function
|
||||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||||
// identical either way, so we can freely switch the feed URL between attempts
|
// identical either way, so we can freely switch the feed URL between attempts
|
||||||
// to fall back from one download origin to the other.
|
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||||
|
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||||
|
|
||||||
// Which origin the current session prefers, derived from the app UI language
|
// Which origin the current session prefers, derived from the app UI language
|
||||||
|
|||||||
@@ -100,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
|||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string
|
content: string
|
||||||
|
/**
|
||||||
|
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||||
|
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||||
|
* the knowledge viewer so index links open the target doc rather than firing
|
||||||
|
* an "application cannot be opened (-120)" error in Electron.
|
||||||
|
*/
|
||||||
|
onInternalLink?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const html = useMemo(() => md.render(content || ''), [content])
|
const html = useMemo(() => md.render(content || ''), [content])
|
||||||
|
|
||||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = useCallback(
|
||||||
const target = e.target as HTMLElement
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
const target = e.target as HTMLElement
|
||||||
if (!btn) return
|
|
||||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||||
if (!pre) return
|
if (onInternalLink) {
|
||||||
navigator.clipboard.writeText(pre.textContent || '')
|
const a = target.closest('a') as HTMLAnchorElement | null
|
||||||
const original = btn.textContent
|
if (a) {
|
||||||
btn.textContent = t('msg_copied')
|
const href = a.getAttribute('href') || ''
|
||||||
btn.classList.add('copied')
|
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||||
setTimeout(() => {
|
e.preventDefault()
|
||||||
btn.textContent = original
|
onInternalLink(href)
|
||||||
btn.classList.remove('copied')
|
return
|
||||||
}, 1600)
|
}
|
||||||
}, [])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard.writeText(pre.textContent || '')
|
||||||
|
const original = btn.textContent
|
||||||
|
btn.textContent = t('msg_copied')
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 1600)
|
||||||
|
},
|
||||||
|
[onInternalLink]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
muted, separated from the final answer by a dashed divider. */}
|
muted, separated from the final answer by a dashed divider. */}
|
||||||
{(hasSteps || hasLiveReasoning) && (
|
{(hasSteps || hasLiveReasoning) && (
|
||||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
|
||||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||||
|
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||||
|
must render after all committed steps (tools/thinking), not at
|
||||||
|
the very top of the bubble. */}
|
||||||
|
{hasLiveReasoning && (
|
||||||
|
<div className={hasSteps ? 'mt-1' : ''}>
|
||||||
|
<ThinkingStep content={message.reasoning!} streaming />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||||
import type { MessageStep } from '../types'
|
import type { MessageStep } from '../types'
|
||||||
|
import { t } from '../i18n'
|
||||||
import Markdown from './Markdown'
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
|||||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<Brain size={12} className="flex-shrink-0" />
|
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: '已中止',
|
msg_cancelled: '已中止',
|
||||||
msg_self_learned: '自主学习',
|
msg_self_learned: '自主学习',
|
||||||
msg_stop: '停止',
|
msg_stop: '停止',
|
||||||
|
thinking_in_progress: '思考中…',
|
||||||
|
thinking_done: '已深度思考',
|
||||||
chat_clear_context: '清除上下文',
|
chat_clear_context: '清除上下文',
|
||||||
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
chat_load_earlier: '加载更早的消息',
|
chat_load_earlier: '加载更早的消息',
|
||||||
chat_send: '发送',
|
chat_send: '发送',
|
||||||
chat_attach: '添加附件',
|
chat_attach: '添加附件',
|
||||||
slash_hint: '输入 / 查看命令',
|
slash_hint: '输入 / 查看命令',
|
||||||
chat_welcome: '有什么可以帮你的?',
|
chat_welcome: '有什么可以帮你的?',
|
||||||
chat_empty_hint: '发送一条消息开始对话',
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务',
|
example_task_title: '定时任务',
|
||||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: '已连接',
|
channels_connected_section: '已连接',
|
||||||
channels_available_section: '可添加',
|
channels_available_section: '可添加',
|
||||||
channels_empty_connected: '暂无已连接的通道',
|
channels_empty_connected: '暂无已连接的通道',
|
||||||
|
channels_empty: '暂未接入任何通道',
|
||||||
|
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||||
channels_save_ok: '已保存',
|
channels_save_ok: '已保存',
|
||||||
channels_save_error: '保存失败',
|
channels_save_error: '保存失败',
|
||||||
@@ -512,7 +517,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: 'Cancelled',
|
msg_cancelled: 'Cancelled',
|
||||||
msg_self_learned: 'Self-learned',
|
msg_self_learned: 'Self-learned',
|
||||||
msg_stop: 'Stop',
|
msg_stop: 'Stop',
|
||||||
|
thinking_in_progress: 'Thinking…',
|
||||||
|
thinking_done: 'Thought',
|
||||||
chat_clear_context: 'Clear context',
|
chat_clear_context: 'Clear context',
|
||||||
|
context_cleared: '— Context above has been cleared —',
|
||||||
chat_load_earlier: 'Load earlier messages',
|
chat_load_earlier: 'Load earlier messages',
|
||||||
chat_send: 'Send',
|
chat_send: 'Send',
|
||||||
chat_attach: 'Attach file',
|
chat_attach: 'Attach file',
|
||||||
@@ -671,6 +679,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: 'Connected',
|
channels_connected_section: 'Connected',
|
||||||
channels_available_section: 'Available',
|
channels_available_section: 'Available',
|
||||||
channels_empty_connected: 'No connected channels yet',
|
channels_empty_connected: 'No connected channels yet',
|
||||||
|
channels_empty: 'No channels connected',
|
||||||
|
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||||
channels_save_ok: 'Saved',
|
channels_save_ok: 'Saved',
|
||||||
channels_save_error: 'Failed to save',
|
channels_save_error: 'Failed to save',
|
||||||
|
|||||||
@@ -27,21 +27,21 @@
|
|||||||
--danger-border: rgba(239, 68, 68, 0.3);
|
--danger-border: rgba(239, 68, 68, 0.3);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Light theme — layered neutral surfaces */
|
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||||
--bg-base: #fafafa; /* app background */
|
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||||
--bg-surface: #ffffff; /* panels, cards */
|
--bg-surface: #ffffff; /* panels, cards */
|
||||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||||
|
|
||||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||||
--text-secondary: #52525b; /* body, labels */
|
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||||
--text-tertiary: #71717a; /* hints, captions */
|
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||||
--text-disabled: #a1a1aa;
|
--text-disabled: #94a3b8; /* slate-400 */
|
||||||
|
|
||||||
--border-default: #e4e4e7;
|
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||||
--border-strong: #d4d4d8;
|
--border-strong: #d1d5db; /* gray-300 */
|
||||||
--border-subtle: #f0f0f1;
|
--border-subtle: #f3f4f6; /* gray-100 */
|
||||||
|
|
||||||
--overlay: rgba(0, 0, 0, 0.4);
|
--overlay: rgba(0, 0, 0, 0.4);
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Headset,
|
Headset,
|
||||||
Hash,
|
Hash,
|
||||||
AtSign,
|
AtSign,
|
||||||
|
RadioTower,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { t, localizedLabel } from '../i18n'
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{connected.length === 0 && !addOpen ? (
|
{connected.length === 0 && !addOpen ? (
|
||||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||||
|
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||||
|
<RadioTower size={26} className="text-info" />
|
||||||
|
</span>
|
||||||
|
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||||
|
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||||
|
{t('channels_empty_desc')}
|
||||||
|
</p>
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Btn variant="primary" onClick={openAdd}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Plus size={15} />
|
||||||
|
{t('channels_add')}
|
||||||
|
</span>
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
|||||||
import type { Attachment, ChatMessage } from '../types'
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
import { useChatStore } from '../store/chatStore'
|
import { useChatStore } from '../store/chatStore'
|
||||||
import { useSessionStore } from '../store/sessionStore'
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
import { useUIStore } from '../store/uiStore'
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
const clearContext = useChatStore((s) => s.clearContext)
|
||||||
|
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||||
|
|
||||||
const messages = session?.messages ?? []
|
const messages = session?.messages ?? []
|
||||||
const isStreaming = session?.isStreaming ?? false
|
const isStreaming = session?.isStreaming ?? false
|
||||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const id = newSession()
|
const id = newSession()
|
||||||
ensureSession(id)
|
ensureSession(id)
|
||||||
loadHistory(id, 1)
|
loadHistory(id, 1)
|
||||||
}, [newSession, ensureSession, loadHistory])
|
// Auto-expand the session list so the user sees the new/switched session.
|
||||||
|
setSessionsCollapsed(false)
|
||||||
|
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||||
|
|
||||||
const handleClearContext = useCallback(async () => {
|
const handleClearContext = useCallback(async () => {
|
||||||
try {
|
await clearContext(activeId)
|
||||||
await apiClient.clearContext(activeId)
|
scrollToBottom(true)
|
||||||
} catch {
|
}, [clearContext, activeId, scrollToBottom])
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [activeId])
|
|
||||||
|
|
||||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||||
|
{t('welcome_subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-3 max-w-3xl mx-auto">
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) =>
|
||||||
<MessageBubble
|
msg.kind === 'divider' ? (
|
||||||
key={msg.id}
|
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||||
message={msg}
|
<span
|
||||||
onRegenerate={handleRegenerate}
|
className="flex-1 h-px"
|
||||||
onEdit={handleEdit}
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
onDelete={handleDelete}
|
/>
|
||||||
onMediaLoad={handleMediaLoad}
|
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||||
/>
|
<span
|
||||||
))}
|
className="flex-1 h-px"
|
||||||
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onMediaLoad={handleMediaLoad}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||||
|
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||||
|
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||||
|
function stripDuplicateH1(content: string, title: string): string {
|
||||||
|
if (!content) return content
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||||
|
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||||
|
if (!m) return content
|
||||||
|
if (norm(m[1]) !== norm(title)) return content
|
||||||
|
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten the tree into category paths (for destination selectors).
|
// Flatten the tree into category paths (for destination selectors).
|
||||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||||
|
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||||
|
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||||
|
for (const f of list.root_files || []) {
|
||||||
|
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
for (const f of dir.files) {
|
||||||
|
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
for (const c of dir.children) {
|
||||||
|
const hit = walk(c, dirPath)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const d of list.tree || []) {
|
||||||
|
const hit = walk(d, '')
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||||
|
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||||
|
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||||
|
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||||
|
const clean = href.split('#')[0].split('?')[0]
|
||||||
|
if (!clean.endsWith('.md')) return null
|
||||||
|
if (clean.startsWith('knowledge/')) {
|
||||||
|
const path = clean.replace(/^knowledge\//, '')
|
||||||
|
return { path, title: findTitle(list, path) }
|
||||||
|
}
|
||||||
|
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||||
|
return { path: clean, title: findTitle(list, clean) }
|
||||||
|
}
|
||||||
|
// Relative/other path: fall back to matching by filename.
|
||||||
|
const filename = clean.split('/').pop() || clean
|
||||||
|
return findFileByName(list, filename)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a document's display title from its path, falling back to the stem.
|
// Resolve a document's display title from its path, falling back to the stem.
|
||||||
function findTitle(list: KnowledgeList, path: string): string {
|
function findTitle(list: KnowledgeList, path: string): string {
|
||||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
setContent('')
|
setContent('')
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.readKnowledge(path)
|
const res = await apiClient.readKnowledge(path)
|
||||||
setContent(res.content || '')
|
setContent(stripDuplicateH1(res.content || '', title))
|
||||||
} catch {
|
} catch {
|
||||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||||
|
// Falls back silently when the target can't be resolved in the current tree.
|
||||||
|
const openInternalLink = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
if (!data) return
|
||||||
|
const hit = resolveKnowledgeLink(data, href)
|
||||||
|
if (hit) void openDoc(hit.path, hit.title)
|
||||||
|
},
|
||||||
|
[data, openDoc]
|
||||||
|
)
|
||||||
|
|
||||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||||
// currently open doc (or open the first one on the initial load).
|
// currently open doc (or open the first one on the initial load).
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
<Loader2 size={16} className="animate-spin mr-2" />
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Markdown content={content} />
|
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ChatState {
|
|||||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||||
|
|
||||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||||
|
clearContext: (sid: string) => Promise<boolean>
|
||||||
clearLocal: (sid: string) => void
|
clearLocal: (sid: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
cancel: async (sid) => {
|
cancel: async (sid) => {
|
||||||
const s = get().sessions[sid]
|
const s = get().sessions[sid]
|
||||||
if (!s?.requestId) return
|
if (!s?.requestId) return
|
||||||
|
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||||
|
// cancelled, free the input, and tear down the local SSE stream so no
|
||||||
|
// further deltas render after the user hit stop. The backend still gets
|
||||||
|
// the cancel request to abort the running agent task.
|
||||||
|
patchMessages(sid, (msgs) => {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') {
|
||||||
|
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...msgs]
|
||||||
|
})
|
||||||
|
patchSession(sid, { isStreaming: false, requestId: null })
|
||||||
|
const es = streams[sid]
|
||||||
|
if (es) {
|
||||||
|
es.close()
|
||||||
|
delete streams[sid]
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearContext: async (sid) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.clearContext(sid)
|
||||||
|
if (res.status !== 'success') return false
|
||||||
|
// Append a visual divider so the user sees the context was cleared
|
||||||
|
// (mirrors the web console's context-divider).
|
||||||
|
patchMessages(sid, (msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
id: uid('divider'),
|
||||||
|
role: 'system',
|
||||||
|
kind: 'divider',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearLocal: (sid) => {
|
clearLocal: (sid) => {
|
||||||
const es = streams[sid]
|
const es = streams[sid]
|
||||||
if (es) {
|
if (es) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UIState {
|
|||||||
/** Session list panel collapsed (hidden) vs expanded. */
|
/** Session list panel collapsed (hidden) vs expanded. */
|
||||||
sessionsCollapsed: boolean
|
sessionsCollapsed: boolean
|
||||||
toggleSessions: () => void
|
toggleSessions: () => void
|
||||||
|
setSessionsCollapsed: (v: boolean) => void
|
||||||
|
|
||||||
/** Currently active session id (Chat page). */
|
/** Currently active session id (Chat page). */
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||||
return { sessionsCollapsed: next }
|
return { sessionsCollapsed: next }
|
||||||
}),
|
}),
|
||||||
|
setSessionsCollapsed: (v) => {
|
||||||
|
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||||
|
set({ sessionsCollapsed: v })
|
||||||
|
},
|
||||||
|
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
|||||||
// Chat / messages / streaming
|
// Chat / messages / streaming
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export type Role = 'user' | 'assistant'
|
export type Role = 'user' | 'assistant' | 'system'
|
||||||
|
|
||||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||||
export interface MessageStep {
|
export interface MessageStep {
|
||||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
|||||||
/** Sequence numbers from backend (for delete/regenerate). */
|
/** Sequence numbers from backend (for delete/regenerate). */
|
||||||
userSeq?: number
|
userSeq?: number
|
||||||
botSeq?: number
|
botSeq?: number
|
||||||
/** Self-evolution bubble flag. */
|
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||||
kind?: 'evolution'
|
kind?: 'evolution' | 'divider'
|
||||||
extras?: Record<string, unknown>
|
extras?: Record<string, unknown>
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
isCancelled?: boolean
|
isCancelled?: boolean
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"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_id` | Feishu app App ID | - |
|
||||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||||
|
| `feishu_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>
|
</Tab>
|
||||||
</Tabs>
|
</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**.
|
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"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
@@ -98,6 +103,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| Image messages | ✅ send/receive |
|
| Image messages | ✅ send/receive |
|
||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
|
| Markdown card | ✅ for non-streaming and scheduled replies |
|
||||||
|
| 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>
|
<Note>
|
||||||
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
||||||
@@ -108,3 +116,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
After connection, search for the bot name in Feishu to start a chat.
|
After connection, search for the bot name in Feishu to start a chat.
|
||||||
|
|
||||||
To use in groups, add the bot to a group and @-mention it.
|
To use in groups, add the bot to a group and @-mention it.
|
||||||
|
|
||||||
|
Send `/tasks` in a private chat, or @-mention the bot with `/tasks` in a group, to manage tasks belonging to that chat.
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/ja/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"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_id` | Feishu アプリの App ID | - |
|
||||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
|
| `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` |
|
||||||
|
| `feishu_progress_card` | 通常会話のストリーミングをリッチ進捗カード(ステータス、推論/ツールパネル、経過時間)で表示。無効時は通常のタイプライターカード | `false` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -81,7 +84,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
2. **イベントを追加** で「メッセージ受信」を検索し、**メッセージ受信 v2.0** を選択。
|
||||||
|
|
||||||
3. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
3. (任意)**コールバック** で **カードアクショントリガー**(`card.action.trigger`)を追加すると `/tasks` の操作ボタンを使用できます。`/tasks` のスケジューラー管理を使う場合のみ必要で、それ以外はスキップできます。
|
||||||
|
|
||||||
|
4. **バージョン管理とリリース** で新バージョンを作成し **本番リリース** を申請、Feishu クライアントで承認:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
@@ -95,6 +100,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 画像メッセージ | ✅ 送受信 |
|
| 画像メッセージ | ✅ 送受信 |
|
||||||
| 音声メッセージ | ✅ 送受信 |
|
| 音声メッセージ | ✅ 送受信 |
|
||||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
|
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
||||||
|
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
||||||
|
| スケジューラー操作 | ✅ `/tasks` で一覧、有効化、無効化、削除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||||
@@ -105,3 +113,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
接続完了後、Feishu で Bot 名を検索してチャットを開始できます。
|
||||||
|
|
||||||
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
グループで使う場合は Bot をグループに追加し、@メンションでメッセージを送ってください。
|
||||||
|
|
||||||
|
個人チャットでは `/tasks`、グループでは Bot に @メンションして `/tasks` を送ると、そのチャットのタスクを管理できます。
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、claude-fable-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、claude-fable-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k3、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -26,7 +26,7 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
| [カスタム](/models/custom) | ローカルモデル / サードパーティプロキシ | ✅ | | | | | |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
description: Kimi(Moonshot)モデル設定(テキスト対話 + 画像理解)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
Kimi は Moonshot が提供するモデルで、テキスト対話と画像理解をサポートします。`kimi-k3` と `kimi-k2.x` シリーズはネイティブにビジョンをサポートしています。
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
Web コンソールの「モデル管理」ページから、以下のすべての機能をワンストップで設定でき、設定ファイルを手動で編集する必要はありません。
|
||||||
@@ -13,14 +13,14 @@ Kimi は Moonshot が提供するモデルで、テキスト対話と画像理
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
| `model` | `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` を指定可能 |
|
||||||
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
| `moonshot_api_key` | [Moonshot コンソール](https://platform.moonshot.cn/console/api-keys) で作成 |
|
||||||
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 任意。デフォルトは `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
|||||||
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 100+ models from multiple providers | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
description: Kimi (Moonshot) model configuration (Text Chat + Image Understanding)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k2.x` series natively supports vision.
|
Kimi is provided by Moonshot and supports both text chat and image understanding. The `kimi-k3` and `kimi-k2.x` series natively support vision.
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
All capabilities below can be configured in one place via the "Model Management" page in the Web Console, with no need to manually edit the configuration file.
|
||||||
@@ -13,14 +13,14 @@ Kimi is provided by Moonshot and supports both text chat and image understanding
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Can be `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
| `model` | Can be `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2`, `moonshot-v1-8k`, `moonshot-v1-32k`, `moonshot-v1-128k` |
|
||||||
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
| `moonshot_api_key` | Create one in the [Moonshot Console](https://platform.moonshot.cn/console/api-keys) |
|
||||||
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | Optional, defaults to `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一个 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
"channel_type": "feishu",
|
"channel_type": "feishu",
|
||||||
"feishu_app_id": "YOUR_APP_ID",
|
"feishu_app_id": "YOUR_APP_ID",
|
||||||
"feishu_app_secret": "YOUR_APP_SECRET",
|
"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_id` | 飞书应用 App ID | - |
|
||||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
|
| `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` |
|
||||||
|
| `feishu_progress_card` | 普通对话流式回复是否使用富进度卡片(状态头、思考/工具面板、耗时);关闭时用普通打字机卡片 | `false` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -85,7 +88,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
|
|
||||||
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
2. 点击 **添加事件**,搜索 "接收消息",选择 **接收消息 v2.0** 并确认。
|
||||||
|
|
||||||
3. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
3. (可选)在 **回调** 中添加 **卡片回传交互**(`card.action.trigger`),以启用 `/tasks` 操作按钮。仅使用 `/tasks` 定时任务管理时需要,否则可跳过。
|
||||||
|
|
||||||
|
4. 点击 **版本管理与发布**,创建版本并申请 **线上发布**,在飞书客户端审核通过:
|
||||||
|
|
||||||
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
<img src="https://cdn.link-ai.tech/doc/202601311807356.png" width="600"/>
|
||||||
|
|
||||||
@@ -99,6 +104,9 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 图片消息 | ✅ 收发 |
|
| 图片消息 | ✅ 收发 |
|
||||||
| 语音消息 | ✅ 收发 |
|
| 语音消息 | ✅ 收发 |
|
||||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
|
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
||||||
|
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
||||||
|
| 定时任务操作卡 | ✅ `/tasks` 查看、启用、停用与删除 |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||||
@@ -109,3 +117,5 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
完成接入后,在飞书中搜索机器人名称即可开始单聊对话。
|
||||||
|
|
||||||
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
如需在群聊中使用,将机器人添加到群中,@机器人发送消息即可。
|
||||||
|
|
||||||
|
私聊发送 `/tasks`,或在群聊中 @机器人并发送 `/tasks`,即可管理属于当前会话的定时任务。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/zh/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||||
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/zh/models/linkai) | 多厂商 100+ 模型统一接入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: Kimi
|
|||||||
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
description: Kimi(Moonshot)模型配置(文本对话 + 图像理解)
|
||||||
---
|
---
|
||||||
|
|
||||||
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系列原生支持视觉。
|
Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k3` 与 `kimi-k2.x` 系列原生支持视觉。
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
通过 Web 控制台的「模型管理」页面可一站式配置以下全部能力,无需手动改配置文件。
|
||||||
@@ -13,14 +13,14 @@ Kimi 由 Moonshot 提供,支持文本对话与图像理解,`kimi-k2.x` 系
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "kimi-k2.7-code",
|
"model": "kimi-k3",
|
||||||
"moonshot_api_key": "YOUR_API_KEY"
|
"moonshot_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 可填 `kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
|
| `model` | 可填 `kimi-k3`、`kimi-k2.7-code`、`kimi-k2.7-code-highspeed`、`kimi-k2.6`、`kimi-k2.5`、`kimi-k2`、`moonshot-v1-8k`、`moonshot-v1-32k`、`moonshot-v1-128k` |
|
||||||
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
| `moonshot_api_key` | 在 [Moonshot 控制台](https://platform.moonshot.cn/console/api-keys) 创建 |
|
||||||
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
| `moonshot_base_url` | 可选,默认为 `https://api.moonshot.cn/v1` |
|
||||||
|
|
||||||
|
|||||||
54
docs/zh/releases/v2.1.4.mdx
Normal file
54
docs/zh/releases/v2.1.4.mdx
Normal 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)
|
||||||
@@ -61,7 +61,7 @@ class MoonshotBot(Bot):
|
|||||||
m = model_name.lower()
|
m = model_name.lower()
|
||||||
if cls._is_builtin_reasoning_model(m):
|
if cls._is_builtin_reasoning_model(m):
|
||||||
return False
|
return False
|
||||||
return m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
return m.startswith("kimi-k3") or m.startswith("kimi-k2") or m.startswith("kimi-k1.5")
|
||||||
|
|
||||||
def _build_headers(self) -> dict:
|
def _build_headers(self) -> dict:
|
||||||
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
"""Build HTTP headers, adding Coding-Agent User-Agent for Kimi Coding Plan."""
|
||||||
|
|||||||
4
run.sh
4
run.sh
@@ -604,7 +604,7 @@ select_model() {
|
|||||||
"GLM (glm-5.2, etc.)" \
|
"GLM (glm-5.2, etc.)" \
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)" \
|
||||||
"Doubao (doubao-seed-2.1, etc.)" \
|
"Doubao (doubao-seed-2.1, etc.)" \
|
||||||
"Kimi (kimi-k2.7-code, etc.)" \
|
"Kimi (kimi-k3, etc.)" \
|
||||||
"MiMo (mimo-v2.5-pro, etc.)" \
|
"MiMo (mimo-v2.5-pro, etc.)" \
|
||||||
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
"LinkAI ($(t "一个 Key 接入所有模型" "access all models via one API"))" \
|
||||||
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
"$(t "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")"
|
||||||
@@ -636,7 +636,7 @@ configure_model() {
|
|||||||
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
6) read_model_config "GLM" "glm-5.2" "ZHIPU_KEY" ;;
|
||||||
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
7) read_model_config "Qwen (DashScope)" "qwen3.7-plus" "DASHSCOPE_KEY" ;;
|
||||||
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-1-pro-260628" "ARK_KEY" ;;
|
8) read_model_config "Doubao (Volcengine Ark)" "doubao-seed-2-1-pro-260628" "ARK_KEY" ;;
|
||||||
9) read_model_config "Kimi (Moonshot)" "kimi-k2.7-code" "MOONSHOT_KEY" ;;
|
9) read_model_config "Kimi (Moonshot)" "kimi-k3" "MOONSHOT_KEY" ;;
|
||||||
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
|
10) read_model_config "MiMo" "mimo-v2.5-pro" "MIMO_KEY" ;;
|
||||||
11)
|
11)
|
||||||
# Show where to obtain a LinkAI key (zh users -> console page).
|
# Show where to obtain a LinkAI key (zh users -> console page).
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ $ModelChoices = @{
|
|||||||
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
||||||
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
||||||
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-1-pro-260628"; Field = "ark_api_key" }
|
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-1-pro-260628"; Field = "ark_api_key" }
|
||||||
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.7-code"; Field = "moonshot_api_key" }
|
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k3"; Field = "moonshot_api_key" }
|
||||||
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
|
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
|
||||||
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
||||||
}
|
}
|
||||||
@@ -478,7 +478,7 @@ function Select-Model {
|
|||||||
"GLM (glm-5.2, etc.)",
|
"GLM (glm-5.2, etc.)",
|
||||||
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
||||||
"Doubao (doubao-seed-2.1, etc.)",
|
"Doubao (doubao-seed-2.1, etc.)",
|
||||||
"Kimi (kimi-k2.7-code, etc.)",
|
"Kimi (kimi-k3, etc.)",
|
||||||
"MiMo (mimo-v2.5-pro, etc.)",
|
"MiMo (mimo-v2.5-pro, etc.)",
|
||||||
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
||||||
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
||||||
|
|||||||
103
tests/test_feishu_progress_card.py
Normal file
103
tests/test_feishu_progress_card.py
Normal 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"
|
||||||
140
tests/test_feishu_scheduler_card.py
Normal file
140
tests/test_feishu_scheduler_card.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from channel.feishu.feishu_scheduler_card import (
|
||||||
|
build_scheduler_card,
|
||||||
|
handle_scheduler_action,
|
||||||
|
tasks_for_receivers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTaskStore:
|
||||||
|
def __init__(self, tasks):
|
||||||
|
self.tasks = {task["id"]: deepcopy(task) for task in tasks}
|
||||||
|
self.enable_calls = []
|
||||||
|
self.delete_calls = []
|
||||||
|
|
||||||
|
def list_tasks(self):
|
||||||
|
return list(self.tasks.values())
|
||||||
|
|
||||||
|
def get_task(self, task_id):
|
||||||
|
return self.tasks.get(task_id)
|
||||||
|
|
||||||
|
def enable_task(self, task_id, enabled=True):
|
||||||
|
self.enable_calls.append((task_id, enabled))
|
||||||
|
self.tasks[task_id]["enabled"] = enabled
|
||||||
|
|
||||||
|
def delete_task(self, task_id):
|
||||||
|
self.delete_calls.append(task_id)
|
||||||
|
del self.tasks[task_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _task(task_id, receiver, enabled=True):
|
||||||
|
return {
|
||||||
|
"id": task_id,
|
||||||
|
"name": "Daily report",
|
||||||
|
"enabled": enabled,
|
||||||
|
"schedule": {"type": "cron", "expression": "0 9 * * *"},
|
||||||
|
"next_run_at": "2026-07-18T09:00:00",
|
||||||
|
"action": {"receiver": receiver, "channel_type": "feishu"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_card_has_explicit_idempotent_actions():
|
||||||
|
card = build_scheduler_card([_task("task-1", "chat-1")])
|
||||||
|
|
||||||
|
assert card["schema"] == "2.0"
|
||||||
|
assert card["config"]["enable_forward_interaction"] is False
|
||||||
|
assert card["header"]["title"]["content"] == "Scheduled tasks"
|
||||||
|
action_row = next(
|
||||||
|
element for element in card["body"]["elements"] if element.get("tag") == "column_set"
|
||||||
|
)
|
||||||
|
values = [column["elements"][0]["value"] for column in action_row["columns"]]
|
||||||
|
assert values == [
|
||||||
|
{
|
||||||
|
"cowagent": "scheduler",
|
||||||
|
"action": "disable",
|
||||||
|
"task_id": "task-1",
|
||||||
|
"receiver": "chat-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cowagent": "scheduler",
|
||||||
|
"action": "delete",
|
||||||
|
"task_id": "task-1",
|
||||||
|
"receiver": "chat-1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_are_scoped_to_callback_chat_or_operator():
|
||||||
|
tasks = [
|
||||||
|
_task("group", "chat-1"),
|
||||||
|
_task("private", "user-1"),
|
||||||
|
_task("other", "chat-2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
visible = tasks_for_receivers(tasks, {"chat-1", "user-1"})
|
||||||
|
|
||||||
|
assert [task["id"] for task in visible] == ["group", "private"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_action_is_idempotent_and_returns_refreshed_card():
|
||||||
|
store = FakeTaskStore([_task("task-1", "chat-1")])
|
||||||
|
value = {"cowagent": "scheduler", "action": "disable", "task_id": "task-1"}
|
||||||
|
|
||||||
|
first = handle_scheduler_action(value, store, {"chat-1"})
|
||||||
|
second = handle_scheduler_action(value, store, {"chat-1"})
|
||||||
|
|
||||||
|
assert store.enable_calls == [("task-1", False), ("task-1", False)]
|
||||||
|
assert first["toast"] == {"type": "success", "content": "Task disabled"}
|
||||||
|
assert second["card"]["type"] == "raw"
|
||||||
|
refreshed_columns = next(
|
||||||
|
element
|
||||||
|
for element in second["card"]["data"]["body"]["elements"]
|
||||||
|
if element.get("tag") == "column_set"
|
||||||
|
)["columns"]
|
||||||
|
assert refreshed_columns[0]["elements"][0]["value"]["action"] == "enable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_rejects_task_from_another_receiver():
|
||||||
|
store = FakeTaskStore([_task("task-1", "chat-2")])
|
||||||
|
|
||||||
|
response = handle_scheduler_action(
|
||||||
|
{"cowagent": "scheduler", "action": "delete", "task_id": "task-1"},
|
||||||
|
store,
|
||||||
|
{"chat-1", "user-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response["toast"] == {"type": "error", "content": "Task is not available in this chat"}
|
||||||
|
assert store.delete_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_rejects_mismatched_receiver_embedded_in_card_value():
|
||||||
|
store = FakeTaskStore([_task("task-1", "user-1")])
|
||||||
|
|
||||||
|
response = handle_scheduler_action(
|
||||||
|
{
|
||||||
|
"cowagent": "scheduler",
|
||||||
|
"action": "delete",
|
||||||
|
"task_id": "task-1",
|
||||||
|
"receiver": "chat-1",
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
{"chat-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response["toast"] == {"type": "error", "content": "Task is not available in this chat"}
|
||||||
|
assert store.delete_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_action_removes_owned_task():
|
||||||
|
store = FakeTaskStore([_task("task-1", "user-1")])
|
||||||
|
|
||||||
|
response = handle_scheduler_action(
|
||||||
|
{"cowagent": "scheduler", "action": "delete", "task_id": "task-1"},
|
||||||
|
store,
|
||||||
|
{"user-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.delete_calls == ["task-1"]
|
||||||
|
assert response["toast"] == {"type": "success", "content": "Task deleted"}
|
||||||
|
assert "No scheduled tasks" in response["card"]["data"]["body"]["elements"][0]["content"]
|
||||||
34
tests/test_feishu_static_card.py
Normal file
34
tests/test_feishu_static_card.py
Normal 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
|
||||||
92
tests/test_scheduler_silent.py
Normal file
92
tests/test_scheduler_silent.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Regression tests for silent scheduled agent tasks."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from agent.tools.scheduler.integration import _execute_agent_task
|
||||||
|
from agent.tools.scheduler.scheduler_tool import SchedulerTool
|
||||||
|
|
||||||
|
|
||||||
|
class _Context(dict):
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
def add_task(self, task):
|
||||||
|
self.added.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
class _AgentBridge:
|
||||||
|
def __init__(self, content="maintenance complete"):
|
||||||
|
self.content = content
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def agent_reply(self, task_description, **kwargs):
|
||||||
|
self.calls.append((task_description, kwargs))
|
||||||
|
return SimpleNamespace(content=self.content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerSilentMode(unittest.TestCase):
|
||||||
|
def test_schema_exposes_silent_for_agent_tasks(self):
|
||||||
|
silent = SchedulerTool.params["properties"]["silent"]
|
||||||
|
|
||||||
|
self.assertEqual(silent["type"], "boolean")
|
||||||
|
self.assertFalse(silent["default"])
|
||||||
|
|
||||||
|
def test_create_persists_silent_on_agent_task(self):
|
||||||
|
tool = SchedulerTool({"channel_type": "web"})
|
||||||
|
store = _TaskStore()
|
||||||
|
tool.task_store = store
|
||||||
|
tool.current_context = _Context(
|
||||||
|
receiver="user-1",
|
||||||
|
session_id="session-1",
|
||||||
|
isgroup=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.execute(
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"name": "refresh token",
|
||||||
|
"ai_task": "refresh the token",
|
||||||
|
"schedule_type": "interval",
|
||||||
|
"schedule_value": "3000",
|
||||||
|
"silent": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "success")
|
||||||
|
self.assertEqual(len(store.added), 1)
|
||||||
|
self.assertIs(store.added[0]["action"]["silent"], True)
|
||||||
|
|
||||||
|
def test_silent_agent_task_executes_without_delivery(self):
|
||||||
|
bridge = _AgentBridge()
|
||||||
|
task = {
|
||||||
|
"id": "task-1",
|
||||||
|
"action": {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "rotate logs",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"is_group": False,
|
||||||
|
"channel_type": "web",
|
||||||
|
"silent": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("channel.channel_factory.create_channel") as create_channel:
|
||||||
|
result = _execute_agent_task(task, bridge)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(len(bridge.calls), 1)
|
||||||
|
create_channel.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
114
tests/test_scheduler_web_update.py
Normal file
114
tests/test_scheduler_web_update.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Regression tests for scheduler edits made through the Web console."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import 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.web_channel import 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_edit_preserves_hidden_agent_action_fields(tmp_path):
|
||||||
|
store = _store_task(tmp_path, {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "refresh the index",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"receiver_name": "User",
|
||||||
|
"is_group": False,
|
||||||
|
"channel_type": "feishu",
|
||||||
|
"notify_session_id": "session-1",
|
||||||
|
"silent": True,
|
||||||
|
"delivery_extension": {"trace": True},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = _post_update(tmp_path, {
|
||||||
|
"task_id": "task-1",
|
||||||
|
"name": "renamed maintenance",
|
||||||
|
"action": {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "refresh both indexes",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"channel_type": "feishu",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
action = store.get_task("task-1")["action"]
|
||||||
|
assert action["task_description"] == "refresh both indexes"
|
||||||
|
assert action["silent"] is True
|
||||||
|
assert action["notify_session_id"] == "session-1"
|
||||||
|
assert action["delivery_extension"] == {"trace": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch_to_message_drops_agent_only_fields(tmp_path):
|
||||||
|
store = _store_task(tmp_path, {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "refresh the index",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"channel_type": "web",
|
||||||
|
"notify_session_id": "session-1",
|
||||||
|
"silent": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
result = _post_update(tmp_path, {
|
||||||
|
"task_id": "task-1",
|
||||||
|
"action": {
|
||||||
|
"type": "send_message",
|
||||||
|
"content": "Index refresh reminder",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"channel_type": "web",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
action = store.get_task("task-1")["action"]
|
||||||
|
assert action["content"] == "Index refresh reminder"
|
||||||
|
assert "task_description" not in action
|
||||||
|
assert "silent" not in action
|
||||||
|
assert action["notify_session_id"] == "session-1"
|
||||||
Reference in New Issue
Block a user