mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-18 20:17:09 +08:00
Compare commits
29 Commits
feat-mcp-o
...
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 | ||
|
|
5d55ec0f8c | ||
|
|
d5fdd644cf | ||
|
|
eeb4b7981e | ||
|
|
5f1c98881d | ||
|
|
94d0f56689 | ||
|
|
4d690341a7 | ||
|
|
d8c419227c |
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
|
||||||
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
@@ -113,6 +113,31 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
# Download the Windows signing CLI. The URL comes from a repo variable, so
|
||||||
|
# nothing about the signing setup is hardcoded in a public workflow. Only
|
||||||
|
# runs on the Windows leg and only when a URL is set; otherwise the build
|
||||||
|
# stays unsigned. SIGNTOOL_PATH is exported for the next step's
|
||||||
|
# electron-builder.win.js to invoke.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
# Unzip and locate the signtool executable regardless of nesting.
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Normalize to a Windows-style path for execFileSync in Node.
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
- name: Build & publish (electron-builder)
|
- name: Build & publish (electron-builder)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -124,8 +149,14 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
# Windows code signing via the signing CLI. Credentials are
|
||||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
# secrets; SIGNTOOL_PATH was exported by the download step above.
|
||||||
|
# COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
|
||||||
|
# with a self-signed cert before buying a real one — no quota used.
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
run: |
|
run: |
|
||||||
# Pick the signing cert for THIS platform only. The mac and win secrets
|
# Pick the signing cert for THIS platform only. The mac and win secrets
|
||||||
# are both present in the job env, but a mac cert must never leak into a
|
# are both present in the job env, but a mac cert must never leak into a
|
||||||
@@ -137,6 +168,10 @@ jobs:
|
|||||||
#
|
#
|
||||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||||
# (an `unset` can return non-zero and abort under errexit).
|
# (an `unset` can return non-zero and abort under errexit).
|
||||||
|
# macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
|
||||||
|
# a local .pfx (EV private keys can't be exported since 2023); it signs
|
||||||
|
# via the CLI wired into electron-builder.win.js instead, using the
|
||||||
|
# SIGNTOOL_* env already set above — nothing to export here.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac)
|
mac)
|
||||||
if [ -n "$MAC_CSC_LINK" ]; then
|
if [ -n "$MAC_CSC_LINK" ]; then
|
||||||
@@ -144,12 +179,6 @@ jobs:
|
|||||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
win)
|
|
||||||
if [ -n "$WIN_CSC_LINK" ]; then
|
|
||||||
export CSC_LINK="$WIN_CSC_LINK"
|
|
||||||
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Never let electron-builder publish: our publish target is a generic
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
@@ -157,19 +186,25 @@ jobs:
|
|||||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# `--publish never` still emits the latest*.yml files.
|
# `--publish never` still emits the latest*.yml files.
|
||||||
#
|
#
|
||||||
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
|
||||||
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||||
# notarization) — it's a pure no-op on Windows. Passing --config on
|
# win -> electron-builder.win.js (wires the sign hook; electron-builder
|
||||||
# Windows was what silently broke the Windows build (it produced no
|
# signs the app, backend and installer)
|
||||||
# installer while the job still reported success; Windows worked fine
|
# HISTORY: passing --config on Windows previously broke the build (no
|
||||||
# before --config was introduced). So Windows uses the plain
|
# installer, job still green). That happened because the MAC config
|
||||||
# package.json build config and only mac uses the dynamic one.
|
# (electron-builder.js) was a no-op on Windows yet still disturbed the
|
||||||
|
# run. The fix is a DEDICATED win config that correctly extends
|
||||||
|
# config.win — not sharing the mac one. If a build ever runs WITHOUT
|
||||||
|
# signing configured, electron-builder.win.js still returns the base
|
||||||
|
# config unchanged (sign hook just skips), so the installer is still
|
||||||
|
# produced.
|
||||||
#
|
#
|
||||||
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||||
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||||
# make bash return before the wrapped process finishes. node skips it.
|
# make bash return before the wrapped process finishes. node skips it.
|
||||||
case "${{ matrix.platform }}" in
|
case "${{ matrix.platform }}" in
|
||||||
mac) config_arg="--config electron-builder.js" ;;
|
mac) config_arg="--config electron-builder.js" ;;
|
||||||
|
win) config_arg="--config electron-builder.win.js" ;;
|
||||||
*) config_arg="" ;;
|
*) config_arg="" ;;
|
||||||
esac
|
esac
|
||||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
|
|
||||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [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
|
||||||
|
|||||||
290
agent/tools/browser/browser_env.py
Normal file
290
agent/tools/browser/browser_env.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Browser environment detection and capability resolution.
|
||||||
|
|
||||||
|
Centralizes everything about *where* a usable browser engine comes from, so
|
||||||
|
both the runtime (browser_service) and the installer (cli/commands/install)
|
||||||
|
agree on the same decisions:
|
||||||
|
|
||||||
|
- Whether the `playwright` Python package is importable.
|
||||||
|
- Whether a system Chrome / Edge is installed (Playwright can drive it via
|
||||||
|
the `channel="chrome"/"msedge"` launcher, no download needed).
|
||||||
|
- Where Playwright's own Chromium download lives (redirected to the writable
|
||||||
|
data dir so it survives frozen/desktop app updates).
|
||||||
|
|
||||||
|
Resolution priority (see resolve_engine):
|
||||||
|
1. system-chrome -> drive the user's installed Chrome / Edge (zero download)
|
||||||
|
2. playwright-chromium -> Playwright's own Chromium, if already downloaded
|
||||||
|
3. none -> nothing usable yet; caller should trigger onboarding
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Playwright browser channels we accept for the "system-chrome" mode, in
|
||||||
|
# preference order. "chrome" covers stable Google Chrome; "msedge" is the
|
||||||
|
# Chromium-based Edge shipped on every Windows 10/11.
|
||||||
|
_PREFERRED_CHANNELS = ("chrome", "msedge", "chrome-beta", "msedge-beta")
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_root() -> str:
|
||||||
|
"""Writable data root (~/.cow on desktop, else CWD-based).
|
||||||
|
|
||||||
|
Mirrors the logic in common/log.py without importing config, to avoid a
|
||||||
|
circular import. The desktop build sets COW_DATA_DIR; source deployments
|
||||||
|
fall back to the current working directory.
|
||||||
|
"""
|
||||||
|
data_dir = os.environ.get("COW_DATA_DIR")
|
||||||
|
if data_dir:
|
||||||
|
return os.path.expanduser(data_dir)
|
||||||
|
return os.getcwd()
|
||||||
|
|
||||||
|
|
||||||
|
def browsers_download_dir() -> str:
|
||||||
|
"""Directory Playwright downloads its Chromium into.
|
||||||
|
|
||||||
|
We pin it under the writable data root (~/.cow/ms-playwright) rather than
|
||||||
|
Playwright's default (~/.cache/ms-playwright or %USERPROFILE%). This keeps
|
||||||
|
the frozen desktop build self-contained and makes the download survive app
|
||||||
|
updates. Set as PLAYWRIGHT_BROWSERS_PATH for both install and runtime.
|
||||||
|
"""
|
||||||
|
return os.path.join(get_data_root(), "ms-playwright")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_browsers_path_env() -> None:
|
||||||
|
"""Point Playwright at our pinned download dir via env var (idempotent).
|
||||||
|
|
||||||
|
Only set it when not already provided by the user, so power users can
|
||||||
|
override the location. Must run before importing playwright's launcher.
|
||||||
|
"""
|
||||||
|
if not os.environ.get("PLAYWRIGHT_BROWSERS_PATH"):
|
||||||
|
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def is_frozen() -> bool:
|
||||||
|
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||||
|
|
||||||
|
In this mode sys.executable is the frozen exe (no pip), so the installer
|
||||||
|
must skip `pip install` and only download the browser binary.
|
||||||
|
"""
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def is_desktop() -> bool:
|
||||||
|
"""True when running as the Electron desktop client (dev or packaged).
|
||||||
|
|
||||||
|
The desktop shell always sets COW_DESKTOP=1 (see python-manager.ts), both in
|
||||||
|
`npm run dev` (runs app.py with the user's Python) and in the packaged build
|
||||||
|
(frozen exe). Desktop users have no `cow` CLI, so onboarding must point them
|
||||||
|
at the in-chat `/install-browser` command rather than a terminal command.
|
||||||
|
"""
|
||||||
|
return os.environ.get("COW_DESKTOP") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def has_playwright_package() -> bool:
|
||||||
|
"""True if the `playwright` Python package can be imported."""
|
||||||
|
try:
|
||||||
|
import playwright # noqa: F401
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_program_dirs() -> list:
|
||||||
|
dirs = []
|
||||||
|
for var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
||||||
|
val = os.environ.get(var)
|
||||||
|
if val:
|
||||||
|
dirs.append(val)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def detect_system_chrome() -> Optional[Dict[str, str]]:
|
||||||
|
"""Locate an installed Chromium-based browser Playwright can drive.
|
||||||
|
|
||||||
|
Returns a dict {"channel": <playwright channel>, "path": <exe path>} for
|
||||||
|
the first match, or None. The `channel` is what we hand to Playwright's
|
||||||
|
launcher; `path` is only informational (Playwright resolves the channel on
|
||||||
|
its own, but we keep the path for logging / onboarding messages).
|
||||||
|
"""
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
candidates = [
|
||||||
|
("chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||||
|
("msedge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
||||||
|
("chrome-beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"),
|
||||||
|
]
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
prog_dirs = _windows_program_dirs()
|
||||||
|
for base in prog_dirs:
|
||||||
|
candidates.append(("chrome", os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")))
|
||||||
|
candidates.append(("msedge", os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe")))
|
||||||
|
else:
|
||||||
|
# Linux: rely on PATH lookups for the common binaries.
|
||||||
|
path_lookups = [
|
||||||
|
("chrome", "google-chrome"),
|
||||||
|
("chrome", "google-chrome-stable"),
|
||||||
|
("chrome", "chromium"),
|
||||||
|
("chrome", "chromium-browser"),
|
||||||
|
("msedge", "microsoft-edge"),
|
||||||
|
]
|
||||||
|
for channel, binary in path_lookups:
|
||||||
|
found = shutil.which(binary)
|
||||||
|
if found:
|
||||||
|
return {"channel": channel, "path": found}
|
||||||
|
|
||||||
|
for channel, path in candidates:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
return {"channel": channel, "path": path}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_downloaded_chromium() -> bool:
|
||||||
|
"""True if Playwright already has a Chromium download available.
|
||||||
|
|
||||||
|
We check our pinned download dir for a chromium-* folder. This is a
|
||||||
|
lightweight heuristic (avoids importing/launching Playwright just to probe)
|
||||||
|
and matches how Playwright lays browsers out on disk.
|
||||||
|
"""
|
||||||
|
download_dir = browsers_download_dir()
|
||||||
|
if not os.path.isdir(download_dir):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
for name in os.listdir(download_dir):
|
||||||
|
# Playwright names its browser dirs like "chromium-1140",
|
||||||
|
# "chromium_headless_shell-1140".
|
||||||
|
if name.startswith("chromium"):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_engine(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""Decide which browser engine to use, given config and environment.
|
||||||
|
|
||||||
|
Returns a dict describing the launch strategy:
|
||||||
|
{
|
||||||
|
"mode": "system-chrome" | "playwright-chromium" | "none",
|
||||||
|
"channel": Optional[str], # for system-chrome
|
||||||
|
"path": Optional[str], # for system-chrome (informational)
|
||||||
|
"has_playwright": bool,
|
||||||
|
"reason": str, # human-readable, for logging / onboarding
|
||||||
|
}
|
||||||
|
|
||||||
|
Config keys under tools.browser that influence this:
|
||||||
|
- engine: "auto" (default) | "system-chrome" | "chromium"
|
||||||
|
Force a specific engine. "auto" prefers system Chrome, then falls
|
||||||
|
back to a downloaded Chromium.
|
||||||
|
- prefer_system_browser: bool (default True). When False under "auto",
|
||||||
|
skip system Chrome and go straight to Playwright's Chromium.
|
||||||
|
"""
|
||||||
|
config = config or {}
|
||||||
|
apply_browsers_path_env()
|
||||||
|
|
||||||
|
has_pw = has_playwright_package()
|
||||||
|
engine_pref = str(config.get("engine", "auto")).strip().lower()
|
||||||
|
prefer_system = config.get("prefer_system_browser", True)
|
||||||
|
|
||||||
|
if not has_pw:
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": False,
|
||||||
|
"reason": "playwright package not available",
|
||||||
|
}
|
||||||
|
|
||||||
|
system = None
|
||||||
|
if engine_pref in ("auto", "system-chrome") and prefer_system:
|
||||||
|
system = detect_system_chrome()
|
||||||
|
|
||||||
|
if engine_pref == "system-chrome":
|
||||||
|
# Explicitly requested: use system Chrome if found, else report none.
|
||||||
|
if system:
|
||||||
|
return {
|
||||||
|
"mode": "system-chrome",
|
||||||
|
"channel": system["channel"],
|
||||||
|
"path": system["path"],
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": f"using system browser ({system['channel']})",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "engine=system-chrome but no Chrome/Edge found",
|
||||||
|
}
|
||||||
|
|
||||||
|
if engine_pref == "chromium":
|
||||||
|
# Explicitly requested Playwright's own Chromium.
|
||||||
|
if has_downloaded_chromium():
|
||||||
|
return {
|
||||||
|
"mode": "playwright-chromium",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "using downloaded Playwright Chromium",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "engine=chromium but Chromium not downloaded yet",
|
||||||
|
}
|
||||||
|
|
||||||
|
# auto: system Chrome first, then downloaded Chromium.
|
||||||
|
if system:
|
||||||
|
return {
|
||||||
|
"mode": "system-chrome",
|
||||||
|
"channel": system["channel"],
|
||||||
|
"path": system["path"],
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": f"auto: using system browser ({system['channel']})",
|
||||||
|
}
|
||||||
|
if has_downloaded_chromium():
|
||||||
|
return {
|
||||||
|
"mode": "playwright-chromium",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "auto: using downloaded Playwright Chromium",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "none",
|
||||||
|
"channel": None,
|
||||||
|
"path": None,
|
||||||
|
"has_playwright": True,
|
||||||
|
"reason": "no system Chrome/Edge and no downloaded Chromium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def capability_summary(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""High-level browser capability status, for onboarding / diagnostics.
|
||||||
|
|
||||||
|
Combines resolve_engine with raw detection flags so the UI / tool layer can
|
||||||
|
craft a helpful message (e.g. "Chrome detected, click to enable" vs
|
||||||
|
"no browser, will download ~150MB").
|
||||||
|
"""
|
||||||
|
engine = resolve_engine(config)
|
||||||
|
system = detect_system_chrome()
|
||||||
|
return {
|
||||||
|
"ready": engine["mode"] != "none",
|
||||||
|
"engine": engine,
|
||||||
|
"has_playwright": engine["has_playwright"],
|
||||||
|
"has_system_chrome": system is not None,
|
||||||
|
"system_chrome": system,
|
||||||
|
"has_downloaded_chromium": has_downloaded_chromium(),
|
||||||
|
"is_frozen": is_frozen(),
|
||||||
|
"is_desktop": is_desktop(),
|
||||||
|
"browsers_dir": browsers_download_dir(),
|
||||||
|
}
|
||||||
@@ -326,12 +326,19 @@ class BrowserService:
|
|||||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||||
# so cookies / login state survive across runs (default).
|
# so cookies / login state survive across runs (default).
|
||||||
# - fresh: classic launch + new_context, clean state every run.
|
# - fresh: classic launch + new_context, clean state every run.
|
||||||
|
#
|
||||||
|
# Within persistent/fresh, the actual Chromium binary is resolved by
|
||||||
|
# browser_env.resolve_engine(): a system Chrome/Edge (channel-based, zero
|
||||||
|
# download) is preferred, falling back to Playwright's own downloaded
|
||||||
|
# Chromium. `self._channel` is the Playwright channel ("chrome"/"msedge")
|
||||||
|
# when driving a system browser, else None (bundled Chromium).
|
||||||
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
||||||
persistent_flag = self._config.get("persistent", True)
|
persistent_flag = self._config.get("persistent", True)
|
||||||
user_data_dir_cfg = self._config.get("user_data_dir")
|
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||||
if user_data_dir_cfg is None:
|
if user_data_dir_cfg is None:
|
||||||
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
||||||
|
|
||||||
|
self._channel: Optional[str] = None
|
||||||
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
||||||
if self._cdp_endpoint:
|
if self._cdp_endpoint:
|
||||||
self._launch_mode = "cdp"
|
self._launch_mode = "cdp"
|
||||||
@@ -343,6 +350,22 @@ class BrowserService:
|
|||||||
self._launch_mode = "fresh"
|
self._launch_mode = "fresh"
|
||||||
self._user_data_dir = ""
|
self._user_data_dir = ""
|
||||||
|
|
||||||
|
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||||
|
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||||
|
if self._launch_mode != "cdp":
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import resolve_engine
|
||||||
|
engine = resolve_engine(self._config)
|
||||||
|
if engine["mode"] == "system-chrome":
|
||||||
|
self._channel = engine["channel"]
|
||||||
|
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||||
|
elif engine["mode"] == "playwright-chromium":
|
||||||
|
logger.info(f"[Browser] Engine resolved: {engine['reason']}")
|
||||||
|
else:
|
||||||
|
logger.info(f"[Browser] No ready engine yet: {engine['reason']}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] Engine resolution skipped: {e}")
|
||||||
|
|
||||||
# Idle auto-release
|
# Idle auto-release
|
||||||
idle_cfg = self._config.get("idle_timeout")
|
idle_cfg = self._config.get("idle_timeout")
|
||||||
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||||
@@ -428,11 +451,30 @@ class BrowserService:
|
|||||||
|
|
||||||
def _launch_browser(self):
|
def _launch_browser(self):
|
||||||
"""Launch / connect Chromium on the background thread."""
|
"""Launch / connect Chromium on the background thread."""
|
||||||
|
# Point Playwright at our pinned download dir before any launch so a
|
||||||
|
# bundled-Chromium fallback finds the browser downloaded to ~/.cow.
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import apply_browsers_path_env
|
||||||
|
apply_browsers_path_env()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] apply_browsers_path_env skipped: {e}")
|
||||||
|
|
||||||
if self._headless is None:
|
if self._headless is None:
|
||||||
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")
|
||||||
|
|
||||||
@@ -475,12 +517,20 @@ class BrowserService:
|
|||||||
logger.info("[Browser] Browser ready")
|
logger.info("[Browser] Browser ready")
|
||||||
|
|
||||||
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
"""Classic launch: brand new Chromium with an empty context."""
|
"""Classic launch: brand new Chromium with an empty context.
|
||||||
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
|
|
||||||
self._browser = self._playwright.chromium.launch(
|
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
|
||||||
headless=self._headless,
|
the user's installed system browser instead of its own Chromium.
|
||||||
args=launch_args,
|
"""
|
||||||
)
|
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||||
|
logger.info(f"[Browser] Launching {engine_label} (fresh, headless={self._headless})")
|
||||||
|
launch_kwargs: Dict[str, Any] = {
|
||||||
|
"headless": self._headless,
|
||||||
|
"args": launch_args,
|
||||||
|
}
|
||||||
|
if self._channel:
|
||||||
|
launch_kwargs["channel"] = self._channel
|
||||||
|
self._browser = self._playwright.chromium.launch(**launch_kwargs)
|
||||||
self._context = self._browser.new_context(
|
self._context = self._browser.new_context(
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
@@ -491,18 +541,25 @@ class BrowserService:
|
|||||||
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
||||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
|
f"[Browser] Launching {engine_label} (persistent, headless={self._headless}, "
|
||||||
f"profile={self._user_data_dir})"
|
f"profile={self._user_data_dir})"
|
||||||
)
|
)
|
||||||
|
persistent_kwargs: Dict[str, Any] = {
|
||||||
|
"user_data_dir": self._user_data_dir,
|
||||||
|
"headless": self._headless,
|
||||||
|
"args": launch_args,
|
||||||
|
"viewport": viewport,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
}
|
||||||
|
# When driving a system browser, let it use its real UA instead of the
|
||||||
|
# spoofed Chromium one (avoids UA/engine mismatch on real Chrome/Edge).
|
||||||
|
if self._channel:
|
||||||
|
persistent_kwargs["channel"] = self._channel
|
||||||
|
persistent_kwargs.pop("user_agent", None)
|
||||||
try:
|
try:
|
||||||
self._context = self._playwright.chromium.launch_persistent_context(
|
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
|
||||||
user_data_dir=self._user_data_dir,
|
|
||||||
headless=self._headless,
|
|
||||||
args=launch_args,
|
|
||||||
viewport=viewport,
|
|
||||||
user_agent=user_agent,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Profile is locked when another Chromium instance already holds it.
|
# Profile is locked when another Chromium instance already holds it.
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
@@ -687,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()
|
||||||
|
|||||||
@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
|
|||||||
f"({ip_str}), request blocked for security"
|
f"({ip_str}), request blocked for security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_engine_ready(self) -> Optional[ToolResult]:
|
||||||
|
"""Return an actionable onboarding message if no browser engine is ready.
|
||||||
|
|
||||||
|
Returns None when a system Chrome/Edge or a downloaded Chromium is
|
||||||
|
available (so the tool can proceed). Otherwise returns a ToolResult with
|
||||||
|
clear guidance so the agent asks the user to enable the browser instead
|
||||||
|
of surfacing a raw Playwright launch error. CDP mode is exempt (the
|
||||||
|
endpoint is external and validated at connect time).
|
||||||
|
"""
|
||||||
|
if self.config.get("cdp_endpoint"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import capability_summary
|
||||||
|
summary = capability_summary(self.config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] capability probe failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if summary.get("ready"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Desktop clients (dev or packaged) have no `cow` CLI — onboard via the
|
||||||
|
# in-chat `/install-browser` command. Source / web / server installs use
|
||||||
|
# the `cow install-browser` terminal command.
|
||||||
|
install_hint = (
|
||||||
|
"reply `/install-browser`" if summary.get("is_desktop")
|
||||||
|
else "run `cow install-browser` in a terminal"
|
||||||
|
)
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Browser tool not ready. Ask the user to {install_hint} (installs a browser engine; "
|
||||||
|
"skipped automatically if Google Chrome is already installed). "
|
||||||
|
"Do not retry until the user confirms."
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
action = args.get("action", "").strip().lower()
|
action = args.get("action", "").strip().lower()
|
||||||
if not action:
|
if not action:
|
||||||
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
|
|||||||
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||||
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||||
|
|
||||||
|
# Preflight: on desktop the playwright package is bundled but the browser
|
||||||
|
# binary may be missing; return actionable onboarding instead of a cryptic
|
||||||
|
# launch failure.
|
||||||
|
not_ready = self._check_engine_ready()
|
||||||
|
if not_ready is not None:
|
||||||
|
return not_ready
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return handler(self, args)
|
return handler(self, args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
|||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
return True # agent ran but produced nothing; don't loop
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
if action.get("silent", False):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
channel = create_channel(channel_type)
|
channel = create_channel(channel_type)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
|||||||
"schedule_value": {
|
"schedule_value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
|||||||
"channel_type": self.config.get("channel_type", "unknown"),
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
"notify_session_id": notify_session_id,
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
# silent only applies to ai_task; fixed messages always deliver
|
||||||
|
if kwargs.get("silent", False):
|
||||||
|
action["silent"] = True
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
msg = context.kwargs.get("msg")
|
msg = context.kwargs.get("msg")
|
||||||
@@ -216,14 +224,17 @@ class SchedulerTool(BaseTool):
|
|||||||
content_desc = f"💬 固定消息: {message}"
|
content_desc = f"💬 固定消息: {message}"
|
||||||
else:
|
else:
|
||||||
content_desc = f"🤖 AI任务: {ai_task}"
|
content_desc = f"🤖 AI任务: {ai_task}"
|
||||||
|
|
||||||
|
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||||
|
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"✅ 定时任务创建成功\n\n"
|
f"✅ 定时任务创建成功\n\n"
|
||||||
f"📋 任务ID: {task_id}\n"
|
f"📋 任务ID: {task_id}\n"
|
||||||
f"📝 名称: {name}\n"
|
f"📝 名称: {name}\n"
|
||||||
f"⏰ 调度: {schedule_desc}\n"
|
f"⏰ 调度: {schedule_desc}\n"
|
||||||
f"👤 接收者: {receiver_desc}\n"
|
f"👤 接收者: {receiver_desc}\n"
|
||||||
f"{content_desc}\n"
|
f"{content_desc}{silent_desc}\n"
|
||||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -222,14 +215,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -261,14 +247,7 @@ class ToolManager:
|
|||||||
# If there are missing tools, record warnings
|
# If there are missing tools, record warnings
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
for tool_name in missing_tools:
|
for tool_name in missing_tools:
|
||||||
if tool_name == "browser":
|
if tool_name == "google_search":
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif tool_name == "google_search":
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||||
f" Get API key from: https://serper.dev\n"
|
f" Get API key from: https://serper.dev\n"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1139,6 +1139,33 @@ function createMd() {
|
|||||||
return hljsLib.highlightAuto(str).value;
|
return hljsLib.highlightAuto(str).value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (*)
|
||||||
|
// and CJK full-width punctuation glued to a URL (common in LLM output like
|
||||||
|
// "**https://x**,中文"), turning the whole tail into one broken link. Cut
|
||||||
|
// the URL at the first such char and spill the remainder back as text.
|
||||||
|
var GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/;
|
||||||
|
md.core.ruler.after('linkify', 'fix_greedy_linkify', function(state) {
|
||||||
|
for (var b = 0; b < state.tokens.length; b++) {
|
||||||
|
var blk = state.tokens[b];
|
||||||
|
if (blk.type !== 'inline' || !blk.children) continue;
|
||||||
|
var ch = blk.children;
|
||||||
|
for (var i = 0; i < ch.length; i++) {
|
||||||
|
var open = ch[i];
|
||||||
|
if (open.type !== 'link_open' || open.markup !== 'linkify') continue;
|
||||||
|
var textTok = ch[i + 1], close = ch[i + 2];
|
||||||
|
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue;
|
||||||
|
var idx = textTok.content.search(GREEDY_LINK_CUT);
|
||||||
|
if (idx < 0) continue;
|
||||||
|
var keep = textTok.content.slice(0, idx);
|
||||||
|
var spill = textTok.content.slice(idx);
|
||||||
|
textTok.content = keep;
|
||||||
|
open.attrSet('href', keep);
|
||||||
|
var spillTok = new state.Token('text', '', 0);
|
||||||
|
spillTok.content = spill;
|
||||||
|
ch.splice(i + 3, 0, spillTok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
|
||||||
return self.renderToken(tokens, idx, options);
|
return self.renderToken(tokens, idx, options);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1700,15 +1700,14 @@ class ConfigHandler:
|
|||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||||
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
|
# claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
|
||||||
# from this web console list for now.
|
const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||||
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
|
||||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.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,
|
||||||
]
|
]
|
||||||
@@ -1747,7 +1746,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
}),
|
}),
|
||||||
("gemini", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1763,7 +1762,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "open_ai_api_base",
|
"api_base_key": "open_ai_api_base",
|
||||||
"api_base_default": "https://api.openai.com/v1",
|
"api_base_default": "https://api.openai.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
"models": [const.GPT_56_LUNA, const.GPT_56_TERRA, const.GPT_56_SOL, const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o],
|
||||||
}),
|
}),
|
||||||
("zhipu", {
|
("zhipu", {
|
||||||
"label": {"zh": "智谱AI", "en": "GLM"},
|
"label": {"zh": "智谱AI", "en": "GLM"},
|
||||||
@@ -1795,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"},
|
||||||
@@ -2339,9 +2338,12 @@ class ModelsHandler:
|
|||||||
# Anything not listed here intentionally hides the model dropdown so
|
# Anything not listed here intentionally hides the model dropdown so
|
||||||
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||||
_VISION_PROVIDER_MODELS = {
|
_VISION_PROVIDER_MODELS = {
|
||||||
# OpenAI ordering matches the recommended GPT-5.4 family first, then
|
# OpenAI ordering puts the GPT-5.6 family first, then GPT-5.5/5.4,
|
||||||
# GPT-5 and the GPT-4.1/4o backstops.
|
# GPT-5 and the GPT-4.1/4o backstops.
|
||||||
"openai": [
|
"openai": [
|
||||||
|
const.GPT_56_LUNA,
|
||||||
|
const.GPT_56_TERRA,
|
||||||
|
const.GPT_56_SOL,
|
||||||
const.GPT_55,
|
const.GPT_55,
|
||||||
const.GPT_54,
|
const.GPT_54,
|
||||||
const.GPT_54_MINI,
|
const.GPT_54_MINI,
|
||||||
@@ -2354,7 +2356,7 @@ class ModelsHandler:
|
|||||||
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
||||||
"moonshot": [const.KIMI_K2_6],
|
"moonshot": [const.KIMI_K2_6],
|
||||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||||
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_FABLE_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||||
@@ -2378,6 +2380,7 @@ class ModelsHandler:
|
|||||||
const.DOUBAO_SEED_2_1_PRO,
|
const.DOUBAO_SEED_2_1_PRO,
|
||||||
const.KIMI_K2_6,
|
const.KIMI_K2_6,
|
||||||
const.CLAUDE_SONNET_5,
|
const.CLAUDE_SONNET_5,
|
||||||
|
const.CLAUDE_FABLE_5,
|
||||||
const.GEMINI_31_FLASH_LITE_PRE,
|
const.GEMINI_31_FLASH_LITE_PRE,
|
||||||
],
|
],
|
||||||
# Custom OpenAI-compatible providers have no preset list — model
|
# Custom OpenAI-compatible providers have no preset list — model
|
||||||
@@ -4607,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
|
||||||
|
|||||||
@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def _is_frozen() -> bool:
|
||||||
|
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||||||
|
|
||||||
|
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
|
||||||
|
playwright is already bundled and we only need to download the browser
|
||||||
|
binary in-process rather than pip-installing anything.
|
||||||
|
"""
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
|
||||||
|
"""Invoke the Playwright CLI, working in both source and frozen builds.
|
||||||
|
|
||||||
|
Source builds shell out to ``python -m playwright <args>``. Frozen builds
|
||||||
|
can't use ``-m`` (the exe isn't a Python interpreter), so we call
|
||||||
|
Playwright's driver entrypoint in-process instead. ``env`` overrides are
|
||||||
|
applied to os.environ for the duration of the call (frozen path) or passed
|
||||||
|
through to the subprocess (source path).
|
||||||
|
"""
|
||||||
|
if not _is_frozen():
|
||||||
|
cmd = [sys.executable, "-m", "playwright"] + args
|
||||||
|
return subprocess.call(cmd, env=env)
|
||||||
|
|
||||||
|
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
|
||||||
|
# returns the Node driver shipped inside the bundle; we spawn it directly.
|
||||||
|
prev_env = {}
|
||||||
|
if env:
|
||||||
|
for k, v in env.items():
|
||||||
|
prev_env[k] = os.environ.get(k)
|
||||||
|
os.environ[k] = v
|
||||||
|
try:
|
||||||
|
from playwright._impl._driver import compute_driver_executable, get_driver_env
|
||||||
|
driver = compute_driver_executable()
|
||||||
|
# compute_driver_executable may return a tuple (node, cli.js) on newer
|
||||||
|
# Playwright, or a single path on older ones.
|
||||||
|
if isinstance(driver, (list, tuple)):
|
||||||
|
cmd = list(driver) + args
|
||||||
|
else:
|
||||||
|
cmd = [str(driver)] + args
|
||||||
|
# get_driver_env() snapshots os.environ, which we've already patched with
|
||||||
|
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
|
||||||
|
# so mirror + pinned browsers dir are honored here too.
|
||||||
|
return subprocess.call(cmd, env=get_driver_env())
|
||||||
|
except Exception as e:
|
||||||
|
# Last resort: try the module main via runpy (works if the frozen build
|
||||||
|
# kept playwright.__main__ importable).
|
||||||
|
try:
|
||||||
|
import runpy
|
||||||
|
sys.argv = ["playwright"] + args
|
||||||
|
runpy.run_module("playwright", run_name="__main__")
|
||||||
|
return 0
|
||||||
|
except SystemExit as se:
|
||||||
|
return int(se.code or 0)
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
for k, v in prev_env.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
||||||
"""CLI: colored click output."""
|
"""CLI: colored click output."""
|
||||||
if fg == "yellow":
|
if fg == "yellow":
|
||||||
@@ -129,6 +192,7 @@ def run_install_browser(
|
|||||||
stream = stream or _default_stream
|
stream = stream or _default_stream
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
legacy_mode = False
|
legacy_mode = False
|
||||||
|
frozen = _is_frozen()
|
||||||
|
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
||||||
@@ -159,7 +223,7 @@ def run_install_browser(
|
|||||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32" and not frozen:
|
||||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||||
ret = subprocess.call(
|
ret = subprocess.call(
|
||||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||||
@@ -172,22 +236,52 @@ def run_install_browser(
|
|||||||
"yellow",
|
"yellow",
|
||||||
)
|
)
|
||||||
|
|
||||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
if frozen:
|
||||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
# Desktop bundle: playwright is already shipped inside the app; there is
|
||||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||||||
if ret != 0:
|
installed = _get_installed_version()
|
||||||
stream("Failed to install playwright package.", "red")
|
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||||||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
_phase(on_phase, _t(
|
||||||
return 1
|
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
|
||||||
|
"✅ [1/3] Playwright is bundled in the app; skipping install.",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||||
|
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||||
|
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||||
|
if ret != 0:
|
||||||
|
stream("Failed to install playwright package.", "red")
|
||||||
|
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||||||
|
return 1
|
||||||
|
|
||||||
installed = _get_installed_version()
|
installed = _get_installed_version()
|
||||||
if installed:
|
if installed:
|
||||||
stream(f" playwright {installed} installed.", "green")
|
stream(f" playwright {installed} installed.", "green")
|
||||||
stream("")
|
stream("")
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||||||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# With playwright available, prefer the user's system Chrome/Edge: the browser
|
||||||
|
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
|
||||||
|
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
|
||||||
|
# source) — only headless Linux servers, which usually lack a system browser,
|
||||||
|
# fall through to the download below. Honors prefer_system_browser via
|
||||||
|
# resolve_engine, so users who force downloaded Chromium still get it.
|
||||||
|
try:
|
||||||
|
from agent.tools.browser import browser_env
|
||||||
|
summary = browser_env.capability_summary()
|
||||||
|
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
|
||||||
|
sc = summary.get("system_chrome") or {}
|
||||||
|
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
|
||||||
|
_phase(on_phase, _t(
|
||||||
|
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。",
|
||||||
|
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
|
||||||
|
))
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
stream(f" (system browser probe skipped: {e})", None)
|
||||||
|
|
||||||
if sys.platform == "linux":
|
if sys.platform == "linux":
|
||||||
_phase(on_phase, _t(
|
_phase(on_phase, _t(
|
||||||
@@ -195,7 +289,7 @@ def run_install_browser(
|
|||||||
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
||||||
))
|
))
|
||||||
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
||||||
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
ret = _playwright_cli(["install-deps", "chromium"])
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(
|
stream(
|
||||||
" Could not auto-install system deps (may need sudo).\n"
|
" Could not auto-install system deps (may need sudo).\n"
|
||||||
@@ -238,12 +332,12 @@ def run_install_browser(
|
|||||||
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
||||||
))
|
))
|
||||||
stream("[3/3] Installing Chromium browser...", "yellow")
|
stream("[3/3] Installing Chromium browser...", "yellow")
|
||||||
cmd = [python, "-m", "playwright", "install", "chromium"]
|
pw_args = ["install", "chromium"]
|
||||||
|
|
||||||
if _is_headless_linux() and not legacy_mode:
|
if _is_headless_linux() and not legacy_mode:
|
||||||
ver = _version_tuple(installed or "")
|
ver = _version_tuple(installed or "")
|
||||||
if ver >= (1, 57, 0):
|
if ver >= (1, 57, 0):
|
||||||
cmd.append("--only-shell")
|
pw_args.append("--only-shell")
|
||||||
stream(" (headless shell for Linux server)", None)
|
stream(" (headless shell for Linux server)", None)
|
||||||
else:
|
else:
|
||||||
stream(" (full Chromium)", None)
|
stream(" (full Chromium)", None)
|
||||||
@@ -251,6 +345,15 @@ def run_install_browser(
|
|||||||
stream(" (full browser for Linux desktop)", None)
|
stream(" (full browser for Linux desktop)", None)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
# Pin the download location so it survives desktop app updates and matches
|
||||||
|
# what the runtime looks up (see browser_env.browsers_download_dir()).
|
||||||
|
try:
|
||||||
|
from agent.tools.browser.browser_env import browsers_download_dir
|
||||||
|
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||||||
|
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
use_mirror = _is_china_network()
|
use_mirror = _is_china_network()
|
||||||
if use_mirror:
|
if use_mirror:
|
||||||
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
||||||
@@ -260,7 +363,7 @@ def run_install_browser(
|
|||||||
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
||||||
))
|
))
|
||||||
|
|
||||||
ret = subprocess.call(cmd, env=env)
|
ret = _playwright_cli(pw_args, env=env)
|
||||||
|
|
||||||
if ret != 0 and use_mirror:
|
if ret != 0 and use_mirror:
|
||||||
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
||||||
@@ -268,9 +371,9 @@ def run_install_browser(
|
|||||||
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
||||||
"⚠️ Mirror download failed; retrying with the official CDN…",
|
"⚠️ Mirror download failed; retrying with the official CDN…",
|
||||||
))
|
))
|
||||||
env_no_mirror = os.environ.copy()
|
env_no_mirror = dict(env)
|
||||||
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
||||||
ret = subprocess.call(cmd, env=env_no_mirror)
|
ret = _playwright_cli(pw_args, env=env_no_mirror)
|
||||||
|
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream("Failed to install Chromium.", "red")
|
stream("Failed to install Chromium.", "red")
|
||||||
@@ -282,10 +385,18 @@ def run_install_browser(
|
|||||||
|
|
||||||
stream("Verifying browser installation...", None)
|
stream("Verifying browser installation...", None)
|
||||||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||||||
ret = subprocess.call(
|
if frozen:
|
||||||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
# Frozen: no child interpreter to spawn; import in-process instead.
|
||||||
stderr=subprocess.DEVNULL,
|
try:
|
||||||
)
|
from playwright.sync_api import sync_playwright # noqa: F401
|
||||||
|
ret = 0
|
||||||
|
except Exception:
|
||||||
|
ret = 1
|
||||||
|
else:
|
||||||
|
ret = subprocess.call(
|
||||||
|
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
stream(
|
stream(
|
||||||
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to t
|
|||||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 - alternative Claude 5 flagship
|
||||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||||
@@ -80,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
|||||||
GPT_54_MINI = "gpt-5.4-mini"
|
GPT_54_MINI = "gpt-5.4-mini"
|
||||||
GPT_54_NANO = "gpt-5.4-nano"
|
GPT_54_NANO = "gpt-5.4-nano"
|
||||||
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
GPT_55 = "gpt-5.5" # GPT-5.5 - top-tier (expensive), not default
|
||||||
|
GPT_56_LUNA = "gpt-5.6-luna" # GPT-5.6 Luna - default flagship model for GPT
|
||||||
|
GPT_56_TERRA = "gpt-5.6-terra" # GPT-5.6 Terra
|
||||||
|
GPT_56_SOL = "gpt-5.6-sol" # GPT-5.6 Sol - highest intelligence, higher latency
|
||||||
O1 = "o1-preview"
|
O1 = "o1-preview"
|
||||||
O1_MINI = "o1-mini"
|
O1_MINI = "o1-mini"
|
||||||
WHISPER_1 = "whisper-1"
|
WHISPER_1 = "whisper-1"
|
||||||
@@ -139,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"
|
||||||
@@ -200,7 +204,7 @@ MODEL_LIST = [
|
|||||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE_SONNET_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
CLAUDE_SONNET_5, CLAUDE_FABLE_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -214,6 +218,7 @@ MODEL_LIST = [
|
|||||||
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
GPT4_TURBO, GPT4_TURBO_PREVIEW, GPT4_TURBO_01_25, GPT4_TURBO_11_06, GPT4_TURBO_04_09,
|
||||||
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
GPT_4o, GPT_4O_0806, GPT_4o_MINI,
|
||||||
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
GPT_41, GPT_41_MINI, GPT_41_NANO,
|
||||||
|
GPT_56_LUNA, GPT_56_TERRA, GPT_56_SOL,
|
||||||
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
GPT_5, GPT_5_MINI, GPT_5_NANO,
|
||||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||||
O1, O1_MINI,
|
O1, O1_MINI,
|
||||||
@@ -231,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
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ hiddenimports += collect_submodules('docx')
|
|||||||
hiddenimports += collect_submodules('pptx')
|
hiddenimports += collect_submodules('pptx')
|
||||||
hiddenimports += collect_submodules('openpyxl')
|
hiddenimports += collect_submodules('openpyxl')
|
||||||
|
|
||||||
|
# Playwright powers the browser tool. Only the pure-Python package + its bundled
|
||||||
|
# Node driver are shipped (~10-15MB); the ~150MB Chromium binary is NOT bundled
|
||||||
|
# and is either satisfied by the user's system Chrome/Edge (preferred, zero
|
||||||
|
# download) or downloaded on demand into ~/.cow/ms-playwright at first use.
|
||||||
|
# Playwright imports its transport/driver lazily, so list submodules explicitly.
|
||||||
|
hiddenimports += ['playwright', 'playwright.sync_api', 'playwright._impl']
|
||||||
|
hiddenimports += collect_submodules('playwright')
|
||||||
|
|
||||||
# --- Data files -----------------------------------------------------------
|
# --- Data files -----------------------------------------------------------
|
||||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||||
# (source, dest_dir_in_bundle).
|
# (source, dest_dir_in_bundle).
|
||||||
@@ -134,6 +142,12 @@ datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
|||||||
datas += collect_data_files('docx')
|
datas += collect_data_files('docx')
|
||||||
datas += collect_data_files('pptx')
|
datas += collect_data_files('pptx')
|
||||||
|
|
||||||
|
# Playwright ships its Node.js driver + package.json under playwright/driver/.
|
||||||
|
# These are NOT Python modules, so hiddenimports won't pull them in — collect
|
||||||
|
# them as data or `playwright install` / launching fails in the frozen build.
|
||||||
|
# include_py_files=True is required: the driver dir contains .py entrypoints.
|
||||||
|
datas += collect_data_files('playwright', include_py_files=True)
|
||||||
|
|
||||||
# --- Excludes -------------------------------------------------------------
|
# --- Excludes -------------------------------------------------------------
|
||||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||||
# mode), tests/docs, and dev-only packages.
|
# mode), tests/docs, and dev-only packages.
|
||||||
@@ -143,7 +157,10 @@ excludes = [
|
|||||||
'pip',
|
'pip',
|
||||||
'wheel',
|
'wheel',
|
||||||
'pytest',
|
'pytest',
|
||||||
'playwright', # browser tool is opt-in, not bundled
|
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
|
||||||
|
# so the browser tool works out of the box on desktop. The heavy Chromium
|
||||||
|
# binary is still NOT bundled: it comes from the user's system Chrome/Edge or
|
||||||
|
# is downloaded on demand into ~/.cow/ms-playwright. See browser_env.py.
|
||||||
]
|
]
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ python-docx
|
|||||||
openpyxl
|
openpyxl
|
||||||
python-pptx
|
python-pptx
|
||||||
|
|
||||||
|
# ---- browser tool ----
|
||||||
|
# Only the pure-Python package + Node driver are bundled by PyInstaller (~10-15MB).
|
||||||
|
# The Chromium binary is NOT bundled: the browser tool drives the user's system
|
||||||
|
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
|
||||||
|
playwright==1.52.0
|
||||||
|
|
||||||
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
||||||
wechatpy
|
wechatpy
|
||||||
pycryptodome
|
pycryptodome
|
||||||
|
|||||||
122
desktop/electron-builder.win.js
Normal file
122
desktop/electron-builder.win.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Dynamic electron-builder config for WINDOWS code signing.
|
||||||
|
*
|
||||||
|
* Mirrors electron-builder.js (which handles mac.binaries) but for Windows.
|
||||||
|
* It wires a signing CLI into electron-builder so that every .exe is signed,
|
||||||
|
* with the private key kept in hardware per the post-2023 code-signing rules.
|
||||||
|
*
|
||||||
|
* A SINGLE sign hook (win.signtoolOptions.sign) covers everything: electron-
|
||||||
|
* builder calls it for EVERY .exe it processes, which includes the app
|
||||||
|
* launcher, the packaged PyInstaller backend (extraResources/backend/
|
||||||
|
* cowagent-backend.exe) and the NSIS installer. We deliberately do NOT add an
|
||||||
|
* afterPack pass — that would sign the backend a second time and waste a paid
|
||||||
|
* signing call on every release.
|
||||||
|
*
|
||||||
|
* PRIVACY: the CLI path and all credentials come from env vars only. Nothing in
|
||||||
|
* this file (or the public workflow) is hardcoded, so a public repo never leaks
|
||||||
|
* any signing configuration.
|
||||||
|
*
|
||||||
|
* DRY-RUN / SKIP: when SIGNTOOL_CERT_CODE is absent we skip signing entirely
|
||||||
|
* (unsigned dev/dry builds keep working). When COW_SIGN_DRY_RUN=1 we pass
|
||||||
|
* --dry-run so the WHOLE pipeline can be validated in CI with a self-signed
|
||||||
|
* cert, WITHOUT a real certificate and WITHOUT consuming any signing quota.
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const config = require('./package.json').build
|
||||||
|
|
||||||
|
// Absolute path to the signing CLI on the runner. Injected by CI so this file
|
||||||
|
// never hardcodes a download URL. e.g. C:\signtool\signtool.exe
|
||||||
|
const SIGNTOOL = process.env.SIGNTOOL_PATH || ''
|
||||||
|
// Dry-run validates the pipeline with a self-signed cert (no quota, no real
|
||||||
|
// cert needed). Any truthy value enables it.
|
||||||
|
const DRY_RUN = !!process.env.COW_SIGN_DRY_RUN
|
||||||
|
|
||||||
|
// In dry-run the CLI still requires these flags to be NON-EMPTY (it validates
|
||||||
|
// presence, not the value, and signs with a self-signed cert). So when no real
|
||||||
|
// credentials are provided during a dry-run, fall back to harmless placeholders
|
||||||
|
// to satisfy the CLI's arg check. Real runs pass the actual secrets through.
|
||||||
|
const PLACEHOLDER = DRY_RUN ? 'dry-run' : ''
|
||||||
|
const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || PLACEHOLDER
|
||||||
|
const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || PLACEHOLDER
|
||||||
|
const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || PLACEHOLDER
|
||||||
|
|
||||||
|
// RFC3161 timestamp server for SHA256. Microsoft's is reliable from CI runners
|
||||||
|
// worldwide; overridable via env if needed.
|
||||||
|
const TIMESTAMP = process.env.SIGNTOOL_TIMESTAMP || 'http://timestamp.acs.microsoft.com'
|
||||||
|
|
||||||
|
// Signing is possible when we have the CLI plus either a real cert code or
|
||||||
|
// explicit dry-run mode (dry-run accepts placeholder credentials).
|
||||||
|
function canSign() {
|
||||||
|
if (!SIGNTOOL || !fs.existsSync(SIGNTOOL)) return false
|
||||||
|
if (DRY_RUN) return true
|
||||||
|
return !!(ACCESS_KEY && ACCESS_SECRET && CERT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a single file in place using the signing CLI. The CLI writes to a
|
||||||
|
* separate --out path (it refuses to overwrite an existing file), so we sign to
|
||||||
|
* a temp file and atomically move it back over the original.
|
||||||
|
*/
|
||||||
|
function signFile(filePath) {
|
||||||
|
const tmpOut = `${filePath}.signed`
|
||||||
|
// Remove a stale temp from a previous failed run (CLI errors if --out exists).
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tmpOut)) fs.rmSync(tmpOut)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'sign',
|
||||||
|
...(DRY_RUN ? ['--dry-run'] : []),
|
||||||
|
`--access-key=${ACCESS_KEY}`,
|
||||||
|
`--access-secret=${ACCESS_SECRET}`,
|
||||||
|
`--cert-code=${CERT_CODE}`,
|
||||||
|
`--file=${filePath}`,
|
||||||
|
`--out=${tmpOut}`,
|
||||||
|
'--sha1=false',
|
||||||
|
'--sha2=true',
|
||||||
|
'--timestamp-rfc3161',
|
||||||
|
TIMESTAMP,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Never print credentials: log only the file being signed.
|
||||||
|
console.log(`[win-sign] signing ${path.basename(filePath)}${DRY_RUN ? ' (dry-run)' : ''}`)
|
||||||
|
execFileSync(SIGNTOOL, args, { stdio: ['ignore', 'inherit', 'inherit'] })
|
||||||
|
|
||||||
|
if (!fs.existsSync(tmpOut)) {
|
||||||
|
throw new Error(`[win-sign] signed output not produced for ${filePath}`)
|
||||||
|
}
|
||||||
|
// Replace the original with the signed copy.
|
||||||
|
fs.rmSync(filePath)
|
||||||
|
fs.renameSync(tmpOut, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// electron-builder calls this for each artifact it generates (app exe, NSIS
|
||||||
|
// installer, uninstaller). Signature: (configuration) => void, where
|
||||||
|
// configuration.path is the file to sign.
|
||||||
|
async function customSign(configuration) {
|
||||||
|
if (!canSign()) {
|
||||||
|
console.warn('[win-sign] signing skipped (no signtool/credentials)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signFile(configuration.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend the base config: attach the sign hook. Only meaningful on Windows
|
||||||
|
// builds (this config is only passed via --config on the win matrix leg).
|
||||||
|
//
|
||||||
|
// electron-builder invokes customSign for EVERY .exe it touches — that already
|
||||||
|
// includes the packaged backend (extraResources/backend/cowagent-backend.exe)
|
||||||
|
// and the NSIS installer, not just the app launcher. So there's no separate
|
||||||
|
// afterPack pass: adding one would sign the backend twice (wasting a paid
|
||||||
|
// signing call per release). Nested PyInstaller .dll/.pyd files are left
|
||||||
|
// unsigned, which Windows Authenticode tolerates (unlike macOS, it doesn't
|
||||||
|
// require deep-signing every nested lib — a signed top-level exe is enough for
|
||||||
|
// SmartScreen/Defender to attribute the publisher).
|
||||||
|
config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } }
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||||
|
{ cmd: '/install-browser', desc: t('slash_install_browser') },
|
||||||
{ cmd: '/config', desc: t('slash_config') },
|
{ cmd: '/config', desc: t('slash_config') },
|
||||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||||
{ cmd: '/logs', desc: t('slash_logs') },
|
{ cmd: '/logs', desc: t('slash_logs') },
|
||||||
|
|||||||
@@ -30,6 +30,34 @@ const md: MarkdownIt = new MarkdownIt({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fix greedy linkify: markdown-it's linkify swallows markdown emphasis (`*`)
|
||||||
|
// and CJK full-width punctuation glued to a URL (common in LLM output like
|
||||||
|
// `**https://x**,中文`), turning the whole tail into one broken link. Cut the
|
||||||
|
// URL at the first such char and spill the remainder back as plain text.
|
||||||
|
const _GREEDY_LINK_CUT = /[*\u3000-\u303F\uFF00-\uFFEF]/
|
||||||
|
md.core.ruler.after('linkify', 'fix_greedy_linkify', (state) => {
|
||||||
|
for (const blk of state.tokens) {
|
||||||
|
if (blk.type !== 'inline' || !blk.children) continue
|
||||||
|
const ch = blk.children
|
||||||
|
for (let i = 0; i < ch.length; i++) {
|
||||||
|
const open = ch[i]
|
||||||
|
if (open.type !== 'link_open' || open.markup !== 'linkify') continue
|
||||||
|
const textTok = ch[i + 1]
|
||||||
|
const close = ch[i + 2]
|
||||||
|
if (!textTok || textTok.type !== 'text' || !close || close.type !== 'link_close') continue
|
||||||
|
const idx = textTok.content.search(_GREEDY_LINK_CUT)
|
||||||
|
if (idx < 0) continue
|
||||||
|
const keep = textTok.content.slice(0, idx)
|
||||||
|
const spill = textTok.content.slice(idx)
|
||||||
|
textTok.content = keep
|
||||||
|
open.attrSet('href', keep)
|
||||||
|
const spillTok = new state.Token('text', '', 0)
|
||||||
|
spillTok.content = spill
|
||||||
|
ch.splice(i + 3, 0, spillTok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Open links in a new tab safely.
|
// Open links in a new tab safely.
|
||||||
const defaultLinkOpen =
|
const defaultLinkOpen =
|
||||||
md.renderer.rules.link_open ||
|
md.renderer.rules.link_open ||
|
||||||
@@ -72,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: '保存失败',
|
||||||
@@ -376,6 +381,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||||
slash_knowledge: '查看知识库统计',
|
slash_knowledge: '查看知识库统计',
|
||||||
slash_knowledge_list: '查看知识库文件树',
|
slash_knowledge_list: '查看知识库文件树',
|
||||||
|
slash_install_browser: '安装浏览器工具',
|
||||||
slash_config: '查看当前配置',
|
slash_config: '查看当前配置',
|
||||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||||
slash_logs: '查看最近日志',
|
slash_logs: '查看最近日志',
|
||||||
@@ -511,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',
|
||||||
@@ -670,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',
|
||||||
@@ -758,6 +769,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||||
slash_knowledge: 'Show knowledge base stats',
|
slash_knowledge: 'Show knowledge base stats',
|
||||||
slash_knowledge_list: 'Show knowledge base file tree',
|
slash_knowledge_list: 'Show knowledge base file tree',
|
||||||
|
slash_install_browser: 'Install browser tool',
|
||||||
slash_config: 'Show current config',
|
slash_config: 'Show current config',
|
||||||
slash_cancel: 'Abort the running agent task',
|
slash_cancel: 'Abort the running agent task',
|
||||||
slash_logs: 'Show recent logs',
|
slash_logs: 'Show recent logs',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント"></a>
|
<a href="https://docs.cowagent.ai/ja"><img src="https://img.shields.io/badge/%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="ドキュメント" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -106,14 +106,14 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [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 | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -230,7 +230,7 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
GitHub で [Issue を報告](https://github.com/zhayujie/CowAgent/issues) するか、下記 QR コードをスキャンして WeChat コミュニティに参加してください:
|
||||||
|
|
||||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -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` を送ると、そのチャットのタスクを管理できます。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
| `model` | `claude-sonnet-5`、`claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
||||||
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
| モデル | 用途 |
|
| モデル | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
||||||
|
| `claude-fable-5` | Claude 5 シリーズのもう一つのフラッグシップモデル |
|
||||||
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
||||||
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、claude-fable-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k3、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -20,13 +20,13 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.6 シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [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` |
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ description: LinkAI プラットフォーム経由でテキスト、ビジョン
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` など。
|
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`claude-fable-5`、`gemini-3.1-flash-lite-preview` など。
|
||||||
|
|
||||||
## 画像生成
|
## 画像生成
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI は最も広範な機能をカバーするベンダーで、テキスト
|
|||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1`、o シリーズなどをサポート。Agent モードのデフォルトは `gpt-5.5`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
| `model` | OpenAI API の [model パラメータ](https://platform.openai.com/docs/models) と同じです。`gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` シリーズ、`gpt-4.1` などをサポート。Agent モードのデフォルトは `gpt-5.6-luna`、コストパフォーマンスを重視する場合は `gpt-5.4` に変更可能 |
|
||||||
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
| `open_ai_api_key` | [OpenAI プラットフォーム](https://platform.openai.com/api-keys) で作成 |
|
||||||
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
| `open_ai_api_base` | 任意。サードパーティのプロキシに接続するために変更可能 |
|
||||||
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
| `bot_type` | OpenAI 公式モデルを使用する場合は不要。互換プロトコルでベンダーモデルに接続する場合は `openai` に設定 |
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ Chromiumブラウザを操作してWebページのナビゲーション、要素
|
|||||||
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
2. ブラウザToolは依存関係が大きい(約300MB)ため、不要な場合はインストールを省略できます。軽量なWebコンテンツ取得には `web_fetch` Toolをご利用ください。
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**デスクトップクライアント利用者**:playwright はインストーラーに同梱済みで、別途インストールは不要です。ブラウザToolの初回利用時:
|
||||||
|
- **Google Chrome / Edge** がインストールされていれば、システムのブラウザを直接駆動し、**ダウンロード不要**です(推奨);
|
||||||
|
- 未インストールの場合は、チャットで `/install-browser` を送信すると、軽量なブラウザエンジンを `~/.cow` にダウンロードします。
|
||||||
|
</Note>
|
||||||
|
|
||||||
## ワークフロー
|
## ワークフロー
|
||||||
|
|
||||||
Agentがブラウザを使う典型的な流れ:
|
Agentがブラウザを使う典型的な流れ:
|
||||||
@@ -105,6 +111,15 @@ Agentがブラウザを使う典型的な流れ:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ブラウザエンジン
|
||||||
|
|
||||||
|
ブラウザエンジンは自動選択され、設定は不要です:
|
||||||
|
|
||||||
|
1. マシンに **Google Chrome / Edge** が検出された場合、システムのブラウザを直接駆動し、**Chromium のダウンロードは不要**で、実際のブラウザフィンガープリントを使用します;
|
||||||
|
2. それ以外の場合は、`install-browser` で `~/.cow` にダウンロードした Chromium エンジンにフォールバックします。
|
||||||
|
|
||||||
|
どちらも以下のログイン状態の永続化を使用し、挙動は同一です。
|
||||||
|
|
||||||
## ログイン状態の永続化
|
## ログイン状態の永続化
|
||||||
|
|
||||||
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
**対象サイトに一度ログインすれば、Agentは以降そのまま利用できます。** 2つの方法があります:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Supports `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | Supports `claude-sonnet-5`, `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
||||||
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
| Model | Use Case |
|
| Model | Use Case |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
|
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
|
||||||
|
| `claude-fable-5` | Alternative flagship in the Claude 5 family |
|
||||||
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
|
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
|
||||||
| `claude-opus-4-7` | Earlier Opus flagship |
|
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [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` |
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-1-pro-260628`, `kimi-k2.6`, `claude-sonnet-5`, `gemini-3.1-flash-lite-preview`, etc.
|
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-1-pro-260628`, `kimi-k2.6`, `claude-sonnet-5`, `claude-fable-5`, `gemini-3.1-flash-lite-preview`, etc.
|
||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI offers the most complete coverage and can simultaneously serve text chat,
|
|||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, the o-series, etc. Agent mode defaults to `gpt-5.5`; use `gpt-5.4` for better cost-efficiency |
|
| `model` | Same as OpenAI's [model parameter](https://platform.openai.com/docs/models); supports `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, the `gpt-5` series, `gpt-4.1`, etc. Agent mode defaults to `gpt-5.6-luna`; use `gpt-5.4` for better cost-efficiency |
|
||||||
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
| `open_ai_api_key` | Create one on the [OpenAI Platform](https://platform.openai.com/api-keys) |
|
||||||
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
| `open_ai_api_base` | Optional; change it to access a third-party proxy |
|
||||||
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
| `bot_type` | Not required when using OpenAI's official models; set to `openai` when accessing other providers via the compatible protocol |
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ Control a Chromium browser for web navigation, element interaction and content e
|
|||||||
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
|
2. The browser tool has heavy dependencies (~300MB) and is optional. For lightweight web content retrieval, use the `web_fetch` tool.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**Desktop client users**: playwright is bundled in the installer, no separate install needed. On first use of the browser tool:
|
||||||
|
- If **Google Chrome / Edge** is installed, it drives the system browser directly with **no download** (recommended);
|
||||||
|
- Otherwise, send `/install-browser` in chat to download a lightweight browser engine into `~/.cow`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
A typical browser workflow for the Agent:
|
A typical browser workflow for the Agent:
|
||||||
@@ -105,6 +111,15 @@ You can override it in `config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Browser Engine
|
||||||
|
|
||||||
|
The browser engine is selected automatically, no configuration needed:
|
||||||
|
|
||||||
|
1. If **Google Chrome / Edge** is detected on the machine, it drives the system browser directly, with **no Chromium download**, using real browser fingerprints;
|
||||||
|
2. Otherwise it falls back to the Chromium engine downloaded into `~/.cow` via `install-browser`.
|
||||||
|
|
||||||
|
Both use the persistent login below and behave identically.
|
||||||
|
|
||||||
## Persistent Login
|
## Persistent Login
|
||||||
|
|
||||||
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
|
**Log in to a target site once and the Agent can keep using it.** Two ways are supported:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -108,13 +108,13 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智譜 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+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -233,7 +233,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
掃碼加入微信開源交流群:
|
掃碼加入微信開源交流群:
|
||||||
|
|
||||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
|
||||||
|
|
||||||
也可透過以下方式獲取支援:
|
也可透過以下方式獲取支援:
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
## 🏢 企業服務
|
## 🏢 企業服務
|
||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
|
||||||
|
|
||||||
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
||||||
>
|
>
|
||||||
@@ -262,7 +262,7 @@ CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像
|
|||||||
|
|
||||||
**產品諮詢和企業服務** 可聯絡產品客服:
|
**產品諮詢和企業服務** 可聯絡產品客服:
|
||||||
|
|
||||||
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT" /></a>
|
||||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars" /></a>
|
||||||
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档"></a>
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文档" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -108,13 +108,13 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 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+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -233,7 +233,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
扫码加入微信开源交流群:
|
扫码加入微信开源交流群:
|
||||||
|
|
||||||
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png" />
|
||||||
|
|
||||||
也可通过以下方式获取支持:
|
也可通过以下方式获取支持:
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏢 企业服务
|
## 🏢 企业服务
|
||||||
|
|
||||||
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg" /></a>
|
||||||
|
|
||||||
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
|
> [LinkAI](https://link-ai.tech/) 是面向企业和个人的一站式 AI 智能体平台,为 CowAgent 提供云端托管和企业级支持:
|
||||||
>
|
>
|
||||||
@@ -262,7 +262,7 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
**产品咨询和企业服务** 可联系产品客服:
|
**产品咨询和企业服务** 可联系产品客服:
|
||||||
|
|
||||||
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png" />
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 支持 `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | 支持 `claude-sonnet-5`、`claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
||||||
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
| 模型 | 适用场景 |
|
| 模型 | 适用场景 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
||||||
|
| `claude-fable-5` | Claude 5 系列的另一款旗舰模型 |
|
||||||
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
||||||
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
||||||
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
| [Claude](/zh/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/zh/models/openai) | gpt-5.6 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 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` |
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` 等。
|
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`claude-fable-5`、`gemini-3.1-flash-lite-preview` 等。
|
||||||
|
|
||||||
## 图像生成
|
## 图像生成
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.6-luna",
|
||||||
"open_ai_api_key": "YOUR_API_KEY",
|
"open_ai_api_key": "YOUR_API_KEY",
|
||||||
"open_ai_api_base": "https://api.openai.com/v1"
|
"open_ai_api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ OpenAI 是覆盖最完整的厂商,可同时承担文本对话、视觉理解
|
|||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1`、o 系列等;Agent 模式默认 `gpt-5.5`,追求性价比可改为 `gpt-5.4` |
|
| `model` | 与 OpenAI 接口的 [model 参数](https://platform.openai.com/docs/models) 一致,支持 `gpt-5.6-luna`、`gpt-5.6-terra`、`gpt-5.6-sol`、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5` 系列、`gpt-4.1` 等;Agent 模式默认 `gpt-5.6-luna`,追求性价比可改为 `gpt-5.4` |
|
||||||
| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
| `open_ai_api_key` | 在 [OpenAI 平台](https://platform.openai.com/api-keys) 创建 |
|
||||||
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
| `open_ai_api_base` | 可选,修改可接入第三方代理 |
|
||||||
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
| `bot_type` | 使用 OpenAI 官方模型时无需填写;通过兼容协议接入厂商模型时需设为 `openai` |
|
||||||
|
|||||||
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)
|
||||||
@@ -49,6 +49,12 @@ description: 控制浏览器访问和操作网页
|
|||||||
2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
|
2. 浏览器工具依赖较重(约300MB),为可选安装。轻量的网页内容获取可使用 `web_fetch` 工具。
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
**桌面客户端用户**:playwright 已内置于安装包,无需单独安装。首次使用浏览器工具时:
|
||||||
|
- 若系统已安装 **Google Chrome / Edge**,会直接驱动系统浏览器,**无需任何下载**(推荐);
|
||||||
|
- 若未安装,可在对话中发送 `/install-browser`,自动下载一个精简浏览器内核到 `~/.cow`。
|
||||||
|
</Note>
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
Agent 使用浏览器的典型流程:
|
Agent 使用浏览器的典型流程:
|
||||||
@@ -105,6 +111,15 @@ Agent 使用浏览器的典型流程:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 浏览器内核
|
||||||
|
|
||||||
|
浏览器内核会自动选择,无需配置:
|
||||||
|
|
||||||
|
1. 若检测到本机已安装 **Google Chrome / Edge**,直接驱动系统浏览器,**无需下载 Chromium**,并使用真实浏览器指纹;
|
||||||
|
2. 否则使用 `install-browser` 下载到 `~/.cow` 的 Chromium 内核作为兜底。
|
||||||
|
|
||||||
|
两种方式都使用下面的登录态持久化,行为一致。
|
||||||
|
|
||||||
## 登录态持久化
|
## 登录态持久化
|
||||||
|
|
||||||
**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式:
|
**只需登录一次目标网站,Agent 后续可直接使用**。提供两种方式:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ class OpenAICompatibleBot:
|
|||||||
Subclasses only need to override get_api_config() to provide their specific API settings.
|
Subclasses only need to override get_api_config() to provide their specific API settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_gpt5_reasoning_model(model_name: str) -> bool:
|
||||||
|
"""Whether the model is a GPT-5.x / o-series reasoning model.
|
||||||
|
|
||||||
|
Covers gpt-5, gpt-5.4/5.5/5.6 (including suffixed variants like
|
||||||
|
gpt-5.6-sol / gpt-5.6-luna) and the o1/o3/o4 families. These models
|
||||||
|
only accept default sampling params and, on /v1/chat/completions,
|
||||||
|
reject reasoning_effort together with function tools.
|
||||||
|
"""
|
||||||
|
if not model_name or not isinstance(model_name, str):
|
||||||
|
return False
|
||||||
|
name = model_name.lower()
|
||||||
|
if name.startswith("gpt-5"):
|
||||||
|
return True
|
||||||
|
if name.startswith(("o1", "o3", "o4")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_api_config(self):
|
def get_api_config(self):
|
||||||
"""
|
"""
|
||||||
Get API configuration for this bot.
|
Get API configuration for this bot.
|
||||||
@@ -99,8 +117,10 @@ class OpenAICompatibleBot:
|
|||||||
"presence_penalty": kwargs.get("presence_penalty", api_config.get('default_presence_penalty', 0.0)),
|
"presence_penalty": kwargs.get("presence_penalty", api_config.get('default_presence_penalty', 0.0)),
|
||||||
"stream": stream
|
"stream": stream
|
||||||
}
|
}
|
||||||
# GPT-5 / GPT-5.5 / o1 series only accept default temperature/top_p and reject penalty params
|
# GPT-5.x / o-series reasoning models only accept default
|
||||||
if model_name in ("gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5.5", "o1", "o1-mini"):
|
# temperature/top_p and reject penalty params.
|
||||||
|
is_gpt5_reasoning = self._is_gpt5_reasoning_model(model_name)
|
||||||
|
if is_gpt5_reasoning:
|
||||||
for key in ("temperature", "top_p", "frequency_penalty", "presence_penalty"):
|
for key in ("temperature", "top_p", "frequency_penalty", "presence_penalty"):
|
||||||
request_params.pop(key, None)
|
request_params.pop(key, None)
|
||||||
|
|
||||||
@@ -112,6 +132,12 @@ class OpenAICompatibleBot:
|
|||||||
if tools:
|
if tools:
|
||||||
request_params["tools"] = tools
|
request_params["tools"] = tools
|
||||||
request_params["tool_choice"] = kwargs.get("tool_choice", "auto")
|
request_params["tool_choice"] = kwargs.get("tool_choice", "auto")
|
||||||
|
# GPT-5.x reasoning models reject function tools combined with
|
||||||
|
# reasoning_effort on /v1/chat/completions unless it is "none".
|
||||||
|
# Force "none" so agent tool calling works without migrating to
|
||||||
|
# the Responses API.
|
||||||
|
if is_gpt5_reasoning:
|
||||||
|
request_params["reasoning_effort"] = "none"
|
||||||
|
|
||||||
# Make API call with proper configuration
|
# Make API call with proper configuration
|
||||||
api_key = api_config.get('api_key')
|
api_key = api_config.get('api_key')
|
||||||
|
|||||||
@@ -813,8 +813,8 @@ class CowCliPlugin(Plugin):
|
|||||||
"you can also run `cow install-browser` in a terminal.",
|
"you can also run `cow install-browser` in a terminal.",
|
||||||
)
|
)
|
||||||
return _t(
|
return _t(
|
||||||
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。",
|
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具。",
|
||||||
"✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).",
|
"✅ Installation finished. Restart CowAgent to use the browser tool.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
8
run.sh
8
run.sh
@@ -599,12 +599,12 @@ select_model() {
|
|||||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)" \
|
||||||
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)" \
|
||||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)" \
|
||||||
"OpenAI (gpt-5.5, etc.)" \
|
"OpenAI (gpt-5.6-luna, etc.)" \
|
||||||
"MiniMax (MiniMax-M3, etc.)" \
|
"MiniMax (MiniMax-M3, etc.)" \
|
||||||
"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)")"
|
||||||
@@ -631,12 +631,12 @@ configure_model() {
|
|||||||
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
|
1) read_model_config "DeepSeek" "deepseek-v4-flash" "DEEPSEEK_KEY" ;;
|
||||||
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
|
2) read_model_config "Claude" "claude-opus-4-8" "CLAUDE_KEY" ;;
|
||||||
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
3) read_model_config "Gemini" "gemini-3.1-pro-preview" "GEMINI_KEY" ;;
|
||||||
4) read_model_config "OpenAI" "gpt-5.5" "OPENAI_KEY" ;;
|
4) read_model_config "OpenAI" "gpt-5.6-luna" "OPENAI_KEY" ;;
|
||||||
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
|
5) read_model_config "MiniMax" "MiniMax-M3" "MINIMAX_KEY" ;;
|
||||||
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).
|
||||||
|
|||||||
@@ -456,12 +456,12 @@ $ModelChoices = @{
|
|||||||
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
||||||
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
||||||
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
||||||
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
4 = @{ Provider = "OpenAI"; Default = "gpt-5.6-luna"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
||||||
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
||||||
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 }
|
||||||
}
|
}
|
||||||
@@ -473,12 +473,12 @@ function Select-Model {
|
|||||||
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
||||||
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
||||||
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
||||||
"OpenAI (gpt-5.5, etc.)",
|
"OpenAI (gpt-5.6-luna, etc.)",
|
||||||
"MiniMax (MiniMax-M3, etc.)",
|
"MiniMax (MiniMax-M3, etc.)",
|
||||||
"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